iCode-TUI 0.29.1__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.
- chrys/__init__.py +16 -0
- chrys/app/__init__.py +3 -0
- chrys/app/acp/__init__.py +3 -0
- chrys/app/acp/bridge.py +664 -0
- chrys/app/acp/content.py +132 -0
- chrys/app/acp/doc/frontend-api.md +425 -0
- chrys/app/acp/history.py +585 -0
- chrys/app/acp/server.py +1796 -0
- chrys/app/acp/session_manager.py +1815 -0
- chrys/app/acp/tool_status.py +45 -0
- chrys/app/cli/__init__.py +3 -0
- chrys/app/cli/acp.py +178 -0
- chrys/app/cli/app.py +165 -0
- chrys/app/cli/approvals.py +101 -0
- chrys/app/cli/headless.py +223 -0
- chrys/app/cli/launch_cwd.py +42 -0
- chrys/app/cli/profiles.py +298 -0
- chrys/app/cli/progress.py +857 -0
- chrys/app/cli/run.py +425 -0
- chrys/app/cli/serve.py +1593 -0
- chrys/app/cli/tool_summary.py +225 -0
- chrys/app/cli/trajectory.py +201 -0
- chrys/app/cli/workflow.py +601 -0
- chrys/app/features/__init__.py +3 -0
- chrys/app/features/buddy/__init__.py +3 -0
- chrys/app/features/buddy/actions.py +68 -0
- chrys/app/features/buddy/animation.py +76 -0
- chrys/app/features/buddy/commands.py +196 -0
- chrys/app/features/buddy/docs/design.md +187 -0
- chrys/app/features/buddy/hatchery.py +80 -0
- chrys/app/features/buddy/lifecycle.py +41 -0
- chrys/app/features/buddy/model.py +265 -0
- chrys/app/features/buddy/pixel_renderer.py +113 -0
- chrys/app/features/buddy/pixel_sprites.py +279 -0
- chrys/app/features/buddy/portrait.py +76 -0
- chrys/app/features/buddy/progression.py +58 -0
- chrys/app/features/buddy/replies.py +191 -0
- chrys/app/features/buddy/sprites/alien.toml +70 -0
- chrys/app/features/buddy/sprites/axolotl.toml +71 -0
- chrys/app/features/buddy/sprites/bat.toml +69 -0
- chrys/app/features/buddy/sprites/bee.toml +69 -0
- chrys/app/features/buddy/sprites/cactus.toml +73 -0
- chrys/app/features/buddy/sprites/capybara.toml +70 -0
- chrys/app/features/buddy/sprites/cat.toml +69 -0
- chrys/app/features/buddy/sprites/crab.toml +70 -0
- chrys/app/features/buddy/sprites/dragon.toml +71 -0
- chrys/app/features/buddy/sprites/duck.toml +69 -0
- chrys/app/features/buddy/sprites/fox.toml +69 -0
- chrys/app/features/buddy/sprites/frog.toml +70 -0
- chrys/app/features/buddy/sprites/ghost.toml +70 -0
- chrys/app/features/buddy/sprites/goose.toml +69 -0
- chrys/app/features/buddy/sprites/jelly.toml +70 -0
- chrys/app/features/buddy/sprites/llama.toml +70 -0
- chrys/app/features/buddy/sprites/mushroom.toml +69 -0
- chrys/app/features/buddy/sprites/octopus.toml +70 -0
- chrys/app/features/buddy/sprites/owl.toml +70 -0
- chrys/app/features/buddy/sprites/panda.toml +71 -0
- chrys/app/features/buddy/sprites/penguin.toml +69 -0
- chrys/app/features/buddy/sprites/rabbit.toml +70 -0
- chrys/app/features/buddy/sprites/robot.toml +72 -0
- chrys/app/features/buddy/sprites/shark.toml +70 -0
- chrys/app/features/buddy/sprites/snail.toml +69 -0
- chrys/app/features/buddy/sprites/snake.toml +69 -0
- chrys/app/features/buddy/sprites/tabby.toml +69 -0
- chrys/app/features/buddy/sprites/turtle.toml +69 -0
- chrys/app/features/buddy/store.py +143 -0
- chrys/app/features/session_title/__init__.py +7 -0
- chrys/app/features/session_title/updater.py +582 -0
- chrys/app/installer.py +651 -0
- chrys/app/parsing.py +17 -0
- chrys/app/tui/__init__.py +3 -0
- chrys/app/tui/app.py +1287 -0
- chrys/app/tui/behaviors/__init__.py +3 -0
- chrys/app/tui/behaviors/click_outside_dismiss.py +90 -0
- chrys/app/tui/behaviors/insert_clipboard.py +73 -0
- chrys/app/tui/behaviors/right_click_copy.py +146 -0
- chrys/app/tui/binding_display.py +139 -0
- chrys/app/tui/buddy_messages.py +17 -0
- chrys/app/tui/buddy_reply.py +86 -0
- chrys/app/tui/chrys.tcss +549 -0
- chrys/app/tui/clipboard.py +109 -0
- chrys/app/tui/copy_messages.py +10 -0
- chrys/app/tui/i18n.py +230 -0
- chrys/app/tui/language.py +58 -0
- chrys/app/tui/notifications/__init__.py +12 -0
- chrys/app/tui/notifications/drivers.py +180 -0
- chrys/app/tui/notifications/service.py +126 -0
- chrys/app/tui/notifications/settings.py +141 -0
- chrys/app/tui/screens/__init__.py +3 -0
- chrys/app/tui/screens/agents/__init__.py +8 -0
- chrys/app/tui/screens/agents/agent_draft_store.py +937 -0
- chrys/app/tui/screens/agents/config.py +2142 -0
- chrys/app/tui/screens/agents/config.tcss +169 -0
- chrys/app/tui/screens/agents/panels/__init__.py +3 -0
- chrys/app/tui/screens/agents/panels/acp.py +1176 -0
- chrys/app/tui/screens/agents/panels/basic.py +497 -0
- chrys/app/tui/screens/agents/panels/compaction.py +231 -0
- chrys/app/tui/screens/agents/panels/config_card.py +218 -0
- chrys/app/tui/screens/agents/panels/instructions.py +91 -0
- chrys/app/tui/screens/agents/panels/markdown_report.py +76 -0
- chrys/app/tui/screens/agents/panels/mcp.py +2262 -0
- chrys/app/tui/screens/agents/panels/memory.py +684 -0
- chrys/app/tui/screens/agents/panels/path_entry.py +227 -0
- chrys/app/tui/screens/agents/panels/skills.py +806 -0
- chrys/app/tui/screens/agents/panels/subagents.py +652 -0
- chrys/app/tui/screens/agents/panels/tools.py +205 -0
- chrys/app/tui/screens/agents/picker.py +103 -0
- chrys/app/tui/screens/agents/skill_paths.py +38 -0
- chrys/app/tui/screens/agents/validation_messages.py +452 -0
- chrys/app/tui/screens/dialogs/__init__.py +35 -0
- chrys/app/tui/screens/dialogs/agent_load.py +834 -0
- chrys/app/tui/screens/dialogs/agent_load.tcss +78 -0
- chrys/app/tui/screens/dialogs/app_mode.py +55 -0
- chrys/app/tui/screens/dialogs/approval/__init__.py +15 -0
- chrys/app/tui/screens/dialogs/approval/approval.tcss +311 -0
- chrys/app/tui/screens/dialogs/approval/bodies/__init__.py +3 -0
- chrys/app/tui/screens/dialogs/approval/bodies/file_edit.py +210 -0
- chrys/app/tui/screens/dialogs/approval/bodies/sub_agent.py +82 -0
- chrys/app/tui/screens/dialogs/approval/body.py +110 -0
- chrys/app/tui/screens/dialogs/approval/dialog.py +565 -0
- chrys/app/tui/screens/dialogs/approval/mode.py +105 -0
- chrys/app/tui/screens/dialogs/ask_user.py +148 -0
- chrys/app/tui/screens/dialogs/ask_user.tcss +62 -0
- chrys/app/tui/screens/dialogs/base.py +84 -0
- chrys/app/tui/screens/dialogs/confirm.py +184 -0
- chrys/app/tui/screens/dialogs/confirm.tcss +46 -0
- chrys/app/tui/screens/dialogs/connection_test.py +183 -0
- chrys/app/tui/screens/dialogs/connection_test.tcss +92 -0
- chrys/app/tui/screens/dialogs/diagram.py +102 -0
- chrys/app/tui/screens/dialogs/diagram.tcss +24 -0
- chrys/app/tui/screens/dialogs/editor.py +378 -0
- chrys/app/tui/screens/dialogs/editor.tcss +132 -0
- chrys/app/tui/screens/dialogs/file_picker.py +582 -0
- chrys/app/tui/screens/dialogs/file_picker.tcss +78 -0
- chrys/app/tui/screens/dialogs/fork_session.py +219 -0
- chrys/app/tui/screens/dialogs/fork_session.tcss +75 -0
- chrys/app/tui/screens/dialogs/image_compression.py +102 -0
- chrys/app/tui/screens/dialogs/image_compression.tcss +28 -0
- chrys/app/tui/screens/dialogs/man_page.py +172 -0
- chrys/app/tui/screens/dialogs/man_page.tcss +39 -0
- chrys/app/tui/screens/dialogs/new_folder.py +277 -0
- chrys/app/tui/screens/dialogs/new_folder.tcss +51 -0
- chrys/app/tui/screens/dialogs/notice.tcss +14 -0
- chrys/app/tui/screens/dialogs/prompt_cache.py +144 -0
- chrys/app/tui/screens/dialogs/prompt_cache.tcss +38 -0
- chrys/app/tui/screens/dialogs/runtime_details.py +371 -0
- chrys/app/tui/screens/dialogs/runtime_details.tcss +49 -0
- chrys/app/tui/screens/dialogs/session_title.py +110 -0
- chrys/app/tui/screens/dialogs/session_title.tcss +43 -0
- chrys/app/tui/screens/dialogs/tool_view.py +207 -0
- chrys/app/tui/screens/dialogs/tool_view.tcss +43 -0
- chrys/app/tui/screens/dialogs/vision_unsupported.py +122 -0
- chrys/app/tui/screens/dialogs/vision_unsupported.tcss +35 -0
- chrys/app/tui/screens/dialogs/workflow_confirm.py +91 -0
- chrys/app/tui/screens/dialogs/workflow_confirm.tcss +17 -0
- chrys/app/tui/screens/dialogs/workflow_input.py +258 -0
- chrys/app/tui/screens/dialogs/workflow_input.tcss +73 -0
- chrys/app/tui/screens/dialogs/workflow_node.py +462 -0
- chrys/app/tui/screens/dialogs/workflow_node.tcss +41 -0
- chrys/app/tui/screens/dialogs/workflow_picker.py +46 -0
- chrys/app/tui/screens/dialogs/workflow_result.py +136 -0
- chrys/app/tui/screens/dialogs/workflow_result.tcss +14 -0
- chrys/app/tui/screens/diff/__init__.py +22 -0
- chrys/app/tui/screens/diff/rollback_modal.py +1381 -0
- chrys/app/tui/screens/diff/rollback_modal.tcss +248 -0
- chrys/app/tui/screens/diff/screen.py +1532 -0
- chrys/app/tui/screens/diff/screen.tcss +104 -0
- chrys/app/tui/screens/guides/__init__.py +7 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/agents.md +178 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/approval.md +117 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/chat-options.md +276 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/compaction.md +42 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/memory.md +48 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/models.md +158 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/settings.md +210 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/themes.md +61 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/tools.md +60 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/web-tools.md +286 -0
- chrys/app/tui/screens/guides/_docs/en/guides/daily-use/sessions.md +77 -0
- chrys/app/tui/screens/guides/_docs/en/guides/daily-use/workspaces.md +51 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/external-acp-agents.md +126 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/hooks.md +167 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/mcp.md +95 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/skills.md +175 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/icode-acp.md +61 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/icode-run.md +190 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/icode-serve.md +239 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/workflows.md +664 -0
- chrys/app/tui/screens/guides/_docs/en/reference/agent-profile.md +548 -0
- chrys/app/tui/screens/guides/_docs/en/reference/hooks.md +602 -0
- chrys/app/tui/screens/guides/_docs/en/reference/opentelemetry.md +177 -0
- chrys/app/tui/screens/guides/_docs/en/reference/settings.md +245 -0
- chrys/app/tui/screens/guides/_docs/en/reference/themes.md +149 -0
- chrys/app/tui/screens/guides/_docs/en/reference/tool-kinds-and-names.md +58 -0
- chrys/app/tui/screens/guides/_docs/en/reference/tui-slash-commands.md +76 -0
- chrys/app/tui/screens/guides/_docs/en/reference/workflows.md +1006 -0
- chrys/app/tui/screens/guides/_docs/en/start/getting-started.md +170 -0
- chrys/app/tui/screens/guides/_docs/en/start/what-is-icode.md +34 -0
- chrys/app/tui/screens/guides/_docs/index.yaml +100 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/agents.md +178 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/approval.md +117 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/chat-options.md +276 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/compaction.md +42 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/memory.md +48 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/models.md +158 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/settings.md +210 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/themes.md +61 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/tools.md +60 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/web-tools.md +286 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/sessions.md +77 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/workspaces.md +51 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/external-acp-agents.md +126 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/hooks.md +167 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/mcp.md +95 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/skills.md +175 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-acp.md +61 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-run.md +190 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-serve.md +239 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/workflows.md +664 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/agent-profile.md +548 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/hooks.md +602 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/opentelemetry.md +177 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/settings.md +245 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/themes.md +149 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tool-kinds-and-names.md +58 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tui-slash-commands.md +76 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/workflows.md +1005 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/start/getting-started.md +170 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/start/what-is-icode.md +34 -0
- chrys/app/tui/screens/guides/index.py +215 -0
- chrys/app/tui/screens/guides/reader.py +173 -0
- chrys/app/tui/screens/guides/screen.py +442 -0
- chrys/app/tui/screens/guides/screen.tcss +52 -0
- chrys/app/tui/screens/logs/__init__.py +7 -0
- chrys/app/tui/screens/logs/screen.py +504 -0
- chrys/app/tui/screens/logs/screen.tcss +45 -0
- chrys/app/tui/screens/main/__init__.py +7 -0
- chrys/app/tui/screens/main/action_availability.py +30 -0
- chrys/app/tui/screens/main/buddy_command.py +143 -0
- chrys/app/tui/screens/main/commands.py +1073 -0
- chrys/app/tui/screens/main/config_actions.py +464 -0
- chrys/app/tui/screens/main/copy_actions.py +117 -0
- chrys/app/tui/screens/main/dialog_controllers.py +826 -0
- chrys/app/tui/screens/main/dialog_gateway.py +166 -0
- chrys/app/tui/screens/main/diff_controller.py +478 -0
- chrys/app/tui/screens/main/engine_read_model.py +184 -0
- chrys/app/tui/screens/main/event_handlers.py +2014 -0
- chrys/app/tui/screens/main/input_flow.py +241 -0
- chrys/app/tui/screens/main/live_diff.py +244 -0
- chrys/app/tui/screens/main/main_screen_presenter.py +295 -0
- chrys/app/tui/screens/main/model_indicator.py +217 -0
- chrys/app/tui/screens/main/navigation.py +343 -0
- chrys/app/tui/screens/main/ports.py +571 -0
- chrys/app/tui/screens/main/recent_dirs.py +123 -0
- chrys/app/tui/screens/main/rollback_controller.py +586 -0
- chrys/app/tui/screens/main/runtime_info.py +71 -0
- chrys/app/tui/screens/main/screen.py +2265 -0
- chrys/app/tui/screens/main/screen.tcss +21 -0
- chrys/app/tui/screens/main/session_handlers.py +1127 -0
- chrys/app/tui/screens/main/session_title.py +239 -0
- chrys/app/tui/screens/main/settings_coordinator.py +365 -0
- chrys/app/tui/screens/main/settings_persistence.py +184 -0
- chrys/app/tui/screens/main/shell_mode.py +112 -0
- chrys/app/tui/screens/main/state.py +289 -0
- chrys/app/tui/screens/main/submit_state.py +11 -0
- chrys/app/tui/screens/main/subscriptions.py +177 -0
- chrys/app/tui/screens/main/suggestions.py +982 -0
- chrys/app/tui/screens/main/tool_action_bridge.py +75 -0
- chrys/app/tui/screens/main/view_adapter.py +1567 -0
- chrys/app/tui/screens/main/workflow_browser.py +747 -0
- chrys/app/tui/screens/main/workflow_content.py +248 -0
- chrys/app/tui/screens/main/workflow_controller.py +240 -0
- chrys/app/tui/screens/main/workflow_feedback.py +137 -0
- chrys/app/tui/screens/main/workflow_flow.py +49 -0
- chrys/app/tui/screens/main/workflow_run_control.py +456 -0
- chrys/app/tui/screens/main/workflow_session_view.py +316 -0
- chrys/app/tui/screens/main/workspace_actions.py +272 -0
- chrys/app/tui/screens/main/workspace_branch.py +194 -0
- chrys/app/tui/screens/menus/__init__.py +8 -0
- chrys/app/tui/screens/menus/languages.py +114 -0
- chrys/app/tui/screens/models/__init__.py +7 -0
- chrys/app/tui/screens/models/picker.py +147 -0
- chrys/app/tui/screens/models/screen.py +2043 -0
- chrys/app/tui/screens/models/screen.tcss +255 -0
- chrys/app/tui/screens/sessions/__init__.py +8 -0
- chrys/app/tui/screens/sessions/presenter.py +586 -0
- chrys/app/tui/screens/sessions/screen.py +779 -0
- chrys/app/tui/screens/sessions/screen.tcss +127 -0
- chrys/app/tui/screens/settings/__init__.py +29 -0
- chrys/app/tui/screens/settings/danger.py +26 -0
- chrys/app/tui/screens/settings/dialog.py +281 -0
- chrys/app/tui/screens/settings/layout.py +519 -0
- chrys/app/tui/screens/settings/migrate_dialog.py +253 -0
- chrys/app/tui/screens/settings/panes/__init__.py +3 -0
- chrys/app/tui/screens/settings/panes/notifications.py +248 -0
- chrys/app/tui/screens/settings/panes/sessions.py +148 -0
- chrys/app/tui/screens/settings/ports.py +55 -0
- chrys/app/tui/screens/settings/provenance.py +102 -0
- chrys/app/tui/screens/settings/rows.py +546 -0
- chrys/app/tui/screens/settings/settings.tcss +406 -0
- chrys/app/tui/screens/themes/__init__.py +8 -0
- chrys/app/tui/screens/themes/dialogs.py +330 -0
- chrys/app/tui/screens/themes/editor.py +632 -0
- chrys/app/tui/screens/themes/editor.tcss +268 -0
- chrys/app/tui/screens/themes/messages.py +95 -0
- chrys/app/tui/screens/themes/palette.py +694 -0
- chrys/app/tui/screens/themes/panel.py +113 -0
- chrys/app/tui/screens/themes/picker.py +203 -0
- chrys/app/tui/screens/themes/save.py +191 -0
- chrys/app/tui/support/__init__.py +3 -0
- chrys/app/tui/support/file_manager.py +64 -0
- chrys/app/tui/support/gc_freeze.py +1114 -0
- chrys/app/tui/support/path_shortcuts.py +50 -0
- chrys/app/tui/support/prompt_history.py +298 -0
- chrys/app/tui/support/workspace_mru.py +476 -0
- chrys/app/tui/terminal/__init__.py +6 -0
- chrys/app/tui/terminal/_pty_backend.py +114 -0
- chrys/app/tui/terminal/emulator/__init__.py +24 -0
- chrys/app/tui/terminal/emulator/buffer.py +456 -0
- chrys/app/tui/terminal/emulator/charsets.py +113 -0
- chrys/app/tui/terminal/emulator/core.py +963 -0
- chrys/app/tui/terminal/emulator/input.py +342 -0
- chrys/app/tui/terminal/emulator/parser.py +227 -0
- chrys/app/tui/terminal/emulator/pen.py +158 -0
- chrys/app/tui/terminal/launcher.py +455 -0
- chrys/app/tui/terminal/leaked_reports.py +44 -0
- chrys/app/tui/terminal/panel.py +323 -0
- chrys/app/tui/terminal/pty_process.py +405 -0
- chrys/app/tui/terminal/rendering.py +151 -0
- chrys/app/tui/terminal/shell.py +273 -0
- chrys/app/tui/terminal/shell_integration.py +199 -0
- chrys/app/tui/terminal/title.py +224 -0
- chrys/app/tui/terminal/widget.py +654 -0
- chrys/app/tui/theme.py +422 -0
- chrys/app/tui/theme_loader.py +201 -0
- chrys/app/tui/theme_stylesheet.py +49 -0
- chrys/app/tui/themes/__init__.py +3 -0
- chrys/app/tui/themes/document.py +120 -0
- chrys/app/tui/themes/preview.py +170 -0
- chrys/app/tui/themes/store.py +176 -0
- chrys/app/tui/toasts.py +69 -0
- chrys/app/tui/util/__init__.py +3 -0
- chrys/app/tui/util/context_pressure.py +146 -0
- chrys/app/tui/util/debug_log.py +292 -0
- chrys/app/tui/util/diff_entries.py +64 -0
- chrys/app/tui/util/formatting.py +53 -0
- chrys/app/tui/util/git_branch.py +326 -0
- chrys/app/tui/util/invocation_progress.py +57 -0
- chrys/app/tui/util/logo.py +32 -0
- chrys/app/tui/util/message_gate.py +35 -0
- chrys/app/tui/util/mutation_sources.py +14 -0
- chrys/app/tui/util/picker_text.py +27 -0
- chrys/app/tui/util/removal.py +47 -0
- chrys/app/tui/util/rich_style.py +36 -0
- chrys/app/tui/util/source_text.py +12 -0
- chrys/app/tui/util/static_update.py +88 -0
- chrys/app/tui/util/todo_format.py +51 -0
- chrys/app/tui/util/visibility.py +214 -0
- chrys/app/tui/widgets/__init__.py +60 -0
- chrys/app/tui/widgets/ask_user_controls.py +652 -0
- chrys/app/tui/widgets/ask_user_markdown.py +17 -0
- chrys/app/tui/widgets/ask_user_prompt.py +606 -0
- chrys/app/tui/widgets/buttons.py +52 -0
- chrys/app/tui/widgets/chat/__init__.py +3 -0
- chrys/app/tui/widgets/chat/agent_transcript_surface.py +818 -0
- chrys/app/tui/widgets/chat/chrome.py +296 -0
- chrys/app/tui/widgets/chat/compaction_card.py +353 -0
- chrys/app/tui/widgets/chat/context_fold.py +77 -0
- chrys/app/tui/widgets/chat/file_snapshot.py +91 -0
- chrys/app/tui/widgets/chat/image_preview.py +214 -0
- chrys/app/tui/widgets/chat/live_renderer.py +383 -0
- chrys/app/tui/widgets/chat/messages.py +1039 -0
- chrys/app/tui/widgets/chat/panel.py +1769 -0
- chrys/app/tui/widgets/chat/ports.py +245 -0
- chrys/app/tui/widgets/chat/renderers/__init__.py +7 -0
- chrys/app/tui/widgets/chat/renderers/ask_user.py +747 -0
- chrys/app/tui/widgets/chat/renderers/execute.py +431 -0
- chrys/app/tui/widgets/chat/renderers/file_edit.py +925 -0
- chrys/app/tui/widgets/chat/renderers/hosted_code.py +83 -0
- chrys/app/tui/widgets/chat/renderers/hosted_generic.py +330 -0
- chrys/app/tui/widgets/chat/renderers/hosted_image.py +107 -0
- chrys/app/tui/widgets/chat/renderers/hosted_mcp.py +43 -0
- chrys/app/tui/widgets/chat/renderers/hosted_search.py +167 -0
- chrys/app/tui/widgets/chat/renderers/hosted_shell.py +132 -0
- chrys/app/tui/widgets/chat/renderers/read_file.py +399 -0
- chrys/app/tui/widgets/chat/renderers/search.py +348 -0
- chrys/app/tui/widgets/chat/renderers/skill.py +564 -0
- chrys/app/tui/widgets/chat/renderers/sleep.py +287 -0
- chrys/app/tui/widgets/chat/renderers/sub_agent.py +1385 -0
- chrys/app/tui/widgets/chat/renderers/todo.py +114 -0
- chrys/app/tui/widgets/chat/renderers/web.py +69 -0
- chrys/app/tui/widgets/chat/replay.py +1605 -0
- chrys/app/tui/widgets/chat/replay_mount.py +322 -0
- chrys/app/tui/widgets/chat/scroll_controller.py +579 -0
- chrys/app/tui/widgets/chat/selection_controller.py +435 -0
- chrys/app/tui/widgets/chat/session_json.py +508 -0
- chrys/app/tui/widgets/chat/toc_model.py +156 -0
- chrys/app/tui/widgets/chat/tool_call.py +2478 -0
- chrys/app/tui/widgets/chat/tool_registry.py +753 -0
- chrys/app/tui/widgets/chat/tool_renderers.py +247 -0
- chrys/app/tui/widgets/chat/tool_view_builders.py +260 -0
- chrys/app/tui/widgets/chat/transcript_reader.py +57 -0
- chrys/app/tui/widgets/checkbox.py +24 -0
- chrys/app/tui/widgets/chrome/__init__.py +3 -0
- chrys/app/tui/widgets/chrome/app_header.py +283 -0
- chrys/app/tui/widgets/chrome/commands.py +95 -0
- chrys/app/tui/widgets/chrome/file_index.py +68 -0
- chrys/app/tui/widgets/chrome/file_scanner.py +670 -0
- chrys/app/tui/widgets/chrome/footer.py +284 -0
- chrys/app/tui/widgets/chrome/image_paste.py +351 -0
- chrys/app/tui/widgets/chrome/input_bar.py +1299 -0
- chrys/app/tui/widgets/chrome/status_bar.py +911 -0
- chrys/app/tui/widgets/chrome/suggestion_list.py +652 -0
- chrys/app/tui/widgets/click_affordance.py +24 -0
- chrys/app/tui/widgets/color_picker/__init__.py +254 -0
- chrys/app/tui/widgets/color_picker/controls.py +171 -0
- chrys/app/tui/widgets/color_picker/model.py +89 -0
- chrys/app/tui/widgets/dialog_buttons.py +58 -0
- chrys/app/tui/widgets/dialog_scroll.py +24 -0
- chrys/app/tui/widgets/diff_view/__init__.py +20 -0
- chrys/app/tui/widgets/diff_view/cells.py +59 -0
- chrys/app/tui/widgets/diff_view/code.py +280 -0
- chrys/app/tui/widgets/diff_view/compute.py +286 -0
- chrys/app/tui/widgets/diff_view/gutter.py +161 -0
- chrys/app/tui/widgets/diff_view/inline.py +77 -0
- chrys/app/tui/widgets/diff_view/palette.py +87 -0
- chrys/app/tui/widgets/diff_view/protocols.py +24 -0
- chrys/app/tui/widgets/diff_view/rows.py +138 -0
- chrys/app/tui/widgets/diff_view/unified.py +267 -0
- chrys/app/tui/widgets/diff_view/widget.py +209 -0
- chrys/app/tui/widgets/editor/__init__.py +79 -0
- chrys/app/tui/widgets/editor/coordinates.py +30 -0
- chrys/app/tui/widgets/editor/highlighter.py +139 -0
- chrys/app/tui/widgets/editor/keymaps.py +357 -0
- chrys/app/tui/widgets/editor/types.py +216 -0
- chrys/app/tui/widgets/editor/vim.py +1195 -0
- chrys/app/tui/widgets/editor/widget.py +401 -0
- chrys/app/tui/widgets/file_preview.py +58 -0
- chrys/app/tui/widgets/hatch.py +138 -0
- chrys/app/tui/widgets/input.py +66 -0
- chrys/app/tui/widgets/loading.py +125 -0
- chrys/app/tui/widgets/markdown/__init__.py +25 -0
- chrys/app/tui/widgets/markdown/_utils.py +21 -0
- chrys/app/tui/widgets/markdown/anchors.py +71 -0
- chrys/app/tui/widgets/markdown/blocks.py +115 -0
- chrys/app/tui/widgets/markdown/copyable.py +163 -0
- chrys/app/tui/widgets/markdown/diagram/__init__.py +35 -0
- chrys/app/tui/widgets/markdown/diagram/canvas.py +409 -0
- chrys/app/tui/widgets/markdown/diagram/charts.py +52 -0
- chrys/app/tui/widgets/markdown/diagram/diagnostics.py +101 -0
- chrys/app/tui/widgets/markdown/diagram/geometry.py +46 -0
- chrys/app/tui/widgets/markdown/diagram/layout.py +494 -0
- chrys/app/tui/widgets/markdown/diagram/messages.py +18 -0
- chrys/app/tui/widgets/markdown/diagram/model.py +275 -0
- chrys/app/tui/widgets/markdown/diagram/parser.py +173 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/__init__.py +3 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/charts.py +349 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/common.py +403 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/graphs.py +1072 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/planning.py +317 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/schedule.py +298 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/schemas.py +241 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/structure.py +221 -0
- chrys/app/tui/widgets/markdown/diagram/presentation.py +32 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/__init__.py +3 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/charts.py +275 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/common.py +73 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/planning.py +157 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/schedule.py +202 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/structure.py +77 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/xy.py +385 -0
- chrys/app/tui/widgets/markdown/diagram/router.py +651 -0
- chrys/app/tui/widgets/markdown/diagram/specs/__init__.py +3 -0
- chrys/app/tui/widgets/markdown/diagram/specs/charts.py +94 -0
- chrys/app/tui/widgets/markdown/diagram/specs/planning.py +68 -0
- chrys/app/tui/widgets/markdown/diagram/specs/schedule.py +65 -0
- chrys/app/tui/widgets/markdown/diagram/specs/structure.py +23 -0
- chrys/app/tui/widgets/markdown/diagram/viewer.py +92 -0
- chrys/app/tui/widgets/markdown/links.py +47 -0
- chrys/app/tui/widgets/markdown/math/__init__.py +36 -0
- chrys/app/tui/widgets/markdown/math/layout.py +739 -0
- chrys/app/tui/widgets/markdown/math/markdown.py +291 -0
- chrys/app/tui/widgets/markdown/math/parser.py +325 -0
- chrys/app/tui/widgets/markdown/math/symbols.py +325 -0
- chrys/app/tui/widgets/markdown/navigator.py +80 -0
- chrys/app/tui/widgets/markdown/parser.py +1057 -0
- chrys/app/tui/widgets/markdown/stream.py +73 -0
- chrys/app/tui/widgets/markdown/toc.py +98 -0
- chrys/app/tui/widgets/markdown/viewer.py +146 -0
- chrys/app/tui/widgets/markdown/widget.py +2046 -0
- chrys/app/tui/widgets/marquee.py +101 -0
- chrys/app/tui/widgets/option_menu.py +82 -0
- chrys/app/tui/widgets/page_navigator.py +118 -0
- chrys/app/tui/widgets/select.py +196 -0
- chrys/app/tui/widgets/selection.py +59 -0
- chrys/app/tui/widgets/sidebar/__init__.py +3 -0
- chrys/app/tui/widgets/sidebar/buddy.py +367 -0
- chrys/app/tui/widgets/sidebar/context.py +482 -0
- chrys/app/tui/widgets/sidebar/debug.py +195 -0
- chrys/app/tui/widgets/sidebar/empty_state.py +11 -0
- chrys/app/tui/widgets/sidebar/panel.py +198 -0
- chrys/app/tui/widgets/sidebar/tasks.py +198 -0
- chrys/app/tui/widgets/sidebar/toc.py +166 -0
- chrys/app/tui/widgets/sidebar/toc_types.py +30 -0
- chrys/app/tui/widgets/syntax_theme.py +85 -0
- chrys/app/tui/widgets/text_area.py +258 -0
- chrys/app/tui/widgets/trajectory/__init__.py +8 -0
- chrys/app/tui/widgets/trajectory/chartkit.py +264 -0
- chrys/app/tui/widgets/trajectory/insights.py +1266 -0
- chrys/app/tui/widgets/trajectory/overview.py +1101 -0
- chrys/app/tui/widgets/trajectory/panel.py +983 -0
- chrys/app/tui/widgets/trajectory/presentation.py +311 -0
- chrys/app/tui/widgets/trajectory/session_info.py +124 -0
- chrys/app/tui/widgets/trajectory/text_view.py +124 -0
- chrys/app/tui/widgets/trajectory/timeline.py +380 -0
- chrys/app/tui/widgets/welcome.py +126 -0
- chrys/app/tui/widgets/workflow/__init__.py +5 -0
- chrys/app/tui/widgets/workflow/edges.py +89 -0
- chrys/app/tui/widgets/workflow/graph.py +869 -0
- chrys/app/tui/widgets/workflow/info.py +310 -0
- chrys/app/tui/widgets/workflow/node_view.py +27 -0
- chrys/app/tui/widgets/workflow/output.py +225 -0
- chrys/app/tui/widgets/workflow/panel.py +658 -0
- chrys/app/tui/widgets/workflow/projector.py +324 -0
- chrys/app/tui/widgets/workflow/records.py +69 -0
- chrys/app/tui/widgets/workflow/scrollbar.py +82 -0
- chrys/app/tui/widgets/workflow/selection.py +151 -0
- chrys/app/tui/widgets/workflow/source.py +65 -0
- chrys/app/tui/widgets/workflow/text.py +257 -0
- chrys/app/tui/widgets/workflow/values.py +317 -0
- chrys/foundation/__init__.py +0 -0
- chrys/foundation/branding.py +15 -0
- chrys/foundation/config/__init__.py +7 -0
- chrys/foundation/config/coercion.py +374 -0
- chrys/foundation/config/context.py +34 -0
- chrys/foundation/config/env_file.py +141 -0
- chrys/foundation/config/env_layers.py +370 -0
- chrys/foundation/config/migrations.py +521 -0
- chrys/foundation/config/notification_events.py +22 -0
- chrys/foundation/config/process_settings.py +332 -0
- chrys/foundation/config/runtime_pointer.py +133 -0
- chrys/foundation/config/settings.py +1952 -0
- chrys/foundation/config/settings_store.py +996 -0
- chrys/foundation/config/spec.py +396 -0
- chrys/foundation/config/user_settings.py +140 -0
- chrys/foundation/config/warnings.py +262 -0
- chrys/foundation/config/web_values.py +135 -0
- chrys/foundation/config/yaml_store.py +487 -0
- chrys/foundation/errors/__init__.py +68 -0
- chrys/foundation/errors/_legacy.py +117 -0
- chrys/foundation/errors/_text.py +13 -0
- chrys/foundation/errors/_walk.py +85 -0
- chrys/foundation/errors/classify.py +278 -0
- chrys/foundation/errors/display.py +274 -0
- chrys/foundation/errors/formatting.py +283 -0
- chrys/foundation/errors/kinds.py +67 -0
- chrys/foundation/errors/network.py +497 -0
- chrys/foundation/errors/provider.py +445 -0
- chrys/foundation/errors/route.py +85 -0
- chrys/foundation/events/__init__.py +8 -0
- chrys/foundation/events/bus.py +143 -0
- chrys/foundation/events/types.py +1629 -0
- chrys/foundation/events/workflow.py +38 -0
- chrys/foundation/hosted_tools.py +142 -0
- chrys/foundation/i18n/__init__.py +32 -0
- chrys/foundation/i18n/_catalogs/zh-Hans/LC_MESSAGES/chrys.mo +0 -0
- chrys/foundation/i18n/catalogs.py +42 -0
- chrys/foundation/i18n/formatting.py +202 -0
- chrys/foundation/i18n/locale.py +92 -0
- chrys/foundation/i18n/localizer.py +187 -0
- chrys/foundation/i18n/messages.py +268 -0
- chrys/foundation/io/__init__.py +3 -0
- chrys/foundation/io/result_content.py +237 -0
- chrys/foundation/models/__init__.py +3 -0
- chrys/foundation/models/approval_reuse.py +23 -0
- chrys/foundation/models/ask_user.py +468 -0
- chrys/foundation/models/execution.py +18 -0
- chrys/foundation/models/history_markers.py +115 -0
- chrys/foundation/models/invocations.py +63 -0
- chrys/foundation/models/mutation_scope.py +28 -0
- chrys/foundation/models/session_env.py +87 -0
- chrys/foundation/models/session_surface.py +29 -0
- chrys/foundation/models/todos.py +74 -0
- chrys/foundation/models/turns.py +125 -0
- chrys/foundation/models/workflow_session.py +89 -0
- chrys/foundation/models/workspace.py +59 -0
- chrys/foundation/net/__init__.py +3 -0
- chrys/foundation/net/address_class.py +57 -0
- chrys/foundation/net/route_probe.py +94 -0
- chrys/foundation/net/url.py +107 -0
- chrys/foundation/observability/__init__.py +3 -0
- chrys/foundation/observability/exporters.py +174 -0
- chrys/foundation/observability/gate.py +31 -0
- chrys/foundation/observability/setup.py +497 -0
- chrys/foundation/observability/sink.py +228 -0
- chrys/foundation/patches/__init__.py +19 -0
- chrys/foundation/patches/mdurl_cache.py +59 -0
- chrys/foundation/patches/patcher.py +287 -0
- chrys/foundation/patches/staged_members.py +205 -0
- chrys/foundation/patches/textual_block_border.py +105 -0
- chrys/foundation/patches/textual_button_ansi.py +65 -0
- chrys/foundation/patches/textual_button_ansi_old.txt +68 -0
- chrys/foundation/patches/textual_callback_cache.py +248 -0
- chrys/foundation/patches/textual_callback_dispatch.py +109 -0
- chrys/foundation/patches/textual_compositor_cjk.py +566 -0
- chrys/foundation/patches/textual_dispatch_cache.py +150 -0
- chrys/foundation/patches/textual_ime_cursor_anchor.py +92 -0
- chrys/foundation/patches/textual_kitty_keyboard.py +268 -0
- chrys/foundation/patches/textual_lru_acyclic.py +241 -0
- chrys/foundation/patches/textual_message_pump.py +136 -0
- chrys/foundation/patches/textual_node_diet.py +875 -0
- chrys/foundation/patches/textual_one_shot_timer.py +48 -0
- chrys/foundation/patches/textual_option_list.py +90 -0
- chrys/foundation/patches/textual_precompose.py +89 -0
- chrys/foundation/patches/textual_pruned_tabs.py +69 -0
- chrys/foundation/patches/textual_reflow_reuse.py +1753 -0
- chrys/foundation/patches/textual_removed_node_caches.py +86 -0
- chrys/foundation/patches/textual_removed_screen_callbacks.py +55 -0
- chrys/foundation/patches/textual_selection_extract.py +48 -0
- chrys/foundation/patches/textual_strip_cycles.py +119 -0
- chrys/foundation/patches/textual_tab_selection.py +1611 -0
- chrys/foundation/patches/textual_timer_skip.py +70 -0
- chrys/foundation/patches/textual_utf8_decoder.py +106 -0
- chrys/foundation/patches/textual_win_sleep.py +167 -0
- chrys/foundation/patches/watchdog_fsevents.py +70 -0
- chrys/foundation/patches/watchdog_windows.py +52 -0
- chrys/foundation/platform/__init__.py +556 -0
- chrys/foundation/platform/c_api.py +32 -0
- chrys/foundation/platform/child_reap.py +107 -0
- chrys/foundation/platform/command_line.py +95 -0
- chrys/foundation/platform/files.py +1470 -0
- chrys/foundation/platform/output_capture.py +113 -0
- chrys/foundation/platform/paths.py +193 -0
- chrys/foundation/platform/process.py +1468 -0
- chrys/foundation/platform/pty_output.py +19 -0
- chrys/foundation/platform/runtime_paths.py +281 -0
- chrys/foundation/platform/win_console.py +129 -0
- chrys/foundation/reasoning_origin.py +67 -0
- chrys/foundation/recovery.py +16 -0
- chrys/foundation/retry.py +278 -0
- chrys/foundation/text/__init__.py +3 -0
- chrys/foundation/text/encoding.py +603 -0
- chrys/foundation/text/images.py +304 -0
- chrys/foundation/text/lines.py +24 -0
- chrys/foundation/text/mentions.py +90 -0
- chrys/foundation/text/model_json.py +25 -0
- chrys/foundation/text/reading_order.py +20 -0
- chrys/foundation/text/tokenizer.py +25 -0
- chrys/foundation/text/tool_output.py +287 -0
- chrys/foundation/text/yaml_io.py +62 -0
- chrys/foundation/tool_call_context.py +202 -0
- chrys/foundation/tool_execution_stamp.py +184 -0
- chrys/foundation/tool_invocation_order.py +48 -0
- chrys/foundation/tool_kinds.py +102 -0
- chrys/foundation/tool_result_metadata.py +287 -0
- chrys/foundation/trajectory/__init__.py +10 -0
- chrys/foundation/trajectory/context.py +481 -0
- chrys/foundation/trajectory/envelope.py +571 -0
- chrys/foundation/trajectory/event_types.py +294 -0
- chrys/foundation/trajectory/finalizers.py +46 -0
- chrys/foundation/trajectory/fingerprint.py +62 -0
- chrys/foundation/trajectory/ids.py +155 -0
- chrys/foundation/trajectory/keys.py +99 -0
- chrys/foundation/trajectory/lease.py +173 -0
- chrys/foundation/trajectory/metadata.py +72 -0
- chrys/foundation/trajectory/reader.py +271 -0
- chrys/foundation/trajectory/recovery.py +225 -0
- chrys/foundation/trajectory/revisions.py +152 -0
- chrys/foundation/trajectory/segments.py +225 -0
- chrys/foundation/trajectory/tools.py +117 -0
- chrys/foundation/trajectory/usage.py +151 -0
- chrys/foundation/trajectory/writer.py +972 -0
- chrys/foundation/trajectory_timing.py +97 -0
- chrys/foundation/util/__init__.py +3 -0
- chrys/foundation/util/async_tasks.py +29 -0
- chrys/foundation/util/chrys_headers.py +23 -0
- chrys/foundation/util/env_templates.py +104 -0
- chrys/foundation/util/filenames.py +96 -0
- chrys/foundation/util/header_charset.py +138 -0
- chrys/foundation/util/httpx_helpers.py +12 -0
- chrys/foundation/util/lock.py +236 -0
- chrys/foundation/util/once_close.py +46 -0
- chrys/foundation/util/session_ids.py +29 -0
- chrys/foundation/util/shell_tokens.py +88 -0
- chrys/foundation/util/sub_agent_context.py +38 -0
- chrys/foundation/util/time.py +35 -0
- chrys/foundation/util/unicode_scalars.py +36 -0
- chrys/foundation/vendor/__init__.py +43 -0
- chrys/foundation/vendor/ripgrep/rg-aarch64-apple-darwin +0 -0
- chrys/foundation/vendor/ripgrep/rg-aarch64-pc-windows-msvc.exe +0 -0
- chrys/foundation/vendor/ripgrep/rg-aarch64-unknown-linux-gnu +0 -0
- chrys/foundation/vendor/ripgrep/rg-x86_64-apple-darwin +0 -0
- chrys/foundation/vendor/ripgrep/rg-x86_64-pc-windows-msvc.exe +0 -0
- chrys/foundation/vendor/ripgrep/rg-x86_64-unknown-linux-musl +0 -0
- chrys/kernel/__init__.py +256 -0
- chrys/kernel/_content.py +1866 -0
- chrys/kernel/_loop_recorder.py +631 -0
- chrys/kernel/_null_overlay.py +1026 -0
- chrys/kernel/_result_ceiling.py +216 -0
- chrys/kernel/_serialization.py +431 -0
- chrys/kernel/_tool_arg_errors.py +1629 -0
- chrys/kernel/_tool_execution.py +1172 -0
- chrys/kernel/_tool_expansion.py +26 -0
- chrys/kernel/_types.py +2598 -0
- chrys/kernel/agent.py +994 -0
- chrys/kernel/client.py +1084 -0
- chrys/kernel/compaction.py +1315 -0
- chrys/kernel/exceptions.py +211 -0
- chrys/kernel/exchanges.py +683 -0
- chrys/kernel/identity.py +95 -0
- chrys/kernel/images.py +36 -0
- chrys/kernel/instrumentation.py +1618 -0
- chrys/kernel/loop.py +2308 -0
- chrys/kernel/middleware.py +616 -0
- chrys/kernel/sessions.py +604 -0
- chrys/kernel/tools.py +1061 -0
- chrys/kernel/types.py +126 -0
- chrys/orchestration/__init__.py +3 -0
- chrys/orchestration/engine/__init__.py +3 -0
- chrys/orchestration/engine/assembly.py +236 -0
- chrys/orchestration/engine/build/__init__.py +3 -0
- chrys/orchestration/engine/build/builder.py +1061 -0
- chrys/orchestration/engine/build/construction.py +408 -0
- chrys/orchestration/engine/build/loaded.py +139 -0
- chrys/orchestration/engine/engine.py +789 -0
- chrys/orchestration/engine/execution.py +771 -0
- chrys/orchestration/engine/loader.py +865 -0
- chrys/orchestration/engine/rollback.py +918 -0
- chrys/orchestration/engine/run/__init__.py +3 -0
- chrys/orchestration/engine/run/active_injection.py +419 -0
- chrys/orchestration/engine/run/attachments.py +519 -0
- chrys/orchestration/engine/run/bindings.py +1095 -0
- chrys/orchestration/engine/run/coordinator.py +1062 -0
- chrys/orchestration/engine/run/finalizer.py +417 -0
- chrys/orchestration/engine/run/input_refs.py +60 -0
- chrys/orchestration/engine/run/prompt_content.py +457 -0
- chrys/orchestration/engine/run/resume.py +299 -0
- chrys/orchestration/engine/run/retry.py +948 -0
- chrys/orchestration/engine/run/runner.py +625 -0
- chrys/orchestration/engine/run/runtime_skills.py +146 -0
- chrys/orchestration/engine/run/sub_agent_coordination.py +126 -0
- chrys/orchestration/engine/run/turn_hooks.py +258 -0
- chrys/orchestration/engine/run/turn_state.py +149 -0
- chrys/orchestration/engine/run/working_dir.py +50 -0
- chrys/orchestration/engine/session_lifecycle.py +2013 -0
- chrys/orchestration/engine/state/__init__.py +3 -0
- chrys/orchestration/engine/state/active_session.py +183 -0
- chrys/orchestration/engine/state/controls.py +639 -0
- chrys/orchestration/engine/state/current_agent.py +26 -0
- chrys/orchestration/engine/state/lifecycle_permits.py +395 -0
- chrys/orchestration/engine/state/machine.py +193 -0
- chrys/orchestration/engine/state/session_writer.py +434 -0
- chrys/orchestration/engine/trajectory.py +714 -0
- chrys/orchestration/engine/usage.py +118 -0
- chrys/orchestration/invoker/__init__.py +3 -0
- chrys/orchestration/invoker/acp.py +727 -0
- chrys/orchestration/invoker/acp_protocol.py +1115 -0
- chrys/orchestration/invoker/acp_spec.py +79 -0
- chrys/orchestration/invoker/attempts.py +994 -0
- chrys/orchestration/invoker/child_compaction.py +131 -0
- chrys/orchestration/invoker/child_history.py +99 -0
- chrys/orchestration/invoker/contracts.py +253 -0
- chrys/orchestration/invoker/evidence.py +94 -0
- chrys/orchestration/invoker/kernel.py +343 -0
- chrys/orchestration/invoker/origin.py +79 -0
- chrys/orchestration/invoker/resources.py +343 -0
- chrys/orchestration/invoker/runtime.py +299 -0
- chrys/orchestration/invoker/usage.py +80 -0
- chrys/orchestration/session_hooks.py +117 -0
- chrys/orchestration/session_host.py +1023 -0
- chrys/orchestration/session_resources.py +95 -0
- chrys/orchestration/session_usage.py +219 -0
- chrys/orchestration/startup.py +255 -0
- chrys/orchestration/sub_agents/__init__.py +3 -0
- chrys/orchestration/sub_agents/acp_policy.py +269 -0
- chrys/orchestration/sub_agents/kernel_policy.py +848 -0
- chrys/orchestration/sub_agents/registration.py +44 -0
- chrys/orchestration/sub_agents/shell.py +337 -0
- chrys/orchestration/sub_agents/tools.py +2228 -0
- chrys/orchestration/workflows/__init__.py +3 -0
- chrys/orchestration/workflows/agent_archive.py +156 -0
- chrys/orchestration/workflows/agent_node.py +894 -0
- chrys/orchestration/workflows/agent_node_build.py +687 -0
- chrys/orchestration/workflows/catalog.py +332 -0
- chrys/orchestration/workflows/coordinator.py +771 -0
- chrys/orchestration/workflows/hooks.py +176 -0
- chrys/orchestration/workflows/preview.py +322 -0
- chrys/orchestration/workflows/rollback.py +151 -0
- chrys/orchestration/workflows/runner.py +986 -0
- chrys/orchestration/workflows/session.py +390 -0
- chrys/orchestration/workflows/settings.py +97 -0
- chrys/orchestration/workflows/validation.py +674 -0
- chrys/orchestration/workflows/worker_client.py +1002 -0
- chrys/service/__init__.py +3 -0
- chrys/service/acp_client/__init__.py +65 -0
- chrys/service/acp_client/_transport.py +922 -0
- chrys/service/acp_client/client.py +1311 -0
- chrys/service/acp_client/errors.py +224 -0
- chrys/service/acp_client/protocol.py +220 -0
- chrys/service/acp_client/spawn.py +239 -0
- chrys/service/acp_client/spec.py +100 -0
- chrys/service/agent_middleware/__init__.py +60 -0
- chrys/service/agent_middleware/_metadata_keys.py +52 -0
- chrys/service/agent_middleware/control/__init__.py +3 -0
- chrys/service/agent_middleware/control/approval.py +769 -0
- chrys/service/agent_middleware/control/approval_hooks.py +62 -0
- chrys/service/agent_middleware/control/ask_user.py +207 -0
- chrys/service/agent_middleware/control/interrupt.py +50 -0
- chrys/service/agent_middleware/control/sleep.py +118 -0
- chrys/service/agent_middleware/control/todo.py +117 -0
- chrys/service/agent_middleware/events/__init__.py +3 -0
- chrys/service/agent_middleware/events/hook_dispatch.py +250 -0
- chrys/service/agent_middleware/events/hosted_tools.py +1762 -0
- chrys/service/agent_middleware/events/intermediate_text.py +128 -0
- chrys/service/agent_middleware/events/rejection_metadata.py +50 -0
- chrys/service/agent_middleware/events/result_persistence.py +98 -0
- chrys/service/agent_middleware/events/sub_agent_events.py +951 -0
- chrys/service/agent_middleware/events/tool_error_text.py +22 -0
- chrys/service/agent_middleware/events/tool_events.py +779 -0
- chrys/service/agent_middleware/injection.py +510 -0
- chrys/service/agent_middleware/reminders/__init__.py +91 -0
- chrys/service/agent_middleware/reminders/archive_pointer.py +154 -0
- chrys/service/agent_middleware/reminders/base.py +25 -0
- chrys/service/agent_middleware/reminders/context_usage.py +74 -0
- chrys/service/agent_middleware/reminders/file_change.py +63 -0
- chrys/service/agent_middleware/reminders/mcp.py +39 -0
- chrys/service/agent_middleware/reminders/profile_switch.py +106 -0
- chrys/service/agent_middleware/reminders/runtime_env.py +190 -0
- chrys/service/agent_middleware/reminders/skills.py +44 -0
- chrys/service/agent_middleware/reminders/sub_agents.py +35 -0
- chrys/service/agent_middleware/reminders/todo.py +35 -0
- chrys/service/agent_middleware/reminders/turn_line.py +48 -0
- chrys/service/agent_middleware/response_validation.py +1380 -0
- chrys/service/agent_middleware/system_reminder.py +1107 -0
- chrys/service/agent_middleware/validators.py +396 -0
- chrys/service/analytics/__init__.py +124 -0
- chrys/service/analytics/_actions.py +896 -0
- chrys/service/analytics/_context_evidence.py +357 -0
- chrys/service/analytics/_critical_path.py +179 -0
- chrys/service/analytics/_facts.py +919 -0
- chrys/service/analytics/_insights.py +769 -0
- chrys/service/analytics/_metric_ops.py +83 -0
- chrys/service/analytics/_session_projection.py +460 -0
- chrys/service/analytics/_timeline.py +434 -0
- chrys/service/analytics/_turn_graph.py +1237 -0
- chrys/service/analytics/_turns.py +1377 -0
- chrys/service/analytics/_workflows.py +171 -0
- chrys/service/analytics/aggregation.py +1474 -0
- chrys/service/analytics/classification.py +128 -0
- chrys/service/analytics/export.py +803 -0
- chrys/service/analytics/findings.py +282 -0
- chrys/service/analytics/math.py +79 -0
- chrys/service/analytics/model.py +886 -0
- chrys/service/analytics/reader.py +321 -0
- chrys/service/approval/__init__.py +3 -0
- chrys/service/approval/arbitration.py +102 -0
- chrys/service/approval/command_identity.py +109 -0
- chrys/service/approval/correlation.py +57 -0
- chrys/service/approval/grant_store.py +108 -0
- chrys/service/approval/judge.py +589 -0
- chrys/service/approval/level.py +15 -0
- chrys/service/approval/policy.py +209 -0
- chrys/service/approval/reuse.py +223 -0
- chrys/service/approval/reuse_binding.py +133 -0
- chrys/service/approval/safety_classifier.py +536 -0
- chrys/service/approval/turn_context.py +49 -0
- chrys/service/context/__init__.py +3 -0
- chrys/service/context/compaction/__init__.py +38 -0
- chrys/service/context/compaction/breaker.py +156 -0
- chrys/service/context/compaction/budgets.py +93 -0
- chrys/service/context/compaction/calibration.py +77 -0
- chrys/service/context/compaction/compression.py +690 -0
- chrys/service/context/compaction/current_turn_drop.py +458 -0
- chrys/service/context/compaction/events.py +201 -0
- chrys/service/context/compaction/exclusions.py +261 -0
- chrys/service/context/compaction/groups.py +361 -0
- chrys/service/context/compaction/last_words.py +1996 -0
- chrys/service/context/compaction/last_words_state.py +752 -0
- chrys/service/context/compaction/scoped.py +472 -0
- chrys/service/context/compaction/spill.py +1947 -0
- chrys/service/context/compaction/strategy.py +1116 -0
- chrys/service/context/compaction/summaries.py +319 -0
- chrys/service/context/compaction/turns.py +375 -0
- chrys/service/context/manager.py +174 -0
- chrys/service/context/memory_loader.py +792 -0
- chrys/service/context/middleware/__init__.py +3 -0
- chrys/service/context/middleware/usage.py +332 -0
- chrys/service/context/providers/__init__.py +3 -0
- chrys/service/context/providers/context_management.py +432 -0
- chrys/service/context/providers/history.py +647 -0
- chrys/service/context/providers/memory.py +50 -0
- chrys/service/context/tool_content.py +22 -0
- chrys/service/hooks/__init__.py +37 -0
- chrys/service/hooks/detached_worker.py +118 -0
- chrys/service/hooks/docs/README.md +14 -0
- chrys/service/hooks/docs/authoring.md +684 -0
- chrys/service/hooks/docs/configuration.md +499 -0
- chrys/service/hooks/docs/design.md +359 -0
- chrys/service/hooks/docs/project-hooks.md +348 -0
- chrys/service/hooks/events.py +157 -0
- chrys/service/hooks/loader.py +665 -0
- chrys/service/hooks/manager.py +807 -0
- chrys/service/hooks/matcher.py +111 -0
- chrys/service/hooks/outbox.py +294 -0
- chrys/service/hooks/runner.py +572 -0
- chrys/service/hooks/schema.py +407 -0
- chrys/service/llm/__init__.py +7 -0
- chrys/service/llm/_structured_outputs.py +192 -0
- chrys/service/llm/anthropic_messages/__init__.py +9 -0
- chrys/service/llm/anthropic_messages/client.py +254 -0
- chrys/service/llm/anthropic_messages/decode.py +331 -0
- chrys/service/llm/anthropic_messages/history.py +279 -0
- chrys/service/llm/anthropic_messages/request.py +323 -0
- chrys/service/llm/anthropic_messages/server_tools.py +319 -0
- chrys/service/llm/anthropic_messages/stream.py +297 -0
- chrys/service/llm/anthropic_messages/thinking_binding.py +156 -0
- chrys/service/llm/chat_completions/__init__.py +9 -0
- chrys/service/llm/chat_completions/client.py +369 -0
- chrys/service/llm/chat_completions/decode.py +338 -0
- chrys/service/llm/chat_completions/history.py +657 -0
- chrys/service/llm/chat_completions/reasoning.py +244 -0
- chrys/service/llm/chat_completions/request.py +192 -0
- chrys/service/llm/chat_completions/stream.py +546 -0
- chrys/service/llm/chat_completions/validation.py +308 -0
- chrys/service/llm/clients.py +613 -0
- chrys/service/llm/images.py +65 -0
- chrys/service/llm/mock.py +413 -0
- chrys/service/llm/observer.py +446 -0
- chrys/service/llm/one_shot.py +106 -0
- chrys/service/llm/openai_exceptions.py +93 -0
- chrys/service/llm/openai_responses/__init__.py +10 -0
- chrys/service/llm/openai_responses/client.py +323 -0
- chrys/service/llm/openai_responses/decode.py +432 -0
- chrys/service/llm/openai_responses/history.py +519 -0
- chrys/service/llm/openai_responses/hosted.py +625 -0
- chrys/service/llm/openai_responses/replay.py +629 -0
- chrys/service/llm/openai_responses/request.py +342 -0
- chrys/service/llm/openai_responses/stream.py +847 -0
- chrys/service/llm/openai_timestamps.py +57 -0
- chrys/service/llm/providers.py +107 -0
- chrys/service/llm/proxy_route.py +54 -0
- chrys/service/llm/raw_http_log.py +309 -0
- chrys/service/llm/route_facts.py +63 -0
- chrys/service/llm/route_sessions.py +49 -0
- chrys/service/llm/sdk_anthropic.py +18 -0
- chrys/service/llm/sdk_headers.py +47 -0
- chrys/service/llm/sdk_openai.py +18 -0
- chrys/service/llm/sdk_retry.py +59 -0
- chrys/service/llm/wire_client.py +246 -0
- chrys/service/mcp/__init__.py +3 -0
- chrys/service/mcp/_connection.py +235 -0
- chrys/service/mcp/_http_transport.py +350 -0
- chrys/service/mcp/_progressive.py +459 -0
- chrys/service/mcp/_stdio_transport.py +614 -0
- chrys/service/mcp/_tool_mixins.py +96 -0
- chrys/service/mcp/adapter.py +1031 -0
- chrys/service/mcp/cache.py +476 -0
- chrys/service/mcp/content.py +32 -0
- chrys/service/mcp/errors.py +169 -0
- chrys/service/mcp/owned.py +1330 -0
- chrys/service/mcp/result_limits.py +168 -0
- chrys/service/mcp/thinking_warning.py +95 -0
- chrys/service/mcp/validation.py +74 -0
- chrys/service/mutations/__init__.py +84 -0
- chrys/service/mutations/coordination.py +894 -0
- chrys/service/mutations/detector.py +458 -0
- chrys/service/mutations/git_calibrator.py +397 -0
- chrys/service/mutations/git_state.py +647 -0
- chrys/service/mutations/pipeline.py +779 -0
- chrys/service/mutations/scanner.py +466 -0
- chrys/service/mutations/snapshot_files.py +233 -0
- chrys/service/mutations/store.py +625 -0
- chrys/service/mutations/tool_names.py +15 -0
- chrys/service/mutations/trace.py +246 -0
- chrys/service/mutations/tracker.py +1289 -0
- chrys/service/mutations/types.py +559 -0
- chrys/service/mutations/workspace_changes.py +1583 -0
- chrys/service/profiles/__init__.py +2 -0
- chrys/service/profiles/agents/__init__.py +3 -0
- chrys/service/profiles/agents/builtins/Code.yaml +199 -0
- chrys/service/profiles/agents/builtins/Explore.yaml +62 -0
- chrys/service/profiles/agents/builtins/General.yaml +114 -0
- chrys/service/profiles/agents/builtins/QA.yaml +128 -0
- chrys/service/profiles/agents/loader.py +808 -0
- chrys/service/profiles/agents/registry.py +727 -0
- chrys/service/profiles/agents/schema.py +371 -0
- chrys/service/profiles/agents/serializer.py +196 -0
- chrys/service/profiles/coercion.py +22 -0
- chrys/service/profiles/models/__init__.py +3 -0
- chrys/service/profiles/models/env_bridge.py +157 -0
- chrys/service/profiles/models/loader.py +282 -0
- chrys/service/profiles/models/options.py +438 -0
- chrys/service/profiles/models/registry.py +104 -0
- chrys/service/profiles/models/resolver.py +286 -0
- chrys/service/profiles/models/schema.py +91 -0
- chrys/service/profiles/models/serializer.py +76 -0
- chrys/service/session/__init__.py +3 -0
- chrys/service/session/checkpoint.py +156 -0
- chrys/service/session/history.py +1616 -0
- chrys/service/session/message_metadata.py +151 -0
- chrys/service/session/persistence.py +435 -0
- chrys/service/session/runtime_metadata.py +210 -0
- chrys/service/session/sub_agent_logs.py +1069 -0
- chrys/service/session/sub_agent_transcript.py +654 -0
- chrys/service/skills/__init__.py +3 -0
- chrys/service/skills/adapter.py +408 -0
- chrys/service/skills/constants.py +8 -0
- chrys/service/skills/loader.py +575 -0
- chrys/service/skills/model.py +130 -0
- chrys/service/skills/provider.py +841 -0
- chrys/service/skills/runner.py +390 -0
- chrys/service/state/__init__.py +3 -0
- chrys/service/state/_fork.py +710 -0
- chrys/service/state/_session_files.py +175 -0
- chrys/service/state/_session_meta.py +552 -0
- chrys/service/state/locks.py +154 -0
- chrys/service/state/serializers.py +156 -0
- chrys/service/state/session_catalog.py +361 -0
- chrys/service/state/session_listing.py +84 -0
- chrys/service/state/session_migration.py +446 -0
- chrys/service/state/session_mru.py +315 -0
- chrys/service/state/store.py +2132 -0
- chrys/service/state/workflow.py +158 -0
- chrys/service/text_blocks.py +47 -0
- chrys/service/todos/__init__.py +3 -0
- chrys/service/todos/reminder.py +31 -0
- chrys/service/todos/tracker.py +58 -0
- chrys/service/tools/__init__.py +3 -0
- chrys/service/tools/approval_targets.py +92 -0
- chrys/service/tools/builtins/__init__.py +3 -0
- chrys/service/tools/builtins/ask_user.py +326 -0
- chrys/service/tools/builtins/doc_converter/__init__.py +494 -0
- chrys/service/tools/builtins/doc_converter/artifacts.py +392 -0
- chrys/service/tools/builtins/doc_converter/parsers/__init__.py +3 -0
- chrys/service/tools/builtins/doc_converter/parsers/_table.py +34 -0
- chrys/service/tools/builtins/doc_converter/parsers/base.py +101 -0
- chrys/service/tools/builtins/doc_converter/parsers/docx.py +88 -0
- chrys/service/tools/builtins/doc_converter/parsers/pdf.py +144 -0
- chrys/service/tools/builtins/doc_converter/parsers/pptx.py +103 -0
- chrys/service/tools/builtins/doc_converter/parsers/xls.py +32 -0
- chrys/service/tools/builtins/doc_converter/parsers/xlsx.py +51 -0
- chrys/service/tools/builtins/doc_converter/registry.py +55 -0
- chrys/service/tools/builtins/filesystem.py +1071 -0
- chrys/service/tools/builtins/search.py +1265 -0
- chrys/service/tools/builtins/shell.py +967 -0
- chrys/service/tools/builtins/shell_command_filtering/__init__.py +5 -0
- chrys/service/tools/builtins/shell_command_filtering/policy.py +60 -0
- chrys/service/tools/builtins/shell_command_filtering/presets.py +152 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/__init__.py +358 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/commands.py +416 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/git.py +408 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/syntax.py +87 -0
- chrys/service/tools/builtins/shell_command_filtering/tokenizer.py +214 -0
- chrys/service/tools/builtins/shell_filter.py +24 -0
- chrys/service/tools/builtins/sleep.py +91 -0
- chrys/service/tools/builtins/todo.py +64 -0
- chrys/service/tools/builtins/web/__init__.py +6 -0
- chrys/service/tools/builtins/web/build.py +268 -0
- chrys/service/tools/builtins/web/config.py +338 -0
- chrys/service/tools/builtins/web/credentials.py +37 -0
- chrys/service/tools/builtins/web/fetch/__init__.py +3 -0
- chrys/service/tools/builtins/web/fetch/cache.py +68 -0
- chrys/service/tools/builtins/web/fetch/convert.py +56 -0
- chrys/service/tools/builtins/web/fetch/tool.py +223 -0
- chrys/service/tools/builtins/web/hosted.py +61 -0
- chrys/service/tools/builtins/web/http.py +330 -0
- chrys/service/tools/builtins/web/progress.py +18 -0
- chrys/service/tools/builtins/web/search/__init__.py +3 -0
- chrys/service/tools/builtins/web/search/exa_mcp.py +146 -0
- chrys/service/tools/builtins/web/search/html.py +152 -0
- chrys/service/tools/builtins/web/search/json_api.py +227 -0
- chrys/service/tools/builtins/web/search/providers.py +77 -0
- chrys/service/tools/builtins/web/search/tool.py +218 -0
- chrys/service/tools/builtins/web/search/types.py +28 -0
- chrys/service/tools/builtins/web/tls_identity.py +66 -0
- chrys/service/tools/kinds.py +115 -0
- chrys/service/tools/names.py +47 -0
- chrys/service/tools/registry.py +242 -0
- chrys/service/tools/result_metadata.py +103 -0
- chrys/service/tools/session_artifacts.py +196 -0
- chrys/service/tools/spill.py +218 -0
- chrys/service/tools/workspace_paths.py +81 -0
- chrys/service/trajectory/__init__.py +11 -0
- chrys/service/trajectory/approvals.py +201 -0
- chrys/service/trajectory/compaction.py +420 -0
- chrys/service/trajectory/fork.py +75 -0
- chrys/service/trajectory/hooks.py +217 -0
- chrys/service/trajectory/items.py +41 -0
- chrys/service/trajectory/preparation.py +374 -0
- chrys/service/trajectory/retries.py +95 -0
- chrys/service/trajectory/revisions.py +129 -0
- chrys/service/trajectory/segmented.py +163 -0
- chrys/service/trajectory/session.py +762 -0
- chrys/service/trajectory/state.py +83 -0
- chrys/service/trajectory/sub_agents.py +169 -0
- chrys/service/trajectory/tombstone.py +262 -0
- chrys/service/trajectory/tools.py +323 -0
- chrys/service/trajectory/validation.py +130 -0
- chrys/service/trajectory/waits.py +161 -0
- chrys/service/trajectory/workflow.py +148 -0
- chrys/service/vision.py +128 -0
- chrys/service/workflows/__init__.py +3 -0
- chrys/service/workflows/admission.py +151 -0
- chrys/service/workflows/artifacts.py +109 -0
- chrys/service/workflows/asks.py +130 -0
- chrys/service/workflows/builtins/__init__.py +12 -0
- chrys/service/workflows/builtins/demo-workflow.manifest.json +635 -0
- chrys/service/workflows/builtins/demo-workflow.py +756 -0
- chrys/service/workflows/discovery.py +554 -0
- chrys/service/workflows/environment.py +284 -0
- chrys/service/workflows/graph.py +430 -0
- chrys/service/workflows/history.py +82 -0
- chrys/service/workflows/interpreter.py +107 -0
- chrys/service/workflows/journal.py +334 -0
- chrys/service/workflows/layout.py +56 -0
- chrys/service/workflows/ledger.py +149 -0
- chrys/service/workflows/model_selection.py +18 -0
- chrys/service/workflows/orphans.py +209 -0
- chrys/service/workflows/outcomes.py +46 -0
- chrys/service/workflows/protocol.py +210 -0
- chrys/service/workflows/records.py +142 -0
- chrys/service/workflows/scheduler.py +770 -0
- chrys/service/workflows/sdk/__init__.py +51 -0
- chrys/service/workflows/sdk/_ask.py +167 -0
- chrys/service/workflows/sdk/_builder.py +781 -0
- chrys/service/workflows/sdk/_values.py +92 -0
- chrys/service/workflows/sdk_artifact.py +74 -0
- chrys/service/workflows/store.py +494 -0
- chrys/service/workflows/transcript.py +102 -0
- chrys/service/workflows/values.py +73 -0
- chrys/service/workflows/worker_host.py +1820 -0
- chrys/workflows.py +38 -0
- icode_tui-0.29.1.dist-info/METADATA +104 -0
- icode_tui-0.29.1.dist-info/RECORD +1138 -0
- icode_tui-0.29.1.dist-info/WHEEL +4 -0
- icode_tui-0.29.1.dist-info/entry_points.txt +2 -0
- icode_tui-0.29.1.dist-info/licenses/LICENSE +202 -0
- icode_tui-0.29.1.dist-info/licenses/NOTICE +1955 -0
|
@@ -0,0 +1,1289 @@
|
|
|
1
|
+
# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""MutationTracker — records file mutations and manages per-execution snapshots.
|
|
4
|
+
|
|
5
|
+
Created once per session. Passed to tool implementations so they can
|
|
6
|
+
record mutations. Each file is snapshotted at most once per turn
|
|
7
|
+
(first mutation wins for that turn).
|
|
8
|
+
|
|
9
|
+
Typical lifecycle::
|
|
10
|
+
|
|
11
|
+
tracker = MutationTracker(SnapshotStore(session_dir))
|
|
12
|
+
|
|
13
|
+
# --- turn 1 ---
|
|
14
|
+
tracker.start_turn(1)
|
|
15
|
+
# Called BEFORE the actual write — snapshots the pre-mutation file
|
|
16
|
+
tracker.record("foo.py", MODIFY, EDIT_FILE, "call-001")
|
|
17
|
+
tracker.record("foo.py", MODIFY, EDIT_FILE, "call-002") # no re-snapshot
|
|
18
|
+
|
|
19
|
+
# --- turn 2 (no file changes) ---
|
|
20
|
+
tracker.start_turn(2) # empty turn, still recorded
|
|
21
|
+
|
|
22
|
+
# --- turn 3 ---
|
|
23
|
+
tracker.start_turn(3)
|
|
24
|
+
tracker.record("foo.py", MODIFY, EDIT_FILE, "call-005") # re-snapshots
|
|
25
|
+
|
|
26
|
+
# Multi-step rollback: undo turns 3+2 -> foo.py restored to turn-1 state
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import asyncio
|
|
32
|
+
import logging
|
|
33
|
+
import os
|
|
34
|
+
import time
|
|
35
|
+
from collections.abc import Callable
|
|
36
|
+
from threading import RLock
|
|
37
|
+
from typing import Any, TypeVar
|
|
38
|
+
|
|
39
|
+
from chrys.foundation.models.mutation_scope import MutationScope, WorkflowRunScope
|
|
40
|
+
from chrys.service.mutations.git_state import entry_display_path
|
|
41
|
+
from chrys.service.mutations.store import SnapshotStore
|
|
42
|
+
from chrys.service.mutations.types import (
|
|
43
|
+
FileHashDiff,
|
|
44
|
+
FileMutation,
|
|
45
|
+
FileSnapshot,
|
|
46
|
+
MutationLog,
|
|
47
|
+
MutationOp,
|
|
48
|
+
MutationPeriod,
|
|
49
|
+
MutationProvenance,
|
|
50
|
+
MutationSource,
|
|
51
|
+
RestoreResult,
|
|
52
|
+
RollbackExclusionReason,
|
|
53
|
+
RollbackPlan,
|
|
54
|
+
RunMutations,
|
|
55
|
+
SnapshotSkipReason,
|
|
56
|
+
TurnMutations,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
logger = logging.getLogger(__name__)
|
|
60
|
+
|
|
61
|
+
_T = TypeVar("_T")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _ProvenanceBadges:
|
|
65
|
+
"""Accumulates per-path provenance badge state while folding summaries.
|
|
66
|
+
|
|
67
|
+
Shared by the turn and session summary folds so both apply the same
|
|
68
|
+
rules: a ``FOREIGN`` row never contributes endpoints (and a path
|
|
69
|
+
with only foreign rows is absent); a path that mixes foreign rows
|
|
70
|
+
with ours renders contested (disk may differ from "our" after);
|
|
71
|
+
``inferred`` marks folds whose net change includes window-diff
|
|
72
|
+
inference. MOVE rows badge both ``path`` and ``old_path``,
|
|
73
|
+
mirroring rollback's two-path treatment.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self) -> None:
|
|
77
|
+
self._foreign: set[str] = set()
|
|
78
|
+
self._contested: set[str] = set()
|
|
79
|
+
self._inferred: set[str] = set()
|
|
80
|
+
|
|
81
|
+
def observe(self, m: FileMutation) -> bool:
|
|
82
|
+
"""Register one row; True when it is FOREIGN (caller skips folding)."""
|
|
83
|
+
targets = (m.path, m.old_path) if m.old_path else (m.path,)
|
|
84
|
+
if m.provenance is MutationProvenance.FOREIGN:
|
|
85
|
+
self._foreign.update(targets)
|
|
86
|
+
return True
|
|
87
|
+
if m.contested:
|
|
88
|
+
self._contested.update(targets)
|
|
89
|
+
if m.provenance is MutationProvenance.ASSUMED:
|
|
90
|
+
self._inferred.update(targets)
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
def apply(self, result: dict[str, FileHashDiff]) -> dict[str, FileHashDiff]:
|
|
94
|
+
"""Set folded badges on entries (foreign-only paths are already absent)."""
|
|
95
|
+
from dataclasses import replace
|
|
96
|
+
|
|
97
|
+
for path, diff in result.items():
|
|
98
|
+
contested = path in self._contested or path in self._foreign
|
|
99
|
+
inferred = path in self._inferred
|
|
100
|
+
if contested != diff.contested or inferred != diff.inferred:
|
|
101
|
+
result[path] = replace(diff, contested=contested, inferred=inferred)
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class MutationTracker:
|
|
106
|
+
"""Records file mutations and manages per-execution snapshots.
|
|
107
|
+
|
|
108
|
+
Two layers of state tracking:
|
|
109
|
+
|
|
110
|
+
1. **Turn-level snapshots** (``_ensure_snapshot``): captured once per
|
|
111
|
+
(path, turn) at the first mutation. Used for rollback — represents
|
|
112
|
+
the file state at the *start* of the turn.
|
|
113
|
+
|
|
114
|
+
2. **Last-known hash** (``_last_known_hash``): tracks the most recent
|
|
115
|
+
observed content hash for each path *within the current turn*.
|
|
116
|
+
Updated after every ``record_after()`` and shell ``record()``.
|
|
117
|
+
Used to set accurate ``before_hash`` on each ``FileMutation`` so
|
|
118
|
+
per-mutation DiffView shows the *incremental* change, not a diff
|
|
119
|
+
against the turn-start state.
|
|
120
|
+
|
|
121
|
+
Live write/lock inputs resolve their parent directories once, keeping
|
|
122
|
+
the final entry (including symlinks) distinct. All producers then share
|
|
123
|
+
the earliest snapshot and hash chain. Persisted paths and snapshot keys
|
|
124
|
+
remain lexical: later link retargeting must not reinterpret history.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(self, store: SnapshotStore) -> None:
|
|
128
|
+
self._store = store
|
|
129
|
+
self._log = MutationLog()
|
|
130
|
+
# Tracks which (path, turn_id) pairs have been snapshotted
|
|
131
|
+
self._snapshotted: set[str] = set() # uses MutationLog.snapshot_key
|
|
132
|
+
# Last-known content hash per normalized path within the current turn.
|
|
133
|
+
# Cleared on start_turn(). Enables accurate per-mutation before_hash
|
|
134
|
+
# when the same file is mutated multiple times in one turn.
|
|
135
|
+
self._last_known_hash: dict[str, str | None] = {}
|
|
136
|
+
# Companion to _last_known_hash: why the last-known content backup
|
|
137
|
+
# was withheld (SnapshotPolicy skip), or None when the hash is real
|
|
138
|
+
# or the file was absent. Keyed/cleared identically.
|
|
139
|
+
self._last_known_skip: dict[str, SnapshotSkipReason | None] = {}
|
|
140
|
+
# Per-file asyncio locks for serializing concurrent mutations to the
|
|
141
|
+
# same file. The Chrys tool loop dispatches parallel tool calls via
|
|
142
|
+
# asyncio.gather(); these locks ensure that record() → call_next() →
|
|
143
|
+
# record_after() for the same file execute sequentially, keeping the
|
|
144
|
+
# _last_known_hash chain accurate. Acquired/released from the event
|
|
145
|
+
# loop thread only (never from run_in_executor threads).
|
|
146
|
+
self._file_locks: dict[str, asyncio.Lock] = {}
|
|
147
|
+
# Serializes shell / implicit-detection windows inside a session so
|
|
148
|
+
# before/after observers do not attribute another concurrent shell or
|
|
149
|
+
# skill subprocess to the current tool call.
|
|
150
|
+
self._implicit_window_lock = asyncio.Lock()
|
|
151
|
+
# Protects tracker state because mutation finalization still performs
|
|
152
|
+
# some recording work from executor threads. Keep asyncio locks above
|
|
153
|
+
# event-loop-only; this lock guards plain Python state and blob refs.
|
|
154
|
+
self._state_lock = RLock()
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def log(self) -> MutationLog:
|
|
158
|
+
"""The underlying mutation log (read-only access)."""
|
|
159
|
+
return self._log
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def store(self) -> SnapshotStore:
|
|
163
|
+
"""The snapshot store backing this tracker."""
|
|
164
|
+
return self._store
|
|
165
|
+
|
|
166
|
+
def with_locked_log(self, fn: Callable[[MutationLog], _T]) -> _T:
|
|
167
|
+
"""Run ``fn`` on the log under the tracker's state lock.
|
|
168
|
+
|
|
169
|
+
Generic synchronization seam for components that read or
|
|
170
|
+
re-attribute log rows without the tracker knowing about them
|
|
171
|
+
(cross-session coordination reclassifies provenance this way —
|
|
172
|
+
the tracker itself stays coordination-unaware). Re-entering
|
|
173
|
+
tracker methods from ``fn`` is safe (RLock), but ``fn`` must
|
|
174
|
+
not block on I/O while holding the lock.
|
|
175
|
+
"""
|
|
176
|
+
with self._state_lock:
|
|
177
|
+
return fn(self._log)
|
|
178
|
+
|
|
179
|
+
# -- Turn lifecycle --
|
|
180
|
+
|
|
181
|
+
def start_turn(self, turn_id: int) -> None:
|
|
182
|
+
"""Begin tracking a new agent turn.
|
|
183
|
+
|
|
184
|
+
Must be called at the start of each ``agent.run()`` cycle,
|
|
185
|
+
even if no file mutations are expected. This ensures the
|
|
186
|
+
turn timeline is continuous for multi-step rollback.
|
|
187
|
+
|
|
188
|
+
Clears ``_last_known_hash`` so each turn starts fresh —
|
|
189
|
+
the first mutation per file reads from disk.
|
|
190
|
+
"""
|
|
191
|
+
with self._state_lock:
|
|
192
|
+
self._log.require_chat() # Validate the Chat-only lifecycle boundary.
|
|
193
|
+
self._log.periods.append(TurnMutations(turn_id=turn_id))
|
|
194
|
+
self._last_known_hash.clear()
|
|
195
|
+
self._last_known_skip.clear()
|
|
196
|
+
|
|
197
|
+
def start_workflow_run(self, run_id: str) -> None:
|
|
198
|
+
"""Open one mutation period shared by every node and retry in this run.
|
|
199
|
+
|
|
200
|
+
The ordinal belongs to the snapshot ledger, not to a Chat turn. Persist
|
|
201
|
+
the run identity beside it so history can select a run without guessing
|
|
202
|
+
from the position of a node or an attempt.
|
|
203
|
+
"""
|
|
204
|
+
with self._state_lock:
|
|
205
|
+
if not run_id or any(isinstance(period, TurnMutations) for period in self._log.periods):
|
|
206
|
+
raise ValueError("A workflow run requires a Workflow mutation ledger and a nonempty run_id")
|
|
207
|
+
if any(period.scope == WorkflowRunScope(run_id) for period in self._log.periods):
|
|
208
|
+
raise ValueError(f"Mutation period already exists for run {run_id}")
|
|
209
|
+
ordinal = max((period.period_index for period in self._log.periods), default=0) + 1
|
|
210
|
+
self._log.periods.append(RunMutations(run_id=run_id, period_index=ordinal))
|
|
211
|
+
self._last_known_hash.clear()
|
|
212
|
+
self._last_known_skip.clear()
|
|
213
|
+
|
|
214
|
+
def reset_file_cache(self) -> None:
|
|
215
|
+
"""Clear per-file hash cache without starting a new turn.
|
|
216
|
+
|
|
217
|
+
Used by retry/resume so subsequent mutations read fresh content
|
|
218
|
+
from disk while still recording under the same turn.
|
|
219
|
+
"""
|
|
220
|
+
with self._state_lock:
|
|
221
|
+
self._last_known_hash.clear()
|
|
222
|
+
self._last_known_skip.clear()
|
|
223
|
+
|
|
224
|
+
def mark_detection_truncated(self) -> None:
|
|
225
|
+
"""Record that the active turn's implicit detection was incomplete."""
|
|
226
|
+
with self._state_lock:
|
|
227
|
+
if self._log.periods:
|
|
228
|
+
self._log.periods[-1].detection_truncated = True
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def current_period(self) -> MutationPeriod | None:
|
|
232
|
+
"""The active execution period, shared by all its tools and nodes."""
|
|
233
|
+
with self._state_lock:
|
|
234
|
+
return self._log.periods[-1] if self._log.periods else None
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def current_turn(self) -> TurnMutations | None:
|
|
238
|
+
"""Chat-only accessor; Workflow callers use current_period."""
|
|
239
|
+
with self._state_lock:
|
|
240
|
+
turns = self._log.turns
|
|
241
|
+
return turns[-1] if turns else None
|
|
242
|
+
|
|
243
|
+
def get_file_lock(self, path: str) -> asyncio.Lock:
|
|
244
|
+
"""Get an asyncio lock for serializing concurrent mutations to a file.
|
|
245
|
+
|
|
246
|
+
The Chrys tool loop dispatches parallel tool calls via
|
|
247
|
+
``asyncio.gather()``. Callers (middleware) acquire this lock
|
|
248
|
+
before ``record()`` and release after ``record_after()`` to
|
|
249
|
+
ensure the ``_last_known_hash`` chain stays accurate.
|
|
250
|
+
|
|
251
|
+
Must be called from the event loop thread, never from
|
|
252
|
+
``run_in_executor`` threads.
|
|
253
|
+
"""
|
|
254
|
+
norm = entry_display_path(path)
|
|
255
|
+
with self._state_lock:
|
|
256
|
+
if norm not in self._file_locks:
|
|
257
|
+
self._file_locks[norm] = asyncio.Lock()
|
|
258
|
+
return self._file_locks[norm]
|
|
259
|
+
|
|
260
|
+
def get_implicit_window_lock(self) -> asyncio.Lock:
|
|
261
|
+
"""Lock for serializing shell and implicit mutation observation windows."""
|
|
262
|
+
return self._implicit_window_lock
|
|
263
|
+
|
|
264
|
+
# -- Recording mutations --
|
|
265
|
+
|
|
266
|
+
def record(
|
|
267
|
+
self,
|
|
268
|
+
path: str,
|
|
269
|
+
operation: MutationOp,
|
|
270
|
+
source: MutationSource,
|
|
271
|
+
tool_call_id: str,
|
|
272
|
+
*,
|
|
273
|
+
old_path: str | None = None,
|
|
274
|
+
provenance: MutationProvenance | None = None,
|
|
275
|
+
) -> FileMutation | None:
|
|
276
|
+
"""Record a file mutation.
|
|
277
|
+
|
|
278
|
+
**Calling convention**:
|
|
279
|
+
|
|
280
|
+
- For file tools (``write_file``, ``edit_file``): call BEFORE
|
|
281
|
+
the actual write, then call :meth:`record_after` when done.
|
|
282
|
+
- For shell commands: call :meth:`pre_snapshot` BEFORE execution,
|
|
283
|
+
then call ``record()`` AFTER execution for each detected change
|
|
284
|
+
(no ``record_after`` needed).
|
|
285
|
+
|
|
286
|
+
The turn-level snapshot (for rollback) is taken once per (path,
|
|
287
|
+
turn) via ``_ensure_snapshot``. The per-mutation ``before_hash``
|
|
288
|
+
reflects the *actual current state* of the file — using
|
|
289
|
+
``_last_known_hash`` if the file was already mutated earlier in
|
|
290
|
+
this turn, or falling back to the turn-level snapshot hash.
|
|
291
|
+
|
|
292
|
+
``provenance`` refines attribution beyond the source-derived
|
|
293
|
+
default (``None``) — e.g. a trace-confirmed shell write records
|
|
294
|
+
as ``PROVEN``.
|
|
295
|
+
|
|
296
|
+
Returns the :class:`FileMutation` or ``None`` if no active turn.
|
|
297
|
+
"""
|
|
298
|
+
with self._state_lock:
|
|
299
|
+
turn = self._log.periods[-1] if self._log.periods else None
|
|
300
|
+
if turn is None:
|
|
301
|
+
logger.warning("MutationTracker.record() called with no active turn")
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
norm = entry_display_path(path)
|
|
305
|
+
|
|
306
|
+
# Shell mutations call ``record()`` AFTER execution — reading from
|
|
307
|
+
# disk now (via ``_ensure_snapshot``) would capture post-exec state
|
|
308
|
+
# as the turn-start snapshot. For CREATEs the scanner discovered
|
|
309
|
+
# without a pre-snapshot (e.g. a file inside a newly-created dir),
|
|
310
|
+
# synthesise the correct ``existed=False`` turn-start directly;
|
|
311
|
+
# otherwise the mutation's before_hash == after_hash and the entry
|
|
312
|
+
# drops out of rollback-diff aggregation as net-zero.
|
|
313
|
+
snap_key = MutationLog.snapshot_key(norm, turn.period_index)
|
|
314
|
+
chain_severed = snap_key in self._snapshotted and norm not in self._last_known_hash
|
|
315
|
+
if source == MutationSource.SHELL and operation == MutationOp.CREATE and snap_key not in self._snapshotted:
|
|
316
|
+
snap = self._register_snapshot(
|
|
317
|
+
FileSnapshot(path=norm, period_index=turn.period_index, existed=False), key=snap_key
|
|
318
|
+
)
|
|
319
|
+
else:
|
|
320
|
+
snap = self._ensure_snapshot(norm, turn.period_index, key=snap_key)
|
|
321
|
+
|
|
322
|
+
# before_hash: use last known hash if the file was already mutated
|
|
323
|
+
# earlier in this turn; otherwise fall back to the turn-level snapshot.
|
|
324
|
+
if norm in self._last_known_hash:
|
|
325
|
+
before_hash = self._last_known_hash[norm]
|
|
326
|
+
before_skip = self._last_known_skip.get(norm)
|
|
327
|
+
elif chain_severed and source != MutationSource.SHELL:
|
|
328
|
+
# Snapshotted this turn but the content chain was cleared
|
|
329
|
+
# (retry/resume) or never completed (an interrupted write):
|
|
330
|
+
# the snapshot may predate earlier same-turn mutations.
|
|
331
|
+
# File-tool records run pre-write, so the true before
|
|
332
|
+
# endpoint is the current disk content. Shell records run
|
|
333
|
+
# post-execution — too late to observe it — and rely on
|
|
334
|
+
# pre_snapshot having reseeded the chain instead.
|
|
335
|
+
current = self._store.save_blob(norm)
|
|
336
|
+
before_hash = current.content_hash
|
|
337
|
+
before_skip = current.skip_reason
|
|
338
|
+
else:
|
|
339
|
+
before_hash = snap.content_hash if snap else None
|
|
340
|
+
before_skip = snap.skip_reason if snap else None
|
|
341
|
+
|
|
342
|
+
# For MOVE, also snapshot the old path
|
|
343
|
+
if old_path is not None:
|
|
344
|
+
old_norm = entry_display_path(old_path)
|
|
345
|
+
self._ensure_snapshot(old_norm, turn.period_index)
|
|
346
|
+
else:
|
|
347
|
+
old_norm = None
|
|
348
|
+
|
|
349
|
+
mutation = FileMutation(
|
|
350
|
+
path=norm,
|
|
351
|
+
operation=operation,
|
|
352
|
+
source=source,
|
|
353
|
+
tool_call_id=tool_call_id,
|
|
354
|
+
timestamp=time.time(),
|
|
355
|
+
old_path=old_norm,
|
|
356
|
+
before_hash=before_hash,
|
|
357
|
+
before_skip=before_skip,
|
|
358
|
+
provenance=provenance,
|
|
359
|
+
)
|
|
360
|
+
turn.mutations.append(mutation)
|
|
361
|
+
|
|
362
|
+
# For shell mutations (recorded post-execution): capture the
|
|
363
|
+
# post-execution state as after_hash and update last known hash.
|
|
364
|
+
# File-tool mutations set after_hash via record_after() instead.
|
|
365
|
+
if source == MutationSource.SHELL:
|
|
366
|
+
self.record_after(mutation)
|
|
367
|
+
|
|
368
|
+
return mutation
|
|
369
|
+
|
|
370
|
+
def record_after(self, mutation: FileMutation) -> None:
|
|
371
|
+
"""Snapshot the file AFTER mutation and store the hash.
|
|
372
|
+
|
|
373
|
+
Called after a tool (write_file, edit_file) completes to
|
|
374
|
+
capture the post-mutation content for session replay diffs.
|
|
375
|
+
|
|
376
|
+
Also updates ``_last_known_hash`` so subsequent mutations to
|
|
377
|
+
the same file within this turn get an accurate ``before_hash``.
|
|
378
|
+
"""
|
|
379
|
+
with self._state_lock:
|
|
380
|
+
after = self._store.save_blob(mutation.path)
|
|
381
|
+
mutation.after_hash = after.content_hash
|
|
382
|
+
mutation.after_skip = after.skip_reason
|
|
383
|
+
# A detector's DELETE is a candidate, not proof of absence:
|
|
384
|
+
# refs can move without touching the worktree, and filesystem
|
|
385
|
+
# probes can fail. Only an observed missing endpoint is deleted.
|
|
386
|
+
if mutation.operation is MutationOp.DELETE and (
|
|
387
|
+
after.content_hash is not None or after.skip_reason is not None
|
|
388
|
+
):
|
|
389
|
+
mutation.operation = MutationOp.MODIFY
|
|
390
|
+
if SnapshotSkipReason.UNREADABLE in (mutation.before_skip, mutation.after_skip):
|
|
391
|
+
self.mark_detection_truncated()
|
|
392
|
+
# Update last known state for this file
|
|
393
|
+
norm = os.path.normpath(os.path.abspath(mutation.path))
|
|
394
|
+
self._last_known_hash[norm] = after.content_hash
|
|
395
|
+
self._last_known_skip[norm] = after.skip_reason
|
|
396
|
+
|
|
397
|
+
def pre_snapshot(self, paths: list[str]) -> None:
|
|
398
|
+
"""Snapshot files before a shell command that may modify them.
|
|
399
|
+
|
|
400
|
+
Called with paths identified by :class:`ShellMutationDetector` or
|
|
401
|
+
:class:`WorkspaceScanner` before shell execution. The actual
|
|
402
|
+
mutation recording happens after execution via :meth:`record`.
|
|
403
|
+
|
|
404
|
+
Also initializes ``_last_known_hash`` for files not yet tracked
|
|
405
|
+
in this turn, so that post-execution ``record()`` calls get
|
|
406
|
+
accurate ``before_hash`` values.
|
|
407
|
+
"""
|
|
408
|
+
with self._state_lock:
|
|
409
|
+
turn = self._log.periods[-1] if self._log.periods else None
|
|
410
|
+
if turn is None:
|
|
411
|
+
return
|
|
412
|
+
for path in paths:
|
|
413
|
+
norm = entry_display_path(path)
|
|
414
|
+
snap_key = MutationLog.snapshot_key(norm, turn.period_index)
|
|
415
|
+
chain_severed = snap_key in self._snapshotted and norm not in self._last_known_hash
|
|
416
|
+
snap = self._ensure_snapshot(norm, turn.period_index, key=snap_key)
|
|
417
|
+
# Initialize last known hash if this is the first time we see
|
|
418
|
+
# this file in the current turn. Uses the snapshot hash
|
|
419
|
+
# (avoids a redundant file read since _ensure_snapshot already
|
|
420
|
+
# read and hashed the content).
|
|
421
|
+
if norm not in self._last_known_hash:
|
|
422
|
+
if chain_severed:
|
|
423
|
+
# Snapshotted this turn but the content chain was
|
|
424
|
+
# cleared (retry/resume) or never completed (an
|
|
425
|
+
# interrupted write): the snapshot may predate
|
|
426
|
+
# earlier same-turn mutations, so the chain must
|
|
427
|
+
# restart from what is on disk right now. The
|
|
428
|
+
# snapshot stays the rollback anchor untouched.
|
|
429
|
+
current = self._store.save_blob(norm)
|
|
430
|
+
self._last_known_hash[norm] = current.content_hash
|
|
431
|
+
self._last_known_skip[norm] = current.skip_reason
|
|
432
|
+
else:
|
|
433
|
+
self._last_known_hash[norm] = snap.content_hash if snap else None
|
|
434
|
+
self._last_known_skip[norm] = snap.skip_reason if snap else None
|
|
435
|
+
|
|
436
|
+
def record_calibrated(
|
|
437
|
+
self,
|
|
438
|
+
path: str,
|
|
439
|
+
operation: MutationOp,
|
|
440
|
+
before_data: bytes | None,
|
|
441
|
+
tool_call_id: str = "git_calibration",
|
|
442
|
+
*,
|
|
443
|
+
provenance: MutationProvenance | None = None,
|
|
444
|
+
before_skip: SnapshotSkipReason | None = None,
|
|
445
|
+
before_size: int | None = None,
|
|
446
|
+
before_symlink: bool = False,
|
|
447
|
+
before_symlink_dir: bool | None = None,
|
|
448
|
+
) -> FileMutation | None:
|
|
449
|
+
"""Record an implicit mutation detected by git calibration at turn end.
|
|
450
|
+
|
|
451
|
+
Unlike :meth:`record`, this is called *after* the turn completes
|
|
452
|
+
with explicit before-content (typically from ``git show HEAD:path``).
|
|
453
|
+
The before endpoint is resolved in priority order:
|
|
454
|
+
|
|
455
|
+
- the ``_last_known_hash`` chain (content already tracked this
|
|
456
|
+
turn) — same as :meth:`record` — so a path edited by a tool and
|
|
457
|
+
then by an opaque shell command does not re-attribute the tool
|
|
458
|
+
edit to this command;
|
|
459
|
+
- fresh *before_data*: after a retry severs the chain, the old-HEAD
|
|
460
|
+
blob is this mutation's true before endpoint, even when the path
|
|
461
|
+
already has a turn snapshot from the earlier attempt;
|
|
462
|
+
- *before_skip* (policy withheld the oversized old blob): the event
|
|
463
|
+
is recorded with the skip reason, only the backup is missing;
|
|
464
|
+
- the existing turn snapshot as post-hoc fallback; and
|
|
465
|
+
- the nonexistent endpoint of a Git-confirmed CREATE.
|
|
466
|
+
|
|
467
|
+
The turn-start snapshot stays the rollback anchor in every branch:
|
|
468
|
+
it is registered only when the (path, turn) key has none yet, never
|
|
469
|
+
rewritten. Sets both ``before_hash`` and ``after_hash`` on the
|
|
470
|
+
mutation.
|
|
471
|
+
"""
|
|
472
|
+
with self._state_lock:
|
|
473
|
+
turn = self._log.periods[-1] if self._log.periods else None
|
|
474
|
+
if turn is None:
|
|
475
|
+
return None
|
|
476
|
+
|
|
477
|
+
norm = entry_display_path(path)
|
|
478
|
+
snap_key = MutationLog.snapshot_key(norm, turn.period_index)
|
|
479
|
+
|
|
480
|
+
if norm in self._last_known_hash:
|
|
481
|
+
# Content already tracked this turn: chain from the last
|
|
482
|
+
# known state so this row's diff starts where the previous
|
|
483
|
+
# same-turn mutation ended, not at the turn-start snapshot.
|
|
484
|
+
before_hash = self._last_known_hash[norm]
|
|
485
|
+
before_skip = self._last_known_skip.get(norm)
|
|
486
|
+
elif before_data is not None:
|
|
487
|
+
# Fresh Git evidence beats the turn snapshot: with the chain
|
|
488
|
+
# severed by a retry, the old-HEAD blob is this mutation's
|
|
489
|
+
# true before endpoint.
|
|
490
|
+
before_blob = self._store.save_data_as_blob(before_data)
|
|
491
|
+
before_hash = before_blob.content_hash
|
|
492
|
+
before_skip = before_blob.skip_reason
|
|
493
|
+
if snap_key not in self._snapshotted:
|
|
494
|
+
self._register_snapshot(
|
|
495
|
+
FileSnapshot(
|
|
496
|
+
path=norm,
|
|
497
|
+
period_index=turn.period_index,
|
|
498
|
+
existed=True,
|
|
499
|
+
content_hash=before_hash,
|
|
500
|
+
size=len(before_data),
|
|
501
|
+
skip_reason=before_skip,
|
|
502
|
+
is_symlink=before_symlink,
|
|
503
|
+
# The kind is old-tree evidence supplied by the
|
|
504
|
+
# calibrator — the live filesystem is post-change
|
|
505
|
+
# and proves nothing about the old endpoint.
|
|
506
|
+
symlink_target_is_dir=before_symlink_dir if before_symlink else None,
|
|
507
|
+
),
|
|
508
|
+
key=snap_key,
|
|
509
|
+
)
|
|
510
|
+
elif before_skip is not None:
|
|
511
|
+
# Policy withheld the before content (oversized old blob):
|
|
512
|
+
# record the event with the skip reason; only the content
|
|
513
|
+
# backup is missing.
|
|
514
|
+
before_hash = None
|
|
515
|
+
if snap_key not in self._snapshotted:
|
|
516
|
+
self._register_snapshot(
|
|
517
|
+
FileSnapshot(
|
|
518
|
+
path=norm,
|
|
519
|
+
period_index=turn.period_index,
|
|
520
|
+
existed=True,
|
|
521
|
+
size=before_size or 0,
|
|
522
|
+
skip_reason=before_skip,
|
|
523
|
+
is_symlink=before_symlink,
|
|
524
|
+
),
|
|
525
|
+
key=snap_key,
|
|
526
|
+
)
|
|
527
|
+
elif snap_key in self._snapshotted:
|
|
528
|
+
# Snapshotted this turn but no fresh evidence for this pass:
|
|
529
|
+
# calibration runs post-hoc, so the snapshot is the only
|
|
530
|
+
# before endpoint left.
|
|
531
|
+
snap = self._log.snapshots.get(snap_key)
|
|
532
|
+
before_hash = snap.content_hash if snap else None
|
|
533
|
+
before_skip = snap.skip_reason if snap else None
|
|
534
|
+
else:
|
|
535
|
+
# No before-data and no pre-snapshot (untracked new file)
|
|
536
|
+
before_hash = None
|
|
537
|
+
before_skip = None
|
|
538
|
+
self._register_snapshot(
|
|
539
|
+
FileSnapshot(path=norm, period_index=turn.period_index, existed=False),
|
|
540
|
+
key=snap_key,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
mutation = FileMutation(
|
|
544
|
+
path=norm,
|
|
545
|
+
operation=operation,
|
|
546
|
+
source=MutationSource.IMPLICIT,
|
|
547
|
+
tool_call_id=tool_call_id,
|
|
548
|
+
timestamp=time.time(),
|
|
549
|
+
before_hash=before_hash,
|
|
550
|
+
before_skip=before_skip,
|
|
551
|
+
provenance=provenance,
|
|
552
|
+
)
|
|
553
|
+
self.record_after(mutation)
|
|
554
|
+
turn.mutations.append(mutation)
|
|
555
|
+
return mutation
|
|
556
|
+
|
|
557
|
+
def get_changed_files_set(self) -> set[str]:
|
|
558
|
+
"""Normalized absolute paths of all files mutated in the current turn.
|
|
559
|
+
|
|
560
|
+
``FOREIGN`` rows are excluded: a path demoted to a peer session
|
|
561
|
+
must stay eligible for fresh local detection (git calibration's
|
|
562
|
+
already-tracked skip), or a later real local change on it would
|
|
563
|
+
be silently dropped as "already tracked".
|
|
564
|
+
"""
|
|
565
|
+
with self._state_lock:
|
|
566
|
+
turn = self._log.periods[-1] if self._log.periods else None
|
|
567
|
+
if turn is None:
|
|
568
|
+
return set()
|
|
569
|
+
paths: set[str] = set()
|
|
570
|
+
for m in turn.mutations:
|
|
571
|
+
if m.provenance is MutationProvenance.FOREIGN:
|
|
572
|
+
continue
|
|
573
|
+
paths.add(m.path)
|
|
574
|
+
if m.old_path:
|
|
575
|
+
paths.add(m.old_path)
|
|
576
|
+
return paths
|
|
577
|
+
|
|
578
|
+
def cleanup_unused_snapshots(self) -> int:
|
|
579
|
+
"""Remove snapshots from the current turn that have no corresponding mutations.
|
|
580
|
+
|
|
581
|
+
Pre-snapshots are taken eagerly before shell commands for all git-dirty
|
|
582
|
+
files, in case the command modifies them. If no mutation is recorded
|
|
583
|
+
for a file, its snapshot is wasted. This method removes those orphans
|
|
584
|
+
and deletes any blob files that are no longer referenced.
|
|
585
|
+
|
|
586
|
+
Returns the number of snapshots removed.
|
|
587
|
+
"""
|
|
588
|
+
with self._state_lock:
|
|
589
|
+
turn = self._log.periods[-1] if self._log.periods else None
|
|
590
|
+
if turn is None:
|
|
591
|
+
return 0
|
|
592
|
+
|
|
593
|
+
# Paths that have actual mutations in this turn
|
|
594
|
+
mutated_paths: set[str] = set()
|
|
595
|
+
for m in turn.mutations:
|
|
596
|
+
mutated_paths.add(m.path)
|
|
597
|
+
if m.old_path:
|
|
598
|
+
mutated_paths.add(m.old_path)
|
|
599
|
+
|
|
600
|
+
# Find snapshots for this turn whose paths have no mutations
|
|
601
|
+
orphan_keys: list[str] = []
|
|
602
|
+
orphan_hashes: set[str] = set()
|
|
603
|
+
for key, snap in self._log.snapshots.items():
|
|
604
|
+
if snap.period_index == turn.period_index and snap.path not in mutated_paths:
|
|
605
|
+
orphan_keys.append(key)
|
|
606
|
+
if snap.content_hash:
|
|
607
|
+
orphan_hashes.add(snap.content_hash)
|
|
608
|
+
|
|
609
|
+
if not orphan_keys:
|
|
610
|
+
return 0
|
|
611
|
+
|
|
612
|
+
# Remove orphan snapshot entries
|
|
613
|
+
for key in orphan_keys:
|
|
614
|
+
del self._log.snapshots[key]
|
|
615
|
+
self._snapshotted.discard(key)
|
|
616
|
+
|
|
617
|
+
# Only delete blobs not referenced by any remaining snapshot or mutation
|
|
618
|
+
if orphan_hashes:
|
|
619
|
+
still_referenced = self._all_referenced_hashes()
|
|
620
|
+
truly_orphaned = orphan_hashes - still_referenced
|
|
621
|
+
if truly_orphaned:
|
|
622
|
+
self._store.remove_blobs(truly_orphaned)
|
|
623
|
+
|
|
624
|
+
logger.debug(
|
|
625
|
+
"cleanup_unused_snapshots: removed %d orphan snapshots for turn %d", len(orphan_keys), turn.period_index
|
|
626
|
+
)
|
|
627
|
+
return len(orphan_keys)
|
|
628
|
+
|
|
629
|
+
def _ensure_snapshot(self, norm_path: str, turn_id: int, *, key: str | None = None) -> FileSnapshot | None:
|
|
630
|
+
"""Snapshot a file if not already done for this (path, turn).
|
|
631
|
+
|
|
632
|
+
``key`` is an optional caller-computed ``MutationLog.snapshot_key`` —
|
|
633
|
+
pass it through to avoid recomputing the same string on the hot
|
|
634
|
+
path (``record()`` already has it in hand).
|
|
635
|
+
"""
|
|
636
|
+
if key is None:
|
|
637
|
+
key = MutationLog.snapshot_key(norm_path, turn_id)
|
|
638
|
+
if key not in self._snapshotted:
|
|
639
|
+
return self._register_snapshot(self._store.save(norm_path, turn_id), key=key)
|
|
640
|
+
return self._log.snapshots.get(key)
|
|
641
|
+
|
|
642
|
+
def _register_snapshot(self, snapshot: FileSnapshot, *, key: str | None = None) -> FileSnapshot:
|
|
643
|
+
"""Insert a snapshot into the log and mark its (path, turn) key seen.
|
|
644
|
+
|
|
645
|
+
Single bookkeeping primitive shared by ``_ensure_snapshot`` (disk
|
|
646
|
+
read), the post-exec CREATE guard in ``record()`` (synthetic
|
|
647
|
+
``existed=False``), and ``record_calibrated`` (git-HEAD blob).
|
|
648
|
+
"""
|
|
649
|
+
if key is None:
|
|
650
|
+
key = MutationLog.snapshot_key(snapshot.path, snapshot.period_index)
|
|
651
|
+
self._log.snapshots[key] = snapshot
|
|
652
|
+
self._snapshotted.add(key)
|
|
653
|
+
if snapshot.skip_reason is SnapshotSkipReason.UNREADABLE:
|
|
654
|
+
self.mark_detection_truncated()
|
|
655
|
+
return snapshot
|
|
656
|
+
|
|
657
|
+
# -- Query methods --
|
|
658
|
+
|
|
659
|
+
def get_period(self, scope: MutationScope) -> MutationPeriod:
|
|
660
|
+
"""Resolve an exact execution identity, refusing unknown targets."""
|
|
661
|
+
with self._state_lock:
|
|
662
|
+
for period in self._log.periods:
|
|
663
|
+
if period.scope == scope:
|
|
664
|
+
return period
|
|
665
|
+
raise KeyError(f"Unknown mutation scope: {scope}")
|
|
666
|
+
|
|
667
|
+
def get_all_periods(self) -> list[MutationPeriod]:
|
|
668
|
+
"""All execution periods in ledger order, including empty periods."""
|
|
669
|
+
with self._state_lock:
|
|
670
|
+
return list(self._log.periods)
|
|
671
|
+
|
|
672
|
+
def get_turn_mutations(self, turn_id: int) -> TurnMutations | None:
|
|
673
|
+
"""Chat-only query retained for conversation consumers."""
|
|
674
|
+
with self._state_lock:
|
|
675
|
+
return next((turn for turn in self._log.turns if turn.turn_id == turn_id), None)
|
|
676
|
+
|
|
677
|
+
def get_all_turns(self) -> list[TurnMutations]:
|
|
678
|
+
"""Chat-only periods; runs cannot be mistaken for conversation turns."""
|
|
679
|
+
with self._state_lock:
|
|
680
|
+
return list(self._log.turns)
|
|
681
|
+
|
|
682
|
+
def get_changed_files(self) -> list[str]:
|
|
683
|
+
"""Unique file paths mutated across all turns (ordered by first mutation)."""
|
|
684
|
+
with self._state_lock:
|
|
685
|
+
seen: dict[str, None] = {}
|
|
686
|
+
for turn in self._log.periods:
|
|
687
|
+
for m in turn.mutations:
|
|
688
|
+
seen.setdefault(m.path, None)
|
|
689
|
+
if m.old_path:
|
|
690
|
+
seen.setdefault(m.old_path, None)
|
|
691
|
+
return list(seen)
|
|
692
|
+
|
|
693
|
+
def get_original_snapshot(self, path: str) -> FileSnapshot | None:
|
|
694
|
+
"""Get the *earliest* snapshot for a file — its pre-session state.
|
|
695
|
+
|
|
696
|
+
Used by ``/diff`` to compare original state vs current disk.
|
|
697
|
+
"""
|
|
698
|
+
with self._state_lock:
|
|
699
|
+
norm = os.path.normpath(os.path.abspath(path))
|
|
700
|
+
# Prefer an already recorded identity; resolve only a new query
|
|
701
|
+
# route, never rewrite a saved path after directory retargeting.
|
|
702
|
+
if not any(snap.path == norm for snap in self._log.snapshots.values()):
|
|
703
|
+
norm = entry_display_path(path)
|
|
704
|
+
earliest: FileSnapshot | None = None
|
|
705
|
+
for snap in self._log.snapshots.values():
|
|
706
|
+
if snap.path == norm and (earliest is None or snap.period_index < earliest.period_index):
|
|
707
|
+
earliest = snap
|
|
708
|
+
return earliest
|
|
709
|
+
|
|
710
|
+
def get_snapshot(self, path: str, turn_id: int) -> FileSnapshot | None:
|
|
711
|
+
"""Chat-only snapshot lookup; runs use get_period_snapshot."""
|
|
712
|
+
with self._state_lock:
|
|
713
|
+
self._log.require_chat()
|
|
714
|
+
return self._snapshot_at_index(path, turn_id)
|
|
715
|
+
|
|
716
|
+
def get_period_snapshot(self, path: str, scope: MutationScope) -> FileSnapshot | None:
|
|
717
|
+
"""Get the file's pre-execution snapshot using an exact Turn/Run ID."""
|
|
718
|
+
with self._state_lock:
|
|
719
|
+
return self._snapshot_at_index(path, self.get_period(scope).period_index)
|
|
720
|
+
|
|
721
|
+
def _snapshot_at_index(self, path: str, period_index: int) -> FileSnapshot | None:
|
|
722
|
+
with self._state_lock:
|
|
723
|
+
key = MutationLog.snapshot_key(path, period_index)
|
|
724
|
+
if key not in self._log.snapshots:
|
|
725
|
+
key = MutationLog.snapshot_key(entry_display_path(path), period_index)
|
|
726
|
+
return self._log.snapshots.get(key)
|
|
727
|
+
|
|
728
|
+
def get_period_file_summary(self, scope: MutationScope) -> dict[str, FileHashDiff]:
|
|
729
|
+
"""Net changes made by this exact Turn or Run, independent of later edits."""
|
|
730
|
+
with self._state_lock:
|
|
731
|
+
return self._period_file_summary(self.get_period(scope).period_index)
|
|
732
|
+
|
|
733
|
+
def get_turn_file_summary(self, turn_id: int) -> dict[str, FileHashDiff]:
|
|
734
|
+
"""Chat-only file summary for the existing conversation interfaces."""
|
|
735
|
+
with self._state_lock:
|
|
736
|
+
turn = self.get_turn_mutations(turn_id)
|
|
737
|
+
return {} if turn is None else self._period_file_summary(turn.period_index)
|
|
738
|
+
|
|
739
|
+
def _period_file_summary(self, period_index: int) -> dict[str, FileHashDiff]:
|
|
740
|
+
"""Fold every touched path, including both ends of a move."""
|
|
741
|
+
with self._state_lock:
|
|
742
|
+
periods = [period for period in self._log.periods if period.period_index == period_index]
|
|
743
|
+
return self._summarize_periods(periods)
|
|
744
|
+
|
|
745
|
+
def get_session_file_summary(self) -> dict[str, FileHashDiff]:
|
|
746
|
+
"""Return per-file ``{path: FileHashDiff}`` aggregated across all turns.
|
|
747
|
+
|
|
748
|
+
- ``before``: file state at the start of the *first* turn that
|
|
749
|
+
touched the file (i.e. its pre-session state). ``None`` means
|
|
750
|
+
the file did not exist before the session.
|
|
751
|
+
- ``after``: file state after the *last* mutation to that file
|
|
752
|
+
across the entire session. ``None`` means the file was deleted
|
|
753
|
+
(or ``record_after()`` was never called, e.g. for shell
|
|
754
|
+
mutations).
|
|
755
|
+
|
|
756
|
+
MOVE mutations (``m.old_path`` set) contribute an implicit
|
|
757
|
+
DELETE on the old path — consistent with ``get_changed_files``
|
|
758
|
+
and ``get_rollback_plan``.
|
|
759
|
+
|
|
760
|
+
Entries with ``is_net_zero`` (no provable net change — hashes
|
|
761
|
+
match and existence didn't flip) are excluded. This gives the
|
|
762
|
+
true session-wide before/after state for every file that was
|
|
763
|
+
meaningfully modified. Skip-marked creates/deletes ARE included
|
|
764
|
+
(existence changed even though both hashes read ``None``); the
|
|
765
|
+
fully-skipped modify is the one skip case that stays excluded.
|
|
766
|
+
|
|
767
|
+
Turns are iterated in ``turn_id`` order so "earliest before" and
|
|
768
|
+
"latest after" are chronological, not insertion-order — session
|
|
769
|
+
restore / retry paths can append turns with non-monotonic ids.
|
|
770
|
+
|
|
771
|
+
``FOREIGN`` rows are excluded from the fold — see
|
|
772
|
+
:meth:`get_turn_file_summary` — and folded ``contested`` /
|
|
773
|
+
``inferred`` badges are set on the surviving entries.
|
|
774
|
+
"""
|
|
775
|
+
with self._state_lock:
|
|
776
|
+
return {
|
|
777
|
+
path: diff for path, diff in self._summarize_periods(self._log.periods).items() if not diff.is_net_zero
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
def _summarize_periods(self, periods: list[MutationPeriod]) -> dict[str, FileHashDiff]:
|
|
781
|
+
"""One fold for both period and session summaries, including MOVE sources."""
|
|
782
|
+
with self._state_lock:
|
|
783
|
+
result: dict[str, FileHashDiff] = {}
|
|
784
|
+
badges = _ProvenanceBadges()
|
|
785
|
+
|
|
786
|
+
def _set_before_if_first(path: str, turn_id: int) -> tuple[str | None, SnapshotSkipReason | None]:
|
|
787
|
+
"""Record earliest before_hash/skip for ``path``; return them."""
|
|
788
|
+
if path not in result:
|
|
789
|
+
snap_key = MutationLog.snapshot_key(path, turn_id)
|
|
790
|
+
snap = self._log.snapshots.get(snap_key)
|
|
791
|
+
before = snap.content_hash if snap else None
|
|
792
|
+
before_skip = snap.skip_reason if snap else None
|
|
793
|
+
result[path] = FileHashDiff(before=before, after=None, before_skip=before_skip)
|
|
794
|
+
return before, before_skip
|
|
795
|
+
return result[path].before, result[path].before_skip
|
|
796
|
+
|
|
797
|
+
for turn in sorted(periods, key=lambda period: period.period_index):
|
|
798
|
+
for m in turn.mutations:
|
|
799
|
+
if badges.observe(m):
|
|
800
|
+
continue # foreign row — never folds into endpoints
|
|
801
|
+
before, before_skip = _set_before_if_first(m.path, turn.period_index)
|
|
802
|
+
result[m.path] = FileHashDiff(
|
|
803
|
+
before=before,
|
|
804
|
+
after=m.after_hash,
|
|
805
|
+
before_skip=before_skip,
|
|
806
|
+
after_skip=m.after_skip,
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
# MOVE: old_path is effectively deleted (its content moved away).
|
|
810
|
+
if m.old_path:
|
|
811
|
+
old_before, old_before_skip = _set_before_if_first(m.old_path, turn.period_index)
|
|
812
|
+
result[m.old_path] = FileHashDiff(before=old_before, after=None, before_skip=old_before_skip)
|
|
813
|
+
|
|
814
|
+
return badges.apply(result)
|
|
815
|
+
|
|
816
|
+
def get_file_edit_snapshots(self) -> list[tuple[str, str]]:
|
|
817
|
+
"""Return ``(before_text, after_text)`` for all file-tool mutations, in order.
|
|
818
|
+
|
|
819
|
+
Only includes mutations from ``write_file`` and ``edit_file``
|
|
820
|
+
(shell mutations are excluded). Missing blobs resolve to empty
|
|
821
|
+
strings (e.g. ``before_text=""`` for newly created files).
|
|
822
|
+
Used to build the replay mapping for DiffView rendering.
|
|
823
|
+
"""
|
|
824
|
+
from chrys.foundation.text.encoding import decode_bytes
|
|
825
|
+
|
|
826
|
+
with self._state_lock:
|
|
827
|
+
_file_sources = {MutationSource.WRITE_FILE, MutationSource.EDIT_FILE}
|
|
828
|
+
results: list[tuple[str, str]] = []
|
|
829
|
+
for turn in self._log.periods:
|
|
830
|
+
for m in turn.mutations:
|
|
831
|
+
if m.source not in _file_sources:
|
|
832
|
+
continue
|
|
833
|
+
before_bytes = self._store.read_blob(m.before_hash) if m.before_hash else None
|
|
834
|
+
after_bytes = self._store.read_blob(m.after_hash) if m.after_hash else None
|
|
835
|
+
before_text = decode_bytes(before_bytes) if before_bytes else ""
|
|
836
|
+
after_text = decode_bytes(after_bytes) if after_bytes else ""
|
|
837
|
+
results.append((before_text, after_text))
|
|
838
|
+
return results
|
|
839
|
+
|
|
840
|
+
def get_file_edit_snapshot_refs(self) -> list[FileHashDiff]:
|
|
841
|
+
"""Return before/after blob hashes for all file-tool mutations, in order."""
|
|
842
|
+
with self._state_lock:
|
|
843
|
+
_file_sources = {MutationSource.WRITE_FILE, MutationSource.EDIT_FILE}
|
|
844
|
+
results: list[FileHashDiff] = []
|
|
845
|
+
for turn in self._log.periods:
|
|
846
|
+
for m in turn.mutations:
|
|
847
|
+
if m.source not in _file_sources:
|
|
848
|
+
continue
|
|
849
|
+
results.append(
|
|
850
|
+
FileHashDiff(
|
|
851
|
+
before=m.before_hash,
|
|
852
|
+
after=m.after_hash,
|
|
853
|
+
before_skip=m.before_skip,
|
|
854
|
+
after_skip=m.after_skip,
|
|
855
|
+
)
|
|
856
|
+
)
|
|
857
|
+
return results
|
|
858
|
+
|
|
859
|
+
# -- Rollback --
|
|
860
|
+
|
|
861
|
+
def _build_rollback_plan(self, rolled_back: list[MutationPeriod]) -> RollbackPlan:
|
|
862
|
+
"""Earliest-wins restore plan over ``rolled_back`` turns.
|
|
863
|
+
|
|
864
|
+
Caller must hold ``_state_lock``.
|
|
865
|
+
|
|
866
|
+
Safety filters run AFTER earliest-wins selection; every filtered
|
|
867
|
+
path is reported via :attr:`RollbackPlan.exclusions` (never
|
|
868
|
+
silent):
|
|
869
|
+
|
|
870
|
+
1. **Provenance** — a path touched by a ``FOREIGN`` row, or by a
|
|
871
|
+
``contested`` row, is excluded outright (poisoning-style):
|
|
872
|
+
restoring "our" pre-window snapshot over a path a peer
|
|
873
|
+
session also wrote would revert the peer's work.
|
|
874
|
+
2. **Restorability** — a non-restorable earliest snapshot
|
|
875
|
+
(content withheld by :class:`SnapshotPolicy`) excludes the
|
|
876
|
+
file outright rather than silently restoring a misleading
|
|
877
|
+
mid-window state.
|
|
878
|
+
3. **Move safety** — a MOVE is atomic for rollback: when the
|
|
879
|
+
source's pre-window content is unrecoverable, deleting (or
|
|
880
|
+
overwriting) the destination would destroy the only surviving
|
|
881
|
+
copy, so the destination is excluded too. Applied through
|
|
882
|
+
the explicit ``old_path`` pairing (with chronological
|
|
883
|
+
propagation across chained moves), plus a conservative
|
|
884
|
+
fallback for scanner-decomposed moves (``mv`` observed as
|
|
885
|
+
independent CREATE + DELETE): if any unrecoverable-origin
|
|
886
|
+
file was vacated in-window (or its after endpoint is unreadable
|
|
887
|
+
and may have been vacated), every plan entry whose current
|
|
888
|
+
on-disk content is skip-marked is left untouched — it may be
|
|
889
|
+
that escaped content.
|
|
890
|
+
"""
|
|
891
|
+
earliest: dict[str, FileSnapshot] = {}
|
|
892
|
+
foreign_touched: set[str] = set()
|
|
893
|
+
contested: set[str] = set()
|
|
894
|
+
for turn in rolled_back:
|
|
895
|
+
for m in turn.mutations:
|
|
896
|
+
if m.provenance is MutationProvenance.FOREIGN:
|
|
897
|
+
foreign_touched.add(m.path)
|
|
898
|
+
if m.old_path:
|
|
899
|
+
foreign_touched.add(m.old_path)
|
|
900
|
+
if m.contested:
|
|
901
|
+
contested.add(m.path)
|
|
902
|
+
if m.old_path:
|
|
903
|
+
contested.add(m.old_path)
|
|
904
|
+
key = MutationLog.snapshot_key(m.path, turn.period_index)
|
|
905
|
+
snap = self._log.snapshots.get(key)
|
|
906
|
+
if snap and (m.path not in earliest or turn.period_index < earliest[m.path].period_index):
|
|
907
|
+
earliest[m.path] = snap
|
|
908
|
+
if m.old_path:
|
|
909
|
+
old_key = MutationLog.snapshot_key(m.old_path, turn.period_index)
|
|
910
|
+
old_snap = self._log.snapshots.get(old_key)
|
|
911
|
+
if old_snap and (
|
|
912
|
+
m.old_path not in earliest or turn.period_index < earliest[m.old_path].period_index
|
|
913
|
+
):
|
|
914
|
+
earliest[m.old_path] = old_snap
|
|
915
|
+
|
|
916
|
+
poisoned: set[str] = set()
|
|
917
|
+
vacated: set[str] = set()
|
|
918
|
+
last_after_skip: dict[str, SnapshotSkipReason | None] = {}
|
|
919
|
+
for turn in sorted(rolled_back, key=lambda t: t.period_index):
|
|
920
|
+
for m in turn.mutations:
|
|
921
|
+
if m.operation is MutationOp.DELETE or m.after_skip is SnapshotSkipReason.UNREADABLE:
|
|
922
|
+
# An unreadable endpoint may be a vacated move source.
|
|
923
|
+
# record_after correctly avoids asserting DELETE, but
|
|
924
|
+
# that uncertainty must not release the last-copy guard.
|
|
925
|
+
vacated.add(m.path)
|
|
926
|
+
last_after_skip[m.path] = m.after_skip
|
|
927
|
+
if m.old_path:
|
|
928
|
+
vacated.add(m.old_path)
|
|
929
|
+
src = earliest.get(m.old_path)
|
|
930
|
+
if src is None or not src.restorable or m.old_path in poisoned:
|
|
931
|
+
poisoned.add(m.path)
|
|
932
|
+
|
|
933
|
+
unrecoverable_escape = any((snap := earliest.get(path)) is not None and not snap.restorable for path in vacated)
|
|
934
|
+
if unrecoverable_escape:
|
|
935
|
+
poisoned.update(path for path in earliest if last_after_skip.get(path) is not None)
|
|
936
|
+
|
|
937
|
+
# Reason precedence (display-only, one per path): strongest
|
|
938
|
+
# attribution first — not-ours beats ours-but-conflicted beats
|
|
939
|
+
# content-availability mechanics.
|
|
940
|
+
entries: list[tuple[str, FileSnapshot]] = []
|
|
941
|
+
exclusions: list[tuple[str, RollbackExclusionReason]] = []
|
|
942
|
+
for path, snap in earliest.items():
|
|
943
|
+
if path in foreign_touched:
|
|
944
|
+
exclusions.append((path, RollbackExclusionReason.FOREIGN))
|
|
945
|
+
elif path in contested:
|
|
946
|
+
exclusions.append((path, RollbackExclusionReason.CONTESTED))
|
|
947
|
+
elif not snap.restorable:
|
|
948
|
+
exclusions.append((path, RollbackExclusionReason.UNRESTORABLE))
|
|
949
|
+
elif path in poisoned:
|
|
950
|
+
exclusions.append((path, RollbackExclusionReason.MOVE_POISONED))
|
|
951
|
+
else:
|
|
952
|
+
entries.append((path, snap))
|
|
953
|
+
exclusions.sort(key=lambda item: item[0])
|
|
954
|
+
return RollbackPlan(entries=entries, exclusions=exclusions)
|
|
955
|
+
|
|
956
|
+
def get_rollback_plan_for_periods(self, scopes: set[MutationScope]) -> RollbackPlan:
|
|
957
|
+
"""Build a file-only plan from exact execution identities.
|
|
958
|
+
|
|
959
|
+
No conversation/session restoration is implied. Unknown IDs fail rather
|
|
960
|
+
than silently producing an empty plan. File ownership and unavailable
|
|
961
|
+
snapshot exclusions are shared with Chat rollback.
|
|
962
|
+
"""
|
|
963
|
+
with self._state_lock:
|
|
964
|
+
periods = [self.get_period(scope) for scope in scopes]
|
|
965
|
+
return self._build_rollback_plan(sorted(periods, key=lambda period: period.period_index))
|
|
966
|
+
|
|
967
|
+
def restore_files(self, plan: RollbackPlan, only_paths: set[str] | None = None) -> list[RestoreResult]:
|
|
968
|
+
"""Apply an already selected file plan, preserving the execution history.
|
|
969
|
+
|
|
970
|
+
The session owner supplies locking, stale-selection checks, attribution
|
|
971
|
+
refresh, persistence and result reporting. Chat's destructive rollback
|
|
972
|
+
methods separately truncate its conversation mutation timeline.
|
|
973
|
+
"""
|
|
974
|
+
with self._state_lock:
|
|
975
|
+
entries = plan.entries
|
|
976
|
+
if only_paths is not None:
|
|
977
|
+
entries = [(path, snapshot) for path, snapshot in entries if path in only_paths]
|
|
978
|
+
return self._restore_plan(entries)
|
|
979
|
+
|
|
980
|
+
def get_rollback_plan(self, num_turns: int = 1) -> RollbackPlan:
|
|
981
|
+
"""Compute which files to restore for rolling back the last N turns.
|
|
982
|
+
|
|
983
|
+
For each file mutated in the rolled-back turns, the snapshot
|
|
984
|
+
from the *earliest* rolled-back turn that touched that file is
|
|
985
|
+
used — representing the file state just before those turns began.
|
|
986
|
+
|
|
987
|
+
Returns:
|
|
988
|
+
A :class:`RollbackPlan`. ``entries`` holds ``(path,
|
|
989
|
+
snapshot)`` pairs: ``existed=False`` snapshots mean the file
|
|
990
|
+
should be deleted (it was created during the rolled-back
|
|
991
|
+
turns), ``existed=True`` means restore from blob. Files
|
|
992
|
+
rollback cannot safely act on are listed in ``exclusions``
|
|
993
|
+
with their reason — see :meth:`_build_rollback_plan` for the
|
|
994
|
+
exact filters.
|
|
995
|
+
"""
|
|
996
|
+
with self._state_lock:
|
|
997
|
+
self._log.require_chat() # Chat rollback owns conversation truncation.
|
|
998
|
+
if not self._log.periods or num_turns < 1:
|
|
999
|
+
return RollbackPlan(entries=[])
|
|
1000
|
+
|
|
1001
|
+
n = min(num_turns, len(self._log.periods))
|
|
1002
|
+
return self._build_rollback_plan(self._log.periods[-n:])
|
|
1003
|
+
|
|
1004
|
+
def get_rollback_plan_for_turns(self, turn_ids: set[int]) -> RollbackPlan:
|
|
1005
|
+
"""Compute which files to restore for rolling back explicit turn IDs.
|
|
1006
|
+
|
|
1007
|
+
Unlike :meth:`get_rollback_plan`, this is independent of log
|
|
1008
|
+
insertion order. Rollback controller callers use this when they
|
|
1009
|
+
already know the target turn and need to undo every mutation with
|
|
1010
|
+
``turn_id > target_turn``.
|
|
1011
|
+
"""
|
|
1012
|
+
with self._state_lock:
|
|
1013
|
+
self._log.require_chat() # Chat rollback owns conversation truncation.
|
|
1014
|
+
if not self._log.periods or not turn_ids:
|
|
1015
|
+
return RollbackPlan(entries=[])
|
|
1016
|
+
|
|
1017
|
+
return self._build_rollback_plan([turn for turn in self._log.periods if turn.period_index in turn_ids])
|
|
1018
|
+
|
|
1019
|
+
def rollback(self, num_turns: int = 1, only_paths: set[str] | None = None) -> list[RestoreResult]:
|
|
1020
|
+
"""Execute a rollback of the last N turns.
|
|
1021
|
+
|
|
1022
|
+
Restores each affected file to its pre-rollback-window state
|
|
1023
|
+
using :meth:`SnapshotStore.restore`, then removes the
|
|
1024
|
+
rolled-back turns and their snapshots from the log.
|
|
1025
|
+
|
|
1026
|
+
CONTRACT (see :meth:`SnapshotStore.restore` for the full text):
|
|
1027
|
+
revert is unconditional — each file in the plan is forced to
|
|
1028
|
+
the snapshot's pre-turn state regardless of its current disk
|
|
1029
|
+
state. Users opt out of a file by de-selecting it in the
|
|
1030
|
+
rollback modal before pressing confirm; by the time we get
|
|
1031
|
+
here the selection IS the user's decision.
|
|
1032
|
+
|
|
1033
|
+
Args:
|
|
1034
|
+
num_turns: How many most-recent turns to remove from the log.
|
|
1035
|
+
only_paths: If not ``None``, restore only the files whose
|
|
1036
|
+
absolute path is in this set. Paths not in the set keep
|
|
1037
|
+
their current on-disk content — the rollback modal's
|
|
1038
|
+
per-file checkbox selection passes the checked paths
|
|
1039
|
+
here. ``None`` preserves the historical "restore
|
|
1040
|
+
everything in the plan" behaviour for non-UI callers.
|
|
1041
|
+
|
|
1042
|
+
Returns:
|
|
1043
|
+
One :class:`RestoreResult` per file in the plan (after
|
|
1044
|
+
``only_paths`` filtering), carrying the per-path outcome.
|
|
1045
|
+
Use ``r.changed`` to count files whose on-disk state
|
|
1046
|
+
actually changed, ``r.ok`` for "ended up in target state
|
|
1047
|
+
(applied or already matched)", or inspect ``r.reason`` on
|
|
1048
|
+
FAILED entries for the specific failure mode.
|
|
1049
|
+
"""
|
|
1050
|
+
with self._state_lock:
|
|
1051
|
+
self._log.require_chat() # Chat rollback owns conversation truncation.
|
|
1052
|
+
entries = self.get_rollback_plan(num_turns).entries
|
|
1053
|
+
if only_paths is not None:
|
|
1054
|
+
entries = [(path, snapshot) for path, snapshot in entries if path in only_paths]
|
|
1055
|
+
|
|
1056
|
+
results = self._restore_plan(entries)
|
|
1057
|
+
|
|
1058
|
+
# Remove rolled-back turns from the log + clean up
|
|
1059
|
+
self._remove_last_turns(min(num_turns, len(self._log.periods)))
|
|
1060
|
+
|
|
1061
|
+
return results
|
|
1062
|
+
|
|
1063
|
+
def rollback_turns(
|
|
1064
|
+
self,
|
|
1065
|
+
turn_ids: set[int],
|
|
1066
|
+
only_paths: set[str] | None = None,
|
|
1067
|
+
*,
|
|
1068
|
+
plan: RollbackPlan | None = None,
|
|
1069
|
+
) -> list[RestoreResult]:
|
|
1070
|
+
"""Execute a rollback for explicit turn IDs.
|
|
1071
|
+
|
|
1072
|
+
This is the controller-facing variant used when the target turn
|
|
1073
|
+
is known. It removes all matching turn entries from the mutation
|
|
1074
|
+
log, regardless of their insertion order.
|
|
1075
|
+
|
|
1076
|
+
``plan`` lets the caller execute from a pre-built
|
|
1077
|
+
:class:`RollbackPlan` — the engine builds the plan *first* so
|
|
1078
|
+
its exclusions/warnings can still be reported after this method
|
|
1079
|
+
removes the turns they were derived from (they cannot be
|
|
1080
|
+
reconstructed afterwards). ``None`` builds the plan here.
|
|
1081
|
+
"""
|
|
1082
|
+
with self._state_lock:
|
|
1083
|
+
self._log.require_chat() # Chat rollback owns conversation truncation.
|
|
1084
|
+
if not turn_ids:
|
|
1085
|
+
return []
|
|
1086
|
+
|
|
1087
|
+
if plan is None:
|
|
1088
|
+
plan = self.get_rollback_plan_for_turns(turn_ids)
|
|
1089
|
+
entries = plan.entries
|
|
1090
|
+
if only_paths is not None:
|
|
1091
|
+
entries = [(path, snapshot) for path, snapshot in entries if path in only_paths]
|
|
1092
|
+
|
|
1093
|
+
results = self._restore_plan(entries)
|
|
1094
|
+
self._remove_turn_ids(turn_ids)
|
|
1095
|
+
return results
|
|
1096
|
+
|
|
1097
|
+
def _restore_plan(self, plan: list[tuple[str, FileSnapshot]]) -> list[RestoreResult]:
|
|
1098
|
+
"""Restore each snapshot in a rollback plan best-effort."""
|
|
1099
|
+
from chrys.service.mutations.types import RestoreOutcome
|
|
1100
|
+
|
|
1101
|
+
results: list[RestoreResult] = []
|
|
1102
|
+
for _path, snapshot in plan:
|
|
1103
|
+
try:
|
|
1104
|
+
results.append(self._store.restore(snapshot))
|
|
1105
|
+
except Exception as exc:
|
|
1106
|
+
logger.exception("Rollback: unexpected error restoring %s", snapshot.path)
|
|
1107
|
+
results.append(
|
|
1108
|
+
RestoreResult(path=snapshot.path, outcome=RestoreOutcome.FAILED, reason=str(exc)),
|
|
1109
|
+
)
|
|
1110
|
+
return results
|
|
1111
|
+
|
|
1112
|
+
# -- Cleanup --
|
|
1113
|
+
|
|
1114
|
+
def remove_turn(self, turn_id: int) -> set[str]:
|
|
1115
|
+
"""Remove a specific turn and its snapshots from the log.
|
|
1116
|
+
|
|
1117
|
+
Deletes blob files that are no longer referenced by any remaining
|
|
1118
|
+
snapshot or mutation. Returns the set of content hashes whose
|
|
1119
|
+
blobs were removed from disk.
|
|
1120
|
+
|
|
1121
|
+
This is a data-management operation — it does NOT restore files.
|
|
1122
|
+
Use :meth:`rollback` to undo mutations.
|
|
1123
|
+
"""
|
|
1124
|
+
with self._state_lock:
|
|
1125
|
+
self._log.require_chat() # Chat rollback owns conversation truncation.
|
|
1126
|
+
# Collect all hashes from this turn: snapshots + mutation after_hashes
|
|
1127
|
+
turn_hashes: set[str] = set()
|
|
1128
|
+
keys_to_remove: list[str] = []
|
|
1129
|
+
for key, snap in self._log.snapshots.items():
|
|
1130
|
+
if snap.period_index == turn_id:
|
|
1131
|
+
if snap.content_hash:
|
|
1132
|
+
turn_hashes.add(snap.content_hash)
|
|
1133
|
+
keys_to_remove.append(key)
|
|
1134
|
+
self._snapshotted.discard(key)
|
|
1135
|
+
|
|
1136
|
+
# Collect after_hash values from this turn's mutations
|
|
1137
|
+
removed_turn = None
|
|
1138
|
+
for t in self._log.periods:
|
|
1139
|
+
if t.period_index == turn_id:
|
|
1140
|
+
removed_turn = t
|
|
1141
|
+
break
|
|
1142
|
+
if removed_turn:
|
|
1143
|
+
for m in removed_turn.mutations:
|
|
1144
|
+
if m.before_hash:
|
|
1145
|
+
turn_hashes.add(m.before_hash)
|
|
1146
|
+
if m.after_hash:
|
|
1147
|
+
turn_hashes.add(m.after_hash)
|
|
1148
|
+
|
|
1149
|
+
for key in keys_to_remove:
|
|
1150
|
+
del self._log.snapshots[key]
|
|
1151
|
+
|
|
1152
|
+
# Remove the turn entry
|
|
1153
|
+
self._log.periods = [t for t in self._log.periods if t.period_index != turn_id]
|
|
1154
|
+
|
|
1155
|
+
# Find hashes still referenced by remaining snapshots or mutations
|
|
1156
|
+
still_referenced = self._all_referenced_hashes()
|
|
1157
|
+
orphaned = turn_hashes - still_referenced
|
|
1158
|
+
|
|
1159
|
+
if orphaned:
|
|
1160
|
+
self._store.remove_blobs(orphaned)
|
|
1161
|
+
|
|
1162
|
+
return orphaned
|
|
1163
|
+
|
|
1164
|
+
def clear(self) -> None:
|
|
1165
|
+
"""Remove all turns, snapshots, and blob files.
|
|
1166
|
+
|
|
1167
|
+
Called when a session is deleted or fully reset.
|
|
1168
|
+
"""
|
|
1169
|
+
with self._state_lock:
|
|
1170
|
+
self._log.periods.clear()
|
|
1171
|
+
self._log.snapshots.clear()
|
|
1172
|
+
self._snapshotted.clear()
|
|
1173
|
+
self._store.remove_all()
|
|
1174
|
+
|
|
1175
|
+
def _all_referenced_hashes(self) -> set[str]:
|
|
1176
|
+
"""Collect all content hashes still referenced by remaining data."""
|
|
1177
|
+
hashes: set[str] = set()
|
|
1178
|
+
for s in self._log.snapshots.values():
|
|
1179
|
+
if s.content_hash:
|
|
1180
|
+
hashes.add(s.content_hash)
|
|
1181
|
+
for t in self._log.periods:
|
|
1182
|
+
for m in t.mutations:
|
|
1183
|
+
if m.before_hash:
|
|
1184
|
+
hashes.add(m.before_hash)
|
|
1185
|
+
if m.after_hash:
|
|
1186
|
+
hashes.add(m.after_hash)
|
|
1187
|
+
return hashes
|
|
1188
|
+
|
|
1189
|
+
def _remove_last_turns(self, n: int) -> None:
|
|
1190
|
+
"""Remove the last *n* turns and clean up orphaned blobs."""
|
|
1191
|
+
if n <= 0 or not self._log.periods:
|
|
1192
|
+
return
|
|
1193
|
+
|
|
1194
|
+
removed_turns = self._log.periods[-n:]
|
|
1195
|
+
self._log.periods = self._log.periods[:-n]
|
|
1196
|
+
|
|
1197
|
+
removed_hashes: set[str] = set()
|
|
1198
|
+
for turn in removed_turns:
|
|
1199
|
+
for m in turn.mutations:
|
|
1200
|
+
# A calibrated row's fresh before blob is neither the turn
|
|
1201
|
+
# snapshot nor any after endpoint — collect both sides.
|
|
1202
|
+
if m.before_hash:
|
|
1203
|
+
removed_hashes.add(m.before_hash)
|
|
1204
|
+
if m.after_hash:
|
|
1205
|
+
removed_hashes.add(m.after_hash)
|
|
1206
|
+
# Collect snapshot (before) hashes
|
|
1207
|
+
key = MutationLog.snapshot_key(m.path, turn.period_index)
|
|
1208
|
+
snap = self._log.snapshots.pop(key, None)
|
|
1209
|
+
self._snapshotted.discard(key)
|
|
1210
|
+
if snap and snap.content_hash:
|
|
1211
|
+
removed_hashes.add(snap.content_hash)
|
|
1212
|
+
if m.old_path:
|
|
1213
|
+
old_key = MutationLog.snapshot_key(m.old_path, turn.period_index)
|
|
1214
|
+
old_snap = self._log.snapshots.pop(old_key, None)
|
|
1215
|
+
self._snapshotted.discard(old_key)
|
|
1216
|
+
if old_snap and old_snap.content_hash:
|
|
1217
|
+
removed_hashes.add(old_snap.content_hash)
|
|
1218
|
+
|
|
1219
|
+
# Only delete blobs not referenced by remaining snapshots/mutations
|
|
1220
|
+
still_referenced = self._all_referenced_hashes()
|
|
1221
|
+
orphaned = removed_hashes - still_referenced
|
|
1222
|
+
if orphaned:
|
|
1223
|
+
self._store.remove_blobs(orphaned)
|
|
1224
|
+
|
|
1225
|
+
def _remove_turn_ids(self, turn_ids: set[int]) -> None:
|
|
1226
|
+
"""Remove explicit turn IDs and clean up orphaned blobs."""
|
|
1227
|
+
if not turn_ids or not self._log.periods:
|
|
1228
|
+
return
|
|
1229
|
+
|
|
1230
|
+
removed_turns = [turn for turn in self._log.periods if turn.period_index in turn_ids]
|
|
1231
|
+
if not removed_turns:
|
|
1232
|
+
return
|
|
1233
|
+
self._log.periods = [turn for turn in self._log.periods if turn.period_index not in turn_ids]
|
|
1234
|
+
|
|
1235
|
+
removed_hashes: set[str] = set()
|
|
1236
|
+
for turn in removed_turns:
|
|
1237
|
+
for m in turn.mutations:
|
|
1238
|
+
if m.before_hash:
|
|
1239
|
+
removed_hashes.add(m.before_hash)
|
|
1240
|
+
if m.after_hash:
|
|
1241
|
+
removed_hashes.add(m.after_hash)
|
|
1242
|
+
|
|
1243
|
+
keys_to_remove = [key for key, snap in self._log.snapshots.items() if snap.period_index in turn_ids]
|
|
1244
|
+
for key in keys_to_remove:
|
|
1245
|
+
snap = self._log.snapshots.pop(key)
|
|
1246
|
+
self._snapshotted.discard(key)
|
|
1247
|
+
if snap.content_hash:
|
|
1248
|
+
removed_hashes.add(snap.content_hash)
|
|
1249
|
+
|
|
1250
|
+
still_referenced = self._all_referenced_hashes()
|
|
1251
|
+
orphaned = removed_hashes - still_referenced
|
|
1252
|
+
if orphaned:
|
|
1253
|
+
self._store.remove_blobs(orphaned)
|
|
1254
|
+
|
|
1255
|
+
# -- Serialization --
|
|
1256
|
+
|
|
1257
|
+
def serialize(self) -> dict[str, Any]:
|
|
1258
|
+
"""Serialize for session persistence (JSON-compatible dict)."""
|
|
1259
|
+
with self._state_lock:
|
|
1260
|
+
return {
|
|
1261
|
+
("runs" if any(isinstance(period, RunMutations) for period in self._log.periods) else "turns"): [
|
|
1262
|
+
period.to_dict() for period in self._log.periods
|
|
1263
|
+
],
|
|
1264
|
+
"snapshots": {k: s.to_dict() for k, s in self._log.snapshots.items()},
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
@classmethod
|
|
1268
|
+
def deserialize(cls, data: dict[str, Any], store: SnapshotStore) -> MutationTracker:
|
|
1269
|
+
"""Restore a MutationTracker from serialized session data."""
|
|
1270
|
+
tracker = cls(store)
|
|
1271
|
+
if "runs" in data and "turns" in data:
|
|
1272
|
+
raise ValueError("A mutation ledger cannot mix Chat turns and Workflow runs")
|
|
1273
|
+
for t_data in data.get("turns", []):
|
|
1274
|
+
if "run_id" in t_data:
|
|
1275
|
+
raise ValueError("Workflow runs require their own mutation ledger")
|
|
1276
|
+
tracker._log.periods.append(TurnMutations.from_dict(t_data))
|
|
1277
|
+
for item in data.get("runs", []):
|
|
1278
|
+
period = RunMutations.from_dict(item)
|
|
1279
|
+
if any(
|
|
1280
|
+
prior.scope == period.scope or prior.period_index >= period.period_index
|
|
1281
|
+
for prior in tracker._log.periods
|
|
1282
|
+
):
|
|
1283
|
+
raise ValueError("Duplicate or unordered workflow mutation period")
|
|
1284
|
+
tracker._log.periods.append(period)
|
|
1285
|
+
for key, s_data in data.get("snapshots", {}).items():
|
|
1286
|
+
snap = FileSnapshot.from_dict(s_data)
|
|
1287
|
+
tracker._log.snapshots[key] = snap
|
|
1288
|
+
tracker._snapshotted.add(key)
|
|
1289
|
+
return tracker
|