get-claudia 1.46.0 → 1.47.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.
@@ -143,13 +143,14 @@ def _check_and_repair_database(db_path: Path) -> None:
143
143
  return
144
144
 
145
145
  backup_pattern = str(db_path) + ".backup-*.db"
146
- backups = sorted(glob.glob(backup_pattern))
146
+ backups = sorted(glob.glob(backup_pattern), key=os.path.getmtime)
147
147
  if backups:
148
148
  latest = backups[-1]
149
149
  logger.warning(f"Restoring database from backup: {latest}")
150
150
  shutil.copy2(latest, db_path)
151
- for suffix in (".db-shm", ".db-wal"):
152
- stale = Path(str(db_path).replace(".db", "") + suffix)
151
+ # Clean up stale WAL files using direct path concatenation
152
+ for suffix in ("-shm", "-wal"):
153
+ stale = Path(str(db_path) + suffix)
153
154
  if stale.exists():
154
155
  stale.unlink()
155
156
  logger.info(f"Removed stale WAL file: {stale}")
@@ -162,6 +163,104 @@ def _check_and_repair_database(db_path: Path) -> None:
162
163
  )
163
164
 
164
165
 
166
+ def _auto_migrate_legacy() -> None:
167
+ """Auto-migrate data from legacy claudia.db if it exists.
168
+
169
+ When Claudia switched from a single claudia.db to project-hash naming
170
+ ({sha256[:12]}.db), no data migration was performed. This function
171
+ detects the orphaned legacy database and migrates its data into the
172
+ active project-specific database.
173
+
174
+ Properties:
175
+ - Idempotent: checks _meta flag, won't run twice
176
+ - Safe: backs up before touching anything, preserves original
177
+ - Non-fatal: catches all exceptions, logs, continues
178
+ """
179
+ from .migration import (
180
+ check_legacy_database,
181
+ is_migration_completed,
182
+ mark_migration_completed,
183
+ migrate_legacy_database,
184
+ )
185
+
186
+ try:
187
+ config = get_config()
188
+ legacy_path = Path.home() / ".claudia" / "memory" / "claudia.db"
189
+ active_path = Path(config.db_path)
190
+
191
+ # Skip if active db IS the legacy db (no project isolation active)
192
+ try:
193
+ if legacy_path.resolve() == active_path.resolve():
194
+ return
195
+ except OSError:
196
+ if str(legacy_path) == str(active_path):
197
+ return
198
+
199
+ # Skip if legacy database doesn't exist
200
+ if not legacy_path.exists():
201
+ return
202
+
203
+ # Skip if migration already completed (idempotent)
204
+ db = get_db()
205
+ if is_migration_completed(db):
206
+ return
207
+
208
+ # Check if legacy database has meaningful data
209
+ legacy_stats = check_legacy_database(legacy_path)
210
+ if not legacy_stats:
211
+ # Empty or unreadable legacy db -- mark complete so we don't check again
212
+ mark_migration_completed(db, {"skipped": "no_data"})
213
+ logger.info("Legacy claudia.db exists but has no data worth migrating")
214
+ return
215
+
216
+ logger.info(
217
+ f"Found legacy claudia.db with {legacy_stats.get('entities', 0)} entities "
218
+ f"and {legacy_stats.get('memories', 0)} memories"
219
+ )
220
+
221
+ # Create pre-migration backup of active database (if it has data)
222
+ if active_path.exists():
223
+ try:
224
+ backup_path = db.backup(label="pre-migration")
225
+ logger.info(f"Pre-migration backup created: {backup_path}")
226
+ except Exception as e:
227
+ logger.warning(f"Pre-migration backup failed: {e}")
228
+ # Continue anyway -- the migration is additive, not destructive
229
+
230
+ # Run the migration
231
+ logger.info(f"Starting legacy database migration: {legacy_path} -> {active_path}")
232
+ results = migrate_legacy_database(legacy_path, active_path)
233
+
234
+ # Mark migration as completed
235
+ mark_migration_completed(db, results)
236
+
237
+ # Rename the legacy database (preserve, don't delete)
238
+ from datetime import datetime as dt
239
+ date_suffix = dt.now().strftime("%Y-%m-%d")
240
+ migrated_path = legacy_path.with_suffix(f".db.migrated-{date_suffix}")
241
+ try:
242
+ legacy_path.rename(migrated_path)
243
+ logger.info(f"Renamed legacy database: {legacy_path} -> {migrated_path}")
244
+ except OSError as e:
245
+ logger.warning(f"Could not rename legacy database: {e}")
246
+
247
+ # Log summary
248
+ logger.info(
249
+ f"Legacy migration complete: "
250
+ f"{results.get('entities_created', 0)} entities created, "
251
+ f"{results.get('entities_mapped', 0)} mapped, "
252
+ f"{results.get('memories_migrated', 0)} memories migrated, "
253
+ f"{results.get('links_migrated', 0)} links migrated, "
254
+ f"{results.get('relationships_migrated', 0)} relationships migrated"
255
+ )
256
+
257
+ except Exception as e:
258
+ # Non-fatal: log error and continue with whatever data we have
259
+ logger.error(f"Legacy migration failed (non-fatal): {e}")
260
+ logger.info("Daemon will continue with current database. "
261
+ "Run --migrate-legacy manually to retry.")
262
+
263
+
165
264
  def run_daemon(mcp_mode: bool = True, debug: bool = False, project_id: str = None) -> None:
166
265
  """
167
266
  Run the Claudia Memory Daemon.
@@ -207,6 +306,9 @@ def run_daemon(mcp_mode: bool = True, debug: bool = False, project_id: str = Non
207
306
  db.initialize()
208
307
  logger.info(f"Database initialized at {get_config().db_path}")
209
308
 
309
+ # Auto-migrate legacy claudia.db if it exists
310
+ _auto_migrate_legacy()
311
+
210
312
  # Start health server and scheduler - ONLY in standalone mode.
211
313
  # MCP server processes are ephemeral and session-bound; the standalone
212
314
  # daemon (LaunchAgent/systemd) owns port 3848 and handles scheduling.
@@ -324,6 +426,21 @@ def main():
324
426
  action="store_true",
325
427
  help="Preview mode for --migrate-vault-para: show routing plan without making changes",
326
428
  )
429
+ parser.add_argument(
430
+ "--migrate-legacy",
431
+ action="store_true",
432
+ help="Manually migrate data from legacy claudia.db to project-specific database",
433
+ )
434
+ parser.add_argument(
435
+ "--dry-run",
436
+ action="store_true",
437
+ help="Preview migration without making changes (use with --migrate-legacy)",
438
+ )
439
+ parser.add_argument(
440
+ "--legacy-db",
441
+ type=str,
442
+ help="Path to legacy database (default: ~/.claudia/memory/claudia.db)",
443
+ )
327
444
 
328
445
  args = parser.parse_args()
329
446
 
@@ -857,6 +974,87 @@ def main():
857
974
  run_para_migration(vault_path, db=db, preview=args.preview)
858
975
  return
859
976
 
977
+ if args.migrate_legacy:
978
+ # Manual legacy database migration
979
+ setup_logging(debug=args.debug)
980
+ from .migration import (
981
+ check_legacy_database,
982
+ is_migration_completed,
983
+ mark_migration_completed,
984
+ migrate_legacy_database,
985
+ )
986
+
987
+ db = get_db()
988
+ db.initialize()
989
+ config = get_config()
990
+
991
+ # Resolve paths
992
+ legacy_path = Path(args.legacy_db) if args.legacy_db else (
993
+ Path.home() / ".claudia" / "memory" / "claudia.db"
994
+ )
995
+ active_path = Path(config.db_path)
996
+
997
+ if not legacy_path.exists():
998
+ print(f"Legacy database not found: {legacy_path}")
999
+ sys.exit(1)
1000
+
1001
+ if str(legacy_path.resolve()) == str(active_path.resolve()):
1002
+ print("Error: Legacy and active databases are the same file.")
1003
+ print("Use --project-dir to specify a project for isolation.")
1004
+ sys.exit(1)
1005
+
1006
+ # Check legacy data
1007
+ legacy_stats = check_legacy_database(legacy_path)
1008
+ if not legacy_stats:
1009
+ print(f"Legacy database at {legacy_path} has no data to migrate.")
1010
+ return
1011
+
1012
+ print(f"\nLegacy database: {legacy_path}")
1013
+ print(f"Active database: {active_path}")
1014
+ print(f" Entities: {legacy_stats.get('entities', 0)}")
1015
+ print(f" Memories: {legacy_stats.get('memories', 0)}")
1016
+ print(f" Links: {legacy_stats.get('links', 0)}")
1017
+ print(f" Relationships: {legacy_stats.get('relationships', 0)}")
1018
+ if legacy_stats.get("earliest"):
1019
+ print(f" Date range: {legacy_stats['earliest']} to {legacy_stats['latest']}")
1020
+
1021
+ if is_migration_completed(db):
1022
+ print("\nNote: Migration was already completed previously.")
1023
+ if not args.dry_run:
1024
+ confirm = input("Run again? (y/N): ").strip().lower()
1025
+ if confirm != "y":
1026
+ print("Cancelled.")
1027
+ return
1028
+
1029
+ if args.dry_run:
1030
+ print("\nDry run mode -- no changes will be made.\n")
1031
+ results = migrate_legacy_database(legacy_path, active_path, dry_run=True)
1032
+ else:
1033
+ # Backup active database before migration
1034
+ if active_path.exists():
1035
+ backup_path = db.backup(label="pre-migration")
1036
+ print(f"\nBackup created: {backup_path}")
1037
+
1038
+ print("\nMigrating...")
1039
+ results = migrate_legacy_database(legacy_path, active_path)
1040
+ mark_migration_completed(db, results)
1041
+
1042
+ # Rename legacy database
1043
+ from datetime import datetime as dt
1044
+ date_suffix = dt.now().strftime("%Y-%m-%d")
1045
+ migrated_path = legacy_path.with_suffix(f".db.migrated-{date_suffix}")
1046
+ try:
1047
+ legacy_path.rename(migrated_path)
1048
+ print(f"Renamed: {legacy_path.name} -> {migrated_path.name}")
1049
+ except OSError as e:
1050
+ print(f"Warning: Could not rename legacy database: {e}")
1051
+
1052
+ print(f"\nResults:")
1053
+ for key, value in results.items():
1054
+ if value > 0:
1055
+ print(f" {key}: {value}")
1056
+ return
1057
+
860
1058
  # Run the daemon
861
1059
  run_daemon(mcp_mode=not args.standalone, debug=args.debug, project_id=project_id)
862
1060
 
@@ -80,6 +80,8 @@ class MemoryConfig:
80
80
  # Backup settings
81
81
  backup_retention_count: int = 3 # Number of rolling backups to keep
82
82
  enable_pre_consolidation_backup: bool = True # Auto-backup before consolidation
83
+ backup_daily_retention: int = 7 # Keep 7 daily labeled backups (1 week)
84
+ backup_weekly_retention: int = 4 # Keep 4 weekly labeled backups (1 month)
83
85
 
84
86
  # Retention settings (data cleanup during consolidation)
85
87
  audit_log_retention_days: int = 90
@@ -154,6 +156,10 @@ class MemoryConfig:
154
156
  config.backup_retention_count = data["backup_retention_count"]
155
157
  if "enable_pre_consolidation_backup" in data:
156
158
  config.enable_pre_consolidation_backup = data["enable_pre_consolidation_backup"]
159
+ if "backup_daily_retention" in data:
160
+ config.backup_daily_retention = data["backup_daily_retention"]
161
+ if "backup_weekly_retention" in data:
162
+ config.backup_weekly_retention = data["backup_weekly_retention"]
157
163
  if "audit_log_retention_days" in data:
158
164
  config.audit_log_retention_days = data["audit_log_retention_days"]
159
165
  if "prediction_retention_days" in data:
@@ -241,6 +247,12 @@ class MemoryConfig:
241
247
  if self.backup_retention_count < 1:
242
248
  logger.warning(f"backup_retention_count={self.backup_retention_count} below minimum, using 1")
243
249
  self.backup_retention_count = 1
250
+ if self.backup_daily_retention < 1:
251
+ logger.warning(f"backup_daily_retention={self.backup_daily_retention} below minimum, using 1")
252
+ self.backup_daily_retention = 1
253
+ if self.backup_weekly_retention < 1:
254
+ logger.warning(f"backup_weekly_retention={self.backup_weekly_retention} below minimum, using 1")
255
+ self.backup_weekly_retention = 1
244
256
  for attr in ("audit_log_retention_days", "prediction_retention_days", "turn_buffer_retention_days", "metrics_retention_days"):
245
257
  val = getattr(self, attr)
246
258
  if val < 1:
@@ -305,6 +317,8 @@ class MemoryConfig:
305
317
  "enable_auto_dedupe": self.enable_auto_dedupe,
306
318
  "auto_dedupe_threshold": self.auto_dedupe_threshold,
307
319
  "graph_proximity_weight": self.graph_proximity_weight,
320
+ "backup_daily_retention": self.backup_daily_retention,
321
+ "backup_weekly_retention": self.backup_weekly_retention,
308
322
  }
309
323
 
310
324
  with open(config_path, "w") as f:
@@ -64,6 +64,24 @@ class MemoryScheduler:
64
64
  replace_existing=True,
65
65
  )
66
66
 
67
+ # Daily at 2:30am: Labeled daily backup (7-day retention)
68
+ self.scheduler.add_job(
69
+ self._run_daily_backup,
70
+ CronTrigger(hour=2, minute=30),
71
+ id="daily_backup",
72
+ name="Daily backup",
73
+ replace_existing=True,
74
+ )
75
+
76
+ # Weekly on Sunday at 2:45am: Labeled weekly backup (4-week retention)
77
+ self.scheduler.add_job(
78
+ self._run_weekly_backup,
79
+ CronTrigger(day_of_week="sun", hour=2, minute=45),
80
+ id="weekly_backup",
81
+ name="Weekly backup",
82
+ replace_existing=True,
83
+ )
84
+
67
85
  # Daily at 3:15am: Vault sync (after consolidation)
68
86
  if self.config.vault_sync_enabled:
69
87
  self.scheduler.add_job(
@@ -128,6 +146,24 @@ class MemoryScheduler:
128
146
  except Exception as e:
129
147
  logger.exception("Error in full consolidation")
130
148
 
149
+ def _run_daily_backup(self) -> None:
150
+ """Create a labeled daily backup with 7-day retention."""
151
+ try:
152
+ from ..database import get_db
153
+ backup_path = get_db().backup(label="daily")
154
+ logger.info(f"Daily backup created: {backup_path}")
155
+ except Exception as e:
156
+ logger.exception("Error in daily backup")
157
+
158
+ def _run_weekly_backup(self) -> None:
159
+ """Create a labeled weekly backup with 4-week retention."""
160
+ try:
161
+ from ..database import get_db
162
+ backup_path = get_db().backup(label="weekly")
163
+ logger.info(f"Weekly backup created: {backup_path}")
164
+ except Exception as e:
165
+ logger.exception("Error in weekly backup")
166
+
131
167
  def _run_vault_sync(self) -> None:
132
168
  """Run Obsidian vault sync + canvas regeneration"""
133
169
  try:
@@ -254,6 +254,9 @@ class Database:
254
254
  # Store workspace path in _meta for database identification
255
255
  self._store_workspace_path(conn)
256
256
 
257
+ # Register database in central registry
258
+ self._register_database()
259
+
257
260
  self._initialized = True
258
261
 
259
262
  # All vec0 virtual tables and their primary key columns
@@ -1133,6 +1136,49 @@ class Database:
1133
1136
  conn.commit()
1134
1137
  logger.debug(f"Stored workspace path in _meta: {workspace_path}")
1135
1138
 
1139
+ def _register_database(self) -> None:
1140
+ """Register this database in the central registry.
1141
+
1142
+ Maintains ~/.claudia/memory/registry.json with all known databases,
1143
+ their workspace paths, and last-seen timestamps. Used by the visualizer
1144
+ and /databases command to enumerate databases.
1145
+ """
1146
+ registry_path = Path.home() / ".claudia" / "memory" / "registry.json"
1147
+
1148
+ try:
1149
+ if registry_path.exists():
1150
+ with open(registry_path) as f:
1151
+ registry = json.load(f)
1152
+ else:
1153
+ registry = {"databases": []}
1154
+
1155
+ # Find or create entry for this database path
1156
+ db_str = str(self.db_path)
1157
+ entry = next(
1158
+ (d for d in registry["databases"] if d["path"] == db_str), None
1159
+ )
1160
+
1161
+ workspace = os.environ.get("CLAUDIA_WORKSPACE_PATH", "")
1162
+ name = self.db_path.stem # e.g., "6af67351bcfa" or "claudia"
1163
+
1164
+ if entry:
1165
+ entry["workspace"] = workspace or entry.get("workspace", "")
1166
+ entry["last_seen"] = datetime.now().isoformat()
1167
+ else:
1168
+ registry["databases"].append({
1169
+ "path": db_str,
1170
+ "workspace": workspace,
1171
+ "name": name,
1172
+ "registered_at": datetime.now().isoformat(),
1173
+ "last_seen": datetime.now().isoformat(),
1174
+ })
1175
+
1176
+ registry_path.parent.mkdir(parents=True, exist_ok=True)
1177
+ with open(registry_path, "w") as f:
1178
+ json.dump(registry, f, indent=2)
1179
+ except Exception as e:
1180
+ logger.debug(f"Registry update failed (non-fatal): {e}")
1181
+
1136
1182
  def execute(
1137
1183
  self, sql: str, params: Tuple = (), fetch: bool = False
1138
1184
  ) -> Optional[List[sqlite3.Row]]:
@@ -1213,9 +1259,13 @@ class Database:
1213
1259
  rows = self.query(table, columns, where, where_params, limit=1)
1214
1260
  return rows[0] if rows else None
1215
1261
 
1216
- def backup(self) -> Path:
1262
+ def backup(self, label: str = None) -> Path:
1217
1263
  """Create a backup of the database using SQLite's online backup API.
1218
1264
 
1265
+ Args:
1266
+ label: Optional label for categorized backups (e.g., "daily", "weekly",
1267
+ "pre-migration"). Labeled backups have independent retention counts.
1268
+
1219
1269
  Returns:
1220
1270
  Path to the created backup file
1221
1271
  """
@@ -1223,7 +1273,10 @@ class Database:
1223
1273
 
1224
1274
  config = get_config()
1225
1275
  timestamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
1226
- backup_path = Path(f"{self.db_path}.backup-{timestamp}.db")
1276
+ if label:
1277
+ backup_path = Path(f"{self.db_path}.backup-{label}-{timestamp}.db")
1278
+ else:
1279
+ backup_path = Path(f"{self.db_path}.backup-{timestamp}.db")
1227
1280
 
1228
1281
  # Create backup using SQLite's built-in backup API
1229
1282
  backup_conn = sqlite3.connect(str(backup_path))
@@ -1232,12 +1285,30 @@ class Database:
1232
1285
  finally:
1233
1286
  backup_conn.close()
1234
1287
 
1288
+ # Verify backup integrity
1289
+ try:
1290
+ verify_conn = sqlite3.connect(str(backup_path), timeout=5)
1291
+ result = verify_conn.execute("PRAGMA integrity_check").fetchone()
1292
+ verify_conn.close()
1293
+ if result and result[0] != "ok":
1294
+ logger.error(f"Backup verification FAILED: {result}")
1295
+ backup_path.unlink(missing_ok=True)
1296
+ raise RuntimeError(f"Backup integrity check failed: {result}")
1297
+ except sqlite3.Error as e:
1298
+ logger.warning(f"Backup verification could not run: {e}")
1299
+
1235
1300
  logger.info(f"Database backed up to {backup_path}")
1236
1301
 
1237
- # Rolling retention: delete oldest backups beyond retention count
1238
- pattern = f"{self.db_path}.backup-*.db"
1239
- backups = sorted(glob.glob(pattern))
1240
- while len(backups) > config.backup_retention_count:
1302
+ # Rolling retention (per-label if labeled)
1303
+ if label:
1304
+ pattern = f"{self.db_path}.backup-{label}-*.db"
1305
+ retention = self._get_label_retention(label)
1306
+ else:
1307
+ pattern = f"{self.db_path}.backup-*.db"
1308
+ retention = config.backup_retention_count
1309
+
1310
+ backups = sorted(glob.glob(pattern), key=os.path.getmtime)
1311
+ while len(backups) > retention:
1241
1312
  oldest = backups.pop(0)
1242
1313
  try:
1243
1314
  Path(oldest).unlink()
@@ -1247,6 +1318,15 @@ class Database:
1247
1318
 
1248
1319
  return backup_path
1249
1320
 
1321
+ def _get_label_retention(self, label: str) -> int:
1322
+ """Get retention count for a labeled backup category."""
1323
+ config = get_config()
1324
+ retention_map = {
1325
+ "daily": config.backup_daily_retention,
1326
+ "weekly": config.backup_weekly_retention,
1327
+ }
1328
+ return retention_map.get(label, config.backup_retention_count)
1329
+
1250
1330
  def close(self) -> None:
1251
1331
  """Close the thread-local connection"""
1252
1332
  if hasattr(self._local, "connection") and self._local.connection: