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
@@ -14,9 +14,13 @@ import threading
14
14
  from collections import deque
15
15
  from datetime import datetime, timedelta, timezone
16
16
  from pathlib import Path
17
- from typing import Any, Callable, Dict, List, Optional
17
+ from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
18
18
 
19
19
  from superlocalmemory.infra.data_root import state_path
20
+ from superlocalmemory.storage.write_lock import get_write_lock
21
+
22
+ if TYPE_CHECKING:
23
+ from superlocalmemory.storage.database import DatabaseManager
20
24
 
21
25
  logger = logging.getLogger("superlocalmemory.events")
22
26
 
@@ -65,15 +69,25 @@ class EventBus:
65
69
  _instances_lock = threading.Lock()
66
70
 
67
71
  @classmethod
68
- def get_instance(cls, db_path: Optional[Path] = None) -> "EventBus":
69
- """Get or create the singleton EventBus for a database path."""
72
+ def get_instance(
73
+ cls,
74
+ db_path: Optional[Path] = None,
75
+ db: "DatabaseManager | None" = None,
76
+ ) -> "EventBus":
77
+ """Get or create the singleton EventBus for a database path.
78
+
79
+ Fix C: optional ``db`` forwarded to the constructor on first creation.
80
+ On subsequent calls for the same path the existing instance is returned;
81
+ callers that need to wire a DatabaseManager after construction should
82
+ call ``instance.set_db(db)`` separately.
83
+ """
70
84
  if db_path is None:
71
85
  db_path = state_path("memory.db")
72
86
 
73
87
  key = str(db_path)
74
88
  with cls._instances_lock:
75
89
  if key not in cls._instances:
76
- cls._instances[key] = cls(db_path)
90
+ cls._instances[key] = cls(db_path, db=db)
77
91
  return cls._instances[key]
78
92
 
79
93
  @classmethod
@@ -87,9 +101,31 @@ class EventBus:
87
101
  if key in cls._instances:
88
102
  del cls._instances[key]
89
103
 
90
- def __init__(self, db_path: Path) -> None:
91
- """Initialize EventBus. Prefer get_instance() over direct construction."""
104
+ def __init__(
105
+ self,
106
+ db_path: Path,
107
+ db: "DatabaseManager | None" = None,
108
+ ) -> None:
109
+ """Initialize EventBus. Prefer get_instance() over direct construction.
110
+
111
+ Fix C: optional ``db`` parameter. When provided, all write paths
112
+ (INSERT / UPDATE / DELETE) are routed through DatabaseManager.execute()
113
+ which acquires the process-level RLock before opening a connection.
114
+ This eliminates the EventBus write-storm contribution to the memory.db
115
+ lock contention: instead of 5 independent bare sqlite3.connect() calls
116
+ racing the materialiser, all writes queue through the single writer.
117
+
118
+ When ``db`` is None the fallback path is used — direct sqlite3.connect()
119
+ with ``PRAGMA busy_timeout=10000`` on every connection. This is safe
120
+ for standalone / testing use but is NOT process-lock-serialised.
121
+ """
92
122
  self.db_path = Path(db_path)
123
+ self._db: "DatabaseManager | None" = db
124
+ if db is None:
125
+ logger.debug(
126
+ "EventBus operating in standalone mode — no DatabaseManager "
127
+ "provided; concurrency is unmanaged (busy_timeout=10000 applied)"
128
+ )
93
129
  self._buffer: deque = deque(maxlen=EVENT_BUFFER_SIZE)
94
130
  self._buffer_lock = threading.Lock()
95
131
  self._event_counter = 0
@@ -101,6 +137,15 @@ class EventBus:
101
137
  self._init_schema()
102
138
  logger.info("EventBus initialized: db=%s", self.db_path)
103
139
 
140
+ def set_db(self, db: "DatabaseManager") -> None:
141
+ """Wire EventBus to an already-initialised DatabaseManager.
142
+
143
+ For daemon start-up sequences where EventBus is constructed before
144
+ DatabaseManager is ready. Call once from the daemon after both
145
+ are initialised. Subsequent write calls will use ``db``.
146
+ """
147
+ self._db = db
148
+
104
149
  def _init_schema(self) -> None:
105
150
  """Create the memory_events table if it does not exist.
106
151
 
@@ -108,38 +153,46 @@ class EventBus:
108
153
  dashboard viewing profile A never sees profile B's events. This table is
109
154
  store-owned (created here, not by the migration runner), so the store
110
155
  owns its upgrade. Existing rows backfill to the 'default' profile.
156
+
157
+ Uses a direct connection here (before DatabaseManager may be available)
158
+ with busy_timeout=10000 for robustness during daemon start-up.
111
159
  """
112
- conn = sqlite3.connect(str(self.db_path))
113
- try:
114
- cur = conn.cursor()
115
- cur.execute("""
116
- CREATE TABLE IF NOT EXISTS memory_events (
117
- id INTEGER PRIMARY KEY AUTOINCREMENT,
118
- profile_id TEXT NOT NULL DEFAULT 'default',
119
- event_type TEXT NOT NULL,
120
- memory_id INTEGER,
121
- source_agent TEXT DEFAULT 'user',
122
- source_protocol TEXT DEFAULT 'internal',
123
- payload TEXT,
124
- importance INTEGER DEFAULT 5,
125
- tier TEXT DEFAULT 'hot',
126
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
127
- )
128
- """)
129
- existing = {r[1] for r in cur.execute(
130
- "PRAGMA table_info(memory_events)").fetchall()}
131
- if "profile_id" not in existing:
132
- cur.execute(
133
- "ALTER TABLE memory_events "
134
- "ADD COLUMN profile_id TEXT NOT NULL DEFAULT 'default'"
135
- )
136
- cur.execute("CREATE INDEX IF NOT EXISTS idx_events_type ON memory_events(event_type)")
137
- cur.execute("CREATE INDEX IF NOT EXISTS idx_events_created ON memory_events(created_at)")
138
- cur.execute("CREATE INDEX IF NOT EXISTS idx_events_tier ON memory_events(tier)")
139
- cur.execute("CREATE INDEX IF NOT EXISTS idx_events_profile ON memory_events(profile_id, id)")
140
- conn.commit()
141
- finally:
142
- conn.close()
160
+ # Startup DDL is a memory.db write — serialise it with the process
161
+ # write lock so a bus created concurrently with active writers cannot
162
+ # race the schema create/migrate at the WAL layer.
163
+ with get_write_lock(self.db_path):
164
+ conn = sqlite3.connect(str(self.db_path))
165
+ conn.execute("PRAGMA busy_timeout=10000")
166
+ try:
167
+ cur = conn.cursor()
168
+ cur.execute("""
169
+ CREATE TABLE IF NOT EXISTS memory_events (
170
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
171
+ profile_id TEXT NOT NULL DEFAULT 'default',
172
+ event_type TEXT NOT NULL,
173
+ memory_id INTEGER,
174
+ source_agent TEXT DEFAULT 'user',
175
+ source_protocol TEXT DEFAULT 'internal',
176
+ payload TEXT,
177
+ importance INTEGER DEFAULT 5,
178
+ tier TEXT DEFAULT 'hot',
179
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
180
+ )
181
+ """)
182
+ existing = {r[1] for r in cur.execute(
183
+ "PRAGMA table_info(memory_events)").fetchall()}
184
+ if "profile_id" not in existing:
185
+ cur.execute(
186
+ "ALTER TABLE memory_events "
187
+ "ADD COLUMN profile_id TEXT NOT NULL DEFAULT 'default'"
188
+ )
189
+ cur.execute("CREATE INDEX IF NOT EXISTS idx_events_type ON memory_events(event_type)")
190
+ cur.execute("CREATE INDEX IF NOT EXISTS idx_events_created ON memory_events(created_at)")
191
+ cur.execute("CREATE INDEX IF NOT EXISTS idx_events_tier ON memory_events(tier)")
192
+ cur.execute("CREATE INDEX IF NOT EXISTS idx_events_profile ON memory_events(profile_id, id)")
193
+ conn.commit()
194
+ finally:
195
+ conn.close()
143
196
 
144
197
  @staticmethod
145
198
  def _resolve_profile(profile_id: Optional[str]) -> str:
@@ -246,26 +299,60 @@ class EventBus:
246
299
  publish = emit
247
300
 
248
301
  def _persist_event(self, event: dict) -> Optional[int]:
249
- """Persist event to the memory_events table. Returns row id or None."""
302
+ """Persist event to the memory_events table. Returns row id or None.
303
+
304
+ Fix C: when a DatabaseManager is available, routes the INSERT through
305
+ db.execute() (which acquires the process-level RLock for the write).
306
+ The fallback path (no db) uses a direct connection with
307
+ busy_timeout=10000 for resilience against short lock holds.
308
+
309
+ Note: db.execute() returns list[sqlite3.Row] — for an INSERT the
310
+ return value is empty. The ROWID of the inserted row is retrieved
311
+ with a follow-up SELECT last_insert_rowid() on the same connection.
312
+ Because db.execute() is a per-call connect/commit/close, the rowid
313
+ is obtained inside the same logical operation.
314
+ """
315
+ sql = (
316
+ "INSERT INTO memory_events (profile_id, event_type, memory_id,"
317
+ " source_agent, source_protocol, payload, importance, tier,"
318
+ " created_at)"
319
+ " VALUES (?, ?, ?, ?, ?, ?, ?, 'hot', ?)"
320
+ )
321
+ params = (
322
+ event.get("profile_id", "default"),
323
+ event["event_type"],
324
+ event.get("memory_id"),
325
+ event["source_agent"],
326
+ event["source_protocol"],
327
+ json.dumps(event["payload"]),
328
+ event["importance"],
329
+ event["timestamp"],
330
+ )
331
+
250
332
  try:
251
- conn = sqlite3.connect(str(self.db_path))
252
- try:
253
- cur = conn.cursor()
254
- cur.execute(
255
- "INSERT INTO memory_events (profile_id, event_type, memory_id,"
256
- " source_agent, source_protocol, payload, importance, tier,"
257
- " created_at)"
258
- " VALUES (?, ?, ?, ?, ?, ?, ?, 'hot', ?)",
259
- (event.get("profile_id", "default"), event["event_type"],
260
- event.get("memory_id"),
261
- event["source_agent"], event["source_protocol"],
262
- json.dumps(event["payload"]), event["importance"],
263
- event["timestamp"]),
264
- )
265
- conn.commit()
266
- return cur.lastrowid
267
- finally:
268
- conn.close()
333
+ if self._db is not None:
334
+ # Serialised path: routes through DatabaseManager's RLock.
335
+ # Use a transaction() context to get the lastrowid.
336
+ with self._db.transaction():
337
+ self._db.execute(sql, params)
338
+ rows = self._db.execute("SELECT last_insert_rowid() AS id")
339
+ return int(rows[0]["id"]) if rows else None
340
+ else:
341
+ # Fallback: acquire the process write lock BEFORE opening the
342
+ # connection so this INSERT is serialised with every other
343
+ # memory.db writer even when no DatabaseManager was wired in
344
+ # (daemon/route/MCP callers construct via get_instance(path)).
345
+ # The write is a single tiny row — the lock is held for <1ms.
346
+ with get_write_lock(self.db_path):
347
+ conn = sqlite3.connect(str(self.db_path))
348
+ conn.execute("PRAGMA busy_timeout=10000")
349
+ try:
350
+ cur = conn.cursor()
351
+ cur.execute(sql, params)
352
+ conn.commit()
353
+ return cur.lastrowid
354
+ finally:
355
+ conn.close()
269
356
  except Exception as exc:
270
357
  logger.error("Failed to persist event: %s", exc)
271
358
  return None
@@ -318,6 +405,7 @@ class EventBus:
318
405
 
319
406
  try:
320
407
  conn = sqlite3.connect(str(self.db_path))
408
+ conn.execute("PRAGMA busy_timeout=10000")
321
409
  try:
322
410
  cur = conn.cursor()
323
411
 
@@ -379,6 +467,7 @@ class EventBus:
379
467
  scope = self._resolve_profile(profile_id)
380
468
  try:
381
469
  conn = sqlite3.connect(str(self.db_path))
470
+ conn.execute("PRAGMA busy_timeout=10000")
382
471
  try:
383
472
  cur = conn.cursor()
384
473
 
@@ -427,42 +516,115 @@ class EventBus:
427
516
  surface. Tenant isolation of event CONTENT is enforced on read via the
428
517
  profile_id filter; this sweep only demotes/expires old rows by age.
429
518
  """
430
- try:
431
- conn = sqlite3.connect(str(self.db_path))
432
- try:
433
- cur = conn.cursor()
434
- now = datetime.now()
435
- stats = {"hot_to_warm": 0, "warm_to_cold": 0, "archived": 0}
436
-
437
- # Hot -> Warm: older than hot_hours, importance < 5
438
- warm_cutoff = (now - timedelta(hours=hot_hours)).isoformat()
439
- cur.execute(
440
- "UPDATE memory_events SET tier = 'warm' "
441
- "WHERE tier = 'hot' AND created_at < ? AND importance < 5",
442
- (warm_cutoff,),
443
- )
444
- stats["hot_to_warm"] = cur.rowcount
519
+ now = datetime.now()
520
+ warm_cutoff = (now - timedelta(hours=hot_hours)).isoformat()
521
+ cold_cutoff = (now - timedelta(hours=warm_hours)).isoformat()
522
+ archive_cutoff = (now - timedelta(hours=cold_hours)).isoformat()
523
+ stats = {"hot_to_warm": 0, "warm_to_cold": 0, "archived": 0}
445
524
 
446
- # Warm -> Cold: delete warm events older than warm_hours
447
- cold_cutoff = (now - timedelta(hours=warm_hours)).isoformat()
448
- cur.execute(
449
- "DELETE FROM memory_events "
450
- "WHERE tier = 'warm' AND created_at < ?",
451
- (cold_cutoff,),
452
- )
453
- stats["warm_to_cold"] = cur.rowcount
525
+ _PRUNE_BATCH = 5000 # max rows per transaction keeps lock time bounded
454
526
 
455
- # Archive: delete everything older than cold_hours
456
- archive_cutoff = (now - timedelta(hours=cold_hours)).isoformat()
457
- cur.execute(
458
- "DELETE FROM memory_events WHERE created_at < ?",
459
- (archive_cutoff,),
527
+ if self._db is not None:
528
+ # Fix C: route tier-management writes through DatabaseManager.
529
+ # Use separate short transactions per batch to avoid one long hold.
530
+ try:
531
+ # Hot → warm (UPDATE, bounded loop)
532
+ while True:
533
+ with self._db.transaction():
534
+ self._db.execute(
535
+ "UPDATE memory_events SET tier = 'warm' "
536
+ "WHERE rowid IN ("
537
+ " SELECT rowid FROM memory_events "
538
+ " WHERE tier = 'hot' AND created_at < ? AND importance < 5 "
539
+ " LIMIT ?"
540
+ ")",
541
+ (warm_cutoff, _PRUNE_BATCH),
542
+ )
543
+ rows = self._db.execute("SELECT changes()")
544
+ changed = rows[0][0] if rows else 0
545
+ stats["hot_to_warm"] += changed
546
+ if changed < _PRUNE_BATCH:
547
+ break
548
+
549
+ # Warm → cold (DELETE, bounded loop)
550
+ while True:
551
+ with self._db.transaction():
552
+ self._db.execute(
553
+ "DELETE FROM memory_events "
554
+ "WHERE rowid IN ("
555
+ " SELECT rowid FROM memory_events "
556
+ " WHERE tier = 'warm' AND created_at < ? "
557
+ " LIMIT ?"
558
+ ")",
559
+ (cold_cutoff, _PRUNE_BATCH),
560
+ )
561
+ rows = self._db.execute("SELECT changes()")
562
+ changed = rows[0][0] if rows else 0
563
+ stats["warm_to_cold"] += changed
564
+ if changed < _PRUNE_BATCH:
565
+ break
566
+
567
+ # Archive prune (DELETE, bounded loop)
568
+ while True:
569
+ with self._db.transaction():
570
+ self._db.execute(
571
+ "DELETE FROM memory_events "
572
+ "WHERE rowid IN ("
573
+ " SELECT rowid FROM memory_events "
574
+ " WHERE created_at < ? "
575
+ " LIMIT ?"
576
+ ")",
577
+ (archive_cutoff, _PRUNE_BATCH),
578
+ )
579
+ rows = self._db.execute("SELECT changes()")
580
+ changed = rows[0][0] if rows else 0
581
+ stats["archived"] += changed
582
+ if changed < _PRUNE_BATCH:
583
+ break
584
+
585
+ logger.info(
586
+ "Prune complete (via db): hot->warm=%d warm->cold=%d archived=%d",
587
+ stats["hot_to_warm"], stats["warm_to_cold"], stats["archived"],
460
588
  )
461
- stats["archived"] = cur.rowcount
589
+ return stats
590
+ except Exception as exc:
591
+ logger.error("Event pruning failed (db path): %s", exc)
592
+ return {"error": str(exc)}
462
593
 
463
- conn.commit()
464
- finally:
465
- conn.close()
594
+ # Fallback: acquire the process write lock so this maintenance prune
595
+ # is serialised with all other memory.db writers even when no
596
+ # DatabaseManager was wired in. memory_events is a small bounded table
597
+ # (retention-capped) so the lock is held only briefly.
598
+ try:
599
+ with get_write_lock(self.db_path):
600
+ conn = sqlite3.connect(str(self.db_path))
601
+ conn.execute("PRAGMA busy_timeout=10000")
602
+ try:
603
+ cur = conn.cursor()
604
+
605
+ cur.execute(
606
+ "UPDATE memory_events SET tier = 'warm' "
607
+ "WHERE tier = 'hot' AND created_at < ? AND importance < 5",
608
+ (warm_cutoff,),
609
+ )
610
+ stats["hot_to_warm"] = cur.rowcount
611
+
612
+ cur.execute(
613
+ "DELETE FROM memory_events "
614
+ "WHERE tier = 'warm' AND created_at < ?",
615
+ (cold_cutoff,),
616
+ )
617
+ stats["warm_to_cold"] = cur.rowcount
618
+
619
+ cur.execute(
620
+ "DELETE FROM memory_events WHERE created_at < ?",
621
+ (archive_cutoff,),
622
+ )
623
+ stats["archived"] = cur.rowcount
624
+
625
+ conn.commit()
626
+ finally:
627
+ conn.close()
466
628
 
467
629
  logger.info(
468
630
  "Prune complete: hot->warm=%d warm->cold=%d archived=%d",
@@ -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