superlocalmemory 3.4.49 → 3.4.52
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 +59 -0
- package/README.md +43 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +7 -2
- package/src/superlocalmemory/cli/main.py +10 -0
- package/src/superlocalmemory/core/backend_orchestrator.py +365 -0
- package/src/superlocalmemory/core/ollama_embedder.py +14 -4
- package/src/superlocalmemory/core/pruning_engine.py +216 -0
- package/src/superlocalmemory/core/recall_pipeline.py +26 -2
- package/src/superlocalmemory/core/store_pipeline.py +21 -0
- package/src/superlocalmemory/core/tier_manager.py +124 -0
- package/src/superlocalmemory/graph/__init__.py +9 -0
- package/src/superlocalmemory/graph/cozo_backend.py +527 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +1 -1
- package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
- package/src/superlocalmemory/mcp/tools_active.py +141 -1
- package/src/superlocalmemory/retrieval/engine.py +15 -3
- package/src/superlocalmemory/retrieval/entity_channel.py +50 -1
- package/src/superlocalmemory/retrieval/reranker.py +15 -0
- package/src/superlocalmemory/retrieval/spreading_activation.py +5 -2
- package/src/superlocalmemory/server/unified_daemon.py +73 -5
- package/src/superlocalmemory/storage/migration_runner.py +3 -0
- package/src/superlocalmemory/storage/migrations/M014_v345_scale_ready.py +45 -0
- package/src/superlocalmemory/storage/schema_v345.py +109 -0
- package/src/superlocalmemory/vector/__init__.py +9 -0
- package/src/superlocalmemory/vector/lancedb_backend.py +299 -0
- package/src/superlocalmemory.egg-info/PKG-INFO +44 -2
- package/src/superlocalmemory.egg-info/SOURCES.txt +8 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""SuperLocalMemory v3.4.5 — Graph Pruning Engine.
|
|
6
|
+
|
|
7
|
+
Reduces graph edge count without losing meaningful connections.
|
|
8
|
+
Strategies:
|
|
9
|
+
1. Chain collapse: A→B→C → remove B→C if A→B exists with higher weight
|
|
10
|
+
2. Garbage entity removal: remove edges connected to garbage entities
|
|
11
|
+
3. Low-activity edge decay: edges between entities not accessed in 90+ days
|
|
12
|
+
|
|
13
|
+
CRITICAL RULE: NEVER delete atomic_facts. Only prune graph_edges.
|
|
14
|
+
Edges are derivable from facts — they can be regenerated.
|
|
15
|
+
Facts are the permanent record.
|
|
16
|
+
|
|
17
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
from datetime import datetime, timedelta, UTC
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from superlocalmemory.storage.database import DatabaseManager
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# Thresholds
|
|
32
|
+
LOW_ACTIVITY_DAYS = 90
|
|
33
|
+
CHAIN_COLLAPSE_MIN_WEIGHT_RATIO = 0.8
|
|
34
|
+
BATCH_SIZE = 500
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Public API
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
def prune_graph(
|
|
42
|
+
db: DatabaseManager,
|
|
43
|
+
profile_id: str = "default",
|
|
44
|
+
dry_run: bool = False,
|
|
45
|
+
) -> dict[str, int]:
|
|
46
|
+
"""Prune graph edges using all strategies.
|
|
47
|
+
|
|
48
|
+
Returns counts of edges removed per strategy.
|
|
49
|
+
Safe to run repeatedly — idempotent.
|
|
50
|
+
"""
|
|
51
|
+
stats = {
|
|
52
|
+
"chain_collapsed": 0,
|
|
53
|
+
"garbage_removed": 0,
|
|
54
|
+
"low_activity_decayed": 0,
|
|
55
|
+
"total_removed": 0,
|
|
56
|
+
"edges_before": 0,
|
|
57
|
+
"edges_after": 0,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Count before
|
|
61
|
+
rows = db.execute(
|
|
62
|
+
"SELECT COUNT(*) as c FROM graph_edges", ()
|
|
63
|
+
)
|
|
64
|
+
stats["edges_before"] = rows[0]["c"] if rows else 0
|
|
65
|
+
|
|
66
|
+
# Strategy 1: Chain collapse
|
|
67
|
+
collapsed = _collapse_chains(db, profile_id, dry_run)
|
|
68
|
+
stats["chain_collapsed"] = collapsed
|
|
69
|
+
|
|
70
|
+
# Strategy 2: Garbage entity edges
|
|
71
|
+
garbage = _remove_garbage_edges(db, profile_id, dry_run)
|
|
72
|
+
stats["garbage_removed"] = garbage
|
|
73
|
+
|
|
74
|
+
# Strategy 3: Low-activity edge decay
|
|
75
|
+
decayed = _decay_low_activity_edges(db, profile_id, dry_run)
|
|
76
|
+
stats["low_activity_decayed"] = decayed
|
|
77
|
+
|
|
78
|
+
stats["total_removed"] = collapsed + garbage + decayed
|
|
79
|
+
|
|
80
|
+
# Count after
|
|
81
|
+
rows = db.execute(
|
|
82
|
+
"SELECT COUNT(*) as c FROM graph_edges", ()
|
|
83
|
+
)
|
|
84
|
+
stats["edges_after"] = rows[0]["c"] if rows else 0
|
|
85
|
+
|
|
86
|
+
if stats["total_removed"] > 0:
|
|
87
|
+
logger.info(
|
|
88
|
+
"Graph pruning: %d edges removed (%d → %d)",
|
|
89
|
+
stats["total_removed"], stats["edges_before"], stats["edges_after"],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return stats
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Strategy 1: Chain Collapse
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def _collapse_chains(db: DatabaseManager, profile_id: str, dry_run: bool) -> int:
|
|
100
|
+
"""Collapse redundant chain edges.
|
|
101
|
+
|
|
102
|
+
If A→B (weight=0.9) and B→C (weight=0.5), and A→C also exists,
|
|
103
|
+
remove B→C if A→C weight >= B→C weight * threshold.
|
|
104
|
+
|
|
105
|
+
This preserves the semantic connection (A→C is stronger) while
|
|
106
|
+
removing intermediate edges.
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
rows = db.execute("""
|
|
110
|
+
SELECT ge1.source_id as a, ge1.target_id as b, ge1.weight as w_ab,
|
|
111
|
+
ge2.source_id as b2, ge2.target_id as c, ge2.weight as w_bc,
|
|
112
|
+
ge3.weight as w_ac
|
|
113
|
+
FROM graph_edges ge1
|
|
114
|
+
JOIN graph_edges ge2 ON ge1.target_id = ge2.source_id
|
|
115
|
+
LEFT JOIN graph_edges ge3 ON ge1.source_id = ge3.source_id
|
|
116
|
+
AND ge2.target_id = ge3.target_id
|
|
117
|
+
WHERE ge3.weight >= ge2.weight * ?
|
|
118
|
+
LIMIT ?
|
|
119
|
+
""", (CHAIN_COLLAPSE_MIN_WEIGHT_RATIO, BATCH_SIZE))
|
|
120
|
+
except Exception as exc:
|
|
121
|
+
logger.warning("Chain collapse query failed: %s", exc)
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
remove_ids = []
|
|
125
|
+
for row in rows:
|
|
126
|
+
b_id = row["b"]
|
|
127
|
+
c_id = row["c"]
|
|
128
|
+
# Remove B→C edge
|
|
129
|
+
if not dry_run:
|
|
130
|
+
db.execute(
|
|
131
|
+
"DELETE FROM graph_edges WHERE source_id = ? AND target_id = ?",
|
|
132
|
+
(b_id, c_id),
|
|
133
|
+
)
|
|
134
|
+
remove_ids.append((b_id, c_id))
|
|
135
|
+
|
|
136
|
+
if remove_ids and not dry_run:
|
|
137
|
+
logger.info("Chain collapse: removed %d edges", len(remove_ids))
|
|
138
|
+
|
|
139
|
+
return len(remove_ids)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Strategy 2: Garbage Entity Edges
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def _remove_garbage_edges(db: DatabaseManager, profile_id: str, dry_run: bool) -> int:
|
|
147
|
+
"""Remove edges connected to garbage/blacklisted entities."""
|
|
148
|
+
try:
|
|
149
|
+
rows = db.execute("""
|
|
150
|
+
SELECT ge.source_id, ge.target_id
|
|
151
|
+
FROM graph_edges ge
|
|
152
|
+
WHERE ge.source_id IN (SELECT term FROM entity_blacklist)
|
|
153
|
+
OR ge.target_id IN (SELECT term FROM entity_blacklist)
|
|
154
|
+
LIMIT ?
|
|
155
|
+
""", (BATCH_SIZE,))
|
|
156
|
+
except Exception:
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
count = 0
|
|
160
|
+
for row in rows:
|
|
161
|
+
if not dry_run:
|
|
162
|
+
db.execute(
|
|
163
|
+
"DELETE FROM graph_edges WHERE source_id = ? AND target_id = ?",
|
|
164
|
+
(row["source_id"], row["target_id"]),
|
|
165
|
+
)
|
|
166
|
+
count += 1
|
|
167
|
+
|
|
168
|
+
if count and not dry_run:
|
|
169
|
+
logger.info("Garbage edges removed: %d", count)
|
|
170
|
+
|
|
171
|
+
return count
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# Strategy 3: Low-Activity Edge Decay
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def _decay_low_activity_edges(
|
|
179
|
+
db: DatabaseManager, profile_id: str, dry_run: bool,
|
|
180
|
+
) -> int:
|
|
181
|
+
"""Remove edges between entities not accessed in 90+ days.
|
|
182
|
+
|
|
183
|
+
Only removes edges where BOTH connected entities have no recent access.
|
|
184
|
+
"""
|
|
185
|
+
cutoff = (datetime.now(UTC) - timedelta(days=LOW_ACTIVITY_DAYS)).isoformat()
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
rows = db.execute("""
|
|
189
|
+
SELECT ge.source_id, ge.target_id
|
|
190
|
+
FROM graph_edges ge
|
|
191
|
+
WHERE ge.source_id NOT IN (
|
|
192
|
+
SELECT DISTINCT entity_id FROM fact_access_log
|
|
193
|
+
WHERE accessed_at >= ?
|
|
194
|
+
)
|
|
195
|
+
AND ge.target_id NOT IN (
|
|
196
|
+
SELECT DISTINCT entity_id FROM fact_access_log
|
|
197
|
+
WHERE accessed_at >= ?
|
|
198
|
+
)
|
|
199
|
+
LIMIT ?
|
|
200
|
+
""", (cutoff, cutoff, BATCH_SIZE))
|
|
201
|
+
except Exception:
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
count = 0
|
|
205
|
+
for row in rows:
|
|
206
|
+
if not dry_run:
|
|
207
|
+
db.execute(
|
|
208
|
+
"DELETE FROM graph_edges WHERE source_id = ? AND target_id = ?",
|
|
209
|
+
(row["source_id"], row["target_id"]),
|
|
210
|
+
)
|
|
211
|
+
count += 1
|
|
212
|
+
|
|
213
|
+
if count and not dry_run:
|
|
214
|
+
logger.info("Low-activity edges decayed: %d", count)
|
|
215
|
+
|
|
216
|
+
return count
|
|
@@ -284,15 +284,27 @@ def apply_adaptive_ranking(
|
|
|
284
284
|
from superlocalmemory.learning.ranker import AdaptiveRanker
|
|
285
285
|
ranker = AdaptiveRanker(signal_count=signal_count)
|
|
286
286
|
|
|
287
|
+
from datetime import UTC, datetime as _dt
|
|
288
|
+
_now = _dt.now(UTC)
|
|
289
|
+
|
|
287
290
|
result_dicts = []
|
|
288
291
|
for r in response.results:
|
|
292
|
+
_age = 0.0
|
|
293
|
+
_created = getattr(r.fact, "created_at", None)
|
|
294
|
+
if _created:
|
|
295
|
+
try:
|
|
296
|
+
_age = max(0.0, (_now - _dt.fromisoformat(
|
|
297
|
+
_created.replace("Z", "+00:00")
|
|
298
|
+
)).total_seconds() / 86400.0)
|
|
299
|
+
except (ValueError, TypeError):
|
|
300
|
+
pass
|
|
289
301
|
result_dicts.append({
|
|
290
302
|
"score": r.score,
|
|
291
303
|
"cross_encoder_score": r.score,
|
|
292
304
|
"trust_score": r.trust_score,
|
|
293
305
|
"channel_scores": r.channel_scores or {},
|
|
294
306
|
"fact": {
|
|
295
|
-
"age_days":
|
|
307
|
+
"age_days": _age,
|
|
296
308
|
"access_count": r.fact.access_count,
|
|
297
309
|
},
|
|
298
310
|
"_original": r,
|
|
@@ -364,8 +376,20 @@ def apply_v2_adaptive_ranking(
|
|
|
364
376
|
)
|
|
365
377
|
|
|
366
378
|
# Build result-dict shape expected by the ranker's rerank() path.
|
|
379
|
+
from datetime import UTC, datetime as _dt
|
|
380
|
+
_now_v2 = _dt.now(UTC)
|
|
381
|
+
|
|
367
382
|
result_dicts: list[dict] = []
|
|
368
383
|
for r in response.results:
|
|
384
|
+
_age_v2 = 0.0
|
|
385
|
+
_created_v2 = getattr(r.fact, "created_at", None)
|
|
386
|
+
if _created_v2:
|
|
387
|
+
try:
|
|
388
|
+
_age_v2 = max(0.0, (_now_v2 - _dt.fromisoformat(
|
|
389
|
+
_created_v2.replace("Z", "+00:00")
|
|
390
|
+
)).total_seconds() / 86400.0)
|
|
391
|
+
except (ValueError, TypeError):
|
|
392
|
+
pass
|
|
369
393
|
result_dicts.append({
|
|
370
394
|
"fact_id": r.fact.fact_id,
|
|
371
395
|
"score": r.score,
|
|
@@ -373,7 +397,7 @@ def apply_v2_adaptive_ranking(
|
|
|
373
397
|
"trust_score": r.trust_score,
|
|
374
398
|
"channel_scores": r.channel_scores or {},
|
|
375
399
|
"fact": {
|
|
376
|
-
"age_days":
|
|
400
|
+
"age_days": _age_v2,
|
|
377
401
|
"access_count": r.fact.access_count,
|
|
378
402
|
},
|
|
379
403
|
"_original": r,
|
|
@@ -498,6 +498,8 @@ def run_store_fact_direct(
|
|
|
498
498
|
)
|
|
499
499
|
fact.canonical_entities = list(canonical.values())
|
|
500
500
|
db.store_fact(fact)
|
|
501
|
+
# v3.4.5: Incremental sync to CozoDB/LanceDB (F-04)
|
|
502
|
+
_sync_to_graph_backends(fact)
|
|
501
503
|
if fact.embedding and ann_index:
|
|
502
504
|
ann_index.add(fact.fact_id, fact.embedding)
|
|
503
505
|
# V3.2: VectorStore upsert (dual-write)
|
|
@@ -567,3 +569,22 @@ def run_close_session(
|
|
|
567
569
|
session_id, count, len(session_facts),
|
|
568
570
|
)
|
|
569
571
|
return count
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
# ---------------------------------------------------------------------------
|
|
575
|
+
# v3.4.5: Incremental sync to CozoDB/LanceDB (F-04)
|
|
576
|
+
# ---------------------------------------------------------------------------
|
|
577
|
+
|
|
578
|
+
def _sync_to_graph_backends(fact: Any) -> None:
|
|
579
|
+
"""Sync a newly stored fact to CozoDB/LanceDB.
|
|
580
|
+
|
|
581
|
+
Non-blocking, best-effort. Called after SQLite write.
|
|
582
|
+
Failures are logged, not raised — SQLite is already committed.
|
|
583
|
+
"""
|
|
584
|
+
try:
|
|
585
|
+
from superlocalmemory.core.backend_orchestrator import get_orchestrator
|
|
586
|
+
orch = get_orchestrator()
|
|
587
|
+
if orch is not None:
|
|
588
|
+
orch.sync_new_fact(fact)
|
|
589
|
+
except Exception:
|
|
590
|
+
pass # Best-effort — daemon may not have initialized yet
|
|
@@ -323,3 +323,127 @@ def _demote_tier(
|
|
|
323
323
|
)
|
|
324
324
|
|
|
325
325
|
return len(demoted_ids)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
# Hot-path access recording (v3.4.5 — Sprint 1)
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
# In-memory counter for batch-flush access recording.
|
|
333
|
+
# Thread-safe. Flushed to SQLite every _ACCESS_FLUSH_THRESHOLD records
|
|
334
|
+
# or _ACCESS_FLUSH_SECONDS, whichever comes first.
|
|
335
|
+
import threading as _threading
|
|
336
|
+
from datetime import datetime as _datetime, timedelta as _timedelta
|
|
337
|
+
|
|
338
|
+
_pending_accesses: dict[str, int] = {}
|
|
339
|
+
_access_lock = _threading.Lock()
|
|
340
|
+
_last_access_flush = _datetime.now()
|
|
341
|
+
|
|
342
|
+
_ACCESS_FLUSH_THRESHOLD = 100
|
|
343
|
+
_ACCESS_FLUSH_SECONDS = 60
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def record_access_batch(db: DatabaseManager, fact_ids: list[str]) -> None:
|
|
347
|
+
"""Hot-path access recording for recall. Must be < 1ms average.
|
|
348
|
+
|
|
349
|
+
Uses in-memory counter, batch-flushes to SQLite every
|
|
350
|
+
_ACCESS_FLUSH_THRESHOLD accesses or _ACCESS_FLUSH_SECONDS.
|
|
351
|
+
Promotes cold/archived facts on access.
|
|
352
|
+
|
|
353
|
+
Thread-safe — called from multiple MCP connections.
|
|
354
|
+
"""
|
|
355
|
+
global _last_access_flush
|
|
356
|
+
if not fact_ids:
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
with _access_lock:
|
|
360
|
+
for fid in fact_ids:
|
|
361
|
+
_pending_accesses[fid] = _pending_accesses.get(fid, 0) + 1
|
|
362
|
+
|
|
363
|
+
now = _datetime.now()
|
|
364
|
+
should_flush = (
|
|
365
|
+
len(_pending_accesses) >= _ACCESS_FLUSH_THRESHOLD
|
|
366
|
+
or (now - _last_access_flush).total_seconds() > _ACCESS_FLUSH_SECONDS
|
|
367
|
+
)
|
|
368
|
+
if should_flush:
|
|
369
|
+
_flush_access_batch(db)
|
|
370
|
+
_last_access_flush = now
|
|
371
|
+
|
|
372
|
+
# Promote cold/archived facts on access (F-13: promote to warm)
|
|
373
|
+
try:
|
|
374
|
+
promote_on_access_batch(db, fact_ids)
|
|
375
|
+
except Exception:
|
|
376
|
+
pass
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _flush_access_batch(db: DatabaseManager) -> None:
|
|
380
|
+
"""Batch UPDATE access_count_30d and access_count in SQLite."""
|
|
381
|
+
if not _pending_accesses:
|
|
382
|
+
return
|
|
383
|
+
try:
|
|
384
|
+
for fid, count in list(_pending_accesses.items()):
|
|
385
|
+
db.execute(
|
|
386
|
+
"UPDATE atomic_facts SET access_count_30d = access_count_30d + ?, "
|
|
387
|
+
"access_count = access_count + ? WHERE fact_id = ?",
|
|
388
|
+
(count, count, fid),
|
|
389
|
+
)
|
|
390
|
+
_pending_accesses.clear()
|
|
391
|
+
except Exception as exc:
|
|
392
|
+
logger.warning("Failed to flush access counts: %s", exc)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# ---------------------------------------------------------------------------
|
|
396
|
+
# 30-day access window reset (F-14)
|
|
397
|
+
# ---------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
def reset_access_count_30d(db: DatabaseManager, profile_id: str = "default") -> int:
|
|
400
|
+
"""Reset access_count_30d using actual 30-day window from fact_access_log.
|
|
401
|
+
|
|
402
|
+
Called during nightly rebalance. Returns number of facts updated.
|
|
403
|
+
"""
|
|
404
|
+
try:
|
|
405
|
+
db.execute(
|
|
406
|
+
"UPDATE atomic_facts SET access_count_30d = COALESCE(("
|
|
407
|
+
" SELECT COUNT(*) FROM fact_access_log "
|
|
408
|
+
" WHERE fact_id = atomic_facts.fact_id "
|
|
409
|
+
" AND accessed_at >= datetime('now', '-30 days')"
|
|
410
|
+
"), 0) WHERE profile_id = ?",
|
|
411
|
+
(profile_id,),
|
|
412
|
+
)
|
|
413
|
+
# Can't get rowcount reliably after subquery UPDATE
|
|
414
|
+
return -1
|
|
415
|
+
except Exception as exc:
|
|
416
|
+
logger.warning("Failed to reset access_count_30d: %s", exc)
|
|
417
|
+
return 0
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# ---------------------------------------------------------------------------
|
|
421
|
+
# Backend sync stubs (v3.4.5 — Sprint 2+)
|
|
422
|
+
# ---------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
_cozo_backend: object | None = None
|
|
425
|
+
_lancedb_backend: object | None = None
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def set_backends(cozo: object | None = None, lancedb: object | None = None) -> None:
|
|
429
|
+
"""Register CozoDB/LanceDB backends for tier sync. Called by BackendOrchestrator."""
|
|
430
|
+
global _cozo_backend, _lancedb_backend
|
|
431
|
+
_cozo_backend = cozo
|
|
432
|
+
_lancedb_backend = lancedb
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _sync_tiers_to_backends(
|
|
436
|
+
added: list[str], removed: list[str], db: DatabaseManager,
|
|
437
|
+
) -> None:
|
|
438
|
+
"""Sync tier changes to CozoDB/LanceDB. Non-fatal on failure."""
|
|
439
|
+
if _cozo_backend and hasattr(_cozo_backend, "sync_tier_changes"):
|
|
440
|
+
try:
|
|
441
|
+
_cozo_backend.sync_tier_changes(added=added, removed=removed)
|
|
442
|
+
except Exception as exc:
|
|
443
|
+
logger.warning("CozoDB tier sync failed: %s", exc)
|
|
444
|
+
|
|
445
|
+
if _lancedb_backend and hasattr(_lancedb_backend, "bulk_update_tiers_from_sqlite"):
|
|
446
|
+
try:
|
|
447
|
+
_lancedb_backend.bulk_update_tiers_from_sqlite(db.conn)
|
|
448
|
+
except Exception as exc:
|
|
449
|
+
logger.warning("LanceDB tier sync failed: %s", exc)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Graph backends for SuperLocalMemory v3.4.5."""
|
|
6
|
+
|
|
7
|
+
from superlocalmemory.graph.cozo_backend import CozoDBGraphBackend
|
|
8
|
+
|
|
9
|
+
__all__ = ["CozoDBGraphBackend"]
|