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,624 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import getpass
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import socket
|
|
8
|
+
import threading
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .config import AppConfig, default_sessions_dir
|
|
16
|
+
from .logging_redaction import redact_log_text
|
|
17
|
+
from .session_artifacts import SessionArtifactLayout
|
|
18
|
+
from .web_research import SessionWebResearchTracker
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _now_ts() -> str:
|
|
22
|
+
return datetime.now(UTC).isoformat()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def make_session_id() -> str:
|
|
26
|
+
ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
27
|
+
# 8 chars is enough for local uniqueness.
|
|
28
|
+
import uuid
|
|
29
|
+
|
|
30
|
+
return f"{ts}_{uuid.uuid4().hex[:8]}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sanitize_session_id(session_id: str) -> str:
|
|
34
|
+
return re.sub(r"[^A-Za-z0-9_-]", "_", session_id.strip()) or "session"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def resolve_sessions_dir(cfg: AppConfig) -> Path:
|
|
38
|
+
if cfg.session_log_dir:
|
|
39
|
+
return Path(cfg.session_log_dir)
|
|
40
|
+
return default_sessions_dir()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def local_session_owner() -> str | None:
|
|
44
|
+
"""Deterministic identity (``os-user@hostname``) of the local account.
|
|
45
|
+
|
|
46
|
+
Session logs are stamped with their creator's identity so listing surfaces
|
|
47
|
+
(``/resume``, "latest session" defaults) can hide conversations that were
|
|
48
|
+
not created by this account — regardless of how a foreign log file arrived
|
|
49
|
+
in the sessions directory (copied archive, baked disk image, shared host
|
|
50
|
+
with distinct accounts). Purely local and deterministic: no network, no
|
|
51
|
+
model/provider involvement. Returns ``None`` when neither component can be
|
|
52
|
+
resolved; callers must treat that as "no local identity established".
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
user = getpass.getuser().strip()
|
|
56
|
+
except (KeyError, OSError, ImportError):
|
|
57
|
+
# No passwd entry / no LOGNAME-USER-USERNAME env; on py<=3.12 the
|
|
58
|
+
# env-less fallback is a bare ``import pwd`` which raises
|
|
59
|
+
# ModuleNotFoundError on Windows-family hosts (3.13+ maps it to
|
|
60
|
+
# OSError) — this function must degrade to None, never crash startup.
|
|
61
|
+
user = ""
|
|
62
|
+
try:
|
|
63
|
+
# Use the direct socket API instead of ``platform.node()``. On Windows,
|
|
64
|
+
# ``platform.node()`` can invoke ``ver`` through a shell, which makes a
|
|
65
|
+
# harmless identity lookup observable as an unrelated subprocess and
|
|
66
|
+
# lets hook subprocess instrumentation intercept it.
|
|
67
|
+
host = socket.gethostname().strip()
|
|
68
|
+
except OSError:
|
|
69
|
+
host = ""
|
|
70
|
+
if not user and not host:
|
|
71
|
+
return None
|
|
72
|
+
return f"{user}@{host}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class SessionInfo:
|
|
77
|
+
session_id: str
|
|
78
|
+
path: Path
|
|
79
|
+
mtime: float
|
|
80
|
+
# Log-derived scoping/recency metadata (all optional so any existing
|
|
81
|
+
# ``SessionInfo(session_id=..., path=..., mtime=...)`` construction keeps
|
|
82
|
+
# working). ``workspace_root``/``git_root`` come from the session log's own
|
|
83
|
+
# events (not the filesystem) and let ``/resume`` show only the current
|
|
84
|
+
# workspace's chats. ``last_event_ts`` is the log's own UTC ISO timestamp of
|
|
85
|
+
# the most recent event, used for the displayed "when" instead of file mtime
|
|
86
|
+
# (which drifts after copies/extracts).
|
|
87
|
+
# ``owner`` is the log-recorded creator identity (see
|
|
88
|
+
# :func:`local_session_owner`); listings hide sessions stamped by a
|
|
89
|
+
# different account so one user's conversations never surface for another.
|
|
90
|
+
# ``last_owner`` is the identity stamped on the log's newest event — the
|
|
91
|
+
# account that most recently used the session. Matching either lets an
|
|
92
|
+
# explicit ``/resume <id>`` re-adopt a session whose recorded identity
|
|
93
|
+
# drifted (e.g. after a hostname rename): the resume appends fresh events
|
|
94
|
+
# with the current identity, so the session self-heals into the listing.
|
|
95
|
+
workspace_root: str | None = None
|
|
96
|
+
git_root: str | None = None
|
|
97
|
+
last_event_ts: str | None = None
|
|
98
|
+
owner: str | None = None
|
|
99
|
+
last_owner: str | None = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class SessionStore:
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
*,
|
|
106
|
+
enabled: bool,
|
|
107
|
+
artifact_persistence_enabled: bool | None = None,
|
|
108
|
+
sessions_dir: Path,
|
|
109
|
+
session_id: str,
|
|
110
|
+
cwd: str,
|
|
111
|
+
repo_root: str | None,
|
|
112
|
+
workspace_root: str | None = None,
|
|
113
|
+
focus_dir: str | None = None,
|
|
114
|
+
git_root: str | None = None,
|
|
115
|
+
workspace_kind: str | None = None,
|
|
116
|
+
binding_source: str | None = None,
|
|
117
|
+
binding_requested_path: str | None = None,
|
|
118
|
+
binding_risk_level: str | None = None,
|
|
119
|
+
binding_created_path: bool | None = None,
|
|
120
|
+
runtime_kind: str | None = None,
|
|
121
|
+
active_workdir: str | None = None,
|
|
122
|
+
active_workdir_relpath: str | None = None,
|
|
123
|
+
owner: str | None = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
self.enabled = enabled
|
|
126
|
+
self.sessions_dir = sessions_dir
|
|
127
|
+
self.session_id = session_id
|
|
128
|
+
self.cwd = cwd
|
|
129
|
+
self.repo_root = repo_root
|
|
130
|
+
self.workspace_root = workspace_root
|
|
131
|
+
# Stamp every log with its creator so listings can scope per account.
|
|
132
|
+
self.owner = owner if owner is not None else local_session_owner()
|
|
133
|
+
self.focus_dir = focus_dir
|
|
134
|
+
self.git_root = git_root
|
|
135
|
+
self.workspace_kind = workspace_kind
|
|
136
|
+
self.binding_source = binding_source
|
|
137
|
+
self.binding_requested_path = binding_requested_path
|
|
138
|
+
self.binding_risk_level = binding_risk_level
|
|
139
|
+
self.binding_created_path = binding_created_path
|
|
140
|
+
self.runtime_kind = runtime_kind
|
|
141
|
+
self.active_workdir = active_workdir or cwd
|
|
142
|
+
self.active_workdir_relpath = active_workdir_relpath
|
|
143
|
+
self.path = sessions_dir / f"{session_id}.jsonl"
|
|
144
|
+
self._lock = threading.RLock()
|
|
145
|
+
self.artifact_persistence_enabled = (
|
|
146
|
+
enabled if artifact_persistence_enabled is None else bool(artifact_persistence_enabled)
|
|
147
|
+
)
|
|
148
|
+
self._events: list[dict[str, Any]] = []
|
|
149
|
+
self._web_research = SessionWebResearchTracker()
|
|
150
|
+
self._hydrate_existing_state()
|
|
151
|
+
|
|
152
|
+
self._fh = None
|
|
153
|
+
if self.enabled:
|
|
154
|
+
try:
|
|
155
|
+
self.sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
self._fh = self.path.open("a", encoding="utf-8")
|
|
157
|
+
except OSError:
|
|
158
|
+
self.enabled = False
|
|
159
|
+
self.artifact_persistence_enabled = False
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def session_artifact_root(self) -> Path:
|
|
163
|
+
return self.sessions_dir / sanitize_session_id(self.session_id)
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def session_artifact_layout(self) -> SessionArtifactLayout:
|
|
167
|
+
return SessionArtifactLayout(filesystem_root=self.session_artifact_root)
|
|
168
|
+
|
|
169
|
+
def runtime_artifact_path(self, *parts: str) -> Path:
|
|
170
|
+
return self.session_artifact_layout.artifact_fs_path(*parts)
|
|
171
|
+
|
|
172
|
+
def append(
|
|
173
|
+
self,
|
|
174
|
+
event_type: str,
|
|
175
|
+
payload: dict[str, Any],
|
|
176
|
+
*,
|
|
177
|
+
observation_payload: dict[str, Any] | None = None,
|
|
178
|
+
) -> None:
|
|
179
|
+
event = self._build_event(event_type=event_type, payload=payload)
|
|
180
|
+
with self._lock:
|
|
181
|
+
event["event_id"] = f"{self.session_id}:{len(self._events) + 1}"
|
|
182
|
+
self._events.append(event)
|
|
183
|
+
observed_web_research = self._web_research.observe_event(
|
|
184
|
+
event_type=event_type,
|
|
185
|
+
payload=observation_payload if observation_payload is not None else payload,
|
|
186
|
+
ts=str(event.get("ts") or "").strip() or None,
|
|
187
|
+
event_id=str(event.get("event_id") or "").strip() or None,
|
|
188
|
+
)
|
|
189
|
+
if self.enabled and self._fh:
|
|
190
|
+
self._fh.write(redact_log_text(json.dumps(event, ensure_ascii=True) + "\n"))
|
|
191
|
+
self._fh.flush()
|
|
192
|
+
if observed_web_research and self.artifact_persistence_enabled:
|
|
193
|
+
self._persist_web_research_artifact()
|
|
194
|
+
|
|
195
|
+
def _build_event(self, event_type: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
196
|
+
event = {
|
|
197
|
+
"type": event_type,
|
|
198
|
+
"ts": _now_ts(),
|
|
199
|
+
"session_id": self.session_id,
|
|
200
|
+
"cwd": self.cwd,
|
|
201
|
+
"repo_root": self.repo_root,
|
|
202
|
+
"pid": os.getpid(),
|
|
203
|
+
"payload": payload,
|
|
204
|
+
}
|
|
205
|
+
if self.workspace_root is not None:
|
|
206
|
+
event["workspace_root"] = self.workspace_root
|
|
207
|
+
if self.owner is not None:
|
|
208
|
+
event["owner"] = self.owner
|
|
209
|
+
if self.focus_dir is not None:
|
|
210
|
+
event["focus_dir"] = self.focus_dir
|
|
211
|
+
if self.git_root is not None:
|
|
212
|
+
event["git_root"] = self.git_root
|
|
213
|
+
if self.workspace_kind is not None:
|
|
214
|
+
event["workspace_kind"] = self.workspace_kind
|
|
215
|
+
if self.binding_source is not None:
|
|
216
|
+
event["binding_source"] = self.binding_source
|
|
217
|
+
if self.binding_requested_path is not None:
|
|
218
|
+
event["binding_requested_path"] = self.binding_requested_path
|
|
219
|
+
if self.binding_risk_level is not None:
|
|
220
|
+
event["binding_risk_level"] = self.binding_risk_level
|
|
221
|
+
if self.binding_created_path is not None:
|
|
222
|
+
event["binding_created_path"] = self.binding_created_path
|
|
223
|
+
if self.runtime_kind is not None:
|
|
224
|
+
event["runtime_kind"] = self.runtime_kind
|
|
225
|
+
if self.active_workdir is not None:
|
|
226
|
+
event["active_workdir"] = self.active_workdir
|
|
227
|
+
if self.active_workdir_relpath is not None:
|
|
228
|
+
event["active_workdir_relpath"] = self.active_workdir_relpath
|
|
229
|
+
return event
|
|
230
|
+
|
|
231
|
+
def update_active_workdir(self, *, cwd: str, active_workdir_relpath: str) -> None:
|
|
232
|
+
self.cwd = cwd
|
|
233
|
+
self.active_workdir = cwd
|
|
234
|
+
self.active_workdir_relpath = active_workdir_relpath
|
|
235
|
+
|
|
236
|
+
def append_artifact_jsonl(self, *parts: str, payload: dict[str, Any]) -> Path | None:
|
|
237
|
+
if not self.enabled:
|
|
238
|
+
return None
|
|
239
|
+
path = self.runtime_artifact_path(*parts)
|
|
240
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
242
|
+
fh.write(redact_log_text(json.dumps(payload, ensure_ascii=True) + "\n"))
|
|
243
|
+
return path
|
|
244
|
+
|
|
245
|
+
def events_snapshot(self) -> list[dict[str, Any]]:
|
|
246
|
+
with self._lock:
|
|
247
|
+
return json.loads(json.dumps(self._events, ensure_ascii=True))
|
|
248
|
+
|
|
249
|
+
def events_since(self, cursor: int) -> tuple[list[dict[str, Any]], int]:
|
|
250
|
+
"""Return events appended at or after ``cursor`` and the next cursor.
|
|
251
|
+
|
|
252
|
+
Events are append-only and their positional order is protected by the
|
|
253
|
+
store lock. A slice is therefore enough for incremental consumers; in
|
|
254
|
+
particular, this avoids the full JSON round-trip used by
|
|
255
|
+
:meth:`events_snapshot`.
|
|
256
|
+
"""
|
|
257
|
+
with self._lock:
|
|
258
|
+
start = max(0, int(cursor))
|
|
259
|
+
next_cursor = len(self._events)
|
|
260
|
+
return self._events[start:], next_cursor
|
|
261
|
+
|
|
262
|
+
def classify_web_fetch_url(self, raw_url: Any) -> str | None:
|
|
263
|
+
return self._web_research.classify_fetch_url(raw_url)
|
|
264
|
+
|
|
265
|
+
def resolve_web_fetch_url(self, raw_url: Any) -> tuple[str | None, str | None]:
|
|
266
|
+
return self._web_research.resolve_fetch_url(raw_url)
|
|
267
|
+
|
|
268
|
+
def fetchable_web_fetch_urls(self, *, limit: int = 10) -> list[str]:
|
|
269
|
+
return self._web_research.fetchable_urls(limit=limit)
|
|
270
|
+
|
|
271
|
+
def establish_search_mediated_web_fetch_url(
|
|
272
|
+
self,
|
|
273
|
+
*,
|
|
274
|
+
raw_url: Any,
|
|
275
|
+
query: str,
|
|
276
|
+
source_url: str | None = None,
|
|
277
|
+
) -> tuple[bool, str | None]:
|
|
278
|
+
with self._lock:
|
|
279
|
+
changed, normalized = self._web_research.establish_search_mediated_fetch_url(
|
|
280
|
+
raw_url=raw_url,
|
|
281
|
+
query=query,
|
|
282
|
+
source_url=source_url,
|
|
283
|
+
)
|
|
284
|
+
if changed and self.artifact_persistence_enabled:
|
|
285
|
+
self._persist_web_research_artifact()
|
|
286
|
+
return changed, normalized
|
|
287
|
+
|
|
288
|
+
def web_research_artifact_payload(self) -> dict[str, Any]:
|
|
289
|
+
return self._web_research.artifact_payload()
|
|
290
|
+
|
|
291
|
+
def web_research_metrics_payload(self) -> dict[str, int]:
|
|
292
|
+
return self._web_research.metrics_payload()
|
|
293
|
+
|
|
294
|
+
def has_web_research_activity(self) -> bool:
|
|
295
|
+
return self._web_research.has_activity()
|
|
296
|
+
|
|
297
|
+
def _persist_web_research_artifact(self) -> Path | None:
|
|
298
|
+
if not self.artifact_persistence_enabled or not self._web_research.has_activity():
|
|
299
|
+
return None
|
|
300
|
+
path = self.runtime_artifact_path("web_research_sources.json")
|
|
301
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
302
|
+
path.write_text(
|
|
303
|
+
redact_log_text(
|
|
304
|
+
json.dumps(
|
|
305
|
+
self._web_research.artifact_payload(),
|
|
306
|
+
indent=2,
|
|
307
|
+
sort_keys=True,
|
|
308
|
+
ensure_ascii=True,
|
|
309
|
+
)
|
|
310
|
+
+ "\n"
|
|
311
|
+
),
|
|
312
|
+
encoding="utf-8",
|
|
313
|
+
)
|
|
314
|
+
return path
|
|
315
|
+
|
|
316
|
+
def _hydrate_existing_state(self) -> None:
|
|
317
|
+
self._hydrate_from_existing_log()
|
|
318
|
+
self._hydrate_from_existing_web_research_artifact()
|
|
319
|
+
self._web_research.clear_pending()
|
|
320
|
+
|
|
321
|
+
def _hydrate_from_existing_log(self) -> bool:
|
|
322
|
+
try:
|
|
323
|
+
if not self.path.exists():
|
|
324
|
+
return False
|
|
325
|
+
events = list(read_session_events(self.path))
|
|
326
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
327
|
+
return False
|
|
328
|
+
if not events:
|
|
329
|
+
return False
|
|
330
|
+
self._events = json.loads(json.dumps(events, ensure_ascii=True))
|
|
331
|
+
for index, event in enumerate(self._events, start=1):
|
|
332
|
+
payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
|
|
333
|
+
self._web_research.observe_event(
|
|
334
|
+
event_type=str(event.get("type") or "").strip(),
|
|
335
|
+
payload=payload,
|
|
336
|
+
ts=str(event.get("ts") or "").strip() or None,
|
|
337
|
+
event_id=str(event.get("event_id") or "").strip() or f"{self.session_id}:{index}",
|
|
338
|
+
)
|
|
339
|
+
return True
|
|
340
|
+
|
|
341
|
+
def _hydrate_from_existing_web_research_artifact(self) -> bool:
|
|
342
|
+
artifact_path = self.runtime_artifact_path("web_research_sources.json")
|
|
343
|
+
try:
|
|
344
|
+
if not artifact_path.exists():
|
|
345
|
+
return False
|
|
346
|
+
payload = json.loads(artifact_path.read_text(encoding="utf-8"))
|
|
347
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
348
|
+
return False
|
|
349
|
+
return self._web_research.merge_from_artifact_payload(payload)
|
|
350
|
+
|
|
351
|
+
def close(self) -> None:
|
|
352
|
+
if self.artifact_persistence_enabled and self._web_research.has_activity():
|
|
353
|
+
self._persist_web_research_artifact()
|
|
354
|
+
if self._fh:
|
|
355
|
+
self._fh.close()
|
|
356
|
+
self._fh = None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _nonempty_str(value: Any) -> str | None:
|
|
360
|
+
if isinstance(value, str):
|
|
361
|
+
text = value.strip()
|
|
362
|
+
if text:
|
|
363
|
+
return text
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def canonical_workspace_path(raw: str | Path | None) -> Path | None:
|
|
368
|
+
"""Canonicalize a workspace/git-root path for identity comparison.
|
|
369
|
+
|
|
370
|
+
Uses ``Path.resolve()`` so trailing slashes, ``.``/``..`` segments, and the
|
|
371
|
+
OS's own case rules are absorbed (case-insensitive on Windows via the
|
|
372
|
+
resolved canonical casing, case-sensitive on POSIX) without any per-platform
|
|
373
|
+
special-casing. Returns ``None`` for empty/unresolvable input. This is
|
|
374
|
+
deterministic and provider/model-agnostic by construction.
|
|
375
|
+
"""
|
|
376
|
+
text = _nonempty_str(raw if isinstance(raw, str) else (str(raw) if raw is not None else None))
|
|
377
|
+
if text is None:
|
|
378
|
+
return None
|
|
379
|
+
try:
|
|
380
|
+
return Path(text).expanduser().resolve()
|
|
381
|
+
except (OSError, RuntimeError, ValueError):
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def session_belongs_to_workspace(
|
|
386
|
+
info: SessionInfo,
|
|
387
|
+
current_workspace_root: Path | None,
|
|
388
|
+
current_git_root: Path | None = None,
|
|
389
|
+
) -> bool:
|
|
390
|
+
"""Return True if ``info`` (a session log) belongs to the current workspace.
|
|
391
|
+
|
|
392
|
+
Primary identity is the log's recorded ``workspace_root``. When that is
|
|
393
|
+
absent (a legacy log predating workspace_root recording) we fall back to
|
|
394
|
+
``git_root``. When neither yields a deterministic match we return False:
|
|
395
|
+
such strays are hidden from the scoped picker but remain reachable by an
|
|
396
|
+
explicit ``/resume <id>``. ``current_workspace_root``/``current_git_root``
|
|
397
|
+
must already be canonicalized via :func:`canonical_workspace_path`.
|
|
398
|
+
"""
|
|
399
|
+
if info.workspace_root is not None:
|
|
400
|
+
if current_workspace_root is None:
|
|
401
|
+
return False
|
|
402
|
+
session_ws = canonical_workspace_path(info.workspace_root)
|
|
403
|
+
return session_ws is not None and session_ws == current_workspace_root
|
|
404
|
+
if info.git_root is not None and current_git_root is not None:
|
|
405
|
+
session_git = canonical_workspace_path(info.git_root)
|
|
406
|
+
return session_git is not None and session_git == current_git_root
|
|
407
|
+
return False
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def session_belongs_to_owner(info: SessionInfo, current_owner: str | None) -> bool:
|
|
411
|
+
"""Return True if ``info`` (a session log) may be listed for this account.
|
|
412
|
+
|
|
413
|
+
A log stamped with an owner is only listed when the current identity
|
|
414
|
+
matches either the creator stamp (``owner``, from the log's first events)
|
|
415
|
+
or the newest event's stamp (``last_owner``) — compared case-insensitively,
|
|
416
|
+
since Windows usernames and DNS hostnames are not case-significant. The
|
|
417
|
+
``last_owner`` leg is the self-heal path: when a user's identity drifts
|
|
418
|
+
(hostname rename, WSL vs native), one explicit ``/resume <id>`` appends
|
|
419
|
+
fresh events under the new identity and the session lists again. A foreign
|
|
420
|
+
log stays hidden either way — both its stamps are foreign.
|
|
421
|
+
|
|
422
|
+
Legacy logs with no recorded owner on either end stay visible so an
|
|
423
|
+
upgrade never empties a user's own list (workspace scoping still applies
|
|
424
|
+
to them). When the log records an owner but the local identity cannot be
|
|
425
|
+
established, the log is hidden: a foreign-stamped conversation must never
|
|
426
|
+
surface on an unidentifiable account. Hidden logs remain reachable by an
|
|
427
|
+
explicit ``/resume <id>`` on the machine that holds them.
|
|
428
|
+
"""
|
|
429
|
+
recorded_first = _nonempty_str(info.owner)
|
|
430
|
+
recorded_last = _nonempty_str(info.last_owner)
|
|
431
|
+
if recorded_first is None and recorded_last is None:
|
|
432
|
+
return True
|
|
433
|
+
current = _nonempty_str(current_owner)
|
|
434
|
+
if current is None:
|
|
435
|
+
return False
|
|
436
|
+
current_folded = current.casefold()
|
|
437
|
+
if recorded_first is not None and recorded_first.casefold() == current_folded:
|
|
438
|
+
return True
|
|
439
|
+
return recorded_last is not None and recorded_last.casefold() == current_folded
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def filter_sessions_to_local_owner(infos: list[SessionInfo]) -> list[SessionInfo]:
|
|
443
|
+
"""Drop sessions stamped by a different account than the local one."""
|
|
444
|
+
current_owner = local_session_owner()
|
|
445
|
+
return [info for info in infos if session_belongs_to_owner(info, current_owner)]
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def read_session_first_event_workspace(path: Path) -> tuple[str | None, str | None]:
|
|
449
|
+
"""Read the session log's recorded (workspace_root, git_root)."""
|
|
450
|
+
workspace_root, git_root, _owner = read_session_first_event_scope(path)
|
|
451
|
+
return workspace_root, git_root
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def read_session_first_event_scope(path: Path) -> tuple[str | None, str | None, str | None]:
|
|
455
|
+
"""Read the log's recorded (workspace_root, git_root, owner).
|
|
456
|
+
|
|
457
|
+
Every event carries these at top level (see :meth:`SessionStore._build_event`)
|
|
458
|
+
and the ``session_start`` payload also records the workspace fields; we scan
|
|
459
|
+
the first few events and return the first non-empty values found. Bounded so
|
|
460
|
+
we never read a whole (possibly large) log. Any read/parse error yields
|
|
461
|
+
``(None, None, None)``.
|
|
462
|
+
"""
|
|
463
|
+
workspace_root: str | None = None
|
|
464
|
+
git_root: str | None = None
|
|
465
|
+
owner: str | None = None
|
|
466
|
+
events = read_session_events(path)
|
|
467
|
+
try:
|
|
468
|
+
checked = 0
|
|
469
|
+
for event in events:
|
|
470
|
+
if not isinstance(event, dict):
|
|
471
|
+
continue
|
|
472
|
+
checked += 1
|
|
473
|
+
if workspace_root is None:
|
|
474
|
+
workspace_root = _nonempty_str(event.get("workspace_root"))
|
|
475
|
+
if git_root is None:
|
|
476
|
+
git_root = _nonempty_str(event.get("git_root"))
|
|
477
|
+
if owner is None:
|
|
478
|
+
owner = _nonempty_str(event.get("owner"))
|
|
479
|
+
payload = event.get("payload")
|
|
480
|
+
if isinstance(payload, dict):
|
|
481
|
+
if workspace_root is None:
|
|
482
|
+
workspace_root = _nonempty_str(payload.get("workspace_root"))
|
|
483
|
+
if git_root is None:
|
|
484
|
+
git_root = _nonempty_str(payload.get("git_root"))
|
|
485
|
+
if owner is None:
|
|
486
|
+
owner = _nonempty_str(payload.get("owner"))
|
|
487
|
+
# Keep scanning (within the bound) until the owner stamp is found
|
|
488
|
+
# too: a pre-upgrade log resumed post-upgrade carries its owner
|
|
489
|
+
# only on later events, and classifying it as legacy would leave
|
|
490
|
+
# it visible to every account.
|
|
491
|
+
if workspace_root is not None and git_root is not None and owner is not None:
|
|
492
|
+
break
|
|
493
|
+
if checked >= 5:
|
|
494
|
+
break
|
|
495
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
496
|
+
return None, None, None
|
|
497
|
+
finally:
|
|
498
|
+
close = getattr(events, "close", None)
|
|
499
|
+
if callable(close):
|
|
500
|
+
close()
|
|
501
|
+
return workspace_root, git_root, owner
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def read_session_last_event_ts(path: Path) -> str | None:
|
|
505
|
+
"""Return the UTC ISO ``ts`` of the log's most recent event."""
|
|
506
|
+
ts, _owner = read_session_last_event_fields(path)
|
|
507
|
+
return ts
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def read_session_last_event_fields(path: Path) -> tuple[str | None, str | None]:
|
|
511
|
+
"""Return the (``ts``, ``owner``) recorded at the tail of the log.
|
|
512
|
+
|
|
513
|
+
Tail-scans the last chunk of the file (events are append-only and flushed,
|
|
514
|
+
so the final parseable line is the newest event) rather than parsing the
|
|
515
|
+
whole log. ``ts`` comes from the newest event that carries one; ``owner``
|
|
516
|
+
comes from the newest parseable event — the account that most recently
|
|
517
|
+
used the session. Returns ``(None, None)`` on empty/corrupt/unreadable
|
|
518
|
+
logs so callers fall back to file mtime / creator-stamp semantics.
|
|
519
|
+
"""
|
|
520
|
+
try:
|
|
521
|
+
with path.open("rb") as fh:
|
|
522
|
+
fh.seek(0, os.SEEK_END)
|
|
523
|
+
file_size = fh.tell()
|
|
524
|
+
if file_size <= 0:
|
|
525
|
+
return None, None
|
|
526
|
+
read_size = min(file_size, 16384)
|
|
527
|
+
fh.seek(file_size - read_size)
|
|
528
|
+
data = fh.read()
|
|
529
|
+
except OSError:
|
|
530
|
+
return None, None
|
|
531
|
+
owner: str | None = None
|
|
532
|
+
seen_event = False
|
|
533
|
+
for raw_line in reversed(data.split(b"\n")):
|
|
534
|
+
stripped = raw_line.strip()
|
|
535
|
+
if not stripped:
|
|
536
|
+
continue
|
|
537
|
+
try:
|
|
538
|
+
obj = json.loads(stripped)
|
|
539
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
540
|
+
continue
|
|
541
|
+
if isinstance(obj, dict):
|
|
542
|
+
if not seen_event:
|
|
543
|
+
owner = _nonempty_str(obj.get("owner"))
|
|
544
|
+
seen_event = True
|
|
545
|
+
ts = _nonempty_str(obj.get("ts"))
|
|
546
|
+
if ts is not None:
|
|
547
|
+
return ts, owner
|
|
548
|
+
return None, owner
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _epoch_from_iso(raw: str | None) -> float | None:
|
|
552
|
+
text = _nonempty_str(raw)
|
|
553
|
+
if text is None:
|
|
554
|
+
return None
|
|
555
|
+
if text.endswith("Z"):
|
|
556
|
+
text = text[:-1] + "+00:00"
|
|
557
|
+
try:
|
|
558
|
+
parsed = datetime.fromisoformat(text)
|
|
559
|
+
except ValueError:
|
|
560
|
+
return None
|
|
561
|
+
try:
|
|
562
|
+
return parsed.timestamp()
|
|
563
|
+
except (OSError, OverflowError, ValueError):
|
|
564
|
+
return None
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _session_recency_sort_key(info: SessionInfo) -> float:
|
|
568
|
+
# Prefer the log's own last-event timestamp (immune to copy/extract mtime
|
|
569
|
+
# resets); fall back to filesystem mtime. Both are absolute epoch seconds,
|
|
570
|
+
# so they order correctly even when mixed.
|
|
571
|
+
epoch = _epoch_from_iso(info.last_event_ts)
|
|
572
|
+
if epoch is not None:
|
|
573
|
+
return epoch
|
|
574
|
+
try:
|
|
575
|
+
return float(info.mtime)
|
|
576
|
+
except (TypeError, ValueError):
|
|
577
|
+
return 0.0
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def list_sessions(sessions_dir: Path) -> list[SessionInfo]:
|
|
581
|
+
if not sessions_dir.exists():
|
|
582
|
+
return []
|
|
583
|
+
out: list[SessionInfo] = []
|
|
584
|
+
for p in sessions_dir.glob("*.jsonl"):
|
|
585
|
+
try:
|
|
586
|
+
st = p.stat()
|
|
587
|
+
except OSError:
|
|
588
|
+
continue
|
|
589
|
+
try:
|
|
590
|
+
workspace_root, git_root, owner = read_session_first_event_scope(p)
|
|
591
|
+
except OSError:
|
|
592
|
+
workspace_root, git_root, owner = None, None, None
|
|
593
|
+
try:
|
|
594
|
+
last_event_ts, last_owner = read_session_last_event_fields(p)
|
|
595
|
+
except OSError:
|
|
596
|
+
last_event_ts, last_owner = None, None
|
|
597
|
+
out.append(
|
|
598
|
+
SessionInfo(
|
|
599
|
+
session_id=p.stem,
|
|
600
|
+
path=p,
|
|
601
|
+
mtime=st.st_mtime,
|
|
602
|
+
workspace_root=workspace_root,
|
|
603
|
+
git_root=git_root,
|
|
604
|
+
last_event_ts=last_event_ts,
|
|
605
|
+
owner=owner,
|
|
606
|
+
last_owner=last_owner,
|
|
607
|
+
)
|
|
608
|
+
)
|
|
609
|
+
out.sort(key=_session_recency_sort_key, reverse=True)
|
|
610
|
+
return out
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def read_session_events(path: Path) -> Iterable[dict[str, Any]]:
|
|
614
|
+
with path.open(encoding="utf-8") as fh:
|
|
615
|
+
for line in fh:
|
|
616
|
+
line = line.strip()
|
|
617
|
+
if not line:
|
|
618
|
+
continue
|
|
619
|
+
try:
|
|
620
|
+
obj = json.loads(line)
|
|
621
|
+
except json.JSONDecodeError:
|
|
622
|
+
continue
|
|
623
|
+
if isinstance(obj, dict):
|
|
624
|
+
yield obj
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Skills
|
|
2
|
+
|
|
3
|
+
This package implements Alysis Code skills: reusable instruction bundles rooted
|
|
4
|
+
at a `SKILL.md` file.
|
|
5
|
+
|
|
6
|
+
## Contents
|
|
7
|
+
|
|
8
|
+
- `discovery.py` finds skills in approved project and user roots.
|
|
9
|
+
- `loader.py`, `models.py`, and `validation.py` parse and validate bundles.
|
|
10
|
+
- `scaffold.py`, `install.py`, `state.py`, and `transactions.py` support the
|
|
11
|
+
local skill lifecycle.
|
|
12
|
+
- `prompting.py` prepares concise skill context for sessions.
|
|
13
|
+
- `matching.py` and `evals.py` support deterministic matching and evaluation.
|
|
14
|
+
- `conventions.py` handles repo convention files, which are separate from
|
|
15
|
+
skills.
|
|
16
|
+
|
|
17
|
+
## Scope
|
|
18
|
+
|
|
19
|
+
Skills are instruction text, not host policy or executable capability. They
|
|
20
|
+
should be advertised compactly and read on demand rather than copied wholesale
|
|
21
|
+
into every prompt.
|
|
22
|
+
|
|
23
|
+
Repo convention files such as `AGENTS.md`, `CLAUDE.md`, and `CONVENTIONS.md`
|
|
24
|
+
must remain separate from the skill registry.
|
|
25
|
+
|
|
26
|
+
## Development
|
|
27
|
+
|
|
28
|
+
Changes to discovery, validation, or prompt rendering should include regression
|
|
29
|
+
coverage for both project-local and user-global skill roots.
|
|
30
|
+
|
|
31
|
+
## See Also
|
|
32
|
+
|
|
33
|
+
- [Skills](../../../docs/skills.md)
|
|
34
|
+
- [Skills lifecycle](../../../docs/skills_lifecycle.md)
|