superlocalmemory 3.4.48 → 3.4.51

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 (28) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +43 -1
  3. package/package.json +1 -1
  4. package/pyproject.toml +1 -1
  5. package/src/superlocalmemory/__init__.py +1 -1
  6. package/src/superlocalmemory/cli/commands.py +7 -2
  7. package/src/superlocalmemory/cli/main.py +10 -0
  8. package/src/superlocalmemory/core/backend_orchestrator.py +365 -0
  9. package/src/superlocalmemory/core/pruning_engine.py +216 -0
  10. package/src/superlocalmemory/core/recall_pipeline.py +26 -2
  11. package/src/superlocalmemory/core/store_pipeline.py +21 -0
  12. package/src/superlocalmemory/core/tier_manager.py +124 -0
  13. package/src/superlocalmemory/graph/__init__.py +9 -0
  14. package/src/superlocalmemory/graph/cozo_backend.py +527 -0
  15. package/src/superlocalmemory/llm/backbone.py +1 -12
  16. package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
  17. package/src/superlocalmemory/mcp/tools_active.py +41 -1
  18. package/src/superlocalmemory/retrieval/engine.py +15 -3
  19. package/src/superlocalmemory/retrieval/entity_channel.py +50 -1
  20. package/src/superlocalmemory/retrieval/reranker.py +15 -0
  21. package/src/superlocalmemory/server/unified_daemon.py +3 -1
  22. package/src/superlocalmemory/storage/migration_runner.py +3 -0
  23. package/src/superlocalmemory/storage/migrations/M014_v345_scale_ready.py +45 -0
  24. package/src/superlocalmemory/storage/schema_v345.py +109 -0
  25. package/src/superlocalmemory/vector/__init__.py +9 -0
  26. package/src/superlocalmemory/vector/lancedb_backend.py +299 -0
  27. package/src/superlocalmemory.egg-info/PKG-INFO +44 -2
  28. 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": 0,
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": 0,
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"]