superlocalmemory 3.0.37 → 3.1.1
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/README.md +37 -1
- package/docs/getting-started.md +1 -1
- package/package.json +1 -1
- package/pyproject.toml +2 -1
- package/src/superlocalmemory/cli/commands.py +122 -0
- package/src/superlocalmemory/cli/main.py +13 -0
- package/src/superlocalmemory/core/engine.py +63 -0
- package/src/superlocalmemory/core/summarizer.py +4 -26
- package/src/superlocalmemory/hooks/claude_code_hooks.py +175 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +289 -0
- package/src/superlocalmemory/learning/feedback.py +3 -0
- package/src/superlocalmemory/learning/signals.py +326 -0
- package/src/superlocalmemory/llm/backbone.py +14 -5
- package/src/superlocalmemory/mcp/resources.py +26 -1
- package/src/superlocalmemory/mcp/server.py +2 -0
- package/src/superlocalmemory/mcp/tools_active.py +255 -0
- package/src/superlocalmemory/mcp/tools_core.py +81 -0
- package/src/superlocalmemory/server/routes/agents.py +14 -12
- package/src/superlocalmemory/server/routes/behavioral.py +20 -5
- package/src/superlocalmemory/server/routes/learning.py +72 -14
- package/src/superlocalmemory/server/routes/lifecycle.py +9 -9
- package/src/superlocalmemory/server/routes/memories.py +136 -61
- package/src/superlocalmemory/server/routes/stats.py +33 -5
- package/src/superlocalmemory/server/routes/v3_api.py +93 -0
|
@@ -46,53 +46,37 @@ 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
|
-
#
|
|
49
|
+
# Recency-first: get the most recent nodes, then find their edges
|
|
50
50
|
cursor.execute("""
|
|
51
|
-
SELECT
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
SELECT fact_id as id, content, fact_type as category,
|
|
52
|
+
confidence as importance, session_id as project_name,
|
|
53
|
+
created_at
|
|
54
|
+
FROM atomic_facts
|
|
55
|
+
WHERE profile_id = ? AND confidence >= ?
|
|
56
|
+
ORDER BY created_at DESC
|
|
57
|
+
LIMIT ?
|
|
58
|
+
""", (profile, min_importance / 10.0, max_nodes))
|
|
59
|
+
nodes = cursor.fetchall()
|
|
57
60
|
|
|
58
|
-
|
|
59
|
-
for lk in all_links:
|
|
60
|
-
connected_ids.add(lk['source'])
|
|
61
|
-
connected_ids.add(lk['target'])
|
|
61
|
+
node_ids = {n['id'] for n in nodes}
|
|
62
62
|
|
|
63
|
-
# Fetch
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
# Fetch edges between these nodes
|
|
64
|
+
if node_ids:
|
|
65
|
+
ph = ','.join('?' * len(node_ids))
|
|
66
|
+
id_list = list(node_ids)
|
|
67
67
|
cursor.execute(f"""
|
|
68
|
-
SELECT
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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]
|
|
68
|
+
SELECT source_id as source, target_id as target,
|
|
69
|
+
weight, edge_type as relationship_type
|
|
70
|
+
FROM graph_edges
|
|
71
|
+
WHERE profile_id = ?
|
|
72
|
+
AND source_id IN ({ph}) AND target_id IN ({ph})
|
|
73
|
+
ORDER BY weight DESC
|
|
74
|
+
""", [profile] + id_list + id_list)
|
|
75
|
+
all_links = cursor.fetchall()
|
|
76
|
+
else:
|
|
77
|
+
all_links = []
|
|
78
|
+
|
|
79
|
+
links = all_links
|
|
96
80
|
for n in nodes:
|
|
97
81
|
n['entities'] = []
|
|
98
82
|
n['content_preview'] = _preview(n.get('content'))
|
|
@@ -101,7 +85,33 @@ def _fetch_graph_data(
|
|
|
101
85
|
node_ids = {n['id'] for n in nodes}
|
|
102
86
|
links = [lk for lk in all_links
|
|
103
87
|
if lk['source'] in node_ids and lk['target'] in node_ids]
|
|
104
|
-
|
|
88
|
+
|
|
89
|
+
# Compute clusters from memory_scenes
|
|
90
|
+
clusters = []
|
|
91
|
+
try:
|
|
92
|
+
cursor.execute("""
|
|
93
|
+
SELECT scene_id, theme, fact_ids_json
|
|
94
|
+
FROM memory_scenes WHERE profile_id = ?
|
|
95
|
+
""", (profile,))
|
|
96
|
+
for row in cursor.fetchall():
|
|
97
|
+
fact_ids = []
|
|
98
|
+
try:
|
|
99
|
+
fact_ids = json.loads(row.get('fact_ids_json', '[]') or '[]')
|
|
100
|
+
except (json.JSONDecodeError, TypeError):
|
|
101
|
+
pass
|
|
102
|
+
# Only include clusters that overlap with displayed nodes
|
|
103
|
+
overlap = [fid for fid in fact_ids if fid in node_ids]
|
|
104
|
+
if overlap:
|
|
105
|
+
clusters.append({
|
|
106
|
+
'cluster_id': row['scene_id'],
|
|
107
|
+
'size': len(fact_ids),
|
|
108
|
+
'visible_size': len(overlap),
|
|
109
|
+
'theme': row.get('theme', ''),
|
|
110
|
+
})
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
return nodes, links, clusters
|
|
105
115
|
|
|
106
116
|
# V2 fallback
|
|
107
117
|
try:
|
|
@@ -362,15 +372,54 @@ async def get_clusters(request: Request):
|
|
|
362
372
|
profile = get_active_profile()
|
|
363
373
|
unclustered = 0
|
|
364
374
|
|
|
365
|
-
|
|
375
|
+
# V3 schema: memory_scenes stores fact_ids_json (JSON array)
|
|
376
|
+
if _has_table(cursor, 'memory_scenes'):
|
|
366
377
|
cursor.execute("""
|
|
367
|
-
SELECT
|
|
368
|
-
|
|
369
|
-
FROM
|
|
370
|
-
|
|
378
|
+
SELECT scene_id as cluster_id, theme, fact_ids_json,
|
|
379
|
+
entity_ids_json, created_at as first_memory
|
|
380
|
+
FROM memory_scenes WHERE profile_id = ?
|
|
381
|
+
ORDER BY created_at DESC
|
|
371
382
|
""", (profile,))
|
|
372
|
-
|
|
383
|
+
raw_scenes = cursor.fetchall()
|
|
384
|
+
clusters = []
|
|
385
|
+
for scene in raw_scenes:
|
|
386
|
+
fact_ids = []
|
|
387
|
+
try:
|
|
388
|
+
fact_ids = json.loads(scene.get('fact_ids_json', '[]') or '[]')
|
|
389
|
+
except (json.JSONDecodeError, TypeError):
|
|
390
|
+
pass
|
|
391
|
+
entity_ids = []
|
|
392
|
+
try:
|
|
393
|
+
entity_ids = json.loads(scene.get('entity_ids_json', '[]') or '[]')
|
|
394
|
+
except (json.JSONDecodeError, TypeError):
|
|
395
|
+
pass
|
|
396
|
+
clusters.append({
|
|
397
|
+
'cluster_id': scene['cluster_id'],
|
|
398
|
+
'member_count': len(fact_ids),
|
|
399
|
+
'categories': scene.get('theme', ''),
|
|
400
|
+
'summary': scene.get('theme', ''),
|
|
401
|
+
'first_memory': scene.get('first_memory', ''),
|
|
402
|
+
'top_entities': entity_ids[:5],
|
|
403
|
+
})
|
|
404
|
+
# Filter out empty clusters
|
|
405
|
+
clusters = [c for c in clusters if c['member_count'] > 0]
|
|
406
|
+
clusters.sort(key=lambda c: c['member_count'], reverse=True)
|
|
407
|
+
|
|
408
|
+
# Count facts not in any scene
|
|
409
|
+
all_scene_fact_ids = set()
|
|
410
|
+
for scene in raw_scenes:
|
|
411
|
+
try:
|
|
412
|
+
ids = json.loads(scene.get('fact_ids_json', '[]') or '[]')
|
|
413
|
+
all_scene_fact_ids.update(ids)
|
|
414
|
+
except (json.JSONDecodeError, TypeError):
|
|
415
|
+
pass
|
|
416
|
+
total_facts = cursor.execute(
|
|
417
|
+
"SELECT COUNT(*) as c FROM atomic_facts WHERE profile_id = ?",
|
|
418
|
+
(profile,),
|
|
419
|
+
).fetchone()['c']
|
|
420
|
+
unclustered = total_facts - len(all_scene_fact_ids)
|
|
373
421
|
else:
|
|
422
|
+
# V2 fallback
|
|
374
423
|
try:
|
|
375
424
|
cursor.execute("""
|
|
376
425
|
SELECT cluster_id, COUNT(*) as member_count,
|
|
@@ -382,8 +431,14 @@ async def get_clusters(request: Request):
|
|
|
382
431
|
clusters = [dict(r, top_entities=[]) for r in cursor.fetchall()]
|
|
383
432
|
except Exception:
|
|
384
433
|
clusters = []
|
|
385
|
-
|
|
386
|
-
|
|
434
|
+
try:
|
|
435
|
+
cursor.execute(
|
|
436
|
+
"SELECT COUNT(*) as c FROM memories WHERE cluster_id IS NULL AND profile = ?",
|
|
437
|
+
(profile,),
|
|
438
|
+
)
|
|
439
|
+
unclustered = cursor.fetchone()['c']
|
|
440
|
+
except Exception:
|
|
441
|
+
unclustered = 0
|
|
387
442
|
|
|
388
443
|
conn.close()
|
|
389
444
|
return {"clusters": clusters, "total_clusters": len(clusters), "unclustered_count": unclustered}
|
|
@@ -392,21 +447,41 @@ async def get_clusters(request: Request):
|
|
|
392
447
|
|
|
393
448
|
|
|
394
449
|
@router.get("/api/clusters/{cluster_id}")
|
|
395
|
-
async def get_cluster_detail(request: Request, cluster_id:
|
|
396
|
-
"""Get detailed view of a specific cluster."""
|
|
450
|
+
async def get_cluster_detail(request: Request, cluster_id: str, limit: int = Query(50, ge=1, le=200)):
|
|
451
|
+
"""Get detailed view of a specific cluster (scene)."""
|
|
397
452
|
try:
|
|
398
453
|
conn = get_db_connection()
|
|
399
454
|
conn.row_factory = dict_factory
|
|
400
455
|
cursor = conn.cursor()
|
|
401
456
|
profile = get_active_profile()
|
|
402
457
|
|
|
403
|
-
if _has_table(cursor, '
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
458
|
+
if _has_table(cursor, 'memory_scenes'):
|
|
459
|
+
# Get fact IDs from the scene's JSON array
|
|
460
|
+
cursor.execute(
|
|
461
|
+
"SELECT fact_ids_json, theme FROM memory_scenes "
|
|
462
|
+
"WHERE scene_id = ? AND profile_id = ?",
|
|
463
|
+
(cluster_id, profile),
|
|
464
|
+
)
|
|
465
|
+
scene_row = cursor.fetchone()
|
|
466
|
+
if scene_row:
|
|
467
|
+
fact_ids = []
|
|
468
|
+
try:
|
|
469
|
+
fact_ids = json.loads(scene_row.get('fact_ids_json', '[]') or '[]')
|
|
470
|
+
except (json.JSONDecodeError, TypeError):
|
|
471
|
+
pass
|
|
472
|
+
if fact_ids:
|
|
473
|
+
ph = ','.join('?' * min(len(fact_ids), limit))
|
|
474
|
+
cursor.execute(f"""
|
|
475
|
+
SELECT fact_id as id, content, fact_type as category,
|
|
476
|
+
confidence as importance, created_at
|
|
477
|
+
FROM atomic_facts
|
|
478
|
+
WHERE profile_id = ? AND fact_id IN ({ph})
|
|
479
|
+
ORDER BY confidence DESC
|
|
480
|
+
""", [profile] + fact_ids[:limit])
|
|
481
|
+
else:
|
|
482
|
+
cursor.execute("SELECT 1 WHERE 0") # empty result
|
|
483
|
+
else:
|
|
484
|
+
cursor.execute("SELECT 1 WHERE 0") # empty result
|
|
410
485
|
else:
|
|
411
486
|
cursor.execute("""
|
|
412
487
|
SELECT id, content, summary, category, project_name, importance, created_at, tags
|
|
@@ -13,7 +13,7 @@ from typing import Optional
|
|
|
13
13
|
|
|
14
14
|
from fastapi import APIRouter, HTTPException, Query
|
|
15
15
|
|
|
16
|
-
from .helpers import get_db_connection, dict_factory, get_active_profile, DB_PATH
|
|
16
|
+
from .helpers import get_db_connection, dict_factory, get_active_profile, DB_PATH, MEMORY_DIR
|
|
17
17
|
|
|
18
18
|
logger = logging.getLogger("superlocalmemory.routes.stats")
|
|
19
19
|
router = APIRouter()
|
|
@@ -306,10 +306,38 @@ async def get_patterns():
|
|
|
306
306
|
|
|
307
307
|
if not table_name:
|
|
308
308
|
conn.close()
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
309
|
+
# Fall through to V3.1 behavioral pattern store
|
|
310
|
+
try:
|
|
311
|
+
from superlocalmemory.learning.behavioral import BehavioralPatternStore
|
|
312
|
+
store = BehavioralPatternStore(str(MEMORY_DIR / "learning.db"))
|
|
313
|
+
raw = store.get_patterns(profile_id=active_profile)
|
|
314
|
+
grouped = defaultdict(list)
|
|
315
|
+
for p in raw:
|
|
316
|
+
meta = p.get("metadata", {})
|
|
317
|
+
grouped[p["pattern_type"]].append({
|
|
318
|
+
"pattern_type": p["pattern_type"],
|
|
319
|
+
"key": meta.get("key", p.get("pattern_key", "")),
|
|
320
|
+
"value": meta.get("value", p.get("pattern_key", "")),
|
|
321
|
+
"confidence": p.get("confidence", 0),
|
|
322
|
+
"evidence_count": p.get("evidence_count", 0),
|
|
323
|
+
})
|
|
324
|
+
all_patterns = [p for ps in grouped.values() for p in ps]
|
|
325
|
+
confs = [p["confidence"] for p in all_patterns if p.get("confidence")]
|
|
326
|
+
return {
|
|
327
|
+
"patterns": dict(grouped),
|
|
328
|
+
"total_patterns": len(all_patterns),
|
|
329
|
+
"pattern_types": list(grouped.keys()),
|
|
330
|
+
"confidence_stats": {
|
|
331
|
+
"avg": sum(confs) / len(confs) if confs else 0,
|
|
332
|
+
"min": min(confs) if confs else 0,
|
|
333
|
+
"max": max(confs) if confs else 0,
|
|
334
|
+
},
|
|
335
|
+
}
|
|
336
|
+
except Exception:
|
|
337
|
+
return {
|
|
338
|
+
"patterns": {}, "total_patterns": 0, "pattern_types": [],
|
|
339
|
+
"message": "Pattern learning not initialized.",
|
|
340
|
+
}
|
|
313
341
|
|
|
314
342
|
if table_name == 'identity_patterns':
|
|
315
343
|
cursor.execute("""
|
|
@@ -339,6 +339,13 @@ async def recall_trace(request: Request):
|
|
|
339
339
|
except Exception:
|
|
340
340
|
pass
|
|
341
341
|
|
|
342
|
+
# Record learning signals (non-blocking, non-critical)
|
|
343
|
+
try:
|
|
344
|
+
_record_learning_signals(query, result.get("results", []))
|
|
345
|
+
except Exception as _sig_exc:
|
|
346
|
+
import logging as _log
|
|
347
|
+
_log.getLogger(__name__).warning("Learning signal error: %s", _sig_exc)
|
|
348
|
+
|
|
342
349
|
return {
|
|
343
350
|
"query": query,
|
|
344
351
|
"query_type": result.get("query_type", "unknown"),
|
|
@@ -351,6 +358,44 @@ async def recall_trace(request: Request):
|
|
|
351
358
|
return JSONResponse({"error": str(e)}, status_code=500)
|
|
352
359
|
|
|
353
360
|
|
|
361
|
+
def _record_learning_signals(query: str, results: list) -> None:
|
|
362
|
+
"""Record feedback + co-retrieval + confidence boost for any recall."""
|
|
363
|
+
from pathlib import Path
|
|
364
|
+
from superlocalmemory.core.config import SLMConfig
|
|
365
|
+
|
|
366
|
+
slm_dir = Path.home() / ".superlocalmemory"
|
|
367
|
+
config = SLMConfig.load()
|
|
368
|
+
pid = config.active_profile
|
|
369
|
+
fact_ids = [r.get("fact_id", "") for r in results[:10] if r.get("fact_id")]
|
|
370
|
+
if not fact_ids:
|
|
371
|
+
return
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
from superlocalmemory.learning.feedback import FeedbackCollector
|
|
375
|
+
collector = FeedbackCollector(slm_dir / "learning.db")
|
|
376
|
+
collector.record_implicit(
|
|
377
|
+
profile_id=pid, query=query,
|
|
378
|
+
fact_ids_returned=fact_ids, fact_ids_available=fact_ids,
|
|
379
|
+
)
|
|
380
|
+
except Exception:
|
|
381
|
+
pass
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
from superlocalmemory.learning.signals import LearningSignals
|
|
385
|
+
signals = LearningSignals(slm_dir / "learning.db")
|
|
386
|
+
signals.record_co_retrieval(pid, fact_ids)
|
|
387
|
+
except Exception:
|
|
388
|
+
pass
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
from superlocalmemory.learning.signals import LearningSignals
|
|
392
|
+
mem_db = str(slm_dir / "memory.db")
|
|
393
|
+
for fid in fact_ids[:5]:
|
|
394
|
+
LearningSignals.boost_confidence(mem_db, fid)
|
|
395
|
+
except Exception:
|
|
396
|
+
pass
|
|
397
|
+
|
|
398
|
+
|
|
354
399
|
# ── Trust Dashboard ──────────────────────────────────────────
|
|
355
400
|
|
|
356
401
|
@router.get("/trust/dashboard")
|
|
@@ -521,3 +566,51 @@ async def ide_connect(request: Request):
|
|
|
521
566
|
return {"results": results}
|
|
522
567
|
except Exception as e:
|
|
523
568
|
return JSONResponse({"error": str(e)}, status_code=500)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
# ── Active Memory (V3.1) ────────────────────────────────────
|
|
572
|
+
|
|
573
|
+
@router.get("/learning/signals")
|
|
574
|
+
async def learning_signals():
|
|
575
|
+
"""Get zero-cost learning signal statistics."""
|
|
576
|
+
try:
|
|
577
|
+
from superlocalmemory.learning.signals import LearningSignals
|
|
578
|
+
from superlocalmemory.core.config import SLMConfig
|
|
579
|
+
from superlocalmemory.server.routes.helpers import DB_PATH
|
|
580
|
+
learning_db = DB_PATH.parent / "learning.db"
|
|
581
|
+
signals = LearningSignals(learning_db)
|
|
582
|
+
config = SLMConfig.load()
|
|
583
|
+
pid = config.active_profile
|
|
584
|
+
return {"success": True, **signals.get_signal_stats(pid)}
|
|
585
|
+
except Exception as exc:
|
|
586
|
+
return {"success": False, "error": str(exc)}
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
@router.post("/learning/consolidate")
|
|
590
|
+
async def run_consolidation(request: Request):
|
|
591
|
+
"""Run sleep-time consolidation. Body: {dry_run: true/false}."""
|
|
592
|
+
try:
|
|
593
|
+
body = await request.json()
|
|
594
|
+
dry_run = body.get("dry_run", False)
|
|
595
|
+
from superlocalmemory.learning.consolidation_worker import ConsolidationWorker
|
|
596
|
+
from superlocalmemory.core.config import SLMConfig
|
|
597
|
+
from superlocalmemory.server.routes.helpers import DB_PATH
|
|
598
|
+
worker = ConsolidationWorker(
|
|
599
|
+
memory_db=str(DB_PATH),
|
|
600
|
+
learning_db=str(DB_PATH.parent / "learning.db"),
|
|
601
|
+
)
|
|
602
|
+
config = SLMConfig.load()
|
|
603
|
+
stats = worker.run(config.active_profile, dry_run=dry_run)
|
|
604
|
+
return {"success": True, **stats}
|
|
605
|
+
except Exception as exc:
|
|
606
|
+
return {"success": False, "error": str(exc)}
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
@router.get("/hooks/status")
|
|
610
|
+
async def hooks_status():
|
|
611
|
+
"""Check if Claude Code hooks are installed."""
|
|
612
|
+
try:
|
|
613
|
+
from superlocalmemory.hooks.claude_code_hooks import check_status
|
|
614
|
+
return {"success": True, **check_status()}
|
|
615
|
+
except Exception as exc:
|
|
616
|
+
return {"success": False, "error": str(exc)}
|