superlocalmemory 4.0.0 → 4.0.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 (83) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +11 -11
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  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 +1 -1
  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 +1 -1
  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 +1 -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-governance/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-loop/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-profile/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  32. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  33. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  36. package/pyproject.toml +5 -4
  37. package/src/superlocalmemory/__init__.py +1 -1
  38. package/src/superlocalmemory/cli/commands.py +60 -1
  39. package/src/superlocalmemory/cli/main.py +24 -1
  40. package/src/superlocalmemory/compliance/gdpr.py +104 -73
  41. package/src/superlocalmemory/contracts/__init__.py +1 -0
  42. package/src/superlocalmemory/contracts/schemas/agent-experience-v1.schema.json +92 -0
  43. package/src/superlocalmemory/contracts/schemas/agent-integration-contract-v2.schema.json +46 -0
  44. package/src/superlocalmemory/contracts/schemas/cognitive-turn-receipt-v1.schema.json +59 -0
  45. package/src/superlocalmemory/contracts/v402.py +62 -0
  46. package/src/superlocalmemory/core/engine.py +10 -0
  47. package/src/superlocalmemory/core/recall_pipeline.py +6 -0
  48. package/src/superlocalmemory/core/recall_worker.py +12 -0
  49. package/src/superlocalmemory/core/worker_pool.py +12 -0
  50. package/src/superlocalmemory/hooks/hook_handlers.py +16 -0
  51. package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +12 -6
  52. package/src/superlocalmemory/hooks/session_registry.py +136 -3
  53. package/src/superlocalmemory/hooks/user_prompt_hook.py +9 -2
  54. package/src/superlocalmemory/integrations/__init__.py +1 -0
  55. package/src/superlocalmemory/integrations/bounded_loops_v051.py +236 -0
  56. package/src/superlocalmemory/learning/database.py +21 -14
  57. package/src/superlocalmemory/mcp/_daemon_proxy.py +9 -0
  58. package/src/superlocalmemory/mcp/server.py +5 -0
  59. package/src/superlocalmemory/mcp/tools_brain.py +132 -0
  60. package/src/superlocalmemory/mcp/tools_core.py +25 -6
  61. package/src/superlocalmemory/mcp/tools_v3.py +16 -2
  62. package/src/superlocalmemory/retrieval/engine.py +43 -1
  63. package/src/superlocalmemory/retrieval/temporal_utils.py +16 -1
  64. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +151 -0
  65. package/src/superlocalmemory/server/routes/brain.py +206 -1
  66. package/src/superlocalmemory/server/routes/helpers.py +53 -35
  67. package/src/superlocalmemory/server/routes/v3_api.py +118 -11
  68. package/src/superlocalmemory/server/ui.py +7 -2
  69. package/src/superlocalmemory/server/unified_daemon.py +37 -4
  70. package/src/superlocalmemory/storage/_migration_internals.py +4 -0
  71. package/src/superlocalmemory/storage/_schema_version.py +2 -2
  72. package/src/superlocalmemory/storage/agent_experience.py +490 -0
  73. package/src/superlocalmemory/storage/database.py +189 -34
  74. package/src/superlocalmemory/storage/migration_runner.py +8 -0
  75. package/src/superlocalmemory/storage/migrations/M015_add_pinned_column.py +18 -0
  76. package/src/superlocalmemory/storage/migrations/M040_agent_experience_receipts.py +254 -0
  77. package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
  78. package/src/superlocalmemory/storage/schema.py +4 -0
  79. package/src/superlocalmemory/ui/index.html +2 -2
  80. package/src/superlocalmemory/ui/js/auto-settings.js +18 -14
  81. package/src/superlocalmemory/ui/js/brain.js +57 -1
  82. package/src/superlocalmemory/ui/js/od-brain.js +114 -40
  83. package/src/superlocalmemory/ui/js/od-settings.js +8 -1
@@ -61,6 +61,7 @@ from superlocalmemory.learning.database import LearningDatabase
61
61
  from superlocalmemory.learning.features import FEATURE_DIM
62
62
  from superlocalmemory.infra.data_root import canonical_data_root
63
63
  from superlocalmemory.storage.read_connection import ReadConnectionFactory
64
+ from superlocalmemory.storage.agent_experience import get_profile_receipt_summary
64
65
  from .helpers import get_active_profile
65
66
 
66
67
  logger = logging.getLogger("superlocalmemory.routes.brain")
@@ -320,6 +321,15 @@ def _compute_learning_status(profile_id: str,
320
321
  }
321
322
 
322
323
 
324
+ def _compute_agent_experience(profile_id: str) -> dict:
325
+ """Honest, profile-scoped receipt evidence for the Living Brain.
326
+
327
+ Receipt rows record observation and verification evidence only; this read
328
+ model never alters retrieval, ranking, or model routing.
329
+ """
330
+ return get_profile_receipt_summary(_learning_db_path(), profile_id)
331
+
332
+
323
333
  def _resolve_phase(signals: int, model_active: bool,
324
334
  sha_present: bool) -> tuple[int, str]:
325
335
  """LLD-02 §4.10 — phase truth, U3 enforcement.
@@ -687,6 +697,161 @@ def _compute_cross_platform() -> dict:
687
697
  return out
688
698
 
689
699
 
700
+ def _compute_active_clients(profile_id: str) -> dict:
701
+ """Return recent host presence without leaking session identifiers.
702
+
703
+ ``cross_platform`` describes configured integration targets. This
704
+ separate read-model answers the different question users actually ask:
705
+ which host clients have recently interacted with this local brain?
706
+ """
707
+ try:
708
+ from superlocalmemory.hooks.session_registry import active_client_summary
709
+ clients = active_client_summary(profile_id, within_seconds=300)
710
+ except Exception:
711
+ clients = []
712
+ return {
713
+ "is_real": True,
714
+ "scope": "profile",
715
+ "window_seconds": 300,
716
+ "clients": clients,
717
+ "source": "session_registry (ephemeral, profile-scoped)",
718
+ }
719
+
720
+
721
+ def _compute_feedback_loop(profile_id: str, lrn_db: LearningDatabase) -> dict:
722
+ """Expose only durable feedback evidence that already drives learning."""
723
+ empty = {
724
+ "is_real": True,
725
+ "signals_by_type": {},
726
+ "explicit_signals": 0,
727
+ "implicit_signals": 0,
728
+ "settled_outcomes": 0,
729
+ "mean_settled_reward": None,
730
+ "source": "learning_signals + memory.db:action_outcomes",
731
+ }
732
+ signal_rows: list[sqlite3.Row] = []
733
+ try:
734
+ learning_conn = lrn_db.ro_connection()
735
+ try:
736
+ signal_rows = learning_conn.execute(
737
+ "SELECT signal_type, COUNT(*) AS count FROM learning_signals "
738
+ "WHERE profile_id = ? GROUP BY signal_type ORDER BY signal_type",
739
+ (profile_id,),
740
+ ).fetchall()
741
+ finally:
742
+ learning_conn.close()
743
+ except sqlite3.Error:
744
+ return empty
745
+ by_type = {
746
+ str(row["signal_type"]): int(row["count"] or 0)
747
+ for row in signal_rows
748
+ }
749
+ explicit_types = {
750
+ "user_positive", "user_negative", "user_correction", "user_pin",
751
+ "legacy_feedback",
752
+ }
753
+ explicit = sum(count for kind, count in by_type.items() if kind in explicit_types)
754
+ implicit = sum(count for kind, count in by_type.items() if kind not in explicit_types)
755
+ settled, mean_reward = 0, None
756
+ db_path = _memory_db_path()
757
+ if db_path.exists():
758
+ try:
759
+ memory_conn = ReadConnectionFactory(db_path).open()
760
+ try:
761
+ row = memory_conn.execute(
762
+ "SELECT COUNT(*) AS count, AVG(reward) AS mean_reward "
763
+ "FROM action_outcomes WHERE profile_id = ? AND settled = 1 "
764
+ "AND reward IS NOT NULL",
765
+ (profile_id,),
766
+ ).fetchone()
767
+ if row:
768
+ settled = int(row["count"] or 0)
769
+ mean_reward = (
770
+ float(row["mean_reward"])
771
+ if row["mean_reward"] is not None else None
772
+ )
773
+ finally:
774
+ memory_conn.close()
775
+ except sqlite3.Error:
776
+ pass
777
+ return {
778
+ **empty,
779
+ "signals_by_type": by_type,
780
+ "explicit_signals": explicit,
781
+ "implicit_signals": implicit,
782
+ "settled_outcomes": settled,
783
+ "mean_settled_reward": mean_reward,
784
+ }
785
+
786
+
787
+ def _compute_source_quality(profile_id: str) -> dict:
788
+ """Summarize observed provenance quality; neutral priors are not evidence."""
789
+ empty = {
790
+ "is_real": True,
791
+ "observed_sources": 0,
792
+ "mean_quality": None,
793
+ "source": "learning.db:source_quality_observations",
794
+ }
795
+ db_path = _learning_db_path()
796
+ if not db_path.exists():
797
+ return empty
798
+ try:
799
+ conn = sqlite3.connect(db_path, timeout=5.0)
800
+ conn.row_factory = sqlite3.Row
801
+ try:
802
+ row = conn.execute(
803
+ "SELECT COUNT(DISTINCT source_id) AS sources, AVG(reward) AS mean_quality "
804
+ "FROM source_quality_observations WHERE profile_id = ?",
805
+ (profile_id,),
806
+ ).fetchone()
807
+ finally:
808
+ conn.close()
809
+ if row is None:
810
+ return empty
811
+ return {
812
+ **empty,
813
+ "observed_sources": int(row["sources"] or 0),
814
+ "mean_quality": float(row["mean_quality"]) if row["mean_quality"] is not None else None,
815
+ }
816
+ except sqlite3.Error:
817
+ return empty
818
+
819
+
820
+ def _compute_graph_summary(profile_id: str) -> dict:
821
+ """Return graph evidence counts, never a decorative graph surrogate."""
822
+ empty = {
823
+ "is_real": True,
824
+ "fact_nodes": 0,
825
+ "association_edges": 0,
826
+ "source": "memory.db:atomic_facts + association_edges",
827
+ }
828
+ db_path = _memory_db_path()
829
+ if not db_path.exists():
830
+ return empty
831
+ try:
832
+ conn = ReadConnectionFactory(db_path).open()
833
+ try:
834
+ facts = conn.execute(
835
+ "SELECT COUNT(*) AS count FROM atomic_facts "
836
+ "WHERE profile_id = ?",
837
+ (profile_id,),
838
+ ).fetchone()
839
+ edges = conn.execute(
840
+ "SELECT COUNT(*) AS count FROM association_edges "
841
+ "WHERE profile_id = ?",
842
+ (profile_id,),
843
+ ).fetchone()
844
+ finally:
845
+ conn.close()
846
+ return {
847
+ **empty,
848
+ "fact_nodes": int(facts["count"] or 0) if facts else 0,
849
+ "association_edges": int(edges["count"] or 0) if edges else 0,
850
+ }
851
+ except sqlite3.Error:
852
+ return empty
853
+
854
+
690
855
  def _meta_now() -> dict:
691
856
  return {
692
857
  "generated_at": datetime.now(timezone.utc)
@@ -963,7 +1128,8 @@ async def get_brain(request: Request, profile_id: str | None = None) -> dict:
963
1128
 
964
1129
  (
965
1130
  preferences, learning, usage, bandit_snap, cache,
966
- cross_platform, outcomes_preview, evolution,
1131
+ cross_platform, outcomes_preview, evolution, active_clients,
1132
+ feedback_loop, source_quality, graph_summary, agent_experience,
967
1133
  ) = await asyncio.gather(
968
1134
  asyncio.to_thread(_compute_preferences, profile_id),
969
1135
  asyncio.to_thread(_compute_learning_status, profile_id, lrn_db),
@@ -976,6 +1142,11 @@ async def get_brain(request: Request, profile_id: str | None = None) -> dict:
976
1142
  _compute_evolution_timeseries, profile_id, lrn_db,
977
1143
  days=_EVOLUTION_DEFAULT_DAYS,
978
1144
  ),
1145
+ asyncio.to_thread(_compute_active_clients, profile_id),
1146
+ asyncio.to_thread(_compute_feedback_loop, profile_id, lrn_db),
1147
+ asyncio.to_thread(_compute_source_quality, profile_id),
1148
+ asyncio.to_thread(_compute_graph_summary, profile_id),
1149
+ asyncio.to_thread(_compute_agent_experience, profile_id),
979
1150
  return_exceptions=True,
980
1151
  )
981
1152
 
@@ -986,6 +1157,26 @@ async def get_brain(request: Request, profile_id: str | None = None) -> dict:
986
1157
  return fallback
987
1158
  return value
988
1159
 
1160
+ active_clients = _ok(active_clients, {"is_real": True, "scope": "profile", "clients": [],
1161
+ "window_seconds": 300,
1162
+ "source": "session_registry unavailable"})
1163
+ feedback_loop = _ok(feedback_loop, {"is_real": True, "signals_by_type": {},
1164
+ "explicit_signals": 0, "implicit_signals": 0,
1165
+ "settled_outcomes": 0, "mean_settled_reward": None,
1166
+ "source": "feedback data unavailable"})
1167
+ source_quality = _ok(source_quality, {"is_real": True, "observed_sources": 0,
1168
+ "mean_quality": None,
1169
+ "source": "source-quality data unavailable"})
1170
+ graph_summary = _ok(graph_summary, {"is_real": True, "fact_nodes": 0,
1171
+ "association_edges": 0,
1172
+ "source": "graph data unavailable"})
1173
+ agent_experience = _ok(agent_experience, {
1174
+ "is_real": False, "availability": "unavailable",
1175
+ "experiences_total": 0, "turns_total": 0,
1176
+ "turns_by_state": {}, "claimed_evidence_experiences": 0,
1177
+ "source": "receipt data unavailable",
1178
+ })
1179
+
989
1180
  return {
990
1181
  "profile_id": profile_id,
991
1182
  "preferences": _ok(preferences, {"is_real": True,
@@ -1005,6 +1196,20 @@ async def get_brain(request: Request, profile_id: str | None = None) -> dict:
1005
1196
  "db_size_bytes": 0,
1006
1197
  "entry_count": 0}),
1007
1198
  "cross_platform": _ok(cross_platform, {}),
1199
+ # One canonical, transport-neutral read model for the non-technical
1200
+ # Living Brain. Existing top-level sections stay intact for the CLI,
1201
+ # MCP and older dashboard clients. This is observation only: it never
1202
+ # changes retrieval or model routing.
1203
+ "living_brain": {
1204
+ "is_real": True,
1205
+ "control_plane": "observation_only",
1206
+ "connected_clients": active_clients,
1207
+ "feedback": feedback_loop,
1208
+ "source_quality": source_quality,
1209
+ "graph": graph_summary,
1210
+ "agent_experience": agent_experience,
1211
+ "source": "local durable stores + ephemeral session registry",
1212
+ },
1008
1213
  "evolution_preview": _ok(evolution, {
1009
1214
  "is_real": True, "source": "learning_signals",
1010
1215
  "days": _EVOLUTION_DEFAULT_DAYS, "total_signals": 0, "points": [],
@@ -8,9 +8,10 @@ Shared utilities for all route modules: DB connection, dict factory,
8
8
  profile helper, validation, Pydantic models, config paths, and the
9
9
  shared lazy engine accessor used by every engine-dependent route.
10
10
  """
11
+
12
+ import json
11
13
  import logging
12
14
  import re
13
- import json
14
15
  import sqlite3
15
16
  import threading
16
17
  import time
@@ -24,7 +25,6 @@ from pydantic import BaseModel, Field
24
25
  from superlocalmemory.infra.data_root import DynamicStatePath, canonical_data_root
25
26
  from superlocalmemory.storage.memory_write import memory_read, memory_write
26
27
 
27
-
28
28
  _engine_logger = logging.getLogger("superlocalmemory.engine")
29
29
 
30
30
 
@@ -32,6 +32,7 @@ _engine_logger = logging.getLogger("superlocalmemory.engine")
32
32
  # Version detection (shared — avoids circular import between ui.py ↔ v3_api.py)
33
33
  # ---------------------------------------------------------------------------
34
34
 
35
+
35
36
  def _get_version() -> str:
36
37
  """Read version from package.json / pyproject.toml / importlib.
37
38
 
@@ -43,6 +44,7 @@ def _get_version() -> str:
43
44
  for depth in (5, 4):
44
45
  try:
45
46
  import json as _json
47
+
46
48
  root = here
47
49
  for _ in range(depth):
48
50
  root = root.parent
@@ -55,12 +57,14 @@ def _get_version() -> str:
55
57
  toml_path = root / "pyproject.toml"
56
58
  if toml_path.exists():
57
59
  import tomllib
60
+
58
61
  with open(toml_path, "rb") as f:
59
62
  return tomllib.load(f)["project"]["version"]
60
63
  except Exception:
61
64
  continue
62
65
  try:
63
66
  from importlib.metadata import version
67
+
64
68
  return version("superlocalmemory")
65
69
  except Exception:
66
70
  pass
@@ -147,6 +151,7 @@ def get_engine_lazy(app_state):
147
151
  try:
148
152
  from superlocalmemory.core.config import SLMConfig
149
153
  from superlocalmemory.core.engine import MemoryEngine
154
+
150
155
  config = SLMConfig.load()
151
156
  new_engine = MemoryEngine(config)
152
157
  new_engine.initialize()
@@ -213,7 +218,11 @@ def log_mode_change(
213
218
 
214
219
  _engine_logger.info(
215
220
  "Mode change: %s→%s provider=%s model=%s (%s)",
216
- old_mode, new_mode, provider, model, source,
221
+ old_mode,
222
+ new_mode,
223
+ provider,
224
+ model,
225
+ source,
217
226
  )
218
227
 
219
228
 
@@ -277,17 +286,17 @@ def get_active_profile() -> str:
277
286
  config_file = MEMORY_DIR / "profiles.json"
278
287
  if config_file.exists():
279
288
  try:
280
- with open(config_file, 'r') as f:
289
+ with open(config_file, "r") as f:
281
290
  pconfig = json.load(f)
282
- return pconfig.get('active_profile', 'default')
291
+ return pconfig.get("active_profile", "default")
283
292
  except (json.JSONDecodeError, IOError):
284
293
  pass
285
- return 'default'
294
+ return "default"
286
295
 
287
296
 
288
297
  def validate_profile_name(name: str) -> bool:
289
298
  """Validate profile name (alphanumeric, underscore, hyphen only)."""
290
- return bool(re.match(r'^[a-zA-Z0-9_-]+$', name))
299
+ return bool(re.match(r"^[a-zA-Z0-9_-]+$", name))
291
300
 
292
301
 
293
302
  # ============================================================================
@@ -308,8 +317,7 @@ def ensure_profile_in_db(name: str, description: str = "") -> None:
308
317
  with memory_write(DB_PATH) as conn:
309
318
  conn.execute("PRAGMA foreign_keys=ON")
310
319
  conn.execute(
311
- "INSERT OR IGNORE INTO profiles (profile_id, name, description) "
312
- "VALUES (?, ?, ?)",
320
+ "INSERT OR IGNORE INTO profiles (profile_id, name, description) VALUES (?, ?, ?)",
313
321
  (name, name, description or f"Memory profile: {name}"),
314
322
  )
315
323
 
@@ -317,17 +325,17 @@ def ensure_profile_in_db(name: str, description: str = "") -> None:
317
325
  def ensure_profile_in_json(name: str, description: str = "") -> None:
318
326
  """Ensure a profile entry exists in profiles.json (idempotent)."""
319
327
  from datetime import datetime
320
- config_file = MEMORY_DIR / "profiles.json"
328
+
321
329
  config = _load_profiles_json()
322
- profiles = config.get('profiles', {})
330
+ profiles = config.get("profiles", {})
323
331
  if name not in profiles:
324
332
  profiles[name] = {
325
- 'name': name,
326
- 'description': description or f'Memory profile: {name}',
327
- 'created_at': datetime.now(timezone.utc).isoformat(),
328
- 'last_used': None,
333
+ "name": name,
334
+ "description": description or f"Memory profile: {name}",
335
+ "created_at": datetime.now(timezone.utc).isoformat(),
336
+ "last_used": None,
329
337
  }
330
- config['profiles'] = profiles
338
+ config["profiles"] = profiles
331
339
  _save_profiles_json(config)
332
340
 
333
341
 
@@ -340,31 +348,31 @@ def sync_profiles() -> list[dict]:
340
348
  """
341
349
  db_profiles = _get_db_profiles()
342
350
  json_config = _load_profiles_json()
343
- json_profiles = json_config.get('profiles', {})
351
+ json_profiles = json_config.get("profiles", {})
344
352
 
345
353
  # profile_id is the canonical key (PK in SQLite, FK target everywhere)
346
- db_ids = {p['profile_id'] for p in db_profiles}
354
+ db_ids = {p["profile_id"] for p in db_profiles}
347
355
  json_keys = set(json_profiles.keys())
348
356
 
349
357
  changed = False
350
358
 
351
359
  # JSON-only → add to SQLite (fixes Dashboard-created profiles)
352
360
  for key in json_keys - db_ids:
353
- ensure_profile_in_db(key, json_profiles[key].get('description', ''))
361
+ ensure_profile_in_db(key, json_profiles[key].get("description", ""))
354
362
 
355
363
  # SQLite-only → add to profiles.json (fixes CLI-created profiles)
356
364
  for pid in db_ids - json_keys:
357
- db_entry = next(p for p in db_profiles if p['profile_id'] == pid)
365
+ db_entry = next(p for p in db_profiles if p["profile_id"] == pid)
358
366
  json_profiles[pid] = {
359
- 'name': pid,
360
- 'description': db_entry.get('description', ''),
361
- 'created_at': db_entry.get('created_at', ''),
362
- 'last_used': db_entry.get('last_used'),
367
+ "name": pid,
368
+ "description": db_entry.get("description", ""),
369
+ "created_at": db_entry.get("created_at", ""),
370
+ "last_used": db_entry.get("last_used"),
363
371
  }
364
372
  changed = True
365
373
 
366
374
  if changed:
367
- json_config['profiles'] = json_profiles
375
+ json_config["profiles"] = json_profiles
368
376
  _save_profiles_json(json_config)
369
377
 
370
378
  # Return merged list from SQLite (now authoritative)
@@ -389,6 +397,12 @@ def delete_profile_from_db(name: str) -> None:
389
397
  """
390
398
  if not DB_PATH.exists():
391
399
  return
400
+ # Receipt evidence lives in learning.db, not recall's memory.db. Purge it
401
+ # first: if this fails the profile row must survive for a retry rather than
402
+ # leaving profile-scoped learning evidence orphaned.
403
+ from superlocalmemory.storage.agent_experience import purge_profile_receipts
404
+
405
+ purge_profile_receipts(Path(DB_PATH).parent / "learning.db", name)
392
406
  with memory_write(DB_PATH) as conn:
393
407
  conn.execute("PRAGMA foreign_keys=ON")
394
408
  # Purge role grants for this workspace (no FK CASCADE covers these).
@@ -420,24 +434,24 @@ def _load_profiles_json() -> dict:
420
434
  config_file = MEMORY_DIR / "profiles.json"
421
435
  if config_file.exists():
422
436
  try:
423
- with open(config_file, 'r') as f:
437
+ with open(config_file, "r") as f:
424
438
  data = json.load(f)
425
439
  # Handle ProfileManager array format → convert to dict format
426
- if isinstance(data.get('profiles'), list):
440
+ if isinstance(data.get("profiles"), list):
427
441
  converted = {}
428
- for p in data['profiles']:
429
- n = p.get('name', '')
442
+ for p in data["profiles"]:
443
+ n = p.get("name", "")
430
444
  if n:
431
445
  converted[n] = p
432
- data['profiles'] = converted
433
- if 'active' in data and 'active_profile' not in data:
434
- data['active_profile'] = data.pop('active')
446
+ data["profiles"] = converted
447
+ if "active" in data and "active_profile" not in data:
448
+ data["active_profile"] = data.pop("active")
435
449
  return data
436
450
  except (json.JSONDecodeError, IOError):
437
451
  pass
438
452
  return {
439
- 'profiles': {'default': {'name': 'default', 'description': 'Default memory profile'}},
440
- 'active_profile': 'default',
453
+ "profiles": {"default": {"name": "default", "description": "Default memory profile"}},
454
+ "active_profile": "default",
441
455
  }
442
456
 
443
457
 
@@ -445,7 +459,7 @@ def _save_profiles_json(config: dict) -> None:
445
459
  """Save profiles.json config."""
446
460
  MEMORY_DIR.mkdir(parents=True, exist_ok=True)
447
461
  config_file = MEMORY_DIR / "profiles.json"
448
- with open(config_file, 'w') as f:
462
+ with open(config_file, "w") as f:
449
463
  json.dump(config, f, indent=2)
450
464
 
451
465
 
@@ -453,8 +467,10 @@ def _save_profiles_json(config: dict) -> None:
453
467
  # Pydantic Models (shared across routes)
454
468
  # ============================================================================
455
469
 
470
+
456
471
  class SearchRequest(BaseModel):
457
472
  """Advanced search request model."""
473
+
458
474
  query: str = Field(..., min_length=1, max_length=1000)
459
475
  limit: int = Field(default=10, ge=1, le=100)
460
476
  min_score: float = Field(default=0.3, ge=0.0, le=1.0)
@@ -471,11 +487,13 @@ class SearchRequest(BaseModel):
471
487
 
472
488
  class ProfileSwitch(BaseModel):
473
489
  """Profile switching request."""
490
+
474
491
  profile_name: str = Field(..., min_length=1, max_length=50)
475
492
 
476
493
 
477
494
  class BackupConfigRequest(BaseModel):
478
495
  """Backup configuration update request."""
496
+
479
497
  interval_hours: Optional[int] = Field(None, ge=1, le=8760)
480
498
  max_backups: Optional[int] = Field(None, ge=1, le=100)
481
499
  enabled: Optional[bool] = None
@@ -271,24 +271,102 @@ async def set_full_config(request: Request):
271
271
  require_manage(request)
272
272
  try:
273
273
  body = await request.json()
274
- new_mode = body.get("mode", "a").lower()
275
- provider = body.get("provider", "none")
276
- model = body.get("model", "")
277
- api_key = body.get("api_key", "")
278
-
279
- if new_mode not in ("a", "b", "c"):
280
- return JSONResponse({"error": "Invalid mode"}, status_code=400)
281
-
274
+ if not isinstance(body, dict):
275
+ return JSONResponse({"error": "Request body must be a JSON object"}, status_code=400)
282
276
  from superlocalmemory.core.config import SLMConfig, EmbeddingConfig, LLMConfig
283
277
  from superlocalmemory.storage.models import Mode
284
278
  from superlocalmemory.server.routes.helpers import log_mode_change
279
+
285
280
  config = SLMConfig.load()
286
281
  old_mode = config.mode.value
287
282
 
288
- # v3.6.12 (settings-2): honor a custom endpoint for ANY provider.
289
- _endpoint = (body.get("base_url", "") or body.get("endpoint", "")).strip()
290
- if not _endpoint and provider == "ollama":
283
+ def _nonblank(name: str) -> str | None:
284
+ value = body.get(name)
285
+ if value is None:
286
+ return None
287
+ if not isinstance(value, str):
288
+ raise ValueError(f"{name} must be a string")
289
+ return value.strip() or None
290
+
291
+ new_mode = (_nonblank("mode") or config.mode.value).lower()
292
+ provider_input = _nonblank("provider")
293
+ model_input = _nonblank("model")
294
+ api_key_input = _nonblank("api_key")
295
+ base_url_input = _nonblank("base_url")
296
+ endpoint_input = _nonblank("endpoint")
297
+ clear_api_key = body.get("clear_api_key") is True
298
+ clear_base_url = body.get("clear_base_url") is True
299
+
300
+ if clear_api_key and api_key_input:
301
+ return JSONResponse(
302
+ {"error": "api_key cannot be replaced and cleared together"},
303
+ status_code=400,
304
+ )
305
+ if clear_base_url and (base_url_input or endpoint_input):
306
+ return JSONResponse(
307
+ {"error": "base_url cannot be replaced and cleared together"},
308
+ status_code=400,
309
+ )
310
+ if base_url_input and endpoint_input and base_url_input != endpoint_input:
311
+ return JSONResponse(
312
+ {"error": "base_url and endpoint must match when both are supplied"},
313
+ status_code=400,
314
+ )
315
+
316
+ if new_mode not in ("a", "b", "c"):
317
+ return JSONResponse({"error": "Invalid mode"}, status_code=400)
318
+
319
+ # Field presence, not form defaults, is the persistence contract. A
320
+ # dashboard panel may update embeddings without sending mode/provider,
321
+ # and password fields deliberately reload blank. In both cases saved
322
+ # LLM state must remain intact unless the user supplies a replacement
323
+ # or explicit clear flag.
324
+ provider = provider_input or config.llm.provider or "none"
325
+ model = model_input or config.llm.model
326
+ _endpoint = "" if clear_base_url else (base_url_input or endpoint_input or "")
327
+ if (
328
+ not _endpoint and provider_input == "ollama"
329
+ and config.llm.provider != "ollama"
330
+ and not clear_base_url
331
+ ):
291
332
  _endpoint = "http://localhost:11434"
333
+ elif not _endpoint and not clear_base_url:
334
+ _endpoint = config.llm.api_base or ""
335
+
336
+ # Custom endpoints become runtime egress destinations. Apply the
337
+ # same boundary policy as the connection-test route before persisting.
338
+ if base_url_input is not None or endpoint_input is not None:
339
+ client = getattr(request, "client", None)
340
+ endpoint_error = _validate_provider_url(
341
+ _endpoint, getattr(client, "host", "") if client else ""
342
+ )
343
+ if endpoint_error:
344
+ return JSONResponse({"error": endpoint_error}, status_code=400)
345
+
346
+ # A retained cloud credential must never be redirected to a caller's
347
+ # newly supplied provider or endpoint. Compare canonical URL identity
348
+ # after validation so a harmless host-case or trailing-slash rewrite
349
+ # does not unexpectedly clear the key.
350
+ from urllib.parse import urlsplit, urlunsplit
351
+
352
+ def _endpoint_identity(url: str) -> str:
353
+ parsed = urlsplit(url)
354
+ return urlunsplit((
355
+ parsed.scheme.lower(), parsed.netloc.lower(),
356
+ parsed.path.rstrip("/"), parsed.query, "",
357
+ ))
358
+
359
+ destination_changed = (
360
+ (provider_input is not None and provider_input != config.llm.provider)
361
+ or (
362
+ (base_url_input is not None or endpoint_input is not None)
363
+ and _endpoint_identity(_endpoint)
364
+ != _endpoint_identity(config.llm.api_base or "")
365
+ )
366
+ )
367
+ api_key = "" if (clear_api_key or (destination_changed and not api_key_input)) else (
368
+ api_key_input or config.llm.api_key
369
+ )
292
370
 
293
371
  # Mutate only the fields the dashboard sent — all other config blocks
294
372
  # (forgetting, injection, retrieval, math, consolidation, scope, …) are
@@ -351,6 +429,8 @@ async def set_full_config(request: Request):
351
429
  "embedding_model": config.embedding.model_name,
352
430
  "embedding_dimension": config.embedding.dimension,
353
431
  }
432
+ except ValueError as exc:
433
+ return JSONResponse({"error": str(exc)}, status_code=400)
354
434
  except Exception as e:
355
435
  return _internal_error()
356
436
 
@@ -830,6 +910,17 @@ async def recall_trace(request: Request):
830
910
  limit = body.get("limit", 10)
831
911
  window = body.get("window", "") or ""
832
912
  as_of_raw = (body.get("as_of", "") or "").strip()
913
+ raw_known_as_of = body.get("known_as_of", "")
914
+ raw_valid_at = body.get("valid_at", "")
915
+ if raw_known_as_of is not None and not isinstance(raw_known_as_of, str):
916
+ return JSONResponse({"error": "invalid_known_as_of"}, status_code=400)
917
+ if raw_valid_at is not None and not isinstance(raw_valid_at, str):
918
+ return JSONResponse({"error": "invalid_valid_at"}, status_code=400)
919
+ known_as_of_raw = (raw_known_as_of or "").strip()
920
+ valid_at_raw = (raw_valid_at or "").strip()
921
+ include_unknown = body.get("include_unknown", False)
922
+ if not isinstance(include_unknown, bool):
923
+ return JSONResponse({"error": "invalid_include_unknown"}, status_code=400)
833
924
 
834
925
  # Normalize as_of at HTTP boundary. Invalid → 400.
835
926
  _as_of: str | None = None
@@ -840,6 +931,20 @@ async def recall_trace(request: Request):
840
931
  return JSONResponse(
841
932
  {"error": "invalid_as_of", "raw": as_of_raw}, status_code=400
842
933
  )
934
+ def _normalize_named_time(raw: str, error: str) -> str | None | JSONResponse:
935
+ if not raw:
936
+ return None
937
+ from superlocalmemory.retrieval.temporal_utils import normalize_as_of
938
+ normalized = normalize_as_of(raw)
939
+ if normalized is None:
940
+ return JSONResponse({"error": error, "raw": raw}, status_code=400)
941
+ return normalized
942
+ _known_as_of = _normalize_named_time(known_as_of_raw, "invalid_known_as_of")
943
+ if isinstance(_known_as_of, JSONResponse):
944
+ return _known_as_of
945
+ _valid_at = _normalize_named_time(valid_at_raw, "invalid_valid_at")
946
+ if isinstance(_valid_at, JSONResponse):
947
+ return _valid_at
843
948
 
844
949
  # Use daemon engine — already loaded, shares warm page cache.
845
950
  # run_in_executor keeps event loop alive so browser doesn't abort.
@@ -855,6 +960,8 @@ async def recall_trace(request: Request):
855
960
  lambda: engine.recall(
856
961
  query, limit=limit, fast=False,
857
962
  window=window or None, as_of=_as_of,
963
+ known_as_of=_known_as_of, valid_at=_valid_at,
964
+ include_unknown=include_unknown,
858
965
  ),
859
966
  )
860
967
  elapsed_ms = round((_time.monotonic() - t0) * 1000, 1)