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
@@ -14,7 +14,7 @@ from fastapi import APIRouter, HTTPException, Query, Request
14
14
 
15
15
  from superlocalmemory.infra.data_root import state_path
16
16
 
17
- from .helpers import DB_PATH
17
+ from .helpers import DB_PATH, get_read_connection
18
18
 
19
19
  logger = logging.getLogger("superlocalmemory.routes.agents")
20
20
  router = APIRouter()
@@ -103,8 +103,7 @@ async def get_agent_memory_activity(
103
103
  total = 0
104
104
 
105
105
  if DB_PATH.exists():
106
- conn = sqlite3.connect(str(DB_PATH))
107
- conn.row_factory = sqlite3.Row
106
+ conn = get_read_connection(DB_PATH)
108
107
  try:
109
108
  try:
110
109
  rows = conn.execute(
@@ -188,8 +187,7 @@ async def get_trust_stats(request: Request):
188
187
  by_signal_type = {}
189
188
 
190
189
  if DB_PATH.exists():
191
- conn = sqlite3.connect(str(DB_PATH))
192
- conn.row_factory = sqlite3.Row
190
+ conn = get_read_connection(DB_PATH)
193
191
  try:
194
192
  try:
195
193
  # Count trust signals
@@ -87,9 +87,13 @@ def _require_oauth_start(request: Request) -> None:
87
87
  detail="OAuth initiation requires the local dashboard origin.",
88
88
  )
89
89
 
90
+ from superlocalmemory.server.loopback import is_loopback as _is_loopback_host
91
+
90
92
  host = request.client.host if request.client else ""
91
- is_loopback = host in {"127.0.0.1", "::1", "localhost", "testclient"}
92
- if not is_loopback and principal.get("kind") != "user":
93
+ # "testclient" is included for in-process test compatibility (preserved
94
+ # from original behaviour; was in the original frozenset).
95
+ _from_loopback = _is_loopback_host(host) or host == "testclient"
96
+ if not _from_loopback and principal.get("kind") != "user":
93
97
  raise HTTPException(
94
98
  status_code=403,
95
99
  detail=(
@@ -17,6 +17,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
17
17
  from pydantic import BaseModel, ConfigDict, Field, StrictStr, field_validator
18
18
 
19
19
  from .helpers import MEMORY_DIR, get_active_profile
20
+ from superlocalmemory.storage.memory_write import memory_write
21
+ from superlocalmemory.storage.read_connection import ReadConnectionFactory
20
22
 
21
23
  logger = logging.getLogger("superlocalmemory.routes.behavioral")
22
24
  router = APIRouter()
@@ -145,8 +147,7 @@ def _load_action_outcomes(profile_id: str) -> dict:
145
147
  if not db_path.exists():
146
148
  return empty
147
149
  try:
148
- conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=1.0)
149
- conn.row_factory = sqlite3.Row
150
+ conn = ReadConnectionFactory(db_path).open()
150
151
  try:
151
152
  columns = {
152
153
  str(row["name"])
@@ -368,7 +369,6 @@ def report_outcome(request: Request, data: ReportOutcomeRequest):
368
369
  action_type = data.action_type
369
370
  context_note = data.context
370
371
 
371
- import sqlite3
372
372
  import uuid
373
373
  from datetime import datetime, timezone
374
374
 
@@ -385,10 +385,9 @@ def report_outcome(request: Request, data: ReportOutcomeRequest):
385
385
  "action_type": action_type,
386
386
  "source": "dashboard_report_outcome",
387
387
  }
388
- conn = sqlite3.connect(str(memory_db_path), timeout=5.0)
389
- try:
390
- conn.execute("PRAGMA busy_timeout=5000")
391
- conn.execute("BEGIN IMMEDIATE")
388
+ # memory_write acquires the process write lock (serialises in-process)
389
+ # and sets PRAGMA busy_timeout (cross-process writers wait, not error).
390
+ with memory_write(memory_db_path) as conn:
392
391
  _validate_profile_fact_ids(
393
392
  conn,
394
393
  profile_id=profile,
@@ -407,9 +406,6 @@ def report_outcome(request: Request, data: ReportOutcomeRequest):
407
406
  now_iso, reward, now_iso,
408
407
  ),
409
408
  )
410
- conn.commit()
411
- finally:
412
- conn.close()
413
409
 
414
410
  try:
415
411
  from superlocalmemory.learning.source_quality import (
@@ -454,10 +450,8 @@ def get_assertions(
454
450
  ):
455
451
  """Get learned behavioral assertions for dashboard display."""
456
452
  try:
457
- import sqlite3 as _sqlite3
458
453
  profile = get_active_profile()
459
- conn = _sqlite3.connect(str(MEMORY_DIR / "memory.db"))
460
- conn.row_factory = _sqlite3.Row
454
+ conn = ReadConnectionFactory(MEMORY_DIR / "memory.db").open()
461
455
 
462
456
  query = (
463
457
  "SELECT id, trigger_condition, action, category, confidence, "
@@ -498,10 +492,8 @@ def get_tool_events(
498
492
  ):
499
493
  """Get recent tool events for dashboard display."""
500
494
  try:
501
- import sqlite3 as _sqlite3
502
495
  profile = get_active_profile()
503
- conn = _sqlite3.connect(str(MEMORY_DIR / "memory.db"))
504
- conn.row_factory = _sqlite3.Row
496
+ conn = ReadConnectionFactory(MEMORY_DIR / "memory.db").open()
505
497
 
506
498
  query = (
507
499
  "SELECT id, tool_name, event_type, input_summary, output_summary, "
@@ -531,10 +523,8 @@ def get_soft_prompts(request: Request):
531
523
  """Get active soft prompt templates for dashboard display."""
532
524
  _require_read(request)
533
525
  try:
534
- import sqlite3 as _sqlite3
535
526
  profile = get_active_profile()
536
- conn = _sqlite3.connect(str(MEMORY_DIR / "memory.db"))
537
- conn.row_factory = _sqlite3.Row
527
+ conn = ReadConnectionFactory(MEMORY_DIR / "memory.db").open()
538
528
  rows = conn.execute(
539
529
  "SELECT prompt_id, category, content, confidence, effectiveness, "
540
530
  "token_count, active, version, created_at "
@@ -573,7 +563,6 @@ def log_tool_event_api(request: Request, data: dict):
573
563
  _require_write(request)
574
564
  try:
575
565
  import os
576
- import sqlite3 as _sqlite3
577
566
  from datetime import datetime, timezone
578
567
 
579
568
  tool_name = data.get("tool_name", "unknown")
@@ -589,8 +578,8 @@ def log_tool_event_api(request: Request, data: dict):
589
578
  input_summary = str(input_summary)[:500] if input_summary else ""
590
579
  output_summary = str(output_summary)[:500] if output_summary else ""
591
580
 
592
- conn = _sqlite3.connect(str(MEMORY_DIR / "memory.db"))
593
- try:
581
+ # memory_write: process write lock + busy_timeout for SQLITE_BUSY safety.
582
+ with memory_write(MEMORY_DIR / "memory.db") as conn:
594
583
  conn.execute(
595
584
  "INSERT INTO tool_events "
596
585
  "(session_id, profile_id, project_path, tool_name, event_type, "
@@ -599,9 +588,6 @@ def log_tool_event_api(request: Request, data: dict):
599
588
  (session_id, profile, project_path, tool_name, event_type,
600
589
  input_summary, output_summary, now),
601
590
  )
602
- conn.commit()
603
- finally:
604
- conn.close()
605
591
  return {"ok": True}
606
592
  except Exception:
607
593
  logger.exception("behavioral route error")
@@ -60,6 +60,7 @@ from superlocalmemory.core.security_primitives import (
60
60
  from superlocalmemory.learning.database import LearningDatabase
61
61
  from superlocalmemory.learning.features import FEATURE_DIM
62
62
  from superlocalmemory.infra.data_root import canonical_data_root
63
+ from superlocalmemory.storage.read_connection import ReadConnectionFactory
63
64
  from .helpers import get_active_profile
64
65
 
65
66
  logger = logging.getLogger("superlocalmemory.routes.brain")
@@ -554,7 +555,7 @@ def _compute_cache_stats() -> dict:
554
555
  size = db.stat().st_size
555
556
  entry_count = 0
556
557
  try:
557
- conn = sqlite3.connect(str(db), timeout=5.0)
558
+ conn = ReadConnectionFactory(db).open()
558
559
  try:
559
560
  row = conn.execute(
560
561
  "SELECT COUNT(*) AS cnt FROM atomic_facts",
@@ -579,14 +580,11 @@ def _adapter_last_sync_ago(adapter_name: str) -> int | None:
579
580
  honest empty rather than a fabricated number.
580
581
  """
581
582
  try:
582
- import sqlite3 as _sqlite3
583
583
  from datetime import datetime as _dt, timezone as _tz
584
584
  memory_db = _memory_dir() / "memory.db"
585
585
  if not memory_db.exists():
586
586
  return None
587
- conn = _sqlite3.connect(
588
- f"file:{memory_db}?mode=ro", uri=True, timeout=1.0,
589
- )
587
+ conn = ReadConnectionFactory(memory_db).open()
590
588
  try:
591
589
  cur = conn.execute(
592
590
  "SELECT last_sync_at FROM cross_platform_sync_log "
@@ -914,7 +912,7 @@ def _compute_action_outcomes_preview(profile_id: str) -> dict:
914
912
  if not db_path.exists():
915
913
  return empty
916
914
  try:
917
- conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=1.0)
915
+ conn = ReadConnectionFactory(db_path).open()
918
916
  try:
919
917
  row = conn.execute(
920
918
  "SELECT COUNT(*) FROM action_outcomes WHERE profile_id = ?",
@@ -1036,7 +1034,6 @@ def _compute_outcome_queue_stats(profile_id: str) -> dict:
1036
1034
  ``pending_outcomes`` so the operator can see the closed loop is
1037
1035
  actually flowing: recall → enqueue → persist → finalize.
1038
1036
  """
1039
- import sqlite3
1040
1037
  try:
1041
1038
  from superlocalmemory.learning.outcome_queue import (
1042
1039
  get_counters, queue_size,
@@ -1050,7 +1047,7 @@ def _compute_outcome_queue_stats(profile_id: str) -> dict:
1050
1047
  pending_now = 0
1051
1048
  if db.exists():
1052
1049
  try:
1053
- conn = sqlite3.connect(str(db), timeout=1.0)
1050
+ conn = ReadConnectionFactory(db).open()
1054
1051
  try:
1055
1052
  row = conn.execute(
1056
1053
  "SELECT COUNT(*) FROM pending_outcomes "
@@ -1089,7 +1086,7 @@ def _compute_reward_preview(profile_id: str) -> dict:
1089
1086
  if not db.exists():
1090
1087
  return default
1091
1088
  try:
1092
- conn = sqlite3.connect(str(db), timeout=1.0)
1089
+ conn = ReadConnectionFactory(db).open()
1093
1090
  try:
1094
1091
  row = conn.execute(
1095
1092
  "SELECT COUNT(*) AS c, AVG(reward) AS m "
@@ -10,7 +10,6 @@ Uses V3 compliance modules: ABACEngine, AuditChain, RetentionEngine.
10
10
  """
11
11
  import json
12
12
  import logging
13
- import sqlite3
14
13
  from typing import Optional
15
14
 
16
15
  from fastapi import APIRouter, Query, Request
@@ -18,6 +17,7 @@ from fastapi.responses import JSONResponse
18
17
 
19
18
  from .helpers import get_active_profile, get_engine_lazy, MEMORY_DIR, DB_PATH
20
19
  from superlocalmemory.server.route_mutations import authorize_route_mutation
20
+ from superlocalmemory.storage.memory_write import memory_write
21
21
 
22
22
  logger = logging.getLogger("superlocalmemory.routes.compliance")
23
23
  router = APIRouter()
@@ -61,13 +61,14 @@ async def compliance_status():
61
61
  except Exception as exc:
62
62
  logger.debug("audit chain: %s", exc)
63
63
 
64
- # Retention policies (scoped to the active profile)
64
+ # Retention policies (scoped to the active profile).
65
+ # RetentionEngine.__init__ runs DDL (CREATE TABLE IF NOT EXISTS) so even
66
+ # list_rules() is a write at the constructor level — use memory_write().
65
67
  retention_policies = []
66
68
  try:
67
- conn = sqlite3.connect(str(DB_PATH))
68
- engine = RetentionEngine(conn)
69
- retention_policies = engine.list_rules(profile)
70
- conn.close()
69
+ with memory_write(DB_PATH) as conn:
70
+ engine = RetentionEngine(conn)
71
+ retention_policies = engine.list_rules(profile)
71
72
  except Exception as exc:
72
73
  logger.debug("retention engine: %s", exc)
73
74
 
@@ -163,15 +164,13 @@ async def create_retention_policy(data: dict):
163
164
 
164
165
  try:
165
166
  profile = get_active_profile()
166
- conn = sqlite3.connect(str(DB_PATH))
167
- engine = RetentionEngine(conn)
168
-
169
- rule_id = engine.create_rule(
170
- name=name, framework=framework,
171
- retention_days=retention_days, action=action,
172
- applies_to=applies_to, profile_id=profile,
173
- )
174
- conn.close()
167
+ with memory_write(DB_PATH) as conn:
168
+ engine = RetentionEngine(conn)
169
+ rule_id = engine.create_rule(
170
+ name=name, framework=framework,
171
+ retention_days=retention_days, action=action,
172
+ applies_to=applies_to, profile_id=profile,
173
+ )
175
174
 
176
175
  return {
177
176
  "success": True, "rule_id": rule_id,
@@ -190,10 +189,9 @@ async def delete_retention_policy(name: str = Query(...)):
190
189
  return {"success": False, "error": "Compliance engine not available"}
191
190
  try:
192
191
  profile = get_active_profile()
193
- conn = sqlite3.connect(str(DB_PATH))
194
- engine = RetentionEngine(conn)
195
- removed = engine.delete_rule(profile, name)
196
- conn.close()
192
+ with memory_write(DB_PATH) as conn:
193
+ engine = RetentionEngine(conn)
194
+ removed = engine.delete_rule(profile, name)
197
195
  if not removed:
198
196
  return {"success": False, "error": f"Policy '{name}' not found"}
199
197
  return {"success": True, "active_profile": profile,
@@ -214,10 +212,9 @@ async def enforce_retention():
214
212
  return {"success": False, "error": "Compliance engine not available"}
215
213
  try:
216
214
  profile = get_active_profile()
217
- conn = sqlite3.connect(str(DB_PATH))
218
- engine = RetentionEngine(conn)
219
- result = engine.enforce(profile)
220
- conn.close()
215
+ with memory_write(DB_PATH) as conn:
216
+ engine = RetentionEngine(conn)
217
+ result = engine.enforce(profile)
221
218
  return {"success": True, **result}
222
219
  except Exception:
223
220
  logger.exception("enforce_retention error")
@@ -436,3 +436,86 @@ def put_forgetting_config(request: Request, body: ForgettingConfigUpdate):
436
436
  except Exception:
437
437
  logger.exception("put_forgetting_config failed")
438
438
  return JSONResponse({"error": "Internal server error"}, status_code=500)
439
+
440
+
441
+ # ---------------------------------------------------------------------------
442
+ # Graph Pruning config (v3.8.4-G — GitHub #84)
443
+ # Dedicated endpoint so the forgetting config partial-update contract stays
444
+ # clean. Callers that only want graph knobs do not need to know about the
445
+ # Ebbinghaus curve, and vice-versa.
446
+ # ---------------------------------------------------------------------------
447
+
448
+ _GRAPH_PRUNING_DEFAULTS: dict = {
449
+ "max_degree_per_node": 100,
450
+ "min_edge_weight": 0.0,
451
+ "enabled": True,
452
+ }
453
+
454
+
455
+ class GraphPruningConfigUpdate(BaseModel):
456
+ """Partial update model for graph pruning configuration.
457
+
458
+ All fields are optional so a PUT with only ``max_degree_per_node`` does
459
+ NOT reset ``min_edge_weight`` back to the default. Existing values are
460
+ preserved; only the supplied fields are overwritten.
461
+ """
462
+
463
+ model_config = ConfigDict(extra="forbid")
464
+
465
+ max_degree_per_node: Optional[int] = Field(None, ge=1)
466
+ min_edge_weight: Optional[float] = Field(None, ge=0.0, le=1.0)
467
+ enabled: Optional[StrictBool] = None
468
+
469
+
470
+ # ---------------------------------------------------------------------------
471
+ # GET /api/v3/graph/config
472
+ # ---------------------------------------------------------------------------
473
+
474
+
475
+ @router.get("/graph/config")
476
+ def get_graph_config():
477
+ """Return current graph thinning configuration.
478
+
479
+ Returns all three fields with their defaults when config.json has no
480
+ ``graph_pruning`` section (old installations).
481
+ """
482
+ try:
483
+ data = _read_config()
484
+ stored = data.get("graph_pruning", {})
485
+ result = {**_GRAPH_PRUNING_DEFAULTS, **stored}
486
+ # Return only known fields
487
+ return {k: result[k] for k in _GRAPH_PRUNING_DEFAULTS}
488
+ except Exception:
489
+ logger.exception("get_graph_config failed")
490
+ return JSONResponse({"error": "Internal server error"}, status_code=500)
491
+
492
+
493
+ # ---------------------------------------------------------------------------
494
+ # PUT /api/v3/graph/config
495
+ # ---------------------------------------------------------------------------
496
+
497
+
498
+ @router.put("/graph/config")
499
+ def put_graph_config(request: Request, body: GraphPruningConfigUpdate):
500
+ """Update graph thinning configuration.
501
+
502
+ Only supplied fields are written; all other graph pruning fields are
503
+ preserved. Changes take effect at the next maintenance cycle without
504
+ requiring a daemon restart (the scheduler reads graph_pruning live from
505
+ the config object, which the daemon reloads from disk on each cycle).
506
+ """
507
+ _require_admin(request)
508
+ try:
509
+ updates = body.model_dump(exclude_none=True)
510
+
511
+ def mutate(data: dict) -> None:
512
+ stored = data.get("graph_pruning", {})
513
+ merged = {**_GRAPH_PRUNING_DEFAULTS, **stored, **updates}
514
+ data["graph_pruning"] = merged
515
+
516
+ data = _update_config(mutate)
517
+ merged = data["graph_pruning"]
518
+ return {k: merged[k] for k in _GRAPH_PRUNING_DEFAULTS}
519
+ except Exception:
520
+ logger.exception("put_graph_config failed")
521
+ return JSONResponse({"error": "Internal server error"}, status_code=500)
@@ -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,6 +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_read, memory_write
25
26
 
26
27
 
27
28
  _engine_logger = logging.getLogger("superlocalmemory.engine")
@@ -216,14 +217,48 @@ def log_mode_change(
216
217
  )
217
218
 
218
219
 
219
- def get_db_connection() -> sqlite3.Connection:
220
- """Get database connection."""
221
- if not DB_PATH.exists():
220
+ class _RouteReadConnection:
221
+ """Compatibility wrapper for legacy route callers that close manually.
222
+
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
+ """
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():
222
252
  raise HTTPException(
223
253
  status_code=500,
224
- detail="Memory database not found. Run 'slm init' to initialize."
254
+ detail="Memory database not found. Run 'slm init' to initialize.",
225
255
  )
226
- return sqlite3.connect(str(DB_PATH))
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)
227
262
 
228
263
 
229
264
  def dict_factory(cursor: sqlite3.Cursor, row: tuple) -> dict:
@@ -261,20 +296,22 @@ def validate_profile_name(name: str) -> bool:
261
296
 
262
297
 
263
298
  def ensure_profile_in_db(name: str, description: str = "") -> None:
264
- """Ensure a profile row exists in SQLite (idempotent)."""
299
+ """Ensure a profile row exists in SQLite (idempotent).
300
+
301
+ Hot path: called on every authenticated request. Uses ``memory_write()``
302
+ so in-process writers serialise through the write lock and cross-process
303
+ writers (hooks / CLI) wait via PRAGMA busy_timeout instead of getting
304
+ SQLITE_BUSY.
305
+ """
265
306
  if not DB_PATH.exists():
266
307
  return
267
- conn = sqlite3.connect(str(DB_PATH))
268
- try:
308
+ with memory_write(DB_PATH) as conn:
269
309
  conn.execute("PRAGMA foreign_keys=ON")
270
310
  conn.execute(
271
311
  "INSERT OR IGNORE INTO profiles (profile_id, name, description) "
272
312
  "VALUES (?, ?, ?)",
273
313
  (name, name, description or f"Memory profile: {name}"),
274
314
  )
275
- conn.commit()
276
- finally:
277
- conn.close()
278
315
 
279
316
 
280
317
  def ensure_profile_in_json(name: str, description: str = "") -> None:
@@ -347,11 +384,12 @@ def delete_profile_from_db(name: str) -> None:
347
384
  rbac_memberships has no FK to profiles, so CASCADE does not remove role
348
385
  grants — they would otherwise survive deletion and silently re-activate if
349
386
  a profile of the same name is later recreated. Remove them explicitly.
387
+ Uses ``memory_write()`` so the multi-statement DELETE is atomic and
388
+ serialised against other in-process writers.
350
389
  """
351
390
  if not DB_PATH.exists():
352
391
  return
353
- conn = sqlite3.connect(str(DB_PATH))
354
- try:
392
+ with memory_write(DB_PATH) as conn:
355
393
  conn.execute("PRAGMA foreign_keys=ON")
356
394
  # Purge role grants for this workspace (no FK CASCADE covers these).
357
395
  for tbl in ("rbac_memberships",):
@@ -360,27 +398,21 @@ def delete_profile_from_db(name: str) -> None:
360
398
  except sqlite3.OperationalError:
361
399
  pass # table may not exist on older installs
362
400
  conn.execute("DELETE FROM profiles WHERE profile_id = ?", (name,))
363
- conn.commit()
364
- finally:
365
- conn.close()
366
401
 
367
402
 
368
403
  def _get_db_profiles() -> list[dict]:
369
404
  """Read all profiles from SQLite."""
370
405
  if not DB_PATH.exists():
371
406
  return []
372
- conn = sqlite3.connect(str(DB_PATH))
373
- conn.row_factory = sqlite3.Row
374
407
  try:
375
- rows = conn.execute(
376
- "SELECT profile_id, name, description, created_at, last_used "
377
- "FROM profiles ORDER BY name"
378
- ).fetchall()
379
- 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]
380
414
  except sqlite3.OperationalError:
381
415
  return []
382
- finally:
383
- conn.close()
384
416
 
385
417
 
386
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