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
@@ -29,6 +29,8 @@ import uuid
29
29
  from datetime import datetime, timezone
30
30
  from pathlib import Path
31
31
 
32
+ from superlocalmemory.storage.write_lock import get_write_lock
33
+
32
34
  logger = logging.getLogger(__name__)
33
35
 
34
36
 
@@ -55,64 +57,67 @@ def apply_merges(
55
57
  if not candidates:
56
58
  return 0
57
59
 
58
- conn = sqlite3.connect(str(memory_db_path), timeout=10.0)
59
- conn.execute("PRAGMA busy_timeout=2000")
60
+ # Acquire the process-level write lock BEFORE opening the sqlite3
61
+ # connection. This serialises BEGIN IMMEDIATE → commit with all other
62
+ # in-process writers (DatabaseManager, VectorStore, adapter sync,
63
+ # reward_archive), eliminating SQLITE_BUSY races at the WAL layer.
60
64
  applied = 0
61
- # S-L02: track the candidate list in flight so a rollback diagnostic
62
- # can blame the exact set of (canonical, merged) pairs instead of a
63
- # blanket "rollback" message. Operators on the dashboard previously
64
- # saw zero fidelity about which candidates were in the transaction
65
- # at commit-time.
66
- in_flight: list[tuple[str, str]] = []
67
- try:
68
- conn.execute("BEGIN IMMEDIATE")
69
- for canonical_id, merged_id, cos, jac in candidates:
70
- # Skip if already merged in a prior cycle.
71
- row = conn.execute(
72
- "SELECT archive_status FROM atomic_facts WHERE fact_id=?",
73
- (merged_id,),
74
- ).fetchone()
75
- if row is None:
76
- continue
77
- if row[0] == "merged":
78
- continue
79
-
80
- conn.execute(
81
- "INSERT INTO memory_merge_log "
82
- "(merge_id, profile_id, canonical_fact_id, merged_fact_id, "
83
- " cosine_sim, entity_jaccard, merged_at, reversible) "
84
- "VALUES (?, ?, ?, ?, ?, ?, ?, 1)",
85
- (
86
- str(uuid.uuid4()),
87
- profile_id,
88
- canonical_id,
89
- merged_id,
90
- float(cos),
91
- float(jac),
92
- _iso_now(),
93
- ),
94
- )
95
- conn.execute(
96
- "UPDATE atomic_facts "
97
- "SET archive_status='merged', "
98
- " archive_reason='cosine_dup', "
99
- " merged_into=? "
100
- "WHERE fact_id=?",
101
- (canonical_id, merged_id),
65
+ with get_write_lock(memory_db_path):
66
+ conn = sqlite3.connect(str(memory_db_path), timeout=10.0)
67
+ conn.execute("PRAGMA busy_timeout=2000")
68
+ # S-L02: track the candidate list in flight so a rollback diagnostic
69
+ # can blame the exact set of (canonical, merged) pairs instead of a
70
+ # blanket "rollback" message.
71
+ in_flight: list[tuple[str, str]] = []
72
+ try:
73
+ conn.execute("BEGIN IMMEDIATE")
74
+ for canonical_id, merged_id, cos, jac in candidates:
75
+ # Skip if already merged in a prior cycle.
76
+ row = conn.execute(
77
+ "SELECT archive_status FROM atomic_facts WHERE fact_id=?",
78
+ (merged_id,),
79
+ ).fetchone()
80
+ if row is None:
81
+ continue
82
+ if row[0] == "merged":
83
+ continue
84
+
85
+ conn.execute(
86
+ "INSERT INTO memory_merge_log "
87
+ "(merge_id, profile_id, canonical_fact_id, merged_fact_id, "
88
+ " cosine_sim, entity_jaccard, merged_at, reversible) "
89
+ "VALUES (?, ?, ?, ?, ?, ?, ?, 1)",
90
+ (
91
+ str(uuid.uuid4()),
92
+ profile_id,
93
+ canonical_id,
94
+ merged_id,
95
+ float(cos),
96
+ float(jac),
97
+ _iso_now(),
98
+ ),
99
+ )
100
+ conn.execute(
101
+ "UPDATE atomic_facts "
102
+ "SET archive_status='merged', "
103
+ " archive_reason='cosine_dup', "
104
+ " merged_into=? "
105
+ "WHERE fact_id=?",
106
+ (canonical_id, merged_id),
107
+ )
108
+ applied += 1
109
+ in_flight.append((canonical_id, merged_id))
110
+ conn.commit()
111
+ except sqlite3.Error as exc:
112
+ conn.rollback()
113
+ logger.warning(
114
+ "apply_merges rollback: profile=%s pre-rollback_applied=%d "
115
+ "in_flight=%s error=%s",
116
+ profile_id, applied, in_flight, exc,
102
117
  )
103
- applied += 1
104
- in_flight.append((canonical_id, merged_id))
105
- conn.commit()
106
- except sqlite3.Error as exc:
107
- conn.rollback()
108
- logger.warning(
109
- "apply_merges rollback: profile=%s pre-rollback_applied=%d "
110
- "in_flight=%s error=%s",
111
- profile_id, applied, in_flight, exc,
112
- )
113
- applied = 0
114
- finally:
115
- conn.close()
118
+ applied = 0
119
+ finally:
120
+ conn.close()
116
121
  return applied
117
122
 
118
123
 
@@ -122,39 +127,49 @@ def unmerge(memory_db_path: str | Path, merge_id: str) -> bool:
122
127
  Flips the merged fact's archive_status back to 'live', clears
123
128
  merged_into, and marks the log row ``reversible=0``.
124
129
  """
125
- conn = sqlite3.connect(str(memory_db_path), timeout=10.0)
126
- conn.execute("PRAGMA busy_timeout=2000")
130
+ # READ phase — check reversibility without holding the write lock.
131
+ conn_r = sqlite3.connect(str(memory_db_path), timeout=10.0)
127
132
  try:
128
- row = conn.execute(
133
+ row = conn_r.execute(
129
134
  "SELECT merged_fact_id, reversible FROM memory_merge_log "
130
135
  "WHERE merge_id=?",
131
136
  (merge_id,),
132
137
  ).fetchone()
133
- if row is None:
134
- return False
135
- merged_fid, reversible = row
136
- if not reversible:
137
- return False
138
+ finally:
139
+ conn_r.close()
138
140
 
139
- conn.execute("BEGIN IMMEDIATE")
140
- conn.execute(
141
- "UPDATE atomic_facts "
142
- "SET archive_status='live', archive_reason=NULL, merged_into=NULL "
143
- "WHERE fact_id=?",
144
- (merged_fid,),
145
- )
146
- conn.execute(
147
- "UPDATE memory_merge_log SET reversible=0 WHERE merge_id=?",
148
- (merge_id,),
149
- )
150
- conn.commit()
151
- return True
152
- except sqlite3.Error as exc:
153
- conn.rollback()
154
- logger.warning("unmerge rollback: %s", exc)
141
+ if row is None:
155
142
  return False
156
- finally:
157
- conn.close()
143
+ merged_fid, reversible = row
144
+ if not reversible:
145
+ return False
146
+
147
+ # WRITE phase — acquire the process-level write lock before
148
+ # opening the write connection, serialising with all other in-process
149
+ # writers and eliminating SQLITE_BUSY at the WAL layer.
150
+ with get_write_lock(memory_db_path):
151
+ conn = sqlite3.connect(str(memory_db_path), timeout=10.0)
152
+ conn.execute("PRAGMA busy_timeout=2000")
153
+ try:
154
+ conn.execute("BEGIN IMMEDIATE")
155
+ conn.execute(
156
+ "UPDATE atomic_facts "
157
+ "SET archive_status='live', archive_reason=NULL, merged_into=NULL "
158
+ "WHERE fact_id=?",
159
+ (merged_fid,),
160
+ )
161
+ conn.execute(
162
+ "UPDATE memory_merge_log SET reversible=0 WHERE merge_id=?",
163
+ (merge_id,),
164
+ )
165
+ conn.commit()
166
+ return True
167
+ except sqlite3.Error as exc:
168
+ conn.rollback()
169
+ logger.warning("unmerge rollback: %s", exc)
170
+ return False
171
+ finally:
172
+ conn.close()
158
173
 
159
174
 
160
175
  __all__ = ("apply_merges", "unmerge")
@@ -34,6 +34,7 @@ from pathlib import Path
34
34
  from superlocalmemory.learning.fact_outcome_joins import (
35
35
  has_recent_positive_reward,
36
36
  )
37
+ from superlocalmemory.storage.write_lock import get_write_lock
37
38
 
38
39
  logger = logging.getLogger(__name__)
39
40
 
@@ -80,13 +81,16 @@ def run_reward_gated_archive(
80
81
  return []
81
82
 
82
83
  archived: list[str] = []
83
- conn = sqlite3.connect(str(memory_db_path), timeout=10.0)
84
- conn.row_factory = sqlite3.Row
85
- conn.execute("PRAGMA busy_timeout=2000")
86
84
 
85
+ # READ phase — no write lock needed (WAL allows concurrent reads).
86
+ # H-12/H-P-02: compute the archivable set BEFORE acquiring the writer
87
+ # lock so that the lock is held only for the bulk INSERT/UPDATE pass.
88
+ conn_r = sqlite3.connect(str(memory_db_path), timeout=10.0)
89
+ conn_r.row_factory = sqlite3.Row
90
+ conn_r.execute("PRAGMA busy_timeout=2000")
87
91
  try:
88
92
  placeholders = ",".join("?" for _ in candidate_fact_ids)
89
- rows = conn.execute(
93
+ rows = conn_r.execute(
90
94
  f"SELECT fact_id, content, canonical_entities_json, importance, "
91
95
  f" confidence, embedding, created_at "
92
96
  f"FROM atomic_facts "
@@ -95,13 +99,6 @@ def run_reward_gated_archive(
95
99
  (profile_id, *candidate_fact_ids),
96
100
  ).fetchall()
97
101
 
98
- # H-12/H-P-02: compute the archivable set BEFORE acquiring the
99
- # writer lock. Previously ``BEGIN IMMEDIATE`` wrapped the full
100
- # per-candidate reward-lookup loop — holding RESERVED for the
101
- # entire scan starved concurrent ``record_recall`` writers out
102
- # with SQLITE_BUSY after their 50 ms busy_timeout. Splitting the
103
- # read phase keeps the writer lock held only for the bulk
104
- # INSERT/UPDATE pass.
105
102
  to_archive: list[dict] = []
106
103
  for row in rows:
107
104
  fid = row["fact_id"]
@@ -111,7 +108,7 @@ def run_reward_gated_archive(
111
108
  # 2. Recent positive reward skip (criterion 2).
112
109
  # H-06 fix — JSON1 equality join via helper.
113
110
  if has_recent_positive_reward(
114
- conn, profile_id, fid,
111
+ conn_r, profile_id, fid,
115
112
  min_reward=ARCHIVE_REWARD_THRESHOLD,
116
113
  window_days=REWARD_WINDOW_DAYS,
117
114
  ):
@@ -125,86 +122,97 @@ def run_reward_gated_archive(
125
122
  "embedding": row["embedding"],
126
123
  "created_at": row["created_at"],
127
124
  })
125
+ finally:
126
+ conn_r.close()
128
127
 
129
- if not to_archive:
130
- return []
131
-
132
- conn.execute("BEGIN IMMEDIATE")
133
- # S9-SKEP-07: re-verify reward under RESERVED lock. Between the
134
- # read-only reward scan above and this BEGIN IMMEDIATE another
135
- # writer may have inserted a positive reward row ("user liked
136
- # the memory we are about to archive"). With the writer lock
137
- # held we now know no further inserts are landing; one last
138
- # check per entry catches everything that raced in.
139
- verified: list[dict] = []
140
- for entry in to_archive:
141
- if has_recent_positive_reward(
142
- conn, profile_id, entry["fid"],
143
- min_reward=ARCHIVE_REWARD_THRESHOLD,
144
- window_days=REWARD_WINDOW_DAYS,
145
- ):
146
- continue
147
- verified.append(entry)
148
- to_archive = verified
149
- if not to_archive:
150
- conn.execute("COMMIT")
151
- return []
152
- for entry in to_archive:
153
- fid = entry["fid"]
154
- payload = {
155
- "fact_id": fid,
156
- "content": entry["content"],
157
- "canonical_entities_json": entry["canonical_entities_json"],
158
- "importance": entry["importance"],
159
- "confidence": entry["confidence"],
160
- "embedding": entry["embedding"],
161
- "created_at": entry["created_at"],
162
- }
163
- # SEC-L3 — cap payload_json at 256 KB. Oversize blobs are
164
- # replaced with a minimal stub + ``truncated`` reason so the
165
- # archive row stays within the I4 disk budget while still
166
- # pointing back to the original ``fact_id`` in atomic_facts.
167
- payload_str = json.dumps(payload)
168
- reason = "reward_gated_ebbinghaus"
169
- if len(payload_str.encode("utf-8")) > PAYLOAD_JSON_MAX_BYTES:
170
- payload_str = json.dumps({
128
+ if not to_archive:
129
+ return []
130
+
131
+ # WRITE phase — acquire the process-level write lock BEFORE opening the
132
+ # write connection. This serialises BEGIN IMMEDIATE commit with all
133
+ # other in-process writers (DatabaseManager, VectorStore, adapter sync,
134
+ # memory_merge, reward_boost), eliminating SQLITE_BUSY races at the WAL
135
+ # layer.
136
+ with get_write_lock(memory_db_path):
137
+ conn = sqlite3.connect(str(memory_db_path), timeout=10.0)
138
+ conn.row_factory = sqlite3.Row
139
+ conn.execute("PRAGMA busy_timeout=2000")
140
+ try:
141
+ conn.execute("BEGIN IMMEDIATE")
142
+ # S9-SKEP-07: re-verify reward under RESERVED lock. Between the
143
+ # read-only reward scan above and this BEGIN IMMEDIATE another
144
+ # writer may have inserted a positive reward row. With the writer
145
+ # lock held we know no further inserts are landing; one last
146
+ # check per entry catches everything that raced in.
147
+ verified: list[dict] = []
148
+ for entry in to_archive:
149
+ if has_recent_positive_reward(
150
+ conn, profile_id, entry["fid"],
151
+ min_reward=ARCHIVE_REWARD_THRESHOLD,
152
+ window_days=REWARD_WINDOW_DAYS,
153
+ ):
154
+ continue
155
+ verified.append(entry)
156
+ to_archive = verified
157
+ if not to_archive:
158
+ conn.execute("COMMIT")
159
+ return []
160
+ for entry in to_archive:
161
+ fid = entry["fid"]
162
+ payload = {
171
163
  "fact_id": fid,
172
- "truncated": True,
173
- "original_bytes": len(payload_str.encode("utf-8")),
174
- })
175
- reason = "reward_gated_ebbinghaus_truncated"
176
- logger.warning(
177
- "memory_archive payload >%d bytes for fact_id=%s; "
178
- "truncated to stub", PAYLOAD_JSON_MAX_BYTES, fid,
164
+ "content": entry["content"],
165
+ "canonical_entities_json": entry["canonical_entities_json"],
166
+ "importance": entry["importance"],
167
+ "confidence": entry["confidence"],
168
+ "embedding": entry["embedding"],
169
+ "created_at": entry["created_at"],
170
+ }
171
+ # SEC-L3 — cap payload_json at 256 KB. Oversize blobs are
172
+ # replaced with a minimal stub + ``truncated`` reason so the
173
+ # archive row stays within the I4 disk budget while still
174
+ # pointing back to the original ``fact_id`` in atomic_facts.
175
+ payload_str = json.dumps(payload)
176
+ reason = "reward_gated_ebbinghaus"
177
+ if len(payload_str.encode("utf-8")) > PAYLOAD_JSON_MAX_BYTES:
178
+ payload_str = json.dumps({
179
+ "fact_id": fid,
180
+ "truncated": True,
181
+ "original_bytes": len(payload_str.encode("utf-8")),
182
+ })
183
+ reason = "reward_gated_ebbinghaus_truncated"
184
+ logger.warning(
185
+ "memory_archive payload >%d bytes for fact_id=%s; "
186
+ "truncated to stub", PAYLOAD_JSON_MAX_BYTES, fid,
187
+ )
188
+ conn.execute(
189
+ "INSERT INTO memory_archive "
190
+ "(archive_id, fact_id, profile_id, payload_json, "
191
+ " archived_at, reason) VALUES (?, ?, ?, ?, ?, ?)",
192
+ (
193
+ str(uuid.uuid4()),
194
+ fid,
195
+ profile_id,
196
+ payload_str,
197
+ _iso_now(),
198
+ reason,
199
+ ),
179
200
  )
180
- conn.execute(
181
- "INSERT INTO memory_archive "
182
- "(archive_id, fact_id, profile_id, payload_json, "
183
- " archived_at, reason) VALUES (?, ?, ?, ?, ?, ?)",
184
- (
185
- str(uuid.uuid4()),
186
- fid,
187
- profile_id,
188
- payload_str,
189
- _iso_now(),
190
- reason,
191
- ),
192
- )
193
- conn.execute(
194
- "UPDATE atomic_facts "
195
- "SET archive_status='archived', "
196
- " archive_reason='reward_gated_ebbinghaus' "
197
- "WHERE fact_id=? "
198
- " AND (archive_status IS NULL OR archive_status='live')",
199
- (fid,),
200
- )
201
- archived.append(fid)
202
-
203
- conn.commit()
204
- except sqlite3.Error as exc:
205
- conn.rollback()
206
- logger.warning("run_reward_gated_archive rollback: %s", exc)
207
- finally:
208
- conn.close()
201
+ conn.execute(
202
+ "UPDATE atomic_facts "
203
+ "SET archive_status='archived', "
204
+ " archive_reason='reward_gated_ebbinghaus' "
205
+ "WHERE fact_id=? "
206
+ " AND (archive_status IS NULL OR archive_status='live')",
207
+ (fid,),
208
+ )
209
+ archived.append(fid)
210
+
211
+ conn.commit()
212
+ except sqlite3.Error as exc:
213
+ conn.rollback()
214
+ logger.warning("run_reward_gated_archive rollback: %s", exc)
215
+ finally:
216
+ conn.close()
209
217
 
210
218
  return archived
@@ -23,6 +23,7 @@ from pathlib import Path
23
23
  from superlocalmemory.learning.fact_outcome_joins import (
24
24
  aggregate_reward_for_fact,
25
25
  )
26
+ from superlocalmemory.storage.write_lock import get_write_lock
26
27
 
27
28
 
28
29
  # H-12/H-P-01: single-pass JSON1 aggregation across ALL facts for a profile.
@@ -123,45 +124,54 @@ def apply_strong_memory_boost(
123
124
 
124
125
  Returns number of rows boosted.
125
126
  """
126
- conn = sqlite3.connect(str(memory_db_path), timeout=10.0)
127
- conn.execute("PRAGMA busy_timeout=2000")
128
- boosted = 0
127
+ # READ phase — no write lock needed (WAL allows concurrent reads).
128
+ conn_r = sqlite3.connect(str(memory_db_path), timeout=10.0)
129
129
  try:
130
- rows = conn.execute(
130
+ rows = conn_r.execute(
131
131
  "SELECT fact_id FROM atomic_facts WHERE profile_id=? "
132
132
  " AND (archive_status IS NULL OR archive_status='live')",
133
133
  (profile_id,),
134
134
  ).fetchall()
135
135
  if not rows:
136
+ conn_r.close()
136
137
  return 0
137
-
138
138
  # H-12/H-P-01: single JSON1 GROUP BY replaces the per-fact loop.
139
- # Fallback to per-fact helper preserves legacy behaviour on
140
- # SQLite without JSON1.
141
- stats = _bulk_fact_reward_stats(conn, profile_id)
142
- conn.execute("BEGIN IMMEDIATE")
143
- for (fid,) in rows:
144
- if stats:
145
- count, mean = stats.get(fid, _MISS)
146
- else:
147
- count, mean = aggregate_reward_for_fact(conn, profile_id, fid)
148
- if count < STRONG_BOOST_MIN_OUTCOMES:
149
- continue
150
- if mean <= STRONG_BOOST_MIN_MEAN:
151
- continue
152
- conn.execute(
153
- "UPDATE atomic_facts "
154
- "SET retrieval_prior = MIN(COALESCE(retrieval_prior, 0) + ?, ?) "
155
- "WHERE fact_id=?",
156
- (STRONG_BOOST_INCREMENT, STRONG_BOOST_CAP, fid),
157
- )
158
- boosted += 1
159
- conn.commit()
160
- except sqlite3.Error as exc:
161
- conn.rollback()
162
- logger.warning("apply_strong_memory_boost rollback: %s", exc)
139
+ stats = _bulk_fact_reward_stats(conn_r, profile_id)
163
140
  finally:
164
- conn.close()
141
+ conn_r.close()
142
+
143
+ # WRITE phase — acquire the process-level write lock BEFORE opening the
144
+ # write connection. This serialises BEGIN IMMEDIATE → commit with all
145
+ # other in-process writers (DatabaseManager, VectorStore, adapter sync),
146
+ # eliminating SQLITE_BUSY races at the SQLite WAL layer.
147
+ boosted = 0
148
+ with get_write_lock(memory_db_path):
149
+ conn = sqlite3.connect(str(memory_db_path), timeout=10.0)
150
+ conn.execute("PRAGMA busy_timeout=2000")
151
+ try:
152
+ conn.execute("BEGIN IMMEDIATE")
153
+ for (fid,) in rows:
154
+ if stats:
155
+ count, mean = stats.get(fid, _MISS)
156
+ else:
157
+ count, mean = aggregate_reward_for_fact(conn, profile_id, fid)
158
+ if count < STRONG_BOOST_MIN_OUTCOMES:
159
+ continue
160
+ if mean <= STRONG_BOOST_MIN_MEAN:
161
+ continue
162
+ conn.execute(
163
+ "UPDATE atomic_facts "
164
+ "SET retrieval_prior = MIN(COALESCE(retrieval_prior, 0) + ?, ?) "
165
+ "WHERE fact_id=?",
166
+ (STRONG_BOOST_INCREMENT, STRONG_BOOST_CAP, fid),
167
+ )
168
+ boosted += 1
169
+ conn.commit()
170
+ except sqlite3.Error as exc:
171
+ conn.rollback()
172
+ logger.warning("apply_strong_memory_boost rollback: %s", exc)
173
+ finally:
174
+ conn.close()
165
175
  return boosted
166
176
 
167
177
 
@@ -81,6 +81,7 @@ CREATE TABLE IF NOT EXISTS source_quality_repair_state (
81
81
  _MAX_FACTS_PER_OUTCOME = 100
82
82
  _MAX_SOURCES_PER_OUTCOME = 100
83
83
  _PROVENANCE_QUERY_CHUNK = 500
84
+ _SCHEMA_INIT_LOCK = threading.RLock()
84
85
 
85
86
 
86
87
  class SourceQualityRepairUnavailable(RuntimeError):
@@ -114,45 +115,47 @@ class SourceQualityScorer:
114
115
  # ------------------------------------------------------------------
115
116
 
116
117
  def _ensure_schema(self) -> None:
117
- conn = self._connect()
118
- try:
119
- # Separate scorer instances can be constructed concurrently during
120
- # first startup (background history repair + outcome settlement).
121
- # Serialize the read/ALTER sequence at SQLite's transaction
122
- # boundary so two processes cannot both observe a legacy column as
123
- # missing and race into ``duplicate column name``.
124
- conn.execute("BEGIN IMMEDIATE")
125
- conn.execute(_CREATE_TABLE)
126
- conn.execute(_CREATE_UNIQUE)
127
- conn.execute(_CREATE_OBSERVATIONS)
128
- conn.execute(_CREATE_REPAIR_STATE)
129
- repair_columns = {
130
- str(row["name"])
131
- for row in conn.execute(
132
- "PRAGMA table_info(source_quality_repair_state)"
133
- ).fetchall()
134
- }
135
- if "last_settled_at" not in repair_columns:
136
- conn.execute(
137
- "ALTER TABLE source_quality_repair_state "
138
- "ADD COLUMN last_settled_at TEXT NOT NULL DEFAULT ''"
139
- )
140
- if "last_outcome_id" not in repair_columns:
141
- conn.execute(
142
- "ALTER TABLE source_quality_repair_state "
143
- "ADD COLUMN last_outcome_id TEXT NOT NULL DEFAULT ''"
144
- )
145
- conn.commit()
146
- except Exception:
147
- conn.rollback()
148
- raise
149
- finally:
150
- conn.close()
118
+ # Serialize scorer instances in this process before asking SQLite for
119
+ # its cross-process BEGIN IMMEDIATE lease. This prevents two startup
120
+ # threads from racing the journal-mode/schema bootstrap while SQLite's
121
+ # busy timeout protects the equivalent multi-process boundary.
122
+ with _SCHEMA_INIT_LOCK:
123
+ conn = self._connect()
124
+ try:
125
+ conn.execute("BEGIN IMMEDIATE")
126
+ conn.execute(_CREATE_TABLE)
127
+ conn.execute(_CREATE_UNIQUE)
128
+ conn.execute(_CREATE_OBSERVATIONS)
129
+ conn.execute(_CREATE_REPAIR_STATE)
130
+ repair_columns = {
131
+ str(row["name"])
132
+ for row in conn.execute(
133
+ "PRAGMA table_info(source_quality_repair_state)"
134
+ ).fetchall()
135
+ }
136
+ if "last_settled_at" not in repair_columns:
137
+ conn.execute(
138
+ "ALTER TABLE source_quality_repair_state "
139
+ "ADD COLUMN last_settled_at TEXT NOT NULL DEFAULT ''"
140
+ )
141
+ if "last_outcome_id" not in repair_columns:
142
+ conn.execute(
143
+ "ALTER TABLE source_quality_repair_state "
144
+ "ADD COLUMN last_outcome_id TEXT NOT NULL DEFAULT ''"
145
+ )
146
+ conn.commit()
147
+ except Exception:
148
+ conn.rollback()
149
+ raise
150
+ finally:
151
+ conn.close()
151
152
 
152
153
  def _connect(self) -> sqlite3.Connection:
153
154
  conn = sqlite3.connect(str(self._db_path), timeout=10)
155
+ # Install the busy handler before journal negotiation. On a fresh
156
+ # database, PRAGMA journal_mode itself may contend with another scorer.
157
+ conn.execute("PRAGMA busy_timeout=10000")
154
158
  conn.execute("PRAGMA journal_mode=WAL")
155
- conn.execute("PRAGMA busy_timeout=5000")
156
159
  conn.row_factory = sqlite3.Row
157
160
  return conn
158
161