superlocalmemory 3.8.5 → 3.8.7

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 (82) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +3 -3
  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 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/cli/commands.py +139 -404
  34. package/src/superlocalmemory/core/backend_orchestrator.py +7 -1
  35. package/src/superlocalmemory/core/component_registry.py +4 -2
  36. package/src/superlocalmemory/core/embeddings.py +33 -6
  37. package/src/superlocalmemory/core/engine.py +94 -49
  38. package/src/superlocalmemory/core/engine_ingestion.py +150 -63
  39. package/src/superlocalmemory/core/ingestion_command.py +133 -21
  40. package/src/superlocalmemory/core/mutations.py +32 -10
  41. package/src/superlocalmemory/core/recall_pipeline.py +111 -77
  42. package/src/superlocalmemory/core/remember_admission.py +152 -0
  43. package/src/superlocalmemory/core/remember_runtime.py +712 -0
  44. package/src/superlocalmemory/graph/cozo_backend.py +5 -5
  45. package/src/superlocalmemory/learning/bandit.py +50 -1
  46. package/src/superlocalmemory/learning/source_quality.py +38 -35
  47. package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
  48. package/src/superlocalmemory/mcp/tools_active.py +4 -41
  49. package/src/superlocalmemory/mcp/tools_core.py +26 -87
  50. package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
  51. package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
  52. package/src/superlocalmemory/retrieval/engine.py +8 -3
  53. package/src/superlocalmemory/retrieval/reranker.py +35 -10
  54. package/src/superlocalmemory/server/loopback.py +7 -13
  55. package/src/superlocalmemory/server/profile_runtime.py +14 -0
  56. package/src/superlocalmemory/server/routes/abstraction.py +2 -4
  57. package/src/superlocalmemory/server/routes/agents.py +3 -5
  58. package/src/superlocalmemory/server/routes/behavioral.py +5 -13
  59. package/src/superlocalmemory/server/routes/brain.py +6 -9
  60. package/src/superlocalmemory/server/routes/entity.py +3 -7
  61. package/src/superlocalmemory/server/routes/evolution.py +3 -5
  62. package/src/superlocalmemory/server/routes/helpers.py +44 -23
  63. package/src/superlocalmemory/server/routes/insights.py +2 -4
  64. package/src/superlocalmemory/server/routes/learning.py +2 -5
  65. package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
  66. package/src/superlocalmemory/server/routes/memories.py +122 -100
  67. package/src/superlocalmemory/server/routes/tiers.py +3 -22
  68. package/src/superlocalmemory/server/routes/timeline.py +2 -4
  69. package/src/superlocalmemory/server/routes/v3_api.py +18 -16
  70. package/src/superlocalmemory/server/unified_daemon.py +200 -109
  71. package/src/superlocalmemory/storage/admission_codec.py +119 -0
  72. package/src/superlocalmemory/storage/admission_journal.py +728 -0
  73. package/src/superlocalmemory/storage/database.py +59 -0
  74. package/src/superlocalmemory/storage/deferred_writes.py +67 -11
  75. package/src/superlocalmemory/storage/memory_write.py +8 -12
  76. package/src/superlocalmemory/storage/migration_runner.py +37 -0
  77. package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
  78. package/src/superlocalmemory/storage/read_connection.py +115 -0
  79. package/src/superlocalmemory/storage/write_coordinator.py +756 -0
  80. package/src/superlocalmemory/ui/index.html +1 -1
  81. package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
  82. package/src/superlocalmemory/ui/js/od-settings.js +9 -3
@@ -8,7 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  from fastapi import APIRouter, HTTPException, Query, Request
10
10
 
11
- from .helpers import get_active_profile, require_engine
11
+ from .helpers import get_active_profile, get_read_connection, require_engine
12
12
 
13
13
  router = APIRouter(prefix="/api/entity", tags=["entity"])
14
14
 
@@ -82,9 +82,7 @@ def list_entities(
82
82
  profile = profile or get_active_profile()
83
83
  _require_read(request, profile)
84
84
 
85
- import sqlite3
86
- conn = sqlite3.connect(str(engine._config.db_path))
87
- conn.row_factory = sqlite3.Row
85
+ conn = get_read_connection(engine._config.db_path)
88
86
  try:
89
87
  where = ["ce.profile_id = ?"]
90
88
  params: list[object] = [profile]
@@ -158,9 +156,7 @@ def get_entity(
158
156
  _require_read(request, profile)
159
157
 
160
158
  import json
161
- import sqlite3
162
- conn = sqlite3.connect(str(engine._config.db_path))
163
- conn.row_factory = sqlite3.Row
159
+ conn = get_read_connection(engine._config.db_path)
164
160
  try:
165
161
  # Search by canonical_name (case-insensitive)
166
162
  row = conn.execute("""
@@ -15,6 +15,7 @@ from fastapi import APIRouter, Request
15
15
  from pydantic import BaseModel
16
16
 
17
17
  from superlocalmemory.server.config_file import read_config, update_config
18
+ from superlocalmemory.storage.read_connection import ReadConnectionFactory
18
19
 
19
20
  from .helpers import MEMORY_DIR, get_active_profile
20
21
 
@@ -261,12 +262,9 @@ def evolution_lineage(request: Request, skill_name: str = ""):
261
262
  _require_read(request)
262
263
  conn = None
263
264
  try:
264
- import sqlite3 as _sqlite3
265
-
266
- db_path = str(MEMORY_DIR / "memory.db")
265
+ db_path = MEMORY_DIR / "memory.db"
267
266
  profile_id = get_active_profile()
268
- conn = _sqlite3.connect(db_path, timeout=10)
269
- conn.row_factory = _sqlite3.Row
267
+ conn = ReadConnectionFactory(db_path).open()
270
268
 
271
269
  if skill_name:
272
270
  rows = conn.execute(
@@ -22,7 +22,7 @@ from fastapi import HTTPException, Request
22
22
  from pydantic import BaseModel, Field
23
23
 
24
24
  from superlocalmemory.infra.data_root import DynamicStatePath, canonical_data_root
25
- from superlocalmemory.storage.memory_write import memory_write
25
+ from superlocalmemory.storage.memory_write import memory_read, memory_write
26
26
 
27
27
 
28
28
  _engine_logger = logging.getLogger("superlocalmemory.engine")
@@ -217,24 +217,48 @@ def log_mode_change(
217
217
  )
218
218
 
219
219
 
220
- def get_db_connection() -> sqlite3.Connection:
221
- """Get database connection with busy_timeout set.
220
+ class _RouteReadConnection:
221
+ """Compatibility wrapper for legacy route callers that close manually.
222
222
 
223
- Used for READ-heavy callers. Writers should use ``memory_write(DB_PATH)``
224
- instead to also acquire the process write lock. This connection still gets
225
- ``PRAGMA busy_timeout`` so a cross-process writer (hook / CLI) doing a short
226
- write does not cause an immediate SQLITE_BUSY here.
223
+ New routes should prefer ``with memory_read(path)``. A few shared route
224
+ callers still expect ``get_db_connection()`` to return a connection they
225
+ can close themselves, so this wrapper preserves that contract without
226
+ reopening canonical ``memory.db`` in writable mode.
227
227
  """
228
- if not DB_PATH.exists():
228
+
229
+ def __init__(self, db_path: Path) -> None:
230
+ object.__setattr__(self, "_snapshot", memory_read(db_path))
231
+ object.__setattr__(self, "_connection", self._snapshot.__enter__())
232
+ object.__setattr__(self, "_closed", False)
233
+
234
+ def __getattr__(self, name: str):
235
+ return getattr(self._connection, name)
236
+
237
+ def __setattr__(self, name: str, value) -> None:
238
+ if name.startswith("_"):
239
+ object.__setattr__(self, name, value)
240
+ else:
241
+ setattr(self._connection, name, value)
242
+
243
+ def close(self) -> None:
244
+ if not self._closed:
245
+ self._snapshot.__exit__(None, None, None)
246
+ object.__setattr__(self, "_closed", True)
247
+
248
+
249
+ def get_read_connection(db_path: Path = DB_PATH) -> _RouteReadConnection:
250
+ """Return a legacy-compatible, physically read-only canonical connection."""
251
+ if not db_path.exists():
229
252
  raise HTTPException(
230
253
  status_code=500,
231
- detail="Memory database not found. Run 'slm init' to initialize."
254
+ detail="Memory database not found. Run 'slm init' to initialize.",
232
255
  )
233
- import os as _os
234
- _ms = max(0, int(_os.environ.get("SLM_DB_BUSY_TIMEOUT_MS", "10000")))
235
- conn = sqlite3.connect(str(DB_PATH), timeout=_ms / 1000.0)
236
- conn.execute(f"PRAGMA busy_timeout={_ms}")
237
- return conn
256
+ return _RouteReadConnection(db_path)
257
+
258
+
259
+ def get_db_connection() -> _RouteReadConnection:
260
+ """Return the shared dashboard read connection for canonical ``memory.db``."""
261
+ return get_read_connection(DB_PATH)
238
262
 
239
263
 
240
264
  def dict_factory(cursor: sqlite3.Cursor, row: tuple) -> dict:
@@ -380,18 +404,15 @@ def _get_db_profiles() -> list[dict]:
380
404
  """Read all profiles from SQLite."""
381
405
  if not DB_PATH.exists():
382
406
  return []
383
- conn = sqlite3.connect(str(DB_PATH))
384
- conn.row_factory = sqlite3.Row
385
407
  try:
386
- rows = conn.execute(
387
- "SELECT profile_id, name, description, created_at, last_used "
388
- "FROM profiles ORDER BY name"
389
- ).fetchall()
390
- return [dict(r) for r in rows]
408
+ with memory_read(DB_PATH) as conn:
409
+ rows = conn.execute(
410
+ "SELECT profile_id, name, description, created_at, last_used "
411
+ "FROM profiles ORDER BY name"
412
+ ).fetchall()
413
+ return [dict(r) for r in rows]
391
414
  except sqlite3.OperationalError:
392
415
  return []
393
- finally:
394
- conn.close()
395
416
 
396
417
 
397
418
  def _load_profiles_json() -> dict:
@@ -17,7 +17,7 @@ from typing import Any, Callable
17
17
 
18
18
  from fastapi import APIRouter, Query
19
19
  from fastapi.responses import JSONResponse
20
- from superlocalmemory.server.routes.helpers import DB_PATH, get_active_profile
20
+ from superlocalmemory.server.routes.helpers import DB_PATH, get_active_profile, get_read_connection
21
21
 
22
22
  logger = logging.getLogger(__name__)
23
23
 
@@ -31,9 +31,7 @@ def _get_conn(profile: str = "") -> tuple[sqlite3.Connection | None, str]:
31
31
  pid = profile or get_active_profile()
32
32
  if not DB_PATH.exists():
33
33
  return None, pid
34
- conn = sqlite3.connect(str(DB_PATH))
35
- conn.row_factory = sqlite3.Row
36
- return conn, pid
34
+ return get_read_connection(DB_PATH), pid
37
35
 
38
36
 
39
37
  # ── Action Handlers ───────────────────────────────────────────────
@@ -12,7 +12,6 @@ Uses V3 learning modules: FeedbackCollector, EngagementTracker, AdaptiveLearner.
12
12
  import logging
13
13
  import shutil
14
14
  import sqlite3
15
- from contextlib import closing
16
15
  from datetime import datetime, timezone
17
16
  from pathlib import Path
18
17
 
@@ -30,6 +29,7 @@ from .learning_telemetry import (
30
29
  from .learning_telemetry import (
31
30
  sqlite_status as _sqlite_status,
32
31
  )
32
+ from superlocalmemory.storage.memory_write import memory_read
33
33
 
34
34
  logger = logging.getLogger("superlocalmemory.routes.learning")
35
35
  router = APIRouter()
@@ -667,12 +667,9 @@ def get_patterns():
667
667
 
668
668
  # Graph intelligence contribution to learning (v3.4.1)
669
669
  try:
670
- import sqlite3 as _sqlite3
671
-
672
670
  from superlocalmemory.server.routes.helpers import DB_PATH
673
671
  if DB_PATH.exists():
674
- with closing(_sqlite3.connect(str(DB_PATH))) as conn:
675
- conn.row_factory = _sqlite3.Row
672
+ with memory_read(DB_PATH) as conn:
676
673
  row = conn.execute(
677
674
  "SELECT COUNT(*) AS cnt, "
678
675
  "COUNT(DISTINCT community_id) AS communities, "
@@ -7,13 +7,12 @@
7
7
  Routes: /api/lifecycle/status, /api/lifecycle/compact
8
8
  Uses V3 compliance.lifecycle.LifecycleManager.
9
9
  """
10
- import json
11
10
  import logging
12
11
  import sqlite3
13
12
 
14
13
  from fastapi import APIRouter, Request
15
14
 
16
- from .helpers import get_active_profile, get_engine_lazy, MEMORY_DIR, DB_PATH
15
+ from .helpers import DB_PATH, get_active_profile, get_engine_lazy, get_read_connection
17
16
  from superlocalmemory.server.route_mutations import authorize_route_mutation
18
17
 
19
18
  logger = logging.getLogger("superlocalmemory.routes.lifecycle")
@@ -36,8 +35,7 @@ async def lifecycle_status():
36
35
 
37
36
  try:
38
37
  profile = get_active_profile()
39
- conn = sqlite3.connect(str(DB_PATH))
40
- conn.row_factory = sqlite3.Row
38
+ conn = get_read_connection(DB_PATH)
41
39
 
42
40
  # V3.3: Use fact_retention.lifecycle_zone (Ebbinghaus-driven, authoritative)
43
41
  # Falls back to atomic_facts.lifecycle for pre-3.3 databases
@@ -7,18 +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
- from superlocalmemory.storage.memory_write import memory_write
19
23
 
20
24
  logger = logging.getLogger("superlocalmemory.routes.memories")
21
25
  router = APIRouter()
26
+ _IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{1,256}$")
22
27
 
23
28
  # v3.8.3: GENEROUS latency budget for recall. SLM's value is quality recall
24
29
  # under heavy multi-agent load, so semantic recall is given ample time to
@@ -57,6 +62,58 @@ def _get_engine(request: Request):
57
62
  return get_engine_lazy(request.app.state)
58
63
 
59
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
+
60
117
  def _authorize_memory_mutation(
61
118
  request: Request,
62
119
  operation: str,
@@ -726,13 +783,21 @@ async def get_clusters(request: Request):
726
783
  unclustered = 0
727
784
 
728
785
  conn.close()
729
- 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
+ }
730
791
  except Exception:
731
792
  raise _internal_error("Cluster error")
732
793
 
733
794
 
734
795
  @router.get("/api/clusters/{cluster_id}")
735
- 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
+ ):
736
801
  """Get detailed view of a specific cluster (scene)."""
737
802
  try:
738
803
  conn = get_db_connection()
@@ -974,14 +1039,18 @@ async def delete_memory(request: Request, fact_id: str):
974
1039
  fact_id,
975
1040
  trusted_actor_id=hook_context["agent_id"],
976
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),
977
1046
  )
978
1047
  if not result.get("ok"):
979
1048
  raise HTTPException(status_code=404, detail="Memory not found")
980
1049
  return {"success": True, "deleted": fact_id}
981
1050
  except HTTPException:
982
1051
  raise
983
- except Exception:
984
- raise _internal_error("Delete error")
1052
+ except Exception as exc:
1053
+ raise _canonical_mutation_error(exc, "Delete error")
985
1054
 
986
1055
 
987
1056
  @router.post("/api/memories/{fact_id}/forget")
@@ -993,54 +1062,25 @@ async def forget_memory(request: Request, fact_id: str):
993
1062
  The fact's payload is ALSO copied into ``memory_archive`` so a
994
1063
  future ``slm restore`` can bring it back.
995
1064
  """
996
- import json as _json
997
1065
  engine, active_profile, hook_context = _authorize_memory_mutation(
998
1066
  request, "delete", fact_id,
1067
+ run_pre_hook=False,
999
1068
  )
1000
1069
  try:
1001
- from datetime import datetime, timezone
1002
- import uuid as _uuid
1003
- archived_at = datetime.now(timezone.utc).isoformat()
1004
- archive_id = str(_uuid.uuid4())
1005
- # memory_write: write lock (in-process) + busy_timeout (cross-process).
1006
- # SELECT + INSERT + UPDATE are atomic inside the same connection.
1007
- with memory_write(DB_PATH) as conn:
1008
- conn.row_factory = dict_factory
1009
- row = conn.execute(
1010
- "SELECT fact_id, content, importance, confidence, "
1011
- " canonical_entities_json, embedding, created_at "
1012
- "FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
1013
- (fact_id, active_profile),
1014
- ).fetchone()
1015
- if not row:
1016
- raise HTTPException(status_code=404, detail="Memory not found")
1017
- # Archive copy — payload_json small enough for the canonical row.
1018
- payload = {
1019
- "fact_id": row["fact_id"],
1020
- "content": row["content"],
1021
- "canonical_entities_json": row.get("canonical_entities_json"),
1022
- "importance": row.get("importance"),
1023
- "confidence": row.get("confidence"),
1024
- "created_at": row.get("created_at"),
1025
- }
1026
- conn.execute(
1027
- "INSERT INTO memory_archive "
1028
- "(archive_id, fact_id, profile_id, payload_json, archived_at, reason) "
1029
- "VALUES (?, ?, ?, ?, ?, ?)",
1030
- (archive_id, fact_id, active_profile,
1031
- _json.dumps(payload), archived_at, "user_forget_dashboard"),
1032
- )
1033
- conn.execute(
1034
- "UPDATE atomic_facts SET archive_status = 'archived' "
1035
- "WHERE fact_id = ?",
1036
- (fact_id,),
1037
- )
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),
1075
+ )
1076
+ if not result.get("ok"):
1077
+ raise HTTPException(status_code=404, detail="Memory not found")
1038
1078
  engine._hooks.run_post("delete", hook_context)
1039
- return {"success": True, "fact_id": fact_id, "archived_at": archived_at}
1079
+ return {"success": True, "fact_id": fact_id, "archived_at": result["archived_at"]}
1040
1080
  except HTTPException:
1041
1081
  raise
1042
- except Exception:
1043
- raise _internal_error("Forget error")
1082
+ except Exception as exc:
1083
+ raise _canonical_mutation_error(exc, "Forget error")
1044
1084
 
1045
1085
 
1046
1086
  @router.post("/api/memories/{fact_id}/merge")
@@ -1055,6 +1095,7 @@ async def merge_memory(request: Request, fact_id: str):
1055
1095
  """
1056
1096
  engine, active_profile, hook_context = _authorize_memory_mutation(
1057
1097
  request, "delete", fact_id,
1098
+ run_pre_hook=False,
1058
1099
  )
1059
1100
  try:
1060
1101
  body = await request.json()
@@ -1066,50 +1107,26 @@ async def merge_memory(request: Request, fact_id: str):
1066
1107
  raise HTTPException(400, "'into' exceeds 200-char limit")
1067
1108
  if kept == fact_id:
1068
1109
  raise HTTPException(400, "Cannot merge a fact into itself")
1069
- from datetime import datetime, timezone
1070
- merged_at = datetime.now(timezone.utc).isoformat()
1071
- # memory_write: write lock (in-process) + busy_timeout (cross-process).
1072
- # SELECT + INSERT + UPDATE are atomic inside the same connection.
1073
- with memory_write(DB_PATH) as conn:
1074
- conn.row_factory = dict_factory
1075
- # Both must belong to the active profile.
1076
- found = {
1077
- r["fact_id"]
1078
- for r in conn.execute(
1079
- "SELECT fact_id FROM atomic_facts "
1080
- "WHERE fact_id IN (?, ?) AND profile_id = ?",
1081
- (fact_id, kept, active_profile),
1082
- ).fetchall()
1083
- }
1084
- if fact_id not in found or kept not in found:
1085
- raise HTTPException(
1086
- 404,
1087
- "Both fact_ids must exist in the active profile",
1088
- )
1089
- conn.execute(
1090
- "INSERT INTO memory_merge_log "
1091
- "(kept_fact_id, merged_fact_id, profile_id, reason, merged_at) "
1092
- "VALUES (?, ?, ?, ?, ?)",
1093
- (kept, fact_id, active_profile,
1094
- "user_merge_dashboard", merged_at),
1095
- )
1096
- conn.execute(
1097
- "UPDATE atomic_facts "
1098
- "SET merged_into = ?, archive_status = 'archived' "
1099
- "WHERE fact_id = ?",
1100
- (kept, fact_id),
1101
- )
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),
1116
+ )
1117
+ if not result.get("ok"):
1118
+ raise HTTPException(404, "Both fact_ids must exist in the active profile")
1102
1119
  engine._hooks.run_post("delete", hook_context)
1103
1120
  return {
1104
1121
  "success": True,
1105
1122
  "merged": fact_id,
1106
1123
  "into": kept,
1107
- "merged_at": merged_at,
1124
+ "merged_at": result["merged_at"],
1108
1125
  }
1109
1126
  except HTTPException:
1110
1127
  raise
1111
- except Exception:
1112
- raise _internal_error("Merge error")
1128
+ except Exception as exc:
1129
+ raise _canonical_mutation_error(exc, "Merge error")
1113
1130
 
1114
1131
 
1115
1132
  @router.patch("/api/memories/{fact_id}")
@@ -1135,14 +1152,16 @@ async def edit_memory(request: Request, fact_id: str):
1135
1152
  new_content,
1136
1153
  trusted_actor_id=hook_context["agent_id"],
1137
1154
  source_agent_id="dashboard",
1155
+ canonical_runtime=_canonical_mutation_runtime(request),
1156
+ idempotency_key=_mutation_idempotency_key(request),
1138
1157
  )
1139
1158
  if not result.get("ok"):
1140
1159
  raise HTTPException(status_code=404, detail="Memory not found")
1141
1160
  return {"success": True, "fact_id": fact_id, "content": new_content}
1142
1161
  except HTTPException:
1143
1162
  raise
1144
- except Exception:
1145
- raise _internal_error("Edit error")
1163
+ except Exception as exc:
1164
+ raise _canonical_mutation_error(exc, "Edit error")
1146
1165
 
1147
1166
 
1148
1167
  _VALID_SCOPES = ("personal", "shared", "global")
@@ -1158,7 +1177,6 @@ async def set_memory_scope(request: Request, fact_id: str):
1158
1177
  belong to the active profile (a caller cannot re-scope another profile's
1159
1178
  fact). This is the write side of multi-scope sharing from the dashboard.
1160
1179
  """
1161
- import json as _json
1162
1180
  try:
1163
1181
  body = await request.json()
1164
1182
  scope = (body.get("scope") or "").strip().lower()
@@ -1179,26 +1197,30 @@ async def set_memory_scope(request: Request, fact_id: str):
1179
1197
  if scope != "shared":
1180
1198
  shared_list = []
1181
1199
 
1182
- engine, active_profile, _ctx = _authorize_memory_mutation(
1200
+ engine, active_profile, hook_context = _authorize_memory_mutation(
1183
1201
  request, "update", fact_id, run_pre_hook=False,
1184
1202
  )
1185
- # Ownership check: the fact must belong to the active profile.
1186
- rows = engine._db.execute(
1187
- "SELECT 1 FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
1188
- (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),
1189
1215
  )
1190
- if not rows:
1216
+ if not result.get("ok"):
1191
1217
  raise HTTPException(404, detail="Memory not found in this profile")
1192
-
1193
- engine._db.update_fact(fact_id, {
1194
- "scope": scope,
1195
- "shared_with": _json.dumps(shared_list),
1196
- }, profile_id=active_profile)
1218
+ engine._hooks.run_post("update", hook_context)
1197
1219
  return {
1198
1220
  "success": True, "fact_id": fact_id, "scope": scope,
1199
1221
  "shared_with": shared_list, "active_profile": active_profile,
1200
1222
  }
1201
1223
  except HTTPException:
1202
1224
  raise
1203
- except Exception:
1204
- raise _internal_error("Scope update error")
1225
+ except Exception as exc:
1226
+ raise _canonical_mutation_error(exc, "Scope update error")
@@ -10,7 +10,6 @@ All connections use WAL mode + busy_timeout for concurrency safety.
10
10
  """
11
11
 
12
12
  import logging
13
- import os
14
13
  import re
15
14
  import sqlite3
16
15
  from contextlib import contextmanager
@@ -20,7 +19,7 @@ from fastapi import APIRouter, HTTPException, Request
20
19
  from pydantic import BaseModel, Field
21
20
 
22
21
  from superlocalmemory.server.route_mutations import authorize_route_mutation
23
- from superlocalmemory.storage.memory_write import memory_write
22
+ from superlocalmemory.storage.memory_write import memory_read, memory_write
24
23
 
25
24
  from .helpers import DB_PATH, get_active_profile
26
25
 
@@ -36,29 +35,11 @@ class PinRequest(BaseModel):
36
35
  reason: str = Field(default="", max_length=_MAX_REASON_LENGTH)
37
36
 
38
37
 
39
- def _busy_ms() -> int:
40
- try:
41
- return max(0, int(os.environ.get("SLM_DB_BUSY_TIMEOUT_MS", "10000")))
42
- except (TypeError, ValueError):
43
- return 10000
44
-
45
-
46
38
  @contextmanager
47
39
  def _db():
48
- """Context-managed DB connection with WAL + busy_timeout (READ paths only).
49
-
50
- Write paths (pin, unpin) use ``memory_write()`` directly to also acquire
51
- the process write lock and prevent in-process SQLITE_BUSY races.
52
- """
53
- ms = _busy_ms()
54
- conn = sqlite3.connect(str(DB_PATH), timeout=ms / 1000.0)
55
- conn.execute("PRAGMA journal_mode=WAL")
56
- conn.execute(f"PRAGMA busy_timeout={ms}")
57
- conn.row_factory = sqlite3.Row
58
- try:
40
+ """Yield a short-lived query-only snapshot for tier dashboard reads."""
41
+ with memory_read(DB_PATH) as conn:
59
42
  yield conn
60
- finally:
61
- conn.close()
62
43
 
63
44
 
64
45
  def _validate_profile(profile_id: str) -> str:
@@ -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]