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
@@ -26,6 +26,7 @@ from pathlib import Path
26
26
  from typing import Any
27
27
 
28
28
  from superlocalmemory.infra.data_root import canonical_data_root
29
+ from superlocalmemory.storage.memory_write import memory_write
29
30
 
30
31
  logger = logging.getLogger("superlocalmemory.cloud_backup")
31
32
 
@@ -248,8 +249,7 @@ def add_destination(
248
249
 
249
250
  dest_id = _new_id()
250
251
  path = db_path or _default_db_path()
251
- conn = sqlite3.connect(str(path))
252
- try:
252
+ with memory_write(path) as conn:
253
253
  conn.execute(
254
254
  "INSERT INTO backup_destinations "
255
255
  "(id, destination_type, display_name, credentials_ref, config, "
@@ -257,32 +257,34 @@ def add_destination(
257
257
  (dest_id, destination_type, display_name, credentials_ref,
258
258
  json.dumps(config), datetime.now(UTC).isoformat()),
259
259
  )
260
- conn.commit()
261
- logger.info("Added backup destination: %s (%s)", display_name, destination_type)
262
- return dest_id
263
- finally:
264
- conn.close()
260
+ logger.info("Added backup destination: %s (%s)", display_name, destination_type)
261
+ return dest_id
265
262
 
266
263
 
267
264
  def remove_destination(dest_id: str, db_path: Path | None = None) -> bool:
268
265
  """Remove a backup destination and its credentials."""
269
266
  path = db_path or _default_db_path()
270
- conn = sqlite3.connect(str(path))
271
267
  try:
272
- row = conn.execute(
273
- "SELECT credentials_ref FROM backup_destinations WHERE id = ?",
274
- (dest_id,),
275
- ).fetchone()
276
- if row and row[0]:
277
- _delete_credential(row[0])
278
- conn.execute("DELETE FROM backup_destinations WHERE id = ?", (dest_id,))
279
- conn.commit()
268
+ # Fetch credentials_ref with a short read before taking the write lock.
269
+ import sqlite3 as _sqlite3
270
+ creds_ref = None
271
+ try:
272
+ with _sqlite3.connect(str(path)) as rconn:
273
+ row = rconn.execute(
274
+ "SELECT credentials_ref FROM backup_destinations WHERE id = ?",
275
+ (dest_id,),
276
+ ).fetchone()
277
+ creds_ref = row[0] if row else None
278
+ except Exception:
279
+ pass
280
+ if creds_ref:
281
+ _delete_credential(creds_ref)
282
+ with memory_write(path) as conn:
283
+ conn.execute("DELETE FROM backup_destinations WHERE id = ?", (dest_id,))
280
284
  return True
281
285
  except Exception as exc:
282
286
  logger.error("Failed to remove destination %s: %s", dest_id, exc)
283
287
  return False
284
- finally:
285
- conn.close()
286
288
 
287
289
 
288
290
  def update_sync_status(
@@ -293,18 +295,15 @@ def update_sync_status(
293
295
  ) -> None:
294
296
  """Update the sync status of a destination."""
295
297
  path = db_path or _default_db_path()
296
- conn = sqlite3.connect(str(path))
297
298
  try:
298
- conn.execute(
299
- "UPDATE backup_destinations SET last_sync_at = ?, "
300
- "last_sync_status = ?, last_sync_error = ? WHERE id = ?",
301
- (datetime.now(UTC).isoformat(), status, error, dest_id),
302
- )
303
- conn.commit()
299
+ with memory_write(path) as conn:
300
+ conn.execute(
301
+ "UPDATE backup_destinations SET last_sync_at = ?, "
302
+ "last_sync_status = ?, last_sync_error = ? WHERE id = ?",
303
+ (datetime.now(UTC).isoformat(), status, error, dest_id),
304
+ )
304
305
  except Exception:
305
306
  pass
306
- finally:
307
- conn.close()
308
307
 
309
308
 
310
309
  # ---------------------------------------------------------------------------
@@ -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",
@@ -29,7 +29,6 @@ import os
29
29
  import secrets
30
30
  import sqlite3
31
31
  import threading
32
- import time
33
32
  from dataclasses import dataclass, field
34
33
  from datetime import datetime, timedelta, timezone
35
34
  from pathlib import Path
@@ -262,6 +261,33 @@ class ContextualBandit:
262
261
  play_id=play_id,
263
262
  )
264
263
 
264
+ def choose_readonly(self, context: dict[str, Any]) -> BanditChoice:
265
+ """Sample an arm from a read-only snapshot without recording a play.
266
+
267
+ Recall uses this method so the established bandit weighting and
268
+ ensemble quality path remain available without turning a query into a
269
+ ``bandit_plays`` write. It deliberately bypasses the writer-oriented
270
+ thread-local connection factory: that factory configures WAL mode and
271
+ is not a physical read-only guarantee.
272
+ """
273
+ stratum = compute_stratum(context)
274
+ try:
275
+ posteriors = self._load_stratum_posteriors_readonly(stratum)
276
+ except sqlite3.Error as exc:
277
+ logger.warning(
278
+ "bandit.choose_readonly: posterior load failed stratum=%s: %s",
279
+ stratum,
280
+ exc,
281
+ )
282
+ posteriors = {}
283
+ arm_id = self._sample_best(posteriors)
284
+ return BanditChoice(
285
+ stratum=stratum,
286
+ arm_id=arm_id,
287
+ weights=dict(self._catalog[arm_id]),
288
+ play_id=None,
289
+ )
290
+
265
291
  def _sample_best(
266
292
  self,
267
293
  posteriors: dict[str, tuple[float, float]],
@@ -438,6 +464,29 @@ class ContextualBandit:
438
464
  for r in rows
439
465
  }
440
466
 
467
+ def _load_stratum_posteriors_readonly(
468
+ self,
469
+ stratum: str,
470
+ ) -> dict[str, tuple[float, float]]:
471
+ """Read posteriors through SQLite's URI read-only boundary."""
472
+ uri = f"{self._db_path.resolve().as_uri()}?mode=ro"
473
+ conn = sqlite3.connect(uri, uri=True, timeout=0.25)
474
+ try:
475
+ conn.row_factory = sqlite3.Row
476
+ conn.execute("PRAGMA query_only=ON")
477
+ conn.execute("PRAGMA busy_timeout=250")
478
+ rows = conn.execute(
479
+ "SELECT arm_id, alpha, beta FROM bandit_arms "
480
+ "WHERE profile_id = ? AND stratum = ?",
481
+ (self._profile, stratum),
482
+ ).fetchall()
483
+ finally:
484
+ conn.close()
485
+ return {
486
+ row["arm_id"]: (float(row["alpha"]), float(row["beta"]))
487
+ for row in rows
488
+ }
489
+
441
490
  def _insert_play(
442
491
  self,
443
492
  query_id: str,