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.
- package/CHANGELOG.md +76 -0
- package/README.md +3 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +9 -4
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +68 -76
- package/src/superlocalmemory/cli/commands.py +158 -404
- package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
- package/src/superlocalmemory/cli/main.py +30 -0
- package/src/superlocalmemory/cli/pending_store.py +39 -14
- package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
- package/src/superlocalmemory/core/component_registry.py +4 -2
- package/src/superlocalmemory/core/config.py +78 -0
- package/src/superlocalmemory/core/consolidation_engine.py +79 -73
- package/src/superlocalmemory/core/embeddings.py +33 -6
- package/src/superlocalmemory/core/engine.py +186 -60
- package/src/superlocalmemory/core/engine_ingestion.py +150 -63
- package/src/superlocalmemory/core/fact_consolidator.py +148 -30
- package/src/superlocalmemory/core/graph_pruner.py +436 -39
- package/src/superlocalmemory/core/ingestion_command.py +273 -32
- package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
- package/src/superlocalmemory/core/mutations.py +32 -10
- package/src/superlocalmemory/core/recall_pipeline.py +111 -74
- package/src/superlocalmemory/core/registry.py +5 -1
- package/src/superlocalmemory/core/remember_admission.py +152 -0
- package/src/superlocalmemory/core/remember_runtime.py +712 -0
- package/src/superlocalmemory/core/remote_mode.py +3 -1
- package/src/superlocalmemory/core/scale_engine.py +41 -18
- package/src/superlocalmemory/core/store_pipeline.py +18 -4
- package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
- package/src/superlocalmemory/graph/cozo_backend.py +5 -5
- package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
- package/src/superlocalmemory/hooks/adapter_base.py +58 -44
- package/src/superlocalmemory/hooks/ide_connector.py +26 -8
- package/src/superlocalmemory/hooks/portable_kit.py +105 -9
- package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
- package/src/superlocalmemory/infra/auth_middleware.py +3 -1
- package/src/superlocalmemory/infra/cloud_backup.py +26 -27
- package/src/superlocalmemory/infra/event_bus.py +250 -88
- package/src/superlocalmemory/learning/bandit.py +50 -1
- package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
- package/src/superlocalmemory/learning/entity_compiler.py +148 -132
- package/src/superlocalmemory/learning/memory_merge.py +97 -82
- package/src/superlocalmemory/learning/reward_archive.py +98 -90
- package/src/superlocalmemory/learning/reward_boost.py +40 -30
- package/src/superlocalmemory/learning/source_quality.py +38 -35
- package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
- package/src/superlocalmemory/mcp/http_transport.py +335 -3
- package/src/superlocalmemory/mcp/tools_active.py +4 -41
- package/src/superlocalmemory/mcp/tools_core.py +26 -87
- package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
- package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
- package/src/superlocalmemory/retrieval/engine.py +15 -4
- package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
- package/src/superlocalmemory/retrieval/reranker.py +130 -22
- package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
- package/src/superlocalmemory/retrieval/vector_store.py +84 -69
- package/src/superlocalmemory/server/loopback.py +85 -0
- package/src/superlocalmemory/server/origin.py +9 -4
- package/src/superlocalmemory/server/profile_runtime.py +14 -0
- package/src/superlocalmemory/server/routes/abstraction.py +2 -4
- package/src/superlocalmemory/server/routes/agents.py +3 -5
- package/src/superlocalmemory/server/routes/backup.py +6 -2
- package/src/superlocalmemory/server/routes/behavioral.py +11 -25
- package/src/superlocalmemory/server/routes/brain.py +6 -9
- package/src/superlocalmemory/server/routes/compliance.py +20 -23
- package/src/superlocalmemory/server/routes/config_api.py +83 -0
- package/src/superlocalmemory/server/routes/entity.py +3 -7
- package/src/superlocalmemory/server/routes/evolution.py +3 -5
- package/src/superlocalmemory/server/routes/helpers.py +57 -25
- package/src/superlocalmemory/server/routes/insights.py +2 -4
- package/src/superlocalmemory/server/routes/learning.py +2 -5
- package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
- package/src/superlocalmemory/server/routes/memories.py +119 -98
- package/src/superlocalmemory/server/routes/mesh.py +7 -2
- package/src/superlocalmemory/server/routes/profiles.py +20 -21
- package/src/superlocalmemory/server/routes/rbac.py +0 -1
- package/src/superlocalmemory/server/routes/tiers.py +28 -35
- package/src/superlocalmemory/server/routes/timeline.py +2 -4
- package/src/superlocalmemory/server/routes/v3_api.py +85 -93
- package/src/superlocalmemory/server/unified_daemon.py +400 -140
- package/src/superlocalmemory/server/write_identity.py +22 -4
- package/src/superlocalmemory/storage/admission_codec.py +119 -0
- package/src/superlocalmemory/storage/admission_journal.py +728 -0
- package/src/superlocalmemory/storage/database.py +168 -19
- package/src/superlocalmemory/storage/deferred_writes.py +209 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
- package/src/superlocalmemory/storage/memory_write.py +115 -0
- package/src/superlocalmemory/storage/migration_runner.py +44 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
- package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
- package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
- package/src/superlocalmemory/storage/read_connection.py +115 -0
- package/src/superlocalmemory/storage/write_coordinator.py +756 -0
- package/src/superlocalmemory/storage/write_lock.py +88 -0
- package/src/superlocalmemory/ui/index.html +1 -1
- package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
- 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
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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(
|
|
69
|
-
|
|
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__(
|
|
91
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
"
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
)
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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,
|