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
@@ -18,7 +18,6 @@ from __future__ import annotations
18
18
 
19
19
  import json
20
20
  import logging
21
- import sqlite3
22
21
  import time
23
22
  import uuid
24
23
  from datetime import datetime, timezone
@@ -50,27 +49,30 @@ class EntityCompiler:
50
49
  """Compile all entities that have new facts across all projects.
51
50
 
52
51
  Returns stats: {compiled: N, skipped: N, errors: N}
52
+
53
+ Concurrency fix (v3.8.4): each operation uses a scoped connection via
54
+ memory_read() / memory_write() so the process write lock is never held
55
+ across Ollama / network calls and no long raw connection is kept open.
53
56
  """
57
+ from superlocalmemory.storage.memory_write import memory_read
58
+
54
59
  if self._config and not getattr(self._config, 'entity_compilation_enabled', True):
55
60
  return {"compiled": 0, "skipped": 0, "errors": 0, "reason": "disabled"}
56
61
 
57
62
  stats = {"compiled": 0, "skipped": 0, "errors": 0}
58
- conn = self._connect()
59
- try:
60
- # Get all distinct projects for this profile
63
+
64
+ with memory_read(self._db_path) as conn:
61
65
  projects = conn.execute(
62
66
  "SELECT DISTINCT project_name FROM entity_profiles WHERE profile_id = ?",
63
67
  (profile_id,),
64
68
  ).fetchall()
65
69
  project_names = [r[0] for r in projects] if projects else [""]
66
70
 
67
- for project_name in project_names:
68
- result = self._compile_project(conn, profile_id, project_name)
69
- stats["compiled"] += result["compiled"]
70
- stats["skipped"] += result["skipped"]
71
- stats["errors"] += result["errors"]
72
- finally:
73
- conn.close()
71
+ for project_name in project_names:
72
+ result = self._compile_project(profile_id, project_name)
73
+ stats["compiled"] += result["compiled"]
74
+ stats["skipped"] += result["skipped"]
75
+ stats["errors"] += result["errors"]
74
76
 
75
77
  if stats["compiled"] > 0:
76
78
  logger.info("Entity compilation: %d compiled, %d skipped, %d errors",
@@ -80,57 +82,52 @@ class EntityCompiler:
80
82
  def compile_entity(self, profile_id: str, project_name: str,
81
83
  entity_id: str, entity_name: str) -> dict | None:
82
84
  """Compile a single entity. Returns compiled truth or None."""
83
- conn = self._connect()
84
- try:
85
- return self._compile_single(conn, profile_id, project_name,
86
- entity_id, entity_name)
87
- finally:
88
- conn.close()
89
-
90
- def _connect(self) -> sqlite3.Connection:
91
- conn = sqlite3.connect(self._db_path)
92
- conn.execute("PRAGMA journal_mode=WAL")
93
- conn.execute("PRAGMA busy_timeout=5000")
94
- conn.row_factory = sqlite3.Row
95
- return conn
96
-
97
- def _compile_project(self, conn: sqlite3.Connection, profile_id: str,
98
- project_name: str) -> dict:
99
- """Compile all entities needing update in a project."""
85
+ return self._compile_single(profile_id, project_name, entity_id, entity_name)
86
+
87
+ def _compile_project(self, profile_id: str, project_name: str) -> dict:
88
+ """Compile all entities needing update in a project.
89
+
90
+ Uses scoped memory_read() for the entity query so no connection is
91
+ held across entity compilation iterations.
92
+ """
93
+ from superlocalmemory.storage.memory_write import memory_read
94
+
100
95
  stats = {"compiled": 0, "skipped": 0, "errors": 0}
101
96
 
102
- # Find entities with new facts since last compilation
103
- entities = conn.execute("""
104
- SELECT DISTINCT ce.entity_id, ce.canonical_name, ce.entity_type
105
- FROM canonical_entities ce
106
- WHERE ce.profile_id = ?
107
- AND (
108
- EXISTS (
109
- SELECT 1 FROM atomic_facts af
110
- WHERE af.canonical_entities_json LIKE '%' || ce.entity_id || '%'
111
- AND af.profile_id = ?
112
- AND af.created_at > COALESCE(
113
- (SELECT last_compiled_at FROM entity_profiles
114
- WHERE entity_id = ce.entity_id
115
- AND profile_id = ?
116
- AND project_name = ?),
117
- '1970-01-01')
118
- )
119
- OR NOT EXISTS (
120
- SELECT 1 FROM entity_profiles
121
- WHERE entity_id = ce.entity_id
122
- AND profile_id = ?
123
- AND project_name = ?
124
- AND last_compiled_at IS NOT NULL
97
+ with memory_read(self._db_path) as conn:
98
+ entities = conn.execute("""
99
+ SELECT DISTINCT ce.entity_id, ce.canonical_name, ce.entity_type
100
+ FROM canonical_entities ce
101
+ WHERE ce.profile_id = ?
102
+ AND (
103
+ EXISTS (
104
+ SELECT 1 FROM atomic_facts af
105
+ WHERE af.canonical_entities_json LIKE '%' || ce.entity_id || '%'
106
+ AND af.profile_id = ?
107
+ AND af.created_at > COALESCE(
108
+ (SELECT last_compiled_at FROM entity_profiles
109
+ WHERE entity_id = ce.entity_id
110
+ AND profile_id = ?
111
+ AND project_name = ?),
112
+ '1970-01-01')
113
+ )
114
+ OR NOT EXISTS (
115
+ SELECT 1 FROM entity_profiles
116
+ WHERE entity_id = ce.entity_id
117
+ AND profile_id = ?
118
+ AND project_name = ?
119
+ AND last_compiled_at IS NOT NULL
120
+ )
125
121
  )
126
- )
127
- """, (profile_id, profile_id, profile_id, project_name,
128
- profile_id, project_name)).fetchall()
122
+ """, (profile_id, profile_id, profile_id, project_name,
123
+ profile_id, project_name)).fetchall()
124
+ # Detach rows from the read connection before closing it
125
+ entities = [dict(e) for e in entities]
129
126
 
130
127
  for entity in entities:
131
128
  try:
132
129
  result = self._compile_single(
133
- conn, profile_id, project_name,
130
+ profile_id, project_name,
134
131
  entity["entity_id"], entity["canonical_name"],
135
132
  entity_type=entity["entity_type"],
136
133
  )
@@ -145,30 +142,20 @@ class EntityCompiler:
145
142
 
146
143
  return stats
147
144
 
148
- def _compile_single(self, conn: sqlite3.Connection, profile_id: str,
145
+ def _compile_single(self, profile_id: str,
149
146
  project_name: str, entity_id: str, entity_name: str,
150
147
  entity_type: str = "unknown") -> dict | None:
151
- """Compile one entity. Returns the compiled truth dict or None."""
152
-
153
- # Gather atomic facts for this entity
154
- facts = conn.execute("""
155
- SELECT af.fact_id, af.content, af.confidence, af.created_at,
156
- fi.pagerank_score, fi.community_id
157
- FROM atomic_facts af
158
- LEFT JOIN fact_importance fi ON af.fact_id = fi.fact_id
159
- WHERE af.canonical_entities_json LIKE ? AND af.profile_id = ?
160
- ORDER BY fi.pagerank_score DESC NULLS LAST, af.confidence DESC
161
- LIMIT 50
162
- """, (f"%{entity_id}%", profile_id)).fetchall()
148
+ """Compile one entity. Returns the compiled truth dict or None.
163
149
 
164
- if not facts:
165
- return None
150
+ Concurrency fix (v3.8.4): reads use memory_read(), writes use
151
+ memory_write(). The Ollama call (Mode B) happens with NO lock held.
152
+ Lock-ordering invariant preserved: get_write_lock (outermost) →
153
+ sqlite BEGIN ... COMMIT.
154
+ """
155
+ from superlocalmemory.storage.memory_write import memory_read, memory_write
166
156
 
167
- # Compute PageRank if missing
168
- has_pagerank = any(f["pagerank_score"] is not None for f in facts)
169
- if not has_pagerank and len(facts) > 2:
170
- self._compute_pagerank(conn, [f["fact_id"] for f in facts], profile_id)
171
- # Re-fetch with scores
157
+ # ── Phase 1: read facts (no write lock) ──────────────────────────────
158
+ with memory_read(self._db_path) as conn:
172
159
  facts = conn.execute("""
173
160
  SELECT af.fact_id, af.content, af.confidence, af.created_at,
174
161
  fi.pagerank_score, fi.community_id
@@ -178,8 +165,31 @@ class EntityCompiler:
178
165
  ORDER BY fi.pagerank_score DESC NULLS LAST, af.confidence DESC
179
166
  LIMIT 50
180
167
  """, (f"%{entity_id}%", profile_id)).fetchall()
168
+ facts = [dict(f) for f in facts]
181
169
 
182
- # Generate compiled truth
170
+ if not facts:
171
+ return None
172
+
173
+ has_pagerank = any(f["pagerank_score"] is not None for f in facts)
174
+
175
+ # ── Phase 2: PageRank (short write, NO Ollama) ────────────────────────
176
+ if not has_pagerank and len(facts) > 2:
177
+ self._compute_pagerank([f["fact_id"] for f in facts], profile_id)
178
+ # Re-fetch with updated scores
179
+ with memory_read(self._db_path) as conn:
180
+ facts = conn.execute("""
181
+ SELECT af.fact_id, af.content, af.confidence, af.created_at,
182
+ fi.pagerank_score, fi.community_id
183
+ FROM atomic_facts af
184
+ LEFT JOIN fact_importance fi ON af.fact_id = fi.fact_id
185
+ WHERE af.canonical_entities_json LIKE ? AND af.profile_id = ?
186
+ ORDER BY fi.pagerank_score DESC NULLS LAST, af.confidence DESC
187
+ LIMIT 50
188
+ """, (f"%{entity_id}%", profile_id)).fetchall()
189
+ facts = [dict(f) for f in facts]
190
+
191
+ # ── Phase 3: generate compiled truth — NO write lock held ────────────
192
+ # Mode B calls Ollama (up to 30 s) — write lock MUST NOT be held here.
183
193
  if self._mode in ("b", "c") and len(facts) > 3:
184
194
  compiled = self._compile_mode_b(entity_name, facts)
185
195
  if not compiled:
@@ -187,10 +197,8 @@ class EntityCompiler:
187
197
  else:
188
198
  compiled = self._compile_mode_a(entity_name, entity_type, facts)
189
199
 
190
- # Truncate to limit
191
200
  compiled = self._truncate(compiled, _MAX_COMPILED_TRUTH_CHARS)
192
201
 
193
- # Build timeline entry
194
202
  now = datetime.now(timezone.utc).isoformat()
195
203
  timeline_entry = {
196
204
  "date": now,
@@ -199,50 +207,50 @@ class EntityCompiler:
199
207
  "mode": self._mode,
200
208
  }
201
209
 
202
- # Load existing timeline
203
- existing = conn.execute(
204
- "SELECT timeline, profile_entry_id FROM entity_profiles "
205
- "WHERE entity_id = ? AND profile_id = ? AND project_name = ?",
206
- (entity_id, profile_id, project_name),
207
- ).fetchone()
208
-
209
- timeline = []
210
- if existing and existing["timeline"]:
211
- try:
212
- timeline = json.loads(existing["timeline"])
213
- except (json.JSONDecodeError, TypeError):
214
- timeline = []
215
- timeline.append(timeline_entry)
216
- # Cap at 100 entries
217
- if len(timeline) > _MAX_TIMELINE_ENTRIES:
218
- timeline = timeline[-_MAX_TIMELINE_ENTRIES:]
219
-
220
210
  fact_ids = [f["fact_id"] for f in facts]
221
211
  avg_conf = sum(f["confidence"] or 0.5 for f in facts) / max(len(facts), 1)
222
212
 
223
- # Upsert
224
- if existing:
225
- conn.execute("""
226
- UPDATE entity_profiles SET
227
- compiled_truth = ?, timeline = ?, fact_ids_json = ?,
228
- last_compiled_at = ?, compilation_confidence = ?, last_updated = ?
229
- WHERE entity_id = ? AND profile_id = ? AND project_name = ?
230
- """, (compiled, json.dumps(timeline), json.dumps(fact_ids),
231
- now, round(avg_conf, 3), now,
232
- entity_id, profile_id, project_name))
233
- else:
234
- entry_id = str(uuid.uuid4())[:16]
235
- conn.execute("""
236
- INSERT INTO entity_profiles
237
- (profile_entry_id, entity_id, profile_id, project_name,
238
- knowledge_summary, compiled_truth, timeline, fact_ids_json,
239
- last_compiled_at, compilation_confidence, last_updated)
240
- VALUES (?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?)
241
- """, (entry_id, entity_id, profile_id, project_name,
242
- compiled, json.dumps(timeline), json.dumps(fact_ids),
243
- now, round(avg_conf, 3), now))
244
-
245
- conn.commit()
213
+ # ── Phase 4: short write — hold write lock for INSERT/UPDATE only ────
214
+ with memory_write(self._db_path) as conn:
215
+ # Re-read inside the write lock for correct timeline merge and to
216
+ # decide INSERT vs UPDATE atomically (prevents lost-update race).
217
+ existing = conn.execute(
218
+ "SELECT timeline, profile_entry_id FROM entity_profiles "
219
+ "WHERE entity_id = ? AND profile_id = ? AND project_name = ?",
220
+ (entity_id, profile_id, project_name),
221
+ ).fetchone()
222
+
223
+ timeline: list = []
224
+ if existing and existing["timeline"]:
225
+ try:
226
+ timeline = json.loads(existing["timeline"])
227
+ except (json.JSONDecodeError, TypeError):
228
+ timeline = []
229
+ timeline.append(timeline_entry)
230
+ if len(timeline) > _MAX_TIMELINE_ENTRIES:
231
+ timeline = timeline[-_MAX_TIMELINE_ENTRIES:]
232
+
233
+ if existing:
234
+ conn.execute("""
235
+ UPDATE entity_profiles SET
236
+ compiled_truth = ?, timeline = ?, fact_ids_json = ?,
237
+ last_compiled_at = ?, compilation_confidence = ?,
238
+ last_updated = ?
239
+ WHERE entity_id = ? AND profile_id = ? AND project_name = ?
240
+ """, (compiled, json.dumps(timeline), json.dumps(fact_ids),
241
+ now, round(avg_conf, 3), now,
242
+ entity_id, profile_id, project_name))
243
+ else:
244
+ entry_id = str(uuid.uuid4())[:16]
245
+ conn.execute("""
246
+ INSERT INTO entity_profiles
247
+ (profile_entry_id, entity_id, profile_id, project_name,
248
+ knowledge_summary, compiled_truth, timeline, fact_ids_json,
249
+ last_compiled_at, compilation_confidence, last_updated)
250
+ VALUES (?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?)
251
+ """, (entry_id, entity_id, profile_id, project_name,
252
+ compiled, json.dumps(timeline), json.dumps(fact_ids),
253
+ now, round(avg_conf, 3), now))
246
254
 
247
255
  return {
248
256
  "entity_name": entity_name,
@@ -332,34 +340,42 @@ class EntityCompiler:
332
340
 
333
341
  # -- Helpers --
334
342
 
335
- def _compute_pagerank(self, conn: sqlite3.Connection,
336
- fact_ids: list[str], profile_id: str) -> None:
337
- """Compute PageRank for a set of facts. Stores in fact_importance."""
343
+ def _compute_pagerank(self, fact_ids: list[str], profile_id: str) -> None:
344
+ """Compute PageRank for a set of facts and store in fact_importance.
345
+
346
+ Concurrency fix (v3.8.4): uses memory_write() for the INSERT so the
347
+ process write lock is acquired and busy_timeout is set correctly.
348
+ Pure PageRank computation (networkx) happens BEFORE the write lock.
349
+ """
350
+ from superlocalmemory.storage.memory_write import memory_write
351
+
338
352
  try:
339
353
  import networkx as nx
340
354
  G = nx.Graph()
341
355
  for fid in fact_ids:
342
356
  G.add_node(fid)
343
- # Add edges based on shared entities
344
357
  for i, fid1 in enumerate(fact_ids):
345
358
  for fid2 in fact_ids[i + 1:]:
346
- # Simple heuristic: facts about same entity are connected
347
359
  G.add_edge(fid1, fid2, weight=0.5)
348
360
 
349
361
  if len(G.nodes) < 2:
350
362
  return
351
363
 
364
+ # Compute scores in pure Python — no lock held yet.
352
365
  scores = nx.pagerank(G, alpha=0.85)
353
366
  now = datetime.now(timezone.utc).isoformat()
354
367
 
355
- for fid, score in scores.items():
356
- conn.execute("""
357
- INSERT INTO fact_importance (fact_id, profile_id, pagerank_score, computed_at)
358
- VALUES (?, ?, ?, ?)
359
- ON CONFLICT(fact_id) DO UPDATE SET pagerank_score=excluded.pagerank_score,
360
- computed_at=excluded.computed_at
361
- """, (fid, profile_id, round(score, 6), now))
362
- conn.commit()
368
+ # Short write: lock held only for the INSERT batch.
369
+ with memory_write(self._db_path) as conn:
370
+ for fid, score in scores.items():
371
+ conn.execute("""
372
+ INSERT INTO fact_importance
373
+ (fact_id, profile_id, pagerank_score, computed_at)
374
+ VALUES (?, ?, ?, ?)
375
+ ON CONFLICT(fact_id) DO UPDATE
376
+ SET pagerank_score = excluded.pagerank_score,
377
+ computed_at = excluded.computed_at
378
+ """, (fid, profile_id, round(score, 6), now))
363
379
  except ImportError:
364
380
  logger.debug("NetworkX not available — skipping PageRank")
365
381
  except Exception as exc:
@@ -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")