brainlayer 1.5.45__tar.gz → 1.5.46__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 (236) hide show
  1. {brainlayer-1.5.45 → brainlayer-1.5.46}/PKG-INFO +1 -1
  2. {brainlayer-1.5.45 → brainlayer-1.5.46}/pyproject.toml +1 -1
  3. {brainlayer-1.5.45 → brainlayer-1.5.46}/server.json +2 -2
  4. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/__init__.py +1 -1
  5. brainlayer-1.5.46/src/brainlayer/_build.py +1 -0
  6. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/chunk_write.py +63 -1
  7. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/cli/__init__.py +41 -0
  8. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/doctor.py +87 -0
  9. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/hooks/indexer.py +4 -0
  10. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/maintenance.py +9 -1
  11. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/cloud_scrub.py +44 -5
  12. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/secret_scrub.py +20 -7
  13. brainlayer-1.5.46/src/brainlayer/scrub_at_rest.py +350 -0
  14. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/store.py +8 -2
  15. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/vector_store.py +77 -7
  16. brainlayer-1.5.45/src/brainlayer/_build.py +0 -1
  17. {brainlayer-1.5.45 → brainlayer-1.5.46}/.gitignore +0 -0
  18. {brainlayer-1.5.45 → brainlayer-1.5.46}/LICENSE +0 -0
  19. {brainlayer-1.5.45 → brainlayer-1.5.46}/README.md +0 -0
  20. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/hotlane_brainbar_daemon.py +0 -0
  21. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/backup-daily.sh +0 -0
  22. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/brainlayer-env-run.sh +0 -0
  23. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/brainlayer.env.example +0 -0
  24. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/com.brainlayer.backup-daily.plist +0 -0
  25. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/com.brainlayer.decay.plist +0 -0
  26. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/com.brainlayer.drain.plist +0 -0
  27. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/com.brainlayer.enrichment.plist +0 -0
  28. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/com.brainlayer.health-check.plist +0 -0
  29. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/com.brainlayer.hotlane-brainbar.plist +0 -0
  30. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/com.brainlayer.index.plist +0 -0
  31. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/com.brainlayer.jsonl-backup.plist +0 -0
  32. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/com.brainlayer.log-cap.plist +0 -0
  33. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/com.brainlayer.maintenance-nightly.plist +0 -0
  34. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/com.brainlayer.maintenance-weekly.plist +0 -0
  35. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/com.brainlayer.observability.plist +0 -0
  36. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/com.brainlayer.p0-counter.plist +0 -0
  37. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/com.brainlayer.repair-fts.plist +0 -0
  38. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/com.brainlayer.t3-ingest.plist +0 -0
  39. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/com.brainlayer.throughput-watchdog.plist +0 -0
  40. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/com.brainlayer.tier0-watchdog.plist +0 -0
  41. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/com.brainlayer.wal-checkpoint.plist +0 -0
  42. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/com.brainlayer.watch.plist +0 -0
  43. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/com.etanhey.brainlayer-fleet-watchdog.plist +0 -0
  44. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/fleet-watchdog.sh +0 -0
  45. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/install.sh +0 -0
  46. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/jsonl-backup.sh +0 -0
  47. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/launchd/throughput-watchdog.py +0 -0
  48. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/release-verify-signatures.sh +0 -0
  49. {brainlayer-1.5.45 → brainlayer-1.5.46}/scripts/tier0-watchdog.sh +0 -0
  50. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/__main__.py +0 -0
  51. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/_helpers.py +0 -0
  52. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/agent_profiles.py +0 -0
  53. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/agent_provenance.py +0 -0
  54. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/alarm.py +0 -0
  55. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/archive_collapse.py +0 -0
  56. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/archive_lifecycle.py +0 -0
  57. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/backup_daily.py +0 -0
  58. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/backup_retention_invariant.py +0 -0
  59. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/badge_state.py +0 -0
  60. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/bitemporal.py +0 -0
  61. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/brainbar_hybrid_helper.py +0 -0
  62. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/cachedelete_purge.swift +0 -0
  63. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/calibrate.py +0 -0
  64. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/chunk_origin.py +0 -0
  65. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/chunk_origin_backfill.py +0 -0
  66. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/chunk_origin_wipe.py +0 -0
  67. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/classify.py +0 -0
  68. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/claude_paths.py +0 -0
  69. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/cli/wizard.py +0 -0
  70. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/cli_new.py +0 -0
  71. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/cloud_backfill.py +0 -0
  72. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/clustering.py +0 -0
  73. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/config.py +0 -0
  74. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/content_class.py +0 -0
  75. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/correction_judge.py +0 -0
  76. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/data/agents_registry.yaml +0 -0
  77. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/data/sandbox_seeds/skill-eval-baseline.json +0 -0
  78. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/db_shrink.py +0 -0
  79. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/decay.py +0 -0
  80. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/decay_backfill.py +0 -0
  81. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/decay_job.py +0 -0
  82. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/dedupe.py +0 -0
  83. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/dedupe_merge.py +0 -0
  84. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/deploy_drift.py +0 -0
  85. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/drain.py +0 -0
  86. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/drain_liveness.py +0 -0
  87. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/drive_credentials.py +0 -0
  88. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/embeddings.py +0 -0
  89. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/engine.py +0 -0
  90. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/enrichment_controller.py +0 -0
  91. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/eval/__init__.py +0 -0
  92. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/eval/abcde_enrich_runner.py +0 -0
  93. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/eval/abcde_variants.py +0 -0
  94. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/eval/abcde_variants.yaml +0 -0
  95. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/eval/benchmark.py +0 -0
  96. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/eval/enrichment_gold.py +0 -0
  97. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/eval/enrichment_graders.py +0 -0
  98. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/eval/enrichment_judge.py +0 -0
  99. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/eval/enrichment_llm_judge.py +0 -0
  100. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/eval/enrichment_quality_benchmark.py +0 -0
  101. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/eval/experiment_store.py +0 -0
  102. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/eval/fixtures/README.md +0 -0
  103. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/eval/labeling/enrichment-labeler.html +0 -0
  104. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/eval/phoenix_gate/__init__.py +0 -0
  105. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/eval/phoenix_gate/__main__.py +0 -0
  106. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/eval/phoenix_gate/baseline_store.py +0 -0
  107. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/eval/phoenix_gate/cli.py +0 -0
  108. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/eval/phoenix_gate/models.py +0 -0
  109. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/eval/phoenix_gate/phoenix_client.py +0 -0
  110. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/eval/phoenix_gate/regression_gate.py +0 -0
  111. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/eval/phoenix_gate/triggers.py +0 -0
  112. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/fallback_replay.py +0 -0
  113. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/git_learning.py +0 -0
  114. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/health_check.py +0 -0
  115. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/hook_python.py +0 -0
  116. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/hooks/__init__.py +0 -0
  117. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/import_sweep.py +0 -0
  118. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/index_completeness.py +0 -0
  119. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/index_new.py +0 -0
  120. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/index_watchdog.py +0 -0
  121. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/ingest/__init__.py +0 -0
  122. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/ingest/codex.py +0 -0
  123. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/ingest/t3.py +0 -0
  124. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/ingest_denylist.py +0 -0
  125. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/ingest_guard.py +0 -0
  126. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/isolation_proof.py +0 -0
  127. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/job_alerts.py +0 -0
  128. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/job_lifecycle_health.py +0 -0
  129. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/jobs.py +0 -0
  130. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/jsonl_backup.py +0 -0
  131. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/kg/__init__.py +0 -0
  132. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/kg_cleanup.py +0 -0
  133. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/kg_judge.py +0 -0
  134. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/kg_promotion.py +0 -0
  135. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/kg_repo.py +0 -0
  136. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/kg_review_session.py +0 -0
  137. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/kg_session_finish.py +0 -0
  138. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/kg_session_harvest.py +0 -0
  139. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/launchd_primitive.py +0 -0
  140. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/lexical_defense.py +0 -0
  141. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/lexical_defense_dictionary.json +0 -0
  142. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/log_cap.py +0 -0
  143. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/mcp/__init__.py +0 -0
  144. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/mcp/_format.py +0 -0
  145. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/mcp/_shared.py +0 -0
  146. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/mcp/enrich_handler.py +0 -0
  147. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/mcp/entity_handler.py +0 -0
  148. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/mcp/palette.py +0 -0
  149. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/mcp/search_handler.py +0 -0
  150. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/mcp/store_handler.py +0 -0
  151. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/mcp/tags_handler.py +0 -0
  152. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/mcp_socket_config.py +0 -0
  153. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/mcp_stdio_bridge.py +0 -0
  154. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/memory_types.py +0 -0
  155. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/migrate.py +0 -0
  156. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/notification_policy.py +0 -0
  157. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/observability_backup.py +0 -0
  158. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/observability_surface.py +0 -0
  159. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/p0_longitudinal_count.py +0 -0
  160. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/parent_death.py +0 -0
  161. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/paths.py +0 -0
  162. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pause.py +0 -0
  163. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/phonetic.py +0 -0
  164. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/__init__.py +0 -0
  165. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/agent_enrichment.py +0 -0
  166. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/analyze_communication.py +0 -0
  167. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/batch_extraction.py +0 -0
  168. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/brain_graph.py +0 -0
  169. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/chat_tags.py +0 -0
  170. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/chunk.py +0 -0
  171. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/classify.py +0 -0
  172. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/cluster_sampling.py +0 -0
  173. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/code_intelligence.py +0 -0
  174. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/correction_detection.py +0 -0
  175. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/correction_mining.py +0 -0
  176. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/digest.py +0 -0
  177. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/enrichment.py +0 -0
  178. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/enrichment_tiers.py +0 -0
  179. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/entity_extraction.py +0 -0
  180. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/entity_resolution.py +0 -0
  181. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/extract.py +0 -0
  182. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/extract_claude_desktop.py +0 -0
  183. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/extract_corrections.py +0 -0
  184. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/extract_markdown.py +0 -0
  185. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/extract_whatsapp.py +0 -0
  186. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/frustration_mining.py +0 -0
  187. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/git_overlay.py +0 -0
  188. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/groq.py +0 -0
  189. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/kg_extraction.py +0 -0
  190. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/kg_extraction_groq.py +0 -0
  191. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/longitudinal_analyzer.py +0 -0
  192. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/obsidian_export.py +0 -0
  193. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/operation_grouping.py +0 -0
  194. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/plan_linking.py +0 -0
  195. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/rate_limiter.py +0 -0
  196. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/relation_backfill.py +0 -0
  197. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/sanitize.py +0 -0
  198. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/semantic_style.py +0 -0
  199. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/sentiment.py +0 -0
  200. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/session_enrichment.py +0 -0
  201. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/style_embed.py +0 -0
  202. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/style_index.py +0 -0
  203. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/tag_entity_promotion.py +0 -0
  204. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/temporal_chains.py +0 -0
  205. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/time_batcher.py +0 -0
  206. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/unified_timeline.py +0 -0
  207. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/pipeline/write_queue.py +0 -0
  208. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/provenance.py +0 -0
  209. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/provenance_autosupersede.py +0 -0
  210. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/provenance_integration.py +0 -0
  211. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/queue_io.py +0 -0
  212. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/queue_merge.py +0 -0
  213. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/reembed_backfill.py +0 -0
  214. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/runtime_store.py +0 -0
  215. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/sandbox_db.py +0 -0
  216. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/scoping.py +0 -0
  217. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/search_fanout.py +0 -0
  218. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/search_profile.py +0 -0
  219. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/search_repo.py +0 -0
  220. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/session_repo.py +0 -0
  221. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/setup.py +0 -0
  222. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/socket_hygiene.py +0 -0
  223. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/source_class_migration.py +0 -0
  224. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/spotlight.py +0 -0
  225. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/storage.py +0 -0
  226. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/system_prompt_guard.py +0 -0
  227. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/t3_provenance.py +0 -0
  228. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/tag_normalization.py +0 -0
  229. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/taxonomy.json +0 -0
  230. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/telemetry.py +0 -0
  231. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/timestamp_iso.py +0 -0
  232. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/types.py +0 -0
  233. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/wal_checkpoint.py +0 -0
  234. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/watcher.py +0 -0
  235. {brainlayer-1.5.45 → brainlayer-1.5.46}/src/brainlayer/watcher_bridge.py +0 -0
  236. {brainlayer-1.5.45 → brainlayer-1.5.46}/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.45
3
+ Version: 1.5.46
4
4
  Summary: Persistent memory MCP server for AI agents — 17 tools, semantic search, knowledge graph, on-device 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.45"
3
+ version = "1.5.46"
4
4
  description = "Persistent memory MCP server for AI agents — 17 tools, semantic search, knowledge graph, on-device 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 memory for AI agents \u2014 local-first semantic search, knowledge graph, and 17 MCP tools with ToolAnnotations. One SQLite file, no cloud, no API keys.",
6
- "version": "1.5.45",
6
+ "version": "1.5.46",
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.45",
17
+ "version": "1.5.46",
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.45"
5
+ __version__ = "1.5.46"
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 = "87ae86dbec7bc8f824bb742eed1ebac285d9724e"
@@ -148,6 +148,62 @@ def _table_columns(conn: Any, table: str) -> set[str]:
148
148
  return {row[1] for row in rows}
149
149
 
150
150
 
151
+ def scrub_chunk_values(values: Mapping[str, Any]) -> dict[str, Any]:
152
+ """Scrub stored text before deriving hashes, previews or merge inputs.
153
+
154
+ JSON fields are decoded/normalized before scanning so escaped credentials
155
+ and mapping keys cannot bypass the at-rest policy. Quarantine remains
156
+ metadata only. Already-scrubbed callers retain their findings.
157
+ """
158
+ from .pipeline.cloud_scrub import normalize_json_strings
159
+ from .pipeline.secret_scrub import merge_scrub_metadata, scrub_for_storage
160
+
161
+ clean = dict(values)
162
+ identity_fields = {"id", "brick_id", "conversation_id", "superseded_by", "aggregated_into"}
163
+ findings: dict[str, Any] = {}
164
+ body_findings: dict[str, Any] = {}
165
+ for key, value in clean.items():
166
+ structured = isinstance(value, (dict, list, tuple))
167
+ if structured:
168
+ value = json.dumps(value, ensure_ascii=False)
169
+ if not isinstance(value, str):
170
+ continue
171
+ if key != "content" and key not in identity_fields:
172
+ value = normalize_json_strings(value)
173
+ text, found = scrub_for_storage(value)
174
+ if key in identity_fields and text != value:
175
+ raise ValueError("chunk identifier requires redaction; refusing to change identity")
176
+ clean[key] = json.loads(text) if structured else text
177
+ # Quarantine count describes the body, not keys/derived placeholders.
178
+ findings = merge_scrub_metadata(
179
+ findings, {k: v for k, v in found.items() if k != "secret_scrub_quarantine_count"}
180
+ )
181
+ if key == "content":
182
+ body_findings = found
183
+ if findings or body_findings:
184
+ metadata = clean.get("metadata")
185
+ if metadata is None:
186
+ metadata = {}
187
+ if isinstance(metadata, str):
188
+ try:
189
+ metadata = json.loads(metadata)
190
+ except ValueError:
191
+ metadata = {"value": metadata}
192
+ if not isinstance(metadata, dict):
193
+ metadata = {"value": metadata}
194
+ if clean.get("content") == values.get("content") and (
195
+ "secret_scrub_quarantine_count" in metadata or "secret_scrub_redactions" in metadata
196
+ ):
197
+ body_findings.pop("secret_scrub_quarantine_count", None)
198
+ clean["metadata"] = merge_scrub_metadata(merge_scrub_metadata(metadata, findings), body_findings)
199
+ if clean.get("content") != values.get("content"):
200
+ clean["char_count"] = len(str(clean.get("content") or ""))
201
+ # Caller-supplied dedupe fields describe the pre-scrub text. Recompute.
202
+ for key in ("dedupe_hash", "simhash", *(f"simhash_band_{i}" for i in range(4))):
203
+ clean.pop(key, None)
204
+ return clean
205
+
206
+
151
207
  def prepare_canonical_insert(
152
208
  values: Mapping[str, Any],
153
209
  *,
@@ -158,6 +214,7 @@ def prepare_canonical_insert(
158
214
  """Fill required insert columns. Does not write."""
159
215
  from .timestamp_iso import normalize_timestamp
160
216
 
217
+ values = scrub_chunk_values(values)
161
218
  content = str(values.get("content") or "")
162
219
  if not content.strip():
163
220
  raise ValueError("content must be non-empty")
@@ -271,7 +328,12 @@ def insert_canonical_chunk(
271
328
  on_conflict: str = "ignore",
272
329
  now: datetime | None = None,
273
330
  ) -> dict[str, Any]:
274
- """Insert one chunk row using the canonical column set."""
331
+ """Insert a scrubbed chunk; hashes and dedupe fields describe stored text.
332
+
333
+ Tokens of the same provider collapse to the same placeholder, so dedupe
334
+ treats otherwise identical scrubbed memories as the same content. Existing
335
+ unsanitized rows are not rewritten by this go-forward boundary.
336
+ """
275
337
  columns = _table_columns(conn, "chunks")
276
338
  row = prepare_canonical_insert(values, now=now, columns=columns)
277
339
  names = [key for key in row if key in columns]
@@ -1285,6 +1285,8 @@ def doctor_command(
1285
1285
  f"[bold]{status}[/] chunks={result.chunk_count} "
1286
1286
  f"recent_unvectored={result.recent_unvectored_chunks} queue={result.queue_count}"
1287
1287
  )
1288
+ if result.cli_path_shadow and result.cli_path_shadow["state"] == "skipped":
1289
+ rprint(f"CLI PATH check skipped: {result.cli_path_shadow['reason']}")
1288
1290
  for issue in result.issues:
1289
1291
  color = "red" if issue.severity == "fatal" else "yellow"
1290
1292
  rprint(f"[{color}]{issue.severity.upper()}[/] {issue.code}: {issue.message}")
@@ -2865,6 +2867,45 @@ def wal_checkpoint(
2865
2867
  report_job_alert("wal-checkpoint", None)
2866
2868
 
2867
2869
 
2870
+ @app.command("scrub-at-rest")
2871
+ def scrub_at_rest_command(
2872
+ db_path: Path = typer.Option(..., "--db", help="Explicit database path; live apply requires guarded opt-in."),
2873
+ providers: str = typer.Option("google_oauth", "--providers", help="Only google_oauth is supported."),
2874
+ dry_run: bool = typer.Option(False, "--dry-run", help="Count matches without writing."),
2875
+ batch_size: int = typer.Option(100, "--batch-size", min=1, max=1000),
2876
+ allow_live_db: bool = typer.Option(
2877
+ False, "--allow-live-db", help="Apply with maintenance safety gates and writer quiescence."
2878
+ ),
2879
+ expect_rows: int | None = typer.Option(
2880
+ None, "--expect-rows", min=0, help="Required guarded apply total from the preceding dry run."
2881
+ ),
2882
+ ) -> None:
2883
+ """Redact provider credentials on a copy or a guarded runtime DB; print counts only."""
2884
+ from ..scrub_at_rest import scrub_at_rest
2885
+
2886
+ try:
2887
+ result = scrub_at_rest(
2888
+ db_path,
2889
+ providers=providers,
2890
+ dry_run=dry_run,
2891
+ batch_size=batch_size,
2892
+ allow_live_db=allow_live_db,
2893
+ expect_rows=expect_rows,
2894
+ )
2895
+ except Exception as exc:
2896
+ typer.echo(
2897
+ json.dumps(
2898
+ {
2899
+ "error": "at-rest scrub refused or failed",
2900
+ "error_type": type(exc).__name__,
2901
+ "reason": getattr(exc, "reason", "scrub-failed"),
2902
+ }
2903
+ )
2904
+ )
2905
+ raise typer.Exit(1) from None
2906
+ typer.echo(json.dumps(result, sort_keys=True))
2907
+
2908
+
2868
2909
  @app.command("repair-fts")
2869
2910
  def repair_fts(
2870
2911
  db_path: Path | None = typer.Argument(None, help="Offline database copy; omitted means read-only FTS count check."),
@@ -8,6 +8,9 @@ indexes.
8
8
  from __future__ import annotations
9
9
 
10
10
  import json
11
+ import os
12
+ import re
13
+ import shlex
11
14
  import sqlite3
12
15
  import sys
13
16
  import time
@@ -250,11 +253,93 @@ class DoctorResult:
250
253
  hotlane_running: bool = False
251
254
  roundtrip_latency_seconds: float | None = None
252
255
  fts5_health: dict[str, Any] | None = None
256
+ cli_path_shadow: dict[str, Any] | None = None
253
257
 
254
258
  def to_dict(self) -> dict[str, Any]:
255
259
  return asdict(self)
256
260
 
257
261
 
262
+ def _check_cli_path_shadow(result: DoctorResult) -> None:
263
+ """Keep an advisory PATH probe from aborting the other doctor checks."""
264
+ try:
265
+ _probe_cli_path_shadow(result)
266
+ except Exception as exc:
267
+ reason = f"{type(exc).__name__}: {exc}"
268
+ result.cli_path_shadow = {"state": "warning", "reason": reason}
269
+ result.issues.append(DoctorIssue("cli_path_shadow_check_failed", "warning", reason))
270
+
271
+
272
+ def _probe_cli_path_shadow(result: DoctorResult) -> None:
273
+ prefix = Path(sys.prefix).resolve()
274
+ keg = next(
275
+ (p for p in (prefix, *prefix.parents) if p.parent.name == "brainlayer" and p.parent.parent.name == "Cellar"),
276
+ None,
277
+ )
278
+ result.cli_path_shadow = {"state": "skipped", "reason": "running interpreter is not a BrainLayer keg install"}
279
+ if keg is None:
280
+ return
281
+ paths: list[Path] = []
282
+ seen: set[Path] = set()
283
+ probe_failed = False
284
+ for directory in os.environ.get("PATH", "").split(os.pathsep):
285
+ candidate = Path(directory) / "brainlayer"
286
+ try:
287
+ if candidate.is_file() and os.access(candidate, os.X_OK):
288
+ resolved = candidate.resolve()
289
+ if resolved not in seen:
290
+ paths.append(candidate.absolute())
291
+ seen.add(resolved)
292
+ except OSError as exc:
293
+ probe_failed = True
294
+ result.issues.append(DoctorIssue("cli_path_shadow_probe_failed", "warning", f"Skipped {candidate}: {exc}"))
295
+ severity = "fatal" if paths and not paths[0].resolve().is_relative_to(keg) else "warning"
296
+ duplicates = [p for p in paths if not p.resolve().is_relative_to(keg)]
297
+ result.cli_path_shadow = {
298
+ "state": severity if duplicates or not paths or probe_failed else "pass",
299
+ "paths": [str(p) for p in paths],
300
+ "prefix": str(keg),
301
+ }
302
+ if not paths:
303
+ result.issues.append(DoctorIssue("cli_path_shadow", "warning", "No brainlayer executable on PATH"))
304
+ for path in duplicates:
305
+ severity = "fatal" if path == paths[0] else "warning"
306
+ kind, remedy = "plain script", "put the running BrainLayer keg bin directory first on PATH"
307
+ try:
308
+ with path.open() as stream:
309
+ shebang = stream.readline(4096).strip()
310
+ interpreter = Path(shebang[2:].split()[0]) if shebang.startswith("#!/") else None
311
+ if interpreter and interpreter.name.startswith("python"):
312
+ install = interpreter.parent.parent
313
+ metadata = list(install.glob("lib/python*/site-packages/brainlayer-*.dist-info/direct_url.json"))
314
+ version_match = re.search(r"python(\d+\.\d+)$", interpreter.resolve().name)
315
+ version = version_match.group(1) if version_match else None
316
+ user_metadata = []
317
+ if version:
318
+ user_metadata = list(
319
+ (Path.home() / "Library/Python" / version / "lib/python/site-packages").glob(
320
+ "brainlayer-*.dist-info"
321
+ )
322
+ )
323
+ user_metadata += list(
324
+ (Path.home() / ".local/lib" / f"python{version}/site-packages").glob("brainlayer-*.dist-info")
325
+ )
326
+ if any(json.loads(p.read_text()).get("dir_info", {}).get("editable") is True for p in metadata):
327
+ kind = "editable install"
328
+ env = "PYTHONNOUSERSITE=1 " if user_metadata else ""
329
+ remedy = f"sudo {env}{shlex.quote(str(interpreter.parent / 'pip3'))} uninstall brainlayer"
330
+ if path.is_relative_to(Path.home() / ".local/bin") or path.is_relative_to(
331
+ Path.home() / "Library/Python"
332
+ ):
333
+ remedy = f"{shlex.quote(str(interpreter))} -m pip uninstall brainlayer"
334
+ except (OSError, ValueError, TypeError, AttributeError):
335
+ pass
336
+ result.issues.append(
337
+ DoctorIssue(
338
+ "cli_path_shadow", severity, f"{path}: {kind}; {remedy}", {"path": str(path), "remediation": remedy}
339
+ )
340
+ )
341
+
342
+
258
343
  def _ro_conn(db_path: Path) -> sqlite3.Connection:
259
344
  uri = f"file:{db_path.expanduser()}?mode=ro"
260
345
  conn = sqlite3.connect(uri, uri=True, timeout=5)
@@ -581,6 +666,8 @@ def run_doctor(
581
666
  def warning(code: str, message: str, **details: Any) -> None:
582
667
  result.issues.append(DoctorIssue(code, "warning", message, details))
583
668
 
669
+ _check_cli_path_shadow(result)
670
+
584
671
  db_config_error = get_brainlayer_db_config_error()
585
672
  if db_config_error is not None:
586
673
  fatal(
@@ -181,6 +181,10 @@ class RealtimeIndexer:
181
181
  next_index = row[0] if row else 0
182
182
 
183
183
  try:
184
+ from ..pipeline.secret_scrub import scrub_secrets
185
+
186
+ compact_summary = scrub_secrets(compact_summary).text
187
+ trigger = scrub_secrets(trigger).text
184
188
  cursor = self._db.execute(
185
189
  """INSERT INTO chapters (session_id, chapter_index, compact_summary, trigger)
186
190
  VALUES (?, ?, ?, ?)""",
@@ -376,6 +376,8 @@ def quarantine_stale_queue_files(
376
376
 
377
377
 
378
378
  def _launchd_label(service: str) -> str:
379
+ if service == "fleet-watchdog":
380
+ return "com.etanhey.brainlayer-fleet-watchdog"
379
381
  return f"com.brainlayer.{service}"
380
382
 
381
383
 
@@ -437,6 +439,12 @@ def _verify_enrichment_template_flex_backend(repo_root_or_launchd_dir: Path) ->
437
439
 
438
440
 
439
441
  def _resume_service(repo_root: Path, service: str) -> None:
442
+ if service in {"brainbar", "brainbar-daemon", "fleet-watchdog"}:
443
+ # These already-installed jobs have no normal install.sh service option
444
+ # (or own a different namespace). Restore their existing configuration.
445
+ plist = Path.home() / "Library" / "LaunchAgents" / f"{_launchd_label(service)}.plist"
446
+ run_command(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(plist)], check=True)
447
+ return
440
448
  launchd_dir = _launchd_dir_for_resume(repo_root)
441
449
  if service == "enrichment":
442
450
  _verify_enrichment_template_flex_backend(launchd_dir)
@@ -582,7 +590,7 @@ def _service_is_deliberately_paused(service: str) -> bool:
582
590
  payload, active, _stale = pause_sentinel_state(PAUSE_SENTINEL_PATH, datetime.now(UTC))
583
591
  if not active:
584
592
  return False
585
- return pause_applies_to_label(payload, f"com.brainlayer.{service}")
593
+ return pause_applies_to_label(payload, _launchd_label(service))
586
594
 
587
595
 
588
596
  def _maintenance_keep_down_services() -> set[str]:
@@ -2,8 +2,9 @@
2
2
 
3
3
  Two directions, both fail closed:
4
4
 
5
- - ``scrub_for_cloud`` runs on every text sent to a cloud model. If scrubbing
6
- raises, it raises ``CloudScrubError`` and the caller must not send.
5
+ - ``scrub_for_cloud`` runs on every text sent to a cloud model. It
6
+ redacts provider secrets and quarantine spans. Scrub errors or unresolvable
7
+ quarantine records raise ``CloudScrubError`` and the caller must not send.
7
8
  - ``scrub_llm_output`` runs on every LLM output value before it is persisted.
8
9
  A cloud model copies tokens from its prompt into summaries and key facts,
9
10
  so output is scrubbed even when the input already was. If scrubbing raises,
@@ -32,7 +33,8 @@ class CloudScrubError(RuntimeError):
32
33
 
33
34
  def _scrub_text(text: str) -> str:
34
35
  try:
35
- scrubbed = scrub_secrets(text).text
36
+ result = scrub_secrets(text)
37
+ scrubbed = result.text
36
38
  except Exception as exc:
37
39
  raise CloudScrubError(f"secret scrub failed ({type(exc).__name__}); refusing to pass text on") from None
38
40
  if not isinstance(scrubbed, str):
@@ -41,10 +43,47 @@ def _scrub_text(text: str) -> str:
41
43
 
42
44
 
43
45
  def scrub_for_cloud(text: str) -> str:
44
- """Return ``text`` with secrets redacted, ready to send to a remote LLM."""
46
+ """Redact provider secrets and quarantine spans before a remote send.
47
+
48
+ A second pass finds quarantine offsets in the already-redacted text, so
49
+ provider replacements cannot shift them. Each offset must identify its value;
50
+ a mismatch redacts every occurrence, or fails closed if none can be found.
51
+ Storage retains its local-review quarantine policy; cloud prompts redact even
52
+ identifier-like false positives.
53
+ """
45
54
  if not isinstance(text, str):
46
55
  raise CloudScrubError(f"remote LLM payload must be text, got {type(text).__name__}")
47
- return _scrub_text(text)
56
+ scrubbed = _scrub_text(text)
57
+ try:
58
+ quarantine = scrub_secrets(scrubbed).quarantine
59
+ spans: list[tuple[int, int]] = []
60
+ resolved_values: set[str] = set()
61
+ for token in quarantine:
62
+ if not token.value:
63
+ raise CloudScrubError("quarantine value is empty; refusing to send text")
64
+ if 0 <= token.start < token.end <= len(scrubbed) and scrubbed[token.start : token.end] == token.value:
65
+ spans.append((token.start, token.end))
66
+ elif token.value not in resolved_values:
67
+ start = scrubbed.find(token.value)
68
+ if start < 0:
69
+ raise CloudScrubError("quarantine value not found; refusing to send text")
70
+ while start >= 0:
71
+ end = start + len(token.value)
72
+ spans.append((start, end))
73
+ start = scrubbed.find(token.value, start + 1)
74
+ resolved_values.add(token.value)
75
+ parts: list[str] = []
76
+ cursor = 0
77
+ for start, end in sorted(spans):
78
+ if end <= cursor:
79
+ continue
80
+ if start >= cursor:
81
+ parts.extend((scrubbed[cursor:start], "[REDACTED:quarantine]"))
82
+ cursor = end
83
+ parts.append(scrubbed[cursor:])
84
+ return "".join(parts)
85
+ except Exception as exc:
86
+ raise CloudScrubError(f"secret scrub failed ({type(exc).__name__}); refusing to pass text on") from None
48
87
 
49
88
 
50
89
  def scrub_llm_output(value: T) -> T:
@@ -60,6 +60,9 @@ _PROVIDER_PATTERNS = (
60
60
  ),
61
61
  _ProviderPattern("slack", re.compile(r"\bxox[baprs]-(?:[A-Za-z0-9]+-){1,}[A-Za-z0-9]{16,}\b")),
62
62
  _ProviderPattern("google", re.compile(r"\bAIza[A-Za-z0-9_-]{32,}\b")),
63
+ _ProviderPattern("google_oauth_access", re.compile(r"ya29\.[A-Za-z0-9_.-]{20,}(?![A-Za-z0-9_-])")),
64
+ _ProviderPattern("google_oauth_refresh", re.compile(r"1//[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])")),
65
+ _ProviderPattern("google_client_secret", re.compile(r"GOCSPX-[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])")),
63
66
  _ProviderPattern("gitlab", re.compile(r"\bglpat-[A-Za-z0-9_-]{20,}\b")),
64
67
  _ProviderPattern("supabase", re.compile(r"\b(?:sbp_[A-Za-z0-9]{20,}|sb_secret_[A-Za-z0-9_-]{20,})\b")),
65
68
  _ProviderPattern("sendgrid", re.compile(r"\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{32,}\b")),
@@ -93,20 +96,28 @@ _HEX_RE = re.compile(r"\b[0-9a-fA-F]{16,}\b")
93
96
  _UUID_RE = re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b")
94
97
 
95
98
 
96
- def scrub_secrets(text: str) -> SecretScrubResult:
97
- """Redact labeled secrets before persistence while preserving join-key-like tokens."""
99
+ def scrub_secrets(text: str, *, providers: frozenset[str] | None = None) -> SecretScrubResult:
100
+ """Redact secrets; an explicit provider set selects only those provider spans."""
101
+ if providers is not None and not providers <= {p.provider for p in _PROVIDER_PATTERNS}:
102
+ raise ValueError("unknown secret provider selector")
98
103
  if not text:
99
104
  return SecretScrubResult(text=text)
100
105
 
101
- windows = list(_scan_windows(text))
106
+ # One-off provider selection must see real token boundaries, not window ends.
107
+ windows = [(0, len(text))] if providers is not None else list(_scan_windows(text))
102
108
  spans: list[SecretRedaction] = []
103
109
  for start, end in windows:
104
110
  spans.extend(_provider_redactions(text[start:end], offset=start))
105
- for start, end in windows:
106
- spans.extend(_assignment_redactions(text[start:end], spans, offset=start))
111
+ if providers is not None:
112
+ spans = [span for span in spans if span.provider in providers]
113
+ else:
114
+ for start, end in windows:
115
+ spans.extend(_assignment_redactions(text[start:end], spans, offset=start))
107
116
  spans = _without_overlaps(sorted(spans, key=lambda item: (item.start, item.end)))
108
117
 
109
118
  scrubbed = _apply_redactions(text, spans) if spans else text
119
+ if providers is not None:
120
+ return SecretScrubResult(text=scrubbed, redactions=spans)
110
121
  quarantine: list[QuarantinedToken] = []
111
122
  for start, end in windows:
112
123
  quarantine.extend(_quarantine_unlabeled_entropy(text[start:end], spans, offset=start))
@@ -198,8 +209,10 @@ def _quarantine_unlabeled_entropy(
198
209
  quarantined: list[QuarantinedToken] = []
199
210
  redaction_index = _SpanIndex(redactions)
200
211
  for match in _TOKEN_RE.finditer(text):
201
- value = match.group(0).strip(".,;)")
202
- start = offset + match.start()
212
+ raw_value = match.group(0)
213
+ value = raw_value.strip(".,;)")
214
+ leading_trim = len(raw_value) - len(raw_value.lstrip(".,;)"))
215
+ start = offset + match.start() + leading_trim
203
216
  end = start + len(value)
204
217
  if redaction_index.overlaps(start, end):
205
218
  continue