get-claudia 1.34.2 → 1.35.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.
Files changed (29) hide show
  1. package/memory-daemon/claudia_memory/__main__.py +401 -3
  2. package/memory-daemon/claudia_memory/config.py +42 -0
  3. package/memory-daemon/claudia_memory/daemon/health.py +19 -0
  4. package/memory-daemon/claudia_memory/database.py +97 -26
  5. package/memory-daemon/claudia_memory/embeddings.py +114 -1
  6. package/memory-daemon/claudia_memory/mcp/server.py +122 -0
  7. package/memory-daemon/claudia_memory/schema.sql +5 -28
  8. package/memory-daemon/claudia_memory/services/consolidate.py +146 -24
  9. package/memory-daemon/claudia_memory/services/recall.py +6 -0
  10. package/memory-daemon/scripts/install.sh +73 -8
  11. package/memory-daemon/tests/test_backup.py +72 -0
  12. package/memory-daemon/tests/test_embedding_cache.py +210 -0
  13. package/memory-daemon/tests/test_embedding_migration.py +415 -0
  14. package/memory-daemon/tests/test_invalidated_filter.py +180 -0
  15. package/memory-daemon/tests/test_retention.py +312 -0
  16. package/package.json +1 -1
  17. package/template-v2/.claude/hooks/hooks.json +20 -0
  18. package/template-v2/.claude/hooks/session-health-check.py +43 -1
  19. package/template-v2/.claude/hooks/session-health-check.sh +40 -3
  20. package/template-v2/.claude/skills/README.md +50 -0
  21. package/template-v2/.claude/skills/capability-suggester.md +18 -0
  22. package/template-v2/.claude/skills/commitment-detector.md +18 -0
  23. package/template-v2/.claude/skills/ingest-sources/SKILL.md +1 -0
  24. package/template-v2/.claude/skills/morning-brief/SKILL.md +1 -0
  25. package/template-v2/.claude/skills/pattern-recognizer.md +18 -0
  26. package/template-v2/.claude/skills/relationship-tracker.md +18 -0
  27. package/template-v2/.claude/skills/risk-surfacer.md +18 -0
  28. package/template-v2/.claude/skills/skill-index.json +350 -0
  29. package/template-v2/.claude/skills/structure-evolution.md +18 -0
@@ -13,6 +13,7 @@ import hashlib
13
13
  import logging
14
14
  import os
15
15
  import signal
16
+ import sqlite3
16
17
  import sys
17
18
  from pathlib import Path
18
19
 
@@ -182,6 +183,16 @@ def main():
182
183
  action="store_true",
183
184
  help="Generate embeddings for all memories that don't have them yet, then exit",
184
185
  )
186
+ parser.add_argument(
187
+ "--migrate-embeddings",
188
+ action="store_true",
189
+ help="Migrate embeddings to a new model/dimensions (drop and recreate vec0 tables, re-embed all data)",
190
+ )
191
+ parser.add_argument(
192
+ "--backup",
193
+ action="store_true",
194
+ help="Create a database backup and exit",
195
+ )
185
196
 
186
197
  args = parser.parse_args()
187
198
 
@@ -232,6 +243,21 @@ def main():
232
243
 
233
244
  db = get_db()
234
245
  db.initialize()
246
+ config = get_config()
247
+
248
+ # Fail fast if dimensions mismatch (user needs --migrate-embeddings instead)
249
+ stored_dims = db.execute(
250
+ "SELECT value FROM _meta WHERE key = 'embedding_dimensions'",
251
+ fetch=True,
252
+ )
253
+ if stored_dims and int(stored_dims[0]["value"]) != config.embedding_dimensions:
254
+ print(
255
+ f"Error: Dimension mismatch detected. "
256
+ f"Database has {stored_dims[0]['value']}D embeddings, "
257
+ f"config specifies {config.embedding_dimensions}D. "
258
+ f"Run --migrate-embeddings first."
259
+ )
260
+ sys.exit(1)
235
261
 
236
262
  # Find memories not in the memory_embeddings table
237
263
  missing = db.execute(
@@ -256,11 +282,10 @@ def main():
256
282
  for i, row in enumerate(missing, 1):
257
283
  embedding = svc.embed_sync(row["content"])
258
284
  if embedding:
259
- import struct
260
- blob = struct.pack(f"{len(embedding)}f", *embedding)
285
+ import json as _json
261
286
  db.execute(
262
287
  "INSERT OR REPLACE INTO memory_embeddings (memory_id, embedding) VALUES (?, ?)",
263
- (row["id"], blob),
288
+ (row["id"], _json.dumps(embedding)),
264
289
  )
265
290
  success += 1
266
291
  else:
@@ -268,9 +293,382 @@ def main():
268
293
  if i % 10 == 0 or i == len(missing):
269
294
  print(f" Progress: {i}/{len(missing)} (success={success}, failed={failed})")
270
295
 
296
+ # Update stored embedding model to match current config (clears mismatch warning)
297
+ db.execute(
298
+ "INSERT OR REPLACE INTO _meta (key, value) VALUES ('embedding_model', ?)",
299
+ (svc.model,),
300
+ )
301
+
271
302
  print(f"Backfill complete: {success} embedded, {failed} failed, {len(missing)} total.")
272
303
  return
273
304
 
305
+ if args.migrate_embeddings:
306
+ # Full embedding migration: change model and/or dimensions
307
+ setup_logging(debug=args.debug)
308
+ import json as _json
309
+
310
+ from .database import Database
311
+ from .embeddings import get_embedding_service
312
+
313
+ db = get_db()
314
+ db.initialize()
315
+ config = get_config()
316
+ svc = get_embedding_service()
317
+
318
+ new_model = config.embedding_model
319
+ new_dim = config.embedding_dimensions
320
+
321
+ # Read current state from _meta
322
+ old_model_row = db.execute(
323
+ "SELECT value FROM _meta WHERE key = 'embedding_model'",
324
+ fetch=True,
325
+ )
326
+ old_dims_row = db.execute(
327
+ "SELECT value FROM _meta WHERE key = 'embedding_dimensions'",
328
+ fetch=True,
329
+ )
330
+ old_model = old_model_row[0]["value"] if old_model_row else "unknown"
331
+ old_dim = int(old_dims_row[0]["value"]) if old_dims_row else 384
332
+
333
+ if old_model == new_model and old_dim == new_dim:
334
+ # No mismatch -- offer interactive model selection
335
+ print(f"\nCurrent embedding model: {old_model} ({old_dim}D)")
336
+ print()
337
+ print("Available models:")
338
+ models_info = [
339
+ ("1", "all-minilm:l6-v2", 384, " 23MB", "Fast, good baseline"),
340
+ ("2", "nomic-embed-text", 768, " 274MB", "Better retrieval (+6%)"),
341
+ ("3", "mxbai-embed-large", 1024, " 669MB", "Best accuracy, larger"),
342
+ ]
343
+ for num, name, dim, size, desc in models_info:
344
+ current = " (current)" if name == old_model else ""
345
+ print(f" {num}) {name:<20s} {dim}D {size} {desc}{current}")
346
+ print(" 4) Cancel")
347
+ print()
348
+ choice = input("Switch to [1-4, default=4]: ").strip()
349
+
350
+ model_map = {
351
+ "1": ("all-minilm:l6-v2", 384),
352
+ "2": ("nomic-embed-text", 768),
353
+ "3": ("mxbai-embed-large", 1024),
354
+ }
355
+
356
+ if choice not in model_map:
357
+ print("No changes made.")
358
+ return
359
+
360
+ new_model, new_dim = model_map[choice]
361
+
362
+ if new_model == old_model and new_dim == old_dim:
363
+ print(f"Already using {new_model}. No changes needed.")
364
+ return
365
+
366
+ # Update config.json with the user's choice
367
+ config_path = Path.home() / ".claudia" / "config.json"
368
+ try:
369
+ if config_path.exists():
370
+ with open(config_path) as f:
371
+ cfg_data = _json.load(f)
372
+ else:
373
+ cfg_data = {}
374
+ cfg_data["embedding_model"] = new_model
375
+ cfg_data["embedding_dimensions"] = new_dim
376
+ with open(config_path, "w") as f:
377
+ _json.dump(cfg_data, f, indent=2)
378
+ print(f"\nConfig updated: {new_model} ({new_dim}D)")
379
+ except Exception as e:
380
+ print(f"Warning: Could not update config.json: {e}")
381
+
382
+ # Reinitialize embedding service with new model
383
+ svc.model = new_model
384
+ svc.dimensions = new_dim
385
+ svc._available = None # Force re-check
386
+
387
+ # Pre-flight: verify Ollama is running and model is available
388
+ if not svc.is_available_sync():
389
+ # Distinguish: Ollama not running vs model not pulled
390
+ import subprocess
391
+ import httpx
392
+
393
+ ollama_running = False
394
+ try:
395
+ resp = httpx.get(f"{svc.host}/api/tags", timeout=5)
396
+ ollama_running = resp.status_code == 200
397
+ except Exception:
398
+ pass
399
+
400
+ if not ollama_running:
401
+ print(f"Error: Ollama is not running.")
402
+ print(f"Please start Ollama and try again.")
403
+ sys.exit(1)
404
+
405
+ # Ollama is running but model is missing -- offer to pull it
406
+ print(f"\nThe model '{new_model}' is not installed in Ollama.")
407
+ pull_choice = input(f"Download it now? (Y/n): ").strip().lower()
408
+ if pull_choice in ("", "y", "yes"):
409
+ print(f"Downloading {new_model}... (this may take a minute)")
410
+ try:
411
+ result = subprocess.run(
412
+ ["ollama", "pull", new_model],
413
+ capture_output=False,
414
+ text=True,
415
+ )
416
+ if result.returncode != 0:
417
+ print(f"Error: Failed to pull {new_model}.")
418
+ sys.exit(1)
419
+ except FileNotFoundError:
420
+ print("Error: 'ollama' command not found. Please install Ollama.")
421
+ sys.exit(1)
422
+
423
+ # Re-check availability after pull
424
+ svc._available = None
425
+ if not svc.is_available_sync():
426
+ print(f"Error: Model still not available after pull.")
427
+ sys.exit(1)
428
+ print(f"Model '{new_model}' ready.")
429
+ else:
430
+ print("Migration cancelled.")
431
+ return
432
+
433
+ # Count embeddings across all tables
434
+ embedding_counts = {}
435
+ for table, pk in Database.VEC0_TABLES:
436
+ try:
437
+ rows = db.execute(f"SELECT COUNT(*) as cnt FROM {table}", fetch=True)
438
+ embedding_counts[table] = rows[0]["cnt"] if rows else 0
439
+ except Exception:
440
+ embedding_counts[table] = 0
441
+ total_embeddings = sum(embedding_counts.values())
442
+
443
+ # Show migration summary
444
+ print(f"\nEmbedding Migration")
445
+ print(f" Current: {old_model} ({old_dim}D)")
446
+ print(f" Target: {new_model} ({new_dim}D)")
447
+ print(f" Embeddings to regenerate: {total_embeddings}")
448
+ print()
449
+
450
+ # Count source data to re-embed
451
+ mem_count_rows = db.execute(
452
+ "SELECT COUNT(*) as cnt FROM memories WHERE deleted_at IS NULL",
453
+ fetch=True,
454
+ )
455
+ ent_count_rows = db.execute(
456
+ "SELECT COUNT(*) as cnt FROM entities WHERE deleted_at IS NULL",
457
+ fetch=True,
458
+ )
459
+ ep_count_rows = db.execute(
460
+ "SELECT COUNT(*) as cnt FROM episodes WHERE summary IS NOT NULL AND summary != ''",
461
+ fetch=True,
462
+ )
463
+ msg_count_rows = db.execute(
464
+ "SELECT COUNT(*) as cnt FROM messages",
465
+ fetch=True,
466
+ )
467
+ ref_count_rows = db.execute(
468
+ "SELECT COUNT(*) as cnt FROM reflections",
469
+ fetch=True,
470
+ )
471
+ mem_count = mem_count_rows[0]["cnt"] if mem_count_rows else 0
472
+ ent_count = ent_count_rows[0]["cnt"] if ent_count_rows else 0
473
+ ep_count = ep_count_rows[0]["cnt"] if ep_count_rows else 0
474
+ msg_count = msg_count_rows[0]["cnt"] if msg_count_rows else 0
475
+ ref_count = ref_count_rows[0]["cnt"] if ref_count_rows else 0
476
+ total_to_embed = mem_count + ent_count + ep_count + msg_count + ref_count
477
+
478
+ print(f" Source data to re-embed:")
479
+ print(f" Memories: {mem_count}")
480
+ print(f" Entities: {ent_count}")
481
+ print(f" Episodes: {ep_count}")
482
+ print(f" Messages: {msg_count}")
483
+ print(f" Reflections: {ref_count}")
484
+ print(f" Total: {total_to_embed}")
485
+ print()
486
+
487
+ # Pre-flight: verify sqlite-vec is available
488
+ try:
489
+ db.execute("SELECT vec_version()", fetch=True)
490
+ except Exception:
491
+ print("Error: sqlite-vec extension not available. Cannot migrate embeddings.")
492
+ print("Install with: pip install sqlite-vec")
493
+ sys.exit(1)
494
+
495
+ # Confirmation
496
+ confirm = input("Proceed with migration? (y/N): ").strip().lower()
497
+ if confirm != "y":
498
+ print("Migration cancelled.")
499
+ return
500
+
501
+ # Step 1: Backup
502
+ print("\nStep 1/4: Creating backup...")
503
+ backup_path = db.backup()
504
+ print(f" Backup at: {backup_path}")
505
+
506
+ # Step 2: Drop and recreate vec0 tables with new dimensions
507
+ print("\nStep 2/4: Recreating vector tables...")
508
+ with db.transaction() as conn:
509
+ for table, pk in Database.VEC0_TABLES:
510
+ try:
511
+ conn.execute(f"DROP TABLE IF EXISTS {table}")
512
+ conn.execute(f"""
513
+ CREATE VIRTUAL TABLE {table} USING vec0(
514
+ {pk} INTEGER PRIMARY KEY,
515
+ embedding FLOAT[{new_dim}]
516
+ )
517
+ """)
518
+ print(f" Recreated {table} ({new_dim}D)")
519
+ except sqlite3.OperationalError as e:
520
+ if "no such module: vec0" in str(e):
521
+ print(f" Warning: sqlite-vec not available, skipping {table}")
522
+ else:
523
+ print(f" Error recreating {table}: {e}")
524
+ print("Aborting. Restore from backup to recover.")
525
+ sys.exit(1)
526
+
527
+ # Step 3: Re-embed everything
528
+ print("\nStep 3/4: Re-embedding all data...")
529
+ results = {}
530
+
531
+ # 3a. Memory embeddings (largest, most important)
532
+ if mem_count > 0:
533
+ memories = db.execute(
534
+ "SELECT id, content FROM memories WHERE deleted_at IS NULL",
535
+ fetch=True,
536
+ )
537
+ success = 0
538
+ for i, row in enumerate(memories or [], 1):
539
+ embedding = svc.embed_sync(row["content"])
540
+ if embedding:
541
+ db.execute(
542
+ "INSERT INTO memory_embeddings (memory_id, embedding) VALUES (?, ?)",
543
+ (row["id"], _json.dumps(embedding)),
544
+ )
545
+ success += 1
546
+ if i % 25 == 0 or i == mem_count:
547
+ print(f" Memories: {i}/{mem_count}")
548
+ results["memories"] = success
549
+ else:
550
+ results["memories"] = 0
551
+
552
+ # 3b. Entity embeddings
553
+ if ent_count > 0:
554
+ entities = db.execute(
555
+ "SELECT id, name, description FROM entities WHERE deleted_at IS NULL",
556
+ fetch=True,
557
+ )
558
+ success = 0
559
+ for i, row in enumerate(entities or [], 1):
560
+ text = f"{row['name']}: {row['description'] or ''}"
561
+ embedding = svc.embed_sync(text)
562
+ if embedding:
563
+ db.execute(
564
+ "INSERT INTO entity_embeddings (entity_id, embedding) VALUES (?, ?)",
565
+ (row["id"], _json.dumps(embedding)),
566
+ )
567
+ success += 1
568
+ if i % 25 == 0 or i == ent_count:
569
+ print(f" Entities: {i}/{ent_count}")
570
+ results["entities"] = success
571
+ else:
572
+ results["entities"] = 0
573
+
574
+ # 3c. Episode embeddings (from summaries)
575
+ if ep_count > 0:
576
+ episodes = db.execute(
577
+ "SELECT id, summary FROM episodes WHERE summary IS NOT NULL AND summary != ''",
578
+ fetch=True,
579
+ )
580
+ success = 0
581
+ for i, row in enumerate(episodes or [], 1):
582
+ embedding = svc.embed_sync(row["summary"])
583
+ if embedding:
584
+ db.execute(
585
+ "INSERT INTO episode_embeddings (episode_id, embedding) VALUES (?, ?)",
586
+ (row["id"], _json.dumps(embedding)),
587
+ )
588
+ success += 1
589
+ if i % 25 == 0 or i == ep_count:
590
+ print(f" Episodes: {i}/{ep_count}")
591
+ results["episodes"] = success
592
+ else:
593
+ results["episodes"] = 0
594
+
595
+ # 3d. Message embeddings
596
+ if msg_count > 0:
597
+ messages = db.execute(
598
+ "SELECT id, content FROM messages",
599
+ fetch=True,
600
+ )
601
+ success = 0
602
+ for i, row in enumerate(messages or [], 1):
603
+ embedding = svc.embed_sync(row["content"])
604
+ if embedding:
605
+ db.execute(
606
+ "INSERT INTO message_embeddings (message_id, embedding) VALUES (?, ?)",
607
+ (row["id"], _json.dumps(embedding)),
608
+ )
609
+ success += 1
610
+ if i % 25 == 0 or i == msg_count:
611
+ print(f" Messages: {i}/{msg_count}")
612
+ results["messages"] = success
613
+ else:
614
+ results["messages"] = 0
615
+
616
+ # 3e. Reflection embeddings
617
+ if ref_count > 0:
618
+ reflections = db.execute(
619
+ "SELECT id, content FROM reflections",
620
+ fetch=True,
621
+ )
622
+ success = 0
623
+ for i, row in enumerate(reflections or [], 1):
624
+ embedding = svc.embed_sync(row["content"])
625
+ if embedding:
626
+ db.execute(
627
+ "INSERT INTO reflection_embeddings (reflection_id, embedding) VALUES (?, ?)",
628
+ (row["id"], _json.dumps(embedding)),
629
+ )
630
+ success += 1
631
+ if i % 25 == 0 or i == ref_count:
632
+ print(f" Reflections: {i}/{ref_count}")
633
+ results["reflections"] = success
634
+ else:
635
+ results["reflections"] = 0
636
+
637
+ # Step 4: Update _meta
638
+ print("\nStep 4/4: Updating metadata...")
639
+ db.execute(
640
+ "INSERT OR REPLACE INTO _meta (key, value) VALUES ('embedding_model', ?)",
641
+ (new_model,),
642
+ )
643
+ db.execute(
644
+ "INSERT OR REPLACE INTO _meta (key, value) VALUES ('embedding_dimensions', ?)",
645
+ (str(new_dim),),
646
+ )
647
+
648
+ # Clear embedding cache (old-dimension entries)
649
+ svc._cache.clear()
650
+ svc._model_mismatch = False
651
+
652
+ # Summary
653
+ print(f"\nMigration complete:")
654
+ print(f" Model: {new_model} ({new_dim}D)")
655
+ print(f" Memories re-embedded: {results['memories']}/{mem_count}")
656
+ print(f" Entities re-embedded: {results['entities']}/{ent_count}")
657
+ print(f" Episodes re-embedded: {results['episodes']}/{ep_count}")
658
+ print(f" Messages re-embedded: {results['messages']}/{msg_count}")
659
+ print(f" Reflections re-embedded: {results['reflections']}/{ref_count}")
660
+ print(f" Backup at: {backup_path}")
661
+ print(f"\n To rollback: restore the backup file.")
662
+ return
663
+
664
+ if args.backup:
665
+ setup_logging(debug=args.debug)
666
+ db = get_db()
667
+ db.initialize()
668
+ backup_path = db.backup()
669
+ print(f"Backup created: {backup_path}")
670
+ return
671
+
274
672
  # Run the daemon
275
673
  run_daemon(mcp_mode=not args.standalone, debug=args.debug, project_id=project_id)
276
674
 
@@ -68,6 +68,16 @@ class MemoryConfig:
68
68
  # Health check
69
69
  health_port: int = 3848
70
70
 
71
+ # Backup settings
72
+ backup_retention_count: int = 3 # Number of rolling backups to keep
73
+ enable_pre_consolidation_backup: bool = True # Auto-backup before consolidation
74
+
75
+ # Retention settings (data cleanup during consolidation)
76
+ audit_log_retention_days: int = 90
77
+ prediction_retention_days: int = 30
78
+ turn_buffer_retention_days: int = 60
79
+ metrics_retention_days: int = 90
80
+
71
81
  # Daemon settings
72
82
  log_path: Path = field(default_factory=lambda: Path.home() / ".claudia" / "daemon.log")
73
83
 
@@ -119,6 +129,18 @@ class MemoryConfig:
119
129
  config.fts_weight = data["fts_weight"]
120
130
  if "health_port" in data:
121
131
  config.health_port = data["health_port"]
132
+ if "backup_retention_count" in data:
133
+ config.backup_retention_count = data["backup_retention_count"]
134
+ if "enable_pre_consolidation_backup" in data:
135
+ config.enable_pre_consolidation_backup = data["enable_pre_consolidation_backup"]
136
+ if "audit_log_retention_days" in data:
137
+ config.audit_log_retention_days = data["audit_log_retention_days"]
138
+ if "prediction_retention_days" in data:
139
+ config.prediction_retention_days = data["prediction_retention_days"]
140
+ if "turn_buffer_retention_days" in data:
141
+ config.turn_buffer_retention_days = data["turn_buffer_retention_days"]
142
+ if "metrics_retention_days" in data:
143
+ config.metrics_retention_days = data["metrics_retention_days"]
122
144
  if "log_path" in data:
123
145
  config.log_path = Path(data["log_path"])
124
146
 
@@ -171,6 +193,20 @@ class MemoryConfig:
171
193
  weights = self.vector_weight + self.importance_weight + self.recency_weight + self.fts_weight
172
194
  if abs(weights - 1.0) > 0.01:
173
195
  logger.warning(f"Ranking weights sum to {weights:.3f}, not 1.0. Results may be skewed.")
196
+ if self.backup_retention_count < 1:
197
+ logger.warning(f"backup_retention_count={self.backup_retention_count} below minimum, using 1")
198
+ self.backup_retention_count = 1
199
+ for attr in ("audit_log_retention_days", "prediction_retention_days", "turn_buffer_retention_days", "metrics_retention_days"):
200
+ val = getattr(self, attr)
201
+ if val < 1:
202
+ logger.warning(f"{attr}={val} below minimum, using 1")
203
+ setattr(self, attr, 1)
204
+ common_dims = {384, 512, 768, 1024, 1536}
205
+ if self.embedding_dimensions not in common_dims:
206
+ logger.warning(
207
+ f"embedding_dimensions={self.embedding_dimensions} is not a common value "
208
+ f"({sorted(common_dims)}). Verify this matches your embedding model's output."
209
+ )
174
210
 
175
211
  def save(self) -> None:
176
212
  """Save current configuration to ~/.claudia/config.json"""
@@ -193,6 +229,12 @@ class MemoryConfig:
193
229
  "recency_weight": self.recency_weight,
194
230
  "fts_weight": self.fts_weight,
195
231
  "health_port": self.health_port,
232
+ "backup_retention_count": self.backup_retention_count,
233
+ "enable_pre_consolidation_backup": self.enable_pre_consolidation_backup,
234
+ "audit_log_retention_days": self.audit_log_retention_days,
235
+ "prediction_retention_days": self.prediction_retention_days,
236
+ "turn_buffer_retention_days": self.turn_buffer_retention_days,
237
+ "metrics_retention_days": self.metrics_retention_days,
196
238
  "log_path": str(self.log_path),
197
239
  }
198
240
 
@@ -10,6 +10,7 @@ import logging
10
10
  import threading
11
11
  from datetime import datetime
12
12
  from http.server import BaseHTTPRequestHandler, HTTPServer
13
+ from pathlib import Path
13
14
  from typing import Any, Callable, Dict, Optional
14
15
 
15
16
  from ..config import get_config
@@ -68,6 +69,24 @@ def build_status_report(*, db=None) -> dict:
68
69
  except Exception:
69
70
  report["counts"][table] = -1
70
71
 
72
+ # Backup status
73
+ try:
74
+ import glob
75
+ db_path = str(get_config().db_path)
76
+ pattern = f"{db_path}.backup-*.db"
77
+ backups = sorted(glob.glob(pattern))
78
+ if backups:
79
+ latest = Path(backups[-1])
80
+ report["backup"] = {
81
+ "count": len(backups),
82
+ "latest_path": str(latest),
83
+ "latest_size_bytes": latest.stat().st_size if latest.exists() else 0,
84
+ }
85
+ else:
86
+ report["backup"] = {"count": 0}
87
+ except Exception:
88
+ report["backup"] = {"count": -1, "error": "unable to check"}
89
+
71
90
  except Exception:
72
91
  report["components"]["database"] = "error"
73
92
  report["status"] = "degraded"