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
@@ -32,6 +32,8 @@ import sqlite3
32
32
  from datetime import datetime, timezone
33
33
  from pathlib import Path
34
34
 
35
+ from superlocalmemory.storage.write_lock import get_write_lock
36
+
35
37
  logger = logging.getLogger(__name__)
36
38
 
37
39
  __all__ = ("ConsolidationWorker",)
@@ -100,6 +102,10 @@ class ConsolidationWorker:
100
102
  conn_ga.execute("PRAGMA busy_timeout=5000")
101
103
  conn_ga.row_factory = sqlite3.Row
102
104
 
105
+ # Wrap _DBProxy.execute writes in the process-level write lock.
106
+ # Reads pass through without the lock (WAL-safe).
107
+ _ga_write_lock = get_write_lock(self._memory_db)
108
+
103
109
  class _DBProxy:
104
110
  """Minimal DB proxy for GraphAnalyzer compatibility."""
105
111
 
@@ -107,13 +113,14 @@ class ConsolidationWorker:
107
113
  self._conn = connection
108
114
 
109
115
  def execute(self, sql: str, params: tuple = ()) -> list:
110
- cursor = self._conn.execute(sql, params)
111
116
  if sql.strip().upper().startswith(
112
117
  ("INSERT", "UPDATE", "DELETE", "ALTER", "CREATE"),
113
118
  ):
114
- self._conn.commit()
119
+ with _ga_write_lock:
120
+ cursor = self._conn.execute(sql, params)
121
+ self._conn.commit()
115
122
  return []
116
- return cursor.fetchall()
123
+ return self._conn.execute(sql, params).fetchall()
117
124
 
118
125
  ga = GraphAnalyzer(_DBProxy(conn_ga))
119
126
  if not dry_run:
@@ -238,15 +245,17 @@ class ConsolidationWorker:
238
245
  os.environ.get("SLM_LEGACY_DEDUP_SCAN_CAP", "100000")
239
246
  )
240
247
  try:
241
- conn = sqlite3.connect(self._memory_db, timeout=10)
242
- conn.execute("PRAGMA busy_timeout=5000")
243
- conn.row_factory = sqlite3.Row
248
+ # READ phase — no write lock needed (WAL allows concurrent reads).
249
+ conn_r = sqlite3.connect(self._memory_db, timeout=10)
250
+ conn_r.execute("PRAGMA busy_timeout=5000")
251
+ conn_r.row_factory = sqlite3.Row
244
252
 
245
- rows = conn.execute(
253
+ rows = conn_r.execute(
246
254
  "SELECT fact_id, content FROM atomic_facts "
247
255
  "WHERE profile_id = ? ORDER BY created_at LIMIT ?",
248
256
  (profile_id, _LEGACY_DEDUP_SCAN_CAP),
249
257
  ).fetchall()
258
+ conn_r.close()
250
259
 
251
260
  seen_prefixes: dict[str, str] = {}
252
261
  duplicates = []
@@ -260,16 +269,24 @@ class ConsolidationWorker:
260
269
  seen_prefixes[prefix] = d["fact_id"]
261
270
 
262
271
  if duplicates and not dry_run:
263
- for fid in duplicates:
264
- conn.execute(
265
- "UPDATE atomic_facts "
266
- "SET confidence = MAX(0.1, confidence * 0.5) "
267
- "WHERE fact_id = ?",
268
- (fid,),
269
- )
270
- conn.commit()
272
+ # WRITE phase — acquire the process-level write lock before
273
+ # opening the sqlite3 connection, so this UPDATE is serialised
274
+ # with all other in-process writers (DatabaseManager,
275
+ # VectorStore, adapter sync) and cannot cause SQLITE_BUSY.
276
+ with get_write_lock(self._memory_db):
277
+ conn_w = sqlite3.connect(self._memory_db, timeout=10)
278
+ try:
279
+ for fid in duplicates:
280
+ conn_w.execute(
281
+ "UPDATE atomic_facts "
282
+ "SET confidence = MAX(0.1, confidence * 0.5) "
283
+ "WHERE fact_id = ?",
284
+ (fid,),
285
+ )
286
+ conn_w.commit()
287
+ finally:
288
+ conn_w.close()
271
289
 
272
- conn.close()
273
290
  return len(duplicates)
274
291
  except Exception:
275
292
  return 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: