superlocalmemory 3.8.2 → 3.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +3 -2
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +1 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/access/rbac.py +68 -76
  34. package/src/superlocalmemory/cli/commands.py +19 -0
  35. package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
  36. package/src/superlocalmemory/cli/main.py +30 -0
  37. package/src/superlocalmemory/cli/pending_store.py +39 -14
  38. package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
  39. package/src/superlocalmemory/core/config.py +78 -0
  40. package/src/superlocalmemory/core/consolidation_engine.py +79 -73
  41. package/src/superlocalmemory/core/engine.py +92 -11
  42. package/src/superlocalmemory/core/fact_consolidator.py +148 -30
  43. package/src/superlocalmemory/core/graph_pruner.py +436 -39
  44. package/src/superlocalmemory/core/ingestion_command.py +160 -31
  45. package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
  46. package/src/superlocalmemory/core/recall_pipeline.py +3 -0
  47. package/src/superlocalmemory/core/registry.py +5 -1
  48. package/src/superlocalmemory/core/remote_mode.py +3 -1
  49. package/src/superlocalmemory/core/scale_engine.py +41 -18
  50. package/src/superlocalmemory/core/store_pipeline.py +18 -4
  51. package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
  52. package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
  53. package/src/superlocalmemory/hooks/adapter_base.py +58 -44
  54. package/src/superlocalmemory/hooks/ide_connector.py +26 -8
  55. package/src/superlocalmemory/hooks/portable_kit.py +105 -9
  56. package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
  57. package/src/superlocalmemory/infra/auth_middleware.py +3 -1
  58. package/src/superlocalmemory/infra/cloud_backup.py +26 -27
  59. package/src/superlocalmemory/infra/event_bus.py +250 -88
  60. package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
  61. package/src/superlocalmemory/learning/entity_compiler.py +148 -132
  62. package/src/superlocalmemory/learning/memory_merge.py +97 -82
  63. package/src/superlocalmemory/learning/reward_archive.py +98 -90
  64. package/src/superlocalmemory/learning/reward_boost.py +40 -30
  65. package/src/superlocalmemory/mcp/http_transport.py +335 -3
  66. package/src/superlocalmemory/retrieval/engine.py +7 -1
  67. package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
  68. package/src/superlocalmemory/retrieval/reranker.py +98 -15
  69. package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
  70. package/src/superlocalmemory/retrieval/vector_store.py +84 -69
  71. package/src/superlocalmemory/server/loopback.py +91 -0
  72. package/src/superlocalmemory/server/origin.py +9 -4
  73. package/src/superlocalmemory/server/routes/backup.py +6 -2
  74. package/src/superlocalmemory/server/routes/behavioral.py +6 -12
  75. package/src/superlocalmemory/server/routes/compliance.py +20 -23
  76. package/src/superlocalmemory/server/routes/config_api.py +83 -0
  77. package/src/superlocalmemory/server/routes/helpers.py +24 -13
  78. package/src/superlocalmemory/server/routes/memories.py +139 -91
  79. package/src/superlocalmemory/server/routes/mesh.py +7 -2
  80. package/src/superlocalmemory/server/routes/profiles.py +20 -21
  81. package/src/superlocalmemory/server/routes/rbac.py +0 -1
  82. package/src/superlocalmemory/server/routes/tiers.py +42 -30
  83. package/src/superlocalmemory/server/routes/v3_api.py +67 -77
  84. package/src/superlocalmemory/server/unified_daemon.py +283 -39
  85. package/src/superlocalmemory/server/write_identity.py +22 -4
  86. package/src/superlocalmemory/storage/database.py +109 -19
  87. package/src/superlocalmemory/storage/deferred_writes.py +153 -0
  88. package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
  89. package/src/superlocalmemory/storage/memory_write.py +119 -0
  90. package/src/superlocalmemory/storage/migration_runner.py +7 -0
  91. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
  92. package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
  93. package/src/superlocalmemory/storage/write_lock.py +88 -0
  94. package/src/superlocalmemory/ui/js/core.js +6 -1
@@ -18,6 +18,8 @@ from typing import Any
18
18
 
19
19
  from fastapi import HTTPException, Request
20
20
 
21
+ from superlocalmemory.server.loopback import is_loopback as _is_loopback_host
22
+
21
23
 
22
24
  # H-04 (3.7.9): the test-client auth bypass must be impossible in a real daemon
23
25
  # even if SLM_TEST_ISOLATION=1 leaks into a production environment. Anchor it to
@@ -89,7 +91,7 @@ def require_write_actor(
89
91
  # missing peer address (e.g. behind a proxy that strips it) must not be
90
92
  # trusted just because an install token is presented. require_http_mutation_actor
91
93
  # already excludes "" from its loopback set; keep the two paths consistent.
92
- if is_test_client or client_host in ("127.0.0.1", "::1", "localhost"):
94
+ if is_test_client or _is_loopback_host(client_host):
93
95
  from superlocalmemory.core.engine_ingestion import local_trusted_actor_id
94
96
 
95
97
  return local_trusted_actor_id(actor_kind)
@@ -103,7 +105,15 @@ def require_write_actor(
103
105
  ).hexdigest()
104
106
  return f"api-key:{actor_kind}:{fingerprint}"
105
107
 
106
- raise HTTPException(403, detail="Authenticated write capability required")
108
+ raise HTTPException(
109
+ 403,
110
+ detail=(
111
+ "Write rejected: this origin requires an API key (X-SLM-API-Key). "
112
+ "The install token (X-Install-Token) is accepted only from loopback "
113
+ "(127.x.x.x / ::1 / localhost). If calling from a container or "
114
+ "remote host, configure SLM_API_KEY and present it as X-SLM-API-Key."
115
+ ),
116
+ )
107
117
 
108
118
 
109
119
  def require_http_mutation_actor(
@@ -134,7 +144,7 @@ def require_http_mutation_actor(
134
144
 
135
145
  client_host = request.client.host if request.client else ""
136
146
  is_test_client = client_host == "testclient" and _TEST_ISOLATION_ALLOWED
137
- loopback = client_host in ("127.0.0.1", "::1", "localhost")
147
+ loopback = _is_loopback_host(client_host)
138
148
  # H-01: the loopback trusted-actor bypass is suppressed when the operator
139
149
  # opts into SLM_REQUIRE_CREDENTIALS; in-process tests keep the bypass.
140
150
  if is_test_client or (loopback and not _REQUIRE_CREDENTIALS):
@@ -153,7 +163,15 @@ def require_http_mutation_actor(
153
163
  ).hexdigest()
154
164
  return f"mesh-secret:{fingerprint}"
155
165
 
156
- raise HTTPException(403, detail="Authenticated mutation capability required")
166
+ raise HTTPException(
167
+ 403,
168
+ detail=(
169
+ "Mutation rejected: present one of X-SLM-Daemon-Capability, "
170
+ "X-Install-Token (loopback only), or X-SLM-API-Key. "
171
+ "Uncredentialed writes are accepted only from loopback. "
172
+ "See SLM_API_KEY in the deployment guide for network access."
173
+ ),
174
+ )
157
175
 
158
176
 
159
177
  def authenticated_request_actor(
@@ -18,6 +18,7 @@ from pathlib import Path
18
18
  from types import ModuleType
19
19
  from typing import Any, Generator
20
20
 
21
+ from superlocalmemory.storage.write_lock import get_write_lock
21
22
  from superlocalmemory.storage.models import (
22
23
  AtomicFact, CanonicalEntity, ConsolidationAction, ConsolidationActionType,
23
24
  EdgeType, EntityAlias, EntityProfile, FactType, GraphEdge,
@@ -140,7 +141,17 @@ class DatabaseManager:
140
141
  def __init__(self, db_path: str | Path) -> None:
141
142
  self.db_path = Path(db_path)
142
143
  self.db_path.parent.mkdir(parents=True, exist_ok=True)
143
- self._lock = threading.Lock()
144
+ # Shared write-serialisation lock for this db_path.
145
+ # get_write_lock() returns the SAME RLock for every caller that
146
+ # passes the same resolved path, so DatabaseManager, VectorStore,
147
+ # adapter_base, consolidation, and all other in-process writers
148
+ # all share ONE lock → zero cross-connection SQLite WAL contention.
149
+ #
150
+ # RLock (re-entrant) is required: the self-heal backfill pattern
151
+ # with db._lock: # acquires write lock (count: 1→2)
152
+ # vs.upsert(...) # re-acquires same lock (count: 2→3)
153
+ # is safe because the same thread re-enters the RLock.
154
+ self._lock = get_write_lock(self.db_path)
144
155
  # Transaction connections are thread-affine in sqlite3. A manager is
145
156
  # shared across HTTP, materializer, and worker threads, so a process-
146
157
  # global connection slot lets another thread accidentally execute on
@@ -155,6 +166,20 @@ class DatabaseManager:
155
166
  conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}") # FIRST — so WAL pragma below uses configured timeout
156
167
  conn.execute("PRAGMA journal_mode=WAL")
157
168
  conn.execute("PRAGMA foreign_keys=ON")
169
+ # Fix D: synchronous=NORMAL is safe under WAL — the WAL ensures
170
+ # atomicity independently of the fsync level. Removing the
171
+ # fsync-on-every-commit penalty halves write latency under the
172
+ # 14 000+ edge-write bursts the materialiser produces per pass.
173
+ # Trade-off: on a power loss between COMMIT and WAL-checkpoint
174
+ # the last committed write to memory.db may be lost. Acceptable
175
+ # for an LLM memory system; NOT acceptable for financial records.
176
+ conn.execute("PRAGMA synchronous=NORMAL")
177
+ # Fix D: reduce WAL auto-checkpoint from the default 1000 to 400
178
+ # frames. Smaller checkpoints run more frequently and complete
179
+ # faster, preventing the WAL file growing unboundedly during
180
+ # high-ingestion bursts (which triggered checkpoint-starvation
181
+ # amplifying the lock storm).
182
+ conn.execute("PRAGMA wal_autocheckpoint=400")
158
183
  conn.commit()
159
184
  finally:
160
185
  conn.close()
@@ -231,15 +256,21 @@ class DatabaseManager:
231
256
  self._txn_state.conn = None
232
257
  conn.close()
233
258
 
234
- def execute(self, sql: str, params: tuple[Any, ...] = ()) -> list[sqlite3.Row]:
235
- """Execute SQL with automatic retry on SQLITE_BUSY.
259
+ # DML prefixes that require the single-writer lock when executed outside
260
+ # a transaction() context. Checked case-insensitively against the first
261
+ # word of the stripped SQL statement.
262
+ _DML_PREFIXES: frozenset[str] = frozenset({
263
+ "INSERT", "UPDATE", "DELETE", "REPLACE", "UPSERT",
264
+ "CREATE", "DROP", "ALTER",
265
+ })
236
266
 
237
- Uses shared conn inside transaction, else per-call with retry.
238
- """
239
- transaction_conn = getattr(self._txn_state, "conn", None)
240
- if transaction_conn is not None:
241
- return transaction_conn.execute(sql, params).fetchall()
267
+ def _execute_one(self, sql: str, params: tuple[Any, ...]) -> list[sqlite3.Row]:
268
+ """Open a per-call connection, execute, commit, close — with retry.
242
269
 
270
+ Never called when inside a transaction() context (that path uses the
271
+ context's existing connection directly). Factored out of execute() so
272
+ the RLock acquisition logic stays in one place.
273
+ """
243
274
  last_error: Exception | None = None
244
275
  for attempt in range(_MAX_RETRIES):
245
276
  conn = self._connect()
@@ -264,6 +295,42 @@ class DatabaseManager:
264
295
  logger.warning("DB operation failed after %d retries: %s", _MAX_RETRIES, last_error)
265
296
  raise last_error # type: ignore[misc]
266
297
 
298
+ def execute(self, sql: str, params: tuple[Any, ...] = ()) -> list[sqlite3.Row]:
299
+ """Execute SQL with automatic retry on SQLITE_BUSY.
300
+
301
+ Fix B — single-writer serialisation:
302
+ • Inside a transaction(): uses the existing connection directly (no
303
+ lock acquisition — the transaction() context manager already holds
304
+ _lock for the duration of the whole transaction).
305
+ • Outside a transaction(), for DML (INSERT/UPDATE/DELETE/…): acquires
306
+ _lock before opening a per-call connection. This ensures that
307
+ concurrent callers — including background workers that bypass
308
+ transaction() entirely — do not race at the SQLite WAL layer.
309
+ • Outside a transaction(), for SELECTs: no lock needed — WAL mode
310
+ allows concurrent readers without stalling writers.
311
+
312
+ ORDERING INVARIANT: the _txn_state.conn check MUST come before the
313
+ lock acquisition. Reversing the order would deadlock threads that
314
+ call execute() from inside transaction() because threading.RLock is
315
+ re-entrant per-thread but a re-entering thread inside transaction()
316
+ would still try to re-acquire here (lock is already held by the same
317
+ thread, so RLock re-enters safely — but the old threading.Lock would
318
+ have deadlocked; that is exactly why we changed to RLock).
319
+ """
320
+ # Fast path: already inside a transaction — use its connection directly.
321
+ transaction_conn = getattr(self._txn_state, "conn", None)
322
+ if transaction_conn is not None:
323
+ return transaction_conn.execute(sql, params).fetchall()
324
+
325
+ # Determine if this is a write operation that needs serialisation.
326
+ first_word = sql.strip().upper().split(None, 1)[0] if sql.strip() else ""
327
+ if first_word in self._DML_PREFIXES:
328
+ with self._lock:
329
+ return self._execute_one(sql, params)
330
+ else:
331
+ # Read-only path: concurrent reads are safe in WAL mode.
332
+ return self._execute_one(sql, params)
333
+
267
334
  def store_memory(self, record: MemoryRecord) -> str:
268
335
  """Persist a raw memory record. Returns memory_id."""
269
336
  _scope = getattr(record, 'scope', None) or 'personal'
@@ -1306,17 +1373,40 @@ class DatabaseManager:
1306
1373
  )
1307
1374
  return [dict(r) for r in rows]
1308
1375
 
1309
- def cleanup_activation_cache(self) -> int:
1310
- """Delete expired cache entries. Returns count deleted."""
1311
- before = self.execute(
1312
- "SELECT COUNT(*) AS c FROM activation_cache "
1313
- "WHERE expires_at < datetime('now')"
1314
- )
1315
- count = int(before[0]["c"]) if before else 0
1316
- self.execute(
1317
- "DELETE FROM activation_cache WHERE expires_at < datetime('now')"
1318
- )
1319
- return count
1376
+ def cleanup_activation_cache(
1377
+ self, batch_size: int = 5000, max_batches: int = 500,
1378
+ ) -> int:
1379
+ """Delete expired activation_cache rows in bounded batches.
1380
+
1381
+ Wired into MaintenanceScheduler. Historically NEITHER cleanup path was
1382
+ ever called, so activation_cache grew without bound — observed 83,518
1383
+ rows on a real DB, all expired, oldest ~3.5 months old. That bloats the
1384
+ table and its ``idx_actcache_expires`` index and slows every cache
1385
+ INSERT OR REPLACE / lookup.
1386
+
1387
+ Batched so clearing a large backlog never holds the write lock for one
1388
+ long DELETE: each batch commits and yields, letting remember/materialize
1389
+ writers interleave. ``idx_actcache_expires`` makes the predicate
1390
+ index-backed. Steady state (30-min cycle) deletes only one cycle's
1391
+ worth, so the loop exits after a single small batch.
1392
+ """
1393
+ total_deleted = 0
1394
+ for _ in range(max_batches):
1395
+ remaining = self.execute(
1396
+ "SELECT COUNT(*) AS c FROM activation_cache "
1397
+ "WHERE expires_at < datetime('now')"
1398
+ )
1399
+ n = int(remaining[0]["c"]) if remaining else 0
1400
+ if n <= 0:
1401
+ break
1402
+ self.execute(
1403
+ "DELETE FROM activation_cache WHERE cache_id IN ("
1404
+ " SELECT cache_id FROM activation_cache "
1405
+ " WHERE expires_at < datetime('now') LIMIT ?)",
1406
+ (batch_size,),
1407
+ )
1408
+ total_deleted += min(n, batch_size)
1409
+ return total_deleted
1320
1410
 
1321
1411
  def store_fact_importance(self, entry: dict) -> None:
1322
1412
  """Persist fact importance scores."""
@@ -0,0 +1,153 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com
4
+
5
+ """Best-effort, coalesced deferred writes that must NOT block a read path.
6
+
7
+ Recall must be read-only on its hot path. Historically the entity resolver
8
+ stamped ``canonical_entities.last_seen`` inline during recall (an ``UPDATE``
9
+ that takes the write lock), so recall waited behind writers — the root of the
10
+ "recall is 8 s" regression. ``last_seen`` is consumed ONLY by the dashboard
11
+ (entities / graph "last seen" columns); it never feeds recall ranking, so it
12
+ can be written a moment later with zero quality loss.
13
+
14
+ This module records such touches in memory (instant, lock-free for the caller)
15
+ and flushes them from a single background thread in small coalesced batches.
16
+ Bursts for the same entity collapse to one UPDATE per flush. Failures are
17
+ swallowed — bookkeeping must never raise into a recall/ingest caller.
18
+
19
+ This is deliberately small and self-contained; it is the seed of the wider
20
+ single-writer queue (see WRITE-QUEUE-PLAN.md).
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import queue
25
+ import threading
26
+
27
+ _FLUSH_INTERVAL_S = 2.0
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # General best-effort background writer (seed of the single-writer queue).
32
+ # For NON-ESSENTIAL bookkeeping writes that must never block a read/recall
33
+ # path (access logging, activation-cache warming, etc.). Fire-and-forget:
34
+ # jobs are dropped under extreme backpressure rather than blocking a caller.
35
+ # Substantive/durable writes (remember, materialize) do NOT use this — they
36
+ # get the durable single-writer queue (see WRITE-QUEUE-PLAN.md Stage 3).
37
+ # ---------------------------------------------------------------------------
38
+
39
+ _BG_MAXSIZE = 20000
40
+ _bg_queue: "queue.Queue" = queue.Queue(maxsize=_BG_MAXSIZE)
41
+ _bg_started = False
42
+ _bg_start_lock = threading.Lock()
43
+
44
+
45
+ def _bg_run() -> None:
46
+ while True:
47
+ fn = _bg_queue.get()
48
+ try:
49
+ fn()
50
+ except Exception:
51
+ # Best-effort: bookkeeping must never crash the writer thread.
52
+ pass
53
+ finally:
54
+ _bg_queue.task_done()
55
+
56
+
57
+ def _ensure_bg_thread() -> None:
58
+ global _bg_started
59
+ if _bg_started:
60
+ return
61
+ with _bg_start_lock:
62
+ if _bg_started:
63
+ return
64
+ threading.Thread(
65
+ target=_bg_run, name="slm-bg-writer", daemon=True
66
+ ).start()
67
+ _bg_started = True
68
+
69
+
70
+ def submit_background(fn) -> None:
71
+ """Run *fn* on the shared background writer. Fire-and-forget, best-effort.
72
+
73
+ Recall/read paths call this instead of writing inline, so they never wait
74
+ on the write lock. Under extreme backpressure the job is dropped (the
75
+ write was non-essential bookkeeping).
76
+ """
77
+ _ensure_bg_thread()
78
+ try:
79
+ _bg_queue.put_nowait(fn)
80
+ except queue.Full:
81
+ pass
82
+
83
+
84
+ class DeferredLastSeen:
85
+ """Coalescing background flusher for canonical_entities.last_seen."""
86
+
87
+ def __init__(self, db, interval_s: float = _FLUSH_INTERVAL_S) -> None:
88
+ self._db = db
89
+ self._interval = interval_s
90
+ self._pending: dict[tuple[str, str], str] = {}
91
+ self._lock = threading.Lock()
92
+ self._stop = threading.Event()
93
+ self._thread = threading.Thread(
94
+ target=self._run, name="slm-deferred-lastseen", daemon=True
95
+ )
96
+ self._thread.start()
97
+
98
+ def touch(self, entity_id: str, profile_id: str, ts: str) -> None:
99
+ """Record a last_seen update. Instant, coalesced, never blocks."""
100
+ with self._lock:
101
+ self._pending[(entity_id, profile_id)] = ts
102
+
103
+ def flush(self) -> int:
104
+ """Write all pending updates now. Returns count. Best-effort."""
105
+ with self._lock:
106
+ if not self._pending:
107
+ return 0
108
+ batch = self._pending
109
+ self._pending = {}
110
+ try:
111
+ # One transaction => one write-lock acquisition for the whole
112
+ # batch, off the recall/ingest thread.
113
+ with self._db.transaction():
114
+ for (entity_id, profile_id), ts in batch.items():
115
+ self._db.execute(
116
+ "UPDATE canonical_entities SET last_seen = ? "
117
+ "WHERE entity_id = ? AND profile_id = ?",
118
+ (ts, entity_id, profile_id),
119
+ )
120
+ return len(batch)
121
+ except Exception:
122
+ # Best-effort: drop this batch rather than raise into a caller.
123
+ return 0
124
+
125
+ def _run(self) -> None:
126
+ while not self._stop.wait(self._interval):
127
+ self.flush()
128
+
129
+ def stop(self) -> None:
130
+ self._stop.set()
131
+ self.flush()
132
+
133
+
134
+ _registry: dict[int, DeferredLastSeen] = {}
135
+ _registry_lock = threading.Lock()
136
+
137
+
138
+ def get_deferred_last_seen(db) -> DeferredLastSeen:
139
+ """Return the process-wide DeferredLastSeen flusher for *db* (lazy singleton)."""
140
+ key = id(db)
141
+ with _registry_lock:
142
+ writer = _registry.get(key)
143
+ if writer is None:
144
+ writer = DeferredLastSeen(db)
145
+ _registry[key] = writer
146
+ return writer
147
+
148
+
149
+ __all__ = [
150
+ "DeferredLastSeen",
151
+ "get_deferred_last_seen",
152
+ "submit_background",
153
+ ]
@@ -18,6 +18,8 @@ from __future__ import annotations
18
18
 
19
19
  import json
20
20
  import logging
21
+ import os as _os
22
+ import time
21
23
  from pathlib import Path
22
24
  from typing import TYPE_CHECKING, Any
23
25
 
@@ -33,6 +35,16 @@ logger = logging.getLogger(__name__)
33
35
  #: Default batch size for backfill_missing_embeddings.
34
36
  _BACKFILL_BATCH_SIZE = 50
35
37
 
38
+ #: Cooperative yield between per-fact writes in backfill_missing_embeddings.
39
+ #: After every fact's UPDATE + INSERT pair, the write-back loop releases
40
+ #: db._lock for this many seconds, giving concurrent user writes a guaranteed
41
+ #: acquisition window. Tunable via SLM_SELFHEAL_WRITE_DELAY_S; set to 0 to
42
+ #: disable (only do this on single-user dev databases with no concurrency).
43
+ #: Default 5 ms is imperceptible for humans but visible to the OS scheduler.
44
+ _SELFHEAL_WRITE_DELAY_S: float = float(
45
+ _os.environ.get("SLM_SELFHEAL_WRITE_DELAY_S", "0.005")
46
+ )
47
+
36
48
  #: Max characters embedded per fact during backfill. The embedding model
37
49
  #: (nomic-embed-text-v1.5) truncates at ~8192 tokens anyway, but a raw
38
50
  #: oversized document (observed up to 107 KB on a real DB) makes the shared
@@ -394,6 +406,13 @@ def backfill_missing_embeddings(
394
406
  (fid, pid, current_model, current_dim),
395
407
  )
396
408
  embedded += 1
409
+ # Cooperative yield: release db._lock briefly so concurrent
410
+ # user writes can acquire it between facts. Without this,
411
+ # the write-back loop holds db._lock in rapid succession
412
+ # (Python RLock has no fairness guarantee), potentially
413
+ # starving user POST /remember writes for seconds on large DBs.
414
+ if _SELFHEAL_WRITE_DELAY_S > 0:
415
+ time.sleep(_SELFHEAL_WRITE_DELAY_S)
397
416
  except Exception as exc:
398
417
  logger.warning(
399
418
  "backfill: failed to write fact %s: %s", fid[:16], exc
@@ -0,0 +1,119 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com
4
+
5
+ """Canonical short-lived WRITE / READ connections for memory.db.
6
+
7
+ Why this exists
8
+ ---------------
9
+ memory.db is a WAL SQLite database with exactly one writer allowed at any
10
+ instant. Historically many subsystems opened their own bare
11
+ ``sqlite3.connect(...)`` and wrote without any coordination, which produced
12
+ two failure modes under real multi-agent load:
13
+
14
+ 1. **In-process races** — daemon worker threads racing each other at the WAL
15
+ layer, retrying ``SQLITE_BUSY`` until they time out.
16
+ 2. **Cross-process races** — Claude Code hooks and the ``slm ingest`` CLI run
17
+ as *separate OS processes* and write memory.db directly. An in-process
18
+ ``threading`` lock cannot serialise those; only SQLite's own
19
+ ``PRAGMA busy_timeout`` makes them WAIT for the writer instead of erroring.
20
+
21
+ ``memory_write()`` closes BOTH gaps with one helper that every writer uses:
22
+
23
+ * Acquires the process-level :func:`get_write_lock` (the OUTERMOST lock — see
24
+ ``write_lock.py``) so in-process writers serialise cleanly with **no**
25
+ ``SQLITE_BUSY`` spin.
26
+ * Opens the connection with ``PRAGMA busy_timeout`` so that a *different
27
+ process* (hook / CLI) writing at the same instant WAITS rather than failing.
28
+ * Commits on success, rolls back on error, always closes — a short critical
29
+ section.
30
+
31
+ The single hard rule for callers
32
+ ---------------------------------
33
+ **Never hold this connection across a slow operation** — no embedding /
34
+ network call / large unbounded transaction inside the ``with`` block. Do the
35
+ slow work first, then open ``memory_write()`` only for the fast INSERT/UPDATE.
36
+ Holding the writer across a multi-second op is exactly what starves everyone
37
+ else (SQLITE_BUSY after retries). Batch large writes into bounded chunks that
38
+ each commit quickly.
39
+
40
+ Reads
41
+ -----
42
+ Use :func:`memory_read` for read-only access. WAL allows concurrent readers
43
+ without blocking the writer, so reads do NOT take the write lock — but they
44
+ still get ``busy_timeout`` for robustness during the brief checkpoint window.
45
+ """
46
+ from __future__ import annotations
47
+
48
+ import os
49
+ import sqlite3
50
+ from contextlib import contextmanager
51
+ from pathlib import Path
52
+ from typing import Generator
53
+
54
+ from superlocalmemory.storage.write_lock import get_write_lock
55
+
56
+
57
+ def _busy_timeout_ms() -> int:
58
+ """Busy-timeout in ms, env-overridable, matching DatabaseManager's default.
59
+
60
+ Kept in sync with ``storage/database.py::_BUSY_TIMEOUT_MS`` (env
61
+ ``SLM_DB_BUSY_TIMEOUT_MS``, default 10_000) so every memory.db connection
62
+ in the process — and in hook/CLI child processes that import this — waits
63
+ the same amount for the single writer.
64
+ """
65
+ try:
66
+ return max(0, int(os.environ.get("SLM_DB_BUSY_TIMEOUT_MS", "10000")))
67
+ except (TypeError, ValueError):
68
+ return 10000
69
+
70
+
71
+ @contextmanager
72
+ def memory_write(db_path: str | Path) -> Generator[sqlite3.Connection, None, None]:
73
+ """Yield a serialised, busy-timeout-guarded WRITE connection to memory.db.
74
+
75
+ Acquires the process write lock (outermost), opens a short-lived
76
+ connection with ``busy_timeout``, commits on success / rolls back on
77
+ error, and always closes.
78
+
79
+ HARD RULE: keep the ``with`` block short — never embed / call the network /
80
+ run an unbounded transaction while holding it.
81
+ """
82
+ ms = _busy_timeout_ms()
83
+ lock = get_write_lock(db_path)
84
+ with lock:
85
+ conn = sqlite3.connect(str(db_path), timeout=ms / 1000.0)
86
+ try:
87
+ conn.execute(f"PRAGMA busy_timeout={ms}")
88
+ conn.row_factory = sqlite3.Row
89
+ yield conn
90
+ conn.commit()
91
+ except Exception:
92
+ try:
93
+ conn.rollback()
94
+ except Exception:
95
+ pass
96
+ raise
97
+ finally:
98
+ conn.close()
99
+
100
+
101
+ @contextmanager
102
+ def memory_read(db_path: str | Path) -> Generator[sqlite3.Connection, None, None]:
103
+ """Yield a read-only-usage connection with ``busy_timeout`` (no write lock).
104
+
105
+ WAL permits concurrent readers, so this deliberately does NOT take the
106
+ write lock. ``busy_timeout`` still applies so a read issued during the
107
+ brief WAL checkpoint window waits rather than erroring.
108
+ """
109
+ ms = _busy_timeout_ms()
110
+ conn = sqlite3.connect(str(db_path), timeout=ms / 1000.0)
111
+ try:
112
+ conn.execute(f"PRAGMA busy_timeout={ms}")
113
+ conn.row_factory = sqlite3.Row
114
+ yield conn
115
+ finally:
116
+ conn.close()
117
+
118
+
119
+ __all__ = ["memory_write", "memory_read"]
@@ -122,6 +122,9 @@ from superlocalmemory.storage.migrations import (
122
122
  from superlocalmemory.storage.migrations import (
123
123
  M030_entity_explorer_indexes as _M030,
124
124
  )
125
+ from superlocalmemory.storage.migrations import (
126
+ M031_dead_letter_operations as _M031,
127
+ )
125
128
 
126
129
  # Map migration name → module (used for the optional ``verify(conn)`` hook
127
130
  # that lets the runner detect "already applied" state when an idempotent
@@ -156,6 +159,7 @@ _MODULES = {
156
159
  _M028.NAME: _M028,
157
160
  _M029.NAME: _M029,
158
161
  _M030.NAME: _M030,
162
+ _M031.NAME: _M031,
159
163
  }
160
164
 
161
165
  logger = logging.getLogger(__name__)
@@ -216,6 +220,9 @@ MIGRATIONS: list[Migration] = [
216
220
  Migration(name=_M024.NAME, db_target="memory", ddl=_M024.DDL),
217
221
  Migration(name=_M019.NAME, db_target="memory", ddl=_M019.DDL,
218
222
  dependencies=(_M018.NAME,)),
223
+ # M031 creates dead_letter_operations — standalone table, no FK to engine-
224
+ # bootstrapped tables, so it can run during apply_all (before engine init).
225
+ Migration(name=_M031.NAME, db_target="memory", ddl=_M031.DDL),
219
226
  # M006 + M011 are deliberately NOT here — see DEFERRED_MIGRATIONS below.
220
227
  ]
221
228