brainlayer 1.5.80__tar.gz → 1.5.82__tar.gz

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 (268) hide show
  1. {brainlayer-1.5.80 → brainlayer-1.5.82}/PKG-INFO +1 -1
  2. {brainlayer-1.5.80 → brainlayer-1.5.82}/pyproject.toml +1 -1
  3. {brainlayer-1.5.80 → brainlayer-1.5.82}/server.json +2 -2
  4. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/__init__.py +1 -1
  5. brainlayer-1.5.82/src/brainlayer/_build.py +1 -0
  6. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/backup_daily.py +124 -7
  7. brainlayer-1.5.82/src/brainlayer/linkedin_import.py +365 -0
  8. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/observability_backup.py +13 -2
  9. brainlayer-1.5.80/src/brainlayer/_build.py +0 -1
  10. {brainlayer-1.5.80 → brainlayer-1.5.82}/.gitignore +0 -0
  11. {brainlayer-1.5.80 → brainlayer-1.5.82}/LICENSE +0 -0
  12. {brainlayer-1.5.80 → brainlayer-1.5.82}/README.md +0 -0
  13. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/hotlane_brainbar_daemon.py +0 -0
  14. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/backup-daily.sh +0 -0
  15. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/brainlayer-env-run.sh +0 -0
  16. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/brainlayer.env.example +0 -0
  17. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/com.brainlayer.backup-daily.plist +0 -0
  18. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/com.brainlayer.daemon.plist +0 -0
  19. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/com.brainlayer.decay.plist +0 -0
  20. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/com.brainlayer.drain.plist +0 -0
  21. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/com.brainlayer.health-check.plist +0 -0
  22. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/com.brainlayer.hotlane-brainbar.plist +0 -0
  23. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/com.brainlayer.index.plist +0 -0
  24. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/com.brainlayer.jsonl-backup.plist +0 -0
  25. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/com.brainlayer.log-cap.plist +0 -0
  26. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/com.brainlayer.maintenance-nightly.plist +0 -0
  27. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/com.brainlayer.maintenance-weekly.plist +0 -0
  28. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/com.brainlayer.maintenance.plist +0 -0
  29. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/com.brainlayer.observability.plist +0 -0
  30. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/com.brainlayer.p0-counter.plist +0 -0
  31. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/com.brainlayer.repair-fts.plist +0 -0
  32. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/com.brainlayer.t3-ingest.plist +0 -0
  33. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/com.brainlayer.throughput-watchdog.plist +0 -0
  34. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/com.brainlayer.tier0-watchdog.plist +0 -0
  35. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/com.brainlayer.wal-checkpoint.plist +0 -0
  36. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/com.brainlayer.watch.plist +0 -0
  37. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/com.etanhey.brainlayer-fleet-watchdog.plist +0 -0
  38. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/fleet-watchdog.sh +0 -0
  39. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/install.sh +0 -0
  40. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/jsonl-backup.sh +0 -0
  41. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/launchd/throughput-watchdog.py +0 -0
  42. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/release-verify-signatures.sh +0 -0
  43. {brainlayer-1.5.80 → brainlayer-1.5.82}/scripts/tier0-watchdog.sh +0 -0
  44. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/__main__.py +0 -0
  45. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/_helpers.py +0 -0
  46. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/agent_profiles.py +0 -0
  47. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/agent_provenance.py +0 -0
  48. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/alarm.py +0 -0
  49. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/archive_collapse.py +0 -0
  50. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/archive_lifecycle.py +0 -0
  51. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/backup_retention_invariant.py +0 -0
  52. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/badge_state.py +0 -0
  53. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/bitemporal.py +0 -0
  54. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/brainbar_activation.py +0 -0
  55. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/brainbar_auto_activation.py +0 -0
  56. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/brainbar_hybrid_helper.py +0 -0
  57. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/brainbar_identity.py +0 -0
  58. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/cachedelete_purge.swift +0 -0
  59. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/calibrate.py +0 -0
  60. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/chunk_origin.py +0 -0
  61. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/chunk_origin_backfill.py +0 -0
  62. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/chunk_origin_wipe.py +0 -0
  63. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/chunk_write.py +0 -0
  64. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/classify.py +0 -0
  65. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/claude_paths.py +0 -0
  66. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/cli/__init__.py +0 -0
  67. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/cli/wizard.py +0 -0
  68. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/cli_jobs.py +0 -0
  69. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/cli_new.py +0 -0
  70. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/cloud_backfill.py +0 -0
  71. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/clustering.py +0 -0
  72. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/component_jobs.py +0 -0
  73. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/config.py +0 -0
  74. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/consolidation_plan.py +0 -0
  75. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/content_class.py +0 -0
  76. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/correction_judge.py +0 -0
  77. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/data/agents_registry.yaml +0 -0
  78. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/data/sandbox_seeds/skill-eval-baseline.json +0 -0
  79. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/db_shrink.py +0 -0
  80. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/decay.py +0 -0
  81. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/decay_backfill.py +0 -0
  82. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/decay_job.py +0 -0
  83. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/dedupe.py +0 -0
  84. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/dedupe_merge.py +0 -0
  85. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/deploy_drift.py +0 -0
  86. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/disk_space.py +0 -0
  87. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/doctor.py +0 -0
  88. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/drain.py +0 -0
  89. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/drain_liveness.py +0 -0
  90. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/drive_credentials.py +0 -0
  91. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/embeddings.py +0 -0
  92. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/engine.py +0 -0
  93. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/enrichment_replay.py +0 -0
  94. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/eval/__init__.py +0 -0
  95. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/eval/abcde_enrich_runner.py +0 -0
  96. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/eval/abcde_variants.py +0 -0
  97. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/eval/abcde_variants.yaml +0 -0
  98. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/eval/benchmark.py +0 -0
  99. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/eval/enrichment_gold.py +0 -0
  100. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/eval/enrichment_graders.py +0 -0
  101. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/eval/enrichment_judge.py +0 -0
  102. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/eval/enrichment_llm_judge.py +0 -0
  103. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/eval/enrichment_quality_benchmark.py +0 -0
  104. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/eval/experiment_store.py +0 -0
  105. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/eval/fixtures/README.md +0 -0
  106. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/eval/labeling/enrichment-labeler.html +0 -0
  107. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/eval/phoenix_gate/__init__.py +0 -0
  108. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/eval/phoenix_gate/__main__.py +0 -0
  109. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/eval/phoenix_gate/baseline_store.py +0 -0
  110. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/eval/phoenix_gate/cli.py +0 -0
  111. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/eval/phoenix_gate/models.py +0 -0
  112. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/eval/phoenix_gate/phoenix_client.py +0 -0
  113. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/eval/phoenix_gate/regression_gate.py +0 -0
  114. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/eval/phoenix_gate/triggers.py +0 -0
  115. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/eval/pilot_artifact_report.py +0 -0
  116. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/fallback_replay.py +0 -0
  117. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/git_learning.py +0 -0
  118. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/health_check.py +0 -0
  119. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/hook_python.py +0 -0
  120. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/hooks/__init__.py +0 -0
  121. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/hooks/indexer.py +0 -0
  122. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/import_sweep.py +0 -0
  123. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/index_admission.py +0 -0
  124. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/index_budget.py +0 -0
  125. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/index_completeness.py +0 -0
  126. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/index_new.py +0 -0
  127. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/index_watchdog.py +0 -0
  128. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/ingest/__init__.py +0 -0
  129. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/ingest/codex.py +0 -0
  130. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/ingest/t3.py +0 -0
  131. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/ingest_denylist.py +0 -0
  132. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/ingest_guard.py +0 -0
  133. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/isolation_proof.py +0 -0
  134. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/job_alerts.py +0 -0
  135. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/job_lifecycle_health.py +0 -0
  136. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/jobs.py +0 -0
  137. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/jsonl_backup.py +0 -0
  138. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/kg/__init__.py +0 -0
  139. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/kg_cleanup.py +0 -0
  140. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/kg_judge.py +0 -0
  141. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/kg_promotion.py +0 -0
  142. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/kg_repo.py +0 -0
  143. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/kg_review_session.py +0 -0
  144. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/kg_session_finish.py +0 -0
  145. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/kg_session_harvest.py +0 -0
  146. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/launchd_consolidation.py +0 -0
  147. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/launchd_install.py +0 -0
  148. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/launchd_primitive.py +0 -0
  149. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/launchd_revert.py +0 -0
  150. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/lexical_defense.py +0 -0
  151. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/lexical_defense_dictionary.json +0 -0
  152. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/log_cap.py +0 -0
  153. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/maintenance.py +0 -0
  154. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/maintenance_calendar.py +0 -0
  155. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/maintenance_control.py +0 -0
  156. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/mcp/__init__.py +0 -0
  157. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/mcp/_format.py +0 -0
  158. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/mcp/_shared.py +0 -0
  159. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/mcp/enrich_handler.py +0 -0
  160. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/mcp/entity_handler.py +0 -0
  161. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/mcp/palette.py +0 -0
  162. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/mcp/search_handler.py +0 -0
  163. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/mcp/store_handler.py +0 -0
  164. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/mcp/tags_handler.py +0 -0
  165. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/mcp_socket_config.py +0 -0
  166. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/mcp_stdio_bridge.py +0 -0
  167. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/memory_types.py +0 -0
  168. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/migrate.py +0 -0
  169. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/migration_custody.py +0 -0
  170. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/migration_journal.py +0 -0
  171. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/migration_runtime.py +0 -0
  172. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/notification_policy.py +0 -0
  173. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/observability_surface.py +0 -0
  174. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/observability_transport.py +0 -0
  175. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/p0_longitudinal_count.py +0 -0
  176. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/parent_death.py +0 -0
  177. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/paths.py +0 -0
  178. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pause.py +0 -0
  179. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/phonetic.py +0 -0
  180. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/__init__.py +0 -0
  181. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/agent_enrichment.py +0 -0
  182. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/analyze_communication.py +0 -0
  183. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/batch_extraction.py +0 -0
  184. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/brain_graph.py +0 -0
  185. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/chat_tags.py +0 -0
  186. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/chunk.py +0 -0
  187. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/classify.py +0 -0
  188. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/cloud_scrub.py +0 -0
  189. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/cluster_sampling.py +0 -0
  190. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/code_intelligence.py +0 -0
  191. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/correction_detection.py +0 -0
  192. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/correction_mining.py +0 -0
  193. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/digest.py +0 -0
  194. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/enrichment_prompts.py +0 -0
  195. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/enrichment_results.py +0 -0
  196. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/enrichment_tiers.py +0 -0
  197. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/entity_extraction.py +0 -0
  198. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/entity_resolution.py +0 -0
  199. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/extract.py +0 -0
  200. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/extract_claude_desktop.py +0 -0
  201. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/extract_corrections.py +0 -0
  202. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/extract_markdown.py +0 -0
  203. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/extract_whatsapp.py +0 -0
  204. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/frustration_mining.py +0 -0
  205. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/git_overlay.py +0 -0
  206. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/kg_extraction.py +0 -0
  207. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/kg_extraction_groq.py +0 -0
  208. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/longitudinal_analyzer.py +0 -0
  209. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/obsidian_export.py +0 -0
  210. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/operation_grouping.py +0 -0
  211. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/plan_linking.py +0 -0
  212. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/rate_limiter.py +0 -0
  213. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/relation_backfill.py +0 -0
  214. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/relation_verification.py +0 -0
  215. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/sanitize.py +0 -0
  216. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/secret_scrub.py +0 -0
  217. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/semantic_style.py +0 -0
  218. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/sentiment.py +0 -0
  219. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/session_history.py +0 -0
  220. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/style_embed.py +0 -0
  221. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/style_index.py +0 -0
  222. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/tag_entity_promotion.py +0 -0
  223. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/temporal_chains.py +0 -0
  224. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/time_batcher.py +0 -0
  225. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/unified_timeline.py +0 -0
  226. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/pipeline/write_queue.py +0 -0
  227. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/provenance.py +0 -0
  228. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/provenance_autosupersede.py +0 -0
  229. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/provenance_integration.py +0 -0
  230. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/queue_io.py +0 -0
  231. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/queue_merge.py +0 -0
  232. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/reembed_backfill.py +0 -0
  233. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/retired_services.py +0 -0
  234. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/runtime_store.py +0 -0
  235. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/sandbox_db.py +0 -0
  236. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/scoping.py +0 -0
  237. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/scrub_at_rest.py +0 -0
  238. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/search_fanout.py +0 -0
  239. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/search_profile.py +0 -0
  240. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/search_repo.py +0 -0
  241. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/service_supervisor.py +0 -0
  242. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/session_repo.py +0 -0
  243. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/setup.py +0 -0
  244. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/socket_hygiene.py +0 -0
  245. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/source_class_migration.py +0 -0
  246. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/spotlight.py +0 -0
  247. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/storage.py +0 -0
  248. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/store.py +0 -0
  249. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/supervisor_adapters.py +0 -0
  250. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/supervisor_child.py +0 -0
  251. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/supervisor_diagnostics.py +0 -0
  252. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/supervisor_guard.py +0 -0
  253. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/supervisor_jobs.py +0 -0
  254. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/system_prompt_guard.py +0 -0
  255. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/t3_linkage_alarm.py +0 -0
  256. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/t3_provenance.py +0 -0
  257. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/t3_sources.py +0 -0
  258. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/tag_normalization.py +0 -0
  259. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/taxonomy.json +0 -0
  260. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/telemetry.py +0 -0
  261. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/timestamp_iso.py +0 -0
  262. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/types.py +0 -0
  263. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/vector_store.py +0 -0
  264. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/wal_checkpoint.py +0 -0
  265. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/watcher.py +0 -0
  266. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/watcher_bridge.py +0 -0
  267. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/writer_policy.py +0 -0
  268. {brainlayer-1.5.80 → brainlayer-1.5.82}/src/brainlayer/writer_telemetry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: brainlayer
3
- Version: 1.5.80
3
+ Version: 1.5.82
4
4
  Summary: Persistent local memory for AI agents — 16 BrainBar MCP tools, semantic search, knowledge graph, SQLite
5
5
  Project-URL: Homepage, https://brainlayer.etanheyman.com
6
6
  Project-URL: Repository, https://github.com/EtanHey/brainlayer
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "brainlayer"
3
- version = "1.5.80"
3
+ version = "1.5.82"
4
4
  description = "Persistent local memory for AI agents — 16 BrainBar MCP tools, semantic search, knowledge graph, SQLite"
5
5
  license = {text = "Apache-2.0"}
6
6
  readme = "README.md"
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.etanhey/brainlayer",
4
4
  "title": "BrainLayer",
5
5
  "description": "Persistent local memory for AI agents \u2014 semantic search, knowledge graph, and 16 BrainBar MCP tools with ToolAnnotations over SQLite.",
6
- "version": "1.5.80",
6
+ "version": "1.5.82",
7
7
  "websiteUrl": "https://brainlayer.etanheyman.com",
8
8
  "repository": {
9
9
  "url": "https://github.com/EtanHey/brainlayer",
@@ -14,7 +14,7 @@
14
14
  "registryType": "pypi",
15
15
  "registryBaseUrl": "https://pypi.org",
16
16
  "identifier": "brainlayer",
17
- "version": "1.5.80",
17
+ "version": "1.5.82",
18
18
  "runtimeHint": "pipx",
19
19
  "transport": {
20
20
  "type": "stdio"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "1.5.80"
5
+ __version__ = "1.5.82"
6
6
  # Git sha the distributed package was built from; stamped by the release build, None in a source tree.
7
7
  # scripts/sprint_gate.py reads it in "keg" mode to prove the served package is the code under test.
8
8
  # No runtime `str | None` annotation: launchd/install.sh imports this under /usr/bin/python3 (3.9).
@@ -0,0 +1 @@
1
+ BUILD_SHA = "9b6e1c75b4bed7440161989df15eb49ccabca0db"
@@ -16,6 +16,7 @@ import inspect
16
16
  import json
17
17
  import math
18
18
  import os
19
+ import plistlib
19
20
  import queue
20
21
  import re
21
22
  import shutil
@@ -37,6 +38,7 @@ from dataclasses import dataclass
37
38
  from pathlib import Path
38
39
  from typing import Any
39
40
  from urllib.parse import urlsplit
41
+ from xml.parsers.expat import ExpatError
40
42
 
41
43
  import requests
42
44
 
@@ -45,6 +47,7 @@ from .paths import get_db_path, resolve_db_path
45
47
  from .socket_hygiene import refuse_production_brainbar_socket
46
48
 
47
49
  _sleep = time.sleep
50
+ _reclaim_clock = time.monotonic
48
51
 
49
52
  DEFAULT_TOKEN_PATH = drive_credentials.DEFAULT_TOKEN_PATH
50
53
  DEFAULT_CLIENT_PATH = drive_credentials.DEFAULT_CLIENT_PATH
@@ -61,6 +64,8 @@ DEFAULT_STAGING_DIR = Path.home() / ".local" / "share" / "brainlayer" / "backups
61
64
  MIN_RAW_FREE_BYTES = 2 * 1024 * 1024 * 1024
62
65
  COPY_CHUNK_BYTES = 1024 * 1024
63
66
  RECLAIM_CHUNK_BYTES = 64 * 1024 * 1024
67
+ RECLAIM_WINDOW_SECONDS = 2.0
68
+ RECLAIM_MAX_SAMPLES = 5
64
69
  FRAME_OVERHEAD_BYTES = 64 * 1024
65
70
  POST_PURGE_POLL_SECONDS = 0.5 # 60 polls give APFS up to 30s to release reclaimed bytes.
66
71
  DEFAULT_LOG_PATH = Path.home() / ".local" / "share" / "brainlayer" / "logs" / "backup-daily.log"
@@ -170,6 +175,7 @@ class SQLiteBackupArtifact:
170
175
  compressor_fallback: str | None = None
171
176
  compress_duration_seconds: float | None = None
172
177
  raw_sha256: str | None = None
178
+ reclaim_basis: str | None = None
173
179
 
174
180
  @property
175
181
  def archive_path(self) -> Path:
@@ -1442,7 +1448,102 @@ def _archive_stream_sha256(source: Any, *, zstd: bool) -> str:
1442
1448
  return _stream_sha256(restored).hex()
1443
1449
 
1444
1450
 
1445
- def _compress_reclaiming_raw(raw: Path, archive: Path, *, zstd_binary: str | None) -> str:
1451
+ def _staging_snapshots_may_exist(path: Path) -> bool | None:
1452
+ """Read the staging volume's APFS snapshot inventory; uncertainty keeps the volume gate."""
1453
+ if sys.platform != "darwin":
1454
+ return None
1455
+ try:
1456
+ # df resolves the device even through macOS firmlinks (/Users is on Data).
1457
+ # Use only its device identity, never its free/purgeable space accounting.
1458
+ rows = (
1459
+ subprocess.run(["/bin/df", "-P", str(path)], capture_output=True, check=True, timeout=5)
1460
+ .stdout.decode()
1461
+ .splitlines()
1462
+ )
1463
+ device = rows[1].split()[0] if len(rows) == 2 else ""
1464
+ if not re.fullmatch(r"/dev/disk\d+s\d+", device):
1465
+ raise ValueError("staging device is ambiguous")
1466
+ result = subprocess.run(
1467
+ ["/usr/sbin/diskutil", "apfs", "listSnapshots", device.removeprefix("/dev/"), "-plist"],
1468
+ capture_output=True,
1469
+ check=True,
1470
+ timeout=5,
1471
+ )
1472
+ inventory = plistlib.loads(result.stdout)
1473
+ if not isinstance(inventory, dict) or inventory.get("Error") or inventory.get("ExitCode", 0) != 0:
1474
+ raise ValueError("snapshot inventory reported an error")
1475
+ snapshots = inventory.get("Snapshots")
1476
+ if not isinstance(snapshots, list):
1477
+ raise ValueError("snapshot inventory is ambiguous")
1478
+ return bool(snapshots)
1479
+ except BackupTimeoutError:
1480
+ raise # Preserve the enclosing backup deadline, rather than treating it as probe uncertainty.
1481
+ except (OSError, subprocess.SubprocessError, ValueError, IndexError, ExpatError) as exc:
1482
+ print(f"Backup snapshot probe unknown: {type(exc).__name__}; retaining volume reclaim check", flush=True)
1483
+ return None
1484
+
1485
+
1486
+ class RawReclaimUnconfirmedError(RuntimeError):
1487
+ error_type = "raw_reclaim_unconfirmed"
1488
+
1489
+ def __init__(self, measurements: dict[str, Any]):
1490
+ self.measurements = measurements
1491
+ best_delta = max(
1492
+ (
1493
+ sample["delta_bytes"]
1494
+ for sample in measurements["samples"]
1495
+ if sample["elapsed_seconds"] <= measurements["window_seconds"]
1496
+ ),
1497
+ default=0,
1498
+ )
1499
+ mib = 1024 * 1024
1500
+ super().__init__(
1501
+ "Database backup stopped: raw file blocks were released, but free space rose only "
1502
+ f"{best_delta / mib:g} MiB of the required {measurements['threshold_bytes'] / mib:g} MiB "
1503
+ f"within {measurements['window_seconds']:g} s "
1504
+ "(another process may be writing, or macOS is slow to report freed space)."
1505
+ )
1506
+
1507
+
1508
+ def _confirm_raw_reclaim(
1509
+ path: Path, *, offset: int, length: int, before_blocks: int, after_blocks: int, before_free: int
1510
+ ) -> None:
1511
+ """Keep one pre-punch baseline; bound both sample count and elapsed waiting."""
1512
+ started = _reclaim_clock()
1513
+ samples = []
1514
+ threshold = length - length // 2
1515
+ for index in range(RECLAIM_MAX_SAMPLES):
1516
+ elapsed = _reclaim_clock() - started
1517
+ if index and elapsed > RECLAIM_WINDOW_SECONDS:
1518
+ break
1519
+ free = shutil.disk_usage(path).free
1520
+ elapsed = _reclaim_clock() - started
1521
+ samples.append({"elapsed_seconds": elapsed, "free_bytes": free, "delta_bytes": free - before_free})
1522
+ if free - before_free >= threshold and elapsed <= RECLAIM_WINDOW_SECONDS:
1523
+ return
1524
+ remaining = RECLAIM_WINDOW_SECONDS - elapsed
1525
+ if remaining <= 0 or index == RECLAIM_MAX_SAMPLES - 1:
1526
+ break
1527
+ _sleep(min(RECLAIM_WINDOW_SECONDS / (RECLAIM_MAX_SAMPLES - 1), remaining))
1528
+ raise RawReclaimUnconfirmedError(
1529
+ {
1530
+ "reclaim_basis": "volume_delta",
1531
+ "chunk_offset": offset,
1532
+ "chunk_length": length,
1533
+ "blocks_before": before_blocks,
1534
+ "blocks_after": after_blocks,
1535
+ "free_before": before_free,
1536
+ "samples": samples,
1537
+ "threshold_bytes": threshold,
1538
+ "window_seconds": RECLAIM_WINDOW_SECONDS,
1539
+ "elapsed_seconds": _reclaim_clock() - started,
1540
+ }
1541
+ )
1542
+
1543
+
1544
+ def _compress_reclaiming_raw(
1545
+ raw: Path, archive: Path, *, zstd_binary: str | None, reclaim_basis: str = "volume_delta"
1546
+ ) -> str:
1446
1547
  """Write durable independent frames, then release their raw extents; hash before punching.
1447
1548
 
1448
1549
  Concatenated gzip members and zstd frames decode to the original byte stream. A failed
@@ -1480,7 +1581,6 @@ def _compress_reclaiming_raw(raw: Path, archive: Path, *, zstd_binary: str | Non
1480
1581
  # because no unread bytes follow it. APFS may retain its final partial block.
1481
1582
  before_free = shutil.disk_usage(archive.parent).free
1482
1583
  _punch_raw_hole(source.fileno(), offset, len(chunk))
1483
- freed = shutil.disk_usage(archive.parent).free - before_free
1484
1584
  after_blocks = os.fstat(source.fileno()).st_blocks
1485
1585
  if after_blocks >= before_blocks:
1486
1586
  raise RuntimeError("punched raw extents not reclaimed (filesystem allocation pin)")
@@ -1488,10 +1588,15 @@ def _compress_reclaiming_raw(raw: Path, archive: Path, *, zstd_binary: str | Non
1488
1588
  # accumulate against reclaim. Allow half a chunk for concurrent volume activity.
1489
1589
  # Short tails can free less than this tolerance due to APFS accounting; they
1490
1590
  # retain the per-file block check above and the raw free-space floor below.
1491
- tolerance = len(chunk) // 2
1492
- if len(chunk) == RECLAIM_CHUNK_BYTES and freed < len(chunk) - tolerance:
1493
- raise RuntimeError(
1494
- "punched raw extents not reclaimed (possible APFS snapshot pin or delayed volume reclaim)"
1591
+ # With no snapshots, the block drop proves reclaim despite unrelated writers.
1592
+ if len(chunk) == RECLAIM_CHUNK_BYTES and reclaim_basis != "block_drop_no_snapshot":
1593
+ _confirm_raw_reclaim(
1594
+ archive.parent,
1595
+ offset=offset,
1596
+ length=len(chunk),
1597
+ before_blocks=before_blocks,
1598
+ after_blocks=after_blocks,
1599
+ before_free=before_free,
1495
1600
  )
1496
1601
  offset += len(chunk)
1497
1602
  _require_raw_floor(archive.parent)
@@ -1707,11 +1812,18 @@ def create_sqlite_backup_artifact(
1707
1812
  temp_archive = Path(tmp) / final_archive.name
1708
1813
  compress_started = time.monotonic()
1709
1814
  raw_sha256 = None
1815
+ reclaim_basis = None
1710
1816
  if reclaim_raw:
1817
+ # Cache once for this backup, before compression starts, never per chunk.
1818
+ snapshots = _staging_snapshots_may_exist(output_dir)
1819
+ reclaim_basis = "block_drop_no_snapshot" if snapshots is False else "volume_delta"
1820
+ print(f"Backup raw reclaim_basis={reclaim_basis}; snapshots_may_exist={snapshots}", flush=True)
1711
1821
  # Keep consumed extents inside the owned attempt, never at the published raw path.
1712
1822
  consumed_raw = _consume_raw_in_attempt(raw_snapshot, Path(tmp))
1713
1823
  print("Backup consuming verified raw snapshot; a failed attempt requires a fresh snapshot", flush=True)
1714
- raw_sha256 = _compress_reclaiming_raw(consumed_raw, temp_archive, zstd_binary=zstd_binary)
1824
+ raw_sha256 = _compress_reclaiming_raw(
1825
+ consumed_raw, temp_archive, zstd_binary=zstd_binary, reclaim_basis=reclaim_basis
1826
+ )
1715
1827
  elif zstd_binary is not None:
1716
1828
  _compress_zstd(zstd_binary, raw_snapshot, temp_archive, output_dir)
1717
1829
  else:
@@ -1755,6 +1867,7 @@ def create_sqlite_backup_artifact(
1755
1867
  compressor_fallback=compressor_fallback,
1756
1868
  compress_duration_seconds=compress_duration,
1757
1869
  raw_sha256=raw_sha256,
1870
+ reclaim_basis=reclaim_basis,
1758
1871
  )
1759
1872
 
1760
1873
 
@@ -3425,6 +3538,7 @@ def run_backup(
3425
3538
  snapshot_duration_seconds=resume.get("snapshot_duration_seconds"),
3426
3539
  integrity_duration_seconds=resume.get("integrity_duration_seconds"),
3427
3540
  raw_sha256=resume.get("raw_sha256"),
3541
+ reclaim_basis=resume.get("reclaim_basis"),
3428
3542
  )
3429
3543
  for suffix in DB_SNAPSHOT_SUFFIXES:
3430
3544
  target = Path(staging_dir) / f"{resolved_date_stamp}{suffix}"
@@ -3488,6 +3602,7 @@ def run_backup(
3488
3602
  "snapshot_duration_seconds": getattr(artifact, "snapshot_duration_seconds", None),
3489
3603
  "integrity_duration_seconds": getattr(artifact, "integrity_duration_seconds", None),
3490
3604
  "raw_sha256": getattr(artifact, "raw_sha256", None),
3605
+ "reclaim_basis": getattr(artifact, "reclaim_basis", None),
3491
3606
  "compressor": getattr(artifact, "compressor", None)
3492
3607
  or ("zstd" if snapshot.name.endswith(ZSTD_SNAPSHOT_SUFFIX) else "gzip"),
3493
3608
  "compress_duration_seconds": getattr(artifact, "compress_duration_seconds", None),
@@ -3715,6 +3830,8 @@ def run_backup(
3715
3830
  {"error_type": getattr(exc, "error_type", type(exc).__name__), "error": _redact_upload_secrets(str(exc))}
3716
3831
  )
3717
3832
  error_code = getattr(exc, "error_code", None)
3833
+ if isinstance(exc, RawReclaimUnconfirmedError):
3834
+ result["raw_reclaim"] = exc.measurements
3718
3835
  if isinstance(error_code, str):
3719
3836
  result["error_code"] = error_code
3720
3837
  if isinstance(exc, DriveUploadStalledError):
@@ -0,0 +1,365 @@
1
+ """LinkedIn KG import with an explicit copy or SHA-confirmed live mode. Output contains counts, never contact records.
2
+
3
+ Run: python -m brainlayer.linkedin_import EXPORT.json --db-copy COPY.db
4
+ Identity matching is exact: LinkedIn URL, email, then normalized full name.
5
+ First/last names stay in metadata; name aliases must contain multiple tokens.
6
+ """
7
+
8
+ import argparse
9
+ import hashlib
10
+ import json
11
+ import logging
12
+ import os
13
+ import re
14
+ import time
15
+ from collections import Counter, defaultdict
16
+ from pathlib import Path
17
+ from urllib.parse import urlsplit
18
+ from uuid import uuid4
19
+
20
+ from .disk_space import measure_disk_space
21
+ from .kg_repo import KGMixin
22
+ from .paths import resolve_db_path
23
+ from .pipeline.entity_resolution import _normalize_name
24
+ from .runtime_store import RuntimeStoreModeError, open_writer_store
25
+
26
+ SOURCE = "linkedin-export 2026-10-31 (portfolio scrub)"
27
+ ATTRIBUTES = (
28
+ "first_name",
29
+ "last_name",
30
+ "linkedin_url",
31
+ "company",
32
+ "position",
33
+ "connected_on",
34
+ "relationship_strength",
35
+ "has_messages",
36
+ )
37
+
38
+
39
+ class ImportRefusal(ValueError):
40
+ """An importer-owned static reason; never carries source values."""
41
+
42
+ def __init__(self, reason):
43
+ if reason not in {
44
+ "copy_refusal",
45
+ "invalid_export",
46
+ "disk_floor",
47
+ "confirmation_required",
48
+ "confirmation_sha256_mismatch",
49
+ }:
50
+ raise ValueError("unknown_import_reason")
51
+ self.reason = reason
52
+ super().__init__(reason)
53
+
54
+
55
+ def normalized(value):
56
+ return " ".join(_normalize_name(value or "").split())
57
+
58
+
59
+ def email_key(value):
60
+ return (value or "").strip().casefold()
61
+
62
+
63
+ def linkedin_key(value):
64
+ if not value:
65
+ return ""
66
+ parsed = urlsplit(value if "://" in value else "https://" + value)
67
+ if not re.fullmatch(r"(?:www\.|[a-z]{2}\.)?linkedin\.com", parsed.hostname or "") or not parsed.path.startswith(
68
+ "/in/"
69
+ ):
70
+ raise ValueError("invalid_linkedin_url")
71
+ return "https://linkedin.com" + parsed.path.rstrip("/").casefold()
72
+
73
+
74
+ def require_copy(path, live=None):
75
+ """Refuse canonical, configured-live, symlink and hardlink aliases before opening."""
76
+ from .paths import get_db_path
77
+
78
+ path = Path(path).expanduser().resolve(strict=True)
79
+ protected = [Path.home() / ".local/share/brainlayer/brainlayer.db", get_db_path()]
80
+ if live is not None:
81
+ protected.append(Path(live))
82
+ if any(path == p.expanduser().resolve() or (p.exists() and os.path.samefile(path, p)) for p in protected):
83
+ raise ImportRefusal("copy_refusal")
84
+ return path
85
+
86
+
87
+ class _ImportGraph(KGMixin):
88
+ """Use the supported KG writers on one connection, including read-your-writes."""
89
+
90
+ def __init__(self, store):
91
+ self.conn, self.db_path = store.conn, store.db_path
92
+
93
+ def _read_cursor(self):
94
+ return self.conn.cursor()
95
+
96
+
97
+ def import_connections(store, export):
98
+ """Import atomically on the caller's supported writer; never opens a database."""
99
+ started = time.monotonic()
100
+ raw = export if isinstance(export, bytes) else Path(export).read_bytes()
101
+ rows = json.loads(raw)
102
+ if not isinstance(rows, list):
103
+ raise ImportRefusal("invalid_export")
104
+ store.conn.execute("BEGIN IMMEDIATE")
105
+ try:
106
+ result = _import_rows(_ImportGraph(store), rows, hashlib.sha256(raw).hexdigest(), started)
107
+ store.conn.execute("COMMIT")
108
+ return result
109
+ except BaseException:
110
+ store.conn.execute("ROLLBACK")
111
+ raise
112
+
113
+
114
+ def _import_rows(store, rows, sha256, started):
115
+ provenance = {"source": SOURCE, "sha256": sha256}
116
+ counts = {key: {"new": 0, "merged": 0} for key in ("persons", "companies", "relations")}
117
+ counts["persons"].update(merged_by=dict.fromkeys(("linkedin_url", "email", "name"), 0), new_name_conflict=0)
118
+ counts["companies"]["merged_by"] = {"name": 0}
119
+ skipped = Counter()
120
+ invalid_existing_alias = 0
121
+ identities = defaultdict(lambda: defaultdict(set))
122
+
123
+ def index_identity(kind, key, eid):
124
+ if key:
125
+ indexes[kind][key].add(eid)
126
+ identities[eid][kind].add(key)
127
+
128
+ indexes = {kind: defaultdict(set) for kind in ("person", "company", "linkedin_url", "email")}
129
+ for eid, kind, name, meta in store.conn.execute(
130
+ "SELECT id, entity_type, name, metadata FROM kg_entities WHERE entity_type IN ('person','company')"
131
+ ):
132
+ indexes[kind][normalized(name)].add(eid)
133
+ if kind == "person":
134
+ metadata = json.loads(meta or "{}")
135
+ if metadata.get("linkedin_full_name"):
136
+ indexes[kind][normalized(metadata["linkedin_full_name"])].add(eid)
137
+ try:
138
+ index_identity("linkedin_url", linkedin_key(metadata.get("linkedin_url")), eid)
139
+ except ValueError:
140
+ pass
141
+ contact = metadata.get("contact_info") or {}
142
+ for value in (metadata.get("email"), contact.get("email")):
143
+ index_identity("email", email_key(value), eid)
144
+ for alias, eid, kind in store.conn.execute(
145
+ "SELECT a.alias, a.entity_id, a.alias_type FROM kg_entity_aliases a "
146
+ "JOIN kg_entities e ON e.id=a.entity_id WHERE e.entity_type='person' "
147
+ "AND a.alias_type IN ('name','linkedin_url','email')"
148
+ ):
149
+ if kind == "name":
150
+ indexes["person"][normalized(alias)].add(eid)
151
+ continue
152
+ try:
153
+ key = linkedin_key(alias) if kind == "linkedin_url" else email_key(alias)
154
+ except ValueError:
155
+ invalid_existing_alias += 1
156
+ continue
157
+ index_identity(kind, key, eid)
158
+ owner_ids = indexes["person"].get(normalized("Etan Heyman"), set())
159
+ owner = next(iter(owner_ids)) if len(owner_ids) == 1 else None
160
+ seen_people, seen_companies, seen_relations = set(), set(), set()
161
+
162
+ def upsert(kind, name, eid, attributes, row_number=None, name_conflict=False, identity_key=None):
163
+ old = store.get_entity(eid) if eid else None
164
+ metadata = dict(old["metadata"]) if old else {}
165
+ metadata.update({k: v for k, v in attributes.items() if v is not None and v != ""})
166
+ evidence = dict(provenance)
167
+ if row_number is not None:
168
+ evidence["row_number"] = row_number
169
+ sources = list(metadata.get("linkedin_imports", []))
170
+ if evidence not in sources:
171
+ sources.append(evidence)
172
+ metadata["linkedin_imports"] = sources
173
+ storage_name = old["name"] if old else name
174
+ if name_conflict:
175
+ # The KG enforces UNIQUE(type, name). Keep the original as a full-name alias.
176
+ suffix = hashlib.sha256(identity_key.encode()).hexdigest()[:16]
177
+ storage_name = f"{name} [linkedin-{suffix}]"
178
+ metadata["linkedin_full_name"] = name
179
+ stored = store.upsert_entity(eid or f"{kind}-{uuid4().hex}", kind, storage_name, metadata=metadata)
180
+ indexes[kind][normalized(name)].add(stored)
181
+ if kind == "person" and len(name.split()) > 1:
182
+ store.add_entity_alias(name, stored)
183
+ return stored
184
+
185
+ def relation(source, target, kind, properties):
186
+ key = (source, target, kind)
187
+ if key in seen_relations:
188
+ return
189
+ seen_relations.add(key)
190
+ existing = next(
191
+ (
192
+ r
193
+ for r in store.get_entity_relations(source, direction="outgoing")
194
+ if r["target_id"] == target and r["relation_type"] == kind
195
+ ),
196
+ None,
197
+ )
198
+ props = dict(existing["properties"]) if existing else {}
199
+ props.update({k: v for k, v in properties.items() if v is not None and v != ""})
200
+ sources = list(props.get("linkedin_imports", []))
201
+ if provenance not in sources:
202
+ sources.append(provenance)
203
+ props["linkedin_imports"] = sources
204
+ store.add_relation(
205
+ f"rel-{uuid4().hex}",
206
+ source,
207
+ target,
208
+ kind,
209
+ properties=props,
210
+ confidence=existing["confidence"] if existing else 1.0,
211
+ importance=existing["importance"] if existing else 0.5,
212
+ )
213
+ counts["relations"]["merged" if existing else "new"] += 1
214
+
215
+ for number, row in enumerate(rows, 1):
216
+ if measure_disk_space(store.db_path.parent, include_available=False).free_now_bytes < 2 * 1024**3:
217
+ raise ImportRefusal("disk_floor")
218
+ if (
219
+ not isinstance(row, dict)
220
+ or any(
221
+ value is not None and not isinstance(value, str) for key, value in row.items() if key != "has_messages"
222
+ )
223
+ or row.get("has_messages") not in (None, True, False)
224
+ ):
225
+ skipped["invalid_row"] += 1
226
+ continue
227
+ name = " ".join((row.get("full_name") or "").split())
228
+ if not name:
229
+ skipped["missing_name"] += 1
230
+ continue
231
+ try:
232
+ url = linkedin_key(row.get("linkedin_url"))
233
+ except ValueError:
234
+ skipped["invalid_linkedin_url"] += 1
235
+ continue
236
+ email = email_key(row.get("email"))
237
+ url_owners = indexes["linkedin_url"].get(url, set()) if url else set()
238
+ email_owners = indexes["email"].get(email, set()) if email else set()
239
+ if url_owners and email_owners and url_owners != email_owners:
240
+ # The URL and email already belong to different people: never join them.
241
+ skipped["ambiguous_identity"] += 1
242
+ continue
243
+ match_kind, matches = next(
244
+ (
245
+ (kind, indexes[kind][key])
246
+ for kind, key in (("linkedin_url", url), ("email", email))
247
+ if key and indexes[kind].get(key)
248
+ ),
249
+ ("name", set()),
250
+ )
251
+ # One person has one LinkedIn profile: a candidate holding a different URL is a different person.
252
+ if matches and url and any(identities[eid]["linkedin_url"] - {url} for eid in matches):
253
+ skipped["ambiguous_identity"] += 1
254
+ continue
255
+ name_candidates = indexes["person"].get(normalized(name), set())
256
+ name_conflict = False
257
+ if not matches:
258
+ matches = {
259
+ eid
260
+ for eid in name_candidates
261
+ if not any(
262
+ key and identities[eid][kind] and identities[eid][kind] != {key}
263
+ for kind, key in (("linkedin_url", url), ("email", email))
264
+ )
265
+ }
266
+ name_conflict = bool(name_candidates and not matches)
267
+ if matches and name_candidates - matches:
268
+ skipped["ambiguous_identity"] += 1
269
+ continue
270
+ if len(matches) > 1:
271
+ skipped["ambiguous_identity"] += 1
272
+ continue
273
+ eid = next(iter(matches), None)
274
+ if eid and indexes["person"].get(normalized(store.get_entity(eid)["name"]), set()) - {eid}:
275
+ skipped["ambiguous_identity"] += 1
276
+ continue
277
+ if eid in seen_people:
278
+ skipped["duplicate_in_file"] += 1
279
+ continue
280
+ eid = upsert("person", name, eid, {k: row.get(k) for k in ATTRIBUTES}, number, name_conflict, url or email)
281
+ counts["persons"]["merged" if matches else "new"] += 1
282
+ counts["persons"]["new_name_conflict"] += int(name_conflict)
283
+ if matches:
284
+ counts["persons"]["merged_by"][match_kind] += 1
285
+ seen_people.add(eid)
286
+ for kind, key in (("linkedin_url", url), ("email", email)):
287
+ if key:
288
+ index_identity(kind, key, eid)
289
+ store.add_entity_alias(key, eid, alias_type=kind)
290
+ if owner and owner != eid:
291
+ relation(
292
+ eid,
293
+ owner,
294
+ "linkedin_connection",
295
+ {k: row.get(k) for k in ("connected_on", "relationship_strength", "has_messages")},
296
+ )
297
+ company = " ".join((row.get("company_normalized") or row.get("company") or "").split())
298
+ if not company:
299
+ continue
300
+ key = normalized(company)
301
+ matches = indexes["company"].get(key, set())
302
+ if len(matches) > 1:
303
+ skipped["ambiguous_company"] += 1
304
+ continue
305
+ cid = next(iter(matches), None)
306
+ if key not in seen_companies:
307
+ cid = upsert("company", company, cid, {})
308
+ counts["companies"]["merged" if matches else "new"] += 1
309
+ counts["companies"]["merged_by"]["name"] += int(bool(matches))
310
+ seen_companies.add(key)
311
+ relation(eid, cid, "works_at", {"position": row.get("position")})
312
+ return {
313
+ **counts,
314
+ "rows": len(rows),
315
+ "invalid_existing_alias": invalid_existing_alias,
316
+ "skipped": dict(skipped),
317
+ "owner_found": owner is not None,
318
+ "duplicates_within_file": skipped["duplicate_in_file"],
319
+ "rows_missing_name": skipped["missing_name"],
320
+ "seconds": round(time.monotonic() - started, 3),
321
+ "sha256": provenance["sha256"],
322
+ }
323
+
324
+
325
+ def main():
326
+ parser = argparse.ArgumentParser(description=__doc__)
327
+ parser.add_argument("export", type=Path)
328
+ mode = parser.add_mutually_exclusive_group(required=True)
329
+ mode.add_argument("--db-copy", type=Path)
330
+ mode.add_argument("--live", action="store_true")
331
+ parser.add_argument("--confirm-sha256")
332
+ args = parser.parse_args()
333
+ previous_logging_level = logging.root.manager.disable
334
+ logging.disable(logging.CRITICAL) # Library diagnostics can contain personal data.
335
+ store = None
336
+ try:
337
+ if args.live and not args.confirm_sha256:
338
+ raise ImportRefusal("confirmation_required")
339
+ raw = args.export.read_bytes() # Hash and import these same bytes; never reopen the export.
340
+ if args.live and hashlib.sha256(raw).hexdigest() != args.confirm_sha256:
341
+ raise ImportRefusal("confirmation_sha256_mismatch")
342
+ path = resolve_db_path() if args.live else require_copy(args.db_copy)
343
+ store = open_writer_store(path)
344
+ print(json.dumps(import_connections(store, raw), sort_keys=True))
345
+ except Exception as exc:
346
+ reason = (
347
+ exc.reason
348
+ if isinstance(exc, ImportRefusal)
349
+ else "invalid_export"
350
+ if isinstance(exc, json.JSONDecodeError)
351
+ else "invalid_store_mode"
352
+ if isinstance(exc, RuntimeStoreModeError)
353
+ else "import_failed"
354
+ )
355
+ print(json.dumps({"error_type": type(exc).__name__, "reason": reason}))
356
+ return 1
357
+ finally:
358
+ if store is not None:
359
+ store.close()
360
+ logging.disable(previous_logging_level)
361
+ return 0
362
+
363
+
364
+ if __name__ == "__main__":
365
+ raise SystemExit(main())
@@ -131,7 +131,11 @@ def _daily_snapshot(records: list[dict[str, Any]]) -> tuple[dict[str, Any] | Non
131
131
  error_type = str(pending.get("error_type") or pending.get("error_code") or "backup_error")
132
132
  error_reason = pending.get("error") or pending.get("verification_error")
133
133
  break
134
- if error_type in {"BrainBarDatabaseIdentityError", "BrainBarDatabaseIdentityUnavailableError"} and error_reason:
134
+ if (
135
+ error_type
136
+ in {"BrainBarDatabaseIdentityError", "BrainBarDatabaseIdentityUnavailableError", "raw_reclaim_unconfirmed"}
137
+ and error_reason
138
+ ):
135
139
  error_type = f"{error_type}: {error_reason}"
136
140
  if "google-drive-mcp/tokens.json" in str(latest.get("error", "")):
137
141
  error_type = "drive_credentials_missing"
@@ -489,7 +493,14 @@ def build_backups_section(
489
493
  resolved_alert_path = alert_path(env=env, db_path=db_path)
490
494
  job_alerts = active_alerts(resolved_alert_path)
491
495
  if job_alerts:
492
- error_type = "job_alert:" + next(iter(job_alerts.values()))
496
+ # The generic DB job alert must not replace this attempt's measured diagnostic.
497
+ reclaim_failure = (error_type or "").startswith("raw_reclaim_unconfirmed:")
498
+ generic_db_alerts = all(
499
+ key in {"backup-daily", "backup-snapshot"} and reason == "Database backup failed; check the backup log"
500
+ for key, reason in job_alerts.items()
501
+ )
502
+ if not (reclaim_failure and generic_db_alerts):
503
+ error_type = "job_alert:" + next(iter(job_alerts.values()))
493
504
  alert_input = record_input(resolved_alert_path, status="read", rows_or_bytes=None)
494
505
  inputs.append(alert_input)
495
506
  if alert_input.get("status") == "future":
@@ -1 +0,0 @@
1
- BUILD_SHA = "cc301a9f19b679f75278f9cca43c48fc49fe8d7e"
File without changes
File without changes