superlocalmemory 3.8.3 → 3.8.5

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 (93) hide show
  1. package/CHANGELOG.md +42 -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 +1 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/access/rbac.py +68 -76
  34. package/src/superlocalmemory/cli/commands.py +19 -0
  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/config.py +78 -0
  40. package/src/superlocalmemory/core/consolidation_engine.py +79 -73
  41. package/src/superlocalmemory/core/engine.py +92 -11
  42. package/src/superlocalmemory/core/fact_consolidator.py +148 -30
  43. package/src/superlocalmemory/core/graph_pruner.py +436 -39
  44. package/src/superlocalmemory/core/ingestion_command.py +160 -31
  45. package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
  46. package/src/superlocalmemory/core/recall_pipeline.py +3 -0
  47. package/src/superlocalmemory/core/registry.py +5 -1
  48. package/src/superlocalmemory/core/remote_mode.py +3 -1
  49. package/src/superlocalmemory/core/scale_engine.py +41 -18
  50. package/src/superlocalmemory/core/store_pipeline.py +18 -4
  51. package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
  52. package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
  53. package/src/superlocalmemory/hooks/adapter_base.py +58 -44
  54. package/src/superlocalmemory/hooks/ide_connector.py +26 -8
  55. package/src/superlocalmemory/hooks/portable_kit.py +105 -9
  56. package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
  57. package/src/superlocalmemory/infra/auth_middleware.py +3 -1
  58. package/src/superlocalmemory/infra/cloud_backup.py +26 -27
  59. package/src/superlocalmemory/infra/event_bus.py +250 -88
  60. package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
  61. package/src/superlocalmemory/learning/entity_compiler.py +148 -132
  62. package/src/superlocalmemory/learning/memory_merge.py +97 -82
  63. package/src/superlocalmemory/learning/reward_archive.py +98 -90
  64. package/src/superlocalmemory/learning/reward_boost.py +40 -30
  65. package/src/superlocalmemory/mcp/http_transport.py +335 -3
  66. package/src/superlocalmemory/retrieval/engine.py +7 -1
  67. package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
  68. package/src/superlocalmemory/retrieval/reranker.py +98 -15
  69. package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
  70. package/src/superlocalmemory/retrieval/vector_store.py +84 -69
  71. package/src/superlocalmemory/server/loopback.py +91 -0
  72. package/src/superlocalmemory/server/origin.py +9 -4
  73. package/src/superlocalmemory/server/routes/backup.py +6 -2
  74. package/src/superlocalmemory/server/routes/behavioral.py +6 -12
  75. package/src/superlocalmemory/server/routes/compliance.py +20 -23
  76. package/src/superlocalmemory/server/routes/config_api.py +83 -0
  77. package/src/superlocalmemory/server/routes/helpers.py +24 -13
  78. package/src/superlocalmemory/server/routes/memories.py +67 -68
  79. package/src/superlocalmemory/server/routes/mesh.py +7 -2
  80. package/src/superlocalmemory/server/routes/profiles.py +20 -21
  81. package/src/superlocalmemory/server/routes/rbac.py +0 -1
  82. package/src/superlocalmemory/server/routes/tiers.py +42 -30
  83. package/src/superlocalmemory/server/routes/v3_api.py +67 -77
  84. package/src/superlocalmemory/server/unified_daemon.py +200 -31
  85. package/src/superlocalmemory/server/write_identity.py +22 -4
  86. package/src/superlocalmemory/storage/database.py +109 -19
  87. package/src/superlocalmemory/storage/deferred_writes.py +153 -0
  88. package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
  89. package/src/superlocalmemory/storage/memory_write.py +119 -0
  90. package/src/superlocalmemory/storage/migration_runner.py +7 -0
  91. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
  92. package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
  93. package/src/superlocalmemory/storage/write_lock.py +88 -0
@@ -15,6 +15,7 @@ from .helpers import (
15
15
  get_db_connection, dict_factory, get_active_profile, get_engine_lazy,
16
16
  SearchRequest, DB_PATH, MEMORY_DIR,
17
17
  )
18
+ from superlocalmemory.storage.memory_write import memory_write
18
19
 
19
20
  logger = logging.getLogger("superlocalmemory.routes.memories")
20
21
  router = APIRouter()
@@ -997,45 +998,43 @@ async def forget_memory(request: Request, fact_id: str):
997
998
  request, "delete", fact_id,
998
999
  )
999
1000
  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),
1008
- )
1009
- row = cursor.fetchone()
1010
- if not row:
1011
- conn.close()
1012
- 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
1001
  from datetime import datetime, timezone
1023
- archived_at = datetime.now(timezone.utc).isoformat()
1024
1002
  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()
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
+ )
1039
1038
  engine._hooks.run_post("delete", hook_context)
1040
1039
  return {"success": True, "fact_id": fact_id, "archived_at": archived_at}
1041
1040
  except HTTPException:
@@ -1067,39 +1066,39 @@ async def merge_memory(request: Request, fact_id: str):
1067
1066
  raise HTTPException(400, "'into' exceeds 200-char limit")
1068
1067
  if kept == fact_id:
1069
1068
  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
1069
  from datetime import datetime, timezone
1087
1070
  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),
1100
- )
1101
- conn.commit()
1102
- conn.close()
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
+ )
1103
1102
  engine._hooks.run_post("delete", hook_context)
1104
1103
  return {
1105
1104
  "success": True,
@@ -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":
@@ -10,6 +10,7 @@ All connections use WAL mode + busy_timeout for concurrency safety.
10
10
  """
11
11
 
12
12
  import logging
13
+ import os
13
14
  import re
14
15
  import sqlite3
15
16
  from contextlib import contextmanager
@@ -19,6 +20,7 @@ from fastapi import APIRouter, HTTPException, Request
19
20
  from pydantic import BaseModel, Field
20
21
 
21
22
  from superlocalmemory.server.route_mutations import authorize_route_mutation
23
+ from superlocalmemory.storage.memory_write import memory_write
22
24
 
23
25
  from .helpers import DB_PATH, get_active_profile
24
26
 
@@ -34,12 +36,24 @@ class PinRequest(BaseModel):
34
36
  reason: str = Field(default="", max_length=_MAX_REASON_LENGTH)
35
37
 
36
38
 
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
+
37
46
  @contextmanager
38
47
  def _db():
39
- """Context-managed DB connection with WAL + busy_timeout."""
40
- conn = sqlite3.connect(str(DB_PATH))
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)
41
55
  conn.execute("PRAGMA journal_mode=WAL")
42
- conn.execute("PRAGMA busy_timeout=5000")
56
+ conn.execute(f"PRAGMA busy_timeout={ms}")
43
57
  conn.row_factory = sqlite3.Row
44
58
  try:
45
59
  yield conn
@@ -154,21 +168,20 @@ async def pin_fact_route(
154
168
  fact_id=body.fact_id,
155
169
  )
156
170
 
157
- with _db() as conn:
158
- try:
171
+ # memory_write: process write lock + busy_timeout.
172
+ # SELECT + INSERT + lifecycle update are atomic inside the same connection.
173
+ try:
174
+ with memory_write(DB_PATH) as conn:
159
175
  # Verify fact exists in this profile
160
- c = conn.cursor()
161
- c.execute(
176
+ if conn.execute(
162
177
  "SELECT fact_id FROM atomic_facts "
163
178
  "WHERE fact_id = ? AND profile_id = ?",
164
179
  (body.fact_id, profile_id),
165
- )
166
- if c.fetchone() is None:
180
+ ).fetchone() is None:
167
181
  raise HTTPException(
168
182
  status_code=404,
169
183
  detail=f"Fact {body.fact_id[:8]}... not found",
170
184
  )
171
-
172
185
  now = datetime.now(UTC).isoformat()
173
186
  conn.execute(
174
187
  "INSERT OR REPLACE INTO pinned_facts "
@@ -180,16 +193,15 @@ async def pin_fact_route(
180
193
  set_fact_lifecycle_zone(
181
194
  conn, [body.fact_id], "active", profile_id=profile_id,
182
195
  )
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
196
+ authorization.complete()
197
+ return {"success": True, "message": f"Fact {body.fact_id[:8]}... pinned"}
198
+ except HTTPException:
199
+ raise
200
+ except Exception as exc:
201
+ logger.error("pin_fact failed: %s", exc, exc_info=True)
202
+ raise HTTPException(
203
+ status_code=500, detail="Failed to pin fact",
204
+ ) from None
193
205
 
194
206
 
195
207
  @router.post("/api/tiers/unpin")
@@ -213,17 +225,17 @@ async def unpin_fact_route(
213
225
  fact_id=body.fact_id,
214
226
  )
215
227
 
216
- with _db() as conn:
217
- try:
228
+ # memory_write: process write lock + busy_timeout.
229
+ try:
230
+ with memory_write(DB_PATH) as conn:
218
231
  conn.execute(
219
232
  "DELETE FROM pinned_facts WHERE fact_id = ? AND profile_id = ?",
220
233
  (body.fact_id, profile_id),
221
234
  )
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
235
+ authorization.complete()
236
+ return {"success": True, "unpinned": True}
237
+ except Exception as exc:
238
+ logger.error("unpin_fact failed: %s", exc, exc_info=True)
239
+ raise HTTPException(
240
+ status_code=500, detail="Failed to unpin fact",
241
+ ) from None
@@ -596,7 +596,9 @@ def _validate_provider_url(url: str, client_host: str) -> str | None:
596
596
  host = p.hostname or ""
597
597
  if host.lower() in ("169.254.169.254", "metadata.google.internal", "metadata"):
598
598
  return "Cloud metadata endpoints are not allowed"
599
- if client_host in ("127.0.0.1", "::1", "localhost"):
599
+ from superlocalmemory.server.loopback import is_loopback as _is_loopback_host
600
+
601
+ if _is_loopback_host(client_host):
600
602
  return None # local dashboard may target its own local/LAN endpoints
601
603
  # SLM_REMOTE residue (#40): an allowlisted LAN dashboard is trusted exactly
602
604
  # like the loopback one and may probe its own LAN LLM endpoint. This does
@@ -1808,7 +1810,7 @@ async def update_core_memory_block(block_id: str, request: Request):
1808
1810
  )
1809
1811
 
1810
1812
  from superlocalmemory.server.routes.helpers import DB_PATH, get_active_profile
1811
- import sqlite3
1813
+ from superlocalmemory.storage.memory_write import memory_write
1812
1814
  from datetime import datetime, timezone
1813
1815
 
1814
1816
  if not DB_PATH.exists():
@@ -1827,46 +1829,38 @@ async def update_core_memory_block(block_id: str, request: Request):
1827
1829
  content_preview=str(content),
1828
1830
  )
1829
1831
 
1830
- conn = sqlite3.connect(str(DB_PATH))
1831
- conn.row_factory = sqlite3.Row
1832
-
1833
- # Verify block exists
1834
- existing = conn.execute(
1835
- "SELECT block_id, profile_id, block_type, version "
1836
- "FROM core_memory_blocks WHERE block_id = ? AND profile_id = ?",
1837
- (block_id, pid),
1838
- ).fetchone()
1839
-
1840
- if not existing:
1841
- conn.close()
1842
- return JSONResponse(
1843
- {"error": f"Block {block_id} not found"},
1844
- status_code=404,
1845
- )
1846
-
1847
- existing_dict = dict(existing)
1848
- new_version = existing_dict["version"] + 1
1849
1832
  now = datetime.now(timezone.utc).isoformat()
1833
+ # memory_write: process write lock + busy_timeout.
1834
+ # SELECT + UPDATE + read-back are atomic inside the same connection.
1835
+ with memory_write(DB_PATH) as conn:
1836
+ # Verify block exists
1837
+ existing = conn.execute(
1838
+ "SELECT block_id, profile_id, block_type, version "
1839
+ "FROM core_memory_blocks WHERE block_id = ? AND profile_id = ?",
1840
+ (block_id, pid),
1841
+ ).fetchone()
1850
1842
 
1851
- conn.execute(
1852
- "UPDATE core_memory_blocks SET content = ?, char_count = ?, "
1853
- "version = ?, compiled_by = 'manual', updated_at = ? "
1854
- "WHERE block_id = ? AND profile_id = ?",
1855
- (content, len(content), new_version, now, block_id, pid),
1856
- )
1857
- conn.commit()
1843
+ if not existing:
1844
+ raise HTTPException(status_code=404, detail=f"Block {block_id} not found")
1858
1845
 
1859
- # Read back updated block
1860
- updated = conn.execute(
1861
- "SELECT block_id, block_type, content, char_count, version, "
1862
- "compiled_by, updated_at FROM core_memory_blocks "
1863
- "WHERE block_id = ? AND profile_id = ?",
1864
- (block_id, pid),
1865
- ).fetchone()
1866
- conn.close()
1846
+ new_version = dict(existing)["version"] + 1
1847
+ conn.execute(
1848
+ "UPDATE core_memory_blocks SET content = ?, char_count = ?, "
1849
+ "version = ?, compiled_by = 'manual', updated_at = ? "
1850
+ "WHERE block_id = ? AND profile_id = ?",
1851
+ (content, len(content), new_version, now, block_id, pid),
1852
+ )
1853
+ # Read back updated block while connection is still open.
1854
+ updated = conn.execute(
1855
+ "SELECT block_id, block_type, content, char_count, version, "
1856
+ "compiled_by, updated_at FROM core_memory_blocks "
1857
+ "WHERE block_id = ? AND profile_id = ?",
1858
+ (block_id, pid),
1859
+ ).fetchone()
1860
+ updated_dict = dict(updated) if updated else {"block_id": block_id, "updated": True}
1867
1861
 
1868
1862
  authorization.complete()
1869
- return dict(updated) if updated else {"block_id": block_id, "updated": True}
1863
+ return updated_dict
1870
1864
  except HTTPException:
1871
1865
  raise
1872
1866
  except Exception as e:
@@ -1990,7 +1984,7 @@ async def run_forgetting(request: Request):
1990
1984
  profile = body.get("profile", "")
1991
1985
 
1992
1986
  from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
1993
- import sqlite3 as _sqlite3
1987
+ from superlocalmemory.storage.memory_write import memory_write as _memory_write
1994
1988
  pid = _resolve_mutation_profile(profile)
1995
1989
  _require_manage_for_profile(request, pid)
1996
1990
 
@@ -2003,59 +1997,55 @@ async def run_forgetting(request: Request):
2003
1997
  source_agent_id="http-forgetting-run",
2004
1998
  profile_id=pid,
2005
1999
  )
2006
- conn = _sqlite3.connect(str(DB_PATH))
2007
- conn.row_factory = _sqlite3.Row
2008
2000
 
2001
+ # memory_write: process write lock + busy_timeout — all UPDATEs atomic.
2009
2002
  updated = 0
2010
2003
  try:
2011
- # Apply Ebbinghaus decay: reduce retention for facts not accessed recently
2012
- # Formula: retention *= exp(-0.1) for each cycle (simplified batch decay)
2013
- conn.execute(
2014
- "UPDATE fact_retention "
2015
- "SET retention_score = MAX(0.0, retention_score * 0.9), "
2016
- " last_computed_at = datetime('now') "
2017
- "WHERE profile_id = ? "
2018
- "AND lifecycle_zone NOT IN ('archive', 'forgotten')",
2019
- (pid,),
2020
- )
2021
- updated = conn.total_changes
2022
-
2023
- # Transition zones based on new retention scores
2024
- zone_thresholds = [
2025
- ("forgotten", 0.05),
2026
- ("archive", 0.15),
2027
- ("cold", 0.35),
2028
- ("warm", 0.65),
2029
- ]
2030
- for zone, threshold in zone_thresholds:
2004
+ with _memory_write(DB_PATH) as conn:
2005
+ # Apply Ebbinghaus decay: reduce retention for facts not accessed recently
2006
+ # Formula: retention *= exp(-0.1) for each cycle (simplified batch decay)
2031
2007
  conn.execute(
2032
2008
  "UPDATE fact_retention "
2033
- "SET lifecycle_zone = ? "
2009
+ "SET retention_score = MAX(0.0, retention_score * 0.9), "
2010
+ " last_computed_at = datetime('now') "
2034
2011
  "WHERE profile_id = ? "
2035
- "AND retention_score < ? "
2036
2012
  "AND lifecycle_zone NOT IN ('archive', 'forgotten')",
2037
- (zone, pid, threshold),
2013
+ (pid,),
2038
2014
  )
2015
+ updated = conn.total_changes
2016
+
2017
+ # Transition zones based on new retention scores
2018
+ zone_thresholds = [
2019
+ ("forgotten", 0.05),
2020
+ ("archive", 0.15),
2021
+ ("cold", 0.35),
2022
+ ("warm", 0.65),
2023
+ ]
2024
+ for zone, threshold in zone_thresholds:
2025
+ conn.execute(
2026
+ "UPDATE fact_retention "
2027
+ "SET lifecycle_zone = ? "
2028
+ "WHERE profile_id = ? "
2029
+ "AND retention_score < ? "
2030
+ "AND lifecycle_zone NOT IN ('archive', 'forgotten')",
2031
+ (zone, pid, threshold),
2032
+ )
2039
2033
 
2040
- # Ensure high-retention facts are active
2041
- conn.execute(
2042
- "UPDATE fact_retention "
2043
- "SET lifecycle_zone = 'active' "
2044
- "WHERE profile_id = ? AND retention_score >= 0.65 "
2045
- "AND lifecycle_zone NOT IN ('archive', 'forgotten')",
2046
- (pid,),
2047
- )
2048
-
2049
- from superlocalmemory.core.lifecycle_state import reconcile_profile_lifecycle
2050
- reconcile_profile_lifecycle(conn, pid)
2034
+ # Ensure high-retention facts are active
2035
+ conn.execute(
2036
+ "UPDATE fact_retention "
2037
+ "SET lifecycle_zone = 'active' "
2038
+ "WHERE profile_id = ? AND retention_score >= 0.65 "
2039
+ "AND lifecycle_zone NOT IN ('archive', 'forgotten')",
2040
+ (pid,),
2041
+ )
2051
2042
 
2052
- conn.commit()
2043
+ from superlocalmemory.core.lifecycle_state import reconcile_profile_lifecycle
2044
+ reconcile_profile_lifecycle(conn, pid)
2053
2045
  except Exception as exc:
2054
2046
  logger.exception("run_forgetting decay failed")
2055
- conn.close()
2056
2047
  return {"success": False, "error": "internal error"}
2057
2048
 
2058
- conn.close()
2059
2049
  authorization.complete()
2060
2050
  return {"success": True, "facts_decayed": updated, "profile": pid}
2061
2051
  except HTTPException: