superlocalmemory 3.0.16 → 3.0.18

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.
@@ -23,6 +23,27 @@ UI_DIR = Path(__file__).parent.parent / "ui"
23
23
  PROFILES_DIR = MEMORY_DIR / "profiles"
24
24
 
25
25
 
26
+ def get_engine_lazy(app_state):
27
+ """Get or lazily initialize the V3 engine. Returns engine or None."""
28
+ engine = getattr(app_state, "engine", None)
29
+ if engine is not None:
30
+ return engine
31
+ if getattr(app_state, "_engine_init_attempted", False):
32
+ return None
33
+ try:
34
+ from superlocalmemory.core.config import SLMConfig
35
+ from superlocalmemory.core.engine import MemoryEngine
36
+ config = SLMConfig.load()
37
+ engine = MemoryEngine(config)
38
+ engine.initialize()
39
+ app_state.engine = engine
40
+ app_state._engine_init_attempted = True
41
+ return engine
42
+ except Exception:
43
+ app_state._engine_init_attempted = True
44
+ return None
45
+
46
+
26
47
  def get_db_connection() -> sqlite3.Connection:
27
48
  """Get database connection."""
28
49
  if not DB_PATH.exists():
@@ -12,7 +12,7 @@ from typing import Optional
12
12
  from fastapi import APIRouter, HTTPException, Query, Request
13
13
 
14
14
  from .helpers import (
15
- get_db_connection, dict_factory, get_active_profile,
15
+ get_db_connection, dict_factory, get_active_profile, get_engine_lazy,
16
16
  SearchRequest, DB_PATH, MEMORY_DIR,
17
17
  )
18
18
 
@@ -21,8 +21,8 @@ router = APIRouter()
21
21
 
22
22
 
23
23
  def _get_engine(request: Request):
24
- """Get V3 engine from app state, or None."""
25
- return getattr(request.app.state, "engine", None)
24
+ """Get V3 engine from app state, initializing lazily on first call."""
25
+ return get_engine_lazy(request.app.state)
26
26
 
27
27
 
28
28
  def _preview(content: str | None) -> str:
@@ -46,19 +46,61 @@ def _fetch_graph_data(
46
46
  ) -> tuple[list, list, list]:
47
47
  """Fetch graph nodes, links, clusters from V3 or V2 schema."""
48
48
  if use_v3:
49
+ # Graph-first: fetch edges, then get connected nodes, then fill slots
49
50
  cursor.execute("""
50
- SELECT f.fact_id as id, f.content, f.fact_type as category,
51
- f.confidence as importance, f.session_id as project_name,
52
- f.created_at
53
- FROM atomic_facts f WHERE f.profile_id = ? AND f.confidence >= ?
54
- ORDER BY f.confidence DESC, f.created_at DESC LIMIT ?
55
- """, (profile, min_importance / 10.0, max_nodes))
56
- nodes = cursor.fetchall()
51
+ SELECT source_id as source, target_id as target,
52
+ weight, edge_type as relationship_type
53
+ FROM graph_edges WHERE profile_id = ?
54
+ ORDER BY weight DESC
55
+ """, (profile,))
56
+ all_links = cursor.fetchall()
57
+
58
+ connected_ids = set()
59
+ for lk in all_links:
60
+ connected_ids.add(lk['source'])
61
+ connected_ids.add(lk['target'])
62
+
63
+ # Fetch connected nodes first (these have edges to display)
64
+ connected_nodes: list = []
65
+ if connected_ids:
66
+ ph = ','.join('?' * len(connected_ids))
67
+ cursor.execute(f"""
68
+ SELECT fact_id as id, content, fact_type as category,
69
+ confidence as importance, session_id as project_name,
70
+ created_at
71
+ FROM atomic_facts
72
+ WHERE profile_id = ? AND fact_id IN ({ph})
73
+ """, [profile] + list(connected_ids))
74
+ connected_nodes = cursor.fetchall()
75
+
76
+ # Fill remaining slots with top-confidence unconnected nodes
77
+ remaining = max_nodes - len(connected_nodes)
78
+ if remaining > 0:
79
+ existing = {n['id'] for n in connected_nodes}
80
+ cursor.execute("""
81
+ SELECT fact_id as id, content, fact_type as category,
82
+ confidence as importance, session_id as project_name,
83
+ created_at
84
+ FROM atomic_facts
85
+ WHERE profile_id = ? AND confidence >= ?
86
+ ORDER BY confidence DESC, created_at DESC
87
+ LIMIT ?
88
+ """, (profile, min_importance / 10.0, remaining + len(existing)))
89
+ for n in cursor.fetchall():
90
+ if n['id'] not in existing:
91
+ connected_nodes.append(n)
92
+ if len(connected_nodes) >= max_nodes:
93
+ break
94
+
95
+ nodes = connected_nodes[:max_nodes]
57
96
  for n in nodes:
58
97
  n['entities'] = []
59
98
  n['content_preview'] = _preview(n.get('content'))
60
- ids = [n['id'] for n in nodes]
61
- links = _fetch_edges_v3(cursor, profile, ids)
99
+
100
+ # Filter edges to only those between displayed nodes
101
+ node_ids = {n['id'] for n in nodes}
102
+ links = [lk for lk in all_links
103
+ if lk['source'] in node_ids and lk['target'] in node_ids]
62
104
  return nodes, links, []
63
105
 
64
106
  # V2 fallback
@@ -160,7 +202,7 @@ async def get_memories(
160
202
 
161
203
  if use_v3:
162
204
  query = """
163
- SELECT fact_id as id, content, fact_type as category,
205
+ SELECT fact_id as id, memory_id, content, fact_type as category,
164
206
  confidence as importance, access_count,
165
207
  created_at, created_at as updated_at,
166
208
  session_id as project_name
@@ -271,39 +313,22 @@ async def get_graph(
271
313
 
272
314
  @router.post("/api/search")
273
315
  async def search_memories(request: Request, body: SearchRequest):
274
- """Semantic search using V3 engine recall or fallback."""
316
+ """Semantic search via subprocess worker pool (memory-isolated)."""
275
317
  try:
276
- engine = _get_engine(request)
277
-
278
- if engine:
279
- response = engine.recall(body.query, limit=body.limit)
280
- results = []
281
- for r in response.results:
282
- score = r.score
283
- if score < body.min_score:
284
- continue
285
- if body.category and getattr(r.fact, 'fact_type', None) != body.category:
286
- continue
287
- results.append({
288
- "id": r.fact.fact_id,
289
- "content": r.fact.content,
290
- "score": round(score, 4),
291
- "confidence": round(r.confidence, 4),
292
- "trust_score": round(r.trust_score, 4) if r.trust_score else None,
293
- "channel_scores": r.channel_scores,
294
- "fact_type": getattr(r.fact, 'fact_type', None),
295
- "created_at": getattr(r.fact, 'created_at', None),
296
- })
297
- if len(results) >= body.limit:
298
- break
318
+ from superlocalmemory.core.worker_pool import WorkerPool
319
+ pool = WorkerPool.shared()
320
+ result = pool.recall(body.query, limit=body.limit)
299
321
 
322
+ if result.get("ok"):
300
323
  return {
301
- "query": body.query, "results": results, "total": len(results),
302
- "query_type": response.query_type,
303
- "retrieval_time_ms": response.retrieval_time_ms,
324
+ "query": body.query,
325
+ "results": result.get("results", []),
326
+ "total": result.get("result_count", 0),
327
+ "query_type": result.get("query_type", "unknown"),
328
+ "retrieval_time_ms": result.get("retrieval_time_ms", 0),
304
329
  }
305
330
 
306
- # Fallback: direct DB search (no V3 engine)
331
+ # Fallback: direct DB text search (no engine needed)
307
332
  conn = get_db_connection()
308
333
  conn.row_factory = dict_factory
309
334
  cursor = conn.cursor()
@@ -392,8 +417,41 @@ async def get_cluster_detail(request: Request, cluster_id: int, limit: int = Que
392
417
  conn.close()
393
418
  if not members:
394
419
  raise HTTPException(status_code=404, detail="Cluster not found")
395
- return {"cluster_info": {"cluster_id": cluster_id, "total_members": len(members)}, "members": members, "connections": []}
420
+ # Generate cluster summary
421
+ summary = ""
422
+ try:
423
+ from superlocalmemory.core.worker_pool import WorkerPool
424
+ pool = WorkerPool.shared()
425
+ texts = [m.get("content", "")[:200] for m in members[:10] if m.get("content")]
426
+ if texts:
427
+ result = pool.summarize(texts)
428
+ summary = result.get("summary", "") if result.get("ok") else ""
429
+ except Exception:
430
+ pass
431
+
432
+ return {
433
+ "cluster_info": {"cluster_id": cluster_id, "total_members": len(members)},
434
+ "summary": summary,
435
+ "members": members,
436
+ "connections": [],
437
+ }
396
438
  except HTTPException:
397
439
  raise
398
440
  except Exception as e:
399
441
  raise HTTPException(status_code=500, detail=f"Cluster detail error: {str(e)}")
442
+
443
+
444
+ @router.get("/api/memories/{memory_id}/facts")
445
+ async def get_memory_facts(request: Request, memory_id: str):
446
+ """Get original memory text with all its child atomic facts."""
447
+ try:
448
+ from superlocalmemory.core.worker_pool import WorkerPool
449
+ pool = WorkerPool.shared()
450
+ result = pool.get_memory_facts(memory_id)
451
+ if result.get("ok"):
452
+ return result
453
+ raise HTTPException(status_code=404, detail="Memory not found")
454
+ except HTTPException:
455
+ raise
456
+ except Exception as e:
457
+ raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
@@ -72,6 +72,17 @@ async def get_stats():
72
72
  total_clusters = cursor.fetchone()['total']
73
73
  except Exception:
74
74
  pass
75
+ # Fallback: V2-migrated clusters stored as cluster_id on memories
76
+ if total_clusters == 0:
77
+ try:
78
+ cursor.execute(
79
+ "SELECT COUNT(DISTINCT cluster_id) as total FROM memories "
80
+ "WHERE cluster_id IS NOT NULL AND profile = ?",
81
+ (active_profile,),
82
+ )
83
+ total_clusters = cursor.fetchone()['total']
84
+ except Exception:
85
+ pass
75
86
 
76
87
  # Fact type breakdown (replaces category in V3)
77
88
  cursor.execute("""
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  import json
10
10
  import logging
11
+ import os
11
12
  from pathlib import Path
12
13
  from fastapi import APIRouter, Request
13
14
  from fastapi.responses import JSONResponse
@@ -63,17 +64,22 @@ async def dashboard(request: Request):
63
64
 
64
65
  @router.get("/mode")
65
66
  async def get_mode():
66
- """Get current operating mode."""
67
+ """Get current mode, provider, model — single source of truth for UI."""
67
68
  try:
68
69
  from superlocalmemory.core.config import SLMConfig
69
70
  config = SLMConfig.load()
70
- modes = {
71
- "a": {"name": "Local Guardian", "description": "Zero cloud. Your data never leaves your machine.", "llm": False, "eu_compliant": True},
72
- "b": {"name": "Smart Local", "description": "Local LLM via Ollama. Still fully private.", "llm": "local", "eu_compliant": True},
73
- "c": {"name": "Full Power", "description": "Cloud LLM for maximum accuracy.", "llm": "cloud", "eu_compliant": False},
74
- }
75
71
  current = config.mode.value
76
- return {"current": current, "details": modes.get(current, {}), "all_modes": modes}
72
+ return {
73
+ "mode": current,
74
+ "provider": config.llm.provider or "none",
75
+ "model": config.llm.model or "",
76
+ "has_key": bool(config.llm.api_key),
77
+ "endpoint": config.llm.api_base or "",
78
+ "capabilities": {
79
+ "llm_available": bool(config.llm.provider),
80
+ "cross_encoder": config.retrieval.use_cross_encoder if hasattr(config, 'retrieval') else False,
81
+ },
82
+ }
77
83
  except Exception as e:
78
84
  return JSONResponse({"error": str(e)}, status_code=500)
79
85
 
@@ -109,6 +115,127 @@ async def set_mode(request: Request):
109
115
  return JSONResponse({"error": str(e)}, status_code=500)
110
116
 
111
117
 
118
+ @router.post("/mode/set")
119
+ async def set_full_config(request: Request):
120
+ """Save mode + provider + model + API key together."""
121
+ try:
122
+ body = await request.json()
123
+ new_mode = body.get("mode", "a").lower()
124
+ provider = body.get("provider", "none")
125
+ model = body.get("model", "")
126
+ api_key = body.get("api_key", "")
127
+
128
+ if new_mode not in ("a", "b", "c"):
129
+ return JSONResponse({"error": "Invalid mode"}, status_code=400)
130
+
131
+ from superlocalmemory.core.config import SLMConfig
132
+ from superlocalmemory.storage.models import Mode
133
+ config = SLMConfig.for_mode(
134
+ Mode(new_mode),
135
+ llm_provider=provider if provider != "none" else "",
136
+ llm_model=model,
137
+ llm_api_key=api_key,
138
+ llm_api_base="http://localhost:11434" if provider == "ollama" else "",
139
+ )
140
+ old = SLMConfig.load()
141
+ config.active_profile = old.active_profile
142
+ config.save()
143
+
144
+ # Kill existing worker so next request uses new config
145
+ try:
146
+ from superlocalmemory.core.worker_pool import WorkerPool
147
+ WorkerPool.shared().shutdown()
148
+ except Exception:
149
+ pass
150
+
151
+ if hasattr(request.app.state, "engine"):
152
+ request.app.state.engine = None
153
+
154
+ return {
155
+ "success": True,
156
+ "mode": new_mode,
157
+ "provider": provider,
158
+ "model": model,
159
+ }
160
+ except Exception as e:
161
+ return JSONResponse({"error": str(e)}, status_code=500)
162
+
163
+
164
+ @router.post("/provider/test")
165
+ async def test_provider(request: Request):
166
+ """Test connectivity to an LLM provider."""
167
+ try:
168
+ import httpx
169
+ body = await request.json()
170
+ provider = body.get("provider", "")
171
+ model = body.get("model", "")
172
+ api_key = body.get("api_key", "")
173
+
174
+ if provider == "ollama":
175
+ endpoint = body.get("endpoint", "http://localhost:11434")
176
+ with httpx.Client(timeout=httpx.Timeout(5.0)) as c:
177
+ resp = c.get(f"{endpoint}/api/tags")
178
+ resp.raise_for_status()
179
+ models = [m["name"] for m in resp.json().get("models", [])]
180
+ found = model in models if model else len(models) > 0
181
+ return {
182
+ "success": found,
183
+ "message": f"Ollama OK, {len(models)} models" + (f", '{model}' available" if found and model else ""),
184
+ }
185
+
186
+ if provider == "openrouter":
187
+ if not api_key:
188
+ api_key = os.environ.get("OPENROUTER_API_KEY", "")
189
+ if not api_key:
190
+ return {"success": False, "error": "API key required"}
191
+ with httpx.Client(timeout=httpx.Timeout(10.0)) as c:
192
+ resp = c.get("https://openrouter.ai/api/v1/models", headers={"Authorization": f"Bearer {api_key}"})
193
+ resp.raise_for_status()
194
+ return {"success": True, "message": "OpenRouter connected, key valid"}
195
+
196
+ if provider == "openai":
197
+ if not api_key:
198
+ return {"success": False, "error": "API key required"}
199
+ with httpx.Client(timeout=httpx.Timeout(10.0)) as c:
200
+ resp = c.get("https://api.openai.com/v1/models", headers={"Authorization": f"Bearer {api_key}"})
201
+ resp.raise_for_status()
202
+ return {"success": True, "message": "OpenAI connected, key valid"}
203
+
204
+ if provider == "anthropic":
205
+ if not api_key:
206
+ return {"success": False, "error": "API key required"}
207
+ # Anthropic doesn't have a models list endpoint, just verify key format
208
+ if api_key.startswith("sk-ant-"):
209
+ return {"success": True, "message": "Anthropic key format valid"}
210
+ return {"success": False, "error": "Key should start with sk-ant-"}
211
+
212
+ return {"success": False, "error": f"Unknown provider: {provider}"}
213
+ except httpx.ConnectError:
214
+ return {"success": False, "error": "Cannot connect — is the service running?"}
215
+ except httpx.HTTPStatusError as e:
216
+ return {"success": False, "error": f"HTTP {e.response.status_code}: Invalid key or endpoint"}
217
+ except Exception as e:
218
+ return {"success": False, "error": str(e)}
219
+
220
+
221
+ @router.get("/ollama/status")
222
+ async def ollama_status():
223
+ """Check if Ollama is running and list available models."""
224
+ try:
225
+ import httpx
226
+ with httpx.Client(timeout=httpx.Timeout(5.0)) as client:
227
+ resp = client.get("http://localhost:11434/api/tags")
228
+ resp.raise_for_status()
229
+ data = resp.json()
230
+ models = [
231
+ {"name": m["name"], "size": m.get("size", 0)}
232
+ for m in data.get("models", [])
233
+ ]
234
+ return {"running": True, "models": models, "count": len(models)}
235
+ except Exception:
236
+ return {"running": False, "models": [], "count": 0}
237
+
238
+
112
239
  # ── Provider ─────────────────────────────────────────────────
113
240
 
114
241
  @router.get("/providers")
@@ -187,28 +314,32 @@ async def recall_trace(request: Request):
187
314
  query = body.get("query", "")
188
315
  limit = body.get("limit", 10)
189
316
 
190
- engine = getattr(request.app.state, "engine", None)
191
- if not engine:
192
- return JSONResponse({"error": "Engine not initialized"}, status_code=503)
193
-
194
- response = engine.recall(query, limit=limit)
195
- results = []
196
- for r in response.results[:limit]:
197
- results.append({
198
- "fact_id": r.fact.fact_id,
199
- "content": r.fact.content[:300],
200
- "score": round(r.score, 4),
201
- "confidence": round(r.confidence, 4),
202
- "trust_score": round(r.trust_score, 4),
203
- "channel_scores": {k: round(v, 4) for k, v in (r.channel_scores or {}).items()},
204
- })
317
+ from superlocalmemory.core.worker_pool import WorkerPool
318
+ pool = WorkerPool.shared()
319
+ result = pool.recall(query, limit=limit)
320
+
321
+ if not result.get("ok"):
322
+ return JSONResponse(
323
+ {"error": result.get("error", "Recall failed")},
324
+ status_code=503,
325
+ )
326
+
327
+ # Optional: synthesize answer from results (Mode B/C only)
328
+ synthesis = ""
329
+ if body.get("synthesize") and result.get("results"):
330
+ try:
331
+ syn_result = pool.synthesize(query, result["results"][:5])
332
+ synthesis = syn_result.get("synthesis", "") if syn_result.get("ok") else ""
333
+ except Exception:
334
+ pass
205
335
 
206
336
  return {
207
337
  "query": query,
208
- "query_type": response.query_type,
209
- "result_count": len(results),
210
- "retrieval_time_ms": round(response.retrieval_time_ms, 1),
211
- "results": results,
338
+ "query_type": result.get("query_type", "unknown"),
339
+ "result_count": result.get("result_count", 0),
340
+ "retrieval_time_ms": result.get("retrieval_time_ms", 0),
341
+ "results": result.get("results", []),
342
+ "synthesis": synthesis,
212
343
  }
213
344
  except Exception as e:
214
345
  return JSONResponse({"error": str(e)}, status_code=500)
@@ -218,29 +349,50 @@ async def recall_trace(request: Request):
218
349
 
219
350
  @router.get("/trust/dashboard")
220
351
  async def trust_dashboard(request: Request):
221
- """Trust overview: per-agent scores, alerts."""
352
+ """Trust overview: per-agent scores, alerts. Queries DB directly."""
222
353
  try:
223
- engine = getattr(request.app.state, "engine", None)
224
- if not engine or not engine._trust_scorer:
225
- return {"agents": [], "alerts": [], "message": "Trust scorer not available"}
226
-
227
354
  from superlocalmemory.core.config import SLMConfig
355
+ from superlocalmemory.storage.database import DatabaseManager
356
+ from superlocalmemory.storage import schema as _schema
228
357
  config = SLMConfig.load()
229
- scores = engine._trust_scorer.get_all_scores(config.active_profile)
358
+ pid = config.active_profile
359
+
360
+ db_path = config.db_path
361
+ db = DatabaseManager(db_path)
362
+ db.initialize(_schema)
230
363
 
364
+ # Query trust scores from DB
231
365
  agents = []
232
- for s in scores:
233
- if isinstance(s, dict):
234
- agents.append(s)
235
- else:
366
+ try:
367
+ rows = db.execute(
368
+ "SELECT target_id, target_type, trust_score, evidence_count, "
369
+ "last_updated FROM trust_scores WHERE profile_id = ? "
370
+ "ORDER BY trust_score DESC",
371
+ (pid,),
372
+ )
373
+ for r in rows:
374
+ d = dict(r)
236
375
  agents.append({
237
- "target_id": s.target_id,
238
- "target_type": s.target_type,
239
- "trust_score": round(s.trust_score, 3),
240
- "evidence_count": s.evidence_count,
376
+ "target_id": d.get("target_id", ""),
377
+ "target_type": d.get("target_type", ""),
378
+ "trust_score": round(float(d.get("trust_score", 0.5)), 3),
379
+ "evidence_count": d.get("evidence_count", 0),
380
+ "last_updated": d.get("last_updated", ""),
241
381
  })
382
+ except Exception:
383
+ pass
384
+
385
+ # Aggregate stats
386
+ avg = round(sum(a["trust_score"] for a in agents) / len(agents), 3) if agents else 0.5
387
+ alerts = [a for a in agents if a["trust_score"] < 0.3]
242
388
 
243
- return {"agents": agents, "alerts": [], "profile": config.active_profile}
389
+ return {
390
+ "agents": agents,
391
+ "avg_trust": avg,
392
+ "alerts": alerts,
393
+ "total": len(agents),
394
+ "profile": pid,
395
+ }
244
396
  except Exception as e:
245
397
  return JSONResponse({"error": str(e)}, status_code=500)
246
398
 
@@ -249,9 +401,9 @@ async def trust_dashboard(request: Request):
249
401
 
250
402
  @router.get("/math/health")
251
403
  async def math_health(request: Request):
252
- """Mathematical layer health: Fisher, sheaf, Langevin status."""
404
+ """Mathematical layer health: Fisher, sheaf, Langevin status. Queries DB directly."""
253
405
  try:
254
- engine = getattr(request.app.state, "engine", None)
406
+ engine = None # Engine runs in subprocess; query DB directly below
255
407
 
256
408
  health = {
257
409
  "fisher": {"status": "active", "description": "Fisher-Rao information geometry for similarity"},
@@ -196,22 +196,23 @@ def create_app() -> FastAPI:
196
196
 
197
197
  @application.on_event("startup")
198
198
  async def startup_event():
199
- """Initialize V3 engine and event bus on startup."""
200
- # Initialize V3 engine for dashboard API routes
201
- try:
202
- from superlocalmemory.core.config import SLMConfig
203
- from superlocalmemory.core.engine import MemoryEngine
204
- config = SLMConfig.load()
205
- engine = MemoryEngine(config)
206
- engine.initialize()
207
- application.state.engine = engine
208
- logger.info("V3 engine initialized for dashboard")
209
- except Exception as exc:
210
- logger.warning("V3 engine init failed: %s (V3 API routes will be unavailable)", exc)
211
- application.state.engine = None
212
-
199
+ """Initialize event bus. Engine runs in subprocess worker (never in this process)."""
200
+ # Engine is NEVER loaded in the dashboard process.
201
+ # All recall/search operations go through WorkerPool subprocess.
202
+ # This keeps the dashboard permanently at ~60 MB.
203
+ application.state.engine = None
204
+ logger.info("Dashboard started (~60 MB, engine runs in subprocess worker)")
213
205
  register_event_listener()
214
206
 
207
+ @application.on_event("shutdown")
208
+ async def shutdown_event():
209
+ """Kill worker subprocess on dashboard shutdown."""
210
+ try:
211
+ from superlocalmemory.core.worker_pool import WorkerPool
212
+ WorkerPool.shared().shutdown()
213
+ except Exception:
214
+ pass
215
+
215
216
  return application
216
217
 
217
218
 
@@ -300,6 +300,29 @@ class DatabaseManager:
300
300
  for r in rows
301
301
  ]
302
302
 
303
+ def get_memory_content_batch(self, memory_ids: list[str]) -> dict[str, str]:
304
+ """Batch-fetch original memory text. Returns {memory_id: content}."""
305
+ if not memory_ids:
306
+ return {}
307
+ unique_ids = list(set(memory_ids))
308
+ ph = ','.join('?' * len(unique_ids))
309
+ rows = self.execute(
310
+ f"SELECT memory_id, content FROM memories WHERE memory_id IN ({ph})",
311
+ tuple(unique_ids),
312
+ )
313
+ return {dict(r)["memory_id"]: dict(r)["content"] for r in rows}
314
+
315
+ def get_facts_by_memory_id(
316
+ self, memory_id: str, profile_id: str,
317
+ ) -> list[AtomicFact]:
318
+ """Get all atomic facts for a given memory_id."""
319
+ rows = self.execute(
320
+ "SELECT * FROM atomic_facts WHERE memory_id = ? AND profile_id = ? "
321
+ "ORDER BY confidence DESC",
322
+ (memory_id, profile_id),
323
+ )
324
+ return [self._row_to_fact(r) for r in rows]
325
+
303
326
  def store_edge(self, edge: GraphEdge) -> str:
304
327
  """Persist a graph edge. Returns edge_id."""
305
328
  self.execute(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: superlocalmemory
3
- Version: 3.0.16
3
+ Version: 3.0.18
4
4
  Summary: Information-geometric agent memory with mathematical guarantees
5
5
  Author-email: Varun Pratap Bhardwaj <admin@superlocalmemory.com>
6
6
  License: MIT
@@ -30,13 +30,17 @@ src/superlocalmemory/compliance/retention.py
30
30
  src/superlocalmemory/compliance/scheduler.py
31
31
  src/superlocalmemory/core/__init__.py
32
32
  src/superlocalmemory/core/config.py
33
+ src/superlocalmemory/core/embedding_worker.py
33
34
  src/superlocalmemory/core/embeddings.py
34
35
  src/superlocalmemory/core/engine.py
35
36
  src/superlocalmemory/core/hooks.py
36
37
  src/superlocalmemory/core/maintenance.py
37
38
  src/superlocalmemory/core/modes.py
38
39
  src/superlocalmemory/core/profiles.py
40
+ src/superlocalmemory/core/recall_worker.py
39
41
  src/superlocalmemory/core/registry.py
42
+ src/superlocalmemory/core/summarizer.py
43
+ src/superlocalmemory/core/worker_pool.py
40
44
  src/superlocalmemory/dynamics/__init__.py
41
45
  src/superlocalmemory/dynamics/fisher_langevin_coupling.py
42
46
  src/superlocalmemory/encoding/__init__.py