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,796 @@
1
+ # v2.5.0 - 2026-04-23 - PR4: shadow cost_equivalent fields + mirror for Claude (= real)
2
+ # v2.4.0 - 2026-04-22 - PR2: extend SessionMetrics (dual ctx/cost, TTL-split cache, reasoning, working_ms)
3
+ # v2.3.0 - 2026-04-22 - Re-export from model_registry + add ClaudeMetricsProvider (PR1)
4
+ # v2.2.0 - 2026-03-10 - Normalize model [1m] suffix + dynamic CONTEXT_WINDOW for 1M sessions
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import logging
9
+ import os
10
+ import re
11
+ import time as _time
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Iterable
16
+
17
+ # Re-export from model_registry for backward compat (30+ call sites import
18
+ # these names directly from claude_metrics). PR2 will migrate call sites over
19
+ # time; PR1 keeps imports working unchanged.
20
+ from core.api.services.model_registry import ( # noqa: F401 (re-exported)
21
+ MODEL_PRICING,
22
+ get_context_window,
23
+ normalize_model_id,
24
+ pricing,
25
+ )
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects"
30
+ PANE_METRICS_DIR = Path.home() / ".claude" / "pane-metrics"
31
+ WORKTREES_BASE = Path.home() / "dev"
32
+ DEFAULT_CWD = os.environ.get("MARVIS_WORKSPACE_ROOT", str(Path.home() / "workspace"))
33
+
34
+ # Default context window (legacy constant; use get_context_window(model) for
35
+ # per-model lookup that handles Opus/Sonnet/Haiku correctly).
36
+ CONTEXT_WINDOW = 200_000
37
+
38
+
39
+ @dataclass
40
+ class SessionMetrics:
41
+ # Existing fields — preserve exact order/defaults for back-compat
42
+ conversation_id: str
43
+ model: str | None = None
44
+ context_pct: float = 0.0 # backward-compat alias — mirrors context_pct_real
45
+ cost_usd: float = 0.0 # backward-compat alias — mirrors cost_conversation_usd
46
+ message_count: int = 0
47
+ input_tokens: int = 0
48
+ output_tokens: int = 0
49
+ cache_read_tokens: int = 0
50
+ cache_write_tokens: int = 0
51
+ first_timestamp: str | None = None
52
+ last_timestamp: str | None = None
53
+ duration_minutes: float = 0.0
54
+
55
+ # --- PR2 additions ------------------------------------------------------
56
+ # Context %: real vs scaled (Claude scales by 100/84 to match the
57
+ # auto-compact threshold banner). For OpenCode, `context_pct_scaled` is
58
+ # always None — the 84% fudge factor is Claude-specific.
59
+ context_pct_real: float | None = None
60
+ context_pct_scaled: float | None = None
61
+
62
+ # Cost: conversation = this JSONL only; session = sum across resume chain.
63
+ # `cost_session_usd` is populated at the service layer (session_metrics
64
+ # service aggregates via `session_conversations` table). Parser fills
65
+ # `cost_conversation_usd` with its local total.
66
+ cost_conversation_usd: float | None = None
67
+ cost_session_usd: float | None = None
68
+ cost_session_incomplete: bool = False
69
+
70
+ # TTL-split cache writes (Anthropic: 1.25× 5m, 2× 1h base rate).
71
+ cache_write_5m_tokens: int = 0
72
+ cache_write_1h_tokens: int = 0
73
+
74
+ # Extended tokens
75
+ reasoning_tokens: int | None = None
76
+
77
+ # Wall-clock working time derived from message pairs.
78
+ working_seconds_msg: int | None = None
79
+
80
+ # Resume chain: conversation ids in order (oldest → newest). Empty for
81
+ # freshly started sessions or when the parser doesn't know the chain.
82
+ conversation_ids: list[str] = field(default_factory=list)
83
+
84
+ # Provenance
85
+ metrics_refreshed_at: str | None = None
86
+ pricing_version: str | None = None
87
+
88
+ # --- PR4 additions ------------------------------------------------------
89
+ # Shadow cost: what the session WOULD cost at pay-per-token API rates.
90
+ # For Claude sessions this equals the real cost (already pay-per-token);
91
+ # for OpenCode OAuth/free sessions (real cost=0) this exposes the
92
+ # hypothetical API bill. `None` means "unknown pricing, do not guess"
93
+ # (fallback_strategy=skip). `cost_equivalent_pricing_version` tracks
94
+ # which kb/opencode-pricing-*.json produced the number.
95
+ cost_conversation_equivalent_usd: float | None = None
96
+ cost_session_equivalent_usd: float | None = None
97
+ cost_equivalent_pricing_version: str | None = None
98
+
99
+
100
+ def normalize_cwd(cwd: str = DEFAULT_CWD) -> str:
101
+ """Return an absolute cwd in the format Claude Code encodes on disk."""
102
+ return os.path.abspath(os.path.expanduser(cwd))
103
+
104
+
105
+ def get_project_dir(cwd: str = DEFAULT_CWD) -> Path:
106
+ """Convert a working directory to Claude Code's project path."""
107
+ encoded = normalize_cwd(cwd).replace("/", "-")
108
+ return CLAUDE_PROJECTS_DIR / encoded
109
+
110
+
111
+ def _safe_mtime(path: Path) -> float:
112
+ """Safely get mtime; returns 0.0 on OSError (sorts to end of list)."""
113
+ try:
114
+ return path.stat().st_mtime
115
+ except OSError:
116
+ return 0.0
117
+
118
+
119
+ def find_conversation_for_worktree(worktree_path: str) -> str | None:
120
+ """Find the most recent Claude conversation JSONL for a given worktree path.
121
+
122
+ Claude Code stores conversations at:
123
+ ~/.claude/projects/{encoded_cwd}/{conversation_id}.jsonl
124
+ where encoded_cwd = cwd.replace("/", "-") (same logic as get_project_dir)
125
+
126
+ Security: validates worktree_path is under WORKTREES_BASE to prevent traversal.
127
+ Returns conversation_id (JSONL stem) of most-recent file by mtime, or None.
128
+ """
129
+ if not worktree_path:
130
+ return None
131
+ # Security: reject paths outside ~/dev/
132
+ resolved = Path(worktree_path.rstrip("/")).resolve()
133
+ if not resolved.is_relative_to(WORKTREES_BASE.resolve()):
134
+ logger.warning("Worktree path outside WORKTREES_BASE rejected: %s", worktree_path)
135
+ return None
136
+ # Reuse get_project_dir() — same encoding as Claude Code uses
137
+ project_dir = get_project_dir(worktree_path.rstrip("/"))
138
+ if not project_dir.is_dir():
139
+ logger.debug(
140
+ "No Claude project dir for worktree %s (expected %s)",
141
+ worktree_path,
142
+ project_dir,
143
+ )
144
+ return None
145
+ try:
146
+ files = sorted(project_dir.glob("*.jsonl"), key=_safe_mtime, reverse=True)
147
+ return files[0].stem if files else None
148
+ except OSError as exc:
149
+ logger.warning("Filesystem error scanning worktree %s: %s", worktree_path, exc)
150
+ return None
151
+
152
+
153
+ _PRICING_VERSION = "2026-04-22"
154
+
155
+
156
+ def parse_conversation(jsonl_path: Path) -> SessionMetrics | None:
157
+ """Parse a Claude Code conversation JSONL file for metrics.
158
+
159
+ PR2 additions:
160
+ - Split `cache_creation` into 5m/1h TTL buckets when JSONL provides
161
+ `ephemeral_5m_input_tokens`/`ephemeral_1h_input_tokens`. Legacy entries
162
+ with only `cache_creation_input_tokens` fall back to the 5m bucket
163
+ (conservative — 5m is the default TTL).
164
+ - Cost uses `model_registry.pricing(model)` TTL-split rates.
165
+ - `context_pct_real` = (inp + cr + cw5 + cw1) / context_window * 100.
166
+ - `context_pct_scaled` = real * 100 / 84 (Claude only, capped 100).
167
+ - `working_seconds_msg` pairs each user → next assistant timestamp,
168
+ skipping `<synthetic>` assistants and negative deltas.
169
+ """
170
+ if not jsonl_path.exists():
171
+ return None
172
+
173
+ conversation_id = jsonl_path.stem
174
+ model: str | None = None
175
+ total_input = 0
176
+ total_output = 0
177
+ total_cache_read = 0
178
+ total_cache_write_5m = 0
179
+ total_cache_write_1h = 0
180
+ message_count = 0
181
+ first_ts: str | None = None
182
+ last_ts: str | None = None
183
+ last_context_tokens = 0
184
+
185
+ cost = 0.0
186
+
187
+ # For working_seconds_msg: list of (kind, ts_epoch, is_synthetic)
188
+ # kind: "user" | "assistant"
189
+ events: list[tuple[str, float, bool]] = []
190
+
191
+ def _ts_to_epoch(ts: str | None) -> float | None:
192
+ if not ts:
193
+ return None
194
+ try:
195
+ return datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp()
196
+ except ValueError:
197
+ return None
198
+
199
+ try:
200
+ with open(jsonl_path) as f:
201
+ for line in f:
202
+ if not line.strip():
203
+ continue
204
+ try:
205
+ data = json.loads(line)
206
+ except json.JSONDecodeError:
207
+ continue
208
+
209
+ msg_type = data.get("type")
210
+ if msg_type == "user":
211
+ ts_epoch = _ts_to_epoch(data.get("timestamp"))
212
+ if ts_epoch is not None:
213
+ events.append(("user", ts_epoch, False))
214
+ continue
215
+
216
+ if msg_type == "assistant":
217
+ msg = data.get("message", {})
218
+ usage = msg.get("usage", {})
219
+ msg_model = msg.get("model")
220
+ is_synthetic = msg_model == "<synthetic>"
221
+
222
+ ts_epoch = _ts_to_epoch(data.get("timestamp"))
223
+ if ts_epoch is not None:
224
+ events.append(("assistant", ts_epoch, is_synthetic))
225
+
226
+ if not usage:
227
+ continue
228
+
229
+ if msg_model and not is_synthetic:
230
+ model = normalize_model_id(msg_model)
231
+
232
+ inp = usage.get("input_tokens", 0)
233
+ out = usage.get("output_tokens", 0)
234
+ cr = usage.get("cache_read_input_tokens", 0)
235
+ cw_total = usage.get("cache_creation_input_tokens", 0)
236
+
237
+ # TTL split: prefer explicit breakdown, fall back to 5m
238
+ creation = usage.get("cache_creation") or {}
239
+ cw5: int = 0
240
+ cw1: int = 0
241
+ if isinstance(creation, dict) and (
242
+ "ephemeral_5m_input_tokens" in creation
243
+ or "ephemeral_1h_input_tokens" in creation
244
+ ):
245
+ cw5 = int(creation.get("ephemeral_5m_input_tokens", 0) or 0)
246
+ cw1 = int(creation.get("ephemeral_1h_input_tokens", 0) or 0)
247
+ else:
248
+ # Legacy format: single cache_creation_input_tokens
249
+ # Default to 5m TTL (cheaper, Anthropic's default)
250
+ cw5 = int(cw_total or 0)
251
+ cw1 = 0
252
+
253
+ total_input += int(inp or 0)
254
+ total_output += int(out or 0)
255
+ total_cache_read += int(cr or 0)
256
+ total_cache_write_5m += cw5
257
+ total_cache_write_1h += cw1
258
+ message_count += 1
259
+
260
+ last_context_tokens = int(inp or 0) + int(cr or 0) + cw5 + cw1
261
+
262
+ # TTL-split pricing
263
+ p = pricing(model)
264
+ cost += (
265
+ int(inp or 0) * p.input
266
+ + int(out or 0) * p.output
267
+ + int(cr or 0) * p.cache_read
268
+ + cw5 * p.cache_write_5m
269
+ + cw1 * p.cache_write_1h
270
+ ) / 1_000_000
271
+
272
+ ts = data.get("timestamp")
273
+ if ts:
274
+ if not first_ts:
275
+ first_ts = ts
276
+ last_ts = ts
277
+
278
+ except (OSError, PermissionError) as e:
279
+ logger.error("Failed to read JSONL %s: %s", jsonl_path, e)
280
+ return None
281
+
282
+ if message_count == 0:
283
+ return None
284
+
285
+ duration_min = 0.0
286
+ if first_ts and last_ts:
287
+ try:
288
+ t1 = datetime.fromisoformat(first_ts.replace("Z", "+00:00"))
289
+ t2 = datetime.fromisoformat(last_ts.replace("Z", "+00:00"))
290
+ duration_min = (t2 - t1).total_seconds() / 60
291
+ except ValueError:
292
+ pass
293
+
294
+ ctx_window = get_context_window(model)
295
+ context_pct = (last_context_tokens / ctx_window * 100) if last_context_tokens else 0.0
296
+ context_pct_real = round(context_pct, 1)
297
+ # Scaled to match Claude Code auto-compact threshold banner (raw 84% ≈ full)
298
+ context_pct_scaled = round(min(context_pct_real * 100 / 84, 100.0), 1)
299
+
300
+ # working_seconds_msg: sum of (next_assistant - user) where next assistant
301
+ # is NOT <synthetic>, skipping negative deltas (clock skew / reordering).
302
+ working_seconds_msg = _pair_working_seconds(events)
303
+
304
+ cost_conversation = round(cost, 4)
305
+ total_cache_write = total_cache_write_5m + total_cache_write_1h
306
+
307
+ return SessionMetrics(
308
+ conversation_id=conversation_id,
309
+ model=model,
310
+ # Legacy aliases
311
+ context_pct=context_pct_real,
312
+ cost_usd=cost_conversation,
313
+ message_count=message_count,
314
+ input_tokens=total_input,
315
+ output_tokens=total_output,
316
+ cache_read_tokens=total_cache_read,
317
+ cache_write_tokens=total_cache_write,
318
+ first_timestamp=first_ts,
319
+ last_timestamp=last_ts,
320
+ duration_minutes=round(duration_min, 1),
321
+ # PR2
322
+ context_pct_real=context_pct_real,
323
+ context_pct_scaled=context_pct_scaled,
324
+ cost_conversation_usd=cost_conversation,
325
+ cost_session_usd=None, # populated at service layer
326
+ cost_session_incomplete=False,
327
+ cache_write_5m_tokens=total_cache_write_5m,
328
+ cache_write_1h_tokens=total_cache_write_1h,
329
+ reasoning_tokens=None, # Claude JSONL doesn't expose reasoning
330
+ working_seconds_msg=working_seconds_msg,
331
+ pricing_version=_PRICING_VERSION,
332
+ # PR4: Claude is always pay-per-token → equivalent mirrors real.
333
+ cost_conversation_equivalent_usd=cost_conversation,
334
+ cost_session_equivalent_usd=None, # aggregated at service layer
335
+ cost_equivalent_pricing_version=_PRICING_VERSION,
336
+ )
337
+
338
+
339
+ def _pair_working_seconds(events: list[tuple[str, float, bool]]) -> int:
340
+ """Pair each user message with the next non-synthetic assistant.
341
+
342
+ Returns total seconds (int). Skips negative deltas (clock skew).
343
+ `events` must be in file/append order — i.e. chronological for Claude.
344
+ """
345
+ total = 0.0
346
+ i = 0
347
+ n = len(events)
348
+ while i < n:
349
+ kind, ts, _ = events[i]
350
+ if kind != "user":
351
+ i += 1
352
+ continue
353
+ # Find next assistant (non-synthetic)
354
+ j = i + 1
355
+ while j < n:
356
+ nkind, nts, syn = events[j]
357
+ if nkind == "assistant":
358
+ if syn:
359
+ j += 1
360
+ continue
361
+ delta = nts - ts
362
+ if delta > 0:
363
+ total += delta
364
+ break
365
+ j += 1
366
+ i = j if j > i else i + 1
367
+ return int(total)
368
+
369
+
370
+ def get_jsonl_mtime(conversation_id: str, cwd: str = DEFAULT_CWD) -> float | None:
371
+ """Return the mtime of a conversation JSONL file, or None if not found.
372
+
373
+ The encoded path is deterministic from cwd — no directory scan needed.
374
+ Pass the actual working directory (incl. worktree path) as cwd for an
375
+ accurate lookup.
376
+ """
377
+ primary = get_project_dir(cwd) / f"{conversation_id}.jsonl"
378
+ try:
379
+ return primary.stat().st_mtime
380
+ except OSError:
381
+ return None
382
+
383
+
384
+ def get_last_context_pct(
385
+ conversation_id: str,
386
+ cwd: str = DEFAULT_CWD,
387
+ chunk_size: int = 32768,
388
+ ) -> float | None:
389
+ """Fast context % via tail-read: reads only the last chunk_size bytes.
390
+
391
+ Scans lines in reverse to find the last assistant message with usage data.
392
+ O(chunk_size) instead of O(file_size). Falls back to larger chunks if needed.
393
+ Returns context_pct (0-100) or None if not found.
394
+ """
395
+ path = get_project_dir(cwd) / f"{conversation_id}.jsonl"
396
+ if not path.exists():
397
+ return None
398
+
399
+ try:
400
+ file_size = path.stat().st_size
401
+ except OSError:
402
+ return None
403
+
404
+ for attempt in range(3): # try up to 3 chunk sizes: 32K, 128K, 512K
405
+ current_chunk = chunk_size * (4 ** attempt)
406
+ read_size = min(current_chunk, file_size)
407
+ offset = max(0, file_size - read_size)
408
+
409
+ try:
410
+ with open(path, "rb") as f:
411
+ f.seek(offset)
412
+ raw = f.read(read_size)
413
+ except OSError:
414
+ return None
415
+
416
+ # Decode with replacement to handle any encoding quirks
417
+ text = raw.decode("utf-8", errors="replace")
418
+
419
+ # Split lines and iterate in reverse (skip first partial line if offset > 0)
420
+ lines = text.split("\n")
421
+ start = 1 if offset > 0 else 0 # skip first line (may be partial)
422
+
423
+ for line in reversed(lines[start:]):
424
+ line = line.strip()
425
+ if not line:
426
+ continue
427
+ try:
428
+ data = json.loads(line)
429
+ except json.JSONDecodeError:
430
+ continue
431
+ if data.get("type") != "assistant":
432
+ continue
433
+ usage = data.get("message", {}).get("usage", {})
434
+ if not usage:
435
+ continue
436
+ inp = usage.get("input_tokens", 0)
437
+ cr = usage.get("cache_read_input_tokens", 0)
438
+ cw = usage.get("cache_creation_input_tokens", 0)
439
+ total = inp + cr + cw
440
+ msg_model = normalize_model_id(data.get("message", {}).get("model"))
441
+ if total > 0:
442
+ ctx_window = get_context_window(msg_model)
443
+ return round(total / ctx_window * 100, 1)
444
+
445
+ # If we read the whole file and found nothing, stop
446
+ if read_size >= file_size:
447
+ break
448
+
449
+ return None
450
+
451
+
452
+ def find_conversation_by_id(conversation_id: str, cwd: str = DEFAULT_CWD) -> SessionMetrics | None:
453
+ """Parse a specific conversation by its UUID.
454
+
455
+ The encoded project directory is deterministic from cwd — pass the exact
456
+ working directory (including the worktree path for worktree sessions) so
457
+ the lookup is a single stat, not a directory scan.
458
+ """
459
+ primary = get_project_dir(cwd) / f"{conversation_id}.jsonl"
460
+ if primary.exists():
461
+ return parse_conversation(primary)
462
+ return None
463
+
464
+
465
+ def find_conversation_cwd(
466
+ conversation_id: str,
467
+ candidate_cwds: Iterable[str],
468
+ ) -> str | None:
469
+ """Return the first cwd whose Claude project dir contains the conversation."""
470
+ seen: set[str] = set()
471
+ for cwd in candidate_cwds:
472
+ normalized = normalize_cwd(cwd)
473
+ if normalized in seen:
474
+ continue
475
+ seen.add(normalized)
476
+ if (get_project_dir(normalized) / f"{conversation_id}.jsonl").exists():
477
+ return normalized
478
+ return None
479
+
480
+
481
+ def find_recent_conversations(cwd: str = DEFAULT_CWD, limit: int = 20) -> list[tuple[str, float]]:
482
+ """List recent conversations sorted by modification time. Returns (uuid, mtime) pairs."""
483
+ project_dir = get_project_dir(cwd)
484
+ if not project_dir.exists():
485
+ return []
486
+
487
+ files = []
488
+ for jsonl_path in project_dir.glob("*.jsonl"):
489
+ try:
490
+ mtime = jsonl_path.stat().st_mtime
491
+ files.append((jsonl_path.stem, mtime))
492
+ except OSError:
493
+ continue
494
+
495
+ files.sort(key=lambda x: x[1], reverse=True)
496
+ return files[:limit]
497
+
498
+
499
+ def detect_conversation_for_session(
500
+ session_created_epoch: float,
501
+ cwd: str = DEFAULT_CWD,
502
+ ) -> str | None:
503
+ """Find the conversation JSONL for a tmux session.
504
+
505
+ Strategy:
506
+ 1. Try direct lookup in the cwd project dir first (fast path).
507
+ 2. Find JSONL whose first timestamp is closest to pane_start_time (within 5 min)
508
+ 3. If no timestamp match, find the JSONL whose first timestamp is AFTER
509
+ pane_start_time and most recently modified (the active conversation in that pane)
510
+
511
+ Falls back to scanning recent project dirs only when cwd direct lookup fails.
512
+ Caps the scan at MAX_DIRS project directories (sorted by mtime) to bound cost.
513
+ """
514
+ from datetime import datetime
515
+
516
+ MAX_DIRS = 20 # only inspect the 20 most-recently-modified project dirs
517
+
518
+ # Fast path: if cwd is known, start with its project dir only
519
+ cwd_project_dir = get_project_dir(cwd)
520
+
521
+ # Collect JSONL files — primary dir first, then recent project dirs up to cap
522
+ all_convs: dict[str, tuple[float, Path]] = {} # conv_id -> (mtime, path)
523
+
524
+ def _collect_from_dir(pdir: Path) -> None:
525
+ for jsonl_path in pdir.glob("*.jsonl"):
526
+ try:
527
+ conv_id = jsonl_path.stem
528
+ mtime = jsonl_path.stat().st_mtime
529
+ if conv_id not in all_convs or mtime > all_convs[conv_id][0]:
530
+ all_convs[conv_id] = (mtime, jsonl_path)
531
+ except OSError:
532
+ continue
533
+
534
+ if cwd_project_dir.is_dir():
535
+ _collect_from_dir(cwd_project_dir)
536
+
537
+ if CLAUDE_PROJECTS_DIR.exists():
538
+ # Sort project dirs by mtime descending; cap at MAX_DIRS to avoid full scan
539
+ try:
540
+ project_dirs = sorted(
541
+ (d for d in CLAUDE_PROJECTS_DIR.iterdir() if d.is_dir() and d != cwd_project_dir),
542
+ key=_safe_mtime,
543
+ reverse=True,
544
+ )[:MAX_DIRS]
545
+ except OSError:
546
+ project_dirs = []
547
+ for pdir in project_dirs:
548
+ _collect_from_dir(pdir)
549
+
550
+ recent = sorted(all_convs.items(), key=lambda x: x[1][0], reverse=True)[:50]
551
+
552
+ best_id = None
553
+ best_delta = float("inf")
554
+
555
+ # Fallback: JSONL started after pane, most recently modified
556
+ fallback_id = None
557
+ fallback_mtime = 0.0
558
+
559
+ for conv_id, (mtime, jsonl_path) in recent:
560
+ try:
561
+ # Scan first few lines — JSONL may start with file-history-snapshot
562
+ # entries (no timestamp) before the first real user/assistant message.
563
+ ts_str = None
564
+ with open(jsonl_path) as f:
565
+ for _ in range(5):
566
+ line = f.readline()
567
+ if not line:
568
+ break
569
+ try:
570
+ ts_str = json.loads(line).get("timestamp")
571
+ except json.JSONDecodeError:
572
+ continue
573
+ if ts_str:
574
+ break
575
+ if not ts_str:
576
+ continue
577
+
578
+ file_ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00")).timestamp()
579
+ delta = abs(file_ts - session_created_epoch)
580
+
581
+ # Strategy 1: closest match within 5 minutes
582
+ if delta < best_delta and delta < 300:
583
+ best_delta = delta
584
+ best_id = conv_id
585
+
586
+ # Strategy 2: conversation started after pane, most recently modified
587
+ if file_ts >= session_created_epoch and mtime > fallback_mtime:
588
+ fallback_mtime = mtime
589
+ fallback_id = conv_id
590
+ except (OSError, json.JSONDecodeError, ValueError):
591
+ continue
592
+
593
+ return best_id or fallback_id
594
+
595
+
596
+ _UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
597
+
598
+
599
+ def detect_conversation_by_pid(claude_pid: int) -> str | None:
600
+ """Detect conversation_id by inspecting /proc/{pid}/fd/ for tasks directory.
601
+
602
+ Claude Code opens ~/.claude/tasks/{conversation_id}/ during a session.
603
+ Claude 4.6 may also open an FD on ~/.claude/tasks (the directory itself)
604
+ without a UUID subdirectory in the path — in that case we fall back to
605
+ scanning tasks/ for the most recently modified UUID subdirectory.
606
+ """
607
+ fd_dir = Path(f"/proc/{claude_pid}/fd")
608
+ if not fd_dir.exists():
609
+ return None
610
+
611
+ tasks_prefix = str(Path.home() / ".claude" / "tasks")
612
+ found_tasks_dir = False
613
+ try:
614
+ for entry in fd_dir.iterdir():
615
+ try:
616
+ target = os.readlink(str(entry))
617
+ except OSError:
618
+ continue
619
+ # Remove " (deleted)" suffix if present
620
+ target = target.replace(" (deleted)", "")
621
+ if target.startswith(tasks_prefix):
622
+ rel = target[len(tasks_prefix):].strip("/")
623
+ # Direct UUID match in path
624
+ match = _UUID_RE.match(rel)
625
+ if match:
626
+ return match.group(0)
627
+ # FD points to tasks directory itself (Claude 4.6 pattern)
628
+ if not rel:
629
+ found_tasks_dir = True
630
+ except OSError as e:
631
+ logger.debug("Failed to read /proc/%d/fd: %s", claude_pid, e)
632
+
633
+ # Fallback: if we found the tasks directory but no UUID in FDs,
634
+ # scan tasks/ for the most recently modified UUID subdirectory.
635
+ # Best-effort for multi-session scenarios.
636
+ if found_tasks_dir:
637
+ tasks_path = Path.home() / ".claude" / "tasks"
638
+ try:
639
+ best_uuid = None
640
+ best_mtime = 0.0
641
+ for entry in tasks_path.iterdir():
642
+ if entry.is_dir() and _UUID_RE.match(entry.name):
643
+ try:
644
+ mtime = entry.stat().st_mtime
645
+ if mtime > best_mtime:
646
+ best_mtime = mtime
647
+ best_uuid = entry.name
648
+ except OSError:
649
+ continue
650
+ return best_uuid
651
+ except OSError:
652
+ pass
653
+
654
+ return None
655
+
656
+
657
+ def detect_activity_by_mtime(
658
+ conversation_id: str,
659
+ cwd: str = DEFAULT_CWD,
660
+ working_threshold: float = 15.0,
661
+ ) -> str:
662
+ """Detect activity state by JSONL file modification time.
663
+
664
+ Used as fallback when pane is too small for content analysis.
665
+ If JSONL was modified within threshold seconds → working, else → idle.
666
+ """
667
+ import time
668
+
669
+ project_dir = get_project_dir(cwd)
670
+ jsonl_path = project_dir / f"{conversation_id}.jsonl"
671
+ try:
672
+ mtime = jsonl_path.stat().st_mtime
673
+ age = time.time() - mtime
674
+ return "working" if age < working_threshold else "idle"
675
+ except OSError:
676
+ return "working" # can't read → assume working
677
+
678
+
679
+ @dataclass
680
+ class PaneMetrics:
681
+ """Metrics from statusline.sh per-pane file."""
682
+ session_id: str
683
+ used_pct: float
684
+ cost_usd: float
685
+ model: str | None = None
686
+ timestamp: str | None = None
687
+
688
+
689
+ def read_pane_metrics(pane_id: str, max_age: float = 120.0) -> PaneMetrics | None:
690
+ """Read per-pane metrics written by statusline.sh.
691
+
692
+ Each Claude Code session writes ~/.claude/pane-metrics/{pane_num}.json
693
+ on every status line update. Contains session_id (= conversation_id),
694
+ used_pct (Claude's own context %), cost, model.
695
+
696
+ Args:
697
+ pane_id: tmux pane ID (e.g., "%70" or "70" — digits extracted)
698
+ max_age: ignore files older than this (seconds), default 2 min
699
+
700
+ Returns PaneMetrics or None if file missing/stale/invalid.
701
+ """
702
+ # Extract digits from pane_id (e.g., "%70" → "70")
703
+ digits = re.sub(r"[^0-9]", "", pane_id)
704
+ if not digits:
705
+ return None
706
+
707
+ path = PANE_METRICS_DIR / f"{digits}.json"
708
+ try:
709
+ stat = path.stat()
710
+ age = _time.time() - stat.st_mtime
711
+ if age > max_age:
712
+ return None
713
+ with open(path) as f:
714
+ data = json.load(f)
715
+ except (OSError, json.JSONDecodeError, ValueError):
716
+ return None
717
+
718
+ sid = data.get("session_id")
719
+ if not sid or not _UUID_RE.match(sid):
720
+ return None
721
+
722
+ return PaneMetrics(
723
+ session_id=sid,
724
+ used_pct=float(data.get("used_pct", 0)),
725
+ cost_usd=float(data.get("cost_usd", 0)),
726
+ model=normalize_model_id(data.get("model")) or None,
727
+ timestamp=data.get("timestamp"),
728
+ )
729
+
730
+
731
+ def read_pane_session_id(pane_id: str) -> str | None:
732
+ """Read only session_id from pane-metrics file, ignoring staleness.
733
+
734
+ The session_id (= conversation_id) is stable for the lifetime of a
735
+ Claude session — it doesn't change when Claude is idle. So we can
736
+ read it regardless of file age, unlike read_pane_metrics() which
737
+ enforces a max_age check.
738
+ """
739
+ digits = re.sub(r"[^0-9]", "", pane_id)
740
+ if not digits:
741
+ return None
742
+
743
+ path = PANE_METRICS_DIR / f"{digits}.json"
744
+ try:
745
+ with open(path) as f:
746
+ data = json.load(f)
747
+ except (OSError, json.JSONDecodeError, ValueError):
748
+ return None
749
+
750
+ sid = data.get("session_id")
751
+ if sid and _UUID_RE.match(sid):
752
+ return sid
753
+ return None
754
+
755
+
756
+ def cleanup_pane_metrics(active_pane_ids: set[str]) -> None:
757
+ """Remove pane metrics files for dead panes."""
758
+ if not PANE_METRICS_DIR.is_dir():
759
+ return
760
+ active_digits = {re.sub(r"[^0-9]", "", p) for p in active_pane_ids}
761
+ try:
762
+ for path in PANE_METRICS_DIR.glob("*.json"):
763
+ if path.stem not in active_digits:
764
+ path.unlink(missing_ok=True)
765
+ except OSError:
766
+ pass
767
+
768
+
769
+ # --------------------------------------------------------------------------
770
+ # MetricsProvider adapter (PR1 — see docs/plans/2026-04-22-feat-metrics-
771
+ # provider-consistency-plan.md §Phase 1). Wraps existing functions to satisfy
772
+ # the Protocol defined in api/services/metrics_providers.py. No behavior
773
+ # change for Claude sessions.
774
+ # --------------------------------------------------------------------------
775
+
776
+
777
+ class ClaudeMetricsProvider:
778
+ """MetricsProvider for Claude Code JSONL-backed sessions."""
779
+
780
+ name = "claude"
781
+
782
+ def parse_session(
783
+ self,
784
+ session_id: str,
785
+ cwd: str | None = None,
786
+ ) -> SessionMetrics | None:
787
+ """Parse a Claude conversation JSONL by id. Thin wrapper over find_conversation_by_id."""
788
+ return find_conversation_by_id(session_id, cwd or DEFAULT_CWD)
789
+
790
+ def get_last_context_pct(
791
+ self,
792
+ session_id: str,
793
+ cwd: str | None = None,
794
+ ) -> float | None:
795
+ """Fast tail-read of the last assistant message's context %."""
796
+ return get_last_context_pct(session_id, cwd or DEFAULT_CWD)