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.
- package/bin/slm-npm +8 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/cli/commands.py +29 -0
- package/src/superlocalmemory/cli/main.py +94 -30
- package/src/superlocalmemory/core/embedding_worker.py +120 -0
- package/src/superlocalmemory/core/embeddings.py +156 -240
- package/src/superlocalmemory/core/recall_worker.py +193 -0
- package/src/superlocalmemory/core/summarizer.py +182 -0
- package/src/superlocalmemory/core/worker_pool.py +209 -0
- package/src/superlocalmemory/mcp/server.py +9 -0
- package/src/superlocalmemory/mcp/tools_core.py +21 -8
- package/src/superlocalmemory/mcp/tools_v3.py +21 -0
- package/src/superlocalmemory/server/routes/helpers.py +21 -0
- package/src/superlocalmemory/server/routes/memories.py +100 -42
- package/src/superlocalmemory/server/routes/stats.py +11 -0
- package/src/superlocalmemory/server/routes/v3_api.py +195 -43
- package/src/superlocalmemory/server/ui.py +15 -14
- package/src/superlocalmemory/storage/database.py +23 -0
- package/src/superlocalmemory.egg-info/PKG-INFO +1 -1
- package/src/superlocalmemory.egg-info/SOURCES.txt +4 -0
- package/ui/index.html +113 -29
- package/ui/js/auto-settings.js +330 -1
- package/ui/js/clusters.js +138 -101
- package/ui/js/graph-core.js +3 -1
- package/ui/js/graph-interactions.js +2 -5
- package/ui/js/memories.js +65 -2
- package/ui/js/modal.js +79 -42
- package/ui/js/recall-lab.js +206 -60
|
@@ -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,
|
|
25
|
-
return
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
|
316
|
+
"""Semantic search via subprocess worker pool (memory-isolated)."""
|
|
275
317
|
try:
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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,
|
|
302
|
-
"
|
|
303
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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":
|
|
209
|
-
"result_count":
|
|
210
|
-
"retrieval_time_ms":
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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":
|
|
238
|
-
"target_type":
|
|
239
|
-
"trust_score": round(
|
|
240
|
-
"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 {
|
|
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 =
|
|
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
|
|
200
|
-
#
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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(
|
|
@@ -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
|