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.
@@ -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
- # Graph-first: fetch edges, then get connected nodes, then fill slots
49
+ # Recency-first: get the most recent nodes, then find their edges
50
50
  cursor.execute("""
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()
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
- connected_ids = set()
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 connected nodes first (these have edges to display)
64
- connected_nodes: list = []
65
- if connected_ids:
66
- ph = ','.join('?' * len(connected_ids))
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 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]
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
- return nodes, links, []
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
- if _has_table(cursor, 'scene_facts'):
375
+ # V3 schema: memory_scenes stores fact_ids_json (JSON array)
376
+ if _has_table(cursor, 'memory_scenes'):
366
377
  cursor.execute("""
367
- SELECT s.scene_id as cluster_id, COUNT(sf.fact_id) as member_count,
368
- s.summary, s.created_at as first_memory
369
- FROM scenes s JOIN scene_facts sf ON s.scene_id = sf.scene_id
370
- WHERE s.profile_id = ? GROUP BY s.scene_id ORDER BY member_count DESC
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
- clusters = [dict(r, top_entities=[]) for r in cursor.fetchall()]
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
- cursor.execute("SELECT COUNT(*) as c FROM memories WHERE cluster_id IS NULL AND profile = ?", (profile,))
386
- unclustered = cursor.fetchone()['c']
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: int, limit: int = Query(50, ge=1, le=200)):
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, 'scene_facts'):
404
- cursor.execute("""
405
- SELECT f.fact_id as id, f.content, f.fact_type as category,
406
- f.confidence as importance, f.created_at
407
- FROM atomic_facts f JOIN scene_facts sf ON f.fact_id = sf.fact_id
408
- WHERE sf.scene_id = ? AND f.profile_id = ? ORDER BY f.confidence DESC LIMIT ?
409
- """, (str(cluster_id), profile, limit))
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
- return {
310
- "patterns": {}, "total_patterns": 0, "pattern_types": [],
311
- "message": "Pattern learning not initialized.",
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)}