alysis-code 0.13.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.
- alysis_code/__init__.py +3 -0
- alysis_code/__main__.py +4 -0
- alysis_code/_build_info.py +14 -0
- alysis_code/account_login.py +468 -0
- alysis_code/agent/README.md +35 -0
- alysis_code/agent/__init__.py +11 -0
- alysis_code/agent/acceptance_contract.py +2217 -0
- alysis_code/agent/blast_radius.py +1403 -0
- alysis_code/agent/cache_keepalive.py +227 -0
- alysis_code/agent/completion_certificate.py +366 -0
- alysis_code/agent/completion_gate.py +306 -0
- alysis_code/agent/empty_response_stall.py +403 -0
- alysis_code/agent/errors.py +28 -0
- alysis_code/agent/llm_calls.py +475 -0
- alysis_code/agent/mutation_classification.py +227 -0
- alysis_code/agent/prompt_context.py +2508 -0
- alysis_code/agent/read_ledger.py +253 -0
- alysis_code/agent/regression_baseline.py +642 -0
- alysis_code/agent/reproduction_first.py +610 -0
- alysis_code/agent/sensitive_output.py +629 -0
- alysis_code/agent/session.py +3218 -0
- alysis_code/agent/steering.py +191 -0
- alysis_code/agent/subagent_execution.py +5177 -0
- alysis_code/agent/subagent_workspace.py +666 -0
- alysis_code/agent/tools_assembly.py +4728 -0
- alysis_code/agent/turn/__init__.py +101 -0
- alysis_code/agent/turn/core.py +8483 -0
- alysis_code/agent/turn/events.py +113 -0
- alysis_code/agent/turn/exploration.py +590 -0
- alysis_code/agent/turn/interventions.py +65 -0
- alysis_code/agent/turn/read_cache.py +420 -0
- alysis_code/agent/turn/snapshot.py +179 -0
- alysis_code/agent/turn_contract.py +661 -0
- alysis_code/agent/turn_path.py +129 -0
- alysis_code/agent/verification.py +2885 -0
- alysis_code/agent/verification_commands.py +512 -0
- alysis_code/agent/verification_evidence.py +738 -0
- alysis_code/agent_loop.py +693 -0
- alysis_code/agent_runtimes/__init__.py +51 -0
- alysis_code/agent_runtimes/base.py +114 -0
- alysis_code/agent_runtimes/builtins.py +129 -0
- alysis_code/agent_runtimes/codex_cli.py +664 -0
- alysis_code/agent_runtimes/host.py +263 -0
- alysis_code/agent_runtimes/registry.py +64 -0
- alysis_code/agent_runtimes/service.py +150 -0
- alysis_code/agentbox_client.py +416 -0
- alysis_code/agentbox_integration.py +310 -0
- alysis_code/alysis_cloud.py +152 -0
- alysis_code/approval_scope.py +276 -0
- alysis_code/assets/README.md +33 -0
- alysis_code/assets/__init__.py +126 -0
- alysis_code/assets/asset_read_core.py +281 -0
- alysis_code/assets/budget_allocator.py +456 -0
- alysis_code/assets/comprehender.py +759 -0
- alysis_code/assets/index.py +654 -0
- alysis_code/assets/ingestion.py +275 -0
- alysis_code/assets/legacy_migration.py +413 -0
- alysis_code/assets/models.py +263 -0
- alysis_code/assets/ocr.py +239 -0
- alysis_code/assets/owl/ascii/f-000.txt +13 -0
- alysis_code/assets/owl/ascii/f-001.txt +13 -0
- alysis_code/assets/owl/ascii/f-002.txt +13 -0
- alysis_code/assets/owl/ascii/f-003.txt +13 -0
- alysis_code/assets/owl/ascii/f-004.txt +13 -0
- alysis_code/assets/owl/ascii/f-005.txt +13 -0
- alysis_code/assets/owl/ascii/f-006.txt +13 -0
- alysis_code/assets/owl/ascii/f-007.txt +13 -0
- alysis_code/assets/owl/ascii/f-008.txt +13 -0
- alysis_code/assets/owl/ascii/f-009.txt +13 -0
- alysis_code/assets/owl/ascii/f-010.txt +13 -0
- alysis_code/assets/owl/ascii/f-011.txt +13 -0
- alysis_code/assets/owl/ascii/f-012.txt +13 -0
- alysis_code/assets/owl/ascii/f-013.txt +13 -0
- alysis_code/assets/owl/ascii/f-014.txt +13 -0
- alysis_code/assets/owl/ascii/f-015.txt +13 -0
- alysis_code/assets/owl/ascii/f-016.txt +13 -0
- alysis_code/assets/owl/ascii/f-017.txt +13 -0
- alysis_code/assets/owl/ascii/f-018.txt +13 -0
- alysis_code/assets/owl/ascii/f-019.txt +13 -0
- alysis_code/assets/owl/ascii/f-020.txt +13 -0
- alysis_code/assets/owl/index.html +98 -0
- alysis_code/assets/owl/show-owl.sh +761 -0
- alysis_code/assets/paths.py +49 -0
- alysis_code/assets/plan_binding.py +326 -0
- alysis_code/assets/planner_context.py +466 -0
- alysis_code/assets/planner_tools.py +184 -0
- alysis_code/assets/prompts.py +101 -0
- alysis_code/assets/replanner_context.py +239 -0
- alysis_code/assets/surface.py +521 -0
- alysis_code/assets/untrusted_content.py +48 -0
- alysis_code/assets/usage_logger.py +94 -0
- alysis_code/assets/worker_mirror.py +428 -0
- alysis_code/assets/worker_section.py +303 -0
- alysis_code/assets/worker_tools.py +468 -0
- alysis_code/atomic_io.py +83 -0
- alysis_code/auth_diagnostics.py +272 -0
- alysis_code/background_runner.py +366 -0
- alysis_code/branding.py +270 -0
- alysis_code/budget_policy.py +390 -0
- alysis_code/build_identity.py +465 -0
- alysis_code/builtin_hooks/__init__.py +7 -0
- alysis_code/builtin_hooks/notify_done_windows.py +65 -0
- alysis_code/bwrap_etc.py +76 -0
- alysis_code/cancellation.py +41 -0
- alysis_code/capabilities.py +137 -0
- alysis_code/chatgpt_codex_static_provider.py +133 -0
- alysis_code/cli.py +51 -0
- alysis_code/cli_impl/__init__.py +1 -0
- alysis_code/cli_impl/assets_cli.py +537 -0
- alysis_code/cli_impl/assets_modal.py +412 -0
- alysis_code/cli_impl/chat/__init__.py +156 -0
- alysis_code/cli_impl/chat/commands.py +2616 -0
- alysis_code/cli_impl/chat/loop.py +4508 -0
- alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
- alysis_code/cli_impl/chat/rendering.py +444 -0
- alysis_code/cli_impl/chat/state.py +124 -0
- alysis_code/cli_impl/chat_resume.py +830 -0
- alysis_code/cli_impl/chat_slash_completer.py +258 -0
- alysis_code/cli_impl/commands/__init__.py +11 -0
- alysis_code/cli_impl/commands/_shared.py +89 -0
- alysis_code/cli_impl/commands/auth.py +623 -0
- alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
- alysis_code/cli_impl/commands/chat_state.py +158 -0
- alysis_code/cli_impl/commands/chat_status.py +1248 -0
- alysis_code/cli_impl/commands/chat_terminal.py +942 -0
- alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
- alysis_code/cli_impl/commands/cli_common.py +1223 -0
- alysis_code/cli_impl/commands/cli_surface.py +77 -0
- alysis_code/cli_impl/commands/config.py +131 -0
- alysis_code/cli_impl/commands/conventions.py +85 -0
- alysis_code/cli_impl/commands/execution_helpers.py +350 -0
- alysis_code/cli_impl/commands/extensions.py +401 -0
- alysis_code/cli_impl/commands/forge.py +1282 -0
- alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
- alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
- alysis_code/cli_impl/commands/hooks.py +737 -0
- alysis_code/cli_impl/commands/ide_bridge.py +31 -0
- alysis_code/cli_impl/commands/mcp.py +700 -0
- alysis_code/cli_impl/commands/profile.py +453 -0
- alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
- alysis_code/cli_impl/commands/report.py +88 -0
- alysis_code/cli_impl/commands/root.py +1118 -0
- alysis_code/cli_impl/commands/sandbox.py +184 -0
- alysis_code/cli_impl/commands/server.py +54 -0
- alysis_code/cli_impl/commands/sessions.py +252 -0
- alysis_code/cli_impl/commands/skills.py +404 -0
- alysis_code/cli_impl/commands/startup.py +946 -0
- alysis_code/cli_impl/commands/tools.py +335 -0
- alysis_code/cli_impl/commands/update.py +364 -0
- alysis_code/cli_impl/commands/welcome.py +972 -0
- alysis_code/cli_impl/config_menu.py +3882 -0
- alysis_code/cli_impl/forge.py +4509 -0
- alysis_code/cli_impl/forge_recovery.py +485 -0
- alysis_code/cli_impl/setup_wizard.py +2409 -0
- alysis_code/cli_impl/tui/__init__.py +58 -0
- alysis_code/cli_impl/tui/app.py +4551 -0
- alysis_code/cli_impl/tui/config.py +32 -0
- alysis_code/cli_impl/tui/config_flow.py +2754 -0
- alysis_code/cli_impl/tui/config_overlay.py +566 -0
- alysis_code/cli_impl/tui/content.py +78 -0
- alysis_code/cli_impl/tui/footer.py +218 -0
- alysis_code/cli_impl/tui/forge_status.py +136 -0
- alysis_code/cli_impl/tui/markdown.py +244 -0
- alysis_code/cli_impl/tui/owl.py +109 -0
- alysis_code/cli_impl/tui/plan_meta.py +477 -0
- alysis_code/cli_impl/tui/setup_app.py +519 -0
- alysis_code/cli_impl/tui/setup_flow.py +1622 -0
- alysis_code/cli_impl/tui/state.py +101 -0
- alysis_code/cli_impl/tui/subagent_identity.py +66 -0
- alysis_code/cli_impl/tui/subagent_panel.py +186 -0
- alysis_code/cli_impl/tui/surface.py +796 -0
- alysis_code/cli_impl/tui/transcript.py +514 -0
- alysis_code/cli_impl/tui/update_prompt.py +79 -0
- alysis_code/cli_impl/tui/workspace_guard.py +384 -0
- alysis_code/clipboard.py +172 -0
- alysis_code/code_review.py +1211 -0
- alysis_code/compaction/__init__.py +28 -0
- alysis_code/compaction/conversation_compactor.py +2932 -0
- alysis_code/compaction/importance.py +177 -0
- alysis_code/compaction/settings.py +297 -0
- alysis_code/compaction/tool_output_offload.py +447 -0
- alysis_code/config.py +3509 -0
- alysis_code/conflict_auto_resolver.py +895 -0
- alysis_code/context/__init__.py +1 -0
- alysis_code/context/tool_schema_budgeter.py +220 -0
- alysis_code/crash_diagnostics.py +282 -0
- alysis_code/custom_tools/README.md +34 -0
- alysis_code/custom_tools/__init__.py +43 -0
- alysis_code/custom_tools/discovery.py +903 -0
- alysis_code/custom_tools/runtime.py +1516 -0
- alysis_code/custom_tools/session.py +227 -0
- alysis_code/custom_tools/trust.py +232 -0
- alysis_code/diff_paths.py +113 -0
- alysis_code/direction_change.py +293 -0
- alysis_code/dispatch_timing.py +306 -0
- alysis_code/durable_service_manager.py +1236 -0
- alysis_code/edit_discipline.py +659 -0
- alysis_code/error_text.py +73 -0
- alysis_code/execution_budget.py +411 -0
- alysis_code/execution_context.py +915 -0
- alysis_code/execution_deadline.py +1065 -0
- alysis_code/execution_shared.py +1904 -0
- alysis_code/extensions/README.md +30 -0
- alysis_code/extensions/__init__.py +93 -0
- alysis_code/extensions/activation.py +138 -0
- alysis_code/extensions/install.py +1436 -0
- alysis_code/extensions/manifest.py +487 -0
- alysis_code/extensions/models.py +74 -0
- alysis_code/extensions/paths.py +56 -0
- alysis_code/extensions/registry.json +4 -0
- alysis_code/extensions/registry.py +52 -0
- alysis_code/extensions/state.py +83 -0
- alysis_code/extensions/workspace_trust.py +101 -0
- alysis_code/failed_task_evidence.py +369 -0
- alysis_code/failure_category.py +315 -0
- alysis_code/feedback_report.py +1647 -0
- alysis_code/file_classification.py +485 -0
- alysis_code/forge.py +2064 -0
- alysis_code/forge_completion.py +362 -0
- alysis_code/forge_events.py +475 -0
- alysis_code/frontmatter_utils.py +95 -0
- alysis_code/git_evidence.py +1181 -0
- alysis_code/git_ops.py +560 -0
- alysis_code/git_safe.py +62 -0
- alysis_code/git_worktrees.py +190 -0
- alysis_code/hooks/README.md +33 -0
- alysis_code/hooks/__init__.py +67 -0
- alysis_code/hooks/audit.py +171 -0
- alysis_code/hooks/config.py +225 -0
- alysis_code/hooks/dispatcher.py +1110 -0
- alysis_code/hooks/models.py +447 -0
- alysis_code/hooks/trust.py +202 -0
- alysis_code/host_actions.py +543 -0
- alysis_code/host_browser.py +103 -0
- alysis_code/ide/__init__.py +5 -0
- alysis_code/ide/activity_events.py +399 -0
- alysis_code/ide/approvals.py +337 -0
- alysis_code/ide/artifacts.py +153 -0
- alysis_code/ide/browser_egress_proxy.py +1076 -0
- alysis_code/ide/cdp_websocket_transport.py +1192 -0
- alysis_code/ide/change_ledger.py +1721 -0
- alysis_code/ide/context_blocks.py +979 -0
- alysis_code/ide/event_stream.py +531 -0
- alysis_code/ide/forge_protocol.py +3112 -0
- alysis_code/ide/forge_request_ledger.py +737 -0
- alysis_code/ide/health.py +965 -0
- alysis_code/ide/managed_browser.py +2251 -0
- alysis_code/ide/management_protocol.py +3414 -0
- alysis_code/ide/mcp_oauth_coordinator.py +744 -0
- alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
- alysis_code/ide/prompt_queue.py +1070 -0
- alysis_code/ide/protocol.py +191 -0
- alysis_code/ide/resumable_swarm.py +1543 -0
- alysis_code/ide/session_search.py +295 -0
- alysis_code/ide/stdio_bridge.py +9935 -0
- alysis_code/ide/structured_state.py +1579 -0
- alysis_code/ide/swarm_protocol.py +816 -0
- alysis_code/integration_gate.py +506 -0
- alysis_code/interactive_input_guard.py +39 -0
- alysis_code/interactive_plan_mode.py +26 -0
- alysis_code/internal_artifacts.py +179 -0
- alysis_code/knowledge_base.py +1409 -0
- alysis_code/knowledge_capture.py +1190 -0
- alysis_code/knowledge_librarian.py +605 -0
- alysis_code/language_policy.py +34 -0
- alysis_code/litellm_static_provider.py +535 -0
- alysis_code/llm/__init__.py +1 -0
- alysis_code/llm/anthropic_messages.py +2288 -0
- alysis_code/llm/base.py +71 -0
- alysis_code/llm/cache_capabilities.py +985 -0
- alysis_code/llm/cache_control_blocks.py +244 -0
- alysis_code/llm/cache_policy.py +388 -0
- alysis_code/llm/factory.py +373 -0
- alysis_code/llm/gemini_generate_content.py +2652 -0
- alysis_code/llm/gemini_interactions.py +739 -0
- alysis_code/llm/metadata.py +450 -0
- alysis_code/llm/openai_compat.py +2947 -0
- alysis_code/llm/openai_responses.py +2604 -0
- alysis_code/llm/protocols.py +609 -0
- alysis_code/llm/provider_limits.py +525 -0
- alysis_code/llm/request_plan.py +389 -0
- alysis_code/llm/request_shape.py +238 -0
- alysis_code/llm/streaming.py +108 -0
- alysis_code/llm/temperature_compat.py +78 -0
- alysis_code/llm/types.py +195 -0
- alysis_code/llm/usage_normalization.py +222 -0
- alysis_code/llm_error_display.py +315 -0
- alysis_code/logging_redaction.py +326 -0
- alysis_code/managed_host_deadline.py +196 -0
- alysis_code/mcp/README.md +33 -0
- alysis_code/mcp/__init__.py +24 -0
- alysis_code/mcp/client.py +1137 -0
- alysis_code/mcp/config.py +597 -0
- alysis_code/mcp/errors.py +113 -0
- alysis_code/mcp/forge_scope.py +154 -0
- alysis_code/mcp/jsonrpc.py +214 -0
- alysis_code/mcp/manager.py +2308 -0
- alysis_code/mcp/models.py +666 -0
- alysis_code/mcp/oauth.py +972 -0
- alysis_code/mcp/oauth_runtime.py +310 -0
- alysis_code/mcp/oauth_store.py +276 -0
- alysis_code/mcp/prompts.py +329 -0
- alysis_code/mcp/resources.py +295 -0
- alysis_code/mcp/roots.py +106 -0
- alysis_code/mcp/server_requests.py +75 -0
- alysis_code/mcp/token_store.py +859 -0
- alysis_code/mcp/transport_http.py +1338 -0
- alysis_code/mcp/transport_stdio.py +1267 -0
- alysis_code/mcp/untrusted_content.py +119 -0
- alysis_code/merge_conflict_reviewer.py +729 -0
- alysis_code/model_catalog/__init__.py +1 -0
- alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
- alysis_code/model_metadata_policy.py +223 -0
- alysis_code/model_metadata_utils.py +103 -0
- alysis_code/model_registry.py +1420 -0
- alysis_code/model_router.py +147 -0
- alysis_code/permission_policy.py +1016 -0
- alysis_code/personas.py +451 -0
- alysis_code/pipeline_facts.py +233 -0
- alysis_code/plan_assistant.py +4763 -0
- alysis_code/plan_mode.py +393 -0
- alysis_code/plan_reconciliation.py +1228 -0
- alysis_code/plan_repair.py +652 -0
- alysis_code/plan_validation.py +1099 -0
- alysis_code/planning_constraints.py +904 -0
- alysis_code/policy.py +95 -0
- alysis_code/preview_server.py +457 -0
- alysis_code/process_reaping.py +566 -0
- alysis_code/profile_presets.py +1834 -0
- alysis_code/profiles.py +666 -0
- alysis_code/provider_auth/__init__.py +29 -0
- alysis_code/provider_auth/base.py +99 -0
- alysis_code/provider_auth/openai_codex.py +951 -0
- alysis_code/provider_auth/registry.py +76 -0
- alysis_code/provider_auth/store.py +125 -0
- alysis_code/provider_diagnostics.py +1209 -0
- alysis_code/provider_model_catalog.py +685 -0
- alysis_code/provider_telemetry.py +1699 -0
- alysis_code/provider_url.py +75 -0
- alysis_code/reasoning_contracts.py +911 -0
- alysis_code/remote_sync.py +350 -0
- alysis_code/replanning.py +1195 -0
- alysis_code/repo_scan.py +1152 -0
- alysis_code/request_estimation.py +296 -0
- alysis_code/review_gate.py +617 -0
- alysis_code/run_lock.py +1141 -0
- alysis_code/run_outcome.py +58 -0
- alysis_code/run_provenance.py +774 -0
- alysis_code/run_state.py +445 -0
- alysis_code/runtime_artifacts.py +116 -0
- alysis_code/runtime_context_features.py +78 -0
- alysis_code/runtime_kind.py +52 -0
- alysis_code/safety/__init__.py +11 -0
- alysis_code/safety/mcp_sanitize.py +29 -0
- alysis_code/safety/safe_http.py +297 -0
- alysis_code/safety/subagent_report.py +184 -0
- alysis_code/sandbox_doctor.py +682 -0
- alysis_code/sandbox_runner.py +1025 -0
- alysis_code/sandbox_settings.py +423 -0
- alysis_code/serialized_paths.py +355 -0
- alysis_code/server/__init__.py +3 -0
- alysis_code/server/app.py +367 -0
- alysis_code/server/auth.py +34 -0
- alysis_code/server/job_config.py +30 -0
- alysis_code/server/settings.py +215 -0
- alysis_code/server/store.py +193 -0
- alysis_code/server/worker_runner.py +657 -0
- alysis_code/service_persistence.py +355 -0
- alysis_code/session_artifacts.py +108 -0
- alysis_code/session_metrics.py +331 -0
- alysis_code/session_store.py +624 -0
- alysis_code/skills/README.md +34 -0
- alysis_code/skills/__init__.py +104 -0
- alysis_code/skills/conventions.py +84 -0
- alysis_code/skills/discovery.py +176 -0
- alysis_code/skills/eval_models.py +232 -0
- alysis_code/skills/eval_runner.py +372 -0
- alysis_code/skills/evals.py +1344 -0
- alysis_code/skills/install.py +293 -0
- alysis_code/skills/loader.py +118 -0
- alysis_code/skills/matching.py +103 -0
- alysis_code/skills/models.py +71 -0
- alysis_code/skills/paths.py +56 -0
- alysis_code/skills/prompting.py +500 -0
- alysis_code/skills/scaffold.py +142 -0
- alysis_code/skills/state.py +441 -0
- alysis_code/skills/transactions.py +125 -0
- alysis_code/skills/validation.py +304 -0
- alysis_code/step_budget.py +238 -0
- alysis_code/subagent_labels.py +49 -0
- alysis_code/subagents.py +1072 -0
- alysis_code/surface/__init__.py +80 -0
- alysis_code/surface/base.py +305 -0
- alysis_code/surface/console.py +387 -0
- alysis_code/surface/events.py +372 -0
- alysis_code/surface/hidden_surface.py +529 -0
- alysis_code/surface/noop_surface.py +219 -0
- alysis_code/surface/rich_surface.py +1555 -0
- alysis_code/surface/styles.py +67 -0
- alysis_code/surface/theme.py +455 -0
- alysis_code/surface/types.py +100 -0
- alysis_code/swarm_backend.py +926 -0
- alysis_code/swarm_orchestrator.py +4020 -0
- alysis_code/swarm_scheduler.py +441 -0
- alysis_code/swarm_trace.py +429 -0
- alysis_code/swarm_worker.py +2119 -0
- alysis_code/swarm_write_guard.py +348 -0
- alysis_code/task_dependencies.py +170 -0
- alysis_code/task_readiness.py +992 -0
- alysis_code/task_scope.py +2148 -0
- alysis_code/terminal_manager.py +762 -0
- alysis_code/terminal_ownership.py +460 -0
- alysis_code/text_normalization.py +30 -0
- alysis_code/token_budget.py +97 -0
- alysis_code/tools/README.md +34 -0
- alysis_code/tools/__init__.py +1 -0
- alysis_code/tools/artifacts.py +127 -0
- alysis_code/tools/availability.py +188 -0
- alysis_code/tools/fs.py +1456 -0
- alysis_code/tools/git.py +461 -0
- alysis_code/tools/history.py +229 -0
- alysis_code/tools/http_timeout.py +78 -0
- alysis_code/tools/image_generation.py +552 -0
- alysis_code/tools/registry.py +2936 -0
- alysis_code/tools/repo_map.py +476 -0
- alysis_code/tools/search.py +563 -0
- alysis_code/tools/shell.py +135 -0
- alysis_code/tools/symbols.py +1350 -0
- alysis_code/tools/test_discovery.py +643 -0
- alysis_code/tools/web.py +482 -0
- alysis_code/tools/web_search.py +2012 -0
- alysis_code/tools/web_search_dashscope.py +557 -0
- alysis_code/tools/web_search_ddgs.py +221 -0
- alysis_code/tools/web_search_provider_adapters.py +1429 -0
- alysis_code/tools/web_search_tavily.py +194 -0
- alysis_code/updates.py +933 -0
- alysis_code/usage_tracker.py +1990 -0
- alysis_code/verification_command_analysis.py +1004 -0
- alysis_code/verification_contract.py +574 -0
- alysis_code/verification_failure_summary.py +273 -0
- alysis_code/verification_repair.py +385 -0
- alysis_code/verify_gate.py +3129 -0
- alysis_code/web_research.py +1872 -0
- alysis_code/web_search_adapters.py +66 -0
- alysis_code/web_search_policy.py +27 -0
- alysis_code/workspace_binding.py +389 -0
- alysis_code/workspace_binding_ui.py +408 -0
- alysis_code/workspace_context.py +273 -0
- alysis_code/workspace_isolation.py +138 -0
- alysis_code/workspace_provisioning.py +455 -0
- alysis_code-0.13.0.dist-info/METADATA +507 -0
- alysis_code-0.13.0.dist-info/RECORD +458 -0
- alysis_code-0.13.0.dist-info/WHEEL +4 -0
- alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
- alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
- alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,1647 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import platform
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import webbrowser
|
|
8
|
+
import zipfile
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import urlencode, urlsplit, urlunsplit
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .config import (
|
|
18
|
+
AppConfig,
|
|
19
|
+
resolve_feedback_github_enabled,
|
|
20
|
+
resolve_feedback_github_repo,
|
|
21
|
+
resolve_feedback_open_browser,
|
|
22
|
+
)
|
|
23
|
+
from .failure_category import (
|
|
24
|
+
empty_failure_category_counts,
|
|
25
|
+
increment_failure_category_count,
|
|
26
|
+
)
|
|
27
|
+
from .forge import (
|
|
28
|
+
ForgeError,
|
|
29
|
+
RunPaths,
|
|
30
|
+
current_run_pointer_path,
|
|
31
|
+
load_current_run_paths,
|
|
32
|
+
make_run_paths,
|
|
33
|
+
)
|
|
34
|
+
from .git_ops import GitOpsError, ensure_git_repo, ensure_runtime_artifact_excludes
|
|
35
|
+
from .serialized_paths import (
|
|
36
|
+
looks_like_serialized_path_field,
|
|
37
|
+
safe_serialized_path,
|
|
38
|
+
safe_serialized_path_field,
|
|
39
|
+
sanitize_paths_in_text,
|
|
40
|
+
)
|
|
41
|
+
from .session_metrics import score_session_events, score_session_log
|
|
42
|
+
from .session_store import (
|
|
43
|
+
filter_sessions_to_local_owner,
|
|
44
|
+
list_sessions,
|
|
45
|
+
read_session_events,
|
|
46
|
+
resolve_sessions_dir,
|
|
47
|
+
sanitize_session_id,
|
|
48
|
+
)
|
|
49
|
+
from .web_research import (
|
|
50
|
+
SessionWebResearchTracker,
|
|
51
|
+
build_web_research_metrics_from_artifact_payload,
|
|
52
|
+
web_research_artifact_has_activity,
|
|
53
|
+
)
|
|
54
|
+
from .workspace_context import WorkspaceContextError, resolve_workspace_context
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class FeedbackReportError(RuntimeError):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_SAFE_EXPORT_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
62
|
+
_HIERARCHICAL_URL_RE = re.compile(
|
|
63
|
+
r"\b[A-Za-z][A-Za-z0-9+.-]*://\S+",
|
|
64
|
+
)
|
|
65
|
+
_ISSUE_TITLE_MAX_CHARS = 96
|
|
66
|
+
_ISSUE_FEEDBACK_MAX_CHARS = 1400
|
|
67
|
+
_ISSUE_BODY_MAX_CHARS = 3600
|
|
68
|
+
_ISSUE_URL_MAX_CHARS = 8000
|
|
69
|
+
_SECRET_REPLACEMENTS: tuple[tuple[re.Pattern[str], str], ...] = (
|
|
70
|
+
(re.compile(r"sk-[A-Za-z0-9_\-]{16,}"), "[REDACTED]"),
|
|
71
|
+
(re.compile(r"(Bearer\s+)[A-Za-z0-9._\-]{8,}", re.IGNORECASE), r"\1[REDACTED]"),
|
|
72
|
+
(re.compile(r"(Authorization\s*:\s*)(.+)", re.IGNORECASE), r"\1[REDACTED]"),
|
|
73
|
+
(
|
|
74
|
+
re.compile(r"((?:ALYSIS_API_KEY|OPENAI_API_KEY)\s*[:=]\s*)([^\s\"']+)", re.IGNORECASE),
|
|
75
|
+
r"\1[REDACTED]",
|
|
76
|
+
),
|
|
77
|
+
(
|
|
78
|
+
re.compile(r'((?:"(?:api_key|api-key|authorization)"\s*:\s*"))([^"]+)(")', re.IGNORECASE),
|
|
79
|
+
r"\1[REDACTED]\3",
|
|
80
|
+
),
|
|
81
|
+
(
|
|
82
|
+
re.compile(r"((?:api_key|api-key)\s*[:=]\s*)([^\s,]+)", re.IGNORECASE),
|
|
83
|
+
r"\1[REDACTED]",
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Provider continuation state is required inside a live session but must never
|
|
88
|
+
# leave the private session store through a support bundle. They can contain raw
|
|
89
|
+
# chain-of-thought, encrypted/redacted blocks, provider signatures, response
|
|
90
|
+
# items, and replay tokens. Drop the whole container rather than trying to
|
|
91
|
+
# maintain a provider-by-provider nested denylist.
|
|
92
|
+
_PROVIDER_CONTINUATION_CONTAINER_KEYS = frozenset(
|
|
93
|
+
{
|
|
94
|
+
"providermetadata",
|
|
95
|
+
"alysisprovidermetadata",
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
_REASONING_BLOCK_TYPES = frozenset(
|
|
99
|
+
{
|
|
100
|
+
"reasoning",
|
|
101
|
+
"reasoningencrypted",
|
|
102
|
+
"reasoningsummary",
|
|
103
|
+
"reasoningtext",
|
|
104
|
+
"redactedthinking",
|
|
105
|
+
"thinking",
|
|
106
|
+
"thinkchunk",
|
|
107
|
+
"thought",
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
_REASONING_FAMILIES = ("reasoning", "thinking", "thought")
|
|
111
|
+
_REASONING_SENSITIVE_MARKERS = (
|
|
112
|
+
"block",
|
|
113
|
+
"chunk",
|
|
114
|
+
"content",
|
|
115
|
+
"detail",
|
|
116
|
+
"encrypted",
|
|
117
|
+
"monologue",
|
|
118
|
+
"raw",
|
|
119
|
+
"redacted",
|
|
120
|
+
"signature",
|
|
121
|
+
"summary",
|
|
122
|
+
"text",
|
|
123
|
+
)
|
|
124
|
+
_REASONING_DIAGNOSTIC_MARKERS = (
|
|
125
|
+
"active",
|
|
126
|
+
"adapter",
|
|
127
|
+
"available",
|
|
128
|
+
"availability",
|
|
129
|
+
"budget",
|
|
130
|
+
"capability",
|
|
131
|
+
"confidence",
|
|
132
|
+
"config",
|
|
133
|
+
"count",
|
|
134
|
+
"display",
|
|
135
|
+
"effort",
|
|
136
|
+
"enabled",
|
|
137
|
+
"format",
|
|
138
|
+
"include",
|
|
139
|
+
"kind",
|
|
140
|
+
"level",
|
|
141
|
+
"mode",
|
|
142
|
+
"policy",
|
|
143
|
+
"requested",
|
|
144
|
+
"source",
|
|
145
|
+
"support",
|
|
146
|
+
"token",
|
|
147
|
+
"trace",
|
|
148
|
+
"type",
|
|
149
|
+
)
|
|
150
|
+
_OPAQUE_CONTINUATION_KEYS = frozenset(
|
|
151
|
+
{
|
|
152
|
+
"cachedcontent",
|
|
153
|
+
"continuation",
|
|
154
|
+
"continuationdata",
|
|
155
|
+
"continuationpayload",
|
|
156
|
+
"continuationstate",
|
|
157
|
+
"continuationtoken",
|
|
158
|
+
"encryptedcontent",
|
|
159
|
+
"encryptedreasoning",
|
|
160
|
+
"encryptedthinking",
|
|
161
|
+
"opaquecontinuation",
|
|
162
|
+
"opaquepayload",
|
|
163
|
+
"opaquestate",
|
|
164
|
+
"previousresponseid",
|
|
165
|
+
"redactedcontent",
|
|
166
|
+
"redactedthinking",
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
_INVALID_STRUCTURED_EXPORT = {
|
|
170
|
+
"redacted": "Invalid structured artifact omitted from support bundle."
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass(frozen=True)
|
|
175
|
+
class FeedbackBundleResult:
|
|
176
|
+
bundle_dir: Path
|
|
177
|
+
zip_path: Path
|
|
178
|
+
output_root: Path
|
|
179
|
+
workspace_root: Path
|
|
180
|
+
session_id: str | None
|
|
181
|
+
run_id: str | None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True)
|
|
185
|
+
class FeedbackGithubIssueResult:
|
|
186
|
+
repo: str
|
|
187
|
+
issue_url: str | None
|
|
188
|
+
opened: bool
|
|
189
|
+
open_attempted: bool
|
|
190
|
+
disabled_reason: str | None = None
|
|
191
|
+
open_error: str | None = None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(frozen=True)
|
|
195
|
+
class _ResolvedSessionSource:
|
|
196
|
+
source: str
|
|
197
|
+
session_id: str | None
|
|
198
|
+
log_path: Path | None
|
|
199
|
+
log_meta_path: Path | None
|
|
200
|
+
artifact_root: Path | None
|
|
201
|
+
logging_enabled: bool
|
|
202
|
+
score_payload: dict[str, Any]
|
|
203
|
+
snapshot_payload: dict[str, Any] | None
|
|
204
|
+
web_research_payload: dict[str, Any] | None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True)
|
|
208
|
+
class _ResolvedRunSource:
|
|
209
|
+
source: str
|
|
210
|
+
paths: RunPaths | None
|
|
211
|
+
pointer_path: Path | None
|
|
212
|
+
pointer_matches_run: bool
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def resolve_feedback_workspace_root(path: Path) -> Path:
|
|
216
|
+
candidate = path.expanduser().resolve()
|
|
217
|
+
if not candidate.exists():
|
|
218
|
+
raise FeedbackReportError(f"Workspace path does not exist: {candidate}")
|
|
219
|
+
if not candidate.is_dir():
|
|
220
|
+
raise FeedbackReportError(f"Workspace path is not a directory: {candidate}")
|
|
221
|
+
try:
|
|
222
|
+
return resolve_workspace_context(candidate).workspace_root
|
|
223
|
+
except WorkspaceContextError:
|
|
224
|
+
return candidate
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def create_feedback_bundle(
|
|
228
|
+
*,
|
|
229
|
+
workspace_root: Path,
|
|
230
|
+
feedback_text: str | None = None,
|
|
231
|
+
cfg: AppConfig | None = None,
|
|
232
|
+
active_session: Any | None = None,
|
|
233
|
+
active_run_paths: RunPaths | None = None,
|
|
234
|
+
pending_images: list[str] | None = None,
|
|
235
|
+
session_id: str | None = None,
|
|
236
|
+
run_id: str | None = None,
|
|
237
|
+
latest: bool = False,
|
|
238
|
+
) -> FeedbackBundleResult:
|
|
239
|
+
if session_id and latest:
|
|
240
|
+
raise FeedbackReportError("Use either session_id or latest, not both.")
|
|
241
|
+
|
|
242
|
+
root = resolve_feedback_workspace_root(workspace_root)
|
|
243
|
+
output_root = root / "alysis-feedback"
|
|
244
|
+
output_root.mkdir(parents=True, exist_ok=True)
|
|
245
|
+
_ensure_feedback_dir_is_git_ignored(root)
|
|
246
|
+
|
|
247
|
+
session_source = _resolve_session_source(
|
|
248
|
+
cfg=cfg or AppConfig(),
|
|
249
|
+
active_session=active_session,
|
|
250
|
+
pending_images=pending_images,
|
|
251
|
+
session_id=session_id,
|
|
252
|
+
latest=latest,
|
|
253
|
+
)
|
|
254
|
+
run_source = _resolve_run_source(
|
|
255
|
+
root=root,
|
|
256
|
+
active_run_paths=active_run_paths,
|
|
257
|
+
run_id=run_id,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
if not _session_source_has_exportable_content(session_source) and run_source.paths is None:
|
|
261
|
+
raise FeedbackReportError(
|
|
262
|
+
"No retained session logs found and no current Forge run could be resolved."
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
created_at = _now_utc()
|
|
266
|
+
bundle_name = _make_bundle_name(
|
|
267
|
+
created_at=created_at,
|
|
268
|
+
session_id=session_source.session_id,
|
|
269
|
+
run_id=run_source.paths.run_id if run_source.paths is not None else None,
|
|
270
|
+
)
|
|
271
|
+
bundle_dir = _allocate_bundle_dir(output_root=output_root, base_name=bundle_name)
|
|
272
|
+
zip_path = bundle_dir.with_suffix(".zip")
|
|
273
|
+
bundle_dir.mkdir(parents=True, exist_ok=False)
|
|
274
|
+
|
|
275
|
+
copied_session_log_path: Path | None = None
|
|
276
|
+
copied_session_artifacts_path: Path | None = None
|
|
277
|
+
snapshot_path: Path | None = None
|
|
278
|
+
copied_web_research_path: Path | None = None
|
|
279
|
+
copied_run_root_path: Path | None = None
|
|
280
|
+
copied_pointer_path: Path | None = None
|
|
281
|
+
|
|
282
|
+
session_bundle_dir = bundle_dir / "session"
|
|
283
|
+
if session_source.log_path is not None:
|
|
284
|
+
copied_session_log_path = session_bundle_dir / "log.jsonl"
|
|
285
|
+
_copy_file(session_source.log_path, copied_session_log_path, workspace_root=root)
|
|
286
|
+
if session_source.log_meta_path is not None:
|
|
287
|
+
_copy_file(
|
|
288
|
+
session_source.log_meta_path,
|
|
289
|
+
session_bundle_dir / "log.meta.json",
|
|
290
|
+
workspace_root=root,
|
|
291
|
+
)
|
|
292
|
+
if session_source.artifact_root is not None:
|
|
293
|
+
copied_session_artifacts_path = session_bundle_dir / "artifacts"
|
|
294
|
+
_copy_tree(
|
|
295
|
+
session_source.artifact_root,
|
|
296
|
+
copied_session_artifacts_path,
|
|
297
|
+
workspace_root=root,
|
|
298
|
+
)
|
|
299
|
+
if session_source.snapshot_payload is not None:
|
|
300
|
+
snapshot_path = session_bundle_dir / "session_snapshot.json"
|
|
301
|
+
_write_json(snapshot_path, session_source.snapshot_payload, workspace_root=root)
|
|
302
|
+
if session_source.web_research_payload is not None:
|
|
303
|
+
copied_web_research_path = session_bundle_dir / "web_research_sources.json"
|
|
304
|
+
_write_json(
|
|
305
|
+
copied_web_research_path,
|
|
306
|
+
session_source.web_research_payload,
|
|
307
|
+
workspace_root=root,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
if run_source.paths is not None:
|
|
311
|
+
failure_category_counts = _collect_run_failure_category_counts(run_source.paths)
|
|
312
|
+
copied_run_root_path = bundle_dir / "forge" / "run"
|
|
313
|
+
_copy_selected_run_artifacts(
|
|
314
|
+
run_source.paths,
|
|
315
|
+
copied_run_root_path,
|
|
316
|
+
workspace_root=root,
|
|
317
|
+
)
|
|
318
|
+
if run_source.pointer_path is not None and run_source.pointer_matches_run:
|
|
319
|
+
copied_pointer_path = bundle_dir / "forge" / "current_run.json"
|
|
320
|
+
_copy_file(run_source.pointer_path, copied_pointer_path, workspace_root=root)
|
|
321
|
+
else:
|
|
322
|
+
failure_category_counts = empty_failure_category_counts()
|
|
323
|
+
|
|
324
|
+
session_score_path = bundle_dir / "session_score.json"
|
|
325
|
+
_write_json(session_score_path, session_source.score_payload, workspace_root=root)
|
|
326
|
+
|
|
327
|
+
feedback_path = bundle_dir / "feedback.md"
|
|
328
|
+
feedback_path.write_text(
|
|
329
|
+
_render_feedback_markdown(
|
|
330
|
+
created_at=created_at,
|
|
331
|
+
feedback_text=feedback_text,
|
|
332
|
+
session_id=session_source.session_id,
|
|
333
|
+
run_id=run_source.paths.run_id if run_source.paths is not None else None,
|
|
334
|
+
workspace_root=root,
|
|
335
|
+
),
|
|
336
|
+
encoding="utf-8",
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
summary_path = bundle_dir / "summary.md"
|
|
340
|
+
summary_path.write_text(
|
|
341
|
+
_render_summary_markdown(
|
|
342
|
+
workspace_root=root,
|
|
343
|
+
bundle_dir=bundle_dir,
|
|
344
|
+
output_root=output_root,
|
|
345
|
+
session_source=session_source,
|
|
346
|
+
run_source=run_source,
|
|
347
|
+
copied_session_log_path=copied_session_log_path,
|
|
348
|
+
copied_session_artifacts_path=copied_session_artifacts_path,
|
|
349
|
+
snapshot_path=snapshot_path,
|
|
350
|
+
copied_web_research_path=copied_web_research_path,
|
|
351
|
+
copied_pointer_path=copied_pointer_path,
|
|
352
|
+
copied_run_root_path=copied_run_root_path,
|
|
353
|
+
),
|
|
354
|
+
encoding="utf-8",
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
manifest_path = bundle_dir / "manifest.json"
|
|
358
|
+
_write_json(
|
|
359
|
+
manifest_path,
|
|
360
|
+
{
|
|
361
|
+
"schema_version": 1,
|
|
362
|
+
"tool": "alysis",
|
|
363
|
+
"tool_version": __version__,
|
|
364
|
+
"created_at": _now_iso(created_at),
|
|
365
|
+
"workspace_root": safe_serialized_path_field(
|
|
366
|
+
"workspace_root",
|
|
367
|
+
root,
|
|
368
|
+
workspace_root=root,
|
|
369
|
+
),
|
|
370
|
+
"bundle_dir": safe_serialized_path_field(
|
|
371
|
+
"bundle_dir",
|
|
372
|
+
bundle_dir,
|
|
373
|
+
workspace_root=root,
|
|
374
|
+
),
|
|
375
|
+
"zip_path": safe_serialized_path_field(
|
|
376
|
+
"zip_path",
|
|
377
|
+
zip_path,
|
|
378
|
+
workspace_root=root,
|
|
379
|
+
),
|
|
380
|
+
"session": {
|
|
381
|
+
"source": session_source.source,
|
|
382
|
+
"session_id": session_source.session_id,
|
|
383
|
+
"logging_enabled": session_source.logging_enabled,
|
|
384
|
+
"log_path": safe_serialized_path_field(
|
|
385
|
+
"log_path",
|
|
386
|
+
session_source.log_path,
|
|
387
|
+
workspace_root=root,
|
|
388
|
+
),
|
|
389
|
+
"artifact_root": (
|
|
390
|
+
safe_serialized_path_field(
|
|
391
|
+
"artifact_root",
|
|
392
|
+
session_source.artifact_root,
|
|
393
|
+
workspace_root=root,
|
|
394
|
+
)
|
|
395
|
+
if session_source.artifact_root
|
|
396
|
+
else None
|
|
397
|
+
),
|
|
398
|
+
"included_log": copied_session_log_path is not None,
|
|
399
|
+
"included_artifacts": copied_session_artifacts_path is not None,
|
|
400
|
+
"included_snapshot": snapshot_path is not None,
|
|
401
|
+
"included_web_research_artifact": copied_web_research_path is not None,
|
|
402
|
+
},
|
|
403
|
+
"run": {
|
|
404
|
+
"source": run_source.source,
|
|
405
|
+
"run_id": run_source.paths.run_id if run_source.paths is not None else None,
|
|
406
|
+
"run_dir": (
|
|
407
|
+
safe_serialized_path_field(
|
|
408
|
+
"run_dir",
|
|
409
|
+
run_source.paths.run_dir,
|
|
410
|
+
workspace_root=root,
|
|
411
|
+
)
|
|
412
|
+
if run_source.paths is not None
|
|
413
|
+
else None
|
|
414
|
+
),
|
|
415
|
+
"current_run_pointer_path": (
|
|
416
|
+
safe_serialized_path_field(
|
|
417
|
+
"current_run_pointer_path",
|
|
418
|
+
run_source.pointer_path,
|
|
419
|
+
workspace_root=root,
|
|
420
|
+
)
|
|
421
|
+
if run_source.pointer_path is not None
|
|
422
|
+
else None
|
|
423
|
+
),
|
|
424
|
+
"included_current_run_pointer": copied_pointer_path is not None,
|
|
425
|
+
"included_plan": copied_run_root_path is not None
|
|
426
|
+
and (copied_run_root_path / "plan").exists(),
|
|
427
|
+
"included_execution": copied_run_root_path is not None
|
|
428
|
+
and (copied_run_root_path / "execution").exists(),
|
|
429
|
+
"included_knowledge": copied_run_root_path is not None
|
|
430
|
+
and (copied_run_root_path / "knowledge").exists(),
|
|
431
|
+
"failure_category_counts": failure_category_counts,
|
|
432
|
+
"excluded_worktrees": True,
|
|
433
|
+
},
|
|
434
|
+
"platform": {
|
|
435
|
+
"python_version": platform.python_version(),
|
|
436
|
+
"platform": platform.platform(),
|
|
437
|
+
"system": platform.system(),
|
|
438
|
+
"release": platform.release(),
|
|
439
|
+
},
|
|
440
|
+
"bundle_policy": {
|
|
441
|
+
"local_only": True,
|
|
442
|
+
"excluded": [
|
|
443
|
+
"api_keys",
|
|
444
|
+
"environment_dumps",
|
|
445
|
+
"full_workspace_snapshots",
|
|
446
|
+
"forge_worktrees",
|
|
447
|
+
],
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
workspace_root=root,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
_write_deterministic_zip(bundle_dir=bundle_dir, zip_path=zip_path)
|
|
454
|
+
|
|
455
|
+
return FeedbackBundleResult(
|
|
456
|
+
bundle_dir=bundle_dir,
|
|
457
|
+
zip_path=zip_path,
|
|
458
|
+
output_root=output_root,
|
|
459
|
+
workspace_root=root,
|
|
460
|
+
session_id=session_source.session_id,
|
|
461
|
+
run_id=run_source.paths.run_id if run_source.paths is not None else None,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def create_feedback_github_issue_draft(
|
|
466
|
+
*,
|
|
467
|
+
bundle_result: FeedbackBundleResult,
|
|
468
|
+
feedback_text: str | None = None,
|
|
469
|
+
cfg: AppConfig | None = None,
|
|
470
|
+
github_enabled: bool | None = None,
|
|
471
|
+
open_browser: bool | None = None,
|
|
472
|
+
browser_open: Callable[..., bool] = webbrowser.open,
|
|
473
|
+
) -> FeedbackGithubIssueResult:
|
|
474
|
+
enabled = (
|
|
475
|
+
resolve_feedback_github_enabled(cfg) if github_enabled is None else bool(github_enabled)
|
|
476
|
+
)
|
|
477
|
+
if not enabled:
|
|
478
|
+
return FeedbackGithubIssueResult(
|
|
479
|
+
repo="",
|
|
480
|
+
issue_url=None,
|
|
481
|
+
opened=False,
|
|
482
|
+
open_attempted=False,
|
|
483
|
+
disabled_reason="disabled",
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
repo = resolve_feedback_github_repo(cfg)
|
|
487
|
+
title, body = build_feedback_github_issue_payload(
|
|
488
|
+
bundle_result=bundle_result,
|
|
489
|
+
feedback_text=feedback_text,
|
|
490
|
+
)
|
|
491
|
+
issue_url = _github_new_issue_url(
|
|
492
|
+
repo=repo,
|
|
493
|
+
title=title,
|
|
494
|
+
body=body,
|
|
495
|
+
bundle_result=bundle_result,
|
|
496
|
+
)
|
|
497
|
+
should_open = resolve_feedback_open_browser(cfg) if open_browser is None else bool(open_browser)
|
|
498
|
+
if not should_open:
|
|
499
|
+
return FeedbackGithubIssueResult(
|
|
500
|
+
repo=repo,
|
|
501
|
+
issue_url=issue_url,
|
|
502
|
+
opened=False,
|
|
503
|
+
open_attempted=False,
|
|
504
|
+
disabled_reason="browser_open_disabled",
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
try:
|
|
508
|
+
opened = bool(browser_open(issue_url, new=2))
|
|
509
|
+
except Exception as exc: # noqa: BLE001 - platform/browser integration is best-effort.
|
|
510
|
+
return FeedbackGithubIssueResult(
|
|
511
|
+
repo=repo,
|
|
512
|
+
issue_url=issue_url,
|
|
513
|
+
opened=False,
|
|
514
|
+
open_attempted=True,
|
|
515
|
+
open_error=str(exc),
|
|
516
|
+
)
|
|
517
|
+
return FeedbackGithubIssueResult(
|
|
518
|
+
repo=repo,
|
|
519
|
+
issue_url=issue_url,
|
|
520
|
+
opened=opened,
|
|
521
|
+
open_attempted=True,
|
|
522
|
+
open_error=None if opened else "browser did not accept the URL",
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def build_feedback_github_issue_payload(
|
|
527
|
+
*,
|
|
528
|
+
bundle_result: FeedbackBundleResult,
|
|
529
|
+
feedback_text: str | None = None,
|
|
530
|
+
) -> tuple[str, str]:
|
|
531
|
+
feedback = _sanitize_exported_freeform_text(
|
|
532
|
+
(feedback_text or "").strip(),
|
|
533
|
+
workspace_root=bundle_result.workspace_root,
|
|
534
|
+
)
|
|
535
|
+
title = _feedback_issue_title(feedback)
|
|
536
|
+
manifest = _read_optional_json(bundle_result.bundle_dir / "manifest.json")
|
|
537
|
+
score = _read_optional_json(bundle_result.bundle_dir / "session_score.json")
|
|
538
|
+
body = _feedback_issue_body(
|
|
539
|
+
bundle_result=bundle_result,
|
|
540
|
+
feedback_text=feedback,
|
|
541
|
+
manifest=manifest,
|
|
542
|
+
score=score,
|
|
543
|
+
)
|
|
544
|
+
return title, body
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def feedback_github_issue_status_lines(result: FeedbackGithubIssueResult) -> list[str]:
|
|
548
|
+
if result.issue_url is None:
|
|
549
|
+
return []
|
|
550
|
+
lines: list[str] = []
|
|
551
|
+
if result.opened:
|
|
552
|
+
lines.append(f"GitHub issue draft opened: {result.issue_url}")
|
|
553
|
+
else:
|
|
554
|
+
lines.append(f"GitHub issue draft URL: {result.issue_url}")
|
|
555
|
+
if result.open_attempted and result.open_error:
|
|
556
|
+
lines.append(f"Browser open failed: {result.open_error}")
|
|
557
|
+
lines.append("Review the issue in GitHub before submitting; the archive was not uploaded.")
|
|
558
|
+
return lines
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _github_new_issue_url(
|
|
562
|
+
*,
|
|
563
|
+
repo: str,
|
|
564
|
+
title: str,
|
|
565
|
+
body: str,
|
|
566
|
+
bundle_result: FeedbackBundleResult,
|
|
567
|
+
) -> str:
|
|
568
|
+
url = _raw_github_new_issue_url(repo=repo, title=title, body=body)
|
|
569
|
+
if len(url) <= _ISSUE_URL_MAX_CHARS:
|
|
570
|
+
return url
|
|
571
|
+
|
|
572
|
+
compact_body = _compact_feedback_issue_body(bundle_result=bundle_result)
|
|
573
|
+
url = _raw_github_new_issue_url(repo=repo, title=title, body=compact_body)
|
|
574
|
+
while len(url) > _ISSUE_URL_MAX_CHARS and len(compact_body) > 240:
|
|
575
|
+
compact_body = _truncate_issue_text(
|
|
576
|
+
compact_body,
|
|
577
|
+
max_chars=max(240, len(compact_body) // 2),
|
|
578
|
+
)
|
|
579
|
+
url = _raw_github_new_issue_url(repo=repo, title=title, body=compact_body)
|
|
580
|
+
return url
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _raw_github_new_issue_url(*, repo: str, title: str, body: str) -> str:
|
|
584
|
+
query = urlencode({"title": title, "body": body})
|
|
585
|
+
return f"https://github.com/{repo}/issues/new?{query}"
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _feedback_issue_title(feedback_text: str) -> str:
|
|
589
|
+
first_line = next(
|
|
590
|
+
(line.strip() for line in feedback_text.splitlines() if line.strip()),
|
|
591
|
+
"",
|
|
592
|
+
)
|
|
593
|
+
if not first_line:
|
|
594
|
+
return "Alysis Code feedback report"
|
|
595
|
+
normalized = re.sub(r"\s+", " ", first_line)
|
|
596
|
+
prefix = "Alysis Code feedback: "
|
|
597
|
+
available = _ISSUE_TITLE_MAX_CHARS - len(prefix)
|
|
598
|
+
if len(normalized) > available:
|
|
599
|
+
normalized = normalized[: max(0, available - 1)].rstrip() + "..."
|
|
600
|
+
return f"{prefix}{normalized}"
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _feedback_issue_body(
|
|
604
|
+
*,
|
|
605
|
+
bundle_result: FeedbackBundleResult,
|
|
606
|
+
feedback_text: str,
|
|
607
|
+
manifest: dict[str, Any],
|
|
608
|
+
score: dict[str, Any],
|
|
609
|
+
) -> str:
|
|
610
|
+
bundle_dir = safe_serialized_path(
|
|
611
|
+
bundle_result.bundle_dir,
|
|
612
|
+
workspace_root=bundle_result.workspace_root,
|
|
613
|
+
)
|
|
614
|
+
zip_path = safe_serialized_path(
|
|
615
|
+
bundle_result.zip_path,
|
|
616
|
+
workspace_root=bundle_result.workspace_root,
|
|
617
|
+
)
|
|
618
|
+
platform_payload = (
|
|
619
|
+
manifest.get("platform") if isinstance(manifest.get("platform"), dict) else {}
|
|
620
|
+
)
|
|
621
|
+
run_payload = manifest.get("run") if isinstance(manifest.get("run"), dict) else {}
|
|
622
|
+
session_payload = manifest.get("session") if isinstance(manifest.get("session"), dict) else {}
|
|
623
|
+
feedback_block = _truncate_issue_text(
|
|
624
|
+
feedback_text or "_No feedback text was provided._",
|
|
625
|
+
max_chars=_ISSUE_FEEDBACK_MAX_CHARS,
|
|
626
|
+
)
|
|
627
|
+
lines = [
|
|
628
|
+
"### Feedback",
|
|
629
|
+
"",
|
|
630
|
+
feedback_block,
|
|
631
|
+
"",
|
|
632
|
+
"### Local Support Bundle",
|
|
633
|
+
"",
|
|
634
|
+
f"- Directory: `{bundle_dir or '(unknown)'}`",
|
|
635
|
+
f"- Archive: `{zip_path or '(unknown)'}`",
|
|
636
|
+
"",
|
|
637
|
+
(
|
|
638
|
+
"Please review the archive before attaching it. It is generated locally, applies "
|
|
639
|
+
"best-effort secret/path redaction, and is not uploaded automatically."
|
|
640
|
+
),
|
|
641
|
+
"",
|
|
642
|
+
"### Session",
|
|
643
|
+
"",
|
|
644
|
+
f"- Session ID: `{bundle_result.session_id or '(none)'}`",
|
|
645
|
+
f"- Run ID: `{bundle_result.run_id or '(none)'}`",
|
|
646
|
+
f"- Session source: `{session_payload.get('source') or '(unknown)'}`",
|
|
647
|
+
f"- Run source: `{run_payload.get('source') or '(unknown)'}`",
|
|
648
|
+
f"- Verification authoritative: `{_yes_no(score.get('verification_authoritative'))}`",
|
|
649
|
+
f"- Last verification failure: `{score.get('last_verification_failure_kind') or '(none recorded)'}`",
|
|
650
|
+
"",
|
|
651
|
+
"### Environment",
|
|
652
|
+
"",
|
|
653
|
+
f"- Alysis Code version: `{__version__}`",
|
|
654
|
+
f"- Python: `{platform_payload.get('python_version') or platform.python_version()}`",
|
|
655
|
+
f"- Platform: `{platform_payload.get('platform') or platform.platform()}`",
|
|
656
|
+
"",
|
|
657
|
+
"### Bundle Policy",
|
|
658
|
+
"",
|
|
659
|
+
"- No report data or archive was submitted automatically.",
|
|
660
|
+
"- The issue body intentionally excludes raw logs, tool outputs, environment dumps, and API keys.",
|
|
661
|
+
"- Attach the archive only if you are comfortable sharing the local support bundle.",
|
|
662
|
+
"",
|
|
663
|
+
]
|
|
664
|
+
body = "\n".join(lines)
|
|
665
|
+
return _truncate_issue_text(body, max_chars=_ISSUE_BODY_MAX_CHARS)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _compact_feedback_issue_body(*, bundle_result: FeedbackBundleResult) -> str:
|
|
669
|
+
bundle_dir = safe_serialized_path(
|
|
670
|
+
bundle_result.bundle_dir,
|
|
671
|
+
workspace_root=bundle_result.workspace_root,
|
|
672
|
+
)
|
|
673
|
+
zip_path = safe_serialized_path(
|
|
674
|
+
bundle_result.zip_path,
|
|
675
|
+
workspace_root=bundle_result.workspace_root,
|
|
676
|
+
)
|
|
677
|
+
lines = [
|
|
678
|
+
"### Feedback",
|
|
679
|
+
"",
|
|
680
|
+
"The original feedback text was too large for a stable GitHub prefill URL.",
|
|
681
|
+
"",
|
|
682
|
+
"### Local Support Bundle",
|
|
683
|
+
"",
|
|
684
|
+
f"- Directory: `{bundle_dir or '(unknown)'}`",
|
|
685
|
+
f"- Archive: `{zip_path or '(unknown)'}`",
|
|
686
|
+
"",
|
|
687
|
+
"Please review the archive before attaching it. It was not uploaded automatically.",
|
|
688
|
+
"",
|
|
689
|
+
"### Bundle Policy",
|
|
690
|
+
"",
|
|
691
|
+
"- No report data or archive was submitted automatically.",
|
|
692
|
+
"- The issue body intentionally excludes raw logs, tool outputs, environment dumps, and API keys.",
|
|
693
|
+
]
|
|
694
|
+
return "\n".join(lines)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _read_optional_json(path: Path) -> dict[str, Any]:
|
|
698
|
+
try:
|
|
699
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
700
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
701
|
+
return {}
|
|
702
|
+
return payload if isinstance(payload, dict) else {}
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _truncate_issue_text(text: str, *, max_chars: int) -> str:
|
|
706
|
+
clean = str(text or "")
|
|
707
|
+
if len(clean) <= max_chars:
|
|
708
|
+
return clean
|
|
709
|
+
suffix = "\n\n[Truncated for GitHub issue prefill. See local feedback bundle for details.]"
|
|
710
|
+
return clean[: max(0, max_chars - len(suffix))].rstrip() + suffix
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _yes_no(value: Any) -> str:
|
|
714
|
+
if isinstance(value, bool):
|
|
715
|
+
return "yes" if value else "no"
|
|
716
|
+
return "(unknown)"
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _resolve_session_source(
|
|
720
|
+
*,
|
|
721
|
+
cfg: AppConfig,
|
|
722
|
+
active_session: Any | None,
|
|
723
|
+
pending_images: list[str] | None,
|
|
724
|
+
session_id: str | None,
|
|
725
|
+
latest: bool,
|
|
726
|
+
) -> _ResolvedSessionSource:
|
|
727
|
+
if active_session is not None:
|
|
728
|
+
return _resolve_active_session_source(
|
|
729
|
+
active_session=active_session,
|
|
730
|
+
pending_images=pending_images,
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
sessions_dir = resolve_sessions_dir(cfg)
|
|
734
|
+
target_session_id = _validate_export_id("session id", session_id) if session_id else ""
|
|
735
|
+
if not target_session_id:
|
|
736
|
+
infos = filter_sessions_to_local_owner(list_sessions(sessions_dir))
|
|
737
|
+
if infos:
|
|
738
|
+
target_session_id = infos[0].session_id
|
|
739
|
+
elif latest:
|
|
740
|
+
raise FeedbackReportError(f"No retained sessions found in {sessions_dir}")
|
|
741
|
+
|
|
742
|
+
if not target_session_id:
|
|
743
|
+
return _ResolvedSessionSource(
|
|
744
|
+
source="none",
|
|
745
|
+
session_id=None,
|
|
746
|
+
log_path=None,
|
|
747
|
+
log_meta_path=None,
|
|
748
|
+
artifact_root=None,
|
|
749
|
+
logging_enabled=False,
|
|
750
|
+
score_payload=_fallback_session_score(
|
|
751
|
+
session_id=None,
|
|
752
|
+
reason="no retained session selected",
|
|
753
|
+
),
|
|
754
|
+
snapshot_payload=None,
|
|
755
|
+
web_research_payload=None,
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
log_path = _path_within_dir(
|
|
759
|
+
base_dir=sessions_dir,
|
|
760
|
+
candidate=sessions_dir / f"{target_session_id}.jsonl",
|
|
761
|
+
label="session log",
|
|
762
|
+
require_exists=True,
|
|
763
|
+
)
|
|
764
|
+
if not log_path.exists():
|
|
765
|
+
raise FeedbackReportError(f"Session log not found: {log_path}")
|
|
766
|
+
artifact_root_candidate = sessions_dir / sanitize_session_id(target_session_id)
|
|
767
|
+
artifact_root = (
|
|
768
|
+
_path_within_dir(
|
|
769
|
+
base_dir=sessions_dir,
|
|
770
|
+
candidate=artifact_root_candidate,
|
|
771
|
+
label="session artifact root",
|
|
772
|
+
require_exists=False,
|
|
773
|
+
)
|
|
774
|
+
if artifact_root_candidate.exists()
|
|
775
|
+
else artifact_root_candidate
|
|
776
|
+
)
|
|
777
|
+
meta_path = log_path.with_suffix(".meta.json")
|
|
778
|
+
events = list(read_session_events(log_path))
|
|
779
|
+
score_payload = score_session_events(events)
|
|
780
|
+
score_payload["session_id"] = str(score_payload.get("session_id") or target_session_id)
|
|
781
|
+
score_payload["path"] = str(log_path)
|
|
782
|
+
web_research_payload = _merge_web_research_payloads(
|
|
783
|
+
events=events,
|
|
784
|
+
persisted_payload=_read_web_research_payload(
|
|
785
|
+
artifact_root if artifact_root.exists() else None
|
|
786
|
+
),
|
|
787
|
+
)
|
|
788
|
+
_overlay_web_research_metrics(score_payload, web_research_payload)
|
|
789
|
+
return _ResolvedSessionSource(
|
|
790
|
+
source="explicit_session_id" if session_id else "latest_retained",
|
|
791
|
+
session_id=target_session_id,
|
|
792
|
+
log_path=log_path,
|
|
793
|
+
log_meta_path=meta_path if meta_path.exists() else None,
|
|
794
|
+
artifact_root=artifact_root if artifact_root.exists() else None,
|
|
795
|
+
logging_enabled=True,
|
|
796
|
+
score_payload=score_payload,
|
|
797
|
+
snapshot_payload=None,
|
|
798
|
+
web_research_payload=web_research_payload,
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _resolve_active_session_source(
|
|
803
|
+
*,
|
|
804
|
+
active_session: Any,
|
|
805
|
+
pending_images: list[str] | None,
|
|
806
|
+
) -> _ResolvedSessionSource:
|
|
807
|
+
store = getattr(active_session, "store", None)
|
|
808
|
+
session_id = str(getattr(store, "session_id", "") or "").strip() or None
|
|
809
|
+
logging_enabled = bool(getattr(store, "enabled", False))
|
|
810
|
+
log_path_obj = getattr(store, "path", None)
|
|
811
|
+
log_path = Path(log_path_obj) if log_path_obj is not None else None
|
|
812
|
+
if log_path is not None and not log_path.exists():
|
|
813
|
+
log_path = None
|
|
814
|
+
artifact_root_obj = getattr(store, "session_artifact_root", None)
|
|
815
|
+
artifact_root = artifact_root_obj if isinstance(artifact_root_obj, Path) else None
|
|
816
|
+
if artifact_root is not None and not artifact_root.exists():
|
|
817
|
+
artifact_root = None
|
|
818
|
+
meta_path = log_path.with_suffix(".meta.json") if log_path is not None else None
|
|
819
|
+
event_snapshot = (
|
|
820
|
+
events_fn() if callable(events_fn := getattr(store, "events_snapshot", None)) else None
|
|
821
|
+
)
|
|
822
|
+
web_research_payload = (
|
|
823
|
+
artifact_fn()
|
|
824
|
+
if callable(artifact_fn := getattr(store, "web_research_artifact_payload", None))
|
|
825
|
+
else None
|
|
826
|
+
)
|
|
827
|
+
snapshot_payload = None
|
|
828
|
+
if not logging_enabled or log_path is None:
|
|
829
|
+
snapshot_payload = _build_in_memory_session_snapshot(
|
|
830
|
+
active_session=active_session,
|
|
831
|
+
pending_images=pending_images,
|
|
832
|
+
)
|
|
833
|
+
if isinstance(event_snapshot, list) and event_snapshot:
|
|
834
|
+
score_payload = score_session_events(event_snapshot)
|
|
835
|
+
score_payload["session_id"] = str(score_payload.get("session_id") or session_id or "")
|
|
836
|
+
score_payload["path"] = str(log_path) if log_path is not None else None
|
|
837
|
+
score_payload["available"] = True
|
|
838
|
+
score_payload["reason"] = "derived from active in-memory session events"
|
|
839
|
+
elif log_path is not None:
|
|
840
|
+
score_payload = score_session_log(log_path)
|
|
841
|
+
else:
|
|
842
|
+
score_payload = _fallback_session_score(
|
|
843
|
+
session_id=session_id,
|
|
844
|
+
reason="session logging disabled or log not retained",
|
|
845
|
+
)
|
|
846
|
+
_overlay_web_research_metrics(score_payload, web_research_payload)
|
|
847
|
+
return _ResolvedSessionSource(
|
|
848
|
+
source="active_session",
|
|
849
|
+
session_id=session_id,
|
|
850
|
+
log_path=log_path,
|
|
851
|
+
log_meta_path=meta_path if meta_path is not None and meta_path.exists() else None,
|
|
852
|
+
artifact_root=artifact_root,
|
|
853
|
+
logging_enabled=logging_enabled,
|
|
854
|
+
score_payload=score_payload,
|
|
855
|
+
snapshot_payload=snapshot_payload,
|
|
856
|
+
web_research_payload=(
|
|
857
|
+
web_research_payload
|
|
858
|
+
if web_research_artifact_has_activity(web_research_payload)
|
|
859
|
+
else None
|
|
860
|
+
),
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _read_web_research_payload(artifact_root: Path | None) -> dict[str, Any] | None:
|
|
865
|
+
if artifact_root is None:
|
|
866
|
+
return None
|
|
867
|
+
artifact_path = artifact_root / "web_research_sources.json"
|
|
868
|
+
if not artifact_path.exists():
|
|
869
|
+
return None
|
|
870
|
+
try:
|
|
871
|
+
payload = json.loads(artifact_path.read_text(encoding="utf-8"))
|
|
872
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
873
|
+
return None
|
|
874
|
+
return payload if web_research_artifact_has_activity(payload) else None
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _merge_web_research_payloads(
|
|
878
|
+
*,
|
|
879
|
+
events: list[dict[str, Any]] | None = None,
|
|
880
|
+
persisted_payload: dict[str, Any] | None = None,
|
|
881
|
+
) -> dict[str, Any] | None:
|
|
882
|
+
tracker = SessionWebResearchTracker()
|
|
883
|
+
if isinstance(events, list):
|
|
884
|
+
for event in events:
|
|
885
|
+
payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
|
|
886
|
+
tracker.observe_event(
|
|
887
|
+
event_type=str(event.get("type") or "").strip(),
|
|
888
|
+
payload=payload,
|
|
889
|
+
ts=str(event.get("ts") or "").strip() or None,
|
|
890
|
+
)
|
|
891
|
+
if isinstance(persisted_payload, dict):
|
|
892
|
+
tracker.merge_from_artifact_payload(persisted_payload)
|
|
893
|
+
tracker.clear_pending()
|
|
894
|
+
payload = tracker.artifact_payload()
|
|
895
|
+
return payload if web_research_artifact_has_activity(payload) else None
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _overlay_web_research_metrics(
|
|
899
|
+
score_payload: dict[str, Any],
|
|
900
|
+
web_research_payload: dict[str, Any] | None,
|
|
901
|
+
) -> None:
|
|
902
|
+
if not web_research_artifact_has_activity(web_research_payload):
|
|
903
|
+
return
|
|
904
|
+
score_payload.update(build_web_research_metrics_from_artifact_payload(web_research_payload))
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _resolve_run_source(
|
|
908
|
+
*,
|
|
909
|
+
root: Path,
|
|
910
|
+
active_run_paths: RunPaths | None,
|
|
911
|
+
run_id: str | None,
|
|
912
|
+
) -> _ResolvedRunSource:
|
|
913
|
+
if active_run_paths is not None:
|
|
914
|
+
_validate_run_paths(active_run_paths)
|
|
915
|
+
pointer_path = current_run_pointer_path(active_run_paths.root)
|
|
916
|
+
return _ResolvedRunSource(
|
|
917
|
+
source="active_chat_forge",
|
|
918
|
+
paths=active_run_paths,
|
|
919
|
+
pointer_path=pointer_path if pointer_path.exists() else None,
|
|
920
|
+
pointer_matches_run=True,
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
selected_run_id = _validate_export_id("run id", run_id) if run_id else ""
|
|
924
|
+
if selected_run_id:
|
|
925
|
+
paths = make_run_paths(root=root, run_id=selected_run_id)
|
|
926
|
+
_validate_run_paths(paths)
|
|
927
|
+
if not paths.run_dir.exists():
|
|
928
|
+
raise FeedbackReportError(f"Forge run not found: {paths.run_dir}")
|
|
929
|
+
pointer_path = current_run_pointer_path(root)
|
|
930
|
+
pointer_matches = False
|
|
931
|
+
if pointer_path.exists():
|
|
932
|
+
try:
|
|
933
|
+
current_paths = load_current_run_paths(root)
|
|
934
|
+
pointer_matches = current_paths.run_id == paths.run_id
|
|
935
|
+
except ForgeError:
|
|
936
|
+
pointer_matches = False
|
|
937
|
+
return _ResolvedRunSource(
|
|
938
|
+
source="explicit_run_id",
|
|
939
|
+
paths=paths,
|
|
940
|
+
pointer_path=pointer_path if pointer_path.exists() else None,
|
|
941
|
+
pointer_matches_run=pointer_matches,
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
try:
|
|
945
|
+
paths = load_current_run_paths(root)
|
|
946
|
+
except ForgeError:
|
|
947
|
+
return _ResolvedRunSource(
|
|
948
|
+
source="none",
|
|
949
|
+
paths=None,
|
|
950
|
+
pointer_path=None,
|
|
951
|
+
pointer_matches_run=False,
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
_validate_run_paths(paths)
|
|
955
|
+
pointer_path = current_run_pointer_path(paths.root)
|
|
956
|
+
return _ResolvedRunSource(
|
|
957
|
+
source="current_run_pointer",
|
|
958
|
+
paths=paths,
|
|
959
|
+
pointer_path=pointer_path if pointer_path.exists() else None,
|
|
960
|
+
pointer_matches_run=True,
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _build_in_memory_session_snapshot(
|
|
965
|
+
*,
|
|
966
|
+
active_session: Any,
|
|
967
|
+
pending_images: list[str] | None,
|
|
968
|
+
) -> dict[str, Any]:
|
|
969
|
+
session_root_obj = getattr(active_session, "root", None)
|
|
970
|
+
session_root = session_root_obj if isinstance(session_root_obj, Path) else None
|
|
971
|
+
usage_summary = getattr(active_session, "usage_summary", None)
|
|
972
|
+
totals_fn = getattr(usage_summary, "totals", None)
|
|
973
|
+
usage_totals = totals_fn() if callable(totals_fn) else {}
|
|
974
|
+
messages_obj = getattr(active_session, "messages", [])
|
|
975
|
+
messages = messages_obj if isinstance(messages_obj, list) else []
|
|
976
|
+
non_system_messages = [
|
|
977
|
+
_to_jsonable(message)
|
|
978
|
+
for message in messages
|
|
979
|
+
if isinstance(message, dict) and str(message.get("role") or "").strip().lower() != "system"
|
|
980
|
+
]
|
|
981
|
+
store = getattr(active_session, "store", None)
|
|
982
|
+
web_research_sources = None
|
|
983
|
+
has_web_research_activity = getattr(store, "has_web_research_activity", None)
|
|
984
|
+
web_research_artifact_payload = getattr(store, "web_research_artifact_payload", None)
|
|
985
|
+
if callable(has_web_research_activity) and callable(web_research_artifact_payload):
|
|
986
|
+
if has_web_research_activity():
|
|
987
|
+
web_research_sources = _to_jsonable(web_research_artifact_payload())
|
|
988
|
+
return {
|
|
989
|
+
"snapshot_type": "in_memory_session",
|
|
990
|
+
"session_id": str(getattr(store, "session_id", "") or "").strip() or None,
|
|
991
|
+
"workspace_root": safe_serialized_path_field(
|
|
992
|
+
"workspace_root",
|
|
993
|
+
session_root,
|
|
994
|
+
workspace_root=session_root,
|
|
995
|
+
),
|
|
996
|
+
"mode": str(getattr(active_session, "mode", "") or ""),
|
|
997
|
+
"model": str(getattr(getattr(active_session, "client", None), "model", "") or ""),
|
|
998
|
+
"stream": bool(getattr(active_session, "stream", False)),
|
|
999
|
+
"no_log": bool(getattr(active_session, "no_log", False)),
|
|
1000
|
+
"message_count": len(messages),
|
|
1001
|
+
"messages": non_system_messages,
|
|
1002
|
+
"pending_images": [
|
|
1003
|
+
safe_serialized_path(str(item), workspace_root=session_root) or str(item)
|
|
1004
|
+
for item in list(pending_images or [])
|
|
1005
|
+
if str(item).strip()
|
|
1006
|
+
],
|
|
1007
|
+
"usage_totals": _to_jsonable(usage_totals),
|
|
1008
|
+
"effective_verification_commands": _to_jsonable(
|
|
1009
|
+
getattr(active_session, "effective_verification_commands", [])
|
|
1010
|
+
),
|
|
1011
|
+
"authoritative_verification_commands": _to_jsonable(
|
|
1012
|
+
getattr(active_session, "authoritative_verification_commands", None)
|
|
1013
|
+
),
|
|
1014
|
+
"verification_selection_source": str(
|
|
1015
|
+
getattr(active_session, "verification_selection_source", "") or ""
|
|
1016
|
+
),
|
|
1017
|
+
"verification_selection_reason": str(
|
|
1018
|
+
getattr(active_session, "verification_selection_reason", "") or ""
|
|
1019
|
+
),
|
|
1020
|
+
"verification_contract_type": str(
|
|
1021
|
+
getattr(active_session, "verification_contract_type", "") or ""
|
|
1022
|
+
),
|
|
1023
|
+
"verification_authoritative": bool(
|
|
1024
|
+
getattr(active_session, "verification_authoritative", False)
|
|
1025
|
+
),
|
|
1026
|
+
"web_research_sources": web_research_sources,
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _fallback_session_score(*, session_id: str | None, reason: str) -> dict[str, Any]:
|
|
1031
|
+
payload = score_session_events(
|
|
1032
|
+
[
|
|
1033
|
+
{
|
|
1034
|
+
"type": "session_start",
|
|
1035
|
+
"session_id": str(session_id or ""),
|
|
1036
|
+
"payload": {},
|
|
1037
|
+
}
|
|
1038
|
+
]
|
|
1039
|
+
)
|
|
1040
|
+
payload["session_id"] = str(session_id or "")
|
|
1041
|
+
payload["available"] = False
|
|
1042
|
+
payload["reason"] = reason
|
|
1043
|
+
payload["path"] = None
|
|
1044
|
+
return payload
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _ensure_feedback_dir_is_git_ignored(root: Path) -> None:
|
|
1048
|
+
try:
|
|
1049
|
+
ensure_git_repo(root)
|
|
1050
|
+
except GitOpsError:
|
|
1051
|
+
return
|
|
1052
|
+
ensure_runtime_artifact_excludes(root)
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _session_source_has_exportable_content(source: _ResolvedSessionSource) -> bool:
|
|
1056
|
+
return (
|
|
1057
|
+
source.log_path is not None
|
|
1058
|
+
or source.artifact_root is not None
|
|
1059
|
+
or source.snapshot_payload is not None
|
|
1060
|
+
or source.web_research_payload is not None
|
|
1061
|
+
)
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def _copy_selected_run_artifacts(
|
|
1065
|
+
paths: RunPaths,
|
|
1066
|
+
target_root: Path,
|
|
1067
|
+
*,
|
|
1068
|
+
workspace_root: Path,
|
|
1069
|
+
) -> None:
|
|
1070
|
+
selected = [
|
|
1071
|
+
(paths.plan_dir, target_root / "plan"),
|
|
1072
|
+
(paths.execution_dir, target_root / "execution"),
|
|
1073
|
+
(paths.knowledge_dir, target_root / "knowledge"),
|
|
1074
|
+
]
|
|
1075
|
+
for src, dest in selected:
|
|
1076
|
+
if src.exists():
|
|
1077
|
+
_copy_tree(src, dest, workspace_root=workspace_root)
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def _collect_run_failure_category_counts(paths: RunPaths) -> dict[str, int]:
|
|
1081
|
+
counts = empty_failure_category_counts()
|
|
1082
|
+
_collect_failure_categories_from_json_dir(paths.execution_dir / "worker_results", counts)
|
|
1083
|
+
_collect_failure_categories_from_json_dir(paths.execution_integration_dir, counts)
|
|
1084
|
+
return counts
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def _collect_failure_categories_from_json_dir(path: Path, counts: dict[str, int]) -> None:
|
|
1088
|
+
if not path.exists():
|
|
1089
|
+
return
|
|
1090
|
+
for item in sorted(path.rglob("*.json")):
|
|
1091
|
+
try:
|
|
1092
|
+
payload = json.loads(item.read_text(encoding="utf-8"))
|
|
1093
|
+
except (OSError, json.JSONDecodeError):
|
|
1094
|
+
continue
|
|
1095
|
+
if not isinstance(payload, dict):
|
|
1096
|
+
continue
|
|
1097
|
+
increment_failure_category_count(counts, payload.get("failure_category"))
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def _copy_tree(src: Path, dest: Path, *, workspace_root: Path) -> None:
|
|
1101
|
+
if not src.exists():
|
|
1102
|
+
return
|
|
1103
|
+
if src.is_dir():
|
|
1104
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
1105
|
+
for path in sorted(src.rglob("*")):
|
|
1106
|
+
if path.is_symlink():
|
|
1107
|
+
continue
|
|
1108
|
+
rel = path.relative_to(src)
|
|
1109
|
+
target = dest / rel
|
|
1110
|
+
if path.is_dir():
|
|
1111
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
1112
|
+
continue
|
|
1113
|
+
if not path.is_file():
|
|
1114
|
+
continue
|
|
1115
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1116
|
+
_copy_file(path, target, workspace_root=workspace_root)
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def _copy_file(src: Path, dest: Path, *, workspace_root: Path) -> None:
|
|
1120
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
1121
|
+
text = _read_text_if_utf8(src)
|
|
1122
|
+
suffix = src.suffix.lower()
|
|
1123
|
+
if text is None:
|
|
1124
|
+
if suffix in {".json", ".jsonl"}:
|
|
1125
|
+
_write_invalid_structured_export(dest, jsonl=suffix == ".jsonl")
|
|
1126
|
+
shutil.copystat(src, dest)
|
|
1127
|
+
return
|
|
1128
|
+
shutil.copy2(src, dest)
|
|
1129
|
+
return
|
|
1130
|
+
if suffix == ".json":
|
|
1131
|
+
sanitized_json = _sanitize_json_text(text, workspace_root=workspace_root)
|
|
1132
|
+
if sanitized_json is not None:
|
|
1133
|
+
dest.write_text(sanitized_json, encoding="utf-8")
|
|
1134
|
+
shutil.copystat(src, dest)
|
|
1135
|
+
return
|
|
1136
|
+
_write_invalid_structured_export(dest, jsonl=False)
|
|
1137
|
+
shutil.copystat(src, dest)
|
|
1138
|
+
return
|
|
1139
|
+
if suffix == ".jsonl":
|
|
1140
|
+
sanitized_jsonl = _sanitize_jsonl_text(text, workspace_root=workspace_root)
|
|
1141
|
+
if sanitized_jsonl is not None:
|
|
1142
|
+
dest.write_text(sanitized_jsonl, encoding="utf-8")
|
|
1143
|
+
shutil.copystat(src, dest)
|
|
1144
|
+
return
|
|
1145
|
+
_write_invalid_structured_export(dest, jsonl=True)
|
|
1146
|
+
shutil.copystat(src, dest)
|
|
1147
|
+
return
|
|
1148
|
+
dest.write_text(
|
|
1149
|
+
_sanitize_exported_freeform_text(text, workspace_root=workspace_root),
|
|
1150
|
+
encoding="utf-8",
|
|
1151
|
+
)
|
|
1152
|
+
shutil.copystat(src, dest)
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def _write_invalid_structured_export(path: Path, *, jsonl: bool) -> None:
|
|
1156
|
+
"""Replace an unreadable/malformed structured artifact instead of copying
|
|
1157
|
+
bytes that could bypass the recursive reasoning/continuation scrubber."""
|
|
1158
|
+
|
|
1159
|
+
if jsonl:
|
|
1160
|
+
serialized = (
|
|
1161
|
+
json.dumps(_INVALID_STRUCTURED_EXPORT, sort_keys=True, ensure_ascii=True) + "\n"
|
|
1162
|
+
)
|
|
1163
|
+
else:
|
|
1164
|
+
serialized = (
|
|
1165
|
+
json.dumps(
|
|
1166
|
+
_INVALID_STRUCTURED_EXPORT,
|
|
1167
|
+
indent=2,
|
|
1168
|
+
sort_keys=True,
|
|
1169
|
+
ensure_ascii=True,
|
|
1170
|
+
)
|
|
1171
|
+
+ "\n"
|
|
1172
|
+
)
|
|
1173
|
+
path.write_text(serialized, encoding="utf-8")
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _write_json(
|
|
1177
|
+
path: Path,
|
|
1178
|
+
payload: dict[str, Any],
|
|
1179
|
+
*,
|
|
1180
|
+
workspace_root: Path | None = None,
|
|
1181
|
+
) -> None:
|
|
1182
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1183
|
+
path.write_text(
|
|
1184
|
+
json.dumps(
|
|
1185
|
+
_redact_jsonable(payload, workspace_root=workspace_root),
|
|
1186
|
+
indent=2,
|
|
1187
|
+
sort_keys=True,
|
|
1188
|
+
ensure_ascii=True,
|
|
1189
|
+
)
|
|
1190
|
+
+ "\n",
|
|
1191
|
+
encoding="utf-8",
|
|
1192
|
+
)
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def _write_deterministic_zip(*, bundle_dir: Path, zip_path: Path) -> None:
|
|
1196
|
+
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
1197
|
+
for path in sorted(bundle_dir.rglob("*")):
|
|
1198
|
+
if not path.is_file() or path.is_symlink():
|
|
1199
|
+
continue
|
|
1200
|
+
rel = path.relative_to(bundle_dir).as_posix()
|
|
1201
|
+
info = zipfile.ZipInfo(rel)
|
|
1202
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
1203
|
+
info.date_time = (1980, 1, 1, 0, 0, 0)
|
|
1204
|
+
zf.writestr(info, path.read_bytes())
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def _render_feedback_markdown(
|
|
1208
|
+
*,
|
|
1209
|
+
created_at: datetime,
|
|
1210
|
+
feedback_text: str | None,
|
|
1211
|
+
session_id: str | None,
|
|
1212
|
+
run_id: str | None,
|
|
1213
|
+
workspace_root: Path,
|
|
1214
|
+
) -> str:
|
|
1215
|
+
lines = [
|
|
1216
|
+
"# Feedback",
|
|
1217
|
+
"",
|
|
1218
|
+
f"- Created At: {_now_iso(created_at)}",
|
|
1219
|
+
f"- Session ID: {session_id or '(none)'}",
|
|
1220
|
+
f"- Run ID: {run_id or '(none)'}",
|
|
1221
|
+
"",
|
|
1222
|
+
"## User Feedback",
|
|
1223
|
+
"",
|
|
1224
|
+
]
|
|
1225
|
+
text = _sanitize_exported_freeform_text(
|
|
1226
|
+
(feedback_text or "").strip(),
|
|
1227
|
+
workspace_root=workspace_root,
|
|
1228
|
+
)
|
|
1229
|
+
lines.append(text if text else "_No feedback text was provided._")
|
|
1230
|
+
lines.append("")
|
|
1231
|
+
return "\n".join(lines)
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
def _render_summary_markdown(
|
|
1235
|
+
*,
|
|
1236
|
+
workspace_root: Path,
|
|
1237
|
+
bundle_dir: Path,
|
|
1238
|
+
output_root: Path,
|
|
1239
|
+
session_source: _ResolvedSessionSource,
|
|
1240
|
+
run_source: _ResolvedRunSource,
|
|
1241
|
+
copied_session_log_path: Path | None,
|
|
1242
|
+
copied_session_artifacts_path: Path | None,
|
|
1243
|
+
snapshot_path: Path | None,
|
|
1244
|
+
copied_web_research_path: Path | None,
|
|
1245
|
+
copied_pointer_path: Path | None,
|
|
1246
|
+
copied_run_root_path: Path | None,
|
|
1247
|
+
) -> str:
|
|
1248
|
+
score_payload = (
|
|
1249
|
+
session_source.score_payload if isinstance(session_source.score_payload, dict) else {}
|
|
1250
|
+
)
|
|
1251
|
+
verification_selection_source = str(
|
|
1252
|
+
score_payload.get("verification_selection_source") or ""
|
|
1253
|
+
).strip()
|
|
1254
|
+
verification_selection_reason = str(
|
|
1255
|
+
score_payload.get("verification_selection_reason") or ""
|
|
1256
|
+
).strip()
|
|
1257
|
+
verification_contract_type = str(score_payload.get("verification_contract_type") or "").strip()
|
|
1258
|
+
verification_authoritative = bool(score_payload.get("verification_authoritative", False))
|
|
1259
|
+
last_verification_failure_kind = str(
|
|
1260
|
+
score_payload.get("last_verification_failure_kind") or ""
|
|
1261
|
+
).strip()
|
|
1262
|
+
lines = [
|
|
1263
|
+
"# Support Bundle Summary",
|
|
1264
|
+
"",
|
|
1265
|
+
f"- Output Root: `{safe_serialized_path(output_root, workspace_root=workspace_root)}`",
|
|
1266
|
+
f"- Session Source: `{session_source.source}`",
|
|
1267
|
+
f"- Session ID: `{session_source.session_id or '(none)'}`",
|
|
1268
|
+
f"- Session Logging Enabled: {'yes' if session_source.logging_enabled else 'no'}",
|
|
1269
|
+
(
|
|
1270
|
+
f"- Included Session Log: `"
|
|
1271
|
+
f"{safe_serialized_path(copied_session_log_path, bundle_root=bundle_dir, prefer_bundle_relative=True)}`"
|
|
1272
|
+
if copied_session_log_path is not None
|
|
1273
|
+
else "- Included Session Log: (none)"
|
|
1274
|
+
),
|
|
1275
|
+
(
|
|
1276
|
+
f"- Included Session Artifacts: "
|
|
1277
|
+
f"`{safe_serialized_path(copied_session_artifacts_path, bundle_root=bundle_dir, prefer_bundle_relative=True)}`"
|
|
1278
|
+
if copied_session_artifacts_path is not None
|
|
1279
|
+
else "- Included Session Artifacts: (none)"
|
|
1280
|
+
),
|
|
1281
|
+
(
|
|
1282
|
+
f"- Included In-Memory Snapshot: `"
|
|
1283
|
+
f"{safe_serialized_path(snapshot_path, bundle_root=bundle_dir, prefer_bundle_relative=True)}`"
|
|
1284
|
+
if snapshot_path is not None
|
|
1285
|
+
else "- Included In-Memory Snapshot: (none)"
|
|
1286
|
+
),
|
|
1287
|
+
(
|
|
1288
|
+
f"- Included Web Research Artifact: `"
|
|
1289
|
+
f"{safe_serialized_path(copied_web_research_path, bundle_root=bundle_dir, prefer_bundle_relative=True)}`"
|
|
1290
|
+
if copied_web_research_path is not None
|
|
1291
|
+
else "- Included Web Research Artifact: (none)"
|
|
1292
|
+
),
|
|
1293
|
+
f"- Run Source: `{run_source.source}`",
|
|
1294
|
+
f"- Run ID: `{run_source.paths.run_id if run_source.paths is not None else '(none)'}`",
|
|
1295
|
+
(
|
|
1296
|
+
f"- Included Current Run Pointer: `"
|
|
1297
|
+
f"{safe_serialized_path(copied_pointer_path, bundle_root=bundle_dir, prefer_bundle_relative=True)}`"
|
|
1298
|
+
if copied_pointer_path is not None
|
|
1299
|
+
else "- Included Current Run Pointer: (none)"
|
|
1300
|
+
),
|
|
1301
|
+
(
|
|
1302
|
+
f"- Included Run Artifacts: `"
|
|
1303
|
+
f"{safe_serialized_path(copied_run_root_path, bundle_root=bundle_dir, prefer_bundle_relative=True)}`"
|
|
1304
|
+
if copied_run_root_path is not None
|
|
1305
|
+
else "- Included Run Artifacts: (none)"
|
|
1306
|
+
),
|
|
1307
|
+
f"- Verification Selection Source: `{verification_selection_source or '(unknown)'}`",
|
|
1308
|
+
f"- Verification Contract Type: `{verification_contract_type or '(unknown)'}`",
|
|
1309
|
+
f"- Verification Authoritative: {'yes' if verification_authoritative else 'no'}",
|
|
1310
|
+
(
|
|
1311
|
+
f"- Verification Failure Kind: `{last_verification_failure_kind}`"
|
|
1312
|
+
if last_verification_failure_kind
|
|
1313
|
+
else "- Verification Failure Kind: (none recorded)"
|
|
1314
|
+
),
|
|
1315
|
+
(
|
|
1316
|
+
f"- Verification Selection Reason: {verification_selection_reason}"
|
|
1317
|
+
if verification_selection_reason
|
|
1318
|
+
else "- Verification Selection Reason: (none recorded)"
|
|
1319
|
+
),
|
|
1320
|
+
"- Excluded By Policy: API keys, environment dumps, full workspace snapshots, Forge worktrees",
|
|
1321
|
+
"",
|
|
1322
|
+
]
|
|
1323
|
+
return "\n".join(lines)
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
def _allocate_bundle_dir(*, output_root: Path, base_name: str) -> Path:
|
|
1327
|
+
candidate = output_root / base_name
|
|
1328
|
+
if not candidate.exists() and not candidate.with_suffix(".zip").exists():
|
|
1329
|
+
return candidate
|
|
1330
|
+
index = 2
|
|
1331
|
+
while True:
|
|
1332
|
+
candidate = output_root / f"{base_name}_{index:02d}"
|
|
1333
|
+
if not candidate.exists() and not candidate.with_suffix(".zip").exists():
|
|
1334
|
+
return candidate
|
|
1335
|
+
index += 1
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
def _make_bundle_name(
|
|
1339
|
+
*,
|
|
1340
|
+
created_at: datetime,
|
|
1341
|
+
session_id: str | None,
|
|
1342
|
+
run_id: str | None,
|
|
1343
|
+
) -> str:
|
|
1344
|
+
parts = [f"report_{created_at.strftime('%Y%m%dT%H%M%SZ')}"]
|
|
1345
|
+
if session_id:
|
|
1346
|
+
parts.append(f"session_{sanitize_session_id(session_id)[:24]}")
|
|
1347
|
+
if run_id:
|
|
1348
|
+
parts.append(f"run_{sanitize_session_id(run_id)[:24]}")
|
|
1349
|
+
return "__".join(parts)
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def _to_jsonable(value: Any) -> Any:
|
|
1353
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
1354
|
+
return value
|
|
1355
|
+
if isinstance(value, Path):
|
|
1356
|
+
return str(value)
|
|
1357
|
+
if isinstance(value, dict):
|
|
1358
|
+
return {str(key): _to_jsonable(item) for key, item in value.items()}
|
|
1359
|
+
if isinstance(value, (list, tuple)):
|
|
1360
|
+
return [_to_jsonable(item) for item in value]
|
|
1361
|
+
return str(value)
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def _validate_export_id(kind: str, value: str | None) -> str:
|
|
1365
|
+
normalized = str(value or "").strip()
|
|
1366
|
+
if not normalized:
|
|
1367
|
+
raise FeedbackReportError(f"Missing {kind}.")
|
|
1368
|
+
if not _SAFE_EXPORT_ID_RE.fullmatch(normalized):
|
|
1369
|
+
raise FeedbackReportError(
|
|
1370
|
+
f"Invalid {kind}: {normalized!r}. Only letters, numbers, '_' and '-' are allowed."
|
|
1371
|
+
)
|
|
1372
|
+
return normalized
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
def _path_within_dir(
|
|
1376
|
+
*,
|
|
1377
|
+
base_dir: Path,
|
|
1378
|
+
candidate: Path,
|
|
1379
|
+
label: str,
|
|
1380
|
+
require_exists: bool,
|
|
1381
|
+
) -> Path:
|
|
1382
|
+
base_resolved = base_dir.resolve()
|
|
1383
|
+
try:
|
|
1384
|
+
candidate_resolved = candidate.resolve(strict=require_exists)
|
|
1385
|
+
except OSError as e:
|
|
1386
|
+
raise FeedbackReportError(f"Unable to resolve {label}: {candidate}") from e
|
|
1387
|
+
try:
|
|
1388
|
+
candidate_resolved.relative_to(base_resolved)
|
|
1389
|
+
except ValueError as e:
|
|
1390
|
+
raise FeedbackReportError(f"{label.capitalize()} escapes its expected directory.") from e
|
|
1391
|
+
return candidate_resolved
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
def _validate_run_paths(paths: RunPaths) -> None:
|
|
1395
|
+
allowed_runs_dir = paths.runs_dir.resolve()
|
|
1396
|
+
run_dir_resolved = paths.run_dir.resolve()
|
|
1397
|
+
try:
|
|
1398
|
+
run_dir_resolved.relative_to(allowed_runs_dir)
|
|
1399
|
+
except ValueError as e:
|
|
1400
|
+
raise FeedbackReportError(f"Resolved run path escapes {allowed_runs_dir}") from e
|
|
1401
|
+
for label, candidate in (
|
|
1402
|
+
("plan directory", paths.plan_dir),
|
|
1403
|
+
("execution directory", paths.execution_dir),
|
|
1404
|
+
("knowledge directory", paths.knowledge_dir),
|
|
1405
|
+
):
|
|
1406
|
+
if not candidate.exists():
|
|
1407
|
+
continue
|
|
1408
|
+
try:
|
|
1409
|
+
candidate.resolve().relative_to(run_dir_resolved)
|
|
1410
|
+
except ValueError as e:
|
|
1411
|
+
raise FeedbackReportError(f"Resolved {label} escapes run directory") from e
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
def _redact_bundle_text(text: str) -> str:
|
|
1415
|
+
clean = str(text or "")
|
|
1416
|
+
for pattern, replacement in _SECRET_REPLACEMENTS:
|
|
1417
|
+
clean = pattern.sub(replacement, clean)
|
|
1418
|
+
return clean
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def _sanitize_url_match(match: re.Match[str], *, strip_path: bool) -> str:
|
|
1422
|
+
raw = match.group(0)
|
|
1423
|
+
trailing = ""
|
|
1424
|
+
while raw and raw[-1] in ".,;:!?)]":
|
|
1425
|
+
trailing = raw[-1] + trailing
|
|
1426
|
+
raw = raw[:-1]
|
|
1427
|
+
try:
|
|
1428
|
+
parsed = urlsplit(raw)
|
|
1429
|
+
hostname = str(parsed.hostname or "").rstrip(".")
|
|
1430
|
+
safe_host = hostname.encode("idna").decode("ascii").casefold()
|
|
1431
|
+
if not safe_host or any(char.isspace() or char in "/@?#" for char in safe_host):
|
|
1432
|
+
raise ValueError("URL has no safe host")
|
|
1433
|
+
if ":" in safe_host and not safe_host.startswith("["):
|
|
1434
|
+
safe_host = f"[{safe_host}]"
|
|
1435
|
+
try:
|
|
1436
|
+
port = parsed.port
|
|
1437
|
+
except ValueError:
|
|
1438
|
+
port = None
|
|
1439
|
+
netloc = f"{safe_host}:{port}" if port is not None else safe_host
|
|
1440
|
+
sanitized = urlunsplit(
|
|
1441
|
+
(
|
|
1442
|
+
parsed.scheme.casefold(),
|
|
1443
|
+
netloc,
|
|
1444
|
+
"" if strip_path else parsed.path,
|
|
1445
|
+
"",
|
|
1446
|
+
"",
|
|
1447
|
+
)
|
|
1448
|
+
)
|
|
1449
|
+
except (UnicodeError, ValueError):
|
|
1450
|
+
sanitized = "[redacted URL]"
|
|
1451
|
+
return sanitized + trailing
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def _sanitize_urls_in_text(text: str, *, strip_path: bool = False) -> str:
|
|
1455
|
+
return _HIERARCHICAL_URL_RE.sub(
|
|
1456
|
+
lambda match: _sanitize_url_match(match, strip_path=strip_path),
|
|
1457
|
+
str(text or ""),
|
|
1458
|
+
)
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
def _is_endpoint_url_field(key: object) -> bool:
|
|
1462
|
+
normalized = _normalize_export_key(key)
|
|
1463
|
+
return normalized == "baseurl" or normalized.endswith("baseurl")
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def _sanitize_exported_freeform_text(
|
|
1467
|
+
text: str | Path | None,
|
|
1468
|
+
*,
|
|
1469
|
+
workspace_root: Path | None,
|
|
1470
|
+
) -> str:
|
|
1471
|
+
# Free-form exported strings can still contain absolute host paths even when
|
|
1472
|
+
# the surrounding JSON field is not path-shaped (for example payload.content
|
|
1473
|
+
# or user-authored feedback text), so bundle serialization must sanitize
|
|
1474
|
+
# text values separately from explicit path fields.
|
|
1475
|
+
without_host_paths = sanitize_paths_in_text(str(text or ""), workspace_root=workspace_root)
|
|
1476
|
+
# Support bundles are shared outside the live session boundary. Keep only
|
|
1477
|
+
# scheme + sanitized host (+ optional port) for hierarchical URLs: signed
|
|
1478
|
+
# path segments can be just as sensitive as userinfo and query values.
|
|
1479
|
+
return _redact_bundle_text(_sanitize_urls_in_text(without_host_paths, strip_path=True))
|
|
1480
|
+
|
|
1481
|
+
|
|
1482
|
+
def _normalize_export_key(value: object) -> str:
|
|
1483
|
+
return re.sub(r"[^a-z0-9]+", "", str(value or "").casefold())
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
def _is_provider_reasoning_block(value: Any) -> bool:
|
|
1487
|
+
"""Return whether a mapping is a provider-owned reasoning/thought block.
|
|
1488
|
+
|
|
1489
|
+
The check intentionally uses protocol-level markers shared by OpenAI,
|
|
1490
|
+
Anthropic, Gemini, OpenRouter, and Mistral rather than provider names, so an
|
|
1491
|
+
unknown compatible provider fails closed too.
|
|
1492
|
+
"""
|
|
1493
|
+
|
|
1494
|
+
if not isinstance(value, dict):
|
|
1495
|
+
return False
|
|
1496
|
+
block_type = _normalize_export_key(value.get("type"))
|
|
1497
|
+
if block_type in _REASONING_BLOCK_TYPES:
|
|
1498
|
+
return True
|
|
1499
|
+
if block_type == "signaturedelta":
|
|
1500
|
+
return True
|
|
1501
|
+
if any(family in block_type for family in _REASONING_FAMILIES) and (
|
|
1502
|
+
any(marker in block_type for marker in _REASONING_SENSITIVE_MARKERS)
|
|
1503
|
+
or block_type.endswith(("block", "chunk", "delta", "item", "part"))
|
|
1504
|
+
):
|
|
1505
|
+
return True
|
|
1506
|
+
return value.get("thought") is True
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
def _is_reasoning_payload_key(key: object) -> bool:
|
|
1510
|
+
normalized = _normalize_export_key(key)
|
|
1511
|
+
if not normalized:
|
|
1512
|
+
return False
|
|
1513
|
+
if normalized in _PROVIDER_CONTINUATION_CONTAINER_KEYS:
|
|
1514
|
+
return True
|
|
1515
|
+
if normalized in _OPAQUE_CONTINUATION_KEYS:
|
|
1516
|
+
return True
|
|
1517
|
+
if "chainofthought" in normalized or "internalmonologue" in normalized:
|
|
1518
|
+
return True
|
|
1519
|
+
if not any(family in normalized for family in _REASONING_FAMILIES):
|
|
1520
|
+
return False
|
|
1521
|
+
# Content-bearing markers always win over diagnostic markers. For example,
|
|
1522
|
+
# `reasoning_summary_tokens` still identifies summary content and is removed,
|
|
1523
|
+
# while `reasoning_tokens` and `reasoning_effort` remain useful diagnostics.
|
|
1524
|
+
if any(marker in normalized for marker in _REASONING_SENSITIVE_MARKERS):
|
|
1525
|
+
return True
|
|
1526
|
+
if normalized in {"reasoning", "thinking", "thought", "thoughts"}:
|
|
1527
|
+
return True
|
|
1528
|
+
if any(marker in normalized for marker in _REASONING_DIAGNOSTIC_MARKERS):
|
|
1529
|
+
return False
|
|
1530
|
+
# Unknown content-like fields fail closed; an explicitly diagnostic field
|
|
1531
|
+
# must carry one of the allowlisted markers above.
|
|
1532
|
+
return True
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
def _redact_jsonable(
|
|
1536
|
+
value: Any,
|
|
1537
|
+
*,
|
|
1538
|
+
current_key: str | None = None,
|
|
1539
|
+
workspace_root: Path | None = None,
|
|
1540
|
+
) -> Any:
|
|
1541
|
+
if value is None or isinstance(value, (int, float, bool)):
|
|
1542
|
+
return value
|
|
1543
|
+
if isinstance(value, str):
|
|
1544
|
+
if _is_endpoint_url_field(current_key):
|
|
1545
|
+
return _redact_bundle_text(_sanitize_urls_in_text(value, strip_path=True))
|
|
1546
|
+
if looks_like_serialized_path_field(current_key):
|
|
1547
|
+
return _redact_bundle_text(
|
|
1548
|
+
safe_serialized_path_field(
|
|
1549
|
+
current_key or "",
|
|
1550
|
+
value,
|
|
1551
|
+
workspace_root=workspace_root,
|
|
1552
|
+
)
|
|
1553
|
+
or ""
|
|
1554
|
+
)
|
|
1555
|
+
# Keep path-shaped fields on the precise structured serializer, then use
|
|
1556
|
+
# best-effort text sanitization for generic strings so exported JSON and
|
|
1557
|
+
# JSONL artifacts do not leak absolute host paths via free-form text.
|
|
1558
|
+
return _sanitize_exported_freeform_text(value, workspace_root=workspace_root)
|
|
1559
|
+
if isinstance(value, Path):
|
|
1560
|
+
if looks_like_serialized_path_field(current_key):
|
|
1561
|
+
return _redact_bundle_text(
|
|
1562
|
+
safe_serialized_path_field(
|
|
1563
|
+
current_key or "",
|
|
1564
|
+
value,
|
|
1565
|
+
workspace_root=workspace_root,
|
|
1566
|
+
)
|
|
1567
|
+
or ""
|
|
1568
|
+
)
|
|
1569
|
+
return _sanitize_exported_freeform_text(str(value), workspace_root=workspace_root)
|
|
1570
|
+
if isinstance(value, dict):
|
|
1571
|
+
if _is_provider_reasoning_block(value):
|
|
1572
|
+
return {}
|
|
1573
|
+
sanitized: dict[str, Any] = {}
|
|
1574
|
+
for key, item in value.items():
|
|
1575
|
+
key_text = str(key)
|
|
1576
|
+
if _is_reasoning_payload_key(key_text) or _is_provider_reasoning_block(item):
|
|
1577
|
+
continue
|
|
1578
|
+
sanitized[key_text] = _redact_jsonable(
|
|
1579
|
+
item,
|
|
1580
|
+
current_key=key_text,
|
|
1581
|
+
workspace_root=workspace_root,
|
|
1582
|
+
)
|
|
1583
|
+
return sanitized
|
|
1584
|
+
if isinstance(value, (list, tuple)):
|
|
1585
|
+
return [
|
|
1586
|
+
_redact_jsonable(
|
|
1587
|
+
item,
|
|
1588
|
+
current_key=current_key,
|
|
1589
|
+
workspace_root=workspace_root,
|
|
1590
|
+
)
|
|
1591
|
+
for item in value
|
|
1592
|
+
if not _is_provider_reasoning_block(item)
|
|
1593
|
+
]
|
|
1594
|
+
return _sanitize_exported_freeform_text(str(value), workspace_root=workspace_root)
|
|
1595
|
+
|
|
1596
|
+
|
|
1597
|
+
def _sanitize_json_text(text: str, *, workspace_root: Path) -> str | None:
|
|
1598
|
+
try:
|
|
1599
|
+
payload = json.loads(text)
|
|
1600
|
+
except json.JSONDecodeError:
|
|
1601
|
+
return None
|
|
1602
|
+
return (
|
|
1603
|
+
json.dumps(
|
|
1604
|
+
_redact_jsonable(payload, workspace_root=workspace_root),
|
|
1605
|
+
indent=2,
|
|
1606
|
+
sort_keys=True,
|
|
1607
|
+
ensure_ascii=True,
|
|
1608
|
+
)
|
|
1609
|
+
+ "\n"
|
|
1610
|
+
)
|
|
1611
|
+
|
|
1612
|
+
|
|
1613
|
+
def _sanitize_jsonl_text(text: str, *, workspace_root: Path) -> str | None:
|
|
1614
|
+
lines: list[str] = []
|
|
1615
|
+
for raw_line in text.splitlines():
|
|
1616
|
+
if not raw_line.strip():
|
|
1617
|
+
continue
|
|
1618
|
+
try:
|
|
1619
|
+
payload = json.loads(raw_line)
|
|
1620
|
+
except json.JSONDecodeError:
|
|
1621
|
+
return None
|
|
1622
|
+
# JSONL bundle exports must sanitize both explicit path fields and
|
|
1623
|
+
# generic free-form strings because session events often nest paths
|
|
1624
|
+
# inside payload.content/message-style fields.
|
|
1625
|
+
lines.append(
|
|
1626
|
+
json.dumps(
|
|
1627
|
+
_redact_jsonable(payload, workspace_root=workspace_root),
|
|
1628
|
+
sort_keys=True,
|
|
1629
|
+
ensure_ascii=True,
|
|
1630
|
+
)
|
|
1631
|
+
)
|
|
1632
|
+
return ("\n".join(lines) + "\n") if lines else ""
|
|
1633
|
+
|
|
1634
|
+
|
|
1635
|
+
def _read_text_if_utf8(path: Path) -> str | None:
|
|
1636
|
+
try:
|
|
1637
|
+
return path.read_text(encoding="utf-8")
|
|
1638
|
+
except (UnicodeDecodeError, OSError):
|
|
1639
|
+
return None
|
|
1640
|
+
|
|
1641
|
+
|
|
1642
|
+
def _now_utc() -> datetime:
|
|
1643
|
+
return datetime.now(UTC).replace(microsecond=0)
|
|
1644
|
+
|
|
1645
|
+
|
|
1646
|
+
def _now_iso(value: datetime) -> str:
|
|
1647
|
+
return value.isoformat().replace("+00:00", "Z")
|