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,899 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+ from types import MappingProxyType
8
+ from typing import Any, Mapping
9
+ from urllib.parse import urlparse
10
+
11
+ import aiosqlite
12
+
13
+ from core.api.models import (
14
+ InboxItemDetail,
15
+ InboxItemSummary,
16
+ InboxStatsResponse,
17
+ InboxStatusUpdateRequest,
18
+ InboxTaxonomyUpdateRequest,
19
+ InboxTaxonomyUpdateResponse,
20
+ InboxTriageDecisionRequest,
21
+ InboxTriageDecisionResponse,
22
+ UserInfo,
23
+ )
24
+ from core.api.use_cases._errors import (
25
+ ConflictError,
26
+ NotFoundError,
27
+ ServiceUnavailableError,
28
+ )
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # Differentiated scoring weights per status (PR A of inbox redesign, 2026-04-11).
33
+ # Immutable via MappingProxyType to prevent accidental runtime mutation.
34
+ # - newsletter +3: "gold public" signal (Emilio sends it to subscribers)
35
+ # - preferred +2: "gold private" signal (Emilio wants to see more like this)
36
+ # - saved/idea +1: "useful" signal
37
+ # - read +0.1: "attention" signal (weak but non-zero: reading is information)
38
+ # - auto_ignored 0: LLM auto-decision, zero weight (no write)
39
+ # - ignored -1: "noise" signal
40
+ SCORE_WEIGHTS: Mapping[str, float] = MappingProxyType(
41
+ {
42
+ "newsletter": 3.0,
43
+ "preferred": 2.0,
44
+ "saved": 1.0,
45
+ "idea": 1.0,
46
+ "read": 0.1,
47
+ "auto_ignored": 0.0,
48
+ "ignored": -1.0,
49
+ }
50
+ )
51
+
52
+
53
+ def _pick(mapping: dict[str, Any], *names: str) -> Any:
54
+ for name in names:
55
+ if name in mapping and mapping[name] is not None:
56
+ return mapping[name]
57
+ return None
58
+
59
+
60
+ async def _get_table_columns(db: aiosqlite.Connection, table_name: str) -> set[str]:
61
+ rows = await (await db.execute(f"PRAGMA table_info({table_name})")).fetchall()
62
+ return {
63
+ row[1] if not isinstance(row, aiosqlite.Row) else row["name"] for row in rows
64
+ }
65
+
66
+
67
+ async def ensure_inbox_core_available(db: aiosqlite.Connection) -> set[str]:
68
+ columns = await _get_table_columns(db, "inbox_items")
69
+ if not columns:
70
+ raise ServiceUnavailableError(
71
+ code="inbox_core_unavailable",
72
+ message=(
73
+ "Inbox core table 'inbox_items' not found. "
74
+ "This contract assumes the parallel inbox core branch provides it."
75
+ ),
76
+ )
77
+ return columns
78
+
79
+
80
+ def _normalize_payload(raw_value: Any) -> Any:
81
+ if raw_value is None:
82
+ return None
83
+ if isinstance(raw_value, (dict, list)):
84
+ return raw_value
85
+ if isinstance(raw_value, str):
86
+ text = raw_value.strip()
87
+ if not text:
88
+ return None
89
+ try:
90
+ return json.loads(text)
91
+ except json.JSONDecodeError:
92
+ return raw_value
93
+ return raw_value
94
+
95
+
96
+ def _decision_from_row(row: aiosqlite.Row | None) -> InboxTriageDecisionResponse | None:
97
+ if row is None or row["decision"] is None:
98
+ return None
99
+ tags_raw = row["triage_tags_json"] or "[]"
100
+ try:
101
+ tags = json.loads(tags_raw)
102
+ except (json.JSONDecodeError, TypeError):
103
+ tags = []
104
+ # Use triage_decided_by alias to avoid clash with inbox_items.decided_by
105
+ triage_decided_by = None
106
+ mapping = dict(row)
107
+ if "triage_decided_by" in mapping:
108
+ triage_decided_by = mapping["triage_decided_by"]
109
+ elif "decided_by" in mapping:
110
+ triage_decided_by = mapping["decided_by"]
111
+ return InboxTriageDecisionResponse(
112
+ inbox_item_id=row["id"],
113
+ decision=row["decision"],
114
+ confidence=row["confidence"],
115
+ reason=row["reason"],
116
+ target_program=row["target_program"],
117
+ target_project=row["target_project"],
118
+ task_kind=row["task_kind"],
119
+ task_title=row["task_title"],
120
+ task_description=row["task_description"],
121
+ linked_task_id=row["linked_task_id"],
122
+ tags=tags,
123
+ decided_by=triage_decided_by or "",
124
+ created_at=row["decision_created_at"],
125
+ updated_at=row["decision_updated_at"],
126
+ )
127
+
128
+
129
+ def _coerce_sender_str(sender: Any) -> str | None:
130
+ """Normalize a sender value to a string.
131
+
132
+ Email ingestion can store the `From` header as a structured object
133
+ (e.g. {"name": "a16z", "address": "...", "html": "..."}). InboxItemDetail.sender
134
+ is typed `str | None`, so a dict reaches Pydantic and raises a 500 on item
135
+ fetch. Prefer the display name, fall back to address/email, else None.
136
+ """
137
+ if sender is None or isinstance(sender, str):
138
+ return sender
139
+ if isinstance(sender, dict):
140
+ return (
141
+ sender.get("name")
142
+ or sender.get("address")
143
+ or sender.get("email")
144
+ or None
145
+ )
146
+ if isinstance(sender, (list, tuple)) and sender:
147
+ return _coerce_sender_str(sender[0])
148
+ return str(sender)
149
+
150
+
151
+ def _normalize_item(
152
+ row: aiosqlite.Row, include_raw: bool = False
153
+ ) -> InboxItemSummary | InboxItemDetail:
154
+ mapping = dict(row)
155
+ payload = _normalize_payload(
156
+ _pick(
157
+ mapping,
158
+ "payload_json",
159
+ "metadata_json",
160
+ "raw_payload",
161
+ "payload",
162
+ "raw_content",
163
+ )
164
+ )
165
+ program = _pick(
166
+ mapping, "program", "program_slug", "program_hint", "default_program"
167
+ )
168
+ if program is None:
169
+ candidate_programs_raw = _pick(mapping, "candidate_programs")
170
+ try:
171
+ candidate_programs = json.loads(candidate_programs_raw or "[]")
172
+ except (TypeError, json.JSONDecodeError):
173
+ candidate_programs = []
174
+ if candidate_programs:
175
+ program = candidate_programs[0]
176
+ content = _pick(mapping, "content_text", "body_text", "plain_text", "content")
177
+ triage = _decision_from_row(row)
178
+ source_label = None
179
+ if isinstance(payload, dict):
180
+ source_label = payload.get("feedName") or payload.get("sourceLabel")
181
+ if source_label is None:
182
+ source_label = _pick(
183
+ mapping, "source_label", "feed_title", "mailbox", "source_name", "source"
184
+ )
185
+ sender = _pick(mapping, "sender", "from_email", "author", "source_author")
186
+ if sender is None and isinstance(payload, dict):
187
+ sender = payload.get("sender") or payload.get("author")
188
+ sender = _coerce_sender_str(sender)
189
+ item_status = _pick(mapping, "status") or "unread"
190
+ ignore_reason = _pick(mapping, "ignore_reason")
191
+ base = dict(
192
+ id=str(mapping["id"]),
193
+ source_type=_pick(mapping, "source_type", "source_kind", "channel", "source"),
194
+ source_label=source_label,
195
+ external_id=_pick(
196
+ mapping, "external_id", "message_id", "source_item_id", "item_key"
197
+ ),
198
+ title=_pick(mapping, "title", "subject", "name"),
199
+ snippet=_pick(mapping, "summary", "snippet", "body_preview", "excerpt")
200
+ or (content[:160] if isinstance(content, str) else None),
201
+ sender=sender,
202
+ url=_pick(mapping, "url", "source_url", "link"),
203
+ program=program,
204
+ project=_pick(mapping, "project", "project_slug", "project_hint"),
205
+ topic=_pick(mapping, "topic") or "general",
206
+ treatment=_pick(mapping, "treatment") or "read",
207
+ status=item_status,
208
+ ignore_reason=ignore_reason,
209
+ received_at=_pick(
210
+ mapping, "received_at", "created_at", "ingested_at", "updated_at"
211
+ ),
212
+ needs_triage=item_status == "unread",
213
+ triage=triage,
214
+ )
215
+ if include_raw:
216
+ return InboxItemDetail(
217
+ **base,
218
+ content=content,
219
+ raw_payload=payload,
220
+ tldr=_pick(mapping, "tldr"),
221
+ deep_research=_pick(mapping, "deep_research"),
222
+ )
223
+ return InboxItemSummary(**base)
224
+
225
+
226
+ async def list_inbox_items(
227
+ db: aiosqlite.Connection,
228
+ user: UserInfo,
229
+ *,
230
+ limit: int,
231
+ needs_triage: bool,
232
+ classified: bool | None = None,
233
+ source: str | None = None,
234
+ program: str | None = None,
235
+ topic: str | None = None,
236
+ treatment: str | None = None,
237
+ status: str | None = None,
238
+ ) -> list[InboxItemSummary]:
239
+ columns = await ensure_inbox_core_available(db)
240
+ order_col = next(
241
+ (
242
+ name
243
+ for name in ("received_at", "created_at", "ingested_at", "updated_at", "id")
244
+ if name in columns
245
+ ),
246
+ "id",
247
+ )
248
+ workspace_id = user.workspace_id or "ws_default"
249
+ params: list[Any] = [workspace_id]
250
+ where: list[str] = []
251
+ if "workspace_id" in columns:
252
+ where.append("COALESCE(i.workspace_id, 'ws_default') = ?")
253
+ else:
254
+ params = []
255
+ if needs_triage and not status:
256
+ where.append("i.status = 'unread'")
257
+ if status:
258
+ where.append("i.status = ?")
259
+ params.append(status)
260
+ if source:
261
+ where.append("i.source = ?")
262
+ params.append(source)
263
+ if program:
264
+ where.append("i.default_program = ?")
265
+ params.append(program)
266
+ if topic:
267
+ where.append("i.topic = ?")
268
+ params.append(topic)
269
+ if treatment:
270
+ where.append("i.treatment = ?")
271
+ params.append(treatment)
272
+ where_sql = f"WHERE {' AND '.join(where)}" if where else ""
273
+ query = (
274
+ "SELECT i.*, "
275
+ "d.decision, d.confidence, d.reason, d.target_program, d.target_project, d.task_kind, "
276
+ "d.task_title, d.task_description, d.linked_task_id, d.tags_json AS triage_tags_json, "
277
+ "d.decided_by AS triage_decided_by, d.created_at AS decision_created_at, d.updated_at AS decision_updated_at "
278
+ "FROM inbox_items i "
279
+ "LEFT JOIN inbox_triage_decisions d "
280
+ "ON d.inbox_item_id = i.id AND COALESCE(d.workspace_id, 'ws_default') = ? "
281
+ f"{where_sql} ORDER BY i.{order_col} DESC LIMIT ?"
282
+ )
283
+ params = (
284
+ [workspace_id, *params, limit]
285
+ if "workspace_id" in columns
286
+ else [workspace_id, limit]
287
+ )
288
+ rows = await (await db.execute(query, params)).fetchall()
289
+ items: list[InboxItemSummary] = []
290
+ for row in rows:
291
+ payload = _normalize_payload(row["metadata_json"]) or {}
292
+ is_classified = isinstance(payload, dict) and bool(payload.get("classifiedAt"))
293
+ if classified is not None and is_classified != classified:
294
+ continue
295
+ items.append(_normalize_item(row))
296
+ return items
297
+
298
+
299
+ async def update_inbox_taxonomy(
300
+ db: aiosqlite.Connection,
301
+ user: UserInfo,
302
+ inbox_item_id: str,
303
+ body: InboxTaxonomyUpdateRequest,
304
+ ) -> InboxTaxonomyUpdateResponse:
305
+ columns = await ensure_inbox_core_available(db)
306
+ workspace_id = user.workspace_id or "ws_default"
307
+ where = ["id = ?"]
308
+ params: list[Any] = [inbox_item_id]
309
+ if "workspace_id" in columns:
310
+ where.append("COALESCE(workspace_id, 'ws_default') = ?")
311
+ params.append(workspace_id)
312
+ row = await (
313
+ await db.execute(
314
+ f"SELECT id, metadata_json FROM inbox_items WHERE {' AND '.join(where)}",
315
+ params,
316
+ )
317
+ ).fetchone()
318
+ if row is None:
319
+ raise NotFoundError(code="inbox_item_not_found", message="Inbox item not found")
320
+
321
+ metadata = _normalize_payload(row["metadata_json"]) or {}
322
+ if not isinstance(metadata, dict):
323
+ metadata = {}
324
+ if body.confidence is not None:
325
+ metadata["confidence"] = body.confidence
326
+ if body.note:
327
+ metadata["note"] = body.note
328
+ metadata["classifiedAt"] = datetime.now(timezone.utc).isoformat()
329
+
330
+ now = datetime.now(timezone.utc).isoformat()
331
+ await db.execute(
332
+ "UPDATE inbox_items SET topic = ?, treatment = ?, metadata_json = ?, updated_at = ? WHERE id = ?"
333
+ + (
334
+ " AND COALESCE(workspace_id, 'ws_default') = ?"
335
+ if "workspace_id" in columns
336
+ else ""
337
+ ),
338
+ [
339
+ body.topic,
340
+ body.treatment,
341
+ json.dumps(metadata, ensure_ascii=False, sort_keys=True),
342
+ now,
343
+ inbox_item_id,
344
+ *([workspace_id] if "workspace_id" in columns else []),
345
+ ],
346
+ )
347
+ await db.commit()
348
+ return InboxTaxonomyUpdateResponse(
349
+ inbox_item_id=inbox_item_id,
350
+ topic=body.topic,
351
+ treatment=body.treatment,
352
+ updated_at=now,
353
+ )
354
+
355
+
356
+ async def get_inbox_stats(
357
+ db: aiosqlite.Connection,
358
+ user: UserInfo,
359
+ *,
360
+ needs_triage: bool,
361
+ classified: bool | None = None,
362
+ source: str | None = None,
363
+ program: str | None = None,
364
+ topic: str | None = None,
365
+ treatment: str | None = None,
366
+ status: str | None = None,
367
+ ) -> InboxStatsResponse:
368
+ columns = await ensure_inbox_core_available(db)
369
+ workspace_id = user.workspace_id or "ws_default"
370
+ where: list[str] = []
371
+ params: list[Any] = [workspace_id]
372
+ if "workspace_id" in columns:
373
+ where.append("COALESCE(i.workspace_id, 'ws_default') = ?")
374
+ else:
375
+ params = []
376
+ if needs_triage and not status:
377
+ where.append("i.status = 'unread'")
378
+ if status:
379
+ where.append("i.status = ?")
380
+ params.append(status)
381
+ if source:
382
+ where.append("i.source = ?")
383
+ params.append(source)
384
+ if program:
385
+ where.append("i.default_program = ?")
386
+ params.append(program)
387
+ if topic:
388
+ where.append("i.topic = ?")
389
+ params.append(topic)
390
+ if treatment:
391
+ where.append("i.treatment = ?")
392
+ params.append(treatment)
393
+ where_sql = f"WHERE {' AND '.join(where)}" if where else ""
394
+ query = (
395
+ "SELECT "
396
+ "COUNT(*) AS total, "
397
+ "SUM(CASE WHEN d.task_kind = 'idea' THEN 1 ELSE 0 END) AS ideas, "
398
+ "SUM(CASE WHEN d.task_kind = 'normal' THEN 1 ELSE 0 END) AS tasks, "
399
+ "SUM(CASE WHEN d.decision = 'needs_human_review' THEN 1 ELSE 0 END) AS review, "
400
+ "SUM(CASE WHEN i.status = 'unread' THEN 1 ELSE 0 END) AS unread, "
401
+ "SUM(CASE WHEN i.status = 'read' THEN 1 ELSE 0 END) AS status_read, "
402
+ "SUM(CASE WHEN i.status = 'saved' THEN 1 ELSE 0 END) AS status_saved, "
403
+ "SUM(CASE WHEN i.status IN ('ignored', 'auto_ignored') THEN 1 ELSE 0 END) AS status_ignored "
404
+ "FROM inbox_items i "
405
+ "LEFT JOIN inbox_triage_decisions d "
406
+ "ON d.inbox_item_id = i.id AND COALESCE(d.workspace_id, 'ws_default') = ? "
407
+ f"{where_sql}"
408
+ )
409
+ final_params = (
410
+ [workspace_id, *params] if "workspace_id" in columns else [workspace_id]
411
+ )
412
+ row = await (await db.execute(query, final_params)).fetchone()
413
+ stats = InboxStatsResponse(
414
+ total=int(row["total"] or 0),
415
+ ideas=int(row["ideas"] or 0),
416
+ tasks=int(row["tasks"] or 0),
417
+ review=int(row["review"] or 0),
418
+ unread=int(row["unread"] or 0),
419
+ read=int(row["status_read"] or 0),
420
+ saved=int(row["status_saved"] or 0),
421
+ ignored=int(row["status_ignored"] or 0),
422
+ )
423
+ if classified is None:
424
+ return stats
425
+
426
+ classified_items = await list_inbox_items(
427
+ db,
428
+ user,
429
+ limit=500,
430
+ needs_triage=needs_triage,
431
+ classified=classified,
432
+ source=source,
433
+ program=program,
434
+ topic=topic,
435
+ treatment=treatment,
436
+ status=status,
437
+ )
438
+ return InboxStatsResponse(
439
+ total=len(classified_items),
440
+ ideas=stats.ideas if not needs_triage else 0,
441
+ tasks=stats.tasks if not needs_triage else 0,
442
+ review=stats.review if not needs_triage else 0,
443
+ unread=stats.unread,
444
+ read=stats.read,
445
+ saved=stats.saved,
446
+ ignored=stats.ignored,
447
+ )
448
+
449
+
450
+ async def get_inbox_item_detail(
451
+ db: aiosqlite.Connection, user: UserInfo, inbox_item_id: str
452
+ ) -> InboxItemDetail:
453
+ columns = await ensure_inbox_core_available(db)
454
+ workspace_id = user.workspace_id or "ws_default"
455
+ where = ["i.id = ?"]
456
+ params: list[Any] = [workspace_id, inbox_item_id]
457
+ if "workspace_id" in columns:
458
+ where.append("COALESCE(i.workspace_id, 'ws_default') = ?")
459
+ params.append(workspace_id)
460
+ query = (
461
+ "SELECT i.*, "
462
+ "d.decision, d.confidence, d.reason, d.target_program, d.target_project, d.task_kind, "
463
+ "d.task_title, d.task_description, d.linked_task_id, d.tags_json AS triage_tags_json, "
464
+ "d.decided_by AS triage_decided_by, d.created_at AS decision_created_at, d.updated_at AS decision_updated_at "
465
+ "FROM inbox_items i "
466
+ "LEFT JOIN inbox_triage_decisions d "
467
+ "ON d.inbox_item_id = i.id AND COALESCE(d.workspace_id, 'ws_default') = ? "
468
+ f"WHERE {' AND '.join(where)}"
469
+ )
470
+ row = await (await db.execute(query, params)).fetchone()
471
+ if row is None:
472
+ raise NotFoundError(code="inbox_item_not_found", message="Inbox item not found")
473
+ return _normalize_item(row, include_raw=True)
474
+
475
+
476
+ async def _tasks_has_kind_column(db: aiosqlite.Connection) -> bool:
477
+ return "kind" in await _get_table_columns(db, "tasks")
478
+
479
+
480
+ async def _task_exists(
481
+ db: aiosqlite.Connection, task_id: str, workspace_id: str
482
+ ) -> bool:
483
+ row = await (
484
+ await db.execute(
485
+ "SELECT id FROM tasks WHERE id = ? AND deleted_at IS NULL AND COALESCE(workspace_id, 'ws_default') = ?",
486
+ (task_id, workspace_id),
487
+ )
488
+ ).fetchone()
489
+ return row is not None
490
+
491
+
492
+ async def _create_triage_task(
493
+ db: aiosqlite.Connection,
494
+ user: UserInfo,
495
+ inbox_item_id: str,
496
+ body: InboxTriageDecisionRequest,
497
+ ) -> str:
498
+ task_id = str(uuid.uuid4())
499
+ now = datetime.now(timezone.utc).isoformat()
500
+ tags = ["inboxx"]
501
+ tags.extend(tag for tag in body.tags if tag not in tags)
502
+ tags_json = json.dumps(tags)
503
+ has_kind = await _tasks_has_kind_column(db)
504
+ task_kind = body.task_kind or (
505
+ "idea" if body.decision == "create_idea" else "normal"
506
+ )
507
+ columns = [
508
+ "id",
509
+ "title",
510
+ "description",
511
+ "status",
512
+ "project",
513
+ "priority",
514
+ "created_by",
515
+ "owner_id",
516
+ "source",
517
+ "source_ref",
518
+ "tags",
519
+ "workspace_id",
520
+ "created_at",
521
+ "updated_at",
522
+ ]
523
+ values = [
524
+ task_id,
525
+ body.task_title,
526
+ body.task_description or body.reason,
527
+ "pending",
528
+ body.target_project,
529
+ "medium",
530
+ user.username,
531
+ None,
532
+ "console",
533
+ f"inbox:{inbox_item_id}",
534
+ tags_json,
535
+ user.workspace_id or "ws_default",
536
+ now,
537
+ now,
538
+ ]
539
+ if has_kind:
540
+ columns.insert(5, "kind")
541
+ values.insert(5, task_kind)
542
+ placeholders = ", ".join("?" for _ in columns)
543
+ await db.execute(
544
+ f"INSERT INTO tasks ({', '.join(columns)}) VALUES ({placeholders})",
545
+ values,
546
+ )
547
+ return task_id
548
+
549
+
550
+ async def apply_triage_decision(
551
+ db: aiosqlite.Connection,
552
+ user: UserInfo,
553
+ inbox_item_id: str,
554
+ body: InboxTriageDecisionRequest,
555
+ ) -> InboxTriageDecisionResponse:
556
+ columns = await ensure_inbox_core_available(db)
557
+ workspace_id = user.workspace_id or "ws_default"
558
+ item_query = "SELECT id FROM inbox_items WHERE id = ?"
559
+ item_params: list[Any] = [inbox_item_id]
560
+ if "workspace_id" in columns:
561
+ item_query += " AND COALESCE(workspace_id, 'ws_default') = ?"
562
+ item_params.append(workspace_id)
563
+ row = await (await db.execute(item_query, item_params)).fetchone()
564
+ if row is None:
565
+ raise NotFoundError(code="inbox_item_not_found", message="Inbox item not found")
566
+
567
+ existing = await (
568
+ await db.execute(
569
+ "SELECT linked_task_id, decision FROM inbox_triage_decisions WHERE inbox_item_id = ? AND COALESCE(workspace_id, 'ws_default') = ?",
570
+ (inbox_item_id, workspace_id),
571
+ )
572
+ ).fetchone()
573
+
574
+ linked_task_id = body.linked_task_id
575
+ if body.decision in {"create_idea", "create_task"}:
576
+ if (
577
+ existing
578
+ and existing["linked_task_id"]
579
+ and existing["linked_task_id"] != linked_task_id
580
+ ):
581
+ raise ConflictError(
582
+ code="inbox_item_already_linked",
583
+ message=(
584
+ "This inbox item is already linked to a task. "
585
+ "Reuse linked_task_id instead of creating a duplicate."
586
+ ),
587
+ )
588
+ if linked_task_id:
589
+ if not await _task_exists(db, linked_task_id, workspace_id):
590
+ raise NotFoundError(
591
+ code="linked_task_not_found", message="Linked task not found"
592
+ )
593
+ else:
594
+ linked_task_id = await _create_triage_task(db, user, inbox_item_id, body)
595
+
596
+ now = datetime.now(timezone.utc).isoformat()
597
+ task_kind = body.task_kind or (
598
+ "idea"
599
+ if body.decision == "create_idea"
600
+ else "normal"
601
+ if body.decision == "create_task"
602
+ else None
603
+ )
604
+ tags_json = json.dumps(body.tags)
605
+ decision_id = str(uuid.uuid4())
606
+ await db.execute(
607
+ "INSERT INTO inbox_triage_decisions (id, inbox_item_id, decision, reason, confidence, target_program, "
608
+ "target_project, task_kind, task_title, task_description, linked_task_id, tags_json, decided_by, workspace_id, created_at, updated_at) "
609
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
610
+ "ON CONFLICT(inbox_item_id, workspace_id) DO UPDATE SET "
611
+ "decision=excluded.decision, reason=excluded.reason, confidence=excluded.confidence, "
612
+ "target_program=excluded.target_program, target_project=excluded.target_project, task_kind=excluded.task_kind, "
613
+ "task_title=excluded.task_title, task_description=excluded.task_description, linked_task_id=excluded.linked_task_id, "
614
+ "tags_json=excluded.tags_json, decided_by=excluded.decided_by, updated_at=excluded.updated_at",
615
+ (
616
+ decision_id,
617
+ inbox_item_id,
618
+ body.decision,
619
+ body.reason,
620
+ body.confidence,
621
+ body.target_program,
622
+ body.target_project,
623
+ task_kind,
624
+ body.task_title,
625
+ body.task_description,
626
+ linked_task_id,
627
+ tags_json,
628
+ user.username,
629
+ workspace_id,
630
+ now,
631
+ now,
632
+ ),
633
+ )
634
+ await db.commit()
635
+
636
+ saved = await (
637
+ await db.execute(
638
+ "SELECT ? AS inbox_item_id, decision, confidence, reason, target_program, target_project, task_kind, task_title, "
639
+ "task_description, linked_task_id, tags_json, decided_by, created_at, updated_at "
640
+ "FROM inbox_triage_decisions WHERE inbox_item_id = ? AND COALESCE(workspace_id, 'ws_default') = ?",
641
+ (inbox_item_id, inbox_item_id, workspace_id),
642
+ )
643
+ ).fetchone()
644
+ return InboxTriageDecisionResponse(
645
+ inbox_item_id=inbox_item_id,
646
+ decision=saved["decision"],
647
+ confidence=saved["confidence"],
648
+ reason=saved["reason"],
649
+ target_program=saved["target_program"],
650
+ target_project=saved["target_project"],
651
+ task_kind=saved["task_kind"],
652
+ task_title=saved["task_title"],
653
+ task_description=saved["task_description"],
654
+ linked_task_id=saved["linked_task_id"],
655
+ tags=json.loads(saved["tags_json"] or "[]"),
656
+ decided_by=saved["decided_by"],
657
+ created_at=saved["created_at"],
658
+ updated_at=saved["updated_at"],
659
+ )
660
+
661
+
662
+ async def _update_source_score(
663
+ db: aiosqlite.Connection,
664
+ inbox_item_id: str,
665
+ workspace_id: str,
666
+ new_status: str,
667
+ ) -> None:
668
+ """Update source_scores with differentiated weights.
669
+
670
+ Uses the SCORE_WEIGHTS table (see module-level constant). Weights:
671
+ newsletter=+3, preferred=+2, saved=+1, idea=+1, read=+0.1,
672
+ auto_ignored=0 (no write), ignored=-1.
673
+
674
+ Differentiated counters are also tracked:
675
+ - upvotes: any "positive" signal (saved, newsletter, idea, preferred)
676
+ - downvotes: ignored only
677
+ - reads: read only (the new counter from migration 061)
678
+ """
679
+ if new_status not in SCORE_WEIGHTS:
680
+ return
681
+
682
+ score_delta = SCORE_WEIGHTS[new_status]
683
+ if score_delta == 0:
684
+ # auto_ignored (or any future neutral status): no-op, skip the write
685
+ return
686
+
687
+ # Get source_key from the item's URL domain (most useful for scoring)
688
+ row = await (
689
+ await db.execute(
690
+ "SELECT source, url, domain_key, metadata_json FROM inbox_items WHERE id = ?",
691
+ (inbox_item_id,),
692
+ )
693
+ ).fetchone()
694
+ if row is None:
695
+ return
696
+
697
+ source_key = row["domain_key"] or row["source"]
698
+ # Prefer URL domain as source_key (e.g. "notboring.co", "simonwillison.net").
699
+ # Normalization MUST match _migration_062_backfill_from_urls so the JOIN
700
+ # between source_scores and inbox_sources actually matches rows.
701
+ url = row["url"]
702
+ if url:
703
+ try:
704
+ netloc = urlparse(url).netloc
705
+ if netloc:
706
+ source_key = netloc.removeprefix("www.").lower()
707
+ except Exception: # noqa: BLE001 - defensive; source_key stays as fallback
708
+ pass
709
+
710
+ if not source_key:
711
+ return
712
+
713
+ is_read = new_status == "read"
714
+ is_upvote = new_status in ("saved", "newsletter", "idea", "preferred")
715
+ is_downvote = new_status == "ignored"
716
+
717
+ upvote_delta = 1 if is_upvote else 0
718
+ downvote_delta = 1 if is_downvote else 0
719
+ reads_delta = 1 if is_read else 0
720
+ now = datetime.now(timezone.utc).isoformat()
721
+
722
+ await db.execute(
723
+ "INSERT INTO source_scores "
724
+ "(id, source_key, score, upvotes, downvotes, reads, workspace_id, created_at, updated_at) "
725
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
726
+ "ON CONFLICT(workspace_id, source_key) DO UPDATE SET "
727
+ "score = source_scores.score + ?, "
728
+ "upvotes = source_scores.upvotes + ?, "
729
+ "downvotes = source_scores.downvotes + ?, "
730
+ "reads = source_scores.reads + ?, "
731
+ "updated_at = ?",
732
+ (
733
+ str(uuid.uuid4()),
734
+ source_key,
735
+ score_delta,
736
+ upvote_delta,
737
+ downvote_delta,
738
+ reads_delta,
739
+ workspace_id,
740
+ now,
741
+ now,
742
+ score_delta,
743
+ upvote_delta,
744
+ downvote_delta,
745
+ reads_delta,
746
+ now,
747
+ ),
748
+ )
749
+
750
+
751
+ async def update_inbox_status(
752
+ db: aiosqlite.Connection,
753
+ user: UserInfo,
754
+ inbox_item_id: str,
755
+ body: InboxStatusUpdateRequest,
756
+ ) -> dict[str, Any]:
757
+ """Update the status of an inbox item. decided_by is set server-side."""
758
+ columns = await ensure_inbox_core_available(db)
759
+ workspace_id = user.workspace_id or "ws_default"
760
+ where = ["id = ?"]
761
+ params: list[Any] = [inbox_item_id]
762
+ if "workspace_id" in columns:
763
+ where.append("COALESCE(workspace_id, 'ws_default') = ?")
764
+ params.append(workspace_id)
765
+ row = await (
766
+ await db.execute(
767
+ f"SELECT id FROM inbox_items WHERE {' AND '.join(where)}",
768
+ params,
769
+ )
770
+ ).fetchone()
771
+ if row is None:
772
+ raise NotFoundError(code="inbox_item_not_found", message="Inbox item not found")
773
+
774
+ now = datetime.now(timezone.utc).isoformat()
775
+ update_fields = [
776
+ "status = ?",
777
+ "decided_by = ?",
778
+ "decided_at = ?",
779
+ "updated_at = ?",
780
+ ]
781
+ update_params: list[Any] = [body.status, user.username, now, now]
782
+
783
+ if body.ignore_reason:
784
+ update_fields.append("ignore_reason = ?")
785
+ update_params.append(body.ignore_reason)
786
+ else:
787
+ update_fields.append("ignore_reason = NULL")
788
+
789
+ where_clause = ["id = ?"]
790
+ update_params.append(inbox_item_id)
791
+ if "workspace_id" in columns:
792
+ where_clause.append("COALESCE(workspace_id, 'ws_default') = ?")
793
+ update_params.append(workspace_id)
794
+
795
+ await db.execute(
796
+ f"UPDATE inbox_items SET {', '.join(update_fields)} WHERE {' AND '.join(where_clause)}",
797
+ update_params,
798
+ )
799
+
800
+ if body.status != "unread":
801
+ try:
802
+ from core.api.services.inbox_digest import remove_item_from_digest_selection
803
+
804
+ await remove_item_from_digest_selection(
805
+ db,
806
+ workspace_id=workspace_id,
807
+ inbox_item_id=inbox_item_id,
808
+ )
809
+ except Exception:
810
+ # Non-critical: do not block manual state changes on digest cleanup.
811
+ pass
812
+
813
+ # Update source score based on status decision
814
+ try:
815
+ await _update_source_score(db, inbox_item_id, workspace_id, body.status)
816
+ except Exception:
817
+ # Non-critical: don't fail the status update if score tracking fails
818
+ pass
819
+
820
+ await db.commit()
821
+
822
+ # Auto-generate deep research for newsletter items (non-blocking)
823
+ if body.status == "newsletter":
824
+ import asyncio
825
+
826
+ asyncio.create_task(_auto_generate_deep_research(inbox_item_id, workspace_id))
827
+
828
+ return {
829
+ "inbox_item_id": inbox_item_id,
830
+ "status": body.status,
831
+ "ignore_reason": body.ignore_reason,
832
+ "decided_by": user.username,
833
+ "decided_at": now,
834
+ }
835
+
836
+
837
+ async def _auto_generate_deep_research(
838
+ inbox_item_id: str,
839
+ workspace_id: str,
840
+ ) -> None:
841
+ """Auto-generate deep research for newsletter items in background."""
842
+ try:
843
+ from core.api.services.inbox_tldr import get_or_generate_deep_research
844
+
845
+ await get_or_generate_deep_research(inbox_item_id, workspace_id)
846
+ logger.info(
847
+ "Auto-generated deep research for newsletter item %s", inbox_item_id
848
+ )
849
+ except Exception as exc:
850
+ logger.warning("Auto deep research failed for %s: %s", inbox_item_id, exc)
851
+
852
+
853
+ async def get_source_scores(
854
+ db: aiosqlite.Connection,
855
+ workspace_id: str,
856
+ ) -> list[dict[str, Any]]:
857
+ """Return all source scores for the workspace."""
858
+ rows = await (
859
+ await db.execute(
860
+ "SELECT source_key, score, upvotes, downvotes "
861
+ "FROM source_scores WHERE workspace_id = ? ORDER BY score DESC",
862
+ (workspace_id,),
863
+ )
864
+ ).fetchall()
865
+ return [
866
+ {
867
+ "source_key": row["source_key"],
868
+ "score": row["score"],
869
+ "upvotes": row["upvotes"],
870
+ "downvotes": row["downvotes"],
871
+ }
872
+ for row in rows
873
+ ]
874
+
875
+
876
+ async def get_unread_count(
877
+ db: aiosqlite.Connection,
878
+ user: UserInfo,
879
+ ) -> int:
880
+ """Lightweight unread count query for badge polling."""
881
+ columns = await ensure_inbox_core_available(db)
882
+ workspace_id = user.workspace_id or "ws_default"
883
+
884
+ if "workspace_id" in columns:
885
+ row = await (
886
+ await db.execute(
887
+ "SELECT COUNT(*) AS cnt FROM inbox_items "
888
+ "WHERE status = 'unread' AND COALESCE(workspace_id, 'ws_default') = ?",
889
+ (workspace_id,),
890
+ )
891
+ ).fetchone()
892
+ else:
893
+ row = await (
894
+ await db.execute(
895
+ "SELECT COUNT(*) AS cnt FROM inbox_items WHERE status = 'unread'"
896
+ )
897
+ ).fetchone()
898
+
899
+ return int(row["cnt"] or 0)