shellbrain 0.1.12__tar.gz → 0.1.14__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 (201) hide show
  1. {shellbrain-0.1.12 → shellbrain-0.1.14}/PKG-INFO +14 -1
  2. {shellbrain-0.1.12 → shellbrain-0.1.14}/README.md +13 -0
  3. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/interfaces/repos.py +12 -0
  4. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/policies/_shared/executor.py +2 -2
  5. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/use_cases/sync_episode.py +8 -12
  6. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/admin/analytics_diagnostics.py +1 -1
  7. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/admin/instance_guard.py +20 -1
  8. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/cli/main.py +87 -0
  9. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/repos/relational/episodes_repo.py +55 -1
  10. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/repos/relational/evidence_repo.py +10 -1
  11. shellbrain-0.1.14/app/periphery/metrics/__init__.py +1 -0
  12. shellbrain-0.1.14/app/periphery/metrics/artifacts.py +44 -0
  13. shellbrain-0.1.14/app/periphery/metrics/browser.py +12 -0
  14. shellbrain-0.1.14/app/periphery/metrics/queries.py +192 -0
  15. shellbrain-0.1.14/app/periphery/metrics/render_html.py +512 -0
  16. shellbrain-0.1.14/app/periphery/metrics/service.py +425 -0
  17. {shellbrain-0.1.12 → shellbrain-0.1.14}/pyproject.toml +1 -1
  18. {shellbrain-0.1.12 → shellbrain-0.1.14}/shellbrain.egg-info/PKG-INFO +14 -1
  19. {shellbrain-0.1.12 → shellbrain-0.1.14}/shellbrain.egg-info/SOURCES.txt +6 -0
  20. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/__init__.py +0 -0
  21. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/__main__.py +0 -0
  22. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/boot/__init__.py +0 -0
  23. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/boot/admin_db.py +0 -0
  24. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/boot/config.py +0 -0
  25. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/boot/create_policy.py +0 -0
  26. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/boot/db.py +0 -0
  27. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/boot/embeddings.py +0 -0
  28. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/boot/home.py +0 -0
  29. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/boot/migrations.py +0 -0
  30. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/boot/read_policy.py +0 -0
  31. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/boot/repos.py +0 -0
  32. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/boot/retrieval.py +0 -0
  33. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/boot/thresholds.py +0 -0
  34. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/boot/update_policy.py +0 -0
  35. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/boot/use_cases.py +0 -0
  36. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/config/__init__.py +0 -0
  37. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/config/defaults/create_policy.yaml +0 -0
  38. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/config/defaults/read_policy.yaml +0 -0
  39. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/config/defaults/runtime.yaml +0 -0
  40. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/config/defaults/thresholds.yaml +0 -0
  41. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/config/defaults/update_policy.yaml +0 -0
  42. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/config/loader.py +0 -0
  43. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/__init__.py +0 -0
  44. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/contracts/__init__.py +0 -0
  45. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/contracts/errors.py +0 -0
  46. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/contracts/requests.py +0 -0
  47. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/contracts/responses.py +0 -0
  48. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/entities/__init__.py +0 -0
  49. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/entities/associations.py +0 -0
  50. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/entities/episodes.py +0 -0
  51. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/entities/evidence.py +0 -0
  52. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/entities/facts.py +0 -0
  53. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/entities/guidance.py +0 -0
  54. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/entities/identity.py +0 -0
  55. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/entities/memory.py +0 -0
  56. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/entities/runtime_context.py +0 -0
  57. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/entities/session_state.py +0 -0
  58. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/entities/telemetry.py +0 -0
  59. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/entities/utility.py +0 -0
  60. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/interfaces/__init__.py +0 -0
  61. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/interfaces/clock.py +0 -0
  62. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/interfaces/config.py +0 -0
  63. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/interfaces/embeddings.py +0 -0
  64. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/interfaces/idgen.py +0 -0
  65. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/interfaces/retrieval.py +0 -0
  66. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/interfaces/session_state_store.py +0 -0
  67. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/interfaces/unit_of_work.py +0 -0
  68. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/policies/__init__.py +0 -0
  69. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/policies/_shared/__init__.py +0 -0
  70. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/policies/_shared/side_effects.py +0 -0
  71. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/policies/create_policy/__init__.py +0 -0
  72. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/policies/create_policy/pipeline.py +0 -0
  73. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/policies/read_policy/__init__.py +0 -0
  74. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/policies/read_policy/bm25.py +0 -0
  75. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/policies/read_policy/context_pack_builder.py +0 -0
  76. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/policies/read_policy/expansion.py +0 -0
  77. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/policies/read_policy/fusion_rrf.py +0 -0
  78. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/policies/read_policy/lexical_query.py +0 -0
  79. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/policies/read_policy/pipeline.py +0 -0
  80. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/policies/read_policy/scenario_lift.py +0 -0
  81. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/policies/read_policy/scoring.py +0 -0
  82. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/policies/read_policy/seed_retrieval.py +0 -0
  83. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/policies/read_policy/utility_prior.py +0 -0
  84. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/policies/update_policy/__init__.py +0 -0
  85. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/policies/update_policy/pipeline.py +0 -0
  86. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/use_cases/__init__.py +0 -0
  87. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/use_cases/build_guidance.py +0 -0
  88. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/use_cases/create_memory.py +0 -0
  89. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/use_cases/manage_session_state.py +0 -0
  90. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/use_cases/read_memory.py +0 -0
  91. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/use_cases/record_episode_sync_telemetry.py +0 -0
  92. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/use_cases/record_operation_telemetry.py +0 -0
  93. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/core/use_cases/update_memory.py +0 -0
  94. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/migrations/__init__.py +0 -0
  95. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/migrations/env.py +0 -0
  96. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/migrations/versions/20260226_0001_initial_schema.py +0 -0
  97. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/migrations/versions/20260312_0002_add_hard_invariants.py +0 -0
  98. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/migrations/versions/20260312_0003_drop_create_confidence.py +0 -0
  99. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/migrations/versions/20260313_0004_episode_sync_hardening.py +0 -0
  100. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/migrations/versions/20260313_0005_evidence_episode_event_refs.py +0 -0
  101. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/migrations/versions/20260318_0006_usage_telemetry_schema.py +0 -0
  102. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/migrations/versions/20260319_0007_identity_session_guidance.py +0 -0
  103. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/migrations/versions/20260320_0008_instance_metadata_and_backup_safety.py +0 -0
  104. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/migrations/versions/__init__.py +0 -0
  105. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/onboarding_assets/__init__.py +0 -0
  106. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/onboarding_assets/claude/skills/shellbrain-session-start/SKILL.md +0 -0
  107. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/onboarding_assets/claude/skills/shellbrain-usage-review/SKILL.md +0 -0
  108. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/onboarding_assets/codex/shellbrain-session-start/SKILL.md +0 -0
  109. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/onboarding_assets/codex/shellbrain-session-start/agents/openai.yaml +0 -0
  110. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/onboarding_assets/codex/shellbrain-session-start/assets/shellbrain-large.svg +0 -0
  111. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/onboarding_assets/codex/shellbrain-session-start/assets/shellbrain-small.svg +0 -0
  112. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/onboarding_assets/codex/shellbrain-session-start/references/request-shapes.md +0 -0
  113. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/onboarding_assets/codex/shellbrain-session-start/references/session-workflow.md +0 -0
  114. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/onboarding_assets/codex/shellbrain-usage-review/SKILL.md +0 -0
  115. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/onboarding_assets/codex/shellbrain-usage-review/agents/openai.yaml +0 -0
  116. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/onboarding_assets/cursor/skills/shellbrain-session-start/SKILL.md +0 -0
  117. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/onboarding_assets/cursor/skills/shellbrain-usage-review/SKILL.md +0 -0
  118. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/__init__.py +0 -0
  119. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/admin/__init__.py +0 -0
  120. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/admin/analytics.py +0 -0
  121. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/admin/analytics_queries.py +0 -0
  122. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/admin/backup.py +0 -0
  123. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/admin/destructive_guard.py +0 -0
  124. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/admin/doctor.py +0 -0
  125. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/admin/external_runtime.py +0 -0
  126. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/admin/init.py +0 -0
  127. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/admin/init_errors.py +0 -0
  128. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/admin/machine_state.py +0 -0
  129. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/admin/managed_runtime.py +0 -0
  130. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/admin/privileges.py +0 -0
  131. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/admin/repo_state.py +0 -0
  132. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/admin/restore.py +0 -0
  133. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/admin/storage_setup.py +0 -0
  134. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/admin/upgrade.py +0 -0
  135. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/cli/__init__.py +0 -0
  136. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/cli/handlers.py +0 -0
  137. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/cli/hydration.py +0 -0
  138. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/cli/presenter_json.py +0 -0
  139. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/cli/schema_validation.py +0 -0
  140. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/__init__.py +0 -0
  141. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/engine.py +0 -0
  142. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/models/__init__.py +0 -0
  143. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/models/associations.py +0 -0
  144. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/models/episodes.py +0 -0
  145. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/models/evidence.py +0 -0
  146. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/models/experiences.py +0 -0
  147. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/models/instance_metadata.py +0 -0
  148. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/models/memories.py +0 -0
  149. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/models/metadata.py +0 -0
  150. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/models/registry.py +0 -0
  151. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/models/telemetry.py +0 -0
  152. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/models/utility.py +0 -0
  153. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/models/views.py +0 -0
  154. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/repos/__init__.py +0 -0
  155. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/repos/relational/__init__.py +0 -0
  156. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/repos/relational/associations_repo.py +0 -0
  157. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/repos/relational/experiences_repo.py +0 -0
  158. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/repos/relational/memories_repo.py +0 -0
  159. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/repos/relational/read_policy_repo.py +0 -0
  160. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/repos/relational/telemetry_repo.py +0 -0
  161. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/repos/relational/utility_repo.py +0 -0
  162. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/repos/semantic/__init__.py +0 -0
  163. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/repos/semantic/keyword_retrieval_repo.py +0 -0
  164. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/repos/semantic/semantic_retrieval_repo.py +0 -0
  165. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/session.py +0 -0
  166. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/db/uow.py +0 -0
  167. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/embeddings/__init__.py +0 -0
  168. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/embeddings/local_provider.py +0 -0
  169. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/embeddings/query_vector_search.py +0 -0
  170. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/episodes/__init__.py +0 -0
  171. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/episodes/claude_code.py +0 -0
  172. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/episodes/codex.py +0 -0
  173. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/episodes/cursor.py +0 -0
  174. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/episodes/launcher.py +0 -0
  175. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/episodes/normalization.py +0 -0
  176. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/episodes/poller.py +0 -0
  177. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/episodes/poller_lock.py +0 -0
  178. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/episodes/source_discovery.py +0 -0
  179. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/episodes/tool_filter.py +0 -0
  180. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/identity/__init__.py +0 -0
  181. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/identity/claude_hook_install.py +0 -0
  182. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/identity/claude_runtime.py +0 -0
  183. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/identity/codex_runtime.py +0 -0
  184. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/identity/compatibility.py +0 -0
  185. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/identity/resolver.py +0 -0
  186. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/onboarding/__init__.py +0 -0
  187. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/onboarding/host_assets.py +0 -0
  188. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/session_state/__init__.py +0 -0
  189. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/session_state/file_store.py +0 -0
  190. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/telemetry/__init__.py +0 -0
  191. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/telemetry/operation_summary.py +0 -0
  192. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/telemetry/session_selection.py +0 -0
  193. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/telemetry/sync_summary.py +0 -0
  194. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/validation/__init__.py +0 -0
  195. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/validation/integrity_validation.py +0 -0
  196. {shellbrain-0.1.12 → shellbrain-0.1.14}/app/periphery/validation/semantic_validation.py +0 -0
  197. {shellbrain-0.1.12 → shellbrain-0.1.14}/setup.cfg +0 -0
  198. {shellbrain-0.1.12 → shellbrain-0.1.14}/shellbrain.egg-info/dependency_links.txt +0 -0
  199. {shellbrain-0.1.12 → shellbrain-0.1.14}/shellbrain.egg-info/entry_points.txt +0 -0
  200. {shellbrain-0.1.12 → shellbrain-0.1.14}/shellbrain.egg-info/requires.txt +0 -0
  201. {shellbrain-0.1.12 → shellbrain-0.1.14}/shellbrain.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shellbrain
3
- Version: 0.1.12
3
+ Version: 0.1.14
4
4
  Summary: Repo-scoped Shellbrain CLI with explicit evidence-backed writes.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -96,6 +96,19 @@ Fish PATH setup is written to `~/.config/fish/conf.d/shellbrain.fish`.
96
96
 
97
97
  ---
98
98
 
99
+ ## DB-backed tests
100
+
101
+ **Live memories and DB-backed tests now use different Postgres hosts.**
102
+
103
+ - managed local Shellbrain keeps durable memories on the machine-owned managed instance
104
+ - DB-backed tests and scratch validation should use the dedicated repo-owned test host from `docker-compose.test.yml`
105
+ - `scripts/run_tests` provisions a disposable test database on that dedicated host by default
106
+ - `scripts/storage_status` shows the live managed target, the dedicated test host, and any legacy local test host that is still hanging around
107
+
108
+ If you are running managed local Shellbrain, do not leave a stale `SHELLBRAIN_DB_DSN` export in your shell profile that points at the old local compose database. The machine config wins anyway, and the stale env var just makes the storage layout harder to reason about.
109
+
110
+ ---
111
+
99
112
  ## Docs
100
113
 
101
114
  - [shellbrain.ai/humans](https://shellbrain.ai/humans/) — install, upgrade, getting started
@@ -82,6 +82,19 @@ Fish PATH setup is written to `~/.config/fish/conf.d/shellbrain.fish`.
82
82
 
83
83
  ---
84
84
 
85
+ ## DB-backed tests
86
+
87
+ **Live memories and DB-backed tests now use different Postgres hosts.**
88
+
89
+ - managed local Shellbrain keeps durable memories on the machine-owned managed instance
90
+ - DB-backed tests and scratch validation should use the dedicated repo-owned test host from `docker-compose.test.yml`
91
+ - `scripts/run_tests` provisions a disposable test database on that dedicated host by default
92
+ - `scripts/storage_status` shows the live managed target, the dedicated test host, and any legacy local test host that is still hanging around
93
+
94
+ If you are running managed local Shellbrain, do not leave a stale `SHELLBRAIN_DB_DSN` export in your shell profile that points at the old local compose database. The machine config wins anyway, and the stale env var just makes the storage layout harder to reason about.
95
+
96
+ ---
97
+
85
98
  ## Docs
86
99
 
87
100
  - [shellbrain.ai/humans](https://shellbrain.ai/humans/) — install, upgrade, getting started
@@ -85,6 +85,14 @@ class IEpisodesRepo(ABC):
85
85
  def create_episode(self, episode: Episode) -> None:
86
86
  """This method persists an episode row."""
87
87
 
88
+ @abstractmethod
89
+ def acquire_thread_sync_guard(self, *, repo_id: str, thread_id: str) -> None:
90
+ """This method serializes sync writes for one repo/thread pair."""
91
+
92
+ @abstractmethod
93
+ def get_or_create_episode_for_thread(self, episode: Episode) -> Episode:
94
+ """This method returns the canonical episode row for one thread, creating it when missing."""
95
+
88
96
  @abstractmethod
89
97
  def get_episode_by_thread(
90
98
  self,
@@ -106,6 +114,10 @@ class IEpisodesRepo(ABC):
106
114
  def append_event(self, event: EpisodeEvent) -> None:
107
115
  """This method appends an event into an episode stream."""
108
116
 
117
+ @abstractmethod
118
+ def append_event_if_new(self, event: EpisodeEvent) -> bool:
119
+ """This method appends an event only when its host_event_key is not already present."""
120
+
109
121
  @abstractmethod
110
122
  def close_episode(self, *, episode_id: str, ended_at: datetime) -> None:
111
123
  """This method marks an active episode closed."""
@@ -52,7 +52,7 @@ def apply_side_effects(
52
52
  if effect_type == "memory_evidence.attach":
53
53
  refs = params["refs"]
54
54
  assert isinstance(refs, list)
55
- for ref in refs:
55
+ for ref in sorted(str(ref) for ref in refs):
56
56
  evidence = uow.evidence.upsert_ref(repo_id=str(params["repo_id"]), ref=str(ref))
57
57
  uow.evidence.link_memory_evidence(memory_id=str(params["memory_id"]), evidence_id=evidence.id)
58
58
  continue
@@ -124,7 +124,7 @@ def apply_side_effects(
124
124
  )
125
125
  evidence_refs = params.get("evidence_refs", [])
126
126
  assert isinstance(evidence_refs, list)
127
- for ref in evidence_refs:
127
+ for ref in sorted(str(ref) for ref in evidence_refs):
128
128
  evidence = uow.evidence.upsert_ref(repo_id=str(params["repo_id"]), ref=str(ref))
129
129
  uow.evidence.link_association_edge_evidence(edge_id=edge.id, evidence_id=evidence.id)
130
130
  continue
@@ -25,12 +25,11 @@ def sync_episode(
25
25
  """Import one already-normalized host transcript into episodes and episode events."""
26
26
 
27
27
  counts = _count_normalized_events(normalized_events)
28
- episode = uow.episodes.get_episode_by_thread(repo_id=repo_id, thread_id=thread_id)
28
+ uow.episodes.acquire_thread_sync_guard(repo_id=repo_id, thread_id=thread_id)
29
29
  imported_count = 0
30
-
31
- if episode is None:
32
- started_at = _earliest_event_timestamp(normalized_events) or datetime.now(timezone.utc)
33
- episode = Episode(
30
+ started_at = _earliest_event_timestamp(normalized_events) or datetime.now(timezone.utc)
31
+ episode = uow.episodes.get_or_create_episode_for_thread(
32
+ Episode(
34
33
  id=str(uuid4()),
35
34
  repo_id=repo_id,
36
35
  host_app=host_app,
@@ -39,17 +38,13 @@ def sync_episode(
39
38
  started_at=started_at,
40
39
  created_at=datetime.now(timezone.utc),
41
40
  )
42
- uow.episodes.create_episode(episode)
43
-
44
- existing_keys = set(uow.episodes.list_event_keys(episode_id=episode.id))
41
+ )
45
42
  next_seq = uow.episodes.next_event_seq(episode_id=episode.id)
46
43
  for normalized_event in normalized_events:
47
44
  host_event_key = str(normalized_event["host_event_key"])
48
- if host_event_key in existing_keys:
49
- continue
50
45
  created_at = _parse_timestamp(str(normalized_event["occurred_at"]))
51
46
  source = EpisodeEventSource(str(normalized_event["source"]))
52
- uow.episodes.append_event(
47
+ inserted = uow.episodes.append_event_if_new(
53
48
  EpisodeEvent(
54
49
  id=str(uuid4()),
55
50
  episode_id=episode.id,
@@ -60,7 +55,8 @@ def sync_episode(
60
55
  created_at=created_at,
61
56
  )
62
57
  )
63
- existing_keys.add(host_event_key)
58
+ if not inserted:
59
+ continue
64
60
  next_seq += 1
65
61
  imported_count += 1
66
62
 
@@ -13,7 +13,7 @@ def classify_operation_failure(
13
13
  """Return one stable diagnosis payload for an operation failure."""
14
14
 
15
15
  message = (error_message or "").lower()
16
- if "uq_evidence_repo_ref" in message:
16
+ if "uq_evidence_repo_ref" in message or "uq_evidence_repo_episode_event" in message:
17
17
  return _diagnosis(
18
18
  category="duplicate_evidence_ref",
19
19
  summary="Evidence refs are being inserted twice for the same repo/event pair.",
@@ -45,17 +45,36 @@ def dsn_fingerprint(dsn: str) -> str:
45
45
  return hashlib.sha256(normalize_dsn(dsn).encode("utf-8")).hexdigest()
46
46
 
47
47
 
48
+ def host_port_from_dsn(dsn: str) -> tuple[str, int]:
49
+ """Extract the normalized host/port pair from one DSN."""
50
+
51
+ parsed = urlparse(dsn.replace("+psycopg", ""))
52
+ return ((parsed.hostname or "").lower(), parsed.port or 5432)
53
+
54
+
48
55
  def database_name_from_dsn(dsn: str) -> str:
49
56
  """Extract the target database name from one DSN."""
50
57
 
51
58
  return urlparse(dsn.replace("+psycopg", "")).path.lstrip("/")
52
59
 
53
60
 
54
- def assert_disposable_test_dsn(*, test_dsn: str, protected_dsn: str | None = None) -> None:
61
+ def assert_disposable_test_dsn(
62
+ *,
63
+ test_dsn: str,
64
+ protected_dsn: str | None = None,
65
+ protected_host_ports: set[tuple[str, int]] | None = None,
66
+ ) -> None:
55
67
  """Refuse to treat a protected or production-shaped database as disposable."""
56
68
 
57
69
  if protected_dsn and dsn_fingerprint(test_dsn) == dsn_fingerprint(protected_dsn):
58
70
  raise RuntimeError("Refusing destructive test setup against the protected live database DSN.")
71
+ protected_pairs = set(protected_host_ports or set())
72
+ if protected_dsn:
73
+ protected_pairs.add(host_port_from_dsn(protected_dsn))
74
+ if host_port_from_dsn(test_dsn) in protected_pairs:
75
+ raise RuntimeError(
76
+ "Refusing destructive test setup against the protected live database host/port."
77
+ )
59
78
  db_name = database_name_from_dsn(test_dsn).lower()
60
79
  if db_name in PROTECTED_DB_NAMES:
61
80
  raise RuntimeError(
@@ -60,6 +60,7 @@ _TOP_LEVEL_HELP = dedent(
60
60
  Examples:
61
61
  shellbrain init
62
62
  shellbrain upgrade
63
+ shellbrain metrics --days 30
63
64
  shellbrain read --json '{"query":"Have we seen this migration lock timeout before?","kinds":["problem","solution","failed_tactic"]}'
64
65
  shellbrain read --json '{"query":"What repo constraints or user preferences matter for this auth refactor?","kinds":["fact","preference","change"]}'
65
66
  shellbrain events --json '{"limit":10}'
@@ -229,6 +230,17 @@ _ANALYTICS_HELP = dedent(
229
230
  """
230
231
  )
231
232
 
233
+ _METRICS_HELP = dedent(
234
+ """\
235
+ Generate one lightweight repo-scoped metrics snapshot, write local artifacts, and open a static dashboard.
236
+
237
+ Examples:
238
+ shellbrain metrics
239
+ shellbrain metrics --days 30
240
+ shellbrain metrics --days 14 --no-open
241
+ """
242
+ )
243
+
232
244
  _INSTALL_CLAUDE_HOOK_HELP = dedent(
233
245
  """\
234
246
  Install or update the repo-local Claude Code SessionStart hook used as an explicit repo-local override.
@@ -344,6 +356,26 @@ def build_parser() -> argparse.ArgumentParser:
344
356
  formatter_class=_HelpFormatter,
345
357
  )
346
358
 
359
+ metrics_parser = subparsers.add_parser(
360
+ "metrics",
361
+ help="Open a lightweight repo-scoped metrics dashboard.",
362
+ description="Generate one local metrics snapshot and open a static Shellbrain dashboard.",
363
+ epilog=_METRICS_HELP,
364
+ formatter_class=_HelpFormatter,
365
+ )
366
+ _add_repo_context_arguments(metrics_parser, suppress_default=True)
367
+ metrics_parser.add_argument(
368
+ "--days",
369
+ type=int,
370
+ default=30,
371
+ help="Number of trailing days to include in the snapshot. Defaults to 30.",
372
+ )
373
+ metrics_parser.add_argument(
374
+ "--no-open",
375
+ action="store_true",
376
+ help="Generate artifacts without opening the dashboard in the browser.",
377
+ )
378
+
347
379
  create_parser = subparsers.add_parser(
348
380
  "create",
349
381
  help="Create one Shellbrain entry from explicit evidence.",
@@ -523,6 +555,9 @@ def main(argv: Sequence[str] | None = None) -> int:
523
555
 
524
556
  return run_upgrade()
525
557
 
558
+ if args.command == "metrics":
559
+ return _run_metrics_command(args)
560
+
526
561
  if args.command == "admin":
527
562
  return _run_admin_command(args)
528
563
 
@@ -776,6 +811,58 @@ def _run_admin_command(args: argparse.Namespace) -> int:
776
811
  raise ValueError(f"Unsupported admin command: {args.admin_command}")
777
812
 
778
813
 
814
+ def _run_metrics_command(args: argparse.Namespace) -> int:
815
+ """Generate one repo-scoped metrics snapshot and local dashboard artifacts."""
816
+
817
+ try:
818
+ from app.boot.admin_db import get_optional_admin_db_dsn
819
+ from app.boot.db import get_optional_db_dsn
820
+ from app.periphery.db.engine import get_engine
821
+ from app.periphery.metrics.artifacts import write_metrics_artifacts
822
+ from app.periphery.metrics.browser import open_metrics_dashboard
823
+ from app.periphery.metrics.render_html import render_metrics_dashboard
824
+ from app.periphery.metrics.service import build_metrics_snapshot
825
+
826
+ repo_context = resolve_repo_context(
827
+ repo_root_arg=getattr(args, "repo_root", None),
828
+ repo_id_arg=getattr(args, "repo_id", None),
829
+ )
830
+ _warn_or_fail_on_unsafe_app_role()
831
+ _ensure_repo_registration_for_operation(
832
+ repo_context=repo_context,
833
+ repo_id_override=getattr(args, "repo_id", None),
834
+ )
835
+ dsn = get_optional_db_dsn() or get_optional_admin_db_dsn()
836
+ if not dsn:
837
+ raise RuntimeError("Shellbrain database is not configured. Run `shellbrain init` first.")
838
+ snapshot = build_metrics_snapshot(
839
+ engine=get_engine(dsn),
840
+ repo_id=repo_context.repo_id,
841
+ days=int(args.days),
842
+ )
843
+ html = render_metrics_dashboard(snapshot)
844
+ paths = write_metrics_artifacts(repo_id=repo_context.repo_id, snapshot=snapshot, html=html)
845
+ opened_dashboard = False
846
+ if not bool(getattr(args, "no_open", False)):
847
+ opened_dashboard = bool(open_metrics_dashboard(paths["html_path"]))
848
+ print(f"Generated Shellbrain metrics for {repo_context.repo_id}")
849
+ print(f"Status: {snapshot['status']} ({snapshot['confidence']} confidence)")
850
+ print(f"JSON: {paths['json_path']}")
851
+ print(f"Markdown: {paths['md_path']}")
852
+ print(f"Dashboard: {paths['html_path']}")
853
+ print("Artifacts: updated in place")
854
+ if bool(getattr(args, "no_open", False)):
855
+ print("Browser: skipped")
856
+ elif opened_dashboard:
857
+ print("Browser: opened dashboard")
858
+ else:
859
+ print("Browser: could not open automatically")
860
+ return 0
861
+ except (RuntimeError, ValueError) as exc:
862
+ print(str(exc), file=sys.stderr)
863
+ return 1
864
+
865
+
779
866
  def _print_operation_result(result: dict[str, Any]) -> None:
780
867
  """Render one operation result as JSON for agent consumption."""
781
868
 
@@ -3,7 +3,8 @@
3
3
  from datetime import datetime, timezone
4
4
  from typing import Sequence
5
5
 
6
- from sqlalchemy import func, select, update
6
+ from sqlalchemy import func, select, text, update
7
+ from sqlalchemy.dialects.postgresql import insert
7
8
 
8
9
  from app.core.entities.episodes import Episode, EpisodeEvent, EpisodeEventSource, EpisodeStatus, SessionTransfer
9
10
  from app.core.interfaces.repos import IEpisodesRepo
@@ -36,6 +37,40 @@ class EpisodesRepo(IEpisodesRepo):
36
37
  )
37
38
  )
38
39
 
40
+ def acquire_thread_sync_guard(self, *, repo_id: str, thread_id: str) -> None:
41
+ """This method serializes sync writes for one repo/thread pair."""
42
+
43
+ self._session.execute(
44
+ text("SELECT pg_advisory_xact_lock(hashtext(:repo_id), hashtext(:thread_id))"),
45
+ {"repo_id": repo_id, "thread_id": thread_id},
46
+ )
47
+
48
+ def get_or_create_episode_for_thread(self, episode: Episode) -> Episode:
49
+ """This method returns the canonical episode row for one thread, creating it when missing."""
50
+
51
+ if episode.thread_id is None:
52
+ raise ValueError("thread_id is required when ensuring an episode for sync")
53
+ self._session.execute(
54
+ insert(episodes)
55
+ .values(
56
+ id=episode.id,
57
+ repo_id=episode.repo_id,
58
+ host_app=episode.host_app,
59
+ thread_id=episode.thread_id,
60
+ title=episode.title,
61
+ objective=episode.objective,
62
+ status=episode.status.value,
63
+ started_at=episode.started_at or datetime.now(timezone.utc),
64
+ ended_at=episode.ended_at,
65
+ created_at=episode.created_at or datetime.now(timezone.utc),
66
+ )
67
+ .on_conflict_do_nothing(index_elements=["repo_id", "thread_id"])
68
+ )
69
+ stored = self.get_episode_by_thread(repo_id=episode.repo_id, thread_id=episode.thread_id)
70
+ if stored is None:
71
+ raise RuntimeError("episode ensure failed to return a canonical thread row")
72
+ return stored
73
+
39
74
  def get_episode_by_thread(
40
75
  self,
41
76
  *,
@@ -100,6 +135,25 @@ class EpisodesRepo(IEpisodesRepo):
100
135
  )
101
136
  )
102
137
 
138
+ def append_event_if_new(self, event: EpisodeEvent) -> bool:
139
+ """This method appends an episode event only when its host_event_key is new."""
140
+
141
+ inserted_id = self._session.execute(
142
+ insert(episode_events)
143
+ .values(
144
+ id=event.id,
145
+ episode_id=event.episode_id,
146
+ seq=event.seq,
147
+ host_event_key=event.host_event_key,
148
+ source=event.source.value,
149
+ content=event.content,
150
+ created_at=event.created_at or datetime.now(timezone.utc),
151
+ )
152
+ .on_conflict_do_nothing(index_elements=["episode_id", "host_event_key"])
153
+ .returning(episode_events.c.id)
154
+ ).scalar_one_or_none()
155
+ return inserted_id is not None
156
+
103
157
  def close_episode(self, *, episode_id: str, ended_at: datetime) -> None:
104
158
  """This method marks an active episode closed."""
105
159
 
@@ -3,7 +3,7 @@
3
3
  from datetime import datetime, timezone
4
4
  from uuid import uuid4
5
5
 
6
- from sqlalchemy import select, update
6
+ from sqlalchemy import select, text, update
7
7
  from sqlalchemy.dialects.postgresql import insert
8
8
 
9
9
  from app.core.entities.evidence import EvidenceRef
@@ -24,6 +24,7 @@ class EvidenceRepo(IEvidenceRepo):
24
24
  def upsert_ref(self, repo_id: str, ref: str) -> EvidenceRef:
25
25
  """This method inserts or returns a canonical evidence reference row."""
26
26
 
27
+ self._acquire_ref_guard(repo_id=repo_id, ref=ref)
27
28
  existing = (
28
29
  self._session.execute(
29
30
  select(evidence_refs).where(
@@ -63,6 +64,14 @@ class EvidenceRepo(IEvidenceRepo):
63
64
  )
64
65
  return EvidenceRef(id=evidence_id, repo_id=repo_id, ref=ref, episode_event_id=ref)
65
66
 
67
+ def _acquire_ref_guard(self, *, repo_id: str, ref: str) -> None:
68
+ """Serialize concurrent writes for one repo/ref pair within the active transaction."""
69
+
70
+ self._session.execute(
71
+ text("SELECT pg_advisory_xact_lock(hashtext(:repo_id), hashtext(:ref))"),
72
+ {"repo_id": repo_id, "ref": ref},
73
+ )
74
+
66
75
  def link_memory_evidence(self, memory_id: str, evidence_id: str) -> None:
67
76
  """This method creates shellbrain-to-evidence link rows."""
68
77
 
@@ -0,0 +1 @@
1
+ """Periphery-only helpers for the Shellbrain metrics dashboard."""
@@ -0,0 +1,44 @@
1
+ """Artifact helpers for generated metrics snapshots and dashboards."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from pathlib import Path
8
+ import re
9
+ from typing import Any
10
+
11
+ from app.boot.home import get_shellbrain_home
12
+
13
+
14
+ _NON_ALNUM = re.compile(r"[^a-z0-9]+")
15
+
16
+
17
+ def get_metrics_artifact_dir(*, repo_id: str) -> Path:
18
+ """Return the machine-owned artifact directory for one repo's metrics outputs."""
19
+
20
+ normalized = _NON_ALNUM.sub("-", repo_id.lower()).strip("-") or "repo"
21
+ digest = hashlib.sha1(repo_id.encode("utf-8")).hexdigest()[:8]
22
+ return get_shellbrain_home() / "reports" / "metrics" / f"{normalized}-{digest}"
23
+
24
+
25
+ def write_metrics_artifacts(*, repo_id: str, snapshot: dict[str, Any], html: str) -> dict[str, Path]:
26
+ """Write the latest metrics snapshot, markdown summary, and dashboard HTML."""
27
+
28
+ artifact_dir = get_metrics_artifact_dir(repo_id=repo_id)
29
+ artifact_dir.mkdir(parents=True, exist_ok=True)
30
+
31
+ json_path = artifact_dir / "latest.json"
32
+ md_path = artifact_dir / "latest.md"
33
+ html_path = artifact_dir / "dashboard.html"
34
+
35
+ json_path.write_text(json.dumps(snapshot, indent=2, sort_keys=True), encoding="utf-8")
36
+ md_path.write_text(str(snapshot["summary_md"]).strip() + "\n", encoding="utf-8")
37
+ html_path.write_text(html, encoding="utf-8")
38
+
39
+ return {
40
+ "artifact_dir": artifact_dir,
41
+ "json_path": json_path,
42
+ "md_path": md_path,
43
+ "html_path": html_path,
44
+ }
@@ -0,0 +1,12 @@
1
+ """Browser helpers for opening generated metrics dashboards."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ import webbrowser
7
+
8
+
9
+ def open_metrics_dashboard(path: Path) -> bool:
10
+ """Open one generated dashboard in the default browser."""
11
+
12
+ return bool(webbrowser.open(path.resolve().as_uri()))
@@ -0,0 +1,192 @@
1
+ """SQL query helpers for repo-scoped Shellbrain metrics snapshots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+
7
+ from sqlalchemy import text
8
+ from sqlalchemy.engine import Connection
9
+
10
+
11
+ def fetch_daily_utility_rows(
12
+ *,
13
+ conn: Connection,
14
+ repo_id: str,
15
+ start_at: datetime,
16
+ end_at: datetime,
17
+ ) -> list[dict[str, object]]:
18
+ """Return daily utility vote aggregates for one repo and time range."""
19
+
20
+ rows = conn.execute(
21
+ text(
22
+ """
23
+ SELECT
24
+ date_trunc('day', u.created_at AT TIME ZONE 'UTC') AS day_utc,
25
+ COUNT(*)::INTEGER AS vote_count,
26
+ COALESCE(SUM(u.vote), 0)::DOUBLE PRECISION AS vote_sum
27
+ FROM utility_observations u
28
+ JOIN memories problem_mem ON problem_mem.id = u.problem_id
29
+ WHERE problem_mem.repo_id = :repo_id
30
+ AND u.created_at >= :start_at
31
+ AND u.created_at < :end_at
32
+ GROUP BY date_trunc('day', u.created_at AT TIME ZONE 'UTC')
33
+ ORDER BY day_utc ASC;
34
+ """
35
+ ),
36
+ {"repo_id": repo_id, "start_at": start_at, "end_at": end_at},
37
+ ).mappings()
38
+ return [dict(row) for row in rows]
39
+
40
+
41
+ def fetch_daily_followthrough_rows(
42
+ *,
43
+ conn: Connection,
44
+ repo_id: str,
45
+ start_at: datetime,
46
+ end_at: datetime,
47
+ ) -> list[dict[str, object]]:
48
+ """Return daily utility-guidance follow-through counts for one repo and time range."""
49
+
50
+ rows = conn.execute(
51
+ text(
52
+ """
53
+ WITH pending_threads AS (
54
+ SELECT
55
+ oi.repo_id,
56
+ oi.selected_thread_id,
57
+ MIN(oi.created_at) AS first_guidance_at
58
+ FROM operation_invocations oi
59
+ WHERE oi.repo_id = :repo_id
60
+ AND oi.created_at >= :start_at
61
+ AND oi.created_at < :end_at
62
+ AND oi.selected_thread_id IS NOT NULL
63
+ AND oi.guidance_codes @> '["pending_utility_votes"]'::jsonb
64
+ GROUP BY oi.repo_id, oi.selected_thread_id
65
+ ),
66
+ vote_threads AS (
67
+ SELECT
68
+ oi.repo_id,
69
+ oi.selected_thread_id,
70
+ MIN(oi.created_at) AS first_vote_at
71
+ FROM write_invocation_summaries wis
72
+ JOIN operation_invocations oi ON oi.id = wis.invocation_id
73
+ WHERE oi.repo_id = :repo_id
74
+ AND oi.created_at >= :start_at
75
+ AND oi.created_at < :end_at
76
+ AND oi.selected_thread_id IS NOT NULL
77
+ AND wis.update_type IN ('utility_vote', 'utility_vote_batch')
78
+ GROUP BY oi.repo_id, oi.selected_thread_id
79
+ )
80
+ SELECT
81
+ date_trunc('day', pending.first_guidance_at AT TIME ZONE 'UTC') AS day_utc,
82
+ COUNT(*)::INTEGER AS opportunity_count,
83
+ COUNT(*) FILTER (
84
+ WHERE votes.first_vote_at IS NOT NULL
85
+ AND votes.first_vote_at > pending.first_guidance_at
86
+ )::INTEGER AS followthrough_count
87
+ FROM pending_threads pending
88
+ LEFT JOIN vote_threads votes
89
+ ON votes.repo_id = pending.repo_id
90
+ AND votes.selected_thread_id = pending.selected_thread_id
91
+ GROUP BY date_trunc('day', pending.first_guidance_at AT TIME ZONE 'UTC')
92
+ ORDER BY day_utc ASC;
93
+ """
94
+ ),
95
+ {"repo_id": repo_id, "start_at": start_at, "end_at": end_at},
96
+ ).mappings()
97
+ return [dict(row) for row in rows]
98
+
99
+
100
+ def fetch_daily_zero_result_rows(
101
+ *,
102
+ conn: Connection,
103
+ repo_id: str,
104
+ start_at: datetime,
105
+ end_at: datetime,
106
+ ) -> list[dict[str, object]]:
107
+ """Return daily read and zero-result counts for one repo and time range."""
108
+
109
+ rows = conn.execute(
110
+ text(
111
+ """
112
+ SELECT
113
+ date_trunc('day', oi.created_at AT TIME ZONE 'UTC') AS day_utc,
114
+ COUNT(*)::INTEGER AS read_count,
115
+ COUNT(*) FILTER (WHERE ris.zero_results)::INTEGER AS zero_result_count
116
+ FROM read_invocation_summaries ris
117
+ JOIN operation_invocations oi ON oi.id = ris.invocation_id
118
+ WHERE oi.repo_id = :repo_id
119
+ AND oi.created_at >= :start_at
120
+ AND oi.created_at < :end_at
121
+ GROUP BY date_trunc('day', oi.created_at AT TIME ZONE 'UTC')
122
+ ORDER BY day_utc ASC;
123
+ """
124
+ ),
125
+ {"repo_id": repo_id, "start_at": start_at, "end_at": end_at},
126
+ ).mappings()
127
+ return [dict(row) for row in rows]
128
+
129
+
130
+ def fetch_daily_events_before_write_rows(
131
+ *,
132
+ conn: Connection,
133
+ repo_id: str,
134
+ start_at: datetime,
135
+ end_at: datetime,
136
+ ) -> list[dict[str, object]]:
137
+ """Return daily write counts and events-before-write compliance for one repo."""
138
+
139
+ rows = conn.execute(
140
+ text(
141
+ """
142
+ SELECT
143
+ date_trunc('day', oi.created_at AT TIME ZONE 'UTC') AS day_utc,
144
+ COUNT(*)::INTEGER AS write_count,
145
+ COUNT(*) FILTER (
146
+ WHERE EXISTS (
147
+ SELECT 1
148
+ FROM operation_invocations prior_events
149
+ WHERE prior_events.repo_id = oi.repo_id
150
+ AND prior_events.selected_thread_id = oi.selected_thread_id
151
+ AND prior_events.command = 'events'
152
+ AND prior_events.created_at < oi.created_at
153
+ )
154
+ )::INTEGER AS compliant_count
155
+ FROM write_invocation_summaries wis
156
+ JOIN operation_invocations oi ON oi.id = wis.invocation_id
157
+ WHERE oi.repo_id = :repo_id
158
+ AND oi.created_at >= :start_at
159
+ AND oi.created_at < :end_at
160
+ GROUP BY date_trunc('day', oi.created_at AT TIME ZONE 'UTC')
161
+ ORDER BY day_utc ASC;
162
+ """
163
+ ),
164
+ {"repo_id": repo_id, "start_at": start_at, "end_at": end_at},
165
+ ).mappings()
166
+ return [dict(row) for row in rows]
167
+
168
+
169
+ def fetch_sync_health_summary(
170
+ *,
171
+ conn: Connection,
172
+ repo_id: str,
173
+ start_at: datetime,
174
+ end_at: datetime,
175
+ ) -> dict[str, object]:
176
+ """Return current-window sync health counts for one repo."""
177
+
178
+ row = conn.execute(
179
+ text(
180
+ """
181
+ SELECT
182
+ COUNT(*)::INTEGER AS sync_run_count,
183
+ COUNT(*) FILTER (WHERE outcome = 'error')::INTEGER AS failed_sync_count
184
+ FROM episode_sync_runs
185
+ WHERE repo_id = :repo_id
186
+ AND created_at >= :start_at
187
+ AND created_at < :end_at;
188
+ """
189
+ ),
190
+ {"repo_id": repo_id, "start_at": start_at, "end_at": end_at},
191
+ ).mappings().one()
192
+ return dict(row)