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,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)
@@ -273,10 +273,6 @@ class LLMBackbone:
273
273
  headers = {
274
274
  "x-api-key": self._api_key,
275
275
  "anthropic-version": _ANTHROPIC_API_VERSION,
276
- # Enable prompt caching — system prompt cached as ephemeral block.
277
- # Requires ≥1024 tokens in the cached block to activate.
278
- # Savings: ~90% cost reduction on cached input tokens.
279
- "anthropic-beta": "prompt-caching-2024-07-31",
280
276
  "Content-Type": "application/json",
281
277
  }
282
278
  payload: dict[str, Any] = {
@@ -286,14 +282,7 @@ class LLMBackbone:
286
282
  "messages": [{"role": "user", "content": prompt}],
287
283
  }
288
284
  if system:
289
- # Structured block with cache_control — plain string disables caching.
290
- payload["system"] = [
291
- {
292
- "type": "text",
293
- "text": system,
294
- "cache_control": {"type": "ephemeral"},
295
- }
296
- ]
285
+ payload["system"] = system
297
286
  return _ANTHROPIC_URL, headers, payload
298
287
 
299
288
  def _build_azure(
@@ -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, fast=True)
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 boost: recent facts get up to 1.1x, old facts 0.9x
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
- recency = max(0.1, 1.0 - age_days / 365.0)
695
- recency_boost = 1.0 + 0.2 * (recency - 0.5)
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