superlocalmemory 3.4.49 → 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.
- package/CHANGELOG.md +41 -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/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/_pool_adapter.py +2 -0
- package/src/superlocalmemory/mcp/tools_active.py +41 -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/server/unified_daemon.py +2 -0
- 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,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)
|
|
@@ -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)),
|
|
@@ -82,6 +82,7 @@ def register_active_tools(server, get_engine: Callable) -> None:
|
|
|
82
82
|
project_path: str = "",
|
|
83
83
|
query: str = "",
|
|
84
84
|
max_results: int = 10,
|
|
85
|
+
max_age_days: int = 30,
|
|
85
86
|
) -> dict:
|
|
86
87
|
"""Initialize session with relevant memory context.
|
|
87
88
|
|
|
@@ -91,6 +92,21 @@ def register_active_tools(server, get_engine: Callable) -> None:
|
|
|
91
92
|
- Learning status (signal count, ranking phase)
|
|
92
93
|
|
|
93
94
|
The AI should call this automatically before any other work.
|
|
95
|
+
|
|
96
|
+
Parameters:
|
|
97
|
+
project_path: Working directory path. Used to build the search query
|
|
98
|
+
when no explicit query is provided.
|
|
99
|
+
query: Override the search query. If omitted, derived from project_path
|
|
100
|
+
or falls back to "recent important decisions".
|
|
101
|
+
max_results: Maximum memories to return (default: 10).
|
|
102
|
+
max_age_days: Suppress memories older than this many days unless their
|
|
103
|
+
relevance score is ≥ 0.70 (architectural decisions that remain
|
|
104
|
+
permanently relevant still surface). Default: 30.
|
|
105
|
+
Set to 0 to disable the age gate entirely.
|
|
106
|
+
|
|
107
|
+
Scoring: Uses 6-channel fusion (semantic + BM25 + entity_graph + temporal +
|
|
108
|
+
spreading_activation + hopfield) with Ebbinghaus exponential recency decay
|
|
109
|
+
and FSRS stability strengthening by access frequency.
|
|
94
110
|
"""
|
|
95
111
|
try:
|
|
96
112
|
from superlocalmemory.hooks.rules_engine import RulesEngine
|
|
@@ -111,10 +127,34 @@ def register_active_tools(server, get_engine: Callable) -> None:
|
|
|
111
127
|
else:
|
|
112
128
|
search_query = "recent important decisions"
|
|
113
129
|
|
|
114
|
-
response = pool_recall(search_query, limit=max_results
|
|
130
|
+
response = pool_recall(search_query, limit=max_results)
|
|
131
|
+
|
|
132
|
+
# Age gate: suppress stale memories at session start.
|
|
133
|
+
# Memories older than max_age_days are excluded unless their score
|
|
134
|
+
# exceeds 0.7 (high-relevance architectural decisions always surface).
|
|
135
|
+
# max_age_days=0 disables the gate entirely.
|
|
136
|
+
from datetime import UTC, datetime as _dt
|
|
137
|
+
_now = _dt.now(UTC)
|
|
138
|
+
|
|
139
|
+
def _age_days(created_at_str: str) -> float:
|
|
140
|
+
if not created_at_str:
|
|
141
|
+
return 0.0
|
|
142
|
+
try:
|
|
143
|
+
created = _dt.fromisoformat(
|
|
144
|
+
created_at_str.replace("Z", "+00:00")
|
|
145
|
+
)
|
|
146
|
+
return max(0.0, (_now - created).total_seconds() / 86400.0)
|
|
147
|
+
except (ValueError, TypeError):
|
|
148
|
+
return 0.0
|
|
149
|
+
|
|
115
150
|
relevant = [
|
|
116
151
|
r for r in response.results
|
|
117
152
|
if r.score >= relevance_threshold
|
|
153
|
+
and (
|
|
154
|
+
max_age_days <= 0
|
|
155
|
+
or _age_days(r.fact.created_at) <= max_age_days
|
|
156
|
+
or r.score >= 0.7
|
|
157
|
+
)
|
|
118
158
|
]
|
|
119
159
|
|
|
120
160
|
# Build both return shapes from one recall. Calling recall twice
|
|
@@ -683,7 +683,18 @@ class RetrievalEngine:
|
|
|
683
683
|
for ch, rk in sorted(fr.channel_ranks.items(), key=lambda x: x[1])
|
|
684
684
|
if rk < 1000
|
|
685
685
|
]
|
|
686
|
-
# Recency
|
|
686
|
+
# Recency decay: Ebbinghaus exponential + FSRS stability strengthening (v3.4.51).
|
|
687
|
+
#
|
|
688
|
+
# Base: R = e^(-λt), λ = ln(2)/S, S = effective half-life in days.
|
|
689
|
+
# FSRS v5 (Dae & Jarrett 2024): S grows with successful recall frequency.
|
|
690
|
+
# S_effective = S_base × min(2.0, 1 + 0.1 × access_count)
|
|
691
|
+
# → 0 recalls: S=30d 5 recalls: S=45d 10+ recalls: S=60d (max)
|
|
692
|
+
# Effect: frequently-recalled architectural decisions resist decay naturally;
|
|
693
|
+
# one-off session handoffs and debug notes decay at full rate.
|
|
694
|
+
#
|
|
695
|
+
# Boost range: [0.80×, 1.10×]
|
|
696
|
+
# 0d, 0acc → 1.10× 45d, 0acc → 0.91× 90d, 0acc → 0.84×
|
|
697
|
+
# 45d, 5acc → 0.95× 90d, 10acc → 0.90× (frequently used memories stay relevant)
|
|
687
698
|
age_days = 0.0
|
|
688
699
|
if fact.created_at:
|
|
689
700
|
try:
|
|
@@ -691,8 +702,9 @@ class RetrievalEngine:
|
|
|
691
702
|
age_days = max(0.0, (now - created).total_seconds() / 86400.0)
|
|
692
703
|
except (ValueError, TypeError):
|
|
693
704
|
pass
|
|
694
|
-
|
|
695
|
-
|
|
705
|
+
_access = max(0, getattr(fact, "access_count", 0) or 0)
|
|
706
|
+
_S = 30.0 * min(2.0, 1.0 + 0.1 * _access)
|
|
707
|
+
recency_boost = 0.8 + 0.3 * math.exp(-(math.log(2) / _S) * age_days)
|
|
696
708
|
|
|
697
709
|
# Content quality: penalize short/low-info facts that rank high
|
|
698
710
|
# due to BM25 name-matching (greetings like "Hey Caroline!" score high
|