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.
- package/CHANGELOG.md +42 -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 +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +68 -76
- package/src/superlocalmemory/cli/commands.py +19 -0
- 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/config.py +78 -0
- package/src/superlocalmemory/core/consolidation_engine.py +79 -73
- package/src/superlocalmemory/core/engine.py +92 -11
- 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 +160 -31
- package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
- package/src/superlocalmemory/core/recall_pipeline.py +3 -0
- package/src/superlocalmemory/core/registry.py +5 -1
- 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/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/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/mcp/http_transport.py +335 -3
- package/src/superlocalmemory/retrieval/engine.py +7 -1
- package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
- package/src/superlocalmemory/retrieval/reranker.py +98 -15
- package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
- package/src/superlocalmemory/retrieval/vector_store.py +84 -69
- package/src/superlocalmemory/server/loopback.py +91 -0
- package/src/superlocalmemory/server/origin.py +9 -4
- package/src/superlocalmemory/server/routes/backup.py +6 -2
- package/src/superlocalmemory/server/routes/behavioral.py +6 -12
- package/src/superlocalmemory/server/routes/compliance.py +20 -23
- package/src/superlocalmemory/server/routes/config_api.py +83 -0
- package/src/superlocalmemory/server/routes/helpers.py +24 -13
- package/src/superlocalmemory/server/routes/memories.py +67 -68
- 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 +42 -30
- package/src/superlocalmemory/server/routes/v3_api.py +67 -77
- package/src/superlocalmemory/server/unified_daemon.py +200 -31
- package/src/superlocalmemory/server/write_identity.py +22 -4
- package/src/superlocalmemory/storage/database.py +109 -19
- package/src/superlocalmemory/storage/deferred_writes.py +153 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
- package/src/superlocalmemory/storage/memory_write.py +119 -0
- package/src/superlocalmemory/storage/migration_runner.py +7 -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/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(
|
|
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",
|
|
@@ -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
|
-
|
|
119
|
+
with _ga_write_lock:
|
|
120
|
+
cursor = self._conn.execute(sql, params)
|
|
121
|
+
self._conn.commit()
|
|
115
122
|
return []
|
|
116
|
-
return
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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 =
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|