marvisx-cli 0.1.0__py3-none-any.whl

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 (587) hide show
  1. core/api/__init__.py +0 -0
  2. core/api/agents/__init__.py +0 -0
  3. core/api/agents/session_health.py +59 -0
  4. core/api/agents/session_manager.py +206 -0
  5. core/api/bin/marvisx-state-hook.py +182 -0
  6. core/api/config.py +533 -0
  7. core/api/db.py +1516 -0
  8. core/api/dependencies/__init__.py +0 -0
  9. core/api/dependencies/tenant.py +34 -0
  10. core/api/main.py +1641 -0
  11. core/api/mcp/__init__.py +8 -0
  12. core/api/mcp/_adapter.py +184 -0
  13. core/api/mcp/server.py +58 -0
  14. core/api/mcp/tools/__init__.py +59 -0
  15. core/api/mcp/tools/brain.py +599 -0
  16. core/api/mcp/tools/graph.py +380 -0
  17. core/api/mcp/tools/handoffs.py +112 -0
  18. core/api/mcp/tools/ingest.py +326 -0
  19. core/api/mcp/tools/learnings.py +144 -0
  20. core/api/mcp/tools/projects.py +99 -0
  21. core/api/mcp/tools/pull_requests.py +173 -0
  22. core/api/mcp/tools/safety.py +111 -0
  23. core/api/mcp/tools/search.py +79 -0
  24. core/api/mcp/tools/tasks.py +258 -0
  25. core/api/middleware/__init__.py +0 -0
  26. core/api/middleware/tool_call_audit.py +111 -0
  27. core/api/models/__init__.py +346 -0
  28. core/api/models/auth.py +48 -0
  29. core/api/models/brain.py +1006 -0
  30. core/api/models/common.py +76 -0
  31. core/api/models/costs.py +91 -0
  32. core/api/models/graph.py +66 -0
  33. core/api/models/graph_cosmo.py +125 -0
  34. core/api/models/graph_pr_impact.py +257 -0
  35. core/api/models/graph_ux.py +141 -0
  36. core/api/models/inbox.py +230 -0
  37. core/api/models/ingest_keys.py +108 -0
  38. core/api/models/kg.py +41 -0
  39. core/api/models/llm_config.py +56 -0
  40. core/api/models/monitoring.py +234 -0
  41. core/api/models/projects.py +161 -0
  42. core/api/models/search.py +42 -0
  43. core/api/models/sessions.py +322 -0
  44. core/api/models/tasks.py +184 -0
  45. core/api/models/teams.py +63 -0
  46. core/api/models/users.py +184 -0
  47. core/api/observability/__init__.py +0 -0
  48. core/api/observability/tracing.py +92 -0
  49. core/api/paths.py +26 -0
  50. core/api/rate_limit.py +24 -0
  51. core/api/rbac.py +112 -0
  52. core/api/routers/__init__.py +0 -0
  53. core/api/routers/_adapter.py +24 -0
  54. core/api/routers/admin_pr_impact.py +230 -0
  55. core/api/routers/admin_settings.py +147 -0
  56. core/api/routers/agent.py +1079 -0
  57. core/api/routers/agent_tokens.py +276 -0
  58. core/api/routers/app_settings.py +112 -0
  59. core/api/routers/audit.py +89 -0
  60. core/api/routers/auth.py +586 -0
  61. core/api/routers/bench.py +161 -0
  62. core/api/routers/brain.py +881 -0
  63. core/api/routers/brain_directions.py +527 -0
  64. core/api/routers/ci_checks.py +140 -0
  65. core/api/routers/comments.py +273 -0
  66. core/api/routers/costs.py +148 -0
  67. core/api/routers/docs_coverage.py +217 -0
  68. core/api/routers/docs_governance.py +63 -0
  69. core/api/routers/documents.py +318 -0
  70. core/api/routers/files.py +163 -0
  71. core/api/routers/finder.py +987 -0
  72. core/api/routers/graph.py +836 -0
  73. core/api/routers/handoffs.py +156 -0
  74. core/api/routers/inbox.py +496 -0
  75. core/api/routers/ingest_api_keys.py +205 -0
  76. core/api/routers/ingest_triage.py +1227 -0
  77. core/api/routers/judge.py +306 -0
  78. core/api/routers/kg.py +336 -0
  79. core/api/routers/learnings.py +253 -0
  80. core/api/routers/llm_config.py +130 -0
  81. core/api/routers/monitoring.py +347 -0
  82. core/api/routers/notifications.py +125 -0
  83. core/api/routers/pr_impact.py +315 -0
  84. core/api/routers/projects.py +1061 -0
  85. core/api/routers/pull_requests.py +312 -0
  86. core/api/routers/push.py +67 -0
  87. core/api/routers/raci.py +228 -0
  88. core/api/routers/search.py +125 -0
  89. core/api/routers/sessions.py +3100 -0
  90. core/api/routers/settings.py +90 -0
  91. core/api/routers/share_repo.py +68 -0
  92. core/api/routers/status_updates.py +96 -0
  93. core/api/routers/tags.py +45 -0
  94. core/api/routers/tasks.py +526 -0
  95. core/api/routers/teams.py +425 -0
  96. core/api/routers/terminal.py +105 -0
  97. core/api/routers/users.py +331 -0
  98. core/api/routers/webhooks.py +330 -0
  99. core/api/runtime_settings.py +84 -0
  100. core/api/security.py +652 -0
  101. core/api/services/__init__.py +0 -0
  102. core/api/services/audit.py +58 -0
  103. core/api/services/auto_approval.py +82 -0
  104. core/api/services/brain/__init__.py +52 -0
  105. core/api/services/brain/baseline.py +230 -0
  106. core/api/services/brain/capabilities.py +75 -0
  107. core/api/services/brain/cascade_rollup.py +388 -0
  108. core/api/services/brain/compound_bridge.py +215 -0
  109. core/api/services/brain/cycle.py +1242 -0
  110. core/api/services/brain/cycle_snapshot.py +371 -0
  111. core/api/services/brain/digest_collector.py +147 -0
  112. core/api/services/brain/direction.py +421 -0
  113. core/api/services/brain/drift.py +356 -0
  114. core/api/services/brain/drift_router.py +409 -0
  115. core/api/services/brain/edge_metrics.py +79 -0
  116. core/api/services/brain/events_reader.py +222 -0
  117. core/api/services/brain/findings.py +1379 -0
  118. core/api/services/brain/findings_reader.py +1006 -0
  119. core/api/services/brain/jobs.py +733 -0
  120. core/api/services/brain/journal.py +206 -0
  121. core/api/services/brain/knowledge_forms.py +92 -0
  122. core/api/services/brain/llm/__init__.py +37 -0
  123. core/api/services/brain/llm/_runner.py +62 -0
  124. core/api/services/brain/llm/base.py +70 -0
  125. core/api/services/brain/llm/cache.py +99 -0
  126. core/api/services/brain/llm/constants.py +46 -0
  127. core/api/services/brain/llm/direction_alignment.py +289 -0
  128. core/api/services/brain/llm/factory.py +132 -0
  129. core/api/services/brain/llm/finding_reasoning.py +98 -0
  130. core/api/services/brain/llm/finding_summary.py +92 -0
  131. core/api/services/brain/llm/grounding.py +46 -0
  132. core/api/services/brain/llm/journal_polish.py +96 -0
  133. core/api/services/brain/llm/local_gateway.py +426 -0
  134. core/api/services/brain/llm/parsers.py +71 -0
  135. core/api/services/brain/llm/router_glue.py +422 -0
  136. core/api/services/brain/memory_ops.py +1677 -0
  137. core/api/services/brain/models.py +140 -0
  138. core/api/services/brain/owner_hint.py +211 -0
  139. core/api/services/brain/recap.py +307 -0
  140. core/api/services/brain/rules/__init__.py +65 -0
  141. core/api/services/brain/rules/_signals.py +205 -0
  142. core/api/services/brain/rules/dr1_activity_without_status.py +108 -0
  143. core/api/services/brain/rules/dr2_decision_without_adr.py +110 -0
  144. core/api/services/brain/rules/dr3_stale_open_loop.py +127 -0
  145. core/api/services/brain/rules/dr4_docs_governance_drift.py +79 -0
  146. core/api/services/brain/rules/dr5_playbook_changed.py +99 -0
  147. core/api/services/brain/rules/dr6_external_update_unpropagated.py +100 -0
  148. core/api/services/brain/rules/dr7_claimed_decision_gap.py +123 -0
  149. core/api/services/brain/rules/dr8_direction_misalignment.py +230 -0
  150. core/api/services/brain/runs_reader.py +485 -0
  151. core/api/services/brain/scope.py +79 -0
  152. core/api/services/brain/sources/__init__.py +46 -0
  153. core/api/services/brain/sources/base.py +86 -0
  154. core/api/services/brain/sources/git_kg.py +393 -0
  155. core/api/services/brain/sources/handoffs.py +157 -0
  156. core/api/services/brain/sources/ingestor.py +130 -0
  157. core/api/services/brain/sources/learnings.py +121 -0
  158. core/api/services/brain/sources/pir_tasks.py +245 -0
  159. core/api/services/brain/watermarks.py +147 -0
  160. core/api/services/brain/ws_emitter.py +170 -0
  161. core/api/services/cc_tasks_reader.py +76 -0
  162. core/api/services/ci_service.py +263 -0
  163. core/api/services/claude_metrics.py +796 -0
  164. core/api/services/codex_metrics.py +364 -0
  165. core/api/services/conversation_reader.py +102 -0
  166. core/api/services/cost_service.py +243 -0
  167. core/api/services/crypto.py +147 -0
  168. core/api/services/docs_governance/__init__.py +1 -0
  169. core/api/services/docs_governance/confidence.py +230 -0
  170. core/api/services/docs_governance/config.py +87 -0
  171. core/api/services/docs_governance/enrichment.py +65 -0
  172. core/api/services/docs_governance/frontmatter_validator.py +83 -0
  173. core/api/services/docs_governance/hard_gates.py +221 -0
  174. core/api/services/docs_governance/triage_orchestrator.py +98 -0
  175. core/api/services/embedding_internal.py +395 -0
  176. core/api/services/embedding_service.py +832 -0
  177. core/api/services/event_dispatcher.py +167 -0
  178. core/api/services/events.py +70 -0
  179. core/api/services/git_ops.py +621 -0
  180. core/api/services/graph_cosmo_service.py +440 -0
  181. core/api/services/graph_ranker.py +306 -0
  182. core/api/services/graph_service.py +1589 -0
  183. core/api/services/inbox.py +800 -0
  184. core/api/services/inbox_digest.py +221 -0
  185. core/api/services/inbox_digest_deep_research.py +80 -0
  186. core/api/services/inbox_digest_jobs.py +595 -0
  187. core/api/services/inbox_gmail_sync.py +167 -0
  188. core/api/services/inbox_llm_classifier.py +906 -0
  189. core/api/services/inbox_source_identity.py +116 -0
  190. core/api/services/inbox_sources.py +456 -0
  191. core/api/services/inbox_taxonomy.py +195 -0
  192. core/api/services/inbox_tldr.py +1079 -0
  193. core/api/services/inbox_triage.py +899 -0
  194. core/api/services/ingest/__init__.py +13 -0
  195. core/api/services/ingest/api_key_auth.py +136 -0
  196. core/api/services/ingest/auto_approve.py +120 -0
  197. core/api/services/ingest/classifier.py +138 -0
  198. core/api/services/ingest/confidence.py +173 -0
  199. core/api/services/ingest/dispatch.py +88 -0
  200. core/api/services/ingest/embedding_router.py +272 -0
  201. core/api/services/ingest/events.py +33 -0
  202. core/api/services/ingest/ignore_patterns.py +79 -0
  203. core/api/services/ingest/image_probe.py +218 -0
  204. core/api/services/ingest/ingress.py +263 -0
  205. core/api/services/ingest/insert_saga.py +793 -0
  206. core/api/services/ingest/llm/__init__.py +13 -0
  207. core/api/services/ingest/llm/anthropic_haiku.py +23 -0
  208. core/api/services/ingest/llm/base.py +59 -0
  209. core/api/services/ingest/llm/byok_provider.py +130 -0
  210. core/api/services/ingest/llm/classification_context.py +301 -0
  211. core/api/services/ingest/llm/config_store.py +246 -0
  212. core/api/services/ingest/llm/factory.py +24 -0
  213. core/api/services/ingest/llm/kg_enricher.py +306 -0
  214. core/api/services/ingest/llm/local_gateway.py +821 -0
  215. core/api/services/ingest/llm/local_vllm.py +23 -0
  216. core/api/services/ingest/llm/openai_nano.py +349 -0
  217. core/api/services/ingest/lock_advisory.py +57 -0
  218. core/api/services/ingest/parser_router.py +1756 -0
  219. core/api/services/ingest/parsers/__init__.py +1 -0
  220. core/api/services/ingest/parsers/docling_parser.py +142 -0
  221. core/api/services/ingest/parsers/docparse_gateway.py +178 -0
  222. core/api/services/ingest/parsers/docx_parser.py +127 -0
  223. core/api/services/ingest/parsers/folder_unpacker.py +85 -0
  224. core/api/services/ingest/parsers/gateway_aux.py +147 -0
  225. core/api/services/ingest/parsers/image_parser.py +251 -0
  226. core/api/services/ingest/parsers/internal_markdown.py +89 -0
  227. core/api/services/ingest/parsers/ocr_gateway.py +117 -0
  228. core/api/services/ingest/parsers/ocr_pdf_parser.py +112 -0
  229. core/api/services/ingest/parsers/pdf_types.py +13 -0
  230. core/api/services/ingest/parsers/transcript_parser.py +445 -0
  231. core/api/services/ingest/parsers/vision_gateway.py +186 -0
  232. core/api/services/ingest/parsers/xlsx_parser.py +91 -0
  233. core/api/services/ingest/parsers/zip_unpacker.py +126 -0
  234. core/api/services/ingest/preflight.py +393 -0
  235. core/api/services/ingest/retry_voyage.py +88 -0
  236. core/api/services/ingest/routing_policy.py +307 -0
  237. core/api/services/ingest/serializers/__init__.py +1 -0
  238. core/api/services/ingest/serializers/xlsx_to_markdown.py +80 -0
  239. core/api/services/ingest/skip_log.py +74 -0
  240. core/api/services/ingest/watcher.py +637 -0
  241. core/api/services/kg/__init__.py +0 -0
  242. core/api/services/kg/audit.py +49 -0
  243. core/api/services/kg/hybrid_search.py +691 -0
  244. core/api/services/kg/lens.py +339 -0
  245. core/api/services/kg/pr_impact.py +770 -0
  246. core/api/services/kg/queries.py +152 -0
  247. core/api/services/kg/ranking.py +89 -0
  248. core/api/services/kg/rrf.py +143 -0
  249. core/api/services/kg_watcher_control.py +161 -0
  250. core/api/services/local_llm/__init__.py +19 -0
  251. core/api/services/local_llm/async_client.py +385 -0
  252. core/api/services/local_llm/client.py +173 -0
  253. core/api/services/local_llm/url_validator.py +44 -0
  254. core/api/services/metrics_collector.py +646 -0
  255. core/api/services/metrics_providers.py +65 -0
  256. core/api/services/model_registry.py +266 -0
  257. core/api/services/model_router.py +137 -0
  258. core/api/services/n8n_client.py +77 -0
  259. core/api/services/newsletter_llm_gateway.py +66 -0
  260. core/api/services/notification_service.py +134 -0
  261. core/api/services/openai_responses.py +55 -0
  262. core/api/services/opencode_metrics.py +375 -0
  263. core/api/services/opencode_sessions.py +173 -0
  264. core/api/services/pii_redactor.py +138 -0
  265. core/api/services/pr_impact_pipeline/__init__.py +21 -0
  266. core/api/services/pr_impact_pipeline/differ.py +421 -0
  267. core/api/services/pr_impact_pipeline/dispatcher.py +415 -0
  268. core/api/services/pr_impact_pipeline/gc.py +93 -0
  269. core/api/services/pr_impact_pipeline/languages.py +192 -0
  270. core/api/services/pr_impact_pipeline/parser.py +178 -0
  271. core/api/services/pr_impact_pipeline/writer.py +394 -0
  272. core/api/services/pr_service.py +1393 -0
  273. core/api/services/project_paths.py +70 -0
  274. core/api/services/project_status_updates.py +265 -0
  275. core/api/services/providers.py +276 -0
  276. core/api/services/push_service.py +170 -0
  277. core/api/services/reminder_service.py +89 -0
  278. core/api/services/runas.py +41 -0
  279. core/api/services/salience_service.py +69 -0
  280. core/api/services/security_collector.py +281 -0
  281. core/api/services/session_catalog.py +385 -0
  282. core/api/services/session_metrics_service.py +301 -0
  283. core/api/services/session_ops.py +272 -0
  284. core/api/services/session_state.py +173 -0
  285. core/api/services/share_links.py +222 -0
  286. core/api/services/task_transitions.py +146 -0
  287. core/api/services/terminal_metrics.py +462 -0
  288. core/api/services/terminal_metrics_dump.py +203 -0
  289. core/api/services/tmux.py +1205 -0
  290. core/api/services/webhook_service.py +422 -0
  291. core/api/services/workspace_sync.py +164 -0
  292. core/api/templates/__init__.py +1 -0
  293. core/api/templates/markdown_share.py +164 -0
  294. core/api/terminal.py +1031 -0
  295. core/api/tests/__init__.py +0 -0
  296. core/api/tests/test_agent_facing_auth_dependencies.py +132 -0
  297. core/api/tests/test_audit_permissions.py +133 -0
  298. core/api/tests/test_backfill_session_conversations.py +90 -0
  299. core/api/tests/test_backfill_working_seconds_msg.py +129 -0
  300. core/api/tests/test_claude_metrics.py +326 -0
  301. core/api/tests/test_codex_metrics.py +189 -0
  302. core/api/tests/test_finder_paths.py +74 -0
  303. core/api/tests/test_git_ops_merge.py +155 -0
  304. core/api/tests/test_learnings_check_search.py +81 -0
  305. core/api/tests/test_metrics_providers.py +133 -0
  306. core/api/tests/test_migration_087.py +164 -0
  307. core/api/tests/test_migration_088.py +94 -0
  308. core/api/tests/test_migration_089.py +116 -0
  309. core/api/tests/test_openai_responses.py +24 -0
  310. core/api/tests/test_opencode_metrics.py +740 -0
  311. core/api/tests/test_opencode_sessions.py +321 -0
  312. core/api/tests/test_pr_workflow_e2e.py +457 -0
  313. core/api/tests/test_projects_handoffs.py +31 -0
  314. core/api/tests/test_providers.py +138 -0
  315. core/api/tests/test_safety_bridge.py +347 -0
  316. core/api/tests/test_session_catalog.py +142 -0
  317. core/api/tests/test_session_conversations.py +512 -0
  318. core/api/tests/test_session_metrics_service.py +270 -0
  319. core/api/tests/test_session_resume_paths.py +548 -0
  320. core/api/tests/test_session_theme_mode_migration.py +56 -0
  321. core/api/tests/test_sessions_rbac.py +131 -0
  322. core/api/tests/test_share_edit.py +398 -0
  323. core/api/tests/test_share_repo.py +200 -0
  324. core/api/tests/test_terminal_session_manager.py +98 -0
  325. core/api/tests/test_terminal_upload.py +34 -0
  326. core/api/tests/test_tmux.py +272 -0
  327. core/api/tests/test_workspace_sync.py +186 -0
  328. core/api/tests/test_ws_ticket_in_memory.py +73 -0
  329. core/api/use_cases/__init__.py +11 -0
  330. core/api/use_cases/_context.py +89 -0
  331. core/api/use_cases/_errors.py +62 -0
  332. core/api/use_cases/_roles.py +16 -0
  333. core/api/use_cases/audit.py +171 -0
  334. core/api/use_cases/brain.py +1232 -0
  335. core/api/use_cases/costs.py +249 -0
  336. core/api/use_cases/graph.py +1153 -0
  337. core/api/use_cases/handoffs.py +506 -0
  338. core/api/use_cases/ingest_triage.py +1229 -0
  339. core/api/use_cases/learnings.py +538 -0
  340. core/api/use_cases/projects.py +705 -0
  341. core/api/use_cases/pull_requests.py +415 -0
  342. core/api/use_cases/search.py +926 -0
  343. core/api/use_cases/tasks.py +1495 -0
  344. core/api/visibility.py +141 -0
  345. core/cli/__init__.py +5 -0
  346. core/cli/_index_source.py +632 -0
  347. core/cli/_runtime_ctx.py +160 -0
  348. core/cli/_transmute.py +241 -0
  349. core/cli/marvis_doctor.py +704 -0
  350. core/cli/marvis_feedback.py +396 -0
  351. core/cli/marvis_governance.py +315 -0
  352. core/cli/marvis_hooks.py +515 -0
  353. core/cli/marvis_init.py +757 -0
  354. core/cli/marvis_mcp.py +401 -0
  355. core/cli/marvis_runtime.py +855 -0
  356. core/cli/marvis_telemetry.py +228 -0
  357. core/scripts/_drift_check.py +716 -0
  358. core/scripts/_frontmatter.py +66 -0
  359. core/scripts/_graph_writer.py +189 -0
  360. core/scripts/ast_parser.py +1553 -0
  361. core/scripts/install_hooks/__init__.py +1 -0
  362. core/scripts/install_hooks/_config.sh +109 -0
  363. core/scripts/install_hooks/block-dangerous-bash.sh +23 -0
  364. core/scripts/install_hooks/block-db-direct-write.sh +23 -0
  365. core/scripts/install_hooks/block-push-no-task.sh +23 -0
  366. core/scripts/install_hooks/block-staging-to-prod.sh +23 -0
  367. core/scripts/install_hooks/block-subtree-push.sh +23 -0
  368. core/scripts/install_hooks/config.json +53 -0
  369. core/scripts/install_hooks/enforce-no-merge-main.sh +23 -0
  370. core/scripts/install_hooks/enforce-worktree.sh +23 -0
  371. core/scripts/install_hooks/quality-gate.sh +170 -0
  372. core/scripts/install_hooks/safety_bridge.py +968 -0
  373. core/scripts/install_hooks/secret-scan.sh +23 -0
  374. core/scripts/migrate_spike_node_ids.py +122 -0
  375. core/scripts/populate_artifacts.py +2198 -0
  376. core/scripts/populate_cross_project.py +2457 -0
  377. core/scripts/populate_inbox_nodes.py +357 -0
  378. core/scripts/populate_pr_impact.py +267 -0
  379. core/scripts/populate_project_nodes.py +603 -0
  380. core/scripts/populate_touch_counter.py +337 -0
  381. core/scripts/reparse_failed.py +57 -0
  382. core/scripts/safety_bridge.py +968 -0
  383. core/telemetry/__init__.py +9 -0
  384. core/telemetry/client.py +405 -0
  385. core/telemetry/schema.py +122 -0
  386. core/wizard/__init__.py +65 -0
  387. core/wizard/byok_vault.py +147 -0
  388. core/wizard/defaults.py +58 -0
  389. core/wizard/state.py +117 -0
  390. core/wizard/steps.py +70 -0
  391. core/wizard/validation.py +136 -0
  392. marvisx_cli-0.1.0.dist-info/METADATA +201 -0
  393. marvisx_cli-0.1.0.dist-info/RECORD +587 -0
  394. marvisx_cli-0.1.0.dist-info/WHEEL +5 -0
  395. marvisx_cli-0.1.0.dist-info/entry_points.txt +3 -0
  396. marvisx_cli-0.1.0.dist-info/licenses/LICENSE +98 -0
  397. marvisx_cli-0.1.0.dist-info/top_level.txt +3 -0
  398. migrations/001_initial.sql +33 -0
  399. migrations/002_tasks.sql +30 -0
  400. migrations/003_session_management.sql +7 -0
  401. migrations/004_projects_comments.sql +65 -0
  402. migrations/005_session_intelligence.sql +15 -0
  403. migrations/006_settings.sql +12 -0
  404. migrations/007_task_scoring.sql +12 -0
  405. migrations/008_cost_tracking.sql +31 -0
  406. migrations/009_session_card_metrics.sql +3 -0
  407. migrations/010_monitoring.sql +55 -0
  408. migrations/012_agent_api.sql +21 -0
  409. migrations/013_session_complete.sql +8 -0
  410. migrations/015_pull_requests.sql +43 -0
  411. migrations/015_pull_requests_down.sql +5 -0
  412. migrations/016_users_raci.sql +116 -0
  413. migrations/017_task_cost_entries.sql +87 -0
  414. migrations/018_agents.sql +73 -0
  415. migrations/018_agents_down.sql +13 -0
  416. migrations/019_review_feedback.sql +18 -0
  417. migrations/020_pr_commit_sha.sql +4 -0
  418. migrations/021_webhook_events.sql +18 -0
  419. migrations/022_devx_agent_managed.sql +11 -0
  420. migrations/022_devx_agent_managed_down.sql +6 -0
  421. migrations/023_devx_p1_gate.sql +7 -0
  422. migrations/023_devx_p1_gate_down.sql +3 -0
  423. migrations/024_chat_messages.sql +16 -0
  424. migrations/024_pr_conversation_id.sql +8 -0
  425. migrations/024_task_indexes.sql +21 -0
  426. migrations/024_task_indexes_down.sql +7 -0
  427. migrations/025_audit_log.sql +17 -0
  428. migrations/026_agent_tokens.sql +20 -0
  429. migrations/027_teams_auth_phase_b.sql +35 -0
  430. migrations/028_learnings.sql +23 -0
  431. migrations/029_team_roles.sql +14 -0
  432. migrations/030_finder_pins.sql +10 -0
  433. migrations/031_pr_deploy_status.sql +9 -0
  434. migrations/032_task_reminders.sql +7 -0
  435. migrations/033_events_retry_count.sql +6 -0
  436. migrations/033_session_owner.sql +9 -0
  437. migrations/034_notifications.sql +38 -0
  438. migrations/035_shared_links.sql +15 -0
  439. migrations/036_session_index_upgrade.sql +29 -0
  440. migrations/037_pr_approval.sql +15 -0
  441. migrations/038_pr_submitted_by.sql +6 -0
  442. migrations/039_push_subscriptions.sql +17 -0
  443. migrations/040_semantic_search.sql +16 -0
  444. migrations/041_workspaces.sql +63 -0
  445. migrations/042_oidc_providers.sql +24 -0
  446. migrations/043_ci_checks.sql +31 -0
  447. migrations/044_agent_metrics.sql +30 -0
  448. migrations/045_documents_doc_type.sql +5 -0
  449. migrations/046_salience.sql +13 -0
  450. migrations/047_seed_missing_agents.sql +6 -0
  451. migrations/048_fix_agent_paths_roles.sql +5 -0
  452. migrations/049_agent_role_and_learnings_schema.sql +3 -0
  453. migrations/050_session_provider.sql +2 -0
  454. migrations/051_session_launch_profile.sql +4 -0
  455. migrations/052_session_theme_mode.sql +2 -0
  456. migrations/052_task_kind.sql +4 -0
  457. migrations/053_inbox_items.sql +31 -0
  458. migrations/054_inbox_triage_contract.sql +30 -0
  459. migrations/055_inbox_topic_treatment.sql +12 -0
  460. migrations/056_inbox_treatment_read_save.sql +57 -0
  461. migrations/057_session_theme_mode_backfill.sql +4 -0
  462. migrations/058_inbox_item_status_lifecycle.sql +13 -0
  463. migrations/059_inbox_tldr_and_source_scores.sql +18 -0
  464. migrations/060_newsletter.sql +16 -0
  465. migrations/061_inbox_redesign.sql +69 -0
  466. migrations/062_fix_inbox_sources_backfill.sql +37 -0
  467. migrations/063_task_completion_mode.sql +23 -0
  468. migrations/064_judge_mode_setting.sql +4 -0
  469. migrations/065_knowledge_graph_spike.sql +40 -0
  470. migrations/066_digest_ranking_inputs.sql +10 -0
  471. migrations/066_kg_artifact_nodes.sql +129 -0
  472. migrations/067_inbox_digest_selections.sql +28 -0
  473. migrations/067_kg_temporal.sql +53 -0
  474. migrations/068_inbox_digest_app_settings.sql +9 -0
  475. migrations/068_kg_touch_counter.sql +52 -0
  476. migrations/069_kg_doc_types.sql +117 -0
  477. migrations/070_digest_ranking_inputs_recovery.sql +3 -0
  478. migrations/071_inbox_digest_selections_recovery.sql +3 -0
  479. migrations/072_inbox_digest_app_settings_recovery.sql +3 -0
  480. migrations/073_kg_cross_project.sql +216 -0
  481. migrations/073_kg_cross_project_down.sql +77 -0
  482. migrations/074_kg_infra_types.sql +208 -0
  483. migrations/074_kg_infra_types_down.sql +80 -0
  484. migrations/075_kg_file_state_recovery.sql +35 -0
  485. migrations/075_kg_file_state_recovery_down.sql +5 -0
  486. migrations/076_kg_watcher_state.sql +33 -0
  487. migrations/076_kg_watcher_state_down.sql +3 -0
  488. migrations/077_kg_doc_types_extend.sql +226 -0
  489. migrations/077_kg_doc_types_extend_down.sql +80 -0
  490. migrations/078_kg_fts5.sql +102 -0
  491. migrations/078_kg_fts5_down.sql +14 -0
  492. migrations/079_kg_missing_indexes.sql +31 -0
  493. migrations/079_kg_missing_indexes_down.sql +10 -0
  494. migrations/080_kg_fts5_extended.sql +232 -0
  495. migrations/080_kg_fts5_extended_down.sql +25 -0
  496. migrations/081_kg_lens_indexes.sql +9 -0
  497. migrations/081_kg_lens_indexes_down.sql +3 -0
  498. migrations/082_kg_pins.sql +26 -0
  499. migrations/082_kg_pins_down.sql +14 -0
  500. migrations/083_kg_graph_nodes_degree.sql +20 -0
  501. migrations/083_kg_graph_nodes_degree_down.sql +15 -0
  502. migrations/084_drop_legacy_scheduler_tables.sql +58 -0
  503. migrations/084_drop_legacy_scheduler_tables_down.sql +112 -0
  504. migrations/085_kg_edge_resolves_to.sql +142 -0
  505. migrations/085_kg_edge_resolves_to_down.sql +66 -0
  506. migrations/086_project_status_updates_feed.sql +20 -0
  507. migrations/086_project_status_updates_feed_down.sql +36 -0
  508. migrations/087_session_metrics_dual.sql +50 -0
  509. migrations/087_session_metrics_dual_down.sql +21 -0
  510. migrations/088_rename_context_pct_legacy.sql +23 -0
  511. migrations/088_rename_context_pct_legacy_down.sql +8 -0
  512. migrations/089_session_metrics_equivalent_cost.sql +26 -0
  513. migrations/089_session_metrics_equivalent_cost_down.sql +11 -0
  514. migrations/090_kg_inbox_node_type.sql +26 -0
  515. migrations/090_kg_inbox_node_type_down.sql +20 -0
  516. migrations/091_kg_inbox_node_type_check.sql +265 -0
  517. migrations/091_kg_inbox_node_type_check_down.sql +129 -0
  518. migrations/092_sessions_activity_state_ts.sql +29 -0
  519. migrations/092_sessions_activity_state_ts_down.sql +14 -0
  520. migrations/093_sessions_activity_state_column.sql +29 -0
  521. migrations/093_sessions_activity_state_column_down.sql +10 -0
  522. migrations/094_ingest_pending.sql +55 -0
  523. migrations/094_ingest_pending_down.sql +15 -0
  524. migrations/095_kg_intent_first.sql +77 -0
  525. migrations/095_kg_intent_first_down.sql +25 -0
  526. migrations/096_kg_xlsx_artifact_prefix.sql +17 -0
  527. migrations/096_kg_xlsx_artifact_prefix_down.sql +11 -0
  528. migrations/097_ingest_change_history.sql +37 -0
  529. migrations/097_ingest_change_history_down.sql +13 -0
  530. migrations/098_kg_node_type_business.sql +254 -0
  531. migrations/098_kg_node_type_business_down.sql +195 -0
  532. migrations/099_kg_edges_restore_weight.sql +58 -0
  533. migrations/099_kg_edges_restore_weight_down.sql +12 -0
  534. migrations/100_kg_enriched_at.sql +25 -0
  535. migrations/100_kg_enriched_at_down.sql +12 -0
  536. migrations/101_local_llm_shadow_comparisons.sql +66 -0
  537. migrations/101_local_llm_shadow_comparisons_down.sql +15 -0
  538. migrations/102_promote_llm_costs.sql +69 -0
  539. migrations/102_promote_llm_costs_down.sql +19 -0
  540. migrations/103_ingest_skipped_log.sql +46 -0
  541. migrations/103_ingest_skipped_log_down.sql +15 -0
  542. migrations/120_docs_governance.sql +50 -0
  543. migrations/120_docs_governance_down.sql +11 -0
  544. migrations/121_notification_event_fk_cleanup.sql +21 -0
  545. migrations/121_notification_event_fk_cleanup_down.sql +10 -0
  546. migrations/122_docs_drift_history.sql +34 -0
  547. migrations/122_docs_drift_history_down.sql +15 -0
  548. migrations/123_ingest_parser_waiting_status.sql +69 -0
  549. migrations/123_ingest_parser_waiting_status_down.sql +69 -0
  550. migrations/124_heypocket_recordings.sql +63 -0
  551. migrations/124_heypocket_recordings_down.sql +13 -0
  552. migrations/125_kg_node_type_record.sql +219 -0
  553. migrations/125_kg_node_type_record_down.sql +205 -0
  554. migrations/126_ingest_terminal_upload_source_kind.sql +69 -0
  555. migrations/126_ingest_terminal_upload_source_kind_down.sql +69 -0
  556. migrations/127_brain_v1_substrate.sql +200 -0
  557. migrations/127_brain_v1_substrate_down.sql +32 -0
  558. migrations/128_brain_drift_signals.sql +157 -0
  559. migrations/128_brain_drift_signals_down.sql +23 -0
  560. migrations/129_brain_memory_operations.sql +232 -0
  561. migrations/129_brain_memory_operations_down.sql +27 -0
  562. migrations/130_brain_findings.sql +258 -0
  563. migrations/130_brain_findings_down.sql +29 -0
  564. migrations/132_kg_pr_modifies.sql +242 -0
  565. migrations/132_kg_pr_modifies_down.sql +99 -0
  566. migrations/133_brain_v1_2_direction_schema.sql +476 -0
  567. migrations/133_brain_v1_2_direction_schema_down.sql +273 -0
  568. migrations/134_brain_journal_narrative_polished.sql +8 -0
  569. migrations/134_brain_journal_narrative_polished_down.sql +6 -0
  570. migrations/135_kg_edges_provider.sql +21 -0
  571. migrations/136_documents_fts.sql +56 -0
  572. migrations/137_promote_llm_costs.sql +59 -0
  573. migrations/137_promote_llm_costs_down.sql +19 -0
  574. migrations/138_ingest_api_keys.sql +39 -0
  575. migrations/138_ingest_api_keys_down.sql +8 -0
  576. migrations/139_ingest_pending_ingress.sql +91 -0
  577. migrations/139_ingest_pending_ingress_down.sql +73 -0
  578. migrations/140_ingest_idempotency_quota.sql +45 -0
  579. migrations/140_ingest_idempotency_quota_down.sql +9 -0
  580. migrations/141_ingest_pending_metadata.sql +16 -0
  581. migrations/141_ingest_pending_metadata_down.sql +7 -0
  582. migrations/142_llm_function_config.sql +36 -0
  583. migrations/142_llm_function_config_down.sql +8 -0
  584. migrations/143_kg_code_embeddings.sql +25 -0
  585. migrations/143_kg_code_embeddings_down.sql +5 -0
  586. migrations/__init__.py +4 -0
  587. projects/_template/project.yaml +46 -0
@@ -0,0 +1,393 @@
1
+ # Git + Knowledge Graph source collector
2
+ # (sub-01 §3 — `commit_changed` / `kg_changed` / `doc_changed`).
3
+ #
4
+ # Three streams collapsed into a single collector because they share the
5
+ # same substrate (filesystem + graph nodes) and the same cycle math. Strict
6
+ # anti-patterns enforced (regression-guarded by the no-neighbors test):
7
+ # * Per-project neighbor expansion → N+1 disaster, banned.
8
+ # Use graph_service.get_hotspots instead.
9
+ # * Direct SELECT against the graph node table in this module → banned.
10
+ # Read paths live in api.services.graph_service.
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import logging
15
+ import os
16
+ from collections.abc import AsyncIterator
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+ from core.api.db import acquire_db
21
+ from core.api.paths import repo_root
22
+ from core.api.services import graph_service
23
+ from core.api.services.brain.digest_collector import SourceCollector
24
+ from core.api.services.brain.models import EventDraft, SourceCollectorContext
25
+ from core.api.services.brain.sources.base import (
26
+ normalize_iso,
27
+ resolve_source_project,
28
+ )
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ # ----------------------------------------------------------------------
34
+ # Tunables (kept inline rather than promoted to settings — the plan §3
35
+ # fixes them explicitly and lifting them to app_settings would create
36
+ # another knob that drifts).
37
+ # ----------------------------------------------------------------------
38
+
39
+ COMMIT_CAP_PER_CYCLE: int = 100
40
+ KG_HOTSPOT_LIMIT: int = 20
41
+ KG_HOTSPOT_WINDOW = "30d"
42
+ DOC_NODE_KINDS: frozenset[str] = frozenset(
43
+ {
44
+ "handoff",
45
+ "solution",
46
+ "guide",
47
+ "plan",
48
+ "brainstorm",
49
+ "audit",
50
+ "spike",
51
+ "analysis",
52
+ "research",
53
+ "rubric",
54
+ "report",
55
+ "policy",
56
+ "contract",
57
+ }
58
+ )
59
+
60
+ def _resolve_repo_root() -> Path:
61
+ """Resolve the git repository root for commit_changed scans.
62
+
63
+ Order of precedence:
64
+ 1. ``BRAIN_GIT_REPO_ROOT`` env var (explicit override, used in prod where
65
+ pir-api is deployed under ``/data/pir/api/`` and the source repo lives
66
+ elsewhere — typically ``~/workspace``).
67
+ 2. ``parents[3]`` of this file (development default — works when pir-api
68
+ runs from the monorepo checkout, e.g. CI or dev workstation).
69
+
70
+ The function returns a Path; callers still gate ``git log`` execution on
71
+ ``(repo_root / ".git").exists()`` so a wrong value degrades to an empty
72
+ event stream (collector continues, no crash).
73
+ """
74
+ env_override = os.getenv("BRAIN_GIT_REPO_ROOT")
75
+ if env_override:
76
+ return Path(env_override).expanduser().resolve()
77
+ return repo_root(__file__)
78
+
79
+
80
+ _REPO_ROOT = _resolve_repo_root()
81
+
82
+
83
+ # ----------------------------------------------------------------------
84
+ # commit_changed
85
+ # ----------------------------------------------------------------------
86
+
87
+
88
+ _GIT_LOG_FORMAT = "%H%x1f%aI%x1f%an%x1f%s"
89
+ _GIT_LOG_SEP = "\x1f"
90
+
91
+
92
+ async def _git_log_window(
93
+ *,
94
+ since: datetime,
95
+ until: datetime,
96
+ cwd: Path,
97
+ cap: int,
98
+ ) -> list[dict[str, str]]:
99
+ """Run `git log` for the cycle window.
100
+
101
+ Returns at most `cap` commits ordered ASC by commit time so the watermark
102
+ advances monotonically. Failure to invoke git (binary missing, non-repo)
103
+ surfaces as an empty list — the collector continues and the partial
104
+ failure tracking happens at the cycle level via try/except.
105
+ """
106
+ if not (cwd / ".git").exists():
107
+ return []
108
+ try:
109
+ proc = await asyncio.create_subprocess_exec(
110
+ "git",
111
+ "log",
112
+ f"--since={since.astimezone(timezone.utc).isoformat()}",
113
+ f"--until={until.astimezone(timezone.utc).isoformat()}",
114
+ f"--format={_GIT_LOG_FORMAT}",
115
+ "--reverse",
116
+ f"--max-count={cap}",
117
+ "--no-merges",
118
+ cwd=str(cwd),
119
+ stdout=asyncio.subprocess.PIPE,
120
+ stderr=asyncio.subprocess.PIPE,
121
+ )
122
+ except FileNotFoundError:
123
+ return []
124
+ try:
125
+ stdout_b, _stderr_b = await asyncio.wait_for(proc.communicate(), timeout=10.0)
126
+ except asyncio.TimeoutError:
127
+ proc.kill()
128
+ await proc.wait()
129
+ return []
130
+
131
+ rows: list[dict[str, str]] = []
132
+ for line in stdout_b.decode("utf-8", errors="replace").splitlines():
133
+ parts = line.split(_GIT_LOG_SEP, 3)
134
+ if len(parts) != 4:
135
+ continue
136
+ sha, author_iso, author_name, subject = parts
137
+ rows.append(
138
+ {
139
+ "sha": sha,
140
+ "author_iso": author_iso,
141
+ "author_name": author_name,
142
+ "subject": subject,
143
+ }
144
+ )
145
+ return rows
146
+
147
+
148
+ def _commit_event(
149
+ *, sha: str, author_iso: str, author_name: str, subject: str, now: datetime
150
+ ) -> EventDraft:
151
+ observed_at = normalize_iso(author_iso) or now
152
+ evidence = {
153
+ "sha": sha,
154
+ "subject": subject[:200],
155
+ "author": author_name,
156
+ "author_iso": observed_at.isoformat(),
157
+ }
158
+ return EventDraft(
159
+ event_type="commit_changed",
160
+ source_system="git",
161
+ source_ref=f"commit:{sha}",
162
+ title=subject[:200] or f"commit {sha[:7]}",
163
+ summary=f"{author_name}: {subject[:160]}",
164
+ observed_at=observed_at,
165
+ derived_from_state_at=observed_at,
166
+ evidence=evidence,
167
+ )
168
+
169
+
170
+ # ----------------------------------------------------------------------
171
+ # kg_changed (via graph_service.get_hotspots — NEVER per-project neighbor scan)
172
+ # ----------------------------------------------------------------------
173
+
174
+
175
+ def _node_id_for(node_type: str, qualified_name: str) -> str:
176
+ """Mirror KG id convention so source_ref ↔ KG node_id round-trips."""
177
+ prefix_map = {
178
+ "function": "py",
179
+ "file": "py",
180
+ "module": "py",
181
+ }
182
+ prefix = prefix_map.get(node_type, node_type)
183
+ return f"{prefix}:{node_type}:{qualified_name}"
184
+
185
+
186
+ def _kg_event(*, row: dict, now: datetime) -> EventDraft | None:
187
+ touched_at = normalize_iso(row.get("touch_last_at"))
188
+ if touched_at is None:
189
+ return None
190
+ node_id = row.get("id") or _node_id_for(row["type"], row.get("qualified_name", ""))
191
+ project, program = resolve_source_project(row.get("project_id"))
192
+ evidence = {
193
+ "node_id": node_id,
194
+ "type": row.get("type"),
195
+ "touch_count_30d": row.get("touch_count_30d", 0),
196
+ "touch_count_7d": row.get("touch_count_7d", 0),
197
+ "touch_last_at": touched_at.isoformat(),
198
+ }
199
+ return EventDraft(
200
+ event_type="kg_changed",
201
+ source_system="kg",
202
+ source_ref=node_id,
203
+ title=str(row.get("name") or node_id)[:200],
204
+ summary=f"KG hotspot {node_id}: {row.get('touch_count_30d', 0)}/30d touches",
205
+ observed_at=touched_at,
206
+ derived_from_state_at=touched_at,
207
+ evidence=evidence,
208
+ source_project=project,
209
+ program_key=program,
210
+ )
211
+
212
+
213
+ # ----------------------------------------------------------------------
214
+ # doc_changed (graph_nodes via graph_service helper — no direct SQL)
215
+ # ----------------------------------------------------------------------
216
+
217
+
218
+ async def _list_doc_nodes_changed_since(
219
+ *, since: datetime, limit: int = 200
220
+ ) -> list[dict]:
221
+ """Read doc-kind nodes whose touch_last_at advanced past the watermark.
222
+
223
+ Routed through graph_service.find_recently_touched_doc_nodes so this
224
+ collector stays free of direct SQL against the graph node table
225
+ (anti-pattern enforced via grep on the file).
226
+ """
227
+ fetcher = getattr(graph_service, "find_recently_touched_doc_nodes", None)
228
+ if fetcher is None:
229
+ # Fall back to no-op rather than the banned direct query — tests
230
+ # cover this branch explicitly so the absence is visible in CI.
231
+ return []
232
+ return await fetcher(
233
+ since_iso=since.astimezone(timezone.utc).isoformat(),
234
+ kinds=tuple(sorted(DOC_NODE_KINDS)),
235
+ limit=limit,
236
+ )
237
+
238
+
239
+ def _doc_event(*, row: dict, now: datetime) -> EventDraft | None:
240
+ touched_at = normalize_iso(row.get("touch_last_at"))
241
+ if touched_at is None:
242
+ return None
243
+ node_id = row.get("id")
244
+ path = row.get("file_path") or row.get("name") or node_id
245
+ last_sha = row.get("last_modified_git_sha") or row.get("first_seen_git_sha") or ""
246
+ project, program = resolve_source_project(row.get("project_id"))
247
+ evidence = {
248
+ "node_id": node_id,
249
+ "kind": row.get("type"),
250
+ "path": path,
251
+ "sha": last_sha,
252
+ "touch_last_at": touched_at.isoformat(),
253
+ }
254
+ return EventDraft(
255
+ event_type="doc_changed",
256
+ source_system="kg",
257
+ source_ref=f"doc:{path}@{last_sha[:12] if last_sha else 'untracked'}",
258
+ title=str(row.get("name") or path)[:200],
259
+ summary=f"Doc {row.get('type')} updated: {path}",
260
+ observed_at=touched_at,
261
+ derived_from_state_at=touched_at,
262
+ evidence=evidence,
263
+ source_project=project,
264
+ program_key=program,
265
+ )
266
+
267
+
268
+ # ----------------------------------------------------------------------
269
+ # Collector entry point
270
+ # ----------------------------------------------------------------------
271
+
272
+
273
+ def _resolve_git_window(ctx: SourceCollectorContext) -> tuple[datetime, datetime]:
274
+ """Return (since, until) for ``git log`` — clamped to the cycle window.
275
+
276
+ Bug fix 2026-05-18: collectors must honor ``cycle_window_*`` instead of
277
+ blindly reading from the watermark up to ``cutoff_at``. For backward-
278
+ compat (periodic path with no window bounds) this collapses to the
279
+ legacy ``(watermark, cutoff_at]`` slice.
280
+ """
281
+ if (
282
+ ctx.cycle_window_start is not None
283
+ and ctx.cycle_window_start > ctx.since_watermark
284
+ ):
285
+ since = ctx.cycle_window_start
286
+ else:
287
+ since = ctx.since_watermark
288
+ if (
289
+ ctx.cycle_window_end is not None
290
+ and ctx.cycle_window_end < ctx.cutoff_at
291
+ ):
292
+ until = ctx.cycle_window_end
293
+ else:
294
+ until = ctx.cutoff_at
295
+ return since, until
296
+
297
+
298
+ async def collect_git(ctx: SourceCollectorContext) -> AsyncIterator[EventDraft]:
299
+ """Git commit_changed collector — source_system='git'.
300
+
301
+ Repo root resolution order:
302
+ 1. ``BRAIN_GIT_REPO_ROOT`` env var (production: pir-api deployed under
303
+ /data/pir/api/ → repo lives at ~/workspace).
304
+ 2. Module-level ``_REPO_ROOT`` (dev default + monkeypatchable in tests).
305
+
306
+ Cycle window: bounded via ``ctx.cycle_window_start`` / ``cycle_window_end``
307
+ when present, otherwise falls back to ``(since_watermark, cutoff_at]``.
308
+ """
309
+ env_override = os.getenv("BRAIN_GIT_REPO_ROOT")
310
+ repo_root = (
311
+ Path(env_override).expanduser().resolve() if env_override else _REPO_ROOT
312
+ )
313
+ since, until = _resolve_git_window(ctx)
314
+ commits = await _git_log_window(
315
+ since=since,
316
+ until=until,
317
+ cwd=repo_root,
318
+ cap=COMMIT_CAP_PER_CYCLE,
319
+ )
320
+ for commit in commits:
321
+ observed_at = normalize_iso(commit["author_iso"])
322
+ if observed_at is not None and not ctx.in_window(observed_at):
323
+ continue
324
+ yield _commit_event(
325
+ sha=commit["sha"],
326
+ author_iso=commit["author_iso"],
327
+ author_name=commit["author_name"],
328
+ subject=commit["subject"],
329
+ now=ctx.now,
330
+ )
331
+
332
+
333
+ async def collect_kg(ctx: SourceCollectorContext) -> AsyncIterator[EventDraft]:
334
+ """KG kg_changed (hotspots) + doc_changed (doc nodes) — source_system='kg'.
335
+
336
+ Cycle window enforcement via ``ctx.in_window`` (bug fix 2026-05-18).
337
+ """
338
+ # kg_changed (hotspot delta projection)
339
+ async with acquire_db() as db:
340
+ hotspots = await graph_service.get_hotspots(
341
+ db,
342
+ window=KG_HOTSPOT_WINDOW,
343
+ limit=KG_HOTSPOT_LIMIT,
344
+ type_filter="file",
345
+ )
346
+
347
+ for row in hotspots:
348
+ touched_at = normalize_iso(row.get("touch_last_at"))
349
+ if touched_at is None or not ctx.in_window(touched_at):
350
+ continue
351
+ evt = _kg_event(row=row, now=ctx.now)
352
+ if evt is not None:
353
+ yield evt
354
+
355
+ # doc_changed — fetch since the lower bound (max(watermark, window_start))
356
+ since_doc, _until_doc = _resolve_git_window(ctx)
357
+ try:
358
+ doc_rows = await _list_doc_nodes_changed_since(since=since_doc)
359
+ except Exception: # pragma: no cover — helper missing or KG offline
360
+ logger.exception("brain.sources.git_kg: doc_changed fetch failed")
361
+ doc_rows = []
362
+
363
+ for row in doc_rows:
364
+ touched_at = normalize_iso(row.get("touch_last_at"))
365
+ if touched_at is None or not ctx.in_window(touched_at):
366
+ continue
367
+ evt = _doc_event(row=row, now=ctx.now)
368
+ if evt is not None:
369
+ yield evt
370
+
371
+
372
+ # Backward-compat alias for tests/code che ancora referenziano collect()
373
+ async def collect(ctx: SourceCollectorContext) -> AsyncIterator[EventDraft]:
374
+ async for evt in collect_git(ctx):
375
+ yield evt
376
+ async for evt in collect_kg(ctx):
377
+ yield evt
378
+
379
+
380
+ git_collector = SourceCollector(source_system="git", collect=collect_git)
381
+ kg_collector = SourceCollector(source_system="kg", collect=collect_kg)
382
+ # Legacy alias (NON registrare — single source_system='git_kg' viola CHECK constraint)
383
+ git_kg_collector = git_collector
384
+
385
+
386
+ __all__ = [
387
+ "COMMIT_CAP_PER_CYCLE",
388
+ "DOC_NODE_KINDS",
389
+ "KG_HOTSPOT_LIMIT",
390
+ "KG_HOTSPOT_WINDOW",
391
+ "collect",
392
+ "git_kg_collector",
393
+ ]
@@ -0,0 +1,157 @@
1
+ # Handoffs source collector (sub-01 §3 — `handoff_changed`).
2
+ #
3
+ # Handoffs are filesystem-only: `{metadata_path}/memory/handoff-*.md`. There
4
+ # is no `handoff_index` table in console.db today, so the plan's "fallback
5
+ # to handoff_index.updated_at" is implemented as: `mtime` is authoritative;
6
+ # if a fallback table appears later we add a second branch here.
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from collections.abc import AsyncIterator
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+ import yaml
15
+
16
+ from core.api.services.brain.digest_collector import SourceCollector
17
+ from core.api.services.brain.models import EventDraft, SourceCollectorContext
18
+ from core.api.services.brain.sources.base import (
19
+ resolve_source_project,
20
+ stable_short_hash,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ # Per-cycle hard cap protects against an accidental bulk-touch of every
27
+ # handoff file (e.g. `chmod -R`, restore-from-backup). Plan §6.D0 says 1000
28
+ # events/source — we leave the per-source cap to the orchestrator and just
29
+ # bound the directory walk for safety.
30
+ MAX_HANDOFFS_SCANNED: int = 2000
31
+
32
+
33
+ def _iter_project_roots() -> list[Path]:
34
+ """Return project metadata roots without coupling to router globals.
35
+
36
+ The Marvis convention (kb/projects.md): `/data/projects/<slug>/` plus
37
+ `~/workspace/projects/<slug>/`. We import lazily and tolerate missing
38
+ `api.routers.projects` (e.g. during unit tests that don't bootstrap the
39
+ full API surface).
40
+ """
41
+ try:
42
+ from core.api.routers.projects import PROJECT_DIRS
43
+
44
+ return [p for p in PROJECT_DIRS if p.exists()]
45
+ except Exception: # pragma: no cover — defensive
46
+ defaults = [
47
+ Path("/data/projects"),
48
+ Path.home() / "workspace" / "projects",
49
+ ]
50
+ return [p for p in defaults if p.exists()]
51
+
52
+
53
+ def _parse_handoff_frontmatter(path: Path) -> tuple[list[str], str | None, str | None]:
54
+ """Return (tags, session, slug_hint) from frontmatter; best-effort."""
55
+ try:
56
+ text = path.read_text(encoding="utf-8", errors="replace")
57
+ except OSError:
58
+ return [], None, None
59
+ if not text.startswith("---"):
60
+ return [], None, None
61
+ try:
62
+ _, body = text.split("---", 1)
63
+ meta_block, _ = body.split("---", 1)
64
+ meta = yaml.safe_load(meta_block) or {}
65
+ except (ValueError, yaml.YAMLError):
66
+ return [], None, None
67
+ if not isinstance(meta, dict):
68
+ return [], None, None
69
+ raw_tags = meta.get("tags") or []
70
+ if isinstance(raw_tags, str):
71
+ raw_tags = [raw_tags]
72
+ tags = [str(t) for t in raw_tags if isinstance(t, (str, int))]
73
+ session = (
74
+ str(meta.get("session"))
75
+ if meta.get("session") not in (None, "")
76
+ else None
77
+ )
78
+ slug_hint = meta.get("project") or meta.get("slug")
79
+ return tags, session, str(slug_hint) if slug_hint else None
80
+
81
+
82
+ def _project_slug_from_path(handoff_path: Path) -> str | None:
83
+ """Recover the project slug from `<root>/<slug>/memory/<file>` shape."""
84
+ parts = handoff_path.parts
85
+ if "memory" not in parts:
86
+ return None
87
+ idx = parts.index("memory")
88
+ return parts[idx - 1] if idx >= 1 else None
89
+
90
+
91
+ async def collect(ctx: SourceCollectorContext) -> AsyncIterator[EventDraft]:
92
+ seen = 0
93
+ for root in _iter_project_roots():
94
+ try:
95
+ project_dirs = [d for d in root.iterdir() if d.is_dir()]
96
+ except OSError:
97
+ continue
98
+ for project_dir in project_dirs:
99
+ memory_dir = project_dir / "memory"
100
+ if not memory_dir.is_dir():
101
+ continue
102
+ try:
103
+ handoffs = list(memory_dir.glob("handoff-*.md"))
104
+ except OSError:
105
+ continue
106
+ for handoff in handoffs:
107
+ if seen >= MAX_HANDOFFS_SCANNED:
108
+ return
109
+ seen += 1
110
+ try:
111
+ stat = handoff.stat()
112
+ except OSError:
113
+ continue
114
+ mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
115
+ # Cycle window enforcement (bug fix 2026-05-18) — collapses to
116
+ # the legacy (watermark, cutoff] filter when no window bounds.
117
+ if not ctx.in_window(mtime):
118
+ continue
119
+
120
+ tags, session, slug_hint = _parse_handoff_frontmatter(handoff)
121
+ slug = slug_hint or _project_slug_from_path(handoff) or project_dir.name
122
+ source_project, program_key = resolve_source_project(slug)
123
+ filename_hash = stable_short_hash(handoff.name)
124
+ source_ref = f"handoff:{slug}:{filename_hash}"
125
+ evidence = {
126
+ "path": str(handoff),
127
+ "mtime": mtime.isoformat(),
128
+ "size": stat.st_size,
129
+ "frontmatter_tags": sorted(tags),
130
+ }
131
+ if session:
132
+ evidence["session"] = session
133
+
134
+ title = handoff.name
135
+ summary = (
136
+ f"handoff {handoff.name} updated"
137
+ + (f" (session {session})" if session else "")
138
+ )
139
+
140
+ yield EventDraft(
141
+ event_type="handoff_changed",
142
+ source_system="handoff",
143
+ source_ref=source_ref,
144
+ title=title[:200],
145
+ summary=summary[:2000],
146
+ observed_at=mtime,
147
+ derived_from_state_at=mtime,
148
+ evidence=evidence,
149
+ source_project=source_project,
150
+ program_key=program_key,
151
+ )
152
+
153
+
154
+ handoffs_collector = SourceCollector(source_system="handoff", collect=collect)
155
+
156
+
157
+ __all__ = ["collect", "handoffs_collector"]
@@ -0,0 +1,130 @@
1
+ # Ingestor source collector (sub-01 §3 — `ingest_changed` + `external_update_seen`).
2
+ # Reads inbox_items rows whose updated_at falls in (watermark, cutoff_at]
3
+ # and yields one event per row. No business state is mutated.
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import logging
8
+ from collections.abc import AsyncIterator
9
+
10
+ from core.api.db import acquire_db
11
+ from core.api.services.brain.digest_collector import SourceCollector
12
+ from core.api.services.brain.models import EventDraft, SourceCollectorContext
13
+ from core.api.services.brain.sources.base import (
14
+ normalize_iso,
15
+ resolve_source_project,
16
+ )
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ # Inbox sources that represent EXTERNAL feeds (RSS, email, web). These map to
22
+ # `external_update_seen` per plan §3 so the Drift layer can distinguish "user
23
+ # uploaded something" from "we noticed an upstream feed changed". Anything
24
+ # else (manual upload, API ingest, gmail-marvisx) maps to `ingest_changed`.
25
+ _EXTERNAL_SOURCES: frozenset[str] = frozenset({"rss", "email", "web", "external"})
26
+
27
+
28
+ def _classify_event_type(source_value: str | None) -> str:
29
+ if not source_value:
30
+ return "ingest_changed"
31
+ return (
32
+ "external_update_seen"
33
+ if source_value.lower() in _EXTERNAL_SOURCES
34
+ else "ingest_changed"
35
+ )
36
+
37
+
38
+ def _source_ref(row_id: str, source_value: str | None, url: str | None) -> str:
39
+ if (source_value or "").lower() in _EXTERNAL_SOURCES and url:
40
+ url_hash = hashlib.blake2b(url.encode("utf-8"), digest_size=8).hexdigest()
41
+ return f"external:{url_hash}"
42
+ return f"ingest:{row_id}"
43
+
44
+
45
+ # Pagination page size — bounded read avoids loading >cap rows at once and
46
+ # enables collectors to span sources with >1000 events per cycle (bug fix
47
+ # 2026-05-18: previous code fetched a single LIMIT-less batch and the
48
+ # orchestrator-level cap silently truncated to per_source_event_cap).
49
+ _PAGE_SIZE: int = 500
50
+
51
+
52
+ async def collect(ctx: SourceCollectorContext) -> AsyncIterator[EventDraft]:
53
+ """Yield ingestor events within the cycle window.
54
+
55
+ Window = (max(watermark, cycle_window_start), min(cutoff_at, cycle_window_end)]
56
+ SQL filter uses string comparison on ISO timestamps; Python-side
57
+ ``ctx.in_window`` re-checks each row for TZ-naive substrate rows.
58
+ Pagination via LIMIT/OFFSET so cycles with >page_size events still
59
+ surface every row.
60
+ """
61
+ lower_iso = ctx.lower_bound_iso
62
+ upper_iso = ctx.upper_bound_iso
63
+
64
+ offset = 0
65
+ while True:
66
+ async with acquire_db() as db:
67
+ cur = await db.execute(
68
+ "SELECT id, source, status, default_program, title, url, "
69
+ " workspace_id, updated_at "
70
+ "FROM inbox_items "
71
+ "WHERE updated_at > ? AND updated_at <= ? "
72
+ "ORDER BY updated_at ASC "
73
+ "LIMIT ? OFFSET ?",
74
+ (lower_iso, upper_iso, _PAGE_SIZE, offset),
75
+ )
76
+ rows = await cur.fetchall()
77
+ if not rows:
78
+ break
79
+
80
+ for row in rows:
81
+ row_id = row[0] if not hasattr(row, "keys") else row["id"]
82
+ source_value = row[1] if not hasattr(row, "keys") else row["source"]
83
+ status = row[2] if not hasattr(row, "keys") else row["status"]
84
+ default_program = (
85
+ row[3] if not hasattr(row, "keys") else row["default_program"]
86
+ )
87
+ title = row[4] if not hasattr(row, "keys") else row["title"]
88
+ url = row[5] if not hasattr(row, "keys") else row["url"]
89
+ updated_at_raw = row[7] if not hasattr(row, "keys") else row["updated_at"]
90
+
91
+ observed_at = normalize_iso(updated_at_raw) or ctx.now
92
+ if not ctx.in_window(observed_at):
93
+ continue
94
+
95
+ event_type = _classify_event_type(source_value)
96
+ ref = _source_ref(str(row_id), source_value, url)
97
+ project, program = resolve_source_project(default_program)
98
+
99
+ evidence = {
100
+ "item_id": str(row_id),
101
+ "source_kind": source_value,
102
+ "status": status,
103
+ "project_hint": default_program,
104
+ "updated_at": observed_at.isoformat(),
105
+ }
106
+ if url:
107
+ evidence["url"] = url
108
+
109
+ yield EventDraft(
110
+ event_type=event_type,
111
+ source_system="ingest",
112
+ source_ref=ref,
113
+ title=(title or f"Ingest item {row_id}")[:200],
114
+ summary=f"{source_value or 'inbox'} item status={status}",
115
+ observed_at=observed_at,
116
+ derived_from_state_at=observed_at,
117
+ evidence=evidence,
118
+ source_project=project,
119
+ program_key=program,
120
+ )
121
+
122
+ if len(rows) < _PAGE_SIZE:
123
+ break
124
+ offset += _PAGE_SIZE
125
+
126
+
127
+ ingestor_collector = SourceCollector(source_system="ingest", collect=collect)
128
+
129
+
130
+ __all__ = ["collect", "ingestor_collector"]