superlocalmemory 2.3.6 → 2.4.0

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/ui_server.py CHANGED
@@ -124,6 +124,23 @@ UI_DIR.mkdir(exist_ok=True)
124
124
  app.mount("/static", StaticFiles(directory=str(UI_DIR)), name="static")
125
125
 
126
126
 
127
+ # ============================================================================
128
+ # Profile Helper
129
+ # ============================================================================
130
+
131
+ def get_active_profile() -> str:
132
+ """Read the active profile from profiles.json. Falls back to 'default'."""
133
+ config_file = MEMORY_DIR / "profiles.json"
134
+ if config_file.exists():
135
+ try:
136
+ with open(config_file, 'r') as f:
137
+ pconfig = json.load(f)
138
+ return pconfig.get('active_profile', 'default')
139
+ except (json.JSONDecodeError, IOError):
140
+ pass
141
+ return 'default'
142
+
143
+
127
144
  # ============================================================================
128
145
  # Request/Response Models
129
146
  # ============================================================================
@@ -387,6 +404,8 @@ async def get_memories(
387
404
  conn.row_factory = dict_factory
388
405
  cursor = conn.cursor()
389
406
 
407
+ active_profile = get_active_profile()
408
+
390
409
  # Build dynamic query
391
410
  query = """
392
411
  SELECT
@@ -394,9 +413,9 @@ async def get_memories(
394
413
  importance, cluster_id, depth, access_count, parent_id,
395
414
  created_at, updated_at, last_accessed, tags, memory_type
396
415
  FROM memories
397
- WHERE 1=1
416
+ WHERE profile = ?
398
417
  """
399
- params = []
418
+ params = [active_profile]
400
419
 
401
420
  if category:
402
421
  query += " AND category = ?"
@@ -427,8 +446,8 @@ async def get_memories(
427
446
  memories = cursor.fetchall()
428
447
 
429
448
  # Get total count
430
- count_query = "SELECT COUNT(*) as total FROM memories WHERE 1=1"
431
- count_params = []
449
+ count_query = "SELECT COUNT(*) as total FROM memories WHERE profile = ?"
450
+ count_params = [active_profile]
432
451
 
433
452
  if category:
434
453
  count_query += " AND category = ?"
@@ -483,6 +502,8 @@ async def get_graph(
483
502
  conn.row_factory = dict_factory
484
503
  cursor = conn.cursor()
485
504
 
505
+ active_profile = get_active_profile()
506
+
486
507
  # Get nodes (memories with graph data)
487
508
  cursor.execute("""
488
509
  SELECT
@@ -492,10 +513,10 @@ async def get_graph(
492
513
  gn.entities
493
514
  FROM memories m
494
515
  LEFT JOIN graph_nodes gn ON m.id = gn.memory_id
495
- WHERE m.importance >= ?
516
+ WHERE m.importance >= ? AND m.profile = ?
496
517
  ORDER BY m.importance DESC, m.updated_at DESC
497
518
  LIMIT ?
498
- """, (min_importance, max_nodes))
519
+ """, (min_importance, active_profile, max_nodes))
499
520
  nodes = cursor.fetchall()
500
521
 
501
522
  # Parse entities JSON and create previews
@@ -551,9 +572,9 @@ async def get_graph(
551
572
  COUNT(*) as size,
552
573
  AVG(importance) as avg_importance
553
574
  FROM memories
554
- WHERE cluster_id IS NOT NULL
575
+ WHERE cluster_id IS NOT NULL AND profile = ?
555
576
  GROUP BY cluster_id
556
- """)
577
+ """, (active_profile,))
557
578
  clusters = cursor.fetchall()
558
579
 
559
580
  conn.close()
@@ -607,6 +628,8 @@ async def get_timeline(
607
628
  else: # month
608
629
  date_group = "strftime('%Y-%m', created_at)"
609
630
 
631
+ active_profile = get_active_profile()
632
+
610
633
  # Timeline aggregates
611
634
  cursor.execute(f"""
612
635
  SELECT
@@ -618,9 +641,10 @@ async def get_timeline(
618
641
  GROUP_CONCAT(DISTINCT category) as categories
619
642
  FROM memories
620
643
  WHERE created_at >= datetime('now', '-' || ? || ' days')
644
+ AND profile = ?
621
645
  GROUP BY {date_group}
622
646
  ORDER BY period DESC
623
- """, (days,))
647
+ """, (days, active_profile))
624
648
  timeline = cursor.fetchall()
625
649
 
626
650
  # Category trend over time
@@ -631,10 +655,10 @@ async def get_timeline(
631
655
  COUNT(*) as count
632
656
  FROM memories
633
657
  WHERE created_at >= datetime('now', '-' || ? || ' days')
634
- AND category IS NOT NULL
658
+ AND category IS NOT NULL AND profile = ?
635
659
  GROUP BY {date_group}, category
636
660
  ORDER BY period DESC, count DESC
637
- """, (days,))
661
+ """, (days, active_profile))
638
662
  category_trend = cursor.fetchall()
639
663
 
640
664
  # Period statistics
@@ -646,7 +670,8 @@ async def get_timeline(
646
670
  AVG(importance) as avg_importance
647
671
  FROM memories
648
672
  WHERE created_at >= datetime('now', '-' || ? || ' days')
649
- """, (days,))
673
+ AND profile = ?
674
+ """, (days, active_profile))
650
675
  period_stats = cursor.fetchone()
651
676
 
652
677
  conn.close()
@@ -680,6 +705,8 @@ async def get_clusters():
680
705
  conn.row_factory = dict_factory
681
706
  cursor = conn.cursor()
682
707
 
708
+ active_profile = get_active_profile()
709
+
683
710
  # Get cluster statistics
684
711
  cursor.execute("""
685
712
  SELECT
@@ -693,10 +720,10 @@ async def get_clusters():
693
720
  MIN(created_at) as first_memory,
694
721
  MAX(created_at) as latest_memory
695
722
  FROM memories
696
- WHERE cluster_id IS NOT NULL
723
+ WHERE cluster_id IS NOT NULL AND profile = ?
697
724
  GROUP BY cluster_id
698
725
  ORDER BY member_count DESC
699
- """)
726
+ """, (active_profile,))
700
727
  clusters = cursor.fetchall()
701
728
 
702
729
  # Get dominant entities per cluster
@@ -732,8 +759,8 @@ async def get_clusters():
732
759
  cursor.execute("""
733
760
  SELECT COUNT(*) as count
734
761
  FROM memories
735
- WHERE cluster_id IS NULL
736
- """)
762
+ WHERE cluster_id IS NULL AND profile = ?
763
+ """, (active_profile,))
737
764
  unclustered = cursor.fetchone()['count']
738
765
 
739
766
  conn.close()
@@ -872,13 +899,16 @@ async def get_patterns():
872
899
  "message": "Pattern learning not initialized. Run pattern learning first."
873
900
  }
874
901
 
902
+ active_profile = get_active_profile()
903
+
875
904
  cursor.execute("""
876
905
  SELECT
877
906
  pattern_type, key, value, confidence,
878
907
  evidence_count, updated_at as last_updated
879
908
  FROM identity_patterns
909
+ WHERE profile = ?
880
910
  ORDER BY confidence DESC, evidence_count DESC
881
- """)
911
+ """, (active_profile,))
882
912
  patterns = cursor.fetchall()
883
913
 
884
914
  # Parse value JSON
@@ -937,14 +967,16 @@ async def get_stats():
937
967
  conn.row_factory = dict_factory
938
968
  cursor = conn.cursor()
939
969
 
940
- # Basic counts
941
- cursor.execute("SELECT COUNT(*) as total FROM memories")
970
+ active_profile = get_active_profile()
971
+
972
+ # Basic counts (profile-filtered)
973
+ cursor.execute("SELECT COUNT(*) as total FROM memories WHERE profile = ?", (active_profile,))
942
974
  total_memories = cursor.fetchone()['total']
943
975
 
944
976
  cursor.execute("SELECT COUNT(*) as total FROM sessions")
945
977
  total_sessions = cursor.fetchone()['total']
946
978
 
947
- cursor.execute("SELECT COUNT(DISTINCT cluster_id) as total FROM memories WHERE cluster_id IS NOT NULL")
979
+ cursor.execute("SELECT COUNT(DISTINCT cluster_id) as total FROM memories WHERE cluster_id IS NOT NULL AND profile = ?", (active_profile,))
948
980
  total_clusters = cursor.fetchone()['total']
949
981
 
950
982
  cursor.execute("SELECT COUNT(*) as total FROM graph_nodes")
@@ -1115,30 +1147,40 @@ async def search_memories(request: SearchRequest):
1115
1147
  @app.get("/api/profiles")
1116
1148
  async def list_profiles():
1117
1149
  """
1118
- List available memory profiles.
1150
+ List available memory profiles (column-based).
1119
1151
 
1120
1152
  Returns:
1121
- - profiles: List of profile names
1153
+ - profiles: List of profiles with memory counts
1122
1154
  - active_profile: Currently active profile
1123
1155
  - total_profiles: Profile count
1124
1156
  """
1125
1157
  try:
1126
- PROFILES_DIR.mkdir(exist_ok=True)
1158
+ config_file = MEMORY_DIR / "profiles.json"
1159
+ if config_file.exists():
1160
+ with open(config_file, 'r') as f:
1161
+ config = json.load(f)
1162
+ else:
1163
+ config = {'profiles': {'default': {'name': 'default', 'description': 'Default memory profile'}}, 'active_profile': 'default'}
1127
1164
 
1165
+ active = config.get('active_profile', 'default')
1128
1166
  profiles = []
1129
- for profile_dir in PROFILES_DIR.iterdir():
1130
- if profile_dir.is_dir():
1131
- db_file = profile_dir / "memory.db"
1132
- if db_file.exists():
1133
- profiles.append({
1134
- "name": profile_dir.name,
1135
- "path": str(profile_dir),
1136
- "size_mb": round(db_file.stat().st_size / (1024 * 1024), 2),
1137
- "modified": datetime.fromtimestamp(db_file.stat().st_mtime).isoformat()
1138
- })
1139
1167
 
1140
- # Determine active profile (default is main)
1141
- active = "default"
1168
+ conn = get_db_connection()
1169
+ cursor = conn.cursor()
1170
+
1171
+ for name, info in config.get('profiles', {}).items():
1172
+ cursor.execute("SELECT COUNT(*) FROM memories WHERE profile = ?", (name,))
1173
+ count = cursor.fetchone()[0]
1174
+ profiles.append({
1175
+ "name": name,
1176
+ "description": info.get('description', ''),
1177
+ "memory_count": count,
1178
+ "created_at": info.get('created_at', ''),
1179
+ "last_used": info.get('last_used', ''),
1180
+ "is_active": name == active
1181
+ })
1182
+
1183
+ conn.close()
1142
1184
 
1143
1185
  return {
1144
1186
  "profiles": profiles,
@@ -1153,7 +1195,7 @@ async def list_profiles():
1153
1195
  @app.post("/api/profiles/{name}/switch")
1154
1196
  async def switch_profile(name: str):
1155
1197
  """
1156
- Switch active memory profile.
1198
+ Switch active memory profile (column-based, instant).
1157
1199
 
1158
1200
  Parameters:
1159
1201
  - name: Profile name to switch to
@@ -1161,7 +1203,8 @@ async def switch_profile(name: str):
1161
1203
  Returns:
1162
1204
  - success: Switch status
1163
1205
  - active_profile: New active profile
1164
- - message: Status message
1206
+ - previous_profile: Previously active profile
1207
+ - memory_count: Memories in new profile
1165
1208
  """
1166
1209
  try:
1167
1210
  if not validate_profile_name(name):
@@ -1170,20 +1213,48 @@ async def switch_profile(name: str):
1170
1213
  detail="Invalid profile name. Use alphanumeric, underscore, or hyphen only."
1171
1214
  )
1172
1215
 
1173
- profile_path = PROFILES_DIR / name / "memory.db"
1216
+ config_file = MEMORY_DIR / "profiles.json"
1217
+ if config_file.exists():
1218
+ with open(config_file, 'r') as f:
1219
+ config = json.load(f)
1220
+ else:
1221
+ config = {'profiles': {'default': {'name': 'default', 'description': 'Default memory profile'}}, 'active_profile': 'default'}
1174
1222
 
1175
- if not profile_path.exists():
1223
+ if name not in config.get('profiles', {}):
1176
1224
  raise HTTPException(
1177
1225
  status_code=404,
1178
- detail=f"Profile '{name}' not found"
1226
+ detail=f"Profile '{name}' not found. Available: {', '.join(config.get('profiles', {}).keys())}"
1179
1227
  )
1180
1228
 
1181
- # Note: Actual profile switching would require modifying DB_PATH
1182
- # This is a placeholder implementation
1229
+ previous = config.get('active_profile', 'default')
1230
+ config['active_profile'] = name
1231
+ config['profiles'][name]['last_used'] = datetime.now().isoformat()
1232
+
1233
+ with open(config_file, 'w') as f:
1234
+ json.dump(config, f, indent=2)
1235
+
1236
+ # Get memory count for new profile
1237
+ conn = get_db_connection()
1238
+ cursor = conn.cursor()
1239
+ cursor.execute("SELECT COUNT(*) FROM memories WHERE profile = ?", (name,))
1240
+ count = cursor.fetchone()[0]
1241
+ conn.close()
1242
+
1243
+ # Broadcast profile switch to WebSocket clients
1244
+ await manager.broadcast({
1245
+ "type": "profile_switched",
1246
+ "profile": name,
1247
+ "previous": previous,
1248
+ "memory_count": count,
1249
+ "timestamp": datetime.now().isoformat()
1250
+ })
1251
+
1183
1252
  return {
1184
1253
  "success": True,
1185
1254
  "active_profile": name,
1186
- "message": f"Profile switched to '{name}'. Restart server to apply changes."
1255
+ "previous_profile": previous,
1256
+ "memory_count": count,
1257
+ "message": f"Switched to profile '{name}' ({count} memories). Changes take effect immediately."
1187
1258
  }
1188
1259
 
1189
1260
  except HTTPException:
@@ -1192,6 +1263,97 @@ async def switch_profile(name: str):
1192
1263
  raise HTTPException(status_code=500, detail=f"Profile switch error: {str(e)}")
1193
1264
 
1194
1265
 
1266
+ @app.post("/api/profiles/create")
1267
+ async def create_profile(body: ProfileSwitch):
1268
+ """
1269
+ Create a new memory profile.
1270
+
1271
+ Parameters:
1272
+ - profile_name: Name for the new profile
1273
+
1274
+ Returns:
1275
+ - success: Creation status
1276
+ - profile: Created profile name
1277
+ """
1278
+ try:
1279
+ name = body.profile_name
1280
+ if not validate_profile_name(name):
1281
+ raise HTTPException(status_code=400, detail="Invalid profile name")
1282
+
1283
+ config_file = MEMORY_DIR / "profiles.json"
1284
+ if config_file.exists():
1285
+ with open(config_file, 'r') as f:
1286
+ config = json.load(f)
1287
+ else:
1288
+ config = {'profiles': {'default': {'name': 'default', 'description': 'Default memory profile'}}, 'active_profile': 'default'}
1289
+
1290
+ if name in config.get('profiles', {}):
1291
+ raise HTTPException(status_code=409, detail=f"Profile '{name}' already exists")
1292
+
1293
+ config['profiles'][name] = {
1294
+ 'name': name,
1295
+ 'description': f'Memory profile: {name}',
1296
+ 'created_at': datetime.now().isoformat(),
1297
+ 'last_used': None
1298
+ }
1299
+
1300
+ with open(config_file, 'w') as f:
1301
+ json.dump(config, f, indent=2)
1302
+
1303
+ return {
1304
+ "success": True,
1305
+ "profile": name,
1306
+ "message": f"Profile '{name}' created"
1307
+ }
1308
+
1309
+ except HTTPException:
1310
+ raise
1311
+ except Exception as e:
1312
+ raise HTTPException(status_code=500, detail=f"Profile create error: {str(e)}")
1313
+
1314
+
1315
+ @app.delete("/api/profiles/{name}")
1316
+ async def delete_profile(name: str):
1317
+ """
1318
+ Delete a profile. Moves its memories to 'default'.
1319
+ """
1320
+ try:
1321
+ if name == 'default':
1322
+ raise HTTPException(status_code=400, detail="Cannot delete 'default' profile")
1323
+
1324
+ config_file = MEMORY_DIR / "profiles.json"
1325
+ with open(config_file, 'r') as f:
1326
+ config = json.load(f)
1327
+
1328
+ if name not in config.get('profiles', {}):
1329
+ raise HTTPException(status_code=404, detail=f"Profile '{name}' not found")
1330
+
1331
+ if config.get('active_profile') == name:
1332
+ raise HTTPException(status_code=400, detail="Cannot delete active profile. Switch first.")
1333
+
1334
+ # Move memories to default
1335
+ conn = get_db_connection()
1336
+ cursor = conn.cursor()
1337
+ cursor.execute("UPDATE memories SET profile = 'default' WHERE profile = ?", (name,))
1338
+ moved = cursor.rowcount
1339
+ conn.commit()
1340
+ conn.close()
1341
+
1342
+ del config['profiles'][name]
1343
+ with open(config_file, 'w') as f:
1344
+ json.dump(config, f, indent=2)
1345
+
1346
+ return {
1347
+ "success": True,
1348
+ "message": f"Profile '{name}' deleted. {moved} memories moved to 'default'."
1349
+ }
1350
+
1351
+ except HTTPException:
1352
+ raise
1353
+ except Exception as e:
1354
+ raise HTTPException(status_code=500, detail=f"Profile delete error: {str(e)}")
1355
+
1356
+
1195
1357
  # ============================================================================
1196
1358
  # API Endpoints - Import/Export
1197
1359
  # ============================================================================
@@ -1366,6 +1528,141 @@ async def import_memories(file: UploadFile = File(...)):
1366
1528
  raise HTTPException(status_code=500, detail=f"Import error: {str(e)}")
1367
1529
 
1368
1530
 
1531
+ # ============================================================================
1532
+ # API Endpoints - Backup Management
1533
+ # ============================================================================
1534
+
1535
+ class BackupConfigRequest(BaseModel):
1536
+ """Backup configuration update request."""
1537
+ interval_hours: Optional[int] = Field(None, ge=1, le=8760)
1538
+ max_backups: Optional[int] = Field(None, ge=1, le=100)
1539
+ enabled: Optional[bool] = None
1540
+
1541
+
1542
+ @app.get("/api/backup/status")
1543
+ async def backup_status():
1544
+ """
1545
+ Get auto-backup system status.
1546
+
1547
+ Returns:
1548
+ - enabled: Whether auto-backup is active
1549
+ - interval_display: Human-readable interval
1550
+ - last_backup: Timestamp of last backup
1551
+ - next_backup: When next backup is due
1552
+ - backup_count: Number of existing backups
1553
+ - total_size_mb: Total backup storage used
1554
+ """
1555
+ try:
1556
+ from auto_backup import AutoBackup
1557
+ backup = AutoBackup()
1558
+ return backup.get_status()
1559
+ except ImportError:
1560
+ raise HTTPException(
1561
+ status_code=501,
1562
+ detail="Auto-backup module not installed. Update SuperLocalMemory to v2.4.0+."
1563
+ )
1564
+ except Exception as e:
1565
+ raise HTTPException(status_code=500, detail=f"Backup status error: {str(e)}")
1566
+
1567
+
1568
+ @app.post("/api/backup/create")
1569
+ async def backup_create():
1570
+ """
1571
+ Create a manual backup of memory.db immediately.
1572
+
1573
+ Returns:
1574
+ - success: Whether backup was created
1575
+ - filename: Name of the backup file
1576
+ - status: Updated backup system status
1577
+ """
1578
+ try:
1579
+ from auto_backup import AutoBackup
1580
+ backup = AutoBackup()
1581
+ filename = backup.create_backup(label='manual')
1582
+
1583
+ if filename:
1584
+ return {
1585
+ "success": True,
1586
+ "filename": filename,
1587
+ "message": f"Backup created: {filename}",
1588
+ "status": backup.get_status()
1589
+ }
1590
+ else:
1591
+ return {
1592
+ "success": False,
1593
+ "message": "Backup failed — database may not exist",
1594
+ "status": backup.get_status()
1595
+ }
1596
+ except ImportError:
1597
+ raise HTTPException(
1598
+ status_code=501,
1599
+ detail="Auto-backup module not installed. Update SuperLocalMemory to v2.4.0+."
1600
+ )
1601
+ except Exception as e:
1602
+ raise HTTPException(status_code=500, detail=f"Backup create error: {str(e)}")
1603
+
1604
+
1605
+ @app.post("/api/backup/configure")
1606
+ async def backup_configure(request: BackupConfigRequest):
1607
+ """
1608
+ Update auto-backup configuration.
1609
+
1610
+ Request body (all optional):
1611
+ - interval_hours: Hours between backups (24=daily, 168=weekly)
1612
+ - max_backups: Maximum backup files to retain
1613
+ - enabled: Enable/disable auto-backup
1614
+
1615
+ Returns:
1616
+ - Updated backup status
1617
+ """
1618
+ try:
1619
+ from auto_backup import AutoBackup
1620
+ backup = AutoBackup()
1621
+ result = backup.configure(
1622
+ interval_hours=request.interval_hours,
1623
+ max_backups=request.max_backups,
1624
+ enabled=request.enabled
1625
+ )
1626
+ return {
1627
+ "success": True,
1628
+ "message": "Backup configuration updated",
1629
+ "status": result
1630
+ }
1631
+ except ImportError:
1632
+ raise HTTPException(
1633
+ status_code=501,
1634
+ detail="Auto-backup module not installed. Update SuperLocalMemory to v2.4.0+."
1635
+ )
1636
+ except Exception as e:
1637
+ raise HTTPException(status_code=500, detail=f"Backup configure error: {str(e)}")
1638
+
1639
+
1640
+ @app.get("/api/backup/list")
1641
+ async def backup_list():
1642
+ """
1643
+ List all available backups.
1644
+
1645
+ Returns:
1646
+ - backups: List of backup files with metadata (filename, size, age, created)
1647
+ - count: Total number of backups
1648
+ """
1649
+ try:
1650
+ from auto_backup import AutoBackup
1651
+ backup = AutoBackup()
1652
+ backups = backup.list_backups()
1653
+ return {
1654
+ "backups": backups,
1655
+ "count": len(backups)
1656
+ }
1657
+ except ImportError:
1658
+ raise HTTPException(
1659
+ status_code=501,
1660
+ detail="Auto-backup module not installed. Update SuperLocalMemory to v2.4.0+."
1661
+ )
1662
+ except Exception as e:
1663
+ raise HTTPException(status_code=500, detail=f"Backup list error: {str(e)}")
1664
+
1665
+
1369
1666
  # ============================================================================
1370
1667
  # WebSocket Endpoint - Real-Time Updates
1371
1668
  # ============================================================================