superlocalmemory 3.8.2 → 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 (94) hide show
  1. package/CHANGELOG.md +57 -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 +139 -91
  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 +283 -39
  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
  94. package/src/superlocalmemory/ui/js/core.js +6 -1
@@ -15,10 +15,30 @@ 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()
21
22
 
23
+ # v3.8.3: GENEROUS latency budget for recall. SLM's value is quality recall
24
+ # under heavy multi-agent load, so semantic recall is given ample time to
25
+ # finish — the keyword fallback is a LAST-RESORT safety net for a genuine hang
26
+ # (e.g. a wedged embedder), NOT an aggressive speed cutoff. Only if recall
27
+ # exceeds this budget do we serve the fast keyword search so the caller ALWAYS
28
+ # gets a result instead of hanging forever. Tune with SLM_SEARCH_RECALL_TIMEOUT_S.
29
+ # The dashboard's fetch timeout is set ABOVE this so the browser waits for the
30
+ # quality result rather than aborting early.
31
+ _DEFAULT_RECALL_BUDGET_S = 25.0
32
+
33
+
34
+ def _search_recall_timeout_s() -> float:
35
+ import os
36
+ try:
37
+ v = float(os.environ.get("SLM_SEARCH_RECALL_TIMEOUT_S", ""))
38
+ return v if v > 0 else _DEFAULT_RECALL_BUDGET_S
39
+ except (TypeError, ValueError):
40
+ return _DEFAULT_RECALL_BUDGET_S
41
+
22
42
 
23
43
  def _internal_error(detail: str = "Internal server error") -> HTTPException:
24
44
  """SEC-H-02: log the full traceback server-side; return a generic message.
@@ -526,35 +546,65 @@ async def search_memories(request: Request, body: SearchRequest):
526
546
  _window = getattr(body, "window", None) or ""
527
547
  if not _window and getattr(body, "date_from", None) and getattr(body, "date_to", None):
528
548
  _window = f"{body.date_from}..{body.date_to}"
529
- response = await loop.run_in_executor(
549
+ # v3.8.3: bound the synchronous recall. Under a concurrent
550
+ # maintenance pass or a busy embedder it can run tens of seconds
551
+ # and the browser aborts the fetch. If it exceeds the budget we
552
+ # fall through to the fast keyword search below, so the dashboard
553
+ # ALWAYS returns instead of failing with an abort.
554
+ _recall_future = loop.run_in_executor(
530
555
  None,
531
556
  lambda: engine.recall(
532
557
  body.query, limit=body.limit, fast=True,
533
558
  window=_window or None,
534
559
  ),
535
560
  )
536
- elapsed_ms = round((_time.monotonic() - t0) * 1000, 1)
537
- from superlocalmemory.server.recall_serializer import (
538
- recall_response_metadata,
539
- serialize_recall_response,
540
- )
541
- results, no_confident_match = serialize_recall_response(
542
- response,
543
- limit=body.limit,
544
- per_fact_max=300,
545
- total_max=max(300, body.limit * 300),
546
- )
547
- return {
548
- "query": body.query,
549
- "results": results,
550
- "total": len(results),
551
- "query_type": getattr(response, "query_type", "semantic"),
552
- "retrieval_time_ms": elapsed_ms,
553
- "no_confident_match": no_confident_match,
554
- **recall_response_metadata(response),
555
- }
556
-
557
- # Fallback: direct DB text search (engine not yet initialised)
561
+ # A run_in_executor thread cannot be cancelled, and wait_for() on it
562
+ # blocks until the thread finishes (defeating the timeout). So poll
563
+ # the future without blocking the event loop and give up at the
564
+ # deadline — the orphaned recall completes in the background and its
565
+ # result is discarded. This is what bounds dashboard-search latency.
566
+ _budget = _search_recall_timeout_s()
567
+ _deadline = loop.time() + _budget
568
+ while not _recall_future.done() and loop.time() < _deadline:
569
+ await asyncio.sleep(0.05)
570
+ if _recall_future.done():
571
+ response = _recall_future.result()
572
+ else:
573
+ # Ensure the orphaned future's eventual result/exception is
574
+ # retrieved so asyncio doesn't log "never retrieved".
575
+ _recall_future.add_done_callback(
576
+ lambda f: (f.cancelled() or f.exception())
577
+ )
578
+ logger.warning(
579
+ "search_memories: semantic recall exceeded %.0fs budget for "
580
+ "%r — serving keyword fallback",
581
+ _budget, (body.query or "")[:80],
582
+ )
583
+ response = None
584
+ if response is not None:
585
+ elapsed_ms = round((_time.monotonic() - t0) * 1000, 1)
586
+ from superlocalmemory.server.recall_serializer import (
587
+ recall_response_metadata,
588
+ serialize_recall_response,
589
+ )
590
+ results, no_confident_match = serialize_recall_response(
591
+ response,
592
+ limit=body.limit,
593
+ per_fact_max=300,
594
+ total_max=max(300, body.limit * 300),
595
+ )
596
+ return {
597
+ "query": body.query,
598
+ "results": results,
599
+ "total": len(results),
600
+ "query_type": getattr(response, "query_type", "semantic"),
601
+ "retrieval_time_ms": elapsed_ms,
602
+ "no_confident_match": no_confident_match,
603
+ **recall_response_metadata(response),
604
+ }
605
+ # recall timed out — fall through to the fast keyword search.
606
+
607
+ # Fallback: direct DB text search (engine not ready OR recall over budget)
558
608
  conn = get_db_connection()
559
609
  conn.row_factory = dict_factory
560
610
  cursor = conn.cursor()
@@ -948,45 +998,43 @@ async def forget_memory(request: Request, fact_id: str):
948
998
  request, "delete", fact_id,
949
999
  )
950
1000
  try:
951
- conn = get_db_connection()
952
- conn.row_factory = dict_factory
953
- cursor = conn.cursor()
954
- cursor.execute(
955
- "SELECT fact_id, content, importance, confidence, "
956
- " canonical_entities_json, embedding, created_at "
957
- "FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
958
- (fact_id, active_profile),
959
- )
960
- row = cursor.fetchone()
961
- if not row:
962
- conn.close()
963
- raise HTTPException(status_code=404, detail="Memory not found")
964
- # Archive copy — payload_json small enough for the canonical row.
965
- payload = {
966
- "fact_id": row["fact_id"],
967
- "content": row["content"],
968
- "canonical_entities_json": row.get("canonical_entities_json"),
969
- "importance": row.get("importance"),
970
- "confidence": row.get("confidence"),
971
- "created_at": row.get("created_at"),
972
- }
973
1001
  from datetime import datetime, timezone
974
- archived_at = datetime.now(timezone.utc).isoformat()
975
1002
  import uuid as _uuid
976
- cursor.execute(
977
- "INSERT INTO memory_archive "
978
- "(archive_id, fact_id, profile_id, payload_json, archived_at, reason) "
979
- "VALUES (?, ?, ?, ?, ?, ?)",
980
- (str(_uuid.uuid4()), fact_id, active_profile,
981
- _json.dumps(payload), archived_at, "user_forget_dashboard"),
982
- )
983
- cursor.execute(
984
- "UPDATE atomic_facts SET archive_status = 'archived' "
985
- "WHERE fact_id = ?",
986
- (fact_id,),
987
- )
988
- conn.commit()
989
- 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
+ )
990
1038
  engine._hooks.run_post("delete", hook_context)
991
1039
  return {"success": True, "fact_id": fact_id, "archived_at": archived_at}
992
1040
  except HTTPException:
@@ -1018,39 +1066,39 @@ async def merge_memory(request: Request, fact_id: str):
1018
1066
  raise HTTPException(400, "'into' exceeds 200-char limit")
1019
1067
  if kept == fact_id:
1020
1068
  raise HTTPException(400, "Cannot merge a fact into itself")
1021
- conn = get_db_connection()
1022
- conn.row_factory = dict_factory
1023
- cursor = conn.cursor()
1024
- # Both must belong to the active profile.
1025
- cursor.execute(
1026
- "SELECT fact_id FROM atomic_facts "
1027
- "WHERE fact_id IN (?, ?) AND profile_id = ?",
1028
- (fact_id, kept, active_profile),
1029
- )
1030
- found = {r["fact_id"] for r in cursor.fetchall()}
1031
- if fact_id not in found or kept not in found:
1032
- conn.close()
1033
- raise HTTPException(
1034
- 404,
1035
- "Both fact_ids must exist in the active profile",
1036
- )
1037
1069
  from datetime import datetime, timezone
1038
1070
  merged_at = datetime.now(timezone.utc).isoformat()
1039
- cursor.execute(
1040
- "INSERT INTO memory_merge_log "
1041
- "(kept_fact_id, merged_fact_id, profile_id, reason, merged_at) "
1042
- "VALUES (?, ?, ?, ?, ?)",
1043
- (kept, fact_id, active_profile,
1044
- "user_merge_dashboard", merged_at),
1045
- )
1046
- cursor.execute(
1047
- "UPDATE atomic_facts "
1048
- "SET merged_into = ?, archive_status = 'archived' "
1049
- "WHERE fact_id = ?",
1050
- (kept, fact_id),
1051
- )
1052
- conn.commit()
1053
- 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
+ )
1054
1102
  engine._hooks.run_post("delete", hook_context)
1055
1103
  return {
1056
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