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,832 @@
1
+ # v2.4.0 - 2026-04-12 - Add learning, inbox_item, audit doc_types to search grouping
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from dataclasses import dataclass
6
+ import hashlib
7
+ import logging
8
+ import os
9
+ import struct
10
+ from collections.abc import Mapping, Sequence
11
+ from pathlib import Path
12
+ from typing import Literal, cast
13
+
14
+ import aiosqlite
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ DIMENSIONS = 512
19
+ GRANITE_NATIVE_DIMENSIONS = 384
20
+ DEFAULT_SEARCH_RRF_K = 60
21
+ DEFAULT_SEARCH_SALIENCE_WEIGHT = 0.3
22
+ DEFAULT_SEARCH_SCORE_THRESHOLD = 0.6
23
+ BM25_FETCH_LIMIT = 50
24
+ EmbeddingMode = Literal["voyage", "granite_local", "granite_remote", "dual"]
25
+ _VALID_EMBEDDING_MODES = {"voyage", "granite_local", "granite_remote", "dual"}
26
+
27
+ # Clients are initialized in lifespan where possible. Granite remains lazy and
28
+ # loads the onnxruntime session + tokenizer (torch-free) on first use.
29
+ _voyage_client = None
30
+ _granite_client = None
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class _EmbeddingHit:
35
+ doc_id: int
36
+ distance: float
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class _Bm25Hit:
41
+ doc_id: int
42
+ score: float
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class _HybridRankScore:
47
+ doc_id: int
48
+ score: float
49
+ rrf_score: float
50
+ rank_embedding: int | None
51
+ rank_bm25: int | None
52
+
53
+
54
+ def init_voyage_client() -> None:
55
+ """Called from lifespan. Keeps the legacy name for router compatibility."""
56
+ mode = _embedding_mode()
57
+ if mode in {"voyage", "dual"}:
58
+ _init_voyage_client()
59
+ if mode in {"granite_local", "dual"}:
60
+ client = _get_granite_client()
61
+ logger.info(
62
+ "Granite local embedding client configured (lazy load, model=%s, device=%s)",
63
+ client.active_model_name,
64
+ client.device,
65
+ )
66
+ if mode == "granite_remote":
67
+ logger.info("Granite remote embedding mode selected; Phase 2 sidecar pending")
68
+
69
+
70
+ def _init_voyage_client() -> None:
71
+ """Initialize Voyage. Fails gracefully if VOYAGE_API_KEY is not set."""
72
+ global _voyage_client
73
+ try:
74
+ import voyageai
75
+
76
+ _voyage_client = voyageai.Client()
77
+ logger.info(
78
+ "Voyage AI client initialized (model=voyage-4-lite, dims=%d)",
79
+ DIMENSIONS,
80
+ )
81
+ except Exception as e:
82
+ logger.warning("Voyage AI client not available: %s", e)
83
+
84
+
85
+ def is_available() -> bool:
86
+ mode = _embedding_mode()
87
+ if mode == "voyage":
88
+ return _voyage_client is not None
89
+ if mode == "dual":
90
+ return _voyage_client is not None
91
+ if mode == "granite_local":
92
+ return _get_granite_client().can_attempt_load
93
+ return False
94
+
95
+
96
+ async def embed_texts(
97
+ texts: list[str],
98
+ input_type: str = "document",
99
+ batch_size: int = 16,
100
+ ) -> list[list[float]]:
101
+ """Embed texts using the configured backend.
102
+
103
+ Public callers keep receiving 512-dimensional vectors because sqlite-vec's
104
+ vec_documents table is fixed at float[512].
105
+ """
106
+ mode = _embedding_mode()
107
+ if mode == "voyage":
108
+ return await _embed_voyage_texts(texts, input_type=input_type)
109
+ if mode == "granite_local":
110
+ return await _embed_granite_texts(
111
+ texts,
112
+ input_type=input_type,
113
+ batch_size=batch_size,
114
+ )
115
+ if mode == "dual":
116
+ voyage_embeddings = await _embed_voyage_texts(texts, input_type=input_type)
117
+ try:
118
+ await _embed_granite_texts(
119
+ texts,
120
+ input_type=input_type,
121
+ batch_size=batch_size,
122
+ )
123
+ except Exception:
124
+ logger.exception("Granite dual-mode validation embedding failed")
125
+ return voyage_embeddings
126
+ raise NotImplementedError("Phase 2 sidecar Docker mode")
127
+
128
+
129
+ async def _embed_voyage_texts(
130
+ texts: list[str],
131
+ input_type: str = "document",
132
+ ) -> list[list[float]]:
133
+ """Embed texts using Voyage-4-lite. Uses asyncio.to_thread (SDK is sync)."""
134
+ if not _voyage_client:
135
+ raise RuntimeError("Voyage AI client not initialized")
136
+ result = await asyncio.to_thread(
137
+ _voyage_client.embed,
138
+ texts=texts,
139
+ model="voyage-4-lite",
140
+ input_type=input_type,
141
+ output_dimension=DIMENSIONS,
142
+ )
143
+ return result.embeddings
144
+
145
+
146
+ async def _embed_granite_texts(
147
+ texts: list[str],
148
+ input_type: str,
149
+ batch_size: int,
150
+ ) -> list[list[float]]:
151
+ client = _get_granite_client()
152
+ embeddings = await asyncio.to_thread(
153
+ client.embed_texts,
154
+ texts,
155
+ input_type=input_type,
156
+ batch_size=batch_size,
157
+ )
158
+ return [_coerce_dimensions_for_vec_documents(embedding) for embedding in embeddings]
159
+
160
+
161
+ def _embedding_mode() -> EmbeddingMode:
162
+ raw = os.environ.get("EMBEDDING_MODE")
163
+ if raw is None or not raw.strip():
164
+ # No explicit mode: resolve on Voyage key PRESENCE, never on deploy_mode.
165
+ # OSS users have no VOYAGE_API_KEY -> granite_local (local engine).
166
+ # Prod has the key (deploy_mode=core but key present) -> voyage, unchanged.
167
+ return "voyage" if os.environ.get("VOYAGE_API_KEY", "").strip() else "granite_local"
168
+ mode = raw.strip().lower()
169
+ if mode not in _VALID_EMBEDDING_MODES:
170
+ logger.warning(
171
+ "Unknown EMBEDDING_MODE=%r; falling back to granite_local", mode
172
+ )
173
+ return "granite_local"
174
+ return cast(EmbeddingMode, mode)
175
+
176
+
177
+ def _search_rrf_k() -> int:
178
+ return max(_int_from_env("SEARCH_RRF_K", DEFAULT_SEARCH_RRF_K), 1)
179
+
180
+
181
+ def _search_salience_weight() -> float:
182
+ return max(_float_from_env("SEARCH_SALIENCE_WEIGHT", DEFAULT_SEARCH_SALIENCE_WEIGHT), 0.0)
183
+
184
+
185
+ def _search_score_threshold() -> float:
186
+ return _float_from_env("SEARCH_SCORE_THRESHOLD", DEFAULT_SEARCH_SCORE_THRESHOLD)
187
+
188
+
189
+ def _search_bm25_enabled() -> bool:
190
+ raw = os.environ.get("SEARCH_BM25_ENABLED", "true").strip().lower()
191
+ return raw not in {"0", "false", "no", "off"}
192
+
193
+
194
+ def _int_from_env(name: str, default: int) -> int:
195
+ raw = os.environ.get(name)
196
+ if not raw:
197
+ return default
198
+ try:
199
+ return int(raw)
200
+ except ValueError:
201
+ logger.warning("Invalid %s=%r; using %d", name, raw, default)
202
+ return default
203
+
204
+
205
+ def _float_from_env(name: str, default: float) -> float:
206
+ raw = os.environ.get(name)
207
+ if not raw:
208
+ return default
209
+ try:
210
+ return float(raw)
211
+ except ValueError:
212
+ logger.warning("Invalid %s=%r; using %.3f", name, raw, default)
213
+ return default
214
+
215
+
216
+ def _get_granite_client():
217
+ global _granite_client
218
+ if _granite_client is None:
219
+ from core.api.services.embedding_internal import GraniteEmbeddingClient
220
+
221
+ _granite_client = GraniteEmbeddingClient(
222
+ device=os.environ.get("EMBEDDING_DEVICE", "cpu"),
223
+ )
224
+ return _granite_client
225
+
226
+
227
+ def _coerce_dimensions_for_vec_documents(vec: list[float]) -> list[float]:
228
+ if len(vec) == DIMENSIONS:
229
+ return vec
230
+ if len(vec) > DIMENSIONS:
231
+ return vec[:DIMENSIONS]
232
+
233
+ # W1 pragmatism: vec_documents is hardcoded as float[512]. Granite-97m emits
234
+ # native 384-dimensional, L2-normalized vectors; zero-padding preserves their
235
+ # geometry without a migration. Phase 2 can introduce a
236
+ # vec_documents_granite float[384] shadow table and route per backend.
237
+ return [*vec, *([0.0] * (DIMENSIONS - len(vec)))]
238
+
239
+
240
+ def _empty_grouped_results() -> dict[str, list[dict]]:
241
+ return {
242
+ "task": [],
243
+ "project": [],
244
+ "file": [],
245
+ "handoff": [],
246
+ "learning": [],
247
+ "inbox_item": [],
248
+ "audit": [],
249
+ }
250
+
251
+
252
+ def _normalize_salience(value: object) -> float:
253
+ try:
254
+ salience = float(value) if value is not None else 0.5
255
+ except (TypeError, ValueError):
256
+ salience = 0.5
257
+ return min(max(salience, 0.0), 1.0)
258
+
259
+
260
+ def _reciprocal_rank_score(
261
+ rank_embedding: int | None,
262
+ rank_bm25: int | None,
263
+ *,
264
+ rrf_k: int,
265
+ ) -> float:
266
+ score = 0.0
267
+ if rank_embedding is not None:
268
+ score += 1.0 / (rrf_k + rank_embedding)
269
+ if rank_bm25 is not None:
270
+ score += 1.0 / (rrf_k + rank_bm25)
271
+ return score
272
+
273
+
274
+ def _public_hybrid_score(
275
+ rrf_score: float,
276
+ salience: float,
277
+ *,
278
+ salience_weight: float,
279
+ max_rrf_score: float,
280
+ ) -> float:
281
+ """Map tiny RRF values onto the public 0-ish relevance scale before boost."""
282
+ normalized_rrf = rrf_score / max_rrf_score if max_rrf_score > 0 else 0.0
283
+ return normalized_rrf + salience_weight * _normalize_salience(salience)
284
+
285
+
286
+ def _embedding_only_score(
287
+ distance: float,
288
+ salience: float,
289
+ *,
290
+ salience_weight: float,
291
+ ) -> float:
292
+ similarity = 1.0 - (distance / 2.0)
293
+ similarity = min(max(similarity, 0.0), 1.0)
294
+ semantic_weight = max(1.0 - salience_weight, 0.0)
295
+ return similarity * semantic_weight + _normalize_salience(salience) * salience_weight
296
+
297
+
298
+ def _fuse_ranked_documents(
299
+ embedding_hits: Sequence[_EmbeddingHit],
300
+ bm25_hits: Sequence[_Bm25Hit],
301
+ salience_by_doc_id: Mapping[int, float],
302
+ *,
303
+ rrf_k: int,
304
+ salience_weight: float,
305
+ threshold: float,
306
+ allowed_doc_ids: set[int] | None = None,
307
+ ) -> list[_HybridRankScore]:
308
+ embedding_ranks = {hit.doc_id: rank for rank, hit in enumerate(embedding_hits, start=1)}
309
+ bm25_ranks = {hit.doc_id: rank for rank, hit in enumerate(bm25_hits, start=1)}
310
+ ordered_doc_ids = list(dict.fromkeys([*embedding_ranks.keys(), *bm25_ranks.keys()]))
311
+ if allowed_doc_ids is not None:
312
+ ordered_doc_ids = [doc_id for doc_id in ordered_doc_ids if doc_id in allowed_doc_ids]
313
+
314
+ source_count = 1 + (1 if bm25_hits else 0)
315
+ max_rrf_score = source_count * (1.0 / (rrf_k + 1))
316
+
317
+ fused: list[_HybridRankScore] = []
318
+ for doc_id in ordered_doc_ids:
319
+ rank_embedding = embedding_ranks.get(doc_id)
320
+ rank_bm25 = bm25_ranks.get(doc_id)
321
+ rrf_score = _reciprocal_rank_score(
322
+ rank_embedding,
323
+ rank_bm25,
324
+ rrf_k=rrf_k,
325
+ )
326
+ score = _public_hybrid_score(
327
+ rrf_score,
328
+ salience_by_doc_id.get(doc_id, 0.5),
329
+ salience_weight=salience_weight,
330
+ max_rrf_score=max_rrf_score,
331
+ )
332
+ if score < threshold:
333
+ continue
334
+ fused.append(
335
+ _HybridRankScore(
336
+ doc_id=doc_id,
337
+ score=score,
338
+ rrf_score=rrf_score,
339
+ rank_embedding=rank_embedding,
340
+ rank_bm25=rank_bm25,
341
+ )
342
+ )
343
+
344
+ def _best_rank(item: _HybridRankScore) -> int:
345
+ ranks = [r for r in (item.rank_embedding, item.rank_bm25) if r is not None]
346
+ return min(ranks) if ranks else 10**9
347
+
348
+ fused.sort(key=lambda item: (item.score, item.rrf_score, -_best_rank(item)), reverse=True)
349
+ return fused
350
+
351
+
352
+ def serialize_f32(vec: list[float]) -> bytes:
353
+ """Serialize float vector to binary format for sqlite-vec."""
354
+ vec = _coerce_dimensions_for_vec_documents(vec)
355
+ return struct.pack(f"{len(vec)}f", *vec)
356
+
357
+
358
+ def content_hash(text: str) -> str:
359
+ """SHA-256 hash of content for change detection."""
360
+ return hashlib.sha256(text.encode()).hexdigest()[:16]
361
+
362
+
363
+ async def upsert_document(
364
+ *,
365
+ file_path: str,
366
+ project: str,
367
+ workspace_id: str,
368
+ doc_type: str,
369
+ doc_title: str,
370
+ content: str,
371
+ db: aiosqlite.Connection,
372
+ vec_db: aiosqlite.Connection,
373
+ ) -> bool:
374
+ """Embed and upsert a single document. Returns True if embedding was updated."""
375
+ h = content_hash(content)
376
+ cur = await db.execute(
377
+ "SELECT id, content_hash FROM documents WHERE file_path = ?", [file_path]
378
+ )
379
+ row = await cur.fetchone()
380
+ if row and row["content_hash"] == h:
381
+ return False # unchanged
382
+
383
+ embeddings = await embed_texts([content], input_type="document")
384
+ vec_bytes = serialize_f32(embeddings[0])
385
+
386
+ await db.execute(
387
+ """INSERT INTO documents (file_path, project, workspace_id, doc_type, doc_title, content_hash)
388
+ VALUES (?, ?, ?, ?, ?, ?)
389
+ ON CONFLICT(file_path) DO UPDATE SET
390
+ content_hash = excluded.content_hash,
391
+ project = excluded.project,
392
+ workspace_id = excluded.workspace_id,
393
+ doc_type = excluded.doc_type,
394
+ doc_title = excluded.doc_title""",
395
+ [file_path, project, workspace_id, doc_type, doc_title, h],
396
+ )
397
+ cur2 = await db.execute("SELECT id FROM documents WHERE file_path = ?", [file_path])
398
+ doc_row = await cur2.fetchone()
399
+ doc_id = doc_row["id"]
400
+
401
+ # vec0 virtual table does not support ON CONFLICT/UPSERT — DELETE + INSERT
402
+ await vec_db.execute("DELETE FROM vec_documents WHERE doc_id = ?", [doc_id])
403
+ await vec_db.execute(
404
+ "INSERT INTO vec_documents (doc_id, embedding) VALUES (?, ?)",
405
+ [doc_id, vec_bytes],
406
+ )
407
+ await db.commit()
408
+ await vec_db.commit()
409
+ return True
410
+
411
+
412
+ async def embed_task_document(
413
+ *,
414
+ task_id: str,
415
+ title: str,
416
+ project: str,
417
+ status: str,
418
+ workspace_id: str,
419
+ ) -> None:
420
+ """Embed a task into documents + vec_documents on the single-writer pool.
421
+
422
+ The ONE fastapi-free implementation of the task auto-embed body. Both the HTTP
423
+ surface (``routers.tasks._schedule_embed_task``) and the MCP surface
424
+ (``mcp._adapter.mcp_schedule_embed``) call this — no fork. Callers schedule it
425
+ fire-and-forget; this function owns the embed-then-write sequencing:
426
+
427
+ 1. GATHER (slow, ~10-15s for Voyage / model inference) the embedding OUTSIDE
428
+ the write lock — building the content string and calling ``embed_texts``.
429
+ 2. WRITE (fast, <10ms) the documents row + vec_documents vector INSIDE one
430
+ ``write_db`` acquisition. The single-writer ``asyncio.Lock`` is NOT
431
+ reentrant, so we never hold it across the embed call (learning f83f5209).
432
+
433
+ Writes go through ``write_db`` (the writer pool); the read pool is
434
+ ``query_only=ON`` and would fail any INSERT (learning 6130bc49). No-ops if the
435
+ embedder is unavailable — the caller is expected to gate on ``is_available()``
436
+ too, but this guard keeps the helper safe to call directly.
437
+ """
438
+ if not is_available():
439
+ return
440
+
441
+ content = f"{title}\nStatus: {status}\nProject: {project}"
442
+ # GATHER: embedding (HTTP to Voyage / local model inference) OUTSIDE the write lock.
443
+ h = content_hash(content)
444
+ embeddings = await embed_texts([content], input_type="document")
445
+ vec_bytes = serialize_f32(embeddings[0])
446
+
447
+ # WRITE: DB operations INSIDE the write lock (fast batch).
448
+ from core.api.db import ensure_vec_documents, write_db
449
+
450
+ async with write_db(label="tasks.auto_embed") as db:
451
+ if not await ensure_vec_documents(db):
452
+ return
453
+
454
+ cur = await db.execute(
455
+ "SELECT id, content_hash FROM documents WHERE file_path = ?",
456
+ [f"task:{task_id}"],
457
+ )
458
+ row = await cur.fetchone()
459
+ if row and row["content_hash"] == h:
460
+ return # unchanged
461
+
462
+ await db.execute(
463
+ """INSERT INTO documents (file_path, project, workspace_id, doc_type, doc_title, content_hash)
464
+ VALUES (?, ?, ?, ?, ?, ?)
465
+ ON CONFLICT(file_path) DO UPDATE SET
466
+ content_hash = excluded.content_hash, project = excluded.project,
467
+ workspace_id = excluded.workspace_id, doc_type = excluded.doc_type,
468
+ doc_title = excluded.doc_title""",
469
+ [f"task:{task_id}", project, workspace_id, "task", title, h],
470
+ )
471
+ cur2 = await db.execute(
472
+ "SELECT id FROM documents WHERE file_path = ?",
473
+ [f"task:{task_id}"],
474
+ )
475
+ doc_row = await cur2.fetchone()
476
+ doc_id = doc_row["id"]
477
+ await db.execute("DELETE FROM vec_documents WHERE doc_id = ?", [doc_id])
478
+ await db.execute(
479
+ "INSERT INTO vec_documents (doc_id, embedding) VALUES (?, ?)",
480
+ [doc_id, vec_bytes],
481
+ )
482
+
483
+
484
+ async def _ensure_vec_documents(db: aiosqlite.Connection, vec0_path: str) -> None:
485
+ vec_path = str(Path(vec0_path))
486
+ await db._execute(db._conn.enable_load_extension, True)
487
+ await db.execute("SELECT load_extension(?)", [vec_path])
488
+ await db.execute("""
489
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_documents USING vec0(
490
+ doc_id INTEGER PRIMARY KEY,
491
+ embedding float[512]
492
+ )
493
+ """)
494
+
495
+
496
+ async def _embedding_knn_search(
497
+ db: aiosqlite.Connection,
498
+ vec_bytes: bytes,
499
+ limit: int,
500
+ ) -> list[_EmbeddingHit]:
501
+ cur = await db.execute(
502
+ """SELECT v.doc_id, v.distance
503
+ FROM vec_documents v
504
+ WHERE v.embedding MATCH ? AND k = ?
505
+ ORDER BY v.distance""",
506
+ [vec_bytes, limit],
507
+ )
508
+ rows = await cur.fetchall()
509
+ return [
510
+ _EmbeddingHit(doc_id=int(row["doc_id"]), distance=float(row["distance"]))
511
+ for row in rows
512
+ ]
513
+
514
+
515
+ async def _bm25_documents_search(
516
+ db: aiosqlite.Connection,
517
+ query: str,
518
+ *,
519
+ limit: int,
520
+ ) -> list[_Bm25Hit]:
521
+ if not query.strip():
522
+ return []
523
+ try:
524
+ cur = await db.execute(
525
+ """SELECT rowid AS doc_id, bm25(documents_fts) AS score
526
+ FROM documents_fts
527
+ WHERE documents_fts MATCH ?
528
+ ORDER BY score
529
+ LIMIT ?""",
530
+ [query, limit],
531
+ )
532
+ rows = await cur.fetchall()
533
+ except aiosqlite.OperationalError as exc:
534
+ msg = str(exc).lower()
535
+ if "no such table" in msg or "syntax" in msg or "malformed match" in msg:
536
+ return []
537
+ raise
538
+ return [
539
+ _Bm25Hit(doc_id=int(row["doc_id"]), score=float(row["score"] or 0.0))
540
+ for row in rows
541
+ ]
542
+
543
+
544
+ async def _document_columns(db: aiosqlite.Connection) -> set[str]:
545
+ cur = await db.execute("PRAGMA table_info(documents)")
546
+ return {str(row["name"]) for row in await cur.fetchall()}
547
+
548
+
549
+ async def _fetch_document_rows(
550
+ db: aiosqlite.Connection,
551
+ doc_ids: Sequence[int],
552
+ workspace_id: str,
553
+ ) -> dict[int, dict]:
554
+ if not doc_ids:
555
+ return {}
556
+
557
+ columns = await _document_columns(db)
558
+ doc_type_expr = "doc_type" if "doc_type" in columns else "'file' AS doc_type"
559
+ title_expr = "doc_title" if "doc_title" in columns else "file_path AS doc_title"
560
+ salience_expr = "salience" if "salience" in columns else "0.5 AS salience"
561
+ workspace_filter = ""
562
+ params: list[object] = list(doc_ids)
563
+ if "workspace_id" in columns:
564
+ workspace_filter = "AND COALESCE(workspace_id, 'ws_default') = ?"
565
+ params.append(workspace_id)
566
+ archived_filter = ""
567
+ if "archived" in columns:
568
+ archived_filter = "AND COALESCE(archived, 0) = 0"
569
+
570
+ placeholders = ",".join("?" * len(doc_ids))
571
+ cur = await db.execute(
572
+ f"""SELECT id, file_path, project, {doc_type_expr}, {title_expr}, {salience_expr}
573
+ FROM documents
574
+ WHERE id IN ({placeholders})
575
+ {workspace_filter}
576
+ {archived_filter}""",
577
+ params,
578
+ )
579
+ rows = await cur.fetchall()
580
+ return {int(row["id"]): dict(row) for row in rows}
581
+
582
+
583
+ def _doc_entity_id(file_path: str) -> str:
584
+ return file_path.split(":", 1)[1] if ":" in file_path else file_path
585
+
586
+
587
+ def _base_result_item(row: Mapping[str, object]) -> dict:
588
+ file_path = str(row["file_path"])
589
+ salience = _normalize_salience(row.get("salience"))
590
+ return {
591
+ "doc_id": _doc_entity_id(file_path),
592
+ "title": row.get("doc_title") or file_path,
593
+ "project": row.get("project") or "",
594
+ "path": file_path,
595
+ "salience": salience,
596
+ }
597
+
598
+
599
+ def _group_ranked_rows(
600
+ ranked_scores: Sequence[_HybridRankScore],
601
+ rows_by_doc_id: Mapping[int, Mapping[str, object]],
602
+ *,
603
+ top_k: int,
604
+ ) -> dict[str, list[dict]]:
605
+ results = _empty_grouped_results()
606
+ for ranked in ranked_scores:
607
+ row = rows_by_doc_id.get(ranked.doc_id)
608
+ if row is None:
609
+ continue
610
+ doc_type = str(row.get("doc_type") or "file")
611
+ if doc_type not in results:
612
+ continue
613
+ item = _base_result_item(row)
614
+ item["score"] = round(ranked.score, 4)
615
+ item["rrf_score"] = round(ranked.rrf_score, 6)
616
+ item["rank_embedding"] = ranked.rank_embedding
617
+ item["rank_bm25"] = ranked.rank_bm25
618
+ results[doc_type].append(item)
619
+
620
+ for doc_type in results:
621
+ results[doc_type].sort(key=lambda item: item["score"], reverse=True)
622
+ results[doc_type] = results[doc_type][:top_k]
623
+ return results
624
+
625
+
626
+ def _group_embedding_only_rows(
627
+ embedding_hits: Sequence[_EmbeddingHit],
628
+ rows_by_doc_id: Mapping[int, Mapping[str, object]],
629
+ *,
630
+ top_k: int,
631
+ salience_weight: float,
632
+ threshold: float,
633
+ ) -> dict[str, list[dict]]:
634
+ results = _empty_grouped_results()
635
+ for hit in embedding_hits:
636
+ row = rows_by_doc_id.get(hit.doc_id)
637
+ if row is None:
638
+ continue
639
+ doc_type = str(row.get("doc_type") or "file")
640
+ if doc_type not in results:
641
+ continue
642
+ salience = _normalize_salience(row.get("salience"))
643
+ score = _embedding_only_score(
644
+ hit.distance,
645
+ salience,
646
+ salience_weight=salience_weight,
647
+ )
648
+ if score < threshold:
649
+ continue
650
+ item = _base_result_item(row)
651
+ item["score"] = round(score, 4)
652
+ results[doc_type].append(item)
653
+
654
+ for doc_type in results:
655
+ results[doc_type].sort(key=lambda item: item["score"], reverse=True)
656
+ results[doc_type] = results[doc_type][:top_k]
657
+ return results
658
+
659
+
660
+ async def search_by_type(
661
+ query: str,
662
+ workspace_id: str,
663
+ db_path: str,
664
+ vec0_path: str,
665
+ top_k: int = 5,
666
+ ) -> dict[str, list[dict]]:
667
+ """Hybrid RRF search across all doc_types. Returns grouped results.
668
+
669
+ Uses separate db + vec_db connections (NOT the request-scoped pooled connections)
670
+ because aiosqlite uses ThreadPoolExecutor(max_workers=1) per connection, so queries
671
+ on the same connection are serialized. Two connections = true parallel I/O.
672
+
673
+ Default behavior fuses sqlite-vec semantic KNN with documents_fts BM25
674
+ using Reciprocal Rank Fusion, then applies the salience boost. Set
675
+ SEARCH_BM25_ENABLED=false to use the legacy embedding-only scorer.
676
+ """
677
+ if not is_available():
678
+ raise RuntimeError("Embedding client not initialized")
679
+
680
+ embeddings = await embed_texts([query], input_type="query")
681
+ vec_bytes = serialize_f32(embeddings[0])
682
+
683
+ # Open dedicated connections — never share request-scoped connections
684
+ db = await aiosqlite.connect(db_path)
685
+ db.row_factory = aiosqlite.Row
686
+ await db.execute("PRAGMA journal_mode=WAL")
687
+ await db.commit()
688
+
689
+ vec_db = await aiosqlite.connect(db_path)
690
+ vec_db.row_factory = aiosqlite.Row
691
+ await vec_db.execute("PRAGMA journal_mode=WAL")
692
+ await vec_db.commit()
693
+
694
+ try:
695
+ await _ensure_vec_documents(vec_db, vec0_path)
696
+ except Exception as e:
697
+ await db.close()
698
+ await vec_db.close()
699
+ raise RuntimeError(f"sqlite-vec not available: {e}") from e
700
+
701
+ try:
702
+ # Single KNN with large k, then group by doc_type. Previous approach
703
+ # ran the same KNN 4x with k=20, so files/handoffs were drowned out by
704
+ # task-heavy top-20 results.
705
+ fetch_k = max(BM25_FETCH_LIMIT, top_k * 20)
706
+ embedding_hits = await _embedding_knn_search(vec_db, vec_bytes, fetch_k)
707
+ if not embedding_hits:
708
+ return _empty_grouped_results()
709
+
710
+ bm25_enabled = _search_bm25_enabled()
711
+ bm25_hits: list[_Bm25Hit] = []
712
+ if bm25_enabled:
713
+ bm25_hits = await _bm25_documents_search(
714
+ db,
715
+ query,
716
+ limit=BM25_FETCH_LIMIT,
717
+ )
718
+
719
+ candidate_doc_ids = list(
720
+ dict.fromkeys([hit.doc_id for hit in embedding_hits] + [hit.doc_id for hit in bm25_hits])
721
+ )
722
+ doc_rows = await _fetch_document_rows(db, candidate_doc_ids, workspace_id)
723
+ if not doc_rows:
724
+ return _empty_grouped_results()
725
+
726
+ if bm25_enabled:
727
+ salience_by_doc_id = {
728
+ doc_id: _normalize_salience(row["salience"])
729
+ for doc_id, row in doc_rows.items()
730
+ }
731
+ ranked_scores = _fuse_ranked_documents(
732
+ embedding_hits,
733
+ bm25_hits,
734
+ salience_by_doc_id,
735
+ rrf_k=_search_rrf_k(),
736
+ salience_weight=_search_salience_weight(),
737
+ threshold=_search_score_threshold(),
738
+ allowed_doc_ids=set(doc_rows),
739
+ )
740
+ return _group_ranked_rows(ranked_scores, doc_rows, top_k=top_k)
741
+
742
+ return _group_embedding_only_rows(
743
+ embedding_hits,
744
+ doc_rows,
745
+ top_k=top_k,
746
+ salience_weight=_search_salience_weight(),
747
+ threshold=_search_score_threshold(),
748
+ )
749
+ finally:
750
+ await db.close()
751
+ await vec_db.close()
752
+
753
+
754
+ async def reindex_project(project: str, db: aiosqlite.Connection) -> dict:
755
+ """Reindex all handoff files for a project. Single connection for atomicity."""
756
+ if not is_available():
757
+ raise RuntimeError("Embedding client not initialized")
758
+
759
+ # Find handoff directory
760
+ import core.api.routers.projects as _projects_mod
761
+ project_path = _projects_mod._find_project_path(project)
762
+ if not project_path:
763
+ return {"error": "Project not found", "indexed": 0}
764
+
765
+ memory_dir = project_path / "memory"
766
+ if not memory_dir.is_dir():
767
+ return {"error": "No memory directory", "indexed": 0}
768
+
769
+ # Cleanup orphans
770
+ await db.execute("DELETE FROM vec_documents WHERE doc_id NOT IN (SELECT id FROM documents)")
771
+
772
+ # Load existing hashes
773
+ cursor = await db.execute(
774
+ "SELECT file_path, content_hash FROM documents WHERE project = ?",
775
+ [project],
776
+ )
777
+ existing = {row["file_path"]: row["content_hash"] for row in await cursor.fetchall()}
778
+
779
+ # Scan files and find changed ones
780
+ to_embed: list[tuple[str, str]] = [] # (file_path, content)
781
+ for f in sorted(memory_dir.glob("handoff-*.md")):
782
+ if f.is_symlink():
783
+ continue
784
+ try:
785
+ text = f.read_text(encoding="utf-8", errors="replace")
786
+ except OSError:
787
+ continue
788
+ fpath = str(f)
789
+ h = content_hash(text)
790
+ if existing.get(fpath) == h:
791
+ continue
792
+ to_embed.append((fpath, text))
793
+
794
+ if not to_embed:
795
+ return {"indexed": 0, "skipped": len(existing), "message": "All up to date"}
796
+
797
+ # Batch embed (Voyage max 128 per call)
798
+ indexed = 0
799
+ batch_size = 64
800
+ for i in range(0, len(to_embed), batch_size):
801
+ batch = to_embed[i:i + batch_size]
802
+ texts = [content for _, content in batch]
803
+ embeddings = await embed_texts(texts, input_type="document")
804
+
805
+ for (fpath, text), embedding in zip(batch, embeddings):
806
+ h = content_hash(text)
807
+ # Upsert document
808
+ await db.execute(
809
+ "INSERT INTO documents (file_path, project, content_hash) VALUES (?, ?, ?) "
810
+ "ON CONFLICT(file_path) DO UPDATE SET content_hash = excluded.content_hash",
811
+ [fpath, project, h],
812
+ )
813
+ # Get doc_id
814
+ cur = await db.execute("SELECT id FROM documents WHERE file_path = ?", [fpath])
815
+ doc_row = await cur.fetchone()
816
+ doc_id = doc_row["id"]
817
+
818
+ # vec0 virtual table does not support ON CONFLICT — DELETE + INSERT
819
+ await db.execute("DELETE FROM vec_documents WHERE doc_id = ?", [doc_id])
820
+ await db.execute(
821
+ "INSERT INTO vec_documents (doc_id, embedding) VALUES (?, ?)",
822
+ [doc_id, serialize_f32(embedding)],
823
+ )
824
+ indexed += 1
825
+
826
+ await db.commit()
827
+
828
+ # Rate limit: 1s between batches
829
+ if i + batch_size < len(to_embed):
830
+ await asyncio.sleep(1)
831
+
832
+ return {"indexed": indexed, "skipped": len(existing) - indexed, "total_files": len(to_embed) + len(existing)}