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,116 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import re
6
+ from typing import Any
7
+ from urllib.parse import parse_qs, urlsplit
8
+
9
+ HTTP_URL_RE = re.compile(r"https?://[^\s<>'\")]+")
10
+ GMAIL_HOSTS = {"mail.google.com", "gmail.com"}
11
+ LOW_SIGNAL_SOURCE_KEYS = {"substack.com", "mail.google.com", "gmail.com"}
12
+
13
+
14
+ def normalize_domain_key(raw: str | None) -> str | None:
15
+ if not raw:
16
+ return None
17
+ try:
18
+ parsed = urlsplit(raw)
19
+ if parsed.netloc:
20
+ raw = parsed.netloc
21
+ except Exception: # noqa: BLE001
22
+ pass
23
+ key = raw.strip().lower()
24
+ if key.startswith("www."):
25
+ key = key.removeprefix("www.")
26
+ return key or None
27
+
28
+
29
+ def is_gmail_hosted_url(url: str | None) -> bool:
30
+ if not url:
31
+ return False
32
+ try:
33
+ parsed = urlsplit(url)
34
+ except Exception: # noqa: BLE001
35
+ return False
36
+ return normalize_domain_key(parsed.netloc) in GMAIL_HOSTS
37
+
38
+
39
+ def is_low_signal_source_key(source_key: str | None) -> bool:
40
+ normalized = normalize_domain_key(source_key)
41
+ return bool(
42
+ normalized
43
+ and (
44
+ normalized in LOW_SIGNAL_SOURCE_KEYS
45
+ or normalized.endswith("@substack.com")
46
+ )
47
+ )
48
+
49
+
50
+ def unwrap_tracking_url(url: str | None) -> str | None:
51
+ if not isinstance(url, str) or not url.strip():
52
+ return None
53
+
54
+ raw_url = url.strip()
55
+ try:
56
+ parsed = urlsplit(raw_url)
57
+ except Exception: # noqa: BLE001
58
+ return raw_url
59
+
60
+ if normalize_domain_key(parsed.netloc) != "substack.com":
61
+ return raw_url
62
+
63
+ path_parts = [part for part in parsed.path.split("/") if part]
64
+ if len(path_parts) < 3 or path_parts[0] != "redirect":
65
+ return raw_url
66
+
67
+ payload = _decode_substack_redirect_payload(path_parts[-1])
68
+ if not isinstance(payload, dict):
69
+ return raw_url
70
+
71
+ event_url = payload.get("e")
72
+ if not isinstance(event_url, str):
73
+ return raw_url
74
+
75
+ next_url = _extract_query_url(event_url, "next")
76
+ if next_url:
77
+ return next_url
78
+ if _is_http_url(event_url):
79
+ return event_url
80
+ return raw_url
81
+
82
+
83
+ def _decode_substack_redirect_payload(encoded_segment: str) -> dict[str, Any] | None:
84
+ encoded_payload = encoded_segment.split(".", 1)[0]
85
+ if not encoded_payload:
86
+ return None
87
+ try:
88
+ padded_payload = encoded_payload + "=" * (-len(encoded_payload) % 4)
89
+ decoded = base64.urlsafe_b64decode(padded_payload.encode("ascii")).decode(
90
+ "utf-8"
91
+ )
92
+ payload = json.loads(decoded)
93
+ except Exception: # noqa: BLE001
94
+ return None
95
+ return payload if isinstance(payload, dict) else None
96
+
97
+
98
+ def _extract_query_url(url: str, key: str) -> str | None:
99
+ try:
100
+ values = parse_qs(urlsplit(url).query).get(key, [])
101
+ except Exception: # noqa: BLE001
102
+ return None
103
+ for value in values:
104
+ if _is_http_url(value):
105
+ return value
106
+ return None
107
+
108
+
109
+ def _is_http_url(url: str | None) -> bool:
110
+ if not isinstance(url, str) or not url.strip():
111
+ return False
112
+ try:
113
+ parsed = urlsplit(url.strip())
114
+ except Exception: # noqa: BLE001
115
+ return False
116
+ return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
@@ -0,0 +1,456 @@
1
+ # v1.0.0 - 2026-04-11 - PR B: sources CRUD + metrics + SSRF guard
2
+ """Service for inbox sources catalog (CRUD + metrics).
3
+
4
+ Tables used: inbox_sources (from migration 061), inbox_items, source_scores.
5
+
6
+ The service returns plain dicts so that FastAPI can serialize them directly
7
+ without an extra Pydantic layer. Keep the shape stable: the frontend (PR D)
8
+ consumes these fields verbatim.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import ipaddress
13
+ import logging
14
+ import socket
15
+ import uuid
16
+ from datetime import datetime, timedelta, timezone
17
+ from typing import Literal
18
+ from urllib.parse import urlparse
19
+
20
+ import aiosqlite
21
+ from fastapi import HTTPException
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ MetricRange = Literal["24h", "7d", "30d", "total"]
26
+ _VALID_RANGES: frozenset[str] = frozenset({"24h", "7d", "30d", "total"})
27
+ _VALID_SOURCE_TYPES: frozenset[str] = frozenset({"rss", "email", "manual", "api", "legacy"})
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # SSRF prevention
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ def validate_public_url(url: str) -> None:
36
+ """Raise HTTPException 422 if URL points to a private/loopback/internal address.
37
+
38
+ Blocks: non-http(s) schemes, missing hostname, unresolvable hostnames, and
39
+ hostnames that resolve to private / loopback / link-local / reserved /
40
+ multicast IP ranges (including the AWS metadata 169.254.169.254).
41
+ """
42
+ try:
43
+ parsed = urlparse(url)
44
+ except Exception as exc: # noqa: BLE001
45
+ raise HTTPException(status_code=422, detail="Invalid URL") from exc
46
+ if parsed.scheme not in ("http", "https"):
47
+ raise HTTPException(status_code=422, detail="Only http(s) URLs allowed")
48
+ hostname = parsed.hostname
49
+ if not hostname:
50
+ raise HTTPException(status_code=422, detail="Missing hostname")
51
+ try:
52
+ infos = socket.getaddrinfo(hostname, None)
53
+ except socket.gaierror as exc:
54
+ raise HTTPException(status_code=422, detail="Cannot resolve hostname") from exc
55
+ for info in infos:
56
+ ip_str = info[4][0]
57
+ try:
58
+ ip = ipaddress.ip_address(ip_str)
59
+ except ValueError:
60
+ continue
61
+ if (
62
+ ip.is_private
63
+ or ip.is_loopback
64
+ or ip.is_link_local
65
+ or ip.is_reserved
66
+ or ip.is_multicast
67
+ or ip.is_unspecified
68
+ ):
69
+ raise HTTPException(
70
+ status_code=422,
71
+ detail=f"URL resolves to non-public address: {ip_str}",
72
+ )
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Helpers
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ def _normalize_source_key(raw: str) -> str:
81
+ """Normalize source_key the same way inbox_triage._update_source_score does.
82
+
83
+ Strip whitespace, lowercase, drop 'www.' prefix. This ensures joins with
84
+ source_scores work across sources that were registered with/without 'www.'.
85
+ """
86
+ key = (raw or "").strip().lower()
87
+ # If it looks like a URL, extract the netloc
88
+ try:
89
+ parsed = urlparse(key)
90
+ if parsed.netloc:
91
+ key = parsed.netloc
92
+ except Exception:
93
+ pass
94
+ if key.startswith("www."):
95
+ key = key.removeprefix("www.")
96
+ return key
97
+
98
+
99
+ def _row_to_dict(row: aiosqlite.Row) -> dict:
100
+ """Convert aiosqlite.Row (or any Mapping) to a plain dict."""
101
+ return {k: row[k] for k in row.keys()}
102
+
103
+
104
+ def _range_to_cutoff(range_value: str) -> str | None:
105
+ """Return an ISO timestamp cutoff for a given range, or None for 'total'."""
106
+ if range_value == "total":
107
+ return None
108
+ now = datetime.now(timezone.utc)
109
+ if range_value == "24h":
110
+ cutoff = now - timedelta(hours=24)
111
+ elif range_value == "7d":
112
+ cutoff = now - timedelta(days=7)
113
+ elif range_value == "30d":
114
+ cutoff = now - timedelta(days=30)
115
+ else:
116
+ return None
117
+ return cutoff.isoformat()
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # CRUD
122
+ # ---------------------------------------------------------------------------
123
+
124
+
125
+ async def list_sources(
126
+ db: aiosqlite.Connection,
127
+ workspace_id: str,
128
+ *,
129
+ active_only: bool = False,
130
+ ) -> list[dict]:
131
+ """Return all sources with inline aggregate metrics.
132
+
133
+ One SQL query with LEFT JOINs to avoid N+1. Each row contains:
134
+ - base source columns
135
+ - total_items, unread_count, auto_ignored_count (from inbox_items)
136
+ - score, upvotes, downvotes, reads (from source_scores)
137
+ """
138
+ where_active = "AND s.active = 1" if active_only else ""
139
+
140
+ query = f"""
141
+ SELECT
142
+ s.id,
143
+ s.name,
144
+ s.source_key,
145
+ s.feed_url,
146
+ s.source_type,
147
+ s.active,
148
+ s.last_fetch_at,
149
+ s.last_fetch_error,
150
+ s.workspace_id,
151
+ s.created_at,
152
+ s.updated_at,
153
+ COALESCE(items.total_items, 0) AS total_items,
154
+ COALESCE(items.unread_count, 0) AS unread_count,
155
+ COALESCE(items.auto_ignored_count, 0) AS auto_ignored_count,
156
+ COALESCE(scores.score, 0) AS score,
157
+ COALESCE(scores.upvotes, 0) AS upvotes,
158
+ COALESCE(scores.downvotes, 0) AS downvotes,
159
+ COALESCE(scores.reads, 0) AS reads
160
+ FROM inbox_sources s
161
+ LEFT JOIN (
162
+ SELECT
163
+ REPLACE(
164
+ LOWER(
165
+ SUBSTR(
166
+ REPLACE(REPLACE(url, 'https://', ''), 'http://', ''),
167
+ 1,
168
+ CASE
169
+ WHEN INSTR(REPLACE(REPLACE(url, 'https://', ''), 'http://', ''), '/') > 0
170
+ THEN INSTR(REPLACE(REPLACE(url, 'https://', ''), 'http://', ''), '/') - 1
171
+ ELSE LENGTH(REPLACE(REPLACE(url, 'https://', ''), 'http://', ''))
172
+ END
173
+ )
174
+ ),
175
+ 'www.', ''
176
+ ) AS domain_key,
177
+ COUNT(*) AS total_items,
178
+ SUM(CASE WHEN status = 'unread' THEN 1 ELSE 0 END) AS unread_count,
179
+ SUM(CASE WHEN status = 'auto_ignored' THEN 1 ELSE 0 END) AS auto_ignored_count,
180
+ workspace_id AS ws
181
+ FROM inbox_items
182
+ WHERE url IS NOT NULL AND url != ''
183
+ GROUP BY domain_key, workspace_id
184
+ ) items ON items.domain_key = s.source_key AND items.ws = s.workspace_id
185
+ LEFT JOIN source_scores scores
186
+ ON scores.source_key = s.source_key
187
+ AND scores.workspace_id = s.workspace_id
188
+ WHERE s.workspace_id = ?
189
+ {where_active}
190
+ ORDER BY s.name ASC
191
+ """
192
+
193
+ cursor = await db.execute(query, (workspace_id,))
194
+ rows = await cursor.fetchall()
195
+ return [_row_to_dict(r) for r in rows]
196
+
197
+
198
+ async def get_source(
199
+ db: aiosqlite.Connection,
200
+ workspace_id: str,
201
+ source_id: str,
202
+ ) -> dict | None:
203
+ """Return a single source row with the same aggregate shape as list_sources."""
204
+ query = """
205
+ SELECT
206
+ s.id, s.name, s.source_key, s.feed_url, s.source_type,
207
+ s.active, s.last_fetch_at, s.last_fetch_error,
208
+ s.workspace_id, s.created_at, s.updated_at,
209
+ COALESCE(items.total_items, 0) AS total_items,
210
+ COALESCE(items.unread_count, 0) AS unread_count,
211
+ COALESCE(items.auto_ignored_count, 0) AS auto_ignored_count,
212
+ COALESCE(scores.score, 0) AS score,
213
+ COALESCE(scores.upvotes, 0) AS upvotes,
214
+ COALESCE(scores.downvotes, 0) AS downvotes,
215
+ COALESCE(scores.reads, 0) AS reads
216
+ FROM inbox_sources s
217
+ LEFT JOIN (
218
+ SELECT
219
+ REPLACE(
220
+ LOWER(
221
+ SUBSTR(
222
+ REPLACE(REPLACE(url, 'https://', ''), 'http://', ''),
223
+ 1,
224
+ CASE
225
+ WHEN INSTR(REPLACE(REPLACE(url, 'https://', ''), 'http://', ''), '/') > 0
226
+ THEN INSTR(REPLACE(REPLACE(url, 'https://', ''), 'http://', ''), '/') - 1
227
+ ELSE LENGTH(REPLACE(REPLACE(url, 'https://', ''), 'http://', ''))
228
+ END
229
+ )
230
+ ),
231
+ 'www.', ''
232
+ ) AS domain_key,
233
+ COUNT(*) AS total_items,
234
+ SUM(CASE WHEN status = 'unread' THEN 1 ELSE 0 END) AS unread_count,
235
+ SUM(CASE WHEN status = 'auto_ignored' THEN 1 ELSE 0 END) AS auto_ignored_count,
236
+ workspace_id AS ws
237
+ FROM inbox_items
238
+ WHERE url IS NOT NULL AND url != ''
239
+ GROUP BY domain_key, workspace_id
240
+ ) items ON items.domain_key = s.source_key AND items.ws = s.workspace_id
241
+ LEFT JOIN source_scores scores
242
+ ON scores.source_key = s.source_key
243
+ AND scores.workspace_id = s.workspace_id
244
+ WHERE s.id = ? AND s.workspace_id = ?
245
+ """
246
+ cursor = await db.execute(query, (source_id, workspace_id))
247
+ row = await cursor.fetchone()
248
+ return _row_to_dict(row) if row else None
249
+
250
+
251
+ async def create_source(
252
+ db: aiosqlite.Connection,
253
+ workspace_id: str,
254
+ *,
255
+ name: str,
256
+ source_key: str,
257
+ feed_url: str | None = None,
258
+ source_type: str = "rss",
259
+ ) -> dict:
260
+ """Insert a new source. 409 on duplicate (workspace_id, source_key)."""
261
+ if source_type not in _VALID_SOURCE_TYPES:
262
+ raise HTTPException(
263
+ status_code=422,
264
+ detail=f"Invalid source_type: {source_type}",
265
+ )
266
+ normalized_key = _normalize_source_key(source_key)
267
+ if not normalized_key:
268
+ raise HTTPException(status_code=422, detail="source_key cannot be empty")
269
+
270
+ source_id = f"src_{uuid.uuid4().hex[:24]}"
271
+ now = datetime.now(timezone.utc).isoformat()
272
+
273
+ try:
274
+ await db.execute(
275
+ "INSERT INTO inbox_sources "
276
+ "(id, name, source_key, feed_url, source_type, active, "
277
+ "workspace_id, created_at, updated_at) "
278
+ "VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)",
279
+ (
280
+ source_id,
281
+ name.strip(),
282
+ normalized_key,
283
+ feed_url,
284
+ source_type,
285
+ workspace_id,
286
+ now,
287
+ now,
288
+ ),
289
+ )
290
+ await db.commit()
291
+ except aiosqlite.IntegrityError as exc:
292
+ raise HTTPException(
293
+ status_code=409,
294
+ detail=f"Source already exists with source_key={normalized_key!r}",
295
+ ) from exc
296
+
297
+ result = await get_source(db, workspace_id, source_id)
298
+ if result is None:
299
+ raise HTTPException(status_code=500, detail="Source created but not found")
300
+ return result
301
+
302
+
303
+ async def update_source(
304
+ db: aiosqlite.Connection,
305
+ workspace_id: str,
306
+ source_id: str,
307
+ *,
308
+ name: str | None = None,
309
+ feed_url: str | None = None,
310
+ active: bool | None = None,
311
+ ) -> dict:
312
+ """Selectively update a source. Returns the updated row."""
313
+ existing = await get_source(db, workspace_id, source_id)
314
+ if existing is None:
315
+ raise HTTPException(status_code=404, detail="Source not found")
316
+
317
+ updates: list[str] = []
318
+ params: list[object] = []
319
+ if name is not None:
320
+ updates.append("name = ?")
321
+ params.append(name.strip())
322
+ if feed_url is not None:
323
+ updates.append("feed_url = ?")
324
+ params.append(feed_url)
325
+ if active is not None:
326
+ updates.append("active = ?")
327
+ params.append(1 if active else 0)
328
+
329
+ if not updates:
330
+ return existing
331
+
332
+ updates.append("updated_at = ?")
333
+ params.append(datetime.now(timezone.utc).isoformat())
334
+ params.append(source_id)
335
+ params.append(workspace_id)
336
+
337
+ await db.execute(
338
+ f"UPDATE inbox_sources SET {', '.join(updates)} "
339
+ "WHERE id = ? AND workspace_id = ?",
340
+ tuple(params),
341
+ )
342
+ await db.commit()
343
+
344
+ result = await get_source(db, workspace_id, source_id)
345
+ if result is None: # pragma: no cover - defensive
346
+ raise HTTPException(status_code=500, detail="Source disappeared after update")
347
+ return result
348
+
349
+
350
+ async def delete_source(
351
+ db: aiosqlite.Connection,
352
+ workspace_id: str,
353
+ source_id: str,
354
+ ) -> None:
355
+ """Soft delete: set active = 0. Keeps historical data intact."""
356
+ existing = await get_source(db, workspace_id, source_id)
357
+ if existing is None:
358
+ raise HTTPException(status_code=404, detail="Source not found")
359
+
360
+ await db.execute(
361
+ "UPDATE inbox_sources SET active = 0, updated_at = ? "
362
+ "WHERE id = ? AND workspace_id = ?",
363
+ (datetime.now(timezone.utc).isoformat(), source_id, workspace_id),
364
+ )
365
+ await db.commit()
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # Metrics
370
+ # ---------------------------------------------------------------------------
371
+
372
+
373
+ async def get_source_metrics(
374
+ db: aiosqlite.Connection,
375
+ workspace_id: str,
376
+ source_id: str,
377
+ *,
378
+ range: MetricRange = "total", # noqa: A002 - deliberate shadow of builtin for API ergonomics
379
+ ) -> dict:
380
+ """Return detailed metrics for a source, filtered by a time range.
381
+
382
+ Range options:
383
+ - "24h" -> items created in last 24h
384
+ - "7d" -> items created in last 7d
385
+ - "30d" -> items created in last 30d
386
+ - "total"-> all-time (default)
387
+ """
388
+ if range not in _VALID_RANGES:
389
+ raise HTTPException(status_code=422, detail=f"Invalid range: {range}")
390
+
391
+ source_row = await (
392
+ await db.execute(
393
+ "SELECT source_key FROM inbox_sources "
394
+ "WHERE id = ? AND workspace_id = ?",
395
+ (source_id, workspace_id),
396
+ )
397
+ ).fetchone()
398
+ if source_row is None:
399
+ raise HTTPException(status_code=404, detail="Source not found")
400
+ source_key = source_row["source_key"]
401
+
402
+ cutoff = _range_to_cutoff(range)
403
+ where_items = "source = ? AND workspace_id = ?"
404
+ params: list[object] = [source_key, workspace_id]
405
+ if cutoff is not None:
406
+ where_items += " AND created_at >= ?"
407
+ params.append(cutoff)
408
+
409
+ row = await (
410
+ await db.execute(
411
+ f"""
412
+ SELECT
413
+ COUNT(*) AS total,
414
+ SUM(CASE WHEN status = 'unread' THEN 1 ELSE 0 END) AS unread,
415
+ SUM(CASE WHEN status = 'read' THEN 1 ELSE 0 END) AS read_items,
416
+ SUM(CASE WHEN status = 'saved' THEN 1 ELSE 0 END) AS saved,
417
+ SUM(CASE WHEN status = 'newsletter' THEN 1 ELSE 0 END) AS newsletter,
418
+ SUM(CASE WHEN status = 'preferred' THEN 1 ELSE 0 END) AS preferred,
419
+ SUM(CASE WHEN status = 'idea' THEN 1 ELSE 0 END) AS idea,
420
+ SUM(CASE WHEN status = 'ignored' THEN 1 ELSE 0 END) AS ignored,
421
+ SUM(CASE WHEN status = 'auto_ignored' THEN 1 ELSE 0 END) AS auto_ignored
422
+ FROM inbox_items
423
+ WHERE {where_items}
424
+ """,
425
+ tuple(params),
426
+ )
427
+ ).fetchone()
428
+
429
+ # Pull the score row (cumulative, range-independent — score is an aggregate)
430
+ score_row = await (
431
+ await db.execute(
432
+ "SELECT score, upvotes, downvotes, reads FROM source_scores "
433
+ "WHERE workspace_id = ? AND source_key = ?",
434
+ (workspace_id, source_key),
435
+ )
436
+ ).fetchone()
437
+
438
+ return {
439
+ "source_id": source_id,
440
+ "source_key": source_key,
441
+ "range": range,
442
+ "cutoff": cutoff,
443
+ "total": int(row["total"] or 0),
444
+ "unread": int(row["unread"] or 0),
445
+ "read": int(row["read_items"] or 0),
446
+ "saved": int(row["saved"] or 0),
447
+ "newsletter": int(row["newsletter"] or 0),
448
+ "preferred": int(row["preferred"] or 0),
449
+ "idea": int(row["idea"] or 0),
450
+ "ignored": int(row["ignored"] or 0),
451
+ "auto_ignored": int(row["auto_ignored"] or 0),
452
+ "score": float(score_row["score"]) if score_row else 0.0,
453
+ "upvotes": int(score_row["upvotes"]) if score_row else 0,
454
+ "downvotes": int(score_row["downvotes"]) if score_row else 0,
455
+ "reads": int(score_row["reads"]) if score_row else 0,
456
+ }