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.
Files changed (30) hide show
  1. package/CHANGELOG.md +59 -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/ollama_embedder.py +14 -4
  10. package/src/superlocalmemory/core/pruning_engine.py +216 -0
  11. package/src/superlocalmemory/core/recall_pipeline.py +26 -2
  12. package/src/superlocalmemory/core/store_pipeline.py +21 -0
  13. package/src/superlocalmemory/core/tier_manager.py +124 -0
  14. package/src/superlocalmemory/graph/__init__.py +9 -0
  15. package/src/superlocalmemory/graph/cozo_backend.py +527 -0
  16. package/src/superlocalmemory/mcp/_daemon_proxy.py +1 -1
  17. package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
  18. package/src/superlocalmemory/mcp/tools_active.py +141 -1
  19. package/src/superlocalmemory/retrieval/engine.py +15 -3
  20. package/src/superlocalmemory/retrieval/entity_channel.py +50 -1
  21. package/src/superlocalmemory/retrieval/reranker.py +15 -0
  22. package/src/superlocalmemory/retrieval/spreading_activation.py +5 -2
  23. package/src/superlocalmemory/server/unified_daemon.py +73 -5
  24. package/src/superlocalmemory/storage/migration_runner.py +3 -0
  25. package/src/superlocalmemory/storage/migrations/M014_v345_scale_ready.py +45 -0
  26. package/src/superlocalmemory/storage/schema_v345.py +109 -0
  27. package/src/superlocalmemory/vector/__init__.py +9 -0
  28. package/src/superlocalmemory/vector/lancedb_backend.py +299 -0
  29. package/src/superlocalmemory.egg-info/PKG-INFO +44 -2
  30. package/src/superlocalmemory.egg-info/SOURCES.txt +8 -0
@@ -0,0 +1,527 @@
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 — CozoDB Graph Backend.
6
+
7
+ Embedded graph database backend powered by CozoDB (MPL-2.0).
8
+ Replaces NetworkX for entity graph storage and traversal.
9
+
10
+ All Datalog queries are private to this module.
11
+ External code calls Python methods only — never raw Datalog strings.
12
+
13
+ Verified API: pycozo v0.7.6, Client('rocksdb', path), db.run(datalog), db.put(name, dicts)
14
+
15
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ import sqlite3
23
+ from datetime import datetime
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # Optional import — CozoDB is an optional dependency
30
+ try:
31
+ from pycozo.client import Client as _CozoClient
32
+ _COZO_AVAILABLE = True
33
+ except ImportError:
34
+ _CozoClient = None # type: ignore[assignment]
35
+ _COZO_AVAILABLE = False
36
+
37
+
38
+ class CozoDBError(Exception):
39
+ """Base exception for CozoDB backend failures."""
40
+
41
+
42
+ class CozoDBNotAvailable(CozoDBError):
43
+ """CozoDB not installed. Install with: pip install superlocalmemory[cozo]"""
44
+
45
+
46
+ class CozoDBConnectionError(CozoDBError):
47
+ """CozoDB file not found or corrupted."""
48
+
49
+
50
+ class CozoDBQueryError(CozoDBError):
51
+ """Datalog query execution failed."""
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # CozoDBGraphBackend
56
+ # ---------------------------------------------------------------------------
57
+
58
+ class CozoDBGraphBackend:
59
+ """Embedded graph backend powered by CozoDB.
60
+
61
+ Wraps pycozo for graph storage, traversal, and algorithms.
62
+ All Datalog queries are private. External code calls Python methods.
63
+ """
64
+
65
+ def __init__(self, db_path: str) -> None:
66
+ if not _COZO_AVAILABLE:
67
+ raise CozoDBNotAvailable(
68
+ "CozoDB not installed. Run: pip install superlocalmemory[cozo]"
69
+ )
70
+ path = Path(db_path)
71
+ path.parent.mkdir(parents=True, exist_ok=True)
72
+ self._db_path = str(path)
73
+ self._db = _CozoClient("rocksdb", self._db_path) # type: ignore[misc]
74
+ self._ensure_schema()
75
+
76
+ def close(self) -> None:
77
+ """Close the CozoDB connection."""
78
+ if hasattr(self, "_db") and self._db is not None:
79
+ self._db.close()
80
+
81
+ # ------------------------------------------------------------------
82
+ # Schema
83
+ # ------------------------------------------------------------------
84
+
85
+ def _ensure_schema(self) -> None:
86
+ """Create relations if they don't exist. Idempotent."""
87
+ try:
88
+ self._db.run("""
89
+ :create entity {
90
+ id: String => name: String, entity_type: String,
91
+ tier: String default 'hot',
92
+ properties: String default '{}',
93
+ profile_id: String default 'default',
94
+ created_at: String, updated_at: String
95
+ }
96
+ """)
97
+ except Exception:
98
+ pass # Already exists
99
+
100
+ try:
101
+ self._db.run("""
102
+ :create edge {
103
+ from_id: String, to_id: String =>
104
+ edge_type: String, weight: Float default 1.0,
105
+ metadata: String default '{}',
106
+ profile_id: String default 'default',
107
+ created_at: String
108
+ }
109
+ """)
110
+ except Exception:
111
+ pass
112
+
113
+ # ------------------------------------------------------------------
114
+ # Write Path
115
+ # ------------------------------------------------------------------
116
+
117
+ def add_entity(
118
+ self,
119
+ entity_id: str,
120
+ name: str,
121
+ entity_type: str,
122
+ properties: dict | None = None,
123
+ profile_id: str = "default",
124
+ ) -> None:
125
+ """Insert or update a canonical entity node."""
126
+ now = datetime.now().isoformat()
127
+ props = json.dumps(properties or {})
128
+ self._db.put("entity", [{
129
+ "id": entity_id,
130
+ "name": name,
131
+ "entity_type": entity_type,
132
+ "properties": props,
133
+ "profile_id": profile_id,
134
+ "tier": "hot",
135
+ "created_at": now,
136
+ "updated_at": now,
137
+ }])
138
+
139
+ def add_edge(
140
+ self,
141
+ from_id: str,
142
+ to_id: str,
143
+ edge_type: str,
144
+ weight: float = 1.0,
145
+ metadata: dict | None = None,
146
+ profile_id: str = "default",
147
+ ) -> None:
148
+ """Insert a relationship edge between two entities."""
149
+ now = datetime.now().isoformat()
150
+ meta = json.dumps(metadata or {})
151
+ self._db.put("edge", [{
152
+ "from_id": from_id,
153
+ "to_id": to_id,
154
+ "edge_type": edge_type,
155
+ "weight": weight,
156
+ "metadata": meta,
157
+ "profile_id": profile_id,
158
+ "created_at": now,
159
+ }])
160
+
161
+ # ------------------------------------------------------------------
162
+ # Bulk Import (SQLite → CozoDB)
163
+ # ------------------------------------------------------------------
164
+
165
+ def bulk_import_from_sqlite(
166
+ self,
167
+ conn: sqlite3.Connection,
168
+ profile_id: str = "default",
169
+ tier_filter: list[str] | None = None,
170
+ ) -> int:
171
+ """Export entities + edges from SQLite to CozoDB.
172
+
173
+ Only imports facts + edges in tier_filter (default: hot+warm).
174
+ Uses parameterized Datalog — no string injection.
175
+
176
+ Returns number of edges imported.
177
+ """
178
+ if tier_filter is None:
179
+ tier_filter = ["active", "warm"]
180
+
181
+ # Step 1: Export all unique node IDs from graph_edges as entities.
182
+ # graph_edges uses fact IDs as nodes. canonical_entities uses separate entity IDs.
183
+ # CozoDB graph mirrors the graph_edges adjacency — node = fact ID.
184
+ entities_sql = """
185
+ SELECT DISTINCT node_id FROM (
186
+ SELECT source_id as node_id FROM graph_edges WHERE profile_id = ?
187
+ UNION
188
+ SELECT target_id as node_id FROM graph_edges WHERE profile_id = ?
189
+ )
190
+ """
191
+ rows = conn.execute(entities_sql, (profile_id, profile_id)).fetchall()
192
+
193
+ entity_dicts = []
194
+ now = datetime.now().isoformat()
195
+ for (nid,) in rows:
196
+ entity_dicts.append({
197
+ "id": nid,
198
+ "name": nid[:12],
199
+ "entity_type": "fact_node",
200
+ "tier": "active",
201
+ "properties": "{}",
202
+ "profile_id": profile_id,
203
+ "created_at": now,
204
+ "updated_at": now,
205
+ })
206
+
207
+ if entity_dicts:
208
+ self._db.put("entity", entity_dicts)
209
+ logger.info("CozoDB: imported %d entities", len(entity_dicts))
210
+
211
+ # Step 2: Export edges directly (source_id/target_id are fact IDs)
212
+ edges_sql = """
213
+ SELECT source_id, target_id, edge_type, weight
214
+ FROM graph_edges WHERE profile_id = ?
215
+ """
216
+ edge_rows = conn.execute(edges_sql, (profile_id,)).fetchall()
217
+
218
+ edge_dicts = []
219
+ for row in edge_rows:
220
+ ea, eb, etype, weight = row
221
+ edge_dicts.append({
222
+ "from_id": ea,
223
+ "to_id": eb,
224
+ "edge_type": etype or "related",
225
+ "weight": float(weight or 1.0),
226
+ "metadata": "{}",
227
+ "profile_id": profile_id,
228
+ "created_at": now,
229
+ })
230
+
231
+ if edge_dicts:
232
+ self._db.put("edge", edge_dicts)
233
+ logger.info("CozoDB: imported %d edges", len(edge_dicts))
234
+
235
+ return len(edge_dicts)
236
+
237
+ # ------------------------------------------------------------------
238
+ # Spreading Activation (Python BFS over CozoDB edges)
239
+ # ------------------------------------------------------------------
240
+
241
+ def spreading_activation(
242
+ self,
243
+ seed_entities: list[str],
244
+ depth: int = 3,
245
+ decay: float = 0.5,
246
+ top_k: int = 50,
247
+ ) -> list[tuple[str, float]]:
248
+ """BFS from seed nodes with weight decay per hop.
249
+
250
+ Uses CozoDB as fast edge store, Python for BFS logic.
251
+ Returns [(entity_id, activation_score), ...] sorted by score desc.
252
+ """
253
+ if not seed_entities:
254
+ return []
255
+
256
+ scores: dict[str, float] = {}
257
+ current_frontier: set[str] = set(seed_entities)
258
+ for s in seed_entities:
259
+ scores[s] = 1.0
260
+
261
+ for d in range(depth):
262
+ if not current_frontier:
263
+ break
264
+ next_frontier: set[str] = set()
265
+ hop_multiplier = decay ** (d + 1)
266
+
267
+ for entity_id in current_frontier:
268
+ # Query all outgoing edges from this entity
269
+ try:
270
+ result = self._db.run(f"""
271
+ ?[to_id, weight] :=
272
+ *edge{{from_id: '{entity_id}', to_id, weight}}
273
+ """)
274
+ df = result if hasattr(result, "values") else result
275
+ if df is None or len(df) == 0:
276
+ continue
277
+ rows = df.values.tolist() if hasattr(df, "values") else []
278
+ for to_id, weight in rows:
279
+ to_id_str = str(to_id)
280
+ score = hop_multiplier * float(weight)
281
+ if to_id_str not in scores or score > scores[to_id_str]:
282
+ scores[to_id_str] = score
283
+ next_frontier.add(to_id_str)
284
+ except Exception:
285
+ continue
286
+
287
+ current_frontier = next_frontier
288
+
289
+ # Sort by score desc, return top_k
290
+ ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
291
+ return ranked[:top_k]
292
+
293
+ # ------------------------------------------------------------------
294
+ # PageRank (Python iterative over CozoDB edges)
295
+ # ------------------------------------------------------------------
296
+
297
+ def pagerank(
298
+ self, damping: float = 0.85, max_iter: int = 100
299
+ ) -> dict[str, float]:
300
+ """Iterative PageRank on the current graph.
301
+
302
+ Uses CozoDB for edge queries, Python for iteration.
303
+ """
304
+ try:
305
+ # Get all entities
306
+ entities_df = self._db.run("?[id] := *entity{id}")
307
+ if entities_df is None or len(entities_df) == 0:
308
+ return {}
309
+ entities = [str(r[0]) for r in entities_df.values.tolist()]
310
+ n = len(entities)
311
+ if n == 0:
312
+ return {}
313
+
314
+ entity_index = {eid: i for i, eid in enumerate(entities)}
315
+ scores = [1.0 / n] * n
316
+
317
+ # Get all edges as adjacency list
318
+ try:
319
+ edges_df = self._db.run("?[from_id, to_id, weight] := *edge{from_id, to_id, weight}")
320
+ if edges_df is not None and len(edges_df) > 0:
321
+ edges = edges_df.values.tolist()
322
+ else:
323
+ edges = []
324
+ except Exception:
325
+ edges = []
326
+
327
+ # Build outgoing edge map
328
+ outgoing: dict[str, list[tuple[str, float]]] = {e: [] for e in entities}
329
+ for from_id, to_id, weight in edges:
330
+ outgoing[str(from_id)].append((str(to_id), float(weight)))
331
+
332
+ # Iterative PageRank
333
+ for _ in range(max_iter):
334
+ new_scores = [(1.0 - damping) / n] * n
335
+ for i, eid in enumerate(entities):
336
+ neighbors = outgoing.get(eid, [])
337
+ if neighbors:
338
+ total_weight = sum(w for _, w in neighbors)
339
+ if total_weight > 0:
340
+ for to_id, weight in neighbors:
341
+ j = entity_index.get(to_id)
342
+ if j is not None:
343
+ new_scores[j] += damping * scores[i] * weight / total_weight
344
+ scores = new_scores
345
+
346
+ return {entities[i]: scores[i] for i in range(n)}
347
+
348
+ except Exception as exc:
349
+ logger.warning("CozoDB PageRank failed: %s", exc)
350
+ return {}
351
+
352
+ # ------------------------------------------------------------------
353
+ # Community Detection (simplified label propagation)
354
+ # ------------------------------------------------------------------
355
+
356
+ def community_detect(self, method: str = "louvain") -> dict[str, int]:
357
+ """Simplified community detection via label propagation.
358
+
359
+ Uses CozoDB for edge queries, Python for iteration.
360
+ Falls back to connected components if Louvain fails.
361
+ """
362
+ try:
363
+ entities_df = self._db.run("?[id] := *entity{id}")
364
+ if entities_df is None or len(entities_df) == 0:
365
+ return {}
366
+ entities = [str(r[0]) for r in entities_df.values.tolist()]
367
+
368
+ # Get edges
369
+ try:
370
+ edges_df = self._db.run("?[from_id, to_id] := *edge{from_id, to_id}")
371
+ if edges_df is not None and len(edges_df) > 0:
372
+ edges = edges_df.values.tolist()
373
+ else:
374
+ edges = []
375
+ except Exception:
376
+ edges = []
377
+
378
+ # Build adjacency for connected components
379
+ adj: dict[str, set[str]] = {e: set() for e in entities}
380
+ for from_id, to_id in edges:
381
+ f, t = str(from_id), str(to_id)
382
+ adj.setdefault(f, set()).add(t)
383
+ adj.setdefault(t, set()).add(f)
384
+
385
+ # Connected components via BFS
386
+ community: dict[str, int] = {}
387
+ visited: set[str] = set()
388
+ comm_id = 0
389
+
390
+ for entity in entities:
391
+ if entity in visited:
392
+ continue
393
+ # BFS from this entity
394
+ queue = [entity]
395
+ visited.add(entity)
396
+ while queue:
397
+ current = queue.pop(0)
398
+ community[current] = comm_id
399
+ for neighbor in adj.get(current, set()):
400
+ if neighbor not in visited:
401
+ visited.add(neighbor)
402
+ queue.append(neighbor)
403
+ comm_id += 1
404
+
405
+ return community
406
+
407
+ except Exception as exc:
408
+ logger.warning("CozoDB community detection failed: %s", exc)
409
+ return {}
410
+
411
+ # ------------------------------------------------------------------
412
+ # Shortest Path (BFS)
413
+ # ------------------------------------------------------------------
414
+
415
+ def shortest_path(self, from_id: str, to_id: str) -> list[str]:
416
+ """BFS shortest path between two entities."""
417
+ try:
418
+ if from_id == to_id:
419
+ return [from_id]
420
+
421
+ edges_df = self._db.run("?[from_id, to_id] := *edge{from_id, to_id}")
422
+ if edges_df is None or len(edges_df) == 0:
423
+ return []
424
+
425
+ # Build adjacency
426
+ adj: dict[str, list[str]] = {}
427
+ for f, t in edges_df.values.tolist():
428
+ adj.setdefault(str(f), []).append(str(t))
429
+ adj.setdefault(str(t), []).append(str(f))
430
+
431
+ # BFS
432
+ from collections import deque
433
+ queue = deque([(from_id, [from_id])])
434
+ visited = {from_id}
435
+
436
+ while queue:
437
+ current, path = queue.popleft()
438
+ for neighbor in adj.get(current, []):
439
+ if neighbor == to_id:
440
+ return path + [neighbor]
441
+ if neighbor not in visited:
442
+ visited.add(neighbor)
443
+ queue.append((neighbor, path + [neighbor]))
444
+
445
+ return []
446
+ except Exception as exc:
447
+ logger.warning("CozoDB shortest path failed: %s", exc)
448
+ return []
449
+
450
+ # ------------------------------------------------------------------
451
+ # Tier Sync
452
+ # ------------------------------------------------------------------
453
+
454
+ def sync_tier_changes(
455
+ self, added: list[str], removed: list[str]
456
+ ) -> None:
457
+ """Sync tier changes: add promoted entities, mark demoted."""
458
+ now = datetime.now().isoformat()
459
+
460
+ if added:
461
+ # Fetch entity data from existing CozoDB entities or set defaults
462
+ for entity_id in added:
463
+ try:
464
+ self._db.run(f"""
465
+ ?[id, tier] <- [['{entity_id}', 'active']]
466
+ :update entity {{id => tier, updated_at: '{now}'}}
467
+ """)
468
+ except Exception:
469
+ pass
470
+
471
+ if removed:
472
+ for entity_id in removed:
473
+ try:
474
+ self._db.run(f"""
475
+ ?[id, tier] <- [['{entity_id}', 'cold']]
476
+ :update entity {{id => tier, updated_at: '{now}'}}
477
+ """)
478
+ except Exception:
479
+ pass
480
+
481
+ # ------------------------------------------------------------------
482
+ # Health Check
483
+ # ------------------------------------------------------------------
484
+
485
+ def health_check(self) -> dict[str, Any]:
486
+ """Return health status of the CozoDB backend."""
487
+ try:
488
+ entity_count = self._db.run(
489
+ "?[count(id)] := *entity{id}"
490
+ )
491
+ edge_count = self._db.run(
492
+ "?[count(from_id)] := *edge{from_id}"
493
+ )
494
+ ec = entity_count.values.tolist()[0][0] if len(entity_count) > 0 else 0
495
+ edc = edge_count.values.tolist()[0][0] if len(edge_count) > 0 else 0
496
+ return {
497
+ "status": "active",
498
+ "entities": int(ec),
499
+ "edges": int(edc),
500
+ "db_path": self._db_path,
501
+ }
502
+ except Exception as exc:
503
+ return {
504
+ "status": "error",
505
+ "error": str(exc),
506
+ "db_path": self._db_path,
507
+ }
508
+
509
+ # ------------------------------------------------------------------
510
+ # Rebuild (from SQLite canonical)
511
+ # ------------------------------------------------------------------
512
+
513
+ def rebuild_from_sqlite(
514
+ self, conn: sqlite3.Connection, profile_id: str = "default"
515
+ ) -> int:
516
+ """Drop all CozoDB data, re-import from SQLite."""
517
+ try:
518
+ self._db.run("::remove entity")
519
+ except Exception:
520
+ pass
521
+ try:
522
+ self._db.run("::remove edge")
523
+ except Exception:
524
+ pass
525
+
526
+ self._ensure_schema()
527
+ return self.bulk_import_from_sqlite(conn, profile_id)
@@ -36,7 +36,7 @@ class DaemonPoolProxy:
36
36
  envelopes — the adapter is responsible for surfacing those.
37
37
  """
38
38
 
39
- def __init__(self, port: int, *, timeout_s: float = 30.0) -> None:
39
+ def __init__(self, port: int, *, timeout_s: float = 60.0) -> None:
40
40
  self._port = port
41
41
  self._timeout = timeout_s
42
42
 
@@ -26,6 +26,7 @@ class PoolFact:
26
26
  fact_id: str = ""
27
27
  content: str = ""
28
28
  memory_id: str = ""
29
+ created_at: str = ""
29
30
 
30
31
 
31
32
  @dataclass(frozen=True)
@@ -93,6 +94,7 @@ def pool_recall(query: str, limit: int = 10, **kwargs: Any) -> PoolRecallRespons
93
94
  fact_id=item.get("fact_id", ""),
94
95
  content=item.get("content", ""),
95
96
  memory_id=item.get("memory_id", ""),
97
+ created_at=item.get("created_at", "") or "",
96
98
  ),
97
99
  score=float(item.get("score", 0.0)),
98
100
  confidence=float(item.get("confidence", 0.0)),