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.
- core/api/__init__.py +0 -0
- core/api/agents/__init__.py +0 -0
- core/api/agents/session_health.py +59 -0
- core/api/agents/session_manager.py +206 -0
- core/api/bin/marvisx-state-hook.py +182 -0
- core/api/config.py +533 -0
- core/api/db.py +1516 -0
- core/api/dependencies/__init__.py +0 -0
- core/api/dependencies/tenant.py +34 -0
- core/api/main.py +1641 -0
- core/api/mcp/__init__.py +8 -0
- core/api/mcp/_adapter.py +184 -0
- core/api/mcp/server.py +58 -0
- core/api/mcp/tools/__init__.py +59 -0
- core/api/mcp/tools/brain.py +599 -0
- core/api/mcp/tools/graph.py +380 -0
- core/api/mcp/tools/handoffs.py +112 -0
- core/api/mcp/tools/ingest.py +326 -0
- core/api/mcp/tools/learnings.py +144 -0
- core/api/mcp/tools/projects.py +99 -0
- core/api/mcp/tools/pull_requests.py +173 -0
- core/api/mcp/tools/safety.py +111 -0
- core/api/mcp/tools/search.py +79 -0
- core/api/mcp/tools/tasks.py +258 -0
- core/api/middleware/__init__.py +0 -0
- core/api/middleware/tool_call_audit.py +111 -0
- core/api/models/__init__.py +346 -0
- core/api/models/auth.py +48 -0
- core/api/models/brain.py +1006 -0
- core/api/models/common.py +76 -0
- core/api/models/costs.py +91 -0
- core/api/models/graph.py +66 -0
- core/api/models/graph_cosmo.py +125 -0
- core/api/models/graph_pr_impact.py +257 -0
- core/api/models/graph_ux.py +141 -0
- core/api/models/inbox.py +230 -0
- core/api/models/ingest_keys.py +108 -0
- core/api/models/kg.py +41 -0
- core/api/models/llm_config.py +56 -0
- core/api/models/monitoring.py +234 -0
- core/api/models/projects.py +161 -0
- core/api/models/search.py +42 -0
- core/api/models/sessions.py +322 -0
- core/api/models/tasks.py +184 -0
- core/api/models/teams.py +63 -0
- core/api/models/users.py +184 -0
- core/api/observability/__init__.py +0 -0
- core/api/observability/tracing.py +92 -0
- core/api/paths.py +26 -0
- core/api/rate_limit.py +24 -0
- core/api/rbac.py +112 -0
- core/api/routers/__init__.py +0 -0
- core/api/routers/_adapter.py +24 -0
- core/api/routers/admin_pr_impact.py +230 -0
- core/api/routers/admin_settings.py +147 -0
- core/api/routers/agent.py +1079 -0
- core/api/routers/agent_tokens.py +276 -0
- core/api/routers/app_settings.py +112 -0
- core/api/routers/audit.py +89 -0
- core/api/routers/auth.py +586 -0
- core/api/routers/bench.py +161 -0
- core/api/routers/brain.py +881 -0
- core/api/routers/brain_directions.py +527 -0
- core/api/routers/ci_checks.py +140 -0
- core/api/routers/comments.py +273 -0
- core/api/routers/costs.py +148 -0
- core/api/routers/docs_coverage.py +217 -0
- core/api/routers/docs_governance.py +63 -0
- core/api/routers/documents.py +318 -0
- core/api/routers/files.py +163 -0
- core/api/routers/finder.py +987 -0
- core/api/routers/graph.py +836 -0
- core/api/routers/handoffs.py +156 -0
- core/api/routers/inbox.py +496 -0
- core/api/routers/ingest_api_keys.py +205 -0
- core/api/routers/ingest_triage.py +1227 -0
- core/api/routers/judge.py +306 -0
- core/api/routers/kg.py +336 -0
- core/api/routers/learnings.py +253 -0
- core/api/routers/llm_config.py +130 -0
- core/api/routers/monitoring.py +347 -0
- core/api/routers/notifications.py +125 -0
- core/api/routers/pr_impact.py +315 -0
- core/api/routers/projects.py +1061 -0
- core/api/routers/pull_requests.py +312 -0
- core/api/routers/push.py +67 -0
- core/api/routers/raci.py +228 -0
- core/api/routers/search.py +125 -0
- core/api/routers/sessions.py +3100 -0
- core/api/routers/settings.py +90 -0
- core/api/routers/share_repo.py +68 -0
- core/api/routers/status_updates.py +96 -0
- core/api/routers/tags.py +45 -0
- core/api/routers/tasks.py +526 -0
- core/api/routers/teams.py +425 -0
- core/api/routers/terminal.py +105 -0
- core/api/routers/users.py +331 -0
- core/api/routers/webhooks.py +330 -0
- core/api/runtime_settings.py +84 -0
- core/api/security.py +652 -0
- core/api/services/__init__.py +0 -0
- core/api/services/audit.py +58 -0
- core/api/services/auto_approval.py +82 -0
- core/api/services/brain/__init__.py +52 -0
- core/api/services/brain/baseline.py +230 -0
- core/api/services/brain/capabilities.py +75 -0
- core/api/services/brain/cascade_rollup.py +388 -0
- core/api/services/brain/compound_bridge.py +215 -0
- core/api/services/brain/cycle.py +1242 -0
- core/api/services/brain/cycle_snapshot.py +371 -0
- core/api/services/brain/digest_collector.py +147 -0
- core/api/services/brain/direction.py +421 -0
- core/api/services/brain/drift.py +356 -0
- core/api/services/brain/drift_router.py +409 -0
- core/api/services/brain/edge_metrics.py +79 -0
- core/api/services/brain/events_reader.py +222 -0
- core/api/services/brain/findings.py +1379 -0
- core/api/services/brain/findings_reader.py +1006 -0
- core/api/services/brain/jobs.py +733 -0
- core/api/services/brain/journal.py +206 -0
- core/api/services/brain/knowledge_forms.py +92 -0
- core/api/services/brain/llm/__init__.py +37 -0
- core/api/services/brain/llm/_runner.py +62 -0
- core/api/services/brain/llm/base.py +70 -0
- core/api/services/brain/llm/cache.py +99 -0
- core/api/services/brain/llm/constants.py +46 -0
- core/api/services/brain/llm/direction_alignment.py +289 -0
- core/api/services/brain/llm/factory.py +132 -0
- core/api/services/brain/llm/finding_reasoning.py +98 -0
- core/api/services/brain/llm/finding_summary.py +92 -0
- core/api/services/brain/llm/grounding.py +46 -0
- core/api/services/brain/llm/journal_polish.py +96 -0
- core/api/services/brain/llm/local_gateway.py +426 -0
- core/api/services/brain/llm/parsers.py +71 -0
- core/api/services/brain/llm/router_glue.py +422 -0
- core/api/services/brain/memory_ops.py +1677 -0
- core/api/services/brain/models.py +140 -0
- core/api/services/brain/owner_hint.py +211 -0
- core/api/services/brain/recap.py +307 -0
- core/api/services/brain/rules/__init__.py +65 -0
- core/api/services/brain/rules/_signals.py +205 -0
- core/api/services/brain/rules/dr1_activity_without_status.py +108 -0
- core/api/services/brain/rules/dr2_decision_without_adr.py +110 -0
- core/api/services/brain/rules/dr3_stale_open_loop.py +127 -0
- core/api/services/brain/rules/dr4_docs_governance_drift.py +79 -0
- core/api/services/brain/rules/dr5_playbook_changed.py +99 -0
- core/api/services/brain/rules/dr6_external_update_unpropagated.py +100 -0
- core/api/services/brain/rules/dr7_claimed_decision_gap.py +123 -0
- core/api/services/brain/rules/dr8_direction_misalignment.py +230 -0
- core/api/services/brain/runs_reader.py +485 -0
- core/api/services/brain/scope.py +79 -0
- core/api/services/brain/sources/__init__.py +46 -0
- core/api/services/brain/sources/base.py +86 -0
- core/api/services/brain/sources/git_kg.py +393 -0
- core/api/services/brain/sources/handoffs.py +157 -0
- core/api/services/brain/sources/ingestor.py +130 -0
- core/api/services/brain/sources/learnings.py +121 -0
- core/api/services/brain/sources/pir_tasks.py +245 -0
- core/api/services/brain/watermarks.py +147 -0
- core/api/services/brain/ws_emitter.py +170 -0
- core/api/services/cc_tasks_reader.py +76 -0
- core/api/services/ci_service.py +263 -0
- core/api/services/claude_metrics.py +796 -0
- core/api/services/codex_metrics.py +364 -0
- core/api/services/conversation_reader.py +102 -0
- core/api/services/cost_service.py +243 -0
- core/api/services/crypto.py +147 -0
- core/api/services/docs_governance/__init__.py +1 -0
- core/api/services/docs_governance/confidence.py +230 -0
- core/api/services/docs_governance/config.py +87 -0
- core/api/services/docs_governance/enrichment.py +65 -0
- core/api/services/docs_governance/frontmatter_validator.py +83 -0
- core/api/services/docs_governance/hard_gates.py +221 -0
- core/api/services/docs_governance/triage_orchestrator.py +98 -0
- core/api/services/embedding_internal.py +395 -0
- core/api/services/embedding_service.py +832 -0
- core/api/services/event_dispatcher.py +167 -0
- core/api/services/events.py +70 -0
- core/api/services/git_ops.py +621 -0
- core/api/services/graph_cosmo_service.py +440 -0
- core/api/services/graph_ranker.py +306 -0
- core/api/services/graph_service.py +1589 -0
- core/api/services/inbox.py +800 -0
- core/api/services/inbox_digest.py +221 -0
- core/api/services/inbox_digest_deep_research.py +80 -0
- core/api/services/inbox_digest_jobs.py +595 -0
- core/api/services/inbox_gmail_sync.py +167 -0
- core/api/services/inbox_llm_classifier.py +906 -0
- core/api/services/inbox_source_identity.py +116 -0
- core/api/services/inbox_sources.py +456 -0
- core/api/services/inbox_taxonomy.py +195 -0
- core/api/services/inbox_tldr.py +1079 -0
- core/api/services/inbox_triage.py +899 -0
- core/api/services/ingest/__init__.py +13 -0
- core/api/services/ingest/api_key_auth.py +136 -0
- core/api/services/ingest/auto_approve.py +120 -0
- core/api/services/ingest/classifier.py +138 -0
- core/api/services/ingest/confidence.py +173 -0
- core/api/services/ingest/dispatch.py +88 -0
- core/api/services/ingest/embedding_router.py +272 -0
- core/api/services/ingest/events.py +33 -0
- core/api/services/ingest/ignore_patterns.py +79 -0
- core/api/services/ingest/image_probe.py +218 -0
- core/api/services/ingest/ingress.py +263 -0
- core/api/services/ingest/insert_saga.py +793 -0
- core/api/services/ingest/llm/__init__.py +13 -0
- core/api/services/ingest/llm/anthropic_haiku.py +23 -0
- core/api/services/ingest/llm/base.py +59 -0
- core/api/services/ingest/llm/byok_provider.py +130 -0
- core/api/services/ingest/llm/classification_context.py +301 -0
- core/api/services/ingest/llm/config_store.py +246 -0
- core/api/services/ingest/llm/factory.py +24 -0
- core/api/services/ingest/llm/kg_enricher.py +306 -0
- core/api/services/ingest/llm/local_gateway.py +821 -0
- core/api/services/ingest/llm/local_vllm.py +23 -0
- core/api/services/ingest/llm/openai_nano.py +349 -0
- core/api/services/ingest/lock_advisory.py +57 -0
- core/api/services/ingest/parser_router.py +1756 -0
- core/api/services/ingest/parsers/__init__.py +1 -0
- core/api/services/ingest/parsers/docling_parser.py +142 -0
- core/api/services/ingest/parsers/docparse_gateway.py +178 -0
- core/api/services/ingest/parsers/docx_parser.py +127 -0
- core/api/services/ingest/parsers/folder_unpacker.py +85 -0
- core/api/services/ingest/parsers/gateway_aux.py +147 -0
- core/api/services/ingest/parsers/image_parser.py +251 -0
- core/api/services/ingest/parsers/internal_markdown.py +89 -0
- core/api/services/ingest/parsers/ocr_gateway.py +117 -0
- core/api/services/ingest/parsers/ocr_pdf_parser.py +112 -0
- core/api/services/ingest/parsers/pdf_types.py +13 -0
- core/api/services/ingest/parsers/transcript_parser.py +445 -0
- core/api/services/ingest/parsers/vision_gateway.py +186 -0
- core/api/services/ingest/parsers/xlsx_parser.py +91 -0
- core/api/services/ingest/parsers/zip_unpacker.py +126 -0
- core/api/services/ingest/preflight.py +393 -0
- core/api/services/ingest/retry_voyage.py +88 -0
- core/api/services/ingest/routing_policy.py +307 -0
- core/api/services/ingest/serializers/__init__.py +1 -0
- core/api/services/ingest/serializers/xlsx_to_markdown.py +80 -0
- core/api/services/ingest/skip_log.py +74 -0
- core/api/services/ingest/watcher.py +637 -0
- core/api/services/kg/__init__.py +0 -0
- core/api/services/kg/audit.py +49 -0
- core/api/services/kg/hybrid_search.py +691 -0
- core/api/services/kg/lens.py +339 -0
- core/api/services/kg/pr_impact.py +770 -0
- core/api/services/kg/queries.py +152 -0
- core/api/services/kg/ranking.py +89 -0
- core/api/services/kg/rrf.py +143 -0
- core/api/services/kg_watcher_control.py +161 -0
- core/api/services/local_llm/__init__.py +19 -0
- core/api/services/local_llm/async_client.py +385 -0
- core/api/services/local_llm/client.py +173 -0
- core/api/services/local_llm/url_validator.py +44 -0
- core/api/services/metrics_collector.py +646 -0
- core/api/services/metrics_providers.py +65 -0
- core/api/services/model_registry.py +266 -0
- core/api/services/model_router.py +137 -0
- core/api/services/n8n_client.py +77 -0
- core/api/services/newsletter_llm_gateway.py +66 -0
- core/api/services/notification_service.py +134 -0
- core/api/services/openai_responses.py +55 -0
- core/api/services/opencode_metrics.py +375 -0
- core/api/services/opencode_sessions.py +173 -0
- core/api/services/pii_redactor.py +138 -0
- core/api/services/pr_impact_pipeline/__init__.py +21 -0
- core/api/services/pr_impact_pipeline/differ.py +421 -0
- core/api/services/pr_impact_pipeline/dispatcher.py +415 -0
- core/api/services/pr_impact_pipeline/gc.py +93 -0
- core/api/services/pr_impact_pipeline/languages.py +192 -0
- core/api/services/pr_impact_pipeline/parser.py +178 -0
- core/api/services/pr_impact_pipeline/writer.py +394 -0
- core/api/services/pr_service.py +1393 -0
- core/api/services/project_paths.py +70 -0
- core/api/services/project_status_updates.py +265 -0
- core/api/services/providers.py +276 -0
- core/api/services/push_service.py +170 -0
- core/api/services/reminder_service.py +89 -0
- core/api/services/runas.py +41 -0
- core/api/services/salience_service.py +69 -0
- core/api/services/security_collector.py +281 -0
- core/api/services/session_catalog.py +385 -0
- core/api/services/session_metrics_service.py +301 -0
- core/api/services/session_ops.py +272 -0
- core/api/services/session_state.py +173 -0
- core/api/services/share_links.py +222 -0
- core/api/services/task_transitions.py +146 -0
- core/api/services/terminal_metrics.py +462 -0
- core/api/services/terminal_metrics_dump.py +203 -0
- core/api/services/tmux.py +1205 -0
- core/api/services/webhook_service.py +422 -0
- core/api/services/workspace_sync.py +164 -0
- core/api/templates/__init__.py +1 -0
- core/api/templates/markdown_share.py +164 -0
- core/api/terminal.py +1031 -0
- core/api/tests/__init__.py +0 -0
- core/api/tests/test_agent_facing_auth_dependencies.py +132 -0
- core/api/tests/test_audit_permissions.py +133 -0
- core/api/tests/test_backfill_session_conversations.py +90 -0
- core/api/tests/test_backfill_working_seconds_msg.py +129 -0
- core/api/tests/test_claude_metrics.py +326 -0
- core/api/tests/test_codex_metrics.py +189 -0
- core/api/tests/test_finder_paths.py +74 -0
- core/api/tests/test_git_ops_merge.py +155 -0
- core/api/tests/test_learnings_check_search.py +81 -0
- core/api/tests/test_metrics_providers.py +133 -0
- core/api/tests/test_migration_087.py +164 -0
- core/api/tests/test_migration_088.py +94 -0
- core/api/tests/test_migration_089.py +116 -0
- core/api/tests/test_openai_responses.py +24 -0
- core/api/tests/test_opencode_metrics.py +740 -0
- core/api/tests/test_opencode_sessions.py +321 -0
- core/api/tests/test_pr_workflow_e2e.py +457 -0
- core/api/tests/test_projects_handoffs.py +31 -0
- core/api/tests/test_providers.py +138 -0
- core/api/tests/test_safety_bridge.py +347 -0
- core/api/tests/test_session_catalog.py +142 -0
- core/api/tests/test_session_conversations.py +512 -0
- core/api/tests/test_session_metrics_service.py +270 -0
- core/api/tests/test_session_resume_paths.py +548 -0
- core/api/tests/test_session_theme_mode_migration.py +56 -0
- core/api/tests/test_sessions_rbac.py +131 -0
- core/api/tests/test_share_edit.py +398 -0
- core/api/tests/test_share_repo.py +200 -0
- core/api/tests/test_terminal_session_manager.py +98 -0
- core/api/tests/test_terminal_upload.py +34 -0
- core/api/tests/test_tmux.py +272 -0
- core/api/tests/test_workspace_sync.py +186 -0
- core/api/tests/test_ws_ticket_in_memory.py +73 -0
- core/api/use_cases/__init__.py +11 -0
- core/api/use_cases/_context.py +89 -0
- core/api/use_cases/_errors.py +62 -0
- core/api/use_cases/_roles.py +16 -0
- core/api/use_cases/audit.py +171 -0
- core/api/use_cases/brain.py +1232 -0
- core/api/use_cases/costs.py +249 -0
- core/api/use_cases/graph.py +1153 -0
- core/api/use_cases/handoffs.py +506 -0
- core/api/use_cases/ingest_triage.py +1229 -0
- core/api/use_cases/learnings.py +538 -0
- core/api/use_cases/projects.py +705 -0
- core/api/use_cases/pull_requests.py +415 -0
- core/api/use_cases/search.py +926 -0
- core/api/use_cases/tasks.py +1495 -0
- core/api/visibility.py +141 -0
- core/cli/__init__.py +5 -0
- core/cli/_index_source.py +632 -0
- core/cli/_runtime_ctx.py +160 -0
- core/cli/_transmute.py +241 -0
- core/cli/marvis_doctor.py +704 -0
- core/cli/marvis_feedback.py +396 -0
- core/cli/marvis_governance.py +315 -0
- core/cli/marvis_hooks.py +515 -0
- core/cli/marvis_init.py +757 -0
- core/cli/marvis_mcp.py +401 -0
- core/cli/marvis_runtime.py +855 -0
- core/cli/marvis_telemetry.py +228 -0
- core/scripts/_drift_check.py +716 -0
- core/scripts/_frontmatter.py +66 -0
- core/scripts/_graph_writer.py +189 -0
- core/scripts/ast_parser.py +1553 -0
- core/scripts/install_hooks/__init__.py +1 -0
- core/scripts/install_hooks/_config.sh +109 -0
- core/scripts/install_hooks/block-dangerous-bash.sh +23 -0
- core/scripts/install_hooks/block-db-direct-write.sh +23 -0
- core/scripts/install_hooks/block-push-no-task.sh +23 -0
- core/scripts/install_hooks/block-staging-to-prod.sh +23 -0
- core/scripts/install_hooks/block-subtree-push.sh +23 -0
- core/scripts/install_hooks/config.json +53 -0
- core/scripts/install_hooks/enforce-no-merge-main.sh +23 -0
- core/scripts/install_hooks/enforce-worktree.sh +23 -0
- core/scripts/install_hooks/quality-gate.sh +170 -0
- core/scripts/install_hooks/safety_bridge.py +968 -0
- core/scripts/install_hooks/secret-scan.sh +23 -0
- core/scripts/migrate_spike_node_ids.py +122 -0
- core/scripts/populate_artifacts.py +2198 -0
- core/scripts/populate_cross_project.py +2457 -0
- core/scripts/populate_inbox_nodes.py +357 -0
- core/scripts/populate_pr_impact.py +267 -0
- core/scripts/populate_project_nodes.py +603 -0
- core/scripts/populate_touch_counter.py +337 -0
- core/scripts/reparse_failed.py +57 -0
- core/scripts/safety_bridge.py +968 -0
- core/telemetry/__init__.py +9 -0
- core/telemetry/client.py +405 -0
- core/telemetry/schema.py +122 -0
- core/wizard/__init__.py +65 -0
- core/wizard/byok_vault.py +147 -0
- core/wizard/defaults.py +58 -0
- core/wizard/state.py +117 -0
- core/wizard/steps.py +70 -0
- core/wizard/validation.py +136 -0
- marvisx_cli-0.1.0.dist-info/METADATA +201 -0
- marvisx_cli-0.1.0.dist-info/RECORD +587 -0
- marvisx_cli-0.1.0.dist-info/WHEEL +5 -0
- marvisx_cli-0.1.0.dist-info/entry_points.txt +3 -0
- marvisx_cli-0.1.0.dist-info/licenses/LICENSE +98 -0
- marvisx_cli-0.1.0.dist-info/top_level.txt +3 -0
- migrations/001_initial.sql +33 -0
- migrations/002_tasks.sql +30 -0
- migrations/003_session_management.sql +7 -0
- migrations/004_projects_comments.sql +65 -0
- migrations/005_session_intelligence.sql +15 -0
- migrations/006_settings.sql +12 -0
- migrations/007_task_scoring.sql +12 -0
- migrations/008_cost_tracking.sql +31 -0
- migrations/009_session_card_metrics.sql +3 -0
- migrations/010_monitoring.sql +55 -0
- migrations/012_agent_api.sql +21 -0
- migrations/013_session_complete.sql +8 -0
- migrations/015_pull_requests.sql +43 -0
- migrations/015_pull_requests_down.sql +5 -0
- migrations/016_users_raci.sql +116 -0
- migrations/017_task_cost_entries.sql +87 -0
- migrations/018_agents.sql +73 -0
- migrations/018_agents_down.sql +13 -0
- migrations/019_review_feedback.sql +18 -0
- migrations/020_pr_commit_sha.sql +4 -0
- migrations/021_webhook_events.sql +18 -0
- migrations/022_devx_agent_managed.sql +11 -0
- migrations/022_devx_agent_managed_down.sql +6 -0
- migrations/023_devx_p1_gate.sql +7 -0
- migrations/023_devx_p1_gate_down.sql +3 -0
- migrations/024_chat_messages.sql +16 -0
- migrations/024_pr_conversation_id.sql +8 -0
- migrations/024_task_indexes.sql +21 -0
- migrations/024_task_indexes_down.sql +7 -0
- migrations/025_audit_log.sql +17 -0
- migrations/026_agent_tokens.sql +20 -0
- migrations/027_teams_auth_phase_b.sql +35 -0
- migrations/028_learnings.sql +23 -0
- migrations/029_team_roles.sql +14 -0
- migrations/030_finder_pins.sql +10 -0
- migrations/031_pr_deploy_status.sql +9 -0
- migrations/032_task_reminders.sql +7 -0
- migrations/033_events_retry_count.sql +6 -0
- migrations/033_session_owner.sql +9 -0
- migrations/034_notifications.sql +38 -0
- migrations/035_shared_links.sql +15 -0
- migrations/036_session_index_upgrade.sql +29 -0
- migrations/037_pr_approval.sql +15 -0
- migrations/038_pr_submitted_by.sql +6 -0
- migrations/039_push_subscriptions.sql +17 -0
- migrations/040_semantic_search.sql +16 -0
- migrations/041_workspaces.sql +63 -0
- migrations/042_oidc_providers.sql +24 -0
- migrations/043_ci_checks.sql +31 -0
- migrations/044_agent_metrics.sql +30 -0
- migrations/045_documents_doc_type.sql +5 -0
- migrations/046_salience.sql +13 -0
- migrations/047_seed_missing_agents.sql +6 -0
- migrations/048_fix_agent_paths_roles.sql +5 -0
- migrations/049_agent_role_and_learnings_schema.sql +3 -0
- migrations/050_session_provider.sql +2 -0
- migrations/051_session_launch_profile.sql +4 -0
- migrations/052_session_theme_mode.sql +2 -0
- migrations/052_task_kind.sql +4 -0
- migrations/053_inbox_items.sql +31 -0
- migrations/054_inbox_triage_contract.sql +30 -0
- migrations/055_inbox_topic_treatment.sql +12 -0
- migrations/056_inbox_treatment_read_save.sql +57 -0
- migrations/057_session_theme_mode_backfill.sql +4 -0
- migrations/058_inbox_item_status_lifecycle.sql +13 -0
- migrations/059_inbox_tldr_and_source_scores.sql +18 -0
- migrations/060_newsletter.sql +16 -0
- migrations/061_inbox_redesign.sql +69 -0
- migrations/062_fix_inbox_sources_backfill.sql +37 -0
- migrations/063_task_completion_mode.sql +23 -0
- migrations/064_judge_mode_setting.sql +4 -0
- migrations/065_knowledge_graph_spike.sql +40 -0
- migrations/066_digest_ranking_inputs.sql +10 -0
- migrations/066_kg_artifact_nodes.sql +129 -0
- migrations/067_inbox_digest_selections.sql +28 -0
- migrations/067_kg_temporal.sql +53 -0
- migrations/068_inbox_digest_app_settings.sql +9 -0
- migrations/068_kg_touch_counter.sql +52 -0
- migrations/069_kg_doc_types.sql +117 -0
- migrations/070_digest_ranking_inputs_recovery.sql +3 -0
- migrations/071_inbox_digest_selections_recovery.sql +3 -0
- migrations/072_inbox_digest_app_settings_recovery.sql +3 -0
- migrations/073_kg_cross_project.sql +216 -0
- migrations/073_kg_cross_project_down.sql +77 -0
- migrations/074_kg_infra_types.sql +208 -0
- migrations/074_kg_infra_types_down.sql +80 -0
- migrations/075_kg_file_state_recovery.sql +35 -0
- migrations/075_kg_file_state_recovery_down.sql +5 -0
- migrations/076_kg_watcher_state.sql +33 -0
- migrations/076_kg_watcher_state_down.sql +3 -0
- migrations/077_kg_doc_types_extend.sql +226 -0
- migrations/077_kg_doc_types_extend_down.sql +80 -0
- migrations/078_kg_fts5.sql +102 -0
- migrations/078_kg_fts5_down.sql +14 -0
- migrations/079_kg_missing_indexes.sql +31 -0
- migrations/079_kg_missing_indexes_down.sql +10 -0
- migrations/080_kg_fts5_extended.sql +232 -0
- migrations/080_kg_fts5_extended_down.sql +25 -0
- migrations/081_kg_lens_indexes.sql +9 -0
- migrations/081_kg_lens_indexes_down.sql +3 -0
- migrations/082_kg_pins.sql +26 -0
- migrations/082_kg_pins_down.sql +14 -0
- migrations/083_kg_graph_nodes_degree.sql +20 -0
- migrations/083_kg_graph_nodes_degree_down.sql +15 -0
- migrations/084_drop_legacy_scheduler_tables.sql +58 -0
- migrations/084_drop_legacy_scheduler_tables_down.sql +112 -0
- migrations/085_kg_edge_resolves_to.sql +142 -0
- migrations/085_kg_edge_resolves_to_down.sql +66 -0
- migrations/086_project_status_updates_feed.sql +20 -0
- migrations/086_project_status_updates_feed_down.sql +36 -0
- migrations/087_session_metrics_dual.sql +50 -0
- migrations/087_session_metrics_dual_down.sql +21 -0
- migrations/088_rename_context_pct_legacy.sql +23 -0
- migrations/088_rename_context_pct_legacy_down.sql +8 -0
- migrations/089_session_metrics_equivalent_cost.sql +26 -0
- migrations/089_session_metrics_equivalent_cost_down.sql +11 -0
- migrations/090_kg_inbox_node_type.sql +26 -0
- migrations/090_kg_inbox_node_type_down.sql +20 -0
- migrations/091_kg_inbox_node_type_check.sql +265 -0
- migrations/091_kg_inbox_node_type_check_down.sql +129 -0
- migrations/092_sessions_activity_state_ts.sql +29 -0
- migrations/092_sessions_activity_state_ts_down.sql +14 -0
- migrations/093_sessions_activity_state_column.sql +29 -0
- migrations/093_sessions_activity_state_column_down.sql +10 -0
- migrations/094_ingest_pending.sql +55 -0
- migrations/094_ingest_pending_down.sql +15 -0
- migrations/095_kg_intent_first.sql +77 -0
- migrations/095_kg_intent_first_down.sql +25 -0
- migrations/096_kg_xlsx_artifact_prefix.sql +17 -0
- migrations/096_kg_xlsx_artifact_prefix_down.sql +11 -0
- migrations/097_ingest_change_history.sql +37 -0
- migrations/097_ingest_change_history_down.sql +13 -0
- migrations/098_kg_node_type_business.sql +254 -0
- migrations/098_kg_node_type_business_down.sql +195 -0
- migrations/099_kg_edges_restore_weight.sql +58 -0
- migrations/099_kg_edges_restore_weight_down.sql +12 -0
- migrations/100_kg_enriched_at.sql +25 -0
- migrations/100_kg_enriched_at_down.sql +12 -0
- migrations/101_local_llm_shadow_comparisons.sql +66 -0
- migrations/101_local_llm_shadow_comparisons_down.sql +15 -0
- migrations/102_promote_llm_costs.sql +69 -0
- migrations/102_promote_llm_costs_down.sql +19 -0
- migrations/103_ingest_skipped_log.sql +46 -0
- migrations/103_ingest_skipped_log_down.sql +15 -0
- migrations/120_docs_governance.sql +50 -0
- migrations/120_docs_governance_down.sql +11 -0
- migrations/121_notification_event_fk_cleanup.sql +21 -0
- migrations/121_notification_event_fk_cleanup_down.sql +10 -0
- migrations/122_docs_drift_history.sql +34 -0
- migrations/122_docs_drift_history_down.sql +15 -0
- migrations/123_ingest_parser_waiting_status.sql +69 -0
- migrations/123_ingest_parser_waiting_status_down.sql +69 -0
- migrations/124_heypocket_recordings.sql +63 -0
- migrations/124_heypocket_recordings_down.sql +13 -0
- migrations/125_kg_node_type_record.sql +219 -0
- migrations/125_kg_node_type_record_down.sql +205 -0
- migrations/126_ingest_terminal_upload_source_kind.sql +69 -0
- migrations/126_ingest_terminal_upload_source_kind_down.sql +69 -0
- migrations/127_brain_v1_substrate.sql +200 -0
- migrations/127_brain_v1_substrate_down.sql +32 -0
- migrations/128_brain_drift_signals.sql +157 -0
- migrations/128_brain_drift_signals_down.sql +23 -0
- migrations/129_brain_memory_operations.sql +232 -0
- migrations/129_brain_memory_operations_down.sql +27 -0
- migrations/130_brain_findings.sql +258 -0
- migrations/130_brain_findings_down.sql +29 -0
- migrations/132_kg_pr_modifies.sql +242 -0
- migrations/132_kg_pr_modifies_down.sql +99 -0
- migrations/133_brain_v1_2_direction_schema.sql +476 -0
- migrations/133_brain_v1_2_direction_schema_down.sql +273 -0
- migrations/134_brain_journal_narrative_polished.sql +8 -0
- migrations/134_brain_journal_narrative_polished_down.sql +6 -0
- migrations/135_kg_edges_provider.sql +21 -0
- migrations/136_documents_fts.sql +56 -0
- migrations/137_promote_llm_costs.sql +59 -0
- migrations/137_promote_llm_costs_down.sql +19 -0
- migrations/138_ingest_api_keys.sql +39 -0
- migrations/138_ingest_api_keys_down.sql +8 -0
- migrations/139_ingest_pending_ingress.sql +91 -0
- migrations/139_ingest_pending_ingress_down.sql +73 -0
- migrations/140_ingest_idempotency_quota.sql +45 -0
- migrations/140_ingest_idempotency_quota_down.sql +9 -0
- migrations/141_ingest_pending_metadata.sql +16 -0
- migrations/141_ingest_pending_metadata_down.sql +7 -0
- migrations/142_llm_function_config.sql +36 -0
- migrations/142_llm_function_config_down.sql +8 -0
- migrations/143_kg_code_embeddings.sql +25 -0
- migrations/143_kg_code_embeddings_down.sql +5 -0
- migrations/__init__.py +4 -0
- 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)
|