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,800 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import logging
6
+ import uuid
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime, timezone
9
+ from email.utils import parseaddr, parsedate_to_datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+ from urllib.parse import urlsplit
13
+
14
+ import aiosqlite
15
+ import yaml
16
+
17
+ from core.api.config import settings
18
+ from core.api.use_cases._errors import (
19
+ AuthorizationError,
20
+ ConflictError,
21
+ ServiceError,
22
+ ValidationError,
23
+ )
24
+ from core.api.models.auth import UserInfo
25
+ from core.api.models.inbox import (
26
+ InboxIngestBatchItemError,
27
+ InboxIngestBatchRequest,
28
+ InboxIngestBatchResponse,
29
+ InboxIngestRequest,
30
+ InboxIngestResponse,
31
+ )
32
+ from core.api.services.inbox_taxonomy import (
33
+ infer_topic_from_metadata,
34
+ infer_treatment,
35
+ normalize_inbox_topic,
36
+ normalize_inbox_treatment,
37
+ )
38
+ from core.api.services.inbox_source_identity import (
39
+ HTTP_URL_RE as _HTTP_URL_RE,
40
+ is_gmail_hosted_url as _shared_is_gmail_hosted_url,
41
+ is_low_signal_source_key,
42
+ normalize_domain_key,
43
+ unwrap_tracking_url,
44
+ )
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+ _PROGRAM_SLUG_RE = r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"
49
+
50
+ _PUBLISHED_METADATA_KEYS = (
51
+ "published_at",
52
+ "publishedAt",
53
+ "published",
54
+ "pub_date",
55
+ "pubDate",
56
+ "updated_at",
57
+ "updatedAt",
58
+ "date",
59
+ )
60
+
61
+
62
+ @dataclass(slots=True)
63
+ class InboxSourceConfig:
64
+ name: str
65
+ enabled: bool = True
66
+ candidate_programs: list[str] = field(default_factory=list)
67
+ default_program: str | None = None
68
+ allowed_roots: list[str] = field(default_factory=list)
69
+
70
+
71
+ def _sanitize_text(value: str | None, *, max_length: int) -> str | None:
72
+ if value is None:
73
+ return None
74
+ sanitized = value.replace("\x00", "").replace("\r\n", "\n").strip()
75
+ if not sanitized:
76
+ return None
77
+ if len(sanitized) > max_length:
78
+ sanitized = sanitized[:max_length].rstrip()
79
+ return sanitized or None
80
+
81
+
82
+ def _sanitize_metadata(value: Any) -> Any:
83
+ if isinstance(value, dict):
84
+ return {str(k): _sanitize_metadata(v) for k, v in value.items()}
85
+ if isinstance(value, list):
86
+ return [_sanitize_metadata(item) for item in value]
87
+ if isinstance(value, str):
88
+ return value.replace("\x00", "").strip()
89
+ if value is None or isinstance(value, (bool, int, float)):
90
+ return value
91
+ return str(value)
92
+
93
+
94
+ def _load_source_configs() -> dict[str, InboxSourceConfig]:
95
+ raw = settings.inbox_sources_json.strip() or "{}"
96
+ try:
97
+ payload = json.loads(raw)
98
+ except json.JSONDecodeError as exc:
99
+ logger.error("Invalid inbox_sources_json: %s", exc)
100
+ return {}
101
+
102
+ if not isinstance(payload, dict):
103
+ return {}
104
+
105
+ configs: dict[str, InboxSourceConfig] = {}
106
+ for source_name, config in payload.items():
107
+ if not isinstance(config, dict):
108
+ continue
109
+ configs[source_name] = InboxSourceConfig(
110
+ name=source_name,
111
+ enabled=bool(config.get("enabled", True)),
112
+ candidate_programs=[
113
+ str(item) for item in config.get("candidate_programs", [])
114
+ ],
115
+ default_program=str(config["default_program"])
116
+ if config.get("default_program")
117
+ else None,
118
+ allowed_roots=[str(item) for item in config.get("allowed_roots", [])],
119
+ )
120
+ return configs
121
+
122
+
123
+ def get_source_config(source: str) -> InboxSourceConfig:
124
+ config = _load_source_configs().get(source)
125
+ if config is None or not config.enabled:
126
+ raise AuthorizationError(
127
+ code="inbox_source_disabled", message="Inbox source is not enabled"
128
+ )
129
+ return config
130
+
131
+
132
+ def _known_programs() -> set[str]:
133
+ yaml_path = Path.home() / "workspace" / "programs.yaml"
134
+ if not yaml_path.exists():
135
+ return set()
136
+ try:
137
+ programs = yaml.safe_load(yaml_path.read_text()) or {}
138
+ except Exception:
139
+ logger.exception("Failed to load programs.yaml for inbox ingest")
140
+ return set()
141
+ if not isinstance(programs, dict):
142
+ return set()
143
+ return {str(name) for name in programs.keys()}
144
+
145
+
146
+ def _validate_program_slug(program: str) -> str:
147
+ import re
148
+
149
+ if not re.match(_PROGRAM_SLUG_RE, program):
150
+ raise ValidationError(
151
+ code="invalid_program_slug", message=f"Invalid program slug: {program}"
152
+ )
153
+ return program
154
+
155
+
156
+ def _merge_program_metadata(
157
+ body: InboxIngestRequest,
158
+ config: InboxSourceConfig,
159
+ ) -> tuple[list[str], str | None]:
160
+ known = _known_programs()
161
+ requested = [_validate_program_slug(item) for item in body.candidate_programs]
162
+ configured = [_validate_program_slug(item) for item in config.candidate_programs]
163
+
164
+ for program in requested + configured:
165
+ if known and program not in known:
166
+ raise ValidationError(
167
+ code="unknown_program", message=f"Unknown program: {program}"
168
+ )
169
+
170
+ merged: list[str] = []
171
+ for program in requested + configured:
172
+ if program not in merged:
173
+ merged.append(program)
174
+
175
+ default_program = body.default_program or config.default_program
176
+ if default_program is not None:
177
+ default_program = _validate_program_slug(default_program)
178
+ if known and default_program not in known:
179
+ raise ValidationError(
180
+ code="unknown_program", message=f"Unknown program: {default_program}"
181
+ )
182
+ if default_program not in merged:
183
+ merged.insert(0, default_program)
184
+ elif merged:
185
+ default_program = merged[0]
186
+
187
+ return merged, default_program
188
+
189
+
190
+ def _normalize_url(url: str | None) -> str | None:
191
+ if url is None:
192
+ return None
193
+ parsed = urlsplit(url)
194
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
195
+ raise ValidationError(code="invalid_url", message="Invalid url")
196
+ return url
197
+
198
+
199
+ def _normalize_domain_key(raw: str | None) -> str | None:
200
+ return normalize_domain_key(raw)
201
+
202
+
203
+ def _normalize_sender_identity(raw: str | None) -> str | None:
204
+ if not raw:
205
+ return None
206
+ _, email = parseaddr(raw)
207
+ candidate = (email or raw).strip().lower()
208
+ if not candidate or "@" not in candidate:
209
+ return None
210
+ local, domain = candidate.rsplit("@", 1)
211
+ local = local.strip()
212
+ domain = domain.strip()
213
+ if not local or not domain:
214
+ return None
215
+ return f"{local}@{domain}"
216
+
217
+
218
+ def _iter_sender_values(value: Any) -> list[str]:
219
+ if isinstance(value, str):
220
+ return [value] if value.strip() else []
221
+ if isinstance(value, list):
222
+ values: list[str] = []
223
+ for entry in value:
224
+ values.extend(_iter_sender_values(entry))
225
+ return values
226
+ if not isinstance(value, dict):
227
+ return []
228
+
229
+ values = []
230
+ parsed_values = value.get("value")
231
+ if isinstance(parsed_values, list):
232
+ for entry in parsed_values:
233
+ if isinstance(entry, dict):
234
+ address = entry.get("address")
235
+ name = entry.get("name")
236
+ if isinstance(address, str) and address.strip():
237
+ if isinstance(name, str) and name.strip():
238
+ values.append(f"{name.strip()} <{address.strip()}>")
239
+ else:
240
+ values.append(address.strip())
241
+ elif isinstance(entry, str) and entry.strip():
242
+ values.append(entry.strip())
243
+
244
+ for key in ("text", "address", "email", "html"):
245
+ raw = value.get(key)
246
+ if isinstance(raw, str) and raw.strip():
247
+ values.append(raw.strip())
248
+ return values
249
+
250
+
251
+ def _extract_sender_domain(metadata: dict[str, Any]) -> str | None:
252
+ for key in ("sender", "from_email", "author", "source_author"):
253
+ for value in _iter_sender_values(metadata.get(key)):
254
+ _, email = parseaddr(value)
255
+ candidate = email or value.strip()
256
+ if "@" not in candidate:
257
+ continue
258
+ _, domain = candidate.rsplit("@", 1)
259
+ domain_key = _normalize_domain_key(domain)
260
+ if domain_key:
261
+ return domain_key
262
+ return None
263
+
264
+
265
+ def _extract_sender_identity(metadata: dict[str, Any]) -> str | None:
266
+ for key in ("sender", "from_email", "author", "source_author"):
267
+ for value in _iter_sender_values(metadata.get(key)):
268
+ sender_key = _normalize_sender_identity(value)
269
+ if sender_key:
270
+ return sender_key
271
+ return None
272
+
273
+
274
+ def _is_gmail_hosted_url(url: str | None) -> bool:
275
+ return _shared_is_gmail_hosted_url(url)
276
+
277
+
278
+ def _safe_normalize_url(url: str | None) -> str | None:
279
+ if not isinstance(url, str) or not url.strip():
280
+ return None
281
+ try:
282
+ return _normalize_url(url.strip())
283
+ except ServiceError:
284
+ return None
285
+
286
+
287
+ def _normalize_article_url_candidate(raw_url: str | None) -> str | None:
288
+ normalized = _safe_normalize_url(raw_url)
289
+ if not normalized or _is_gmail_hosted_url(normalized):
290
+ return None
291
+ unwrapped = _safe_normalize_url(unwrap_tracking_url(normalized))
292
+ if unwrapped and not _is_gmail_hosted_url(unwrapped):
293
+ return unwrapped
294
+ return normalized
295
+
296
+
297
+ def _prefer_real_article_url(
298
+ current: str | None,
299
+ candidate: str | None,
300
+ ) -> str | None:
301
+ if not candidate:
302
+ return current
303
+ if not is_low_signal_source_key(_normalize_domain_key(candidate)):
304
+ return candidate
305
+ return current or candidate
306
+
307
+
308
+ def _extract_url_from_text(text: str | None, current: str | None) -> str | None:
309
+ if not isinstance(text, str) or not text.strip():
310
+ return current
311
+ resolved = current
312
+ for match in _HTTP_URL_RE.finditer(text):
313
+ candidate = _normalize_article_url_candidate(match.group(0).rstrip(".,;:"))
314
+ resolved = _prefer_real_article_url(resolved, candidate)
315
+ if resolved and not is_low_signal_source_key(_normalize_domain_key(resolved)):
316
+ return resolved
317
+ return resolved
318
+
319
+
320
+ def _extract_article_url(metadata: dict[str, Any], content: str | None) -> str | None:
321
+ resolved: str | None = None
322
+ for key in (
323
+ "article_url",
324
+ "articleUrl",
325
+ "canonical_url",
326
+ "canonicalUrl",
327
+ "link",
328
+ "href",
329
+ "url",
330
+ ):
331
+ value = metadata.get(key)
332
+ candidate = _normalize_article_url_candidate(
333
+ value if isinstance(value, str) else None
334
+ )
335
+ resolved = _prefer_real_article_url(resolved, candidate)
336
+ if resolved and not is_low_signal_source_key(_normalize_domain_key(resolved)):
337
+ return resolved
338
+
339
+ links = metadata.get("links")
340
+ if isinstance(links, list):
341
+ for entry in links:
342
+ if isinstance(entry, str):
343
+ candidate = _normalize_article_url_candidate(entry)
344
+ elif isinstance(entry, dict):
345
+ candidate = _normalize_article_url_candidate(
346
+ entry.get("href") or entry.get("url") or entry.get("link")
347
+ )
348
+ else:
349
+ candidate = None
350
+ resolved = _prefer_real_article_url(resolved, candidate)
351
+ if resolved and not is_low_signal_source_key(
352
+ _normalize_domain_key(resolved)
353
+ ):
354
+ return resolved
355
+
356
+ for key in ("snippet", "summary", "description", "text", "body", "plain"):
357
+ resolved = _extract_url_from_text(
358
+ metadata.get(key) if isinstance(metadata.get(key), str) else None,
359
+ resolved,
360
+ )
361
+ if resolved and not is_low_signal_source_key(_normalize_domain_key(resolved)):
362
+ return resolved
363
+
364
+ resolved = _extract_url_from_text(content, resolved)
365
+ return resolved
366
+
367
+
368
+ def _resolve_item_url(
369
+ source: str,
370
+ normalized_url: str | None,
371
+ metadata: dict[str, Any],
372
+ content: str | None,
373
+ ) -> str | None:
374
+ if source != "gmail-marvisx":
375
+ return normalized_url
376
+ candidate = _normalize_article_url_candidate(normalized_url)
377
+ if candidate and not is_low_signal_source_key(_normalize_domain_key(candidate)):
378
+ return candidate
379
+
380
+ article_url = _extract_article_url(metadata, content)
381
+ if article_url and not is_low_signal_source_key(_normalize_domain_key(article_url)):
382
+ return article_url
383
+ return article_url or candidate
384
+
385
+
386
+ def _derive_domain_key(
387
+ source: str,
388
+ normalized_url: str | None,
389
+ metadata: dict[str, Any],
390
+ ) -> str:
391
+ if source == "gmail-marvisx":
392
+ article_domain_key = _normalize_domain_key(normalized_url)
393
+ if article_domain_key and not is_low_signal_source_key(article_domain_key):
394
+ return article_domain_key
395
+ return (
396
+ _extract_sender_identity(metadata)
397
+ or _extract_sender_domain(metadata)
398
+ or article_domain_key
399
+ or _normalize_domain_key(source)
400
+ or source.strip().lower()
401
+ )
402
+ return (
403
+ _normalize_domain_key(normalized_url)
404
+ or _extract_sender_domain(metadata)
405
+ or _normalize_domain_key(source)
406
+ or source.strip().lower()
407
+ )
408
+
409
+
410
+ def _coerce_timestamp(value: str | None) -> str | None:
411
+ if not value or not value.strip():
412
+ return None
413
+ candidate = value.strip()
414
+ try:
415
+ dt = datetime.fromisoformat(candidate.replace("Z", "+00:00"))
416
+ except ValueError:
417
+ try:
418
+ dt = parsedate_to_datetime(candidate)
419
+ except (TypeError, ValueError):
420
+ return None
421
+ if dt.tzinfo is None:
422
+ dt = dt.replace(tzinfo=timezone.utc)
423
+ else:
424
+ dt = dt.astimezone(timezone.utc)
425
+ return dt.isoformat()
426
+
427
+
428
+ def _derive_published_at(
429
+ explicit_published_at: str | None,
430
+ metadata: dict[str, Any],
431
+ ) -> str | None:
432
+ published_at = _coerce_timestamp(explicit_published_at)
433
+ if published_at:
434
+ return published_at
435
+ for key in _PUBLISHED_METADATA_KEYS:
436
+ raw = metadata.get(key)
437
+ if not isinstance(raw, str):
438
+ continue
439
+ published_at = _coerce_timestamp(raw)
440
+ if published_at:
441
+ return published_at
442
+ return None
443
+
444
+
445
+ def _resolve_source_path(raw_path: str | None, config: InboxSourceConfig) -> str | None:
446
+ if raw_path is None:
447
+ return None
448
+ if not config.allowed_roots:
449
+ raise ValidationError(
450
+ code="source_path_not_enabled",
451
+ message="Source paths are not enabled for this source",
452
+ )
453
+
454
+ candidate = Path(raw_path).expanduser()
455
+ if not candidate.is_absolute():
456
+ raise ValidationError(
457
+ code="source_path_not_absolute", message="source_path must be absolute"
458
+ )
459
+ if candidate.is_symlink():
460
+ raise AuthorizationError(
461
+ code="source_path_symlink", message="Symlink source_path is not allowed"
462
+ )
463
+
464
+ resolved = candidate.resolve()
465
+ if not resolved.exists() or not resolved.is_file():
466
+ raise ValidationError(
467
+ code="source_path_not_a_file",
468
+ message="source_path must point to an existing file",
469
+ )
470
+
471
+ for root_str in config.allowed_roots:
472
+ root = Path(root_str).expanduser().resolve()
473
+ if resolved == root or resolved.is_relative_to(root):
474
+ return str(resolved)
475
+
476
+ raise AuthorizationError(
477
+ code="source_path_outside_roots", message="source_path is outside allowed roots"
478
+ )
479
+
480
+
481
+ def _build_dedup_key(
482
+ source: str,
483
+ source_item_id: str | None,
484
+ title: str | None,
485
+ content: str | None,
486
+ url: str | None,
487
+ source_path: str | None,
488
+ ) -> str:
489
+ if source_item_id:
490
+ material = f"{source}|item|{source_item_id}"
491
+ else:
492
+ material = json.dumps(
493
+ {
494
+ "source": source,
495
+ "title": title,
496
+ "content": content,
497
+ "url": url,
498
+ "source_path": source_path,
499
+ },
500
+ sort_keys=True,
501
+ ensure_ascii=True,
502
+ )
503
+ return hashlib.sha256(material.encode("utf-8")).hexdigest()
504
+
505
+
506
+ def _row_to_response(row: aiosqlite.Row, *, deduplicated: bool) -> InboxIngestResponse:
507
+ candidate_programs = json.loads(row["candidate_programs"] or "[]")
508
+ return InboxIngestResponse(
509
+ id=row["id"],
510
+ source=row["source"],
511
+ deduplicated=deduplicated,
512
+ status=row["status"],
513
+ title=row["title"],
514
+ source_item_id=row["source_item_id"],
515
+ source_path=row["source_path"],
516
+ candidate_programs=candidate_programs,
517
+ default_program=row["default_program"],
518
+ topic=row["topic"],
519
+ treatment=row["treatment"],
520
+ created_at=row["created_at"],
521
+ )
522
+
523
+
524
+ # Auto-status from treatment (module-level: shared by single + batch ingest).
525
+ _TREATMENT_TO_STATUS = {
526
+ "read": "unread",
527
+ "read_save": "unread",
528
+ "save": "saved",
529
+ "ignore": "auto_ignored",
530
+ }
531
+
532
+
533
+ _INSERT_INBOX_ITEM_SQL = (
534
+ "INSERT INTO inbox_items ("
535
+ "id, source, source_item_id, dedup_key, status, title, content, url, domain_key, published_at, freshness_at, source_path, "
536
+ "metadata_json, candidate_programs, default_program, topic, treatment, created_by, workspace_id, created_at, updated_at"
537
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
538
+ )
539
+
540
+
541
+ def _prepare_insert(
542
+ body: InboxIngestRequest, *, user: UserInfo
543
+ ) -> tuple[str, tuple, str, str]:
544
+ """Compute sanitized fields for an inbox INSERT.
545
+
546
+ Returns (item_id, insert_args, dedup_key, workspace_id). Raises
547
+ ``ValidationError`` (422) on metadata oversize. Shared by `ingest_item` and
548
+ `ingest_items_batch`.
549
+ """
550
+ config = get_source_config(body.source)
551
+
552
+ title = _sanitize_text(body.title, max_length=settings.inbox_max_title_chars)
553
+ content = _sanitize_text(body.content, max_length=settings.inbox_max_content_chars)
554
+ source_item_id = _sanitize_text(body.source_item_id, max_length=255)
555
+ normalized_url = _normalize_url(_sanitize_text(body.url, max_length=2000))
556
+ metadata = _sanitize_metadata(body.metadata)
557
+
558
+ candidate_programs, default_program = _merge_program_metadata(body, config)
559
+ source_path = _resolve_source_path(
560
+ _sanitize_text(body.source_path, max_length=2000), config
561
+ )
562
+ metadata_dict = dict(metadata) if isinstance(metadata, dict) else {}
563
+ if body.source == "gmail-marvisx" and _is_gmail_hosted_url(normalized_url):
564
+ metadata_dict.setdefault("gmail_thread_url", normalized_url)
565
+ canonical_url = _resolve_item_url(
566
+ body.source, normalized_url, metadata_dict, content
567
+ )
568
+ domain_key = _derive_domain_key(body.source, canonical_url, metadata_dict)
569
+ published_at = _derive_published_at(body.published_at, metadata_dict)
570
+ metadata_json = json.dumps(metadata_dict, sort_keys=True, ensure_ascii=True)
571
+ if len(metadata_json.encode("utf-8")) > settings.inbox_max_metadata_bytes:
572
+ raise ValidationError(
573
+ code="metadata_too_large", message="metadata is too large"
574
+ )
575
+ topic = (
576
+ normalize_inbox_topic(body.topic)
577
+ if body.topic
578
+ else infer_topic_from_metadata(metadata_dict, title=title, content=content)
579
+ )
580
+ treatment = (
581
+ normalize_inbox_treatment(body.treatment)
582
+ if body.treatment
583
+ else infer_treatment(topic, metadata_dict)
584
+ )
585
+ dedup_key = _build_dedup_key(
586
+ body.source,
587
+ source_item_id,
588
+ title,
589
+ content,
590
+ canonical_url,
591
+ source_path,
592
+ )
593
+
594
+ now = datetime.now(timezone.utc).isoformat()
595
+ freshness_at = published_at or now
596
+ item_id = f"inbox_{uuid.uuid4().hex[:24]}"
597
+ candidate_programs_json = json.dumps(candidate_programs, ensure_ascii=True)
598
+ auto_status = _TREATMENT_TO_STATUS.get(treatment, "unread")
599
+ workspace_id = user.workspace_id or "ws_default"
600
+
601
+ args = (
602
+ item_id,
603
+ body.source,
604
+ source_item_id,
605
+ dedup_key,
606
+ auto_status,
607
+ title,
608
+ content,
609
+ canonical_url,
610
+ domain_key,
611
+ published_at,
612
+ freshness_at,
613
+ source_path,
614
+ metadata_json,
615
+ candidate_programs_json,
616
+ default_program,
617
+ topic,
618
+ treatment,
619
+ user.username,
620
+ workspace_id,
621
+ now,
622
+ now,
623
+ )
624
+ return item_id, args, dedup_key, workspace_id
625
+
626
+
627
+ async def ingest_item(
628
+ body: InboxIngestRequest,
629
+ *,
630
+ user: UserInfo,
631
+ db: aiosqlite.Connection,
632
+ ) -> InboxIngestResponse:
633
+ item_id, args, dedup_key, workspace_id = _prepare_insert(body, user=user)
634
+
635
+ try:
636
+ await db.execute(_INSERT_INBOX_ITEM_SQL, args)
637
+ await db.commit()
638
+ except aiosqlite.IntegrityError:
639
+ cursor = await db.execute(
640
+ "SELECT * FROM inbox_items WHERE source = ? AND dedup_key = ? AND COALESCE(workspace_id, 'ws_default') = ?",
641
+ (body.source, dedup_key, workspace_id),
642
+ )
643
+ existing = await cursor.fetchone()
644
+ if existing is None:
645
+ raise ConflictError(
646
+ code="duplicate_inbox_item", message="Duplicate inbox item"
647
+ )
648
+ return _row_to_response(existing, deduplicated=True)
649
+
650
+ cursor = await db.execute("SELECT * FROM inbox_items WHERE id = ?", (item_id,))
651
+ row = await cursor.fetchone()
652
+ if row is None:
653
+ err = ServiceError(
654
+ code="inbox_item_lost",
655
+ message="Inbox item created but not found",
656
+ )
657
+ err.http_status = 500 # preserve the original 500 status (was a 500 raise)
658
+ raise err
659
+
660
+ # PR B (2026-04-11): schedule async classification as a write-ahead task.
661
+ # The item is already persisted with status='unread'. The background task
662
+ # will update status/metadata when the LLM responds (or fall back). This
663
+ # call MUST never raise to the caller — ingest stays non-blocking.
664
+ try:
665
+ from core.api.services.inbox_llm_classifier import schedule_classification
666
+
667
+ schedule_classification(
668
+ settings.db_path,
669
+ workspace_id,
670
+ item_id,
671
+ )
672
+ except Exception: # noqa: BLE001
673
+ logger.exception("Failed to schedule classifier for item %s", item_id)
674
+
675
+ return _row_to_response(row, deduplicated=False)
676
+
677
+
678
+ async def ingest_items_batch(
679
+ body: InboxIngestBatchRequest,
680
+ *,
681
+ user: UserInfo,
682
+ db: aiosqlite.Connection,
683
+ ) -> InboxIngestBatchResponse:
684
+ """Batch-ingest N items in a single write lock + single commit.
685
+
686
+ Rationale: n8n RSS fan-out previously fired N separate POST /ingest requests
687
+ per poll, each acquiring the process-wide `_write_lock` + doing an fsync.
688
+ With N ~800 this saturated disk IO and stalled readers via busy_timeout
689
+ (see session 2026-04-24 handoff). This endpoint folds that burst into one
690
+ transaction: ONE lock acquisition, ONE fsync, per-item SAVEPOINT so a
691
+ single constraint violation doesn't poison the rest of the batch.
692
+
693
+ Per-item errors (validation, dedup collision) are collected into the
694
+ response rather than aborting the whole batch — the caller keeps forward
695
+ progress on good items.
696
+ """
697
+ inserted: list[InboxIngestResponse] = []
698
+ deduplicated: list[InboxIngestResponse] = []
699
+ errors: list[InboxIngestBatchItemError] = []
700
+ fresh_item_ids: list[tuple[str, str]] = [] # (item_id, workspace_id)
701
+
702
+ for idx, item in enumerate(body.items):
703
+ try:
704
+ item_id, args, dedup_key, workspace_id = _prepare_insert(item, user=user)
705
+ except ServiceError as exc:
706
+ errors.append(
707
+ InboxIngestBatchItemError(
708
+ index=idx,
709
+ source=item.source,
710
+ source_item_id=item.source_item_id,
711
+ error=str(exc.message),
712
+ )
713
+ )
714
+ continue
715
+ except Exception as exc: # noqa: BLE001
716
+ logger.exception("Failed to prepare inbox batch item %d", idx)
717
+ errors.append(
718
+ InboxIngestBatchItemError(
719
+ index=idx,
720
+ source=item.source,
721
+ source_item_id=item.source_item_id,
722
+ error=f"prepare_error: {exc}",
723
+ )
724
+ )
725
+ continue
726
+
727
+ savepoint = f"sp_ingest_{idx}"
728
+ await db.execute(f"SAVEPOINT {savepoint}")
729
+ try:
730
+ await db.execute(_INSERT_INBOX_ITEM_SQL, args)
731
+ except aiosqlite.IntegrityError:
732
+ await db.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
733
+ await db.execute(f"RELEASE SAVEPOINT {savepoint}")
734
+ cursor = await db.execute(
735
+ "SELECT * FROM inbox_items WHERE source = ? AND dedup_key = ? AND COALESCE(workspace_id, 'ws_default') = ?",
736
+ (item.source, dedup_key, workspace_id),
737
+ )
738
+ existing = await cursor.fetchone()
739
+ if existing is None:
740
+ errors.append(
741
+ InboxIngestBatchItemError(
742
+ index=idx,
743
+ source=item.source,
744
+ source_item_id=item.source_item_id,
745
+ error="integrity_error_no_match",
746
+ )
747
+ )
748
+ continue
749
+ deduplicated.append(_row_to_response(existing, deduplicated=True))
750
+ continue
751
+ except Exception as exc: # noqa: BLE001
752
+ await db.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
753
+ await db.execute(f"RELEASE SAVEPOINT {savepoint}")
754
+ logger.exception("Failed to INSERT inbox batch item %d", idx)
755
+ errors.append(
756
+ InboxIngestBatchItemError(
757
+ index=idx,
758
+ source=item.source,
759
+ source_item_id=item.source_item_id,
760
+ error=f"insert_error: {exc}",
761
+ )
762
+ )
763
+ continue
764
+
765
+ await db.execute(f"RELEASE SAVEPOINT {savepoint}")
766
+ fresh_item_ids.append((item_id, workspace_id))
767
+
768
+ # Single commit for all successful inserts in the batch.
769
+ await db.commit()
770
+
771
+ # Re-read rows + schedule classification outside the insert loop to keep
772
+ # the write lock held for the shortest possible duration.
773
+ for item_id, workspace_id in fresh_item_ids:
774
+ cursor = await db.execute(
775
+ "SELECT * FROM inbox_items WHERE id = ?", (item_id,)
776
+ )
777
+ row = await cursor.fetchone()
778
+ if row is None:
779
+ logger.warning("Inbox batch item %s inserted but not found post-commit", item_id)
780
+ continue
781
+ inserted.append(_row_to_response(row, deduplicated=False))
782
+
783
+ try:
784
+ from core.api.services.inbox_llm_classifier import schedule_classification
785
+
786
+ schedule_classification(settings.db_path, workspace_id, item_id)
787
+ except Exception: # noqa: BLE001
788
+ logger.exception("Failed to schedule classifier for item %s", item_id)
789
+
790
+ return InboxIngestBatchResponse(
791
+ inserted=inserted,
792
+ deduplicated=deduplicated,
793
+ errors=errors,
794
+ counts={
795
+ "received": len(body.items),
796
+ "inserted": len(inserted),
797
+ "deduplicated": len(deduplicated),
798
+ "errors": len(errors),
799
+ },
800
+ )