superlocalmemory 3.8.3 → 3.8.6

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 (125) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +3 -2
  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-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  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 +9 -4
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/access/rbac.py +68 -76
  34. package/src/superlocalmemory/cli/commands.py +158 -404
  35. package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
  36. package/src/superlocalmemory/cli/main.py +30 -0
  37. package/src/superlocalmemory/cli/pending_store.py +39 -14
  38. package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
  39. package/src/superlocalmemory/core/component_registry.py +4 -2
  40. package/src/superlocalmemory/core/config.py +78 -0
  41. package/src/superlocalmemory/core/consolidation_engine.py +79 -73
  42. package/src/superlocalmemory/core/embeddings.py +33 -6
  43. package/src/superlocalmemory/core/engine.py +186 -60
  44. package/src/superlocalmemory/core/engine_ingestion.py +150 -63
  45. package/src/superlocalmemory/core/fact_consolidator.py +148 -30
  46. package/src/superlocalmemory/core/graph_pruner.py +436 -39
  47. package/src/superlocalmemory/core/ingestion_command.py +273 -32
  48. package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
  49. package/src/superlocalmemory/core/mutations.py +32 -10
  50. package/src/superlocalmemory/core/recall_pipeline.py +111 -74
  51. package/src/superlocalmemory/core/registry.py +5 -1
  52. package/src/superlocalmemory/core/remember_admission.py +152 -0
  53. package/src/superlocalmemory/core/remember_runtime.py +712 -0
  54. package/src/superlocalmemory/core/remote_mode.py +3 -1
  55. package/src/superlocalmemory/core/scale_engine.py +41 -18
  56. package/src/superlocalmemory/core/store_pipeline.py +18 -4
  57. package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
  58. package/src/superlocalmemory/graph/cozo_backend.py +5 -5
  59. package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
  60. package/src/superlocalmemory/hooks/adapter_base.py +58 -44
  61. package/src/superlocalmemory/hooks/ide_connector.py +26 -8
  62. package/src/superlocalmemory/hooks/portable_kit.py +105 -9
  63. package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
  64. package/src/superlocalmemory/infra/auth_middleware.py +3 -1
  65. package/src/superlocalmemory/infra/cloud_backup.py +26 -27
  66. package/src/superlocalmemory/infra/event_bus.py +250 -88
  67. package/src/superlocalmemory/learning/bandit.py +50 -1
  68. package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
  69. package/src/superlocalmemory/learning/entity_compiler.py +148 -132
  70. package/src/superlocalmemory/learning/memory_merge.py +97 -82
  71. package/src/superlocalmemory/learning/reward_archive.py +98 -90
  72. package/src/superlocalmemory/learning/reward_boost.py +40 -30
  73. package/src/superlocalmemory/learning/source_quality.py +38 -35
  74. package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
  75. package/src/superlocalmemory/mcp/http_transport.py +335 -3
  76. package/src/superlocalmemory/mcp/tools_active.py +4 -41
  77. package/src/superlocalmemory/mcp/tools_core.py +26 -87
  78. package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
  79. package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
  80. package/src/superlocalmemory/retrieval/engine.py +15 -4
  81. package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
  82. package/src/superlocalmemory/retrieval/reranker.py +130 -22
  83. package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
  84. package/src/superlocalmemory/retrieval/vector_store.py +84 -69
  85. package/src/superlocalmemory/server/loopback.py +85 -0
  86. package/src/superlocalmemory/server/origin.py +9 -4
  87. package/src/superlocalmemory/server/profile_runtime.py +14 -0
  88. package/src/superlocalmemory/server/routes/abstraction.py +2 -4
  89. package/src/superlocalmemory/server/routes/agents.py +3 -5
  90. package/src/superlocalmemory/server/routes/backup.py +6 -2
  91. package/src/superlocalmemory/server/routes/behavioral.py +11 -25
  92. package/src/superlocalmemory/server/routes/brain.py +6 -9
  93. package/src/superlocalmemory/server/routes/compliance.py +20 -23
  94. package/src/superlocalmemory/server/routes/config_api.py +83 -0
  95. package/src/superlocalmemory/server/routes/entity.py +3 -7
  96. package/src/superlocalmemory/server/routes/evolution.py +3 -5
  97. package/src/superlocalmemory/server/routes/helpers.py +57 -25
  98. package/src/superlocalmemory/server/routes/insights.py +2 -4
  99. package/src/superlocalmemory/server/routes/learning.py +2 -5
  100. package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
  101. package/src/superlocalmemory/server/routes/memories.py +119 -98
  102. package/src/superlocalmemory/server/routes/mesh.py +7 -2
  103. package/src/superlocalmemory/server/routes/profiles.py +20 -21
  104. package/src/superlocalmemory/server/routes/rbac.py +0 -1
  105. package/src/superlocalmemory/server/routes/tiers.py +28 -35
  106. package/src/superlocalmemory/server/routes/timeline.py +2 -4
  107. package/src/superlocalmemory/server/routes/v3_api.py +85 -93
  108. package/src/superlocalmemory/server/unified_daemon.py +400 -140
  109. package/src/superlocalmemory/server/write_identity.py +22 -4
  110. package/src/superlocalmemory/storage/admission_codec.py +119 -0
  111. package/src/superlocalmemory/storage/admission_journal.py +728 -0
  112. package/src/superlocalmemory/storage/database.py +168 -19
  113. package/src/superlocalmemory/storage/deferred_writes.py +209 -0
  114. package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
  115. package/src/superlocalmemory/storage/memory_write.py +115 -0
  116. package/src/superlocalmemory/storage/migration_runner.py +44 -0
  117. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
  118. package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
  119. package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
  120. package/src/superlocalmemory/storage/read_connection.py +115 -0
  121. package/src/superlocalmemory/storage/write_coordinator.py +756 -0
  122. package/src/superlocalmemory/storage/write_lock.py +88 -0
  123. package/src/superlocalmemory/ui/index.html +1 -1
  124. package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
  125. package/src/superlocalmemory/ui/js/od-settings.js +9 -3
@@ -7,17 +7,23 @@ Uses V3 MemoryEngine for store/recall. Falls back to direct DB for list/graph.
7
7
  """
8
8
  import json
9
9
  import logging
10
+ import re
11
+ import uuid
10
12
  from typing import Optional
11
13
 
12
14
  from fastapi import APIRouter, HTTPException, Query, Request
13
15
 
14
16
  from .helpers import (
15
- get_db_connection, dict_factory, get_active_profile, get_engine_lazy,
16
- SearchRequest, DB_PATH, MEMORY_DIR,
17
+ SearchRequest,
18
+ dict_factory,
19
+ get_active_profile,
20
+ get_db_connection,
21
+ get_engine_lazy,
17
22
  )
18
23
 
19
24
  logger = logging.getLogger("superlocalmemory.routes.memories")
20
25
  router = APIRouter()
26
+ _IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{1,256}$")
21
27
 
22
28
  # v3.8.3: GENEROUS latency budget for recall. SLM's value is quality recall
23
29
  # under heavy multi-agent load, so semantic recall is given ample time to
@@ -56,6 +62,58 @@ def _get_engine(request: Request):
56
62
  return get_engine_lazy(request.app.state)
57
63
 
58
64
 
65
+ def _canonical_mutation_runtime(request: Request):
66
+ """Return the daemon-owned mutation boundary or fail before queueing work."""
67
+ runtime = getattr(request.app.state, "canonical_remember_runtime", None)
68
+ if runtime is None or not runtime.ready:
69
+ raise HTTPException(503, detail="canonical mutation writer is not ready; retry shortly")
70
+ return runtime
71
+
72
+
73
+ def _mutation_idempotency_key(request: Request) -> str:
74
+ """Accept a client retry key without making one mandatory for the dashboard."""
75
+ key = request.headers.get("X-Idempotency-Key", "").strip() or str(uuid.uuid4())
76
+ if not _IDEMPOTENCY_KEY.fullmatch(key):
77
+ raise HTTPException(
78
+ 422,
79
+ detail="X-Idempotency-Key must contain 1-256 safe characters",
80
+ )
81
+ return key
82
+
83
+
84
+ def _canonical_mutation_error(exc: Exception, detail: str) -> HTTPException:
85
+ """Map typed mutation failures without leaking SQLite or filesystem detail."""
86
+ from superlocalmemory.core.remember_runtime import (
87
+ CanonicalMutationConflict,
88
+ CanonicalRememberUnavailable,
89
+ )
90
+
91
+ if isinstance(exc, CanonicalMutationConflict):
92
+ return HTTPException(409, detail=str(exc))
93
+ if isinstance(exc, CanonicalRememberUnavailable):
94
+ return HTTPException(
95
+ 503,
96
+ detail="canonical mutation writer is temporarily unavailable",
97
+ )
98
+ return _internal_error(detail)
99
+
100
+
101
+ def _mutation_runtime_or_missing_fact(
102
+ request: Request, engine, profile_id: str, fact_id: str,
103
+ ):
104
+ """Retain the public 404 for a missing fact without creating a local writer."""
105
+ runtime = getattr(request.app.state, "canonical_remember_runtime", None)
106
+ if runtime is not None and runtime.ready:
107
+ return runtime
108
+ rows = engine._db.execute(
109
+ "SELECT 1 FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
110
+ (fact_id, profile_id),
111
+ )
112
+ if not rows:
113
+ raise HTTPException(status_code=404, detail="Memory not found")
114
+ raise HTTPException(503, detail="canonical mutation writer is not ready; retry shortly")
115
+
116
+
59
117
  def _authorize_memory_mutation(
60
118
  request: Request,
61
119
  operation: str,
@@ -725,13 +783,21 @@ async def get_clusters(request: Request):
725
783
  unclustered = 0
726
784
 
727
785
  conn.close()
728
- return {"clusters": clusters, "total_clusters": len(clusters), "unclustered_count": unclustered}
786
+ return {
787
+ "clusters": clusters,
788
+ "total_clusters": len(clusters),
789
+ "unclustered_count": unclustered,
790
+ }
729
791
  except Exception:
730
792
  raise _internal_error("Cluster error")
731
793
 
732
794
 
733
795
  @router.get("/api/clusters/{cluster_id}")
734
- async def get_cluster_detail(request: Request, cluster_id: str, limit: int = Query(50, ge=1, le=200)):
796
+ async def get_cluster_detail(
797
+ request: Request,
798
+ cluster_id: str,
799
+ limit: int = Query(50, ge=1, le=200),
800
+ ):
735
801
  """Get detailed view of a specific cluster (scene)."""
736
802
  try:
737
803
  conn = get_db_connection()
@@ -973,14 +1039,18 @@ async def delete_memory(request: Request, fact_id: str):
973
1039
  fact_id,
974
1040
  trusted_actor_id=hook_context["agent_id"],
975
1041
  source_agent_id="dashboard",
1042
+ canonical_runtime=_mutation_runtime_or_missing_fact(
1043
+ request, engine, _active_profile, fact_id,
1044
+ ),
1045
+ idempotency_key=_mutation_idempotency_key(request),
976
1046
  )
977
1047
  if not result.get("ok"):
978
1048
  raise HTTPException(status_code=404, detail="Memory not found")
979
1049
  return {"success": True, "deleted": fact_id}
980
1050
  except HTTPException:
981
1051
  raise
982
- except Exception:
983
- raise _internal_error("Delete error")
1052
+ except Exception as exc:
1053
+ raise _canonical_mutation_error(exc, "Delete error")
984
1054
 
985
1055
 
986
1056
  @router.post("/api/memories/{fact_id}/forget")
@@ -992,56 +1062,25 @@ async def forget_memory(request: Request, fact_id: str):
992
1062
  The fact's payload is ALSO copied into ``memory_archive`` so a
993
1063
  future ``slm restore`` can bring it back.
994
1064
  """
995
- import json as _json
996
1065
  engine, active_profile, hook_context = _authorize_memory_mutation(
997
1066
  request, "delete", fact_id,
1067
+ run_pre_hook=False,
998
1068
  )
999
1069
  try:
1000
- conn = get_db_connection()
1001
- conn.row_factory = dict_factory
1002
- cursor = conn.cursor()
1003
- cursor.execute(
1004
- "SELECT fact_id, content, importance, confidence, "
1005
- " canonical_entities_json, embedding, created_at "
1006
- "FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
1007
- (fact_id, active_profile),
1070
+ engine._hooks.run_pre("delete", hook_context)
1071
+ result = _canonical_mutation_runtime(request).archive_fact(
1072
+ active_profile,
1073
+ fact_id,
1074
+ idempotency_key=_mutation_idempotency_key(request),
1008
1075
  )
1009
- row = cursor.fetchone()
1010
- if not row:
1011
- conn.close()
1076
+ if not result.get("ok"):
1012
1077
  raise HTTPException(status_code=404, detail="Memory not found")
1013
- # Archive copy — payload_json small enough for the canonical row.
1014
- payload = {
1015
- "fact_id": row["fact_id"],
1016
- "content": row["content"],
1017
- "canonical_entities_json": row.get("canonical_entities_json"),
1018
- "importance": row.get("importance"),
1019
- "confidence": row.get("confidence"),
1020
- "created_at": row.get("created_at"),
1021
- }
1022
- from datetime import datetime, timezone
1023
- archived_at = datetime.now(timezone.utc).isoformat()
1024
- import uuid as _uuid
1025
- cursor.execute(
1026
- "INSERT INTO memory_archive "
1027
- "(archive_id, fact_id, profile_id, payload_json, archived_at, reason) "
1028
- "VALUES (?, ?, ?, ?, ?, ?)",
1029
- (str(_uuid.uuid4()), fact_id, active_profile,
1030
- _json.dumps(payload), archived_at, "user_forget_dashboard"),
1031
- )
1032
- cursor.execute(
1033
- "UPDATE atomic_facts SET archive_status = 'archived' "
1034
- "WHERE fact_id = ?",
1035
- (fact_id,),
1036
- )
1037
- conn.commit()
1038
- conn.close()
1039
1078
  engine._hooks.run_post("delete", hook_context)
1040
- return {"success": True, "fact_id": fact_id, "archived_at": archived_at}
1079
+ return {"success": True, "fact_id": fact_id, "archived_at": result["archived_at"]}
1041
1080
  except HTTPException:
1042
1081
  raise
1043
- except Exception:
1044
- raise _internal_error("Forget error")
1082
+ except Exception as exc:
1083
+ raise _canonical_mutation_error(exc, "Forget error")
1045
1084
 
1046
1085
 
1047
1086
  @router.post("/api/memories/{fact_id}/merge")
@@ -1056,6 +1095,7 @@ async def merge_memory(request: Request, fact_id: str):
1056
1095
  """
1057
1096
  engine, active_profile, hook_context = _authorize_memory_mutation(
1058
1097
  request, "delete", fact_id,
1098
+ run_pre_hook=False,
1059
1099
  )
1060
1100
  try:
1061
1101
  body = await request.json()
@@ -1067,50 +1107,26 @@ async def merge_memory(request: Request, fact_id: str):
1067
1107
  raise HTTPException(400, "'into' exceeds 200-char limit")
1068
1108
  if kept == fact_id:
1069
1109
  raise HTTPException(400, "Cannot merge a fact into itself")
1070
- conn = get_db_connection()
1071
- conn.row_factory = dict_factory
1072
- cursor = conn.cursor()
1073
- # Both must belong to the active profile.
1074
- cursor.execute(
1075
- "SELECT fact_id FROM atomic_facts "
1076
- "WHERE fact_id IN (?, ?) AND profile_id = ?",
1077
- (fact_id, kept, active_profile),
1078
- )
1079
- found = {r["fact_id"] for r in cursor.fetchall()}
1080
- if fact_id not in found or kept not in found:
1081
- conn.close()
1082
- raise HTTPException(
1083
- 404,
1084
- "Both fact_ids must exist in the active profile",
1085
- )
1086
- from datetime import datetime, timezone
1087
- merged_at = datetime.now(timezone.utc).isoformat()
1088
- cursor.execute(
1089
- "INSERT INTO memory_merge_log "
1090
- "(kept_fact_id, merged_fact_id, profile_id, reason, merged_at) "
1091
- "VALUES (?, ?, ?, ?, ?)",
1092
- (kept, fact_id, active_profile,
1093
- "user_merge_dashboard", merged_at),
1094
- )
1095
- cursor.execute(
1096
- "UPDATE atomic_facts "
1097
- "SET merged_into = ?, archive_status = 'archived' "
1098
- "WHERE fact_id = ?",
1099
- (kept, fact_id),
1110
+ engine._hooks.run_pre("delete", hook_context)
1111
+ result = _canonical_mutation_runtime(request).merge_fact(
1112
+ active_profile,
1113
+ fact_id,
1114
+ kept,
1115
+ idempotency_key=_mutation_idempotency_key(request),
1100
1116
  )
1101
- conn.commit()
1102
- conn.close()
1117
+ if not result.get("ok"):
1118
+ raise HTTPException(404, "Both fact_ids must exist in the active profile")
1103
1119
  engine._hooks.run_post("delete", hook_context)
1104
1120
  return {
1105
1121
  "success": True,
1106
1122
  "merged": fact_id,
1107
1123
  "into": kept,
1108
- "merged_at": merged_at,
1124
+ "merged_at": result["merged_at"],
1109
1125
  }
1110
1126
  except HTTPException:
1111
1127
  raise
1112
- except Exception:
1113
- raise _internal_error("Merge error")
1128
+ except Exception as exc:
1129
+ raise _canonical_mutation_error(exc, "Merge error")
1114
1130
 
1115
1131
 
1116
1132
  @router.patch("/api/memories/{fact_id}")
@@ -1136,14 +1152,16 @@ async def edit_memory(request: Request, fact_id: str):
1136
1152
  new_content,
1137
1153
  trusted_actor_id=hook_context["agent_id"],
1138
1154
  source_agent_id="dashboard",
1155
+ canonical_runtime=_canonical_mutation_runtime(request),
1156
+ idempotency_key=_mutation_idempotency_key(request),
1139
1157
  )
1140
1158
  if not result.get("ok"):
1141
1159
  raise HTTPException(status_code=404, detail="Memory not found")
1142
1160
  return {"success": True, "fact_id": fact_id, "content": new_content}
1143
1161
  except HTTPException:
1144
1162
  raise
1145
- except Exception:
1146
- raise _internal_error("Edit error")
1163
+ except Exception as exc:
1164
+ raise _canonical_mutation_error(exc, "Edit error")
1147
1165
 
1148
1166
 
1149
1167
  _VALID_SCOPES = ("personal", "shared", "global")
@@ -1159,7 +1177,6 @@ async def set_memory_scope(request: Request, fact_id: str):
1159
1177
  belong to the active profile (a caller cannot re-scope another profile's
1160
1178
  fact). This is the write side of multi-scope sharing from the dashboard.
1161
1179
  """
1162
- import json as _json
1163
1180
  try:
1164
1181
  body = await request.json()
1165
1182
  scope = (body.get("scope") or "").strip().lower()
@@ -1180,26 +1197,30 @@ async def set_memory_scope(request: Request, fact_id: str):
1180
1197
  if scope != "shared":
1181
1198
  shared_list = []
1182
1199
 
1183
- engine, active_profile, _ctx = _authorize_memory_mutation(
1200
+ engine, active_profile, hook_context = _authorize_memory_mutation(
1184
1201
  request, "update", fact_id, run_pre_hook=False,
1185
1202
  )
1186
- # Ownership check: the fact must belong to the active profile.
1187
- rows = engine._db.execute(
1188
- "SELECT 1 FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
1189
- (fact_id, active_profile),
1203
+ if scope in {"shared", "global"}:
1204
+ from superlocalmemory.access.rbac import Permission
1205
+ from superlocalmemory.server.rbac_enforce import require_permission
1206
+
1207
+ require_permission(request, Permission.SHARE, profile=active_profile)
1208
+ engine._hooks.run_pre("update", hook_context)
1209
+ result = _canonical_mutation_runtime(request).set_fact_scope(
1210
+ active_profile,
1211
+ fact_id,
1212
+ scope,
1213
+ shared_list,
1214
+ idempotency_key=_mutation_idempotency_key(request),
1190
1215
  )
1191
- if not rows:
1216
+ if not result.get("ok"):
1192
1217
  raise HTTPException(404, detail="Memory not found in this profile")
1193
-
1194
- engine._db.update_fact(fact_id, {
1195
- "scope": scope,
1196
- "shared_with": _json.dumps(shared_list),
1197
- }, profile_id=active_profile)
1218
+ engine._hooks.run_post("update", hook_context)
1198
1219
  return {
1199
1220
  "success": True, "fact_id": fact_id, "scope": scope,
1200
1221
  "shared_with": shared_list, "active_profile": active_profile,
1201
1222
  }
1202
1223
  except HTTPException:
1203
1224
  raise
1204
- except Exception:
1205
- raise _internal_error("Scope update error")
1225
+ except Exception as exc:
1226
+ raise _canonical_mutation_error(exc, "Scope update error")
@@ -90,7 +90,9 @@ def _get_broker(request: Request):
90
90
  secret = getattr(broker, "_shared_secret", None)
91
91
  if secret:
92
92
  client_host = request.client.host if request.client else ""
93
- if client_host not in ("127.0.0.1", "::1", "localhost"):
93
+ from superlocalmemory.server.loopback import is_loopback as _is_loopback_host
94
+
95
+ if not _is_loopback_host(client_host):
94
96
  import hmac
95
97
 
96
98
  from superlocalmemory.core.security_primitives import verify_install_token
@@ -261,7 +263,10 @@ def _mesh_read_model(records: list[dict]) -> tuple[list[dict], list[dict]]:
261
263
  "stale_at": stale_at.isoformat(),
262
264
  "expires_at": expires_at.isoformat(),
263
265
  }
264
- if str(record.get("host") or "").lower() in _LOOPBACK_HOSTS:
266
+ # display-only not an auth decision; is_loopback() handles IPv4-mapped forms
267
+ # such as "::ffff:127.0.0.1" that _LOOPBACK_HOSTS misses.
268
+ from superlocalmemory.server.loopback import is_loopback as _is_loopback
269
+ if _is_loopback(str(record.get("host") or "").lower()):
265
270
  local.append(normalized)
266
271
  else:
267
272
  remote.append(normalized)
@@ -25,6 +25,7 @@ from .helpers import (
25
25
  delete_profile_from_db,
26
26
  _load_profiles_json, _save_profiles_json,
27
27
  )
28
+ from superlocalmemory.storage.memory_write import memory_write
28
29
  from superlocalmemory.server.profile_runtime import (
29
30
  commit_daemon_profile_switch,
30
31
  get_profile_runtime,
@@ -250,28 +251,26 @@ async def delete_profile(name: str, request: Request):
250
251
  # profile being deleted (not the active one).
251
252
  from superlocalmemory.server.rbac_enforce import require_manage as _rbac_manage
252
253
  _rbac_manage(request, profile=name)
253
- # Move data to default before deleting (bypasses CASCADE)
254
- conn = get_db_connection()
255
- cursor = conn.cursor()
254
+ # Move data to default before deleting (bypasses CASCADE).
255
+ # memory_write: write lock + busy_timeout — two UPDATEs are atomic.
256
256
  moved = 0
257
- try:
258
- cursor.execute(
259
- "UPDATE atomic_facts SET profile_id = 'default' WHERE profile_id = ?",
260
- (name,),
261
- )
262
- moved = cursor.rowcount
263
- except Exception:
264
- pass
265
- try:
266
- cursor.execute(
267
- "UPDATE memories SET profile_id = 'default' WHERE profile_id = ?",
268
- (name,),
269
- )
270
- moved += cursor.rowcount
271
- except Exception:
272
- pass
273
- conn.commit()
274
- conn.close()
257
+ with memory_write(DB_PATH) as conn:
258
+ try:
259
+ cur = conn.execute(
260
+ "UPDATE atomic_facts SET profile_id = 'default' WHERE profile_id = ?",
261
+ (name,),
262
+ )
263
+ moved = cur.rowcount
264
+ except Exception:
265
+ pass
266
+ try:
267
+ cur2 = conn.execute(
268
+ "UPDATE memories SET profile_id = 'default' WHERE profile_id = ?",
269
+ (name,),
270
+ )
271
+ moved += cur2.rowcount
272
+ except Exception:
273
+ pass
275
274
 
276
275
  # Delete from BOTH stores
277
276
  delete_profile_from_db(name)
@@ -158,7 +158,6 @@ def _require_authority_over_user(request: Request, target_user_id: str) -> None:
158
158
  shares at least one workspace on which the admin holds MANAGE.
159
159
  """
160
160
  from superlocalmemory.access.rbac import Permission
161
- from superlocalmemory.server.rbac_enforce import resolve_principal
162
161
 
163
162
  principal = resolve_principal(request)
164
163
  if principal["kind"] == "owner":
@@ -19,6 +19,7 @@ from fastapi import APIRouter, HTTPException, Request
19
19
  from pydantic import BaseModel, Field
20
20
 
21
21
  from superlocalmemory.server.route_mutations import authorize_route_mutation
22
+ from superlocalmemory.storage.memory_write import memory_read, memory_write
22
23
 
23
24
  from .helpers import DB_PATH, get_active_profile
24
25
 
@@ -36,15 +37,9 @@ class PinRequest(BaseModel):
36
37
 
37
38
  @contextmanager
38
39
  def _db():
39
- """Context-managed DB connection with WAL + busy_timeout."""
40
- conn = sqlite3.connect(str(DB_PATH))
41
- conn.execute("PRAGMA journal_mode=WAL")
42
- conn.execute("PRAGMA busy_timeout=5000")
43
- conn.row_factory = sqlite3.Row
44
- try:
40
+ """Yield a short-lived query-only snapshot for tier dashboard reads."""
41
+ with memory_read(DB_PATH) as conn:
45
42
  yield conn
46
- finally:
47
- conn.close()
48
43
 
49
44
 
50
45
  def _validate_profile(profile_id: str) -> str:
@@ -154,21 +149,20 @@ async def pin_fact_route(
154
149
  fact_id=body.fact_id,
155
150
  )
156
151
 
157
- with _db() as conn:
158
- try:
152
+ # memory_write: process write lock + busy_timeout.
153
+ # SELECT + INSERT + lifecycle update are atomic inside the same connection.
154
+ try:
155
+ with memory_write(DB_PATH) as conn:
159
156
  # Verify fact exists in this profile
160
- c = conn.cursor()
161
- c.execute(
157
+ if conn.execute(
162
158
  "SELECT fact_id FROM atomic_facts "
163
159
  "WHERE fact_id = ? AND profile_id = ?",
164
160
  (body.fact_id, profile_id),
165
- )
166
- if c.fetchone() is None:
161
+ ).fetchone() is None:
167
162
  raise HTTPException(
168
163
  status_code=404,
169
164
  detail=f"Fact {body.fact_id[:8]}... not found",
170
165
  )
171
-
172
166
  now = datetime.now(UTC).isoformat()
173
167
  conn.execute(
174
168
  "INSERT OR REPLACE INTO pinned_facts "
@@ -180,16 +174,15 @@ async def pin_fact_route(
180
174
  set_fact_lifecycle_zone(
181
175
  conn, [body.fact_id], "active", profile_id=profile_id,
182
176
  )
183
- conn.commit()
184
- authorization.complete()
185
- return {"success": True, "message": f"Fact {body.fact_id[:8]}... pinned"}
186
- except HTTPException:
187
- raise
188
- except Exception as exc:
189
- logger.error("pin_fact failed: %s", exc, exc_info=True)
190
- raise HTTPException(
191
- status_code=500, detail="Failed to pin fact",
192
- ) from None
177
+ authorization.complete()
178
+ return {"success": True, "message": f"Fact {body.fact_id[:8]}... pinned"}
179
+ except HTTPException:
180
+ raise
181
+ except Exception as exc:
182
+ logger.error("pin_fact failed: %s", exc, exc_info=True)
183
+ raise HTTPException(
184
+ status_code=500, detail="Failed to pin fact",
185
+ ) from None
193
186
 
194
187
 
195
188
  @router.post("/api/tiers/unpin")
@@ -213,17 +206,17 @@ async def unpin_fact_route(
213
206
  fact_id=body.fact_id,
214
207
  )
215
208
 
216
- with _db() as conn:
217
- try:
209
+ # memory_write: process write lock + busy_timeout.
210
+ try:
211
+ with memory_write(DB_PATH) as conn:
218
212
  conn.execute(
219
213
  "DELETE FROM pinned_facts WHERE fact_id = ? AND profile_id = ?",
220
214
  (body.fact_id, profile_id),
221
215
  )
222
- conn.commit()
223
- authorization.complete()
224
- return {"success": True, "unpinned": True}
225
- except Exception as exc:
226
- logger.error("unpin_fact failed: %s", exc, exc_info=True)
227
- raise HTTPException(
228
- status_code=500, detail="Failed to unpin fact",
229
- ) from None
216
+ authorization.complete()
217
+ return {"success": True, "unpinned": True}
218
+ except Exception as exc:
219
+ logger.error("unpin_fact failed: %s", exc, exc_info=True)
220
+ raise HTTPException(
221
+ status_code=500, detail="Failed to unpin fact",
222
+ ) from None
@@ -13,11 +13,10 @@ from __future__ import annotations
13
13
  import logging
14
14
  import re
15
15
  import sqlite3
16
- from typing import Any
17
16
 
18
17
  from fastapi import APIRouter, Query
19
18
  from fastapi.responses import JSONResponse
20
- from superlocalmemory.server.routes.helpers import DB_PATH, get_active_profile
19
+ from superlocalmemory.server.routes.helpers import DB_PATH, get_active_profile, get_read_connection
21
20
 
22
21
  logger = logging.getLogger(__name__)
23
22
 
@@ -65,8 +64,7 @@ async def get_timeline(
65
64
  if not DB_PATH.exists():
66
65
  return {"range": range, "group_by": group_by, "count": 0, "events": [], "total_available": 0, "offset": 0}
67
66
 
68
- conn = sqlite3.connect(str(DB_PATH))
69
- conn.row_factory = sqlite3.Row
67
+ conn = get_read_connection(DB_PATH)
70
68
 
71
69
  try:
72
70
  start_date = conn.execute("SELECT datetime('now', ?)", (modifier,)).fetchone()[0]