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,1947 @@
|
|
|
1
|
+
# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""Phase-4 dropped-call records, catalog projection, and reconciliation."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import threading
|
|
14
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import TYPE_CHECKING, Any, BinaryIO, cast
|
|
19
|
+
from urllib.parse import parse_qsl, urlsplit
|
|
20
|
+
from uuid import uuid4
|
|
21
|
+
|
|
22
|
+
from chrys.foundation.platform import get_platform
|
|
23
|
+
from chrys.foundation.platform.files import atomic_write_text, fsync_directory, is_utf8_encodable
|
|
24
|
+
from chrys.foundation.text.images import hashable_image_identity
|
|
25
|
+
from chrys.foundation.tool_execution_stamp import (
|
|
26
|
+
canonicalize_effective_arguments,
|
|
27
|
+
execution_stamp_from_metadata,
|
|
28
|
+
)
|
|
29
|
+
from chrys.foundation.tool_result_metadata import TOOL_ERROR_KIND_METADATA_KEY
|
|
30
|
+
from chrys.foundation.util.filenames import bounded_safe_stem, safe_platform_basename
|
|
31
|
+
from chrys.kernel import TOOL_CALL_CONTENT_TYPES, Content, is_image_content
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from chrys.service.context.compaction.scoped import ScopedGroup
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
PER_RECORD_CAP_BYTES = 2 * 1024 * 1024
|
|
39
|
+
"""Maximum bytes retained in one spill record."""
|
|
40
|
+
|
|
41
|
+
PER_ROUND_CAP_BYTES = 64 * 1024 * 1024
|
|
42
|
+
"""Maximum spill-record bytes admitted by one compaction round."""
|
|
43
|
+
|
|
44
|
+
PER_SESSION_QUOTA_BYTES = 256 * 1024 * 1024
|
|
45
|
+
"""Maximum live spill-record bytes retained by one session."""
|
|
46
|
+
|
|
47
|
+
COMPACTIONS_DIR_NAME = "compactions"
|
|
48
|
+
DROPPED_DIR_NAME = "dropped"
|
|
49
|
+
CATALOG_FILE_NAME = "catalog.jsonl"
|
|
50
|
+
MANIFEST_FILE_NAME = "manifest.md"
|
|
51
|
+
SUB_AGENTS_DIR_NAME = "sub_agents"
|
|
52
|
+
RECORD_FILE_SUFFIX = ".md"
|
|
53
|
+
|
|
54
|
+
CATALOG_RELATIVE_PATH = Path(COMPACTIONS_DIR_NAME) / DROPPED_DIR_NAME / CATALOG_FILE_NAME
|
|
55
|
+
"""Session-relative location of the append-only record catalog."""
|
|
56
|
+
|
|
57
|
+
RECORD_ID_HEX_CHARS = 8
|
|
58
|
+
"""Record-ID length: a turn spills at most a few hundred groups and filename
|
|
59
|
+
collisions are handled by the exclusive-write retry, so 8 hex chars suffice."""
|
|
60
|
+
|
|
61
|
+
CATALOG_KIND_GROUP = "group"
|
|
62
|
+
"""Catalog ``kind`` for records archiving one dropped scoped group."""
|
|
63
|
+
|
|
64
|
+
CATALOG_KIND_NOTE = "note"
|
|
65
|
+
"""Catalog ``kind`` for records archiving one superseded LAST_WORDS note."""
|
|
66
|
+
|
|
67
|
+
NOTE_RECORD_TOOL_NAME = "last_words"
|
|
68
|
+
"""Pseudo tool name used in note-record filenames and manifest lines."""
|
|
69
|
+
|
|
70
|
+
NOTE_RECORD_GROUP_ID = "superseded_note"
|
|
71
|
+
"""Synthetic manifest group ID for note records (no scoped group backs them)."""
|
|
72
|
+
|
|
73
|
+
_RECORD_ID_RE = re.compile(rf"^[0-9a-f]{{{RECORD_ID_HEX_CHARS}}}$")
|
|
74
|
+
_TURN_DIRECTORY_RE = re.compile(r"^turn[0-9]{3,}$")
|
|
75
|
+
_RECORD_TERMINATOR = "<!-- end of record -->\n"
|
|
76
|
+
_RECORD_TRUNCATION_TEMPLATE = "\n[… middle-truncated from {size} bytes …]\n"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _UnsafeCatalogPathError(OSError):
|
|
80
|
+
"""The fixed spill catalog path is not owned directly by its session root."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class CatalogRecord:
|
|
85
|
+
"""One live catalog record."""
|
|
86
|
+
|
|
87
|
+
record_id: str
|
|
88
|
+
relative_path: str
|
|
89
|
+
turn: int
|
|
90
|
+
round: int
|
|
91
|
+
tool: str
|
|
92
|
+
bytes: int
|
|
93
|
+
created_at: str
|
|
94
|
+
kind: str = CATALOG_KIND_GROUP
|
|
95
|
+
|
|
96
|
+
def to_catalog_line(self) -> dict[str, Any]:
|
|
97
|
+
"""Return the append-only JSON object for this record."""
|
|
98
|
+
return {
|
|
99
|
+
"record_id": self.record_id,
|
|
100
|
+
"relative_path": self.relative_path,
|
|
101
|
+
"turn": self.turn,
|
|
102
|
+
"round": self.round,
|
|
103
|
+
"tool": self.tool,
|
|
104
|
+
"bytes": self.bytes,
|
|
105
|
+
"created_at": self.created_at,
|
|
106
|
+
"kind": self.kind,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True, slots=True)
|
|
111
|
+
class SpillManifestItem:
|
|
112
|
+
"""Neutral spill result converted to ``last_words_state.ManifestEntry`` state."""
|
|
113
|
+
|
|
114
|
+
record_id: str
|
|
115
|
+
group_id: str
|
|
116
|
+
record_dir: str
|
|
117
|
+
relative_path: str
|
|
118
|
+
turn: int
|
|
119
|
+
round: int
|
|
120
|
+
sequence: int
|
|
121
|
+
tool: str
|
|
122
|
+
display_argument: str
|
|
123
|
+
outcome: str
|
|
124
|
+
size_chars: int
|
|
125
|
+
assistant_text: bool = False
|
|
126
|
+
available: bool = True
|
|
127
|
+
no_record_reason: str = ""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True, slots=True)
|
|
131
|
+
class SpillBatchResult:
|
|
132
|
+
"""Worker-thread result for one Phase-4 spill batch."""
|
|
133
|
+
|
|
134
|
+
entries: tuple[SpillManifestItem, ...]
|
|
135
|
+
unexpected_io_failure: bool = False
|
|
136
|
+
evicted_relative_paths: frozenset[str] = frozenset()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True, slots=True)
|
|
140
|
+
class SpillReconciliationResult:
|
|
141
|
+
"""Catalog-derived state produced while restoring a session."""
|
|
142
|
+
|
|
143
|
+
live_total_bytes: int
|
|
144
|
+
live_record_count: int
|
|
145
|
+
available_relative_paths: frozenset[str]
|
|
146
|
+
deleted_orphans: int = 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class SpillQuota:
|
|
150
|
+
"""Thread-safe session spill ledger with reserve-before-write semantics.
|
|
151
|
+
|
|
152
|
+
``spent_bytes`` reports committed live bytes; outstanding reservations also
|
|
153
|
+
consume headroom internally. ``commit`` rejects an actual byte count larger
|
|
154
|
+
than its reservation and leaves that reservation intact for explicit release.
|
|
155
|
+
``reclaim`` returns committed headroom after cataloged records are evicted.
|
|
156
|
+
Reconciliation failures mark storage unavailable so retained but unaccounted
|
|
157
|
+
records can never be followed by a fresh quota's worth of writes.
|
|
158
|
+
|
|
159
|
+
The separate ``catalog_lock`` serializes the session's append-only catalog
|
|
160
|
+
and its ``manifest.md`` projections across main and sub-agent workers. It is
|
|
161
|
+
intentionally distinct from the quota ledger lock so catalog work may call
|
|
162
|
+
reserve/commit/reclaim without lock recursion.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def __init__(self, limit_bytes: int = PER_SESSION_QUOTA_BYTES) -> None:
|
|
166
|
+
if limit_bytes < 0:
|
|
167
|
+
raise ValueError("limit_bytes must be non-negative")
|
|
168
|
+
self._limit_bytes = limit_bytes
|
|
169
|
+
self._spent_bytes = 0
|
|
170
|
+
self._reserved_bytes = 0
|
|
171
|
+
self._available_relative_paths: set[str] = set()
|
|
172
|
+
self._committed_bytes_by_path: dict[str, int] = {}
|
|
173
|
+
self._live_record_counts_by_path: dict[str, int] = {}
|
|
174
|
+
self._live_catalog_records_by_id: dict[str, CatalogRecord] = {}
|
|
175
|
+
self._catalog_record_id_by_path: dict[str, str] = {}
|
|
176
|
+
self._live_catalog_records_by_directory: dict[str, dict[str, CatalogRecord]] = {}
|
|
177
|
+
self._live_record_count = 0
|
|
178
|
+
self._storage_available = True
|
|
179
|
+
self._lock = threading.Lock()
|
|
180
|
+
self._catalog_lock = threading.Lock()
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def spent_bytes(self) -> int:
|
|
184
|
+
"""Return committed live bytes, excluding outstanding reservations."""
|
|
185
|
+
with self._lock:
|
|
186
|
+
return self._spent_bytes
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def catalog_lock(self) -> threading.Lock:
|
|
190
|
+
"""Return the engine-owned lock serializing catalog/projection work."""
|
|
191
|
+
return self._catalog_lock
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def storage_available(self) -> bool:
|
|
195
|
+
"""Return whether reconciled spill storage may accept new records."""
|
|
196
|
+
with self._lock:
|
|
197
|
+
return self._storage_available
|
|
198
|
+
|
|
199
|
+
def initialize(
|
|
200
|
+
self,
|
|
201
|
+
live_total_bytes: int,
|
|
202
|
+
available_relative_paths: Iterable[str] = (),
|
|
203
|
+
*,
|
|
204
|
+
committed_bytes_by_path: Mapping[str, int] | None = None,
|
|
205
|
+
live_relative_paths: Iterable[str] | None = None,
|
|
206
|
+
live_catalog_records: Iterable[CatalogRecord] | None = None,
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Set the catalog-derived committed baseline before spill work starts."""
|
|
209
|
+
if live_total_bytes < 0:
|
|
210
|
+
raise ValueError("live_total_bytes must be non-negative")
|
|
211
|
+
available = set(available_relative_paths)
|
|
212
|
+
committed = dict(committed_bytes_by_path or {})
|
|
213
|
+
catalog_records = list(live_catalog_records or ())
|
|
214
|
+
if live_relative_paths is not None:
|
|
215
|
+
live_paths = live_relative_paths
|
|
216
|
+
elif catalog_records:
|
|
217
|
+
live_paths = (record.relative_path for record in catalog_records)
|
|
218
|
+
else:
|
|
219
|
+
live_paths = available
|
|
220
|
+
live_counts: dict[str, int] = {}
|
|
221
|
+
for relative_path in live_paths:
|
|
222
|
+
live_counts[relative_path] = live_counts.get(relative_path, 0) + 1
|
|
223
|
+
if any(byte_count < 0 for byte_count in committed.values()):
|
|
224
|
+
raise ValueError("committed record bytes must be non-negative")
|
|
225
|
+
if sum(committed.values()) > live_total_bytes:
|
|
226
|
+
raise ValueError("committed record bytes cannot exceed live_total_bytes")
|
|
227
|
+
with self._lock:
|
|
228
|
+
if self._reserved_bytes:
|
|
229
|
+
raise RuntimeError("cannot initialize SpillQuota with outstanding reservations")
|
|
230
|
+
self._spent_bytes = live_total_bytes
|
|
231
|
+
self._available_relative_paths = available
|
|
232
|
+
self._committed_bytes_by_path = committed
|
|
233
|
+
self._live_record_counts_by_path = live_counts
|
|
234
|
+
self._live_catalog_records_by_id = {record.record_id: record for record in catalog_records}
|
|
235
|
+
self._catalog_record_id_by_path = {record.relative_path: record.record_id for record in catalog_records}
|
|
236
|
+
records_by_directory: dict[str, dict[str, CatalogRecord]] = {}
|
|
237
|
+
for record in catalog_records:
|
|
238
|
+
directory = Path(record.relative_path).parent.as_posix()
|
|
239
|
+
records_by_directory.setdefault(directory, {})[record.record_id] = record
|
|
240
|
+
self._live_catalog_records_by_directory = records_by_directory
|
|
241
|
+
self._live_record_count = sum(live_counts.values())
|
|
242
|
+
self._storage_available = True
|
|
243
|
+
|
|
244
|
+
def disable_storage(self) -> None:
|
|
245
|
+
"""Fail closed after storage cannot be reconciled safely."""
|
|
246
|
+
with self._lock:
|
|
247
|
+
if self._reserved_bytes:
|
|
248
|
+
raise RuntimeError("cannot disable SpillQuota with outstanding reservations")
|
|
249
|
+
self._spent_bytes = 0
|
|
250
|
+
self._available_relative_paths.clear()
|
|
251
|
+
self._committed_bytes_by_path.clear()
|
|
252
|
+
self._live_record_counts_by_path.clear()
|
|
253
|
+
self._live_catalog_records_by_id.clear()
|
|
254
|
+
self._catalog_record_id_by_path.clear()
|
|
255
|
+
self._live_catalog_records_by_directory.clear()
|
|
256
|
+
self._live_record_count = 0
|
|
257
|
+
self._storage_available = False
|
|
258
|
+
|
|
259
|
+
def try_reserve(self, byte_count: int) -> bool:
|
|
260
|
+
"""Atomically reserve quota headroom, returning false at the limit."""
|
|
261
|
+
if byte_count < 0:
|
|
262
|
+
raise ValueError("byte_count must be non-negative")
|
|
263
|
+
with self._lock:
|
|
264
|
+
if not self._storage_available:
|
|
265
|
+
return False
|
|
266
|
+
if self._spent_bytes + self._reserved_bytes + byte_count > self._limit_bytes:
|
|
267
|
+
return False
|
|
268
|
+
self._reserved_bytes += byte_count
|
|
269
|
+
return True
|
|
270
|
+
|
|
271
|
+
def commit(
|
|
272
|
+
self,
|
|
273
|
+
reserved: int,
|
|
274
|
+
actual: int,
|
|
275
|
+
*,
|
|
276
|
+
relative_path: str | None = None,
|
|
277
|
+
catalog_record: CatalogRecord | None = None,
|
|
278
|
+
) -> None:
|
|
279
|
+
"""Replace one reservation with the actual successfully written bytes."""
|
|
280
|
+
if reserved < 0 or actual < 0:
|
|
281
|
+
raise ValueError("reserved and actual must be non-negative")
|
|
282
|
+
if actual > reserved:
|
|
283
|
+
raise ValueError("actual bytes cannot exceed the reservation")
|
|
284
|
+
if catalog_record is not None:
|
|
285
|
+
if catalog_record.bytes != actual:
|
|
286
|
+
raise ValueError("catalog record bytes must equal committed bytes")
|
|
287
|
+
if relative_path is not None and relative_path != catalog_record.relative_path:
|
|
288
|
+
raise ValueError("catalog record path must equal committed relative_path")
|
|
289
|
+
relative_path = catalog_record.relative_path
|
|
290
|
+
with self._lock:
|
|
291
|
+
if reserved > self._reserved_bytes:
|
|
292
|
+
raise RuntimeError("commit exceeds outstanding SpillQuota reservations")
|
|
293
|
+
if catalog_record is not None and (
|
|
294
|
+
catalog_record.record_id in self._live_catalog_records_by_id
|
|
295
|
+
or catalog_record.relative_path in self._catalog_record_id_by_path
|
|
296
|
+
):
|
|
297
|
+
raise RuntimeError("catalog record is already committed to SpillQuota")
|
|
298
|
+
self._reserved_bytes -= reserved
|
|
299
|
+
self._spent_bytes += actual
|
|
300
|
+
if relative_path:
|
|
301
|
+
self._available_relative_paths.add(relative_path)
|
|
302
|
+
self._committed_bytes_by_path[relative_path] = actual
|
|
303
|
+
self._live_record_counts_by_path[relative_path] = (
|
|
304
|
+
self._live_record_counts_by_path.get(relative_path, 0) + 1
|
|
305
|
+
)
|
|
306
|
+
self._live_record_count += 1
|
|
307
|
+
if catalog_record is not None:
|
|
308
|
+
self._live_catalog_records_by_id[catalog_record.record_id] = catalog_record
|
|
309
|
+
self._catalog_record_id_by_path[catalog_record.relative_path] = catalog_record.record_id
|
|
310
|
+
directory = Path(catalog_record.relative_path).parent.as_posix()
|
|
311
|
+
self._live_catalog_records_by_directory.setdefault(directory, {})[catalog_record.record_id] = (
|
|
312
|
+
catalog_record
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def reclaim(self, byte_count: int, *, relative_path: str | None = None) -> None:
|
|
316
|
+
"""Return committed quota after live spill records are evicted."""
|
|
317
|
+
if byte_count < 0:
|
|
318
|
+
raise ValueError("byte_count must be non-negative")
|
|
319
|
+
with self._lock:
|
|
320
|
+
if byte_count > self._spent_bytes:
|
|
321
|
+
raise RuntimeError("reclaim exceeds committed SpillQuota bytes")
|
|
322
|
+
self._spent_bytes -= byte_count
|
|
323
|
+
if relative_path:
|
|
324
|
+
self._available_relative_paths.discard(relative_path)
|
|
325
|
+
self._committed_bytes_by_path.pop(relative_path, None)
|
|
326
|
+
self._decrement_live_record(relative_path)
|
|
327
|
+
|
|
328
|
+
def reclaim_record(self, relative_path: str) -> int:
|
|
329
|
+
"""Return the bytes actually accounted to one evicted record."""
|
|
330
|
+
with self._lock:
|
|
331
|
+
byte_count = self._committed_bytes_by_path.pop(relative_path, 0)
|
|
332
|
+
if byte_count > self._spent_bytes:
|
|
333
|
+
raise RuntimeError("record reclaim exceeds committed SpillQuota bytes")
|
|
334
|
+
self._spent_bytes -= byte_count
|
|
335
|
+
self._available_relative_paths.discard(relative_path)
|
|
336
|
+
self._decrement_live_record(relative_path)
|
|
337
|
+
return byte_count
|
|
338
|
+
|
|
339
|
+
def is_record_available(self, relative_path: str) -> bool:
|
|
340
|
+
"""Return current in-memory availability for one cataloged record."""
|
|
341
|
+
with self._lock:
|
|
342
|
+
return relative_path in self._available_relative_paths
|
|
343
|
+
|
|
344
|
+
def has_catalog_record_id(self, record_id: str) -> bool:
|
|
345
|
+
"""Return whether one record ID is live anywhere in this session's catalog."""
|
|
346
|
+
with self._lock:
|
|
347
|
+
return record_id in self._live_catalog_records_by_id
|
|
348
|
+
|
|
349
|
+
def live_record_count(self, *, excluded_relative_paths: Iterable[str] = ()) -> int:
|
|
350
|
+
"""Return the in-memory live catalog count, excluding active-turn records."""
|
|
351
|
+
excluded = set(excluded_relative_paths)
|
|
352
|
+
with self._lock:
|
|
353
|
+
return self._live_record_count - sum(
|
|
354
|
+
self._live_record_counts_by_path.get(relative_path, 0) for relative_path in excluded
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
def live_catalog_records(self) -> tuple[CatalogRecord, ...]:
|
|
358
|
+
"""Return the ordered in-memory mirror of live authoritative catalog records."""
|
|
359
|
+
with self._lock:
|
|
360
|
+
return tuple(self._live_catalog_records_by_id.values())
|
|
361
|
+
|
|
362
|
+
def catalog_records_for_directories(self, relative_dirs: Iterable[Path]) -> tuple[CatalogRecord, ...]:
|
|
363
|
+
"""Return live records belonging only to the requested projection directories."""
|
|
364
|
+
directory_keys = {Path(relative_dir).as_posix() for relative_dir in relative_dirs}
|
|
365
|
+
with self._lock:
|
|
366
|
+
return tuple(
|
|
367
|
+
record
|
|
368
|
+
for directory in sorted(directory_keys)
|
|
369
|
+
for record in self._live_catalog_records_by_directory.get(directory, {}).values()
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def release(self, reserved: int) -> None:
|
|
373
|
+
"""Release a reservation after a clean failed or skipped write."""
|
|
374
|
+
if reserved < 0:
|
|
375
|
+
raise ValueError("reserved must be non-negative")
|
|
376
|
+
with self._lock:
|
|
377
|
+
if reserved > self._reserved_bytes:
|
|
378
|
+
raise RuntimeError("release exceeds outstanding SpillQuota reservations")
|
|
379
|
+
self._reserved_bytes -= reserved
|
|
380
|
+
|
|
381
|
+
def _decrement_live_record(self, relative_path: str) -> None:
|
|
382
|
+
count = self._live_record_counts_by_path.get(relative_path, 0)
|
|
383
|
+
if not count:
|
|
384
|
+
return
|
|
385
|
+
self._live_record_count -= 1
|
|
386
|
+
if count <= 1:
|
|
387
|
+
self._live_record_counts_by_path.pop(relative_path, None)
|
|
388
|
+
else:
|
|
389
|
+
self._live_record_counts_by_path[relative_path] = count - 1
|
|
390
|
+
record_id = self._catalog_record_id_by_path.pop(relative_path, None)
|
|
391
|
+
if record_id is not None:
|
|
392
|
+
record = self._live_catalog_records_by_id.pop(record_id, None)
|
|
393
|
+
if record is not None:
|
|
394
|
+
directory = Path(record.relative_path).parent.as_posix()
|
|
395
|
+
directory_records = self._live_catalog_records_by_directory.get(directory)
|
|
396
|
+
if directory_records is not None:
|
|
397
|
+
directory_records.pop(record_id, None)
|
|
398
|
+
if not directory_records:
|
|
399
|
+
self._live_catalog_records_by_directory.pop(directory, None)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def turn_directory_name(absolute_turn: int) -> str:
|
|
403
|
+
"""Return the zero-padded directory name for one absolute turn."""
|
|
404
|
+
if absolute_turn < 0:
|
|
405
|
+
raise ValueError("absolute_turn must be non-negative")
|
|
406
|
+
return f"turn{absolute_turn:03d}"
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def dropped_turn_relative_path(absolute_turn: int) -> Path:
|
|
410
|
+
"""Return ``compactions/dropped/turn<NNN>`` relative to a session root."""
|
|
411
|
+
return Path(COMPACTIONS_DIR_NAME) / DROPPED_DIR_NAME / turn_directory_name(absolute_turn)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def sub_agent_dropped_turn_relative_path(tool_name: str, invocation_id: str, absolute_turn: int) -> Path:
|
|
415
|
+
"""Return the isolated dropped-record directory for one sub-agent invocation."""
|
|
416
|
+
return (
|
|
417
|
+
Path(COMPACTIONS_DIR_NAME)
|
|
418
|
+
/ SUB_AGENTS_DIR_NAME
|
|
419
|
+
/ safe_platform_basename(tool_name, default="agent")
|
|
420
|
+
/ safe_platform_basename(invocation_id, default="invocation")
|
|
421
|
+
/ DROPPED_DIR_NAME
|
|
422
|
+
/ turn_directory_name(absolute_turn)
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def build_record_filename(sequence: int, tool_name: str, record_id: str) -> str:
|
|
427
|
+
"""Build ``<seq>_<bounded-tool>_<record-id>.md`` within 255 UTF-8 bytes."""
|
|
428
|
+
if sequence < 0:
|
|
429
|
+
raise ValueError("sequence must be non-negative")
|
|
430
|
+
if not _RECORD_ID_RE.fullmatch(record_id):
|
|
431
|
+
raise ValueError(f"record_id must be {RECORD_ID_HEX_CHARS} lowercase hexadecimal characters")
|
|
432
|
+
prefix = f"{sequence:03d}_"
|
|
433
|
+
suffix = f"_{record_id}{RECORD_FILE_SUFFIX}"
|
|
434
|
+
stem = bounded_safe_stem(
|
|
435
|
+
tool_name,
|
|
436
|
+
prefix=prefix,
|
|
437
|
+
suffix=suffix,
|
|
438
|
+
default="tool",
|
|
439
|
+
)
|
|
440
|
+
return f"{prefix}{stem}{suffix}"
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
_RECORD_ID_MAX_ATTEMPTS = 64
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _generate_record_id(quota: SpillQuota) -> str | None:
|
|
447
|
+
"""Draw one short record ID that no live catalog record already uses.
|
|
448
|
+
|
|
449
|
+
Eight hex characters span a 32-bit space, so a fresh draw practically
|
|
450
|
+
always succeeds immediately; the attempt cap only guards against a broken
|
|
451
|
+
entropy source. Checking the quota's session-wide live map keeps the
|
|
452
|
+
truncated IDs collision-free across record directories — the same map
|
|
453
|
+
:meth:`SpillQuota.commit` enforces uniqueness against.
|
|
454
|
+
"""
|
|
455
|
+
for _ in range(_RECORD_ID_MAX_ATTEMPTS):
|
|
456
|
+
record_id = uuid4().hex[:RECORD_ID_HEX_CHARS]
|
|
457
|
+
if not quota.has_catalog_record_id(record_id):
|
|
458
|
+
return record_id
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def write_spill_batch(
|
|
463
|
+
session_root: Path | None,
|
|
464
|
+
quota: SpillQuota | None,
|
|
465
|
+
groups: Sequence[ScopedGroup],
|
|
466
|
+
*,
|
|
467
|
+
record_dir: Path,
|
|
468
|
+
absolute_turn: int,
|
|
469
|
+
round_number: int,
|
|
470
|
+
session_id: str,
|
|
471
|
+
superseded_note: str | None = None,
|
|
472
|
+
) -> SpillBatchResult:
|
|
473
|
+
"""Write one ordered spill batch and flush catalog projections.
|
|
474
|
+
|
|
475
|
+
This synchronous API is designed to run under ``asyncio.to_thread``. A
|
|
476
|
+
missing session root/quota is a storage-unavailable policy outcome rather
|
|
477
|
+
than an I/O exception; actual filesystem failures set the durability signal.
|
|
478
|
+
|
|
479
|
+
``superseded_note`` archives the LAST_WORDS note this round's merge is
|
|
480
|
+
replacing. Unlike group records it is strictly best-effort: its substance
|
|
481
|
+
already survives inside the merged replacement, so a failed write is
|
|
482
|
+
logged, produces no manifest entry, and never sets the durability signal.
|
|
483
|
+
"""
|
|
484
|
+
if session_root is None or quota is None or not quota.storage_available:
|
|
485
|
+
entries = _policy_entries(
|
|
486
|
+
groups,
|
|
487
|
+
record_dir=record_dir,
|
|
488
|
+
absolute_turn=absolute_turn,
|
|
489
|
+
round_number=round_number,
|
|
490
|
+
reason="storage unavailable",
|
|
491
|
+
)
|
|
492
|
+
return SpillBatchResult(entries=tuple(entries))
|
|
493
|
+
|
|
494
|
+
entries: list[SpillManifestItem] = []
|
|
495
|
+
protected_record_ids: set[str] = set()
|
|
496
|
+
evicted_relative_paths: set[str] = set()
|
|
497
|
+
dirty_projection_dirs: set[Path] = set()
|
|
498
|
+
projection_dirty = False
|
|
499
|
+
round_bytes = 0
|
|
500
|
+
round_cap_reached = False
|
|
501
|
+
unexpected = False
|
|
502
|
+
root = session_root.resolve(strict=False)
|
|
503
|
+
with quota.catalog_lock:
|
|
504
|
+
for sequence, group in enumerate(groups, start=1):
|
|
505
|
+
if round_cap_reached:
|
|
506
|
+
entries.extend(
|
|
507
|
+
_group_manifest_items(
|
|
508
|
+
group,
|
|
509
|
+
record_dir=record_dir,
|
|
510
|
+
relative_path="",
|
|
511
|
+
record_id="",
|
|
512
|
+
absolute_turn=absolute_turn,
|
|
513
|
+
round_number=round_number,
|
|
514
|
+
sequence=sequence,
|
|
515
|
+
no_record_reason="round cap",
|
|
516
|
+
)
|
|
517
|
+
)
|
|
518
|
+
continue
|
|
519
|
+
|
|
520
|
+
created_at = datetime.now(UTC).isoformat()
|
|
521
|
+
record_id = _generate_record_id(quota)
|
|
522
|
+
if record_id is None:
|
|
523
|
+
logger.warning("Spill record ID space exhausted; skipping one record")
|
|
524
|
+
entries.extend(
|
|
525
|
+
_group_manifest_items(
|
|
526
|
+
group,
|
|
527
|
+
record_dir=record_dir,
|
|
528
|
+
relative_path="",
|
|
529
|
+
record_id="",
|
|
530
|
+
absolute_turn=absolute_turn,
|
|
531
|
+
round_number=round_number,
|
|
532
|
+
sequence=sequence,
|
|
533
|
+
no_record_reason="record ID exhaustion",
|
|
534
|
+
)
|
|
535
|
+
)
|
|
536
|
+
continue
|
|
537
|
+
tool = _group_tool_name(group)
|
|
538
|
+
payload = _render_record(
|
|
539
|
+
group,
|
|
540
|
+
record_id=record_id,
|
|
541
|
+
absolute_turn=absolute_turn,
|
|
542
|
+
round_number=round_number,
|
|
543
|
+
created_at=created_at,
|
|
544
|
+
session_id=session_id,
|
|
545
|
+
)
|
|
546
|
+
if round_bytes + len(payload) > PER_ROUND_CAP_BYTES:
|
|
547
|
+
round_cap_reached = True
|
|
548
|
+
entries.extend(
|
|
549
|
+
_group_manifest_items(
|
|
550
|
+
group,
|
|
551
|
+
record_dir=record_dir,
|
|
552
|
+
relative_path="",
|
|
553
|
+
record_id="",
|
|
554
|
+
absolute_turn=absolute_turn,
|
|
555
|
+
round_number=round_number,
|
|
556
|
+
sequence=sequence,
|
|
557
|
+
no_record_reason="round cap",
|
|
558
|
+
)
|
|
559
|
+
)
|
|
560
|
+
continue
|
|
561
|
+
|
|
562
|
+
try:
|
|
563
|
+
reserved = len(payload)
|
|
564
|
+
reservation_acquired = quota.try_reserve(reserved)
|
|
565
|
+
if not reservation_acquired and quota.storage_available:
|
|
566
|
+
evicted_count = len(evicted_relative_paths)
|
|
567
|
+
try:
|
|
568
|
+
_evict_until_reservable(
|
|
569
|
+
root,
|
|
570
|
+
quota,
|
|
571
|
+
reserved,
|
|
572
|
+
protected_record_ids=protected_record_ids,
|
|
573
|
+
evicted_relative_paths=evicted_relative_paths,
|
|
574
|
+
)
|
|
575
|
+
finally:
|
|
576
|
+
if len(evicted_relative_paths) > evicted_count:
|
|
577
|
+
projection_dirty = True
|
|
578
|
+
dirty_projection_dirs.update(Path(path).parent for path in evicted_relative_paths)
|
|
579
|
+
reservation_acquired = quota.try_reserve(reserved)
|
|
580
|
+
if not reservation_acquired:
|
|
581
|
+
entries.extend(
|
|
582
|
+
_group_manifest_items(
|
|
583
|
+
group,
|
|
584
|
+
record_dir=record_dir,
|
|
585
|
+
relative_path="",
|
|
586
|
+
record_id="",
|
|
587
|
+
absolute_turn=absolute_turn,
|
|
588
|
+
round_number=round_number,
|
|
589
|
+
sequence=sequence,
|
|
590
|
+
no_record_reason="quota",
|
|
591
|
+
)
|
|
592
|
+
)
|
|
593
|
+
continue
|
|
594
|
+
except OSError, RuntimeError:
|
|
595
|
+
logger.exception("Unexpected spill eviction/catalog failure")
|
|
596
|
+
unexpected = True
|
|
597
|
+
entries.extend(
|
|
598
|
+
_group_manifest_items(
|
|
599
|
+
group,
|
|
600
|
+
record_dir=record_dir,
|
|
601
|
+
relative_path="",
|
|
602
|
+
record_id="",
|
|
603
|
+
absolute_turn=absolute_turn,
|
|
604
|
+
round_number=round_number,
|
|
605
|
+
sequence=sequence,
|
|
606
|
+
no_record_reason="I/O failure",
|
|
607
|
+
)
|
|
608
|
+
)
|
|
609
|
+
continue
|
|
610
|
+
|
|
611
|
+
written_path: Path | None = None
|
|
612
|
+
try:
|
|
613
|
+
relative_path = record_dir / build_record_filename(sequence, tool, record_id)
|
|
614
|
+
full_path = root / relative_path
|
|
615
|
+
if not _record_path_within_root(root, relative_path):
|
|
616
|
+
raise OSError(f"Unsafe spill record path: {relative_path}")
|
|
617
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
618
|
+
written_path, record_id = _exclusive_write_with_retry(
|
|
619
|
+
full_path,
|
|
620
|
+
sequence=sequence,
|
|
621
|
+
tool=tool,
|
|
622
|
+
payload=payload,
|
|
623
|
+
original_record_id=record_id,
|
|
624
|
+
rerender=lambda new_id, spill_group=group, timestamp=created_at: _render_record(
|
|
625
|
+
spill_group,
|
|
626
|
+
record_id=new_id,
|
|
627
|
+
absolute_turn=absolute_turn,
|
|
628
|
+
round_number=round_number,
|
|
629
|
+
created_at=timestamp,
|
|
630
|
+
session_id=session_id,
|
|
631
|
+
),
|
|
632
|
+
regenerate_record_id=lambda: _generate_record_id(quota),
|
|
633
|
+
)
|
|
634
|
+
if written_path != full_path:
|
|
635
|
+
relative_path = written_path.relative_to(root)
|
|
636
|
+
payload = written_path.read_bytes()
|
|
637
|
+
record = CatalogRecord(
|
|
638
|
+
record_id=record_id,
|
|
639
|
+
relative_path=relative_path.as_posix(),
|
|
640
|
+
turn=absolute_turn,
|
|
641
|
+
round=round_number,
|
|
642
|
+
tool=tool,
|
|
643
|
+
bytes=len(payload),
|
|
644
|
+
created_at=created_at,
|
|
645
|
+
)
|
|
646
|
+
_append_catalog_line(root, record.to_catalog_line())
|
|
647
|
+
except OSError, UnicodeError:
|
|
648
|
+
logger.exception("Unexpected spill record/catalog write failure")
|
|
649
|
+
unexpected = True
|
|
650
|
+
quota.release(reserved)
|
|
651
|
+
if written_path is not None:
|
|
652
|
+
try:
|
|
653
|
+
written_path.unlink(missing_ok=True)
|
|
654
|
+
except OSError:
|
|
655
|
+
logger.debug("Failed to remove un-cataloged spill record %s", written_path, exc_info=True)
|
|
656
|
+
entries.extend(
|
|
657
|
+
_group_manifest_items(
|
|
658
|
+
group,
|
|
659
|
+
record_dir=record_dir,
|
|
660
|
+
relative_path="",
|
|
661
|
+
record_id="",
|
|
662
|
+
absolute_turn=absolute_turn,
|
|
663
|
+
round_number=round_number,
|
|
664
|
+
sequence=sequence,
|
|
665
|
+
no_record_reason="I/O failure",
|
|
666
|
+
)
|
|
667
|
+
)
|
|
668
|
+
continue
|
|
669
|
+
|
|
670
|
+
quota.commit(
|
|
671
|
+
reserved,
|
|
672
|
+
len(payload),
|
|
673
|
+
relative_path=relative_path.as_posix(),
|
|
674
|
+
catalog_record=record,
|
|
675
|
+
)
|
|
676
|
+
protected_record_ids.add(record_id)
|
|
677
|
+
round_bytes += len(payload)
|
|
678
|
+
projection_dirty = True
|
|
679
|
+
dirty_projection_dirs.add(relative_path.parent)
|
|
680
|
+
entries.extend(
|
|
681
|
+
_group_manifest_items(
|
|
682
|
+
group,
|
|
683
|
+
record_dir=record_dir,
|
|
684
|
+
relative_path=relative_path.as_posix(),
|
|
685
|
+
record_id=record_id,
|
|
686
|
+
absolute_turn=absolute_turn,
|
|
687
|
+
round_number=round_number,
|
|
688
|
+
sequence=sequence,
|
|
689
|
+
)
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
if superseded_note:
|
|
693
|
+
note_entry = _write_note_record(
|
|
694
|
+
root,
|
|
695
|
+
quota,
|
|
696
|
+
superseded_note,
|
|
697
|
+
record_dir=record_dir,
|
|
698
|
+
sequence=len(groups) + 1,
|
|
699
|
+
absolute_turn=absolute_turn,
|
|
700
|
+
round_number=round_number,
|
|
701
|
+
session_id=session_id,
|
|
702
|
+
)
|
|
703
|
+
if note_entry is not None:
|
|
704
|
+
entries.append(note_entry)
|
|
705
|
+
projection_dirty = True
|
|
706
|
+
dirty_projection_dirs.add(Path(note_entry.relative_path).parent)
|
|
707
|
+
|
|
708
|
+
if projection_dirty:
|
|
709
|
+
try:
|
|
710
|
+
_rebuild_manifest_projections(
|
|
711
|
+
root,
|
|
712
|
+
live_records=quota.catalog_records_for_directories(dirty_projection_dirs),
|
|
713
|
+
relative_dirs=dirty_projection_dirs,
|
|
714
|
+
)
|
|
715
|
+
except OSError, RuntimeError:
|
|
716
|
+
# Records and authoritative catalog lines are already durable.
|
|
717
|
+
# Keep the usable stubs, but signal the unexpected projection
|
|
718
|
+
# failure so the strategy applies its recovery-sidecar policy.
|
|
719
|
+
logger.exception("Unexpected spill manifest projection failure")
|
|
720
|
+
unexpected = True
|
|
721
|
+
durable_directories = {root / CATALOG_RELATIVE_PATH.parent}
|
|
722
|
+
durable_directories.update((root / Path(path)).parent for path in evicted_relative_paths)
|
|
723
|
+
durable_directories.update(
|
|
724
|
+
(root / Path(entry.relative_path)).parent for entry in entries if entry.relative_path
|
|
725
|
+
)
|
|
726
|
+
_fsync_directory_chains(root, durable_directories)
|
|
727
|
+
|
|
728
|
+
return SpillBatchResult(
|
|
729
|
+
entries=tuple(entries),
|
|
730
|
+
unexpected_io_failure=unexpected,
|
|
731
|
+
evicted_relative_paths=frozenset(evicted_relative_paths),
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _write_note_record(
|
|
736
|
+
root: Path,
|
|
737
|
+
quota: SpillQuota,
|
|
738
|
+
note_text: str,
|
|
739
|
+
*,
|
|
740
|
+
record_dir: Path,
|
|
741
|
+
sequence: int,
|
|
742
|
+
absolute_turn: int,
|
|
743
|
+
round_number: int,
|
|
744
|
+
session_id: str,
|
|
745
|
+
) -> SpillManifestItem | None:
|
|
746
|
+
"""Archive one superseded LAST_WORDS note under the round's record dir.
|
|
747
|
+
|
|
748
|
+
Caller holds ``quota.catalog_lock``. Best-effort by contract: the note's
|
|
749
|
+
substance already lives on inside the merged replacement, so every failure
|
|
750
|
+
(ID exhaustion, quota pressure, I/O) is logged and swallowed without
|
|
751
|
+
setting the batch durability signal, and quota pressure never evicts older
|
|
752
|
+
records to make room for a note copy.
|
|
753
|
+
"""
|
|
754
|
+
created_at = datetime.now(UTC).isoformat()
|
|
755
|
+
record_id = _generate_record_id(quota)
|
|
756
|
+
if record_id is None:
|
|
757
|
+
logger.warning("Spill record ID space exhausted; skipping LAST_WORDS note record")
|
|
758
|
+
return None
|
|
759
|
+
payload = _render_note_record(
|
|
760
|
+
note_text,
|
|
761
|
+
record_id=record_id,
|
|
762
|
+
absolute_turn=absolute_turn,
|
|
763
|
+
round_number=round_number,
|
|
764
|
+
created_at=created_at,
|
|
765
|
+
session_id=session_id,
|
|
766
|
+
)
|
|
767
|
+
reserved = len(payload)
|
|
768
|
+
if not quota.try_reserve(reserved):
|
|
769
|
+
logger.debug("Session spill quota exhausted; skipping LAST_WORDS note record")
|
|
770
|
+
return None
|
|
771
|
+
written_path: Path | None = None
|
|
772
|
+
try:
|
|
773
|
+
relative_path = record_dir / build_record_filename(sequence, NOTE_RECORD_TOOL_NAME, record_id)
|
|
774
|
+
full_path = root / relative_path
|
|
775
|
+
if not _record_path_within_root(root, relative_path):
|
|
776
|
+
raise OSError(f"Unsafe spill record path: {relative_path}")
|
|
777
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
778
|
+
written_path, record_id = _exclusive_write_with_retry(
|
|
779
|
+
full_path,
|
|
780
|
+
sequence=sequence,
|
|
781
|
+
tool=NOTE_RECORD_TOOL_NAME,
|
|
782
|
+
payload=payload,
|
|
783
|
+
original_record_id=record_id,
|
|
784
|
+
rerender=lambda new_id, timestamp=created_at: _render_note_record(
|
|
785
|
+
note_text,
|
|
786
|
+
record_id=new_id,
|
|
787
|
+
absolute_turn=absolute_turn,
|
|
788
|
+
round_number=round_number,
|
|
789
|
+
created_at=timestamp,
|
|
790
|
+
session_id=session_id,
|
|
791
|
+
),
|
|
792
|
+
regenerate_record_id=lambda: _generate_record_id(quota),
|
|
793
|
+
)
|
|
794
|
+
if written_path != full_path:
|
|
795
|
+
relative_path = written_path.relative_to(root)
|
|
796
|
+
payload = written_path.read_bytes()
|
|
797
|
+
record = CatalogRecord(
|
|
798
|
+
record_id=record_id,
|
|
799
|
+
relative_path=relative_path.as_posix(),
|
|
800
|
+
turn=absolute_turn,
|
|
801
|
+
round=round_number,
|
|
802
|
+
tool=NOTE_RECORD_TOOL_NAME,
|
|
803
|
+
bytes=len(payload),
|
|
804
|
+
created_at=created_at,
|
|
805
|
+
kind=CATALOG_KIND_NOTE,
|
|
806
|
+
)
|
|
807
|
+
_append_catalog_line(root, record.to_catalog_line())
|
|
808
|
+
except OSError, UnicodeError:
|
|
809
|
+
logger.warning("Failed to archive the superseded LAST_WORDS note", exc_info=True)
|
|
810
|
+
quota.release(reserved)
|
|
811
|
+
if written_path is not None:
|
|
812
|
+
try:
|
|
813
|
+
written_path.unlink(missing_ok=True)
|
|
814
|
+
except OSError:
|
|
815
|
+
logger.debug("Failed to remove un-cataloged spill record %s", written_path, exc_info=True)
|
|
816
|
+
return None
|
|
817
|
+
quota.commit(reserved, len(payload), relative_path=relative_path.as_posix(), catalog_record=record)
|
|
818
|
+
return SpillManifestItem(
|
|
819
|
+
record_id=record_id,
|
|
820
|
+
# Synthetic marker: last_words_state.ManifestEntry.from_state requires a
|
|
821
|
+
# non-empty group_id, and an empty one would silently drop the entry
|
|
822
|
+
# from the rendered manifest.
|
|
823
|
+
group_id=NOTE_RECORD_GROUP_ID,
|
|
824
|
+
record_dir=record_dir.as_posix(),
|
|
825
|
+
relative_path=relative_path.as_posix(),
|
|
826
|
+
turn=absolute_turn,
|
|
827
|
+
round=round_number,
|
|
828
|
+
sequence=sequence,
|
|
829
|
+
tool=NOTE_RECORD_TOOL_NAME,
|
|
830
|
+
display_argument="superseded by this round's note",
|
|
831
|
+
outcome="merged",
|
|
832
|
+
size_chars=len(note_text),
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def catalog_live_records(session_root: Path, quota: SpillQuota | None = None) -> list[CatalogRecord]:
|
|
837
|
+
"""Return live records after applying append-only tombstones."""
|
|
838
|
+
root = session_root.resolve(strict=False)
|
|
839
|
+
if quota is None:
|
|
840
|
+
return [record for record in _read_live_catalog(root) if _record_path_within_root(root, record.relative_path)]
|
|
841
|
+
with quota.catalog_lock:
|
|
842
|
+
return [record for record in _read_live_catalog(root) if _record_path_within_root(root, record.relative_path)]
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def catalog_live_record_count(session_root: Path, quota: SpillQuota | None = None) -> int:
|
|
846
|
+
"""Return the number of live catalog records for a turn-start snapshot."""
|
|
847
|
+
return len(catalog_live_records(session_root, quota))
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def owned_record_directory_relative_path(value: str | Path) -> bool:
|
|
851
|
+
"""Return whether *value* is one of the two owned spill directory layouts."""
|
|
852
|
+
path = Path(value)
|
|
853
|
+
return not path.is_absolute() and ".." not in path.parts and _safe_record_directory_relative_path(path)
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def owned_record_relative_path(value: str | Path) -> bool:
|
|
857
|
+
"""Return whether *value* is an owned session-relative spill record path."""
|
|
858
|
+
path = Path(value)
|
|
859
|
+
return _safe_record_relative_path(path.as_posix())
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def available_record_path(session_root: Path, relative_path: str | Path) -> bool:
|
|
863
|
+
"""Return whether an owned record currently resolves to a regular session file."""
|
|
864
|
+
try:
|
|
865
|
+
root = session_root.resolve(strict=False)
|
|
866
|
+
except OSError, RuntimeError:
|
|
867
|
+
return False
|
|
868
|
+
if not _record_path_within_root(root, relative_path):
|
|
869
|
+
return False
|
|
870
|
+
try:
|
|
871
|
+
return (root / relative_path).is_file()
|
|
872
|
+
except OSError:
|
|
873
|
+
return False
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def catalog_path_for_read(session_root: Path) -> Path | None:
|
|
877
|
+
"""Return the readable catalog path only when every component is session-owned."""
|
|
878
|
+
try:
|
|
879
|
+
root = session_root.resolve(strict=False)
|
|
880
|
+
catalog = _validated_catalog_path(root)
|
|
881
|
+
resolved_catalog = catalog.resolve(strict=True)
|
|
882
|
+
if not resolved_catalog.is_file() or resolved_catalog != catalog:
|
|
883
|
+
return None
|
|
884
|
+
except OSError, RuntimeError:
|
|
885
|
+
return None
|
|
886
|
+
return catalog
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def reconcile_spill_storage(session_root: Path, quota: SpillQuota) -> SpillReconciliationResult:
|
|
890
|
+
"""Delete un-cataloged records, rebuild projections, and initialize quota."""
|
|
891
|
+
root = session_root.resolve(strict=False)
|
|
892
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
893
|
+
with quota.catalog_lock:
|
|
894
|
+
try:
|
|
895
|
+
live = [
|
|
896
|
+
record for record in _read_live_catalog(root) if _record_path_within_root(root, record.relative_path)
|
|
897
|
+
]
|
|
898
|
+
except _UnsafeCatalogPathError:
|
|
899
|
+
# An unsafe catalog cannot be the source of truth for orphan
|
|
900
|
+
# deletion. Preserve the on-disk records, expose none as
|
|
901
|
+
# available, and disable future spill writes so they cannot follow
|
|
902
|
+
# the link or consume unaccounted quota.
|
|
903
|
+
logger.warning("Ignoring unsafe spill catalog path under %s", root)
|
|
904
|
+
quota.disable_storage()
|
|
905
|
+
return SpillReconciliationResult(0, 0, frozenset())
|
|
906
|
+
live_paths = {record.relative_path for record in live}
|
|
907
|
+
deleted = 0
|
|
908
|
+
compactions = root / COMPACTIONS_DIR_NAME
|
|
909
|
+
if compactions.is_dir():
|
|
910
|
+
for dropped_dir in compactions.rglob(DROPPED_DIR_NAME):
|
|
911
|
+
try:
|
|
912
|
+
relative_dropped_dir = dropped_dir.relative_to(root)
|
|
913
|
+
except ValueError:
|
|
914
|
+
continue
|
|
915
|
+
if not _dropped_directory_within_root(root, relative_dropped_dir) or not dropped_dir.is_dir():
|
|
916
|
+
continue
|
|
917
|
+
for candidate in dropped_dir.rglob(f"*{RECORD_FILE_SUFFIX}"):
|
|
918
|
+
if candidate.name == MANIFEST_FILE_NAME:
|
|
919
|
+
continue
|
|
920
|
+
try:
|
|
921
|
+
relative = candidate.relative_to(root).as_posix()
|
|
922
|
+
except ValueError:
|
|
923
|
+
continue
|
|
924
|
+
if not _record_path_within_root(root, relative):
|
|
925
|
+
continue
|
|
926
|
+
if relative in live_paths:
|
|
927
|
+
continue
|
|
928
|
+
candidate.unlink(missing_ok=True)
|
|
929
|
+
deleted += 1
|
|
930
|
+
|
|
931
|
+
available: set[str] = set()
|
|
932
|
+
committed_bytes_by_path: dict[str, int] = {}
|
|
933
|
+
total = 0
|
|
934
|
+
for record in live:
|
|
935
|
+
path = root / record.relative_path
|
|
936
|
+
try:
|
|
937
|
+
if not path.is_file():
|
|
938
|
+
continue
|
|
939
|
+
size = path.stat().st_size
|
|
940
|
+
except FileNotFoundError:
|
|
941
|
+
continue
|
|
942
|
+
available.add(record.relative_path)
|
|
943
|
+
committed_bytes_by_path[record.relative_path] = size
|
|
944
|
+
total += size
|
|
945
|
+
quota.initialize(
|
|
946
|
+
total,
|
|
947
|
+
available,
|
|
948
|
+
committed_bytes_by_path=committed_bytes_by_path,
|
|
949
|
+
live_catalog_records=live,
|
|
950
|
+
)
|
|
951
|
+
try:
|
|
952
|
+
_rebuild_manifest_projections(root, live_records=live)
|
|
953
|
+
except OSError, RuntimeError:
|
|
954
|
+
# The append-only catalog and records are authoritative. A stale
|
|
955
|
+
# convenience projection must not prevent session hydration.
|
|
956
|
+
logger.warning("Unable to rebuild spill manifest projections under %s", root, exc_info=True)
|
|
957
|
+
return SpillReconciliationResult(
|
|
958
|
+
live_total_bytes=total,
|
|
959
|
+
live_record_count=len(live),
|
|
960
|
+
available_relative_paths=frozenset(available),
|
|
961
|
+
deleted_orphans=deleted,
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _exclusive_write_with_retry(
|
|
966
|
+
path: Path,
|
|
967
|
+
*,
|
|
968
|
+
sequence: int,
|
|
969
|
+
tool: str,
|
|
970
|
+
payload: bytes,
|
|
971
|
+
original_record_id: str,
|
|
972
|
+
rerender: Any,
|
|
973
|
+
regenerate_record_id: Any,
|
|
974
|
+
) -> tuple[Path, str]:
|
|
975
|
+
record_id = original_record_id
|
|
976
|
+
candidate = path
|
|
977
|
+
candidate_payload = payload
|
|
978
|
+
os_module = cast(Any, os)
|
|
979
|
+
for attempt in range(2):
|
|
980
|
+
try:
|
|
981
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
982
|
+
if get_platform().is_windows:
|
|
983
|
+
flags |= os_module.O_BINARY
|
|
984
|
+
else:
|
|
985
|
+
# O_EXCL rejects existing final components; O_NOFOLLOW makes
|
|
986
|
+
# that intent explicit for platforms that support symlinks.
|
|
987
|
+
flags |= os_module.O_NOFOLLOW
|
|
988
|
+
descriptor = os.open(candidate, flags, 0o600)
|
|
989
|
+
except FileExistsError:
|
|
990
|
+
if attempt:
|
|
991
|
+
raise
|
|
992
|
+
new_id = regenerate_record_id()
|
|
993
|
+
if new_id is None:
|
|
994
|
+
raise
|
|
995
|
+
record_id = new_id
|
|
996
|
+
candidate = path.with_name(build_record_filename(sequence, tool, record_id))
|
|
997
|
+
candidate_payload = rerender(record_id)
|
|
998
|
+
continue
|
|
999
|
+
try:
|
|
1000
|
+
with os.fdopen(descriptor, "wb") as output:
|
|
1001
|
+
descriptor = -1
|
|
1002
|
+
output.write(candidate_payload)
|
|
1003
|
+
output.flush()
|
|
1004
|
+
os.fsync(output.fileno())
|
|
1005
|
+
except BaseException:
|
|
1006
|
+
if descriptor >= 0:
|
|
1007
|
+
os.close(descriptor)
|
|
1008
|
+
try:
|
|
1009
|
+
candidate.unlink(missing_ok=True)
|
|
1010
|
+
except OSError:
|
|
1011
|
+
logger.debug("Failed to remove partial spill record %s", candidate, exc_info=True)
|
|
1012
|
+
raise
|
|
1013
|
+
return candidate, record_id
|
|
1014
|
+
raise RuntimeError("exclusive spill record retry exhausted")
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def _render_record(
|
|
1018
|
+
group: ScopedGroup,
|
|
1019
|
+
*,
|
|
1020
|
+
record_id: str,
|
|
1021
|
+
absolute_turn: int,
|
|
1022
|
+
round_number: int,
|
|
1023
|
+
created_at: str,
|
|
1024
|
+
session_id: str,
|
|
1025
|
+
) -> bytes:
|
|
1026
|
+
lines = [
|
|
1027
|
+
"# Dropped context record",
|
|
1028
|
+
"",
|
|
1029
|
+
f"- Record ID: `{record_id}`",
|
|
1030
|
+
f"- Group ID: `{group.group_id}`",
|
|
1031
|
+
f"- Turn: {absolute_turn}",
|
|
1032
|
+
f"- Round: {round_number}",
|
|
1033
|
+
f"- Timestamp: {created_at}",
|
|
1034
|
+
f"- Session ID: `{session_id}`",
|
|
1035
|
+
"",
|
|
1036
|
+
]
|
|
1037
|
+
calls = _group_calls(group)
|
|
1038
|
+
assistant_context = _assistant_group_text(group)
|
|
1039
|
+
matched_result_ids: set[int] = set()
|
|
1040
|
+
if calls:
|
|
1041
|
+
if assistant_context:
|
|
1042
|
+
lines.extend(["## Assistant context", "", assistant_context, ""])
|
|
1043
|
+
results = _group_results(group)
|
|
1044
|
+
for call in calls:
|
|
1045
|
+
result = _matching_result(call, results)
|
|
1046
|
+
if result is not None:
|
|
1047
|
+
matched_result_ids.add(id(result))
|
|
1048
|
+
stamp = execution_stamp_from_metadata(result.additional_properties) if result is not None else None
|
|
1049
|
+
requested = _requested_call_arguments(call)
|
|
1050
|
+
effective = stamp.get("effective_args") if stamp is not None else None
|
|
1051
|
+
lines.extend([f"## Call: {_call_tool_name(call)}", ""])
|
|
1052
|
+
if stamp is not None and effective is not None and _effective_arguments_differ(requested, stamp):
|
|
1053
|
+
lines.extend(
|
|
1054
|
+
[
|
|
1055
|
+
"### Arguments (requested)",
|
|
1056
|
+
"```json",
|
|
1057
|
+
_pretty_arguments(requested),
|
|
1058
|
+
"```",
|
|
1059
|
+
"",
|
|
1060
|
+
"### Arguments (effective)",
|
|
1061
|
+
"```json",
|
|
1062
|
+
_pretty_arguments(effective),
|
|
1063
|
+
"```",
|
|
1064
|
+
"",
|
|
1065
|
+
]
|
|
1066
|
+
)
|
|
1067
|
+
else:
|
|
1068
|
+
label = "### Arguments (as requested)" if stamp is None else "### Arguments (requested/effective)"
|
|
1069
|
+
lines.extend([label, "```json", _pretty_arguments(requested), "```", ""])
|
|
1070
|
+
lines.extend([f"Outcome: `{_structured_outcome(result, stamp)}`", "", "## Result", ""])
|
|
1071
|
+
lines.append(_result_record_text(result) if result is not None else "(missing result)")
|
|
1072
|
+
lines.append("")
|
|
1073
|
+
else:
|
|
1074
|
+
lines.extend(["## Assistant text", "", assistant_context, ""])
|
|
1075
|
+
supplemental = _supplemental_group_contents(group, matched_result_ids)
|
|
1076
|
+
if supplemental:
|
|
1077
|
+
lines.extend(["## Additional group content", ""])
|
|
1078
|
+
for content in supplemental:
|
|
1079
|
+
lines.extend([f"### {content.type}", "", _render_result_value(content) or "(empty)", ""])
|
|
1080
|
+
lines.append(_RECORD_TERMINATOR.rstrip("\n"))
|
|
1081
|
+
return _cap_record_bytes("\n".join(lines) + "\n")
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def _render_note_record(
|
|
1085
|
+
note_text: str,
|
|
1086
|
+
*,
|
|
1087
|
+
record_id: str,
|
|
1088
|
+
absolute_turn: int,
|
|
1089
|
+
round_number: int,
|
|
1090
|
+
created_at: str,
|
|
1091
|
+
session_id: str,
|
|
1092
|
+
) -> bytes:
|
|
1093
|
+
lines = [
|
|
1094
|
+
"# Superseded LAST_WORDS note",
|
|
1095
|
+
"",
|
|
1096
|
+
f"- Record ID: `{record_id}`",
|
|
1097
|
+
f"- Turn: {absolute_turn}",
|
|
1098
|
+
f"- Round: {round_number}",
|
|
1099
|
+
f"- Timestamp: {created_at}",
|
|
1100
|
+
f"- Session ID: `{session_id}`",
|
|
1101
|
+
"",
|
|
1102
|
+
"This compaction round merged the note below into its replacement; this",
|
|
1103
|
+
"file preserves the pre-merge original verbatim.",
|
|
1104
|
+
"",
|
|
1105
|
+
"## Note",
|
|
1106
|
+
"",
|
|
1107
|
+
note_text,
|
|
1108
|
+
"",
|
|
1109
|
+
_RECORD_TERMINATOR.rstrip("\n"),
|
|
1110
|
+
]
|
|
1111
|
+
return _cap_record_bytes("\n".join(lines) + "\n")
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _cap_record_bytes(text: str) -> bytes:
|
|
1115
|
+
# Tool inputs/results can contain POSIX surrogate-escaped path bytes.
|
|
1116
|
+
# Preserve those as visible escape sequences so every ``.md`` record
|
|
1117
|
+
# remains strict UTF-8 and ``read_file`` never replaces archived detail.
|
|
1118
|
+
raw = text.encode("utf-8", errors="backslashreplace")
|
|
1119
|
+
if len(raw) <= PER_RECORD_CAP_BYTES:
|
|
1120
|
+
return raw
|
|
1121
|
+
terminator = _RECORD_TERMINATOR.encode()
|
|
1122
|
+
marker = _RECORD_TRUNCATION_TEMPLATE.format(size=len(raw)).encode("utf-8")
|
|
1123
|
+
body = raw.removesuffix(terminator)
|
|
1124
|
+
keep = max(0, PER_RECORD_CAP_BYTES - len(marker) - len(terminator))
|
|
1125
|
+
head_size = (keep + 1) // 2
|
|
1126
|
+
tail_size = keep - head_size
|
|
1127
|
+
head = body[:head_size].decode("utf-8", errors="ignore").encode("utf-8")
|
|
1128
|
+
tail = body[len(body) - tail_size :].decode("utf-8", errors="ignore").encode("utf-8") if tail_size else b""
|
|
1129
|
+
capped = head + marker + tail + terminator
|
|
1130
|
+
if len(capped) > PER_RECORD_CAP_BYTES:
|
|
1131
|
+
capped = capped[: PER_RECORD_CAP_BYTES - len(terminator)] + terminator
|
|
1132
|
+
return capped
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def _append_catalog_line(root: Path, value: Mapping[str, Any]) -> None:
|
|
1136
|
+
# Validate before mkdir so an existing symlinked parent cannot redirect
|
|
1137
|
+
# directory creation, then validate again immediately before opening.
|
|
1138
|
+
catalog = _validated_catalog_path(root)
|
|
1139
|
+
catalog.parent.mkdir(parents=True, exist_ok=True)
|
|
1140
|
+
catalog = _validated_catalog_path(root)
|
|
1141
|
+
serialized = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
1142
|
+
if get_platform().is_windows:
|
|
1143
|
+
with catalog.open("a+b") as output:
|
|
1144
|
+
_append_catalog_payload(output, serialized)
|
|
1145
|
+
return
|
|
1146
|
+
|
|
1147
|
+
# O_NOFOLLOW closes the final-component race left by the resolved-path
|
|
1148
|
+
# check on POSIX. Keep the platform-only flag inside the guarded body so
|
|
1149
|
+
# this module remains importable on Windows.
|
|
1150
|
+
posix_os = cast(Any, os)
|
|
1151
|
+
descriptor = posix_os.open(
|
|
1152
|
+
catalog,
|
|
1153
|
+
posix_os.O_APPEND | posix_os.O_CREAT | posix_os.O_RDWR | posix_os.O_NOFOLLOW,
|
|
1154
|
+
0o600,
|
|
1155
|
+
)
|
|
1156
|
+
try:
|
|
1157
|
+
with os.fdopen(descriptor, "r+b") as output:
|
|
1158
|
+
descriptor = -1
|
|
1159
|
+
_append_catalog_payload(output, serialized)
|
|
1160
|
+
finally:
|
|
1161
|
+
if descriptor >= 0:
|
|
1162
|
+
os.close(descriptor)
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def _append_catalog_payload(output: BinaryIO, serialized: bytes) -> None:
|
|
1166
|
+
"""Append one isolated JSON line after any crash-torn final fragment."""
|
|
1167
|
+
output.seek(0, os.SEEK_END)
|
|
1168
|
+
size = output.tell()
|
|
1169
|
+
separator = b""
|
|
1170
|
+
if size:
|
|
1171
|
+
output.seek(-1, os.SEEK_END)
|
|
1172
|
+
if output.read(1) != b"\n":
|
|
1173
|
+
separator = b"\n"
|
|
1174
|
+
output.seek(0, os.SEEK_END)
|
|
1175
|
+
output.write(separator + serialized + b"\n")
|
|
1176
|
+
output.flush()
|
|
1177
|
+
os.fsync(output.fileno())
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def _fsync_directory_chains(root: Path, directories: Iterable[Path]) -> None:
|
|
1181
|
+
"""Persist newly created or removed entries from spill leaves through the session root."""
|
|
1182
|
+
pending: set[Path] = set()
|
|
1183
|
+
for directory in directories:
|
|
1184
|
+
current = directory
|
|
1185
|
+
while current.is_relative_to(root):
|
|
1186
|
+
pending.add(current)
|
|
1187
|
+
if current == root:
|
|
1188
|
+
break
|
|
1189
|
+
current = current.parent
|
|
1190
|
+
for directory in sorted(pending, key=lambda path: len(path.parts), reverse=True):
|
|
1191
|
+
fsync_directory(directory)
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
def _read_live_catalog(root: Path) -> list[CatalogRecord]:
|
|
1195
|
+
catalog = _validated_catalog_path(root)
|
|
1196
|
+
try:
|
|
1197
|
+
if get_platform().is_windows:
|
|
1198
|
+
payload = catalog.read_bytes()
|
|
1199
|
+
else:
|
|
1200
|
+
posix_os = cast(Any, os)
|
|
1201
|
+
descriptor = posix_os.open(catalog, posix_os.O_RDONLY | posix_os.O_NOFOLLOW)
|
|
1202
|
+
try:
|
|
1203
|
+
with os.fdopen(descriptor, "rb") as input_file:
|
|
1204
|
+
descriptor = -1
|
|
1205
|
+
payload = input_file.read()
|
|
1206
|
+
finally:
|
|
1207
|
+
if descriptor >= 0:
|
|
1208
|
+
os.close(descriptor)
|
|
1209
|
+
except FileNotFoundError:
|
|
1210
|
+
return []
|
|
1211
|
+
lines = payload.splitlines()
|
|
1212
|
+
# ``live`` doubles as the replay order: dict insertion order matches the
|
|
1213
|
+
# catalog semantics exactly — the first add of an ID claims a position, a
|
|
1214
|
+
# tombstone erases it, and a later re-add (including a reused tombstoned
|
|
1215
|
+
# ID) appends at the end as the newest record. This keeps replay O(1) per
|
|
1216
|
+
# line where an explicit order list would make tombstones quadratic.
|
|
1217
|
+
live: dict[str, CatalogRecord] = {}
|
|
1218
|
+
record_id_by_path: dict[str, str] = {}
|
|
1219
|
+
for line_number, raw_line in enumerate(lines, start=1):
|
|
1220
|
+
try:
|
|
1221
|
+
line = raw_line.decode("utf-8")
|
|
1222
|
+
except UnicodeDecodeError:
|
|
1223
|
+
logger.debug("Ignoring invalid UTF-8 spill catalog line %d", line_number)
|
|
1224
|
+
continue
|
|
1225
|
+
try:
|
|
1226
|
+
value = json.loads(line)
|
|
1227
|
+
except TypeError, ValueError:
|
|
1228
|
+
logger.debug("Ignoring malformed spill catalog line %d", line_number)
|
|
1229
|
+
continue
|
|
1230
|
+
if not isinstance(value, Mapping):
|
|
1231
|
+
continue
|
|
1232
|
+
record_id = value.get("record_id")
|
|
1233
|
+
if not isinstance(record_id, str) or not record_id:
|
|
1234
|
+
continue
|
|
1235
|
+
if value.get("tombstone") is True:
|
|
1236
|
+
live.pop(record_id, None)
|
|
1237
|
+
continue
|
|
1238
|
+
if record_id in live:
|
|
1239
|
+
logger.debug(
|
|
1240
|
+
"Ignoring duplicate spill catalog record ID %s on line %d",
|
|
1241
|
+
record_id,
|
|
1242
|
+
line_number,
|
|
1243
|
+
)
|
|
1244
|
+
continue
|
|
1245
|
+
record = _catalog_record_from_mapping(value)
|
|
1246
|
+
if record is None:
|
|
1247
|
+
continue
|
|
1248
|
+
path_owner = record_id_by_path.get(record.relative_path)
|
|
1249
|
+
if path_owner is not None and path_owner != record_id:
|
|
1250
|
+
logger.debug(
|
|
1251
|
+
"Ignoring duplicate spill catalog path on line %d (owned by record %s)",
|
|
1252
|
+
line_number,
|
|
1253
|
+
path_owner,
|
|
1254
|
+
)
|
|
1255
|
+
continue
|
|
1256
|
+
record_id_by_path[record.relative_path] = record_id
|
|
1257
|
+
live[record_id] = record
|
|
1258
|
+
return list(live.values())
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def _validated_catalog_path(root: Path) -> Path:
|
|
1262
|
+
"""Return the fixed catalog path only when every component is session-owned."""
|
|
1263
|
+
catalog = root / CATALOG_RELATIVE_PATH
|
|
1264
|
+
try:
|
|
1265
|
+
has_link_component = _path_has_link_component(root, CATALOG_RELATIVE_PATH)
|
|
1266
|
+
parent = catalog.parent.resolve(strict=False)
|
|
1267
|
+
resolved = catalog.resolve(strict=False)
|
|
1268
|
+
except (OSError, RuntimeError) as exc:
|
|
1269
|
+
raise _UnsafeCatalogPathError(f"Cannot validate spill catalog path: {catalog}") from exc
|
|
1270
|
+
if has_link_component or not parent.is_relative_to(root) or not resolved.is_relative_to(root):
|
|
1271
|
+
raise _UnsafeCatalogPathError(f"Unsafe spill catalog path: {catalog}")
|
|
1272
|
+
return catalog
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def _catalog_record_from_mapping(value: Mapping[str, Any]) -> CatalogRecord | None:
|
|
1276
|
+
record_id = value.get("record_id")
|
|
1277
|
+
relative_path = value.get("relative_path")
|
|
1278
|
+
turn = value.get("turn")
|
|
1279
|
+
round_number = value.get("round")
|
|
1280
|
+
tool = value.get("tool")
|
|
1281
|
+
byte_count = value.get("bytes")
|
|
1282
|
+
created_at = value.get("created_at")
|
|
1283
|
+
if (
|
|
1284
|
+
not isinstance(record_id, str)
|
|
1285
|
+
or not record_id
|
|
1286
|
+
or not isinstance(relative_path, str)
|
|
1287
|
+
or not is_utf8_encodable(relative_path)
|
|
1288
|
+
or not _safe_record_relative_path(relative_path)
|
|
1289
|
+
or not isinstance(turn, int)
|
|
1290
|
+
or turn < 0
|
|
1291
|
+
or not isinstance(round_number, int)
|
|
1292
|
+
or round_number < 0
|
|
1293
|
+
or not isinstance(tool, str)
|
|
1294
|
+
or not is_utf8_encodable(record_id)
|
|
1295
|
+
or not is_utf8_encodable(tool)
|
|
1296
|
+
or not isinstance(byte_count, int)
|
|
1297
|
+
or byte_count < 0
|
|
1298
|
+
or not isinstance(created_at, str)
|
|
1299
|
+
or not is_utf8_encodable(created_at)
|
|
1300
|
+
):
|
|
1301
|
+
return None
|
|
1302
|
+
# Tolerant by design: catalogs written before the field existed (and any
|
|
1303
|
+
# hand-edited line) fall back to the group kind rather than being dropped.
|
|
1304
|
+
kind = value.get("kind", CATALOG_KIND_GROUP)
|
|
1305
|
+
if not isinstance(kind, str) or not kind or not is_utf8_encodable(kind):
|
|
1306
|
+
kind = CATALOG_KIND_GROUP
|
|
1307
|
+
return CatalogRecord(record_id, relative_path, turn, round_number, tool, byte_count, created_at, kind)
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def _safe_record_relative_path(value: str) -> bool:
|
|
1311
|
+
"""Return whether a catalog path has one of the two spill-record layouts."""
|
|
1312
|
+
path = Path(value)
|
|
1313
|
+
if not value or path.is_absolute() or ".." in path.parts or path.name == MANIFEST_FILE_NAME:
|
|
1314
|
+
return False
|
|
1315
|
+
return _safe_record_directory_relative_path(path.parent) and path.suffix == RECORD_FILE_SUFFIX
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
def _safe_record_directory_relative_path(path: Path) -> bool:
|
|
1319
|
+
"""Return whether *path* is an owned main/sub-agent turn directory."""
|
|
1320
|
+
parts = path.parts
|
|
1321
|
+
main_layout = (
|
|
1322
|
+
len(parts) == 3
|
|
1323
|
+
and parts[:2] == (COMPACTIONS_DIR_NAME, DROPPED_DIR_NAME)
|
|
1324
|
+
and _TURN_DIRECTORY_RE.fullmatch(parts[2]) is not None
|
|
1325
|
+
)
|
|
1326
|
+
sub_agent_layout = (
|
|
1327
|
+
len(parts) == 6
|
|
1328
|
+
and parts[0] == COMPACTIONS_DIR_NAME
|
|
1329
|
+
and parts[1] == SUB_AGENTS_DIR_NAME
|
|
1330
|
+
and bool(parts[2])
|
|
1331
|
+
and bool(parts[3])
|
|
1332
|
+
and parts[4] == DROPPED_DIR_NAME
|
|
1333
|
+
and _TURN_DIRECTORY_RE.fullmatch(parts[5]) is not None
|
|
1334
|
+
)
|
|
1335
|
+
return main_layout or sub_agent_layout
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
def _safe_dropped_directory_relative_path(path: Path) -> bool:
|
|
1339
|
+
"""Return whether *path* is an owned main/sub-agent dropped-record root."""
|
|
1340
|
+
parts = path.parts
|
|
1341
|
+
main_layout = parts == (COMPACTIONS_DIR_NAME, DROPPED_DIR_NAME)
|
|
1342
|
+
sub_agent_layout = (
|
|
1343
|
+
len(parts) == 5
|
|
1344
|
+
and parts[0] == COMPACTIONS_DIR_NAME
|
|
1345
|
+
and parts[1] == SUB_AGENTS_DIR_NAME
|
|
1346
|
+
and bool(parts[2])
|
|
1347
|
+
and bool(parts[3])
|
|
1348
|
+
and parts[4] == DROPPED_DIR_NAME
|
|
1349
|
+
)
|
|
1350
|
+
return main_layout or sub_agent_layout
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
def _record_path_within_root(root: Path, relative_path: str | Path) -> bool:
|
|
1354
|
+
"""Reject record targets redirected away from their owned lexical layout."""
|
|
1355
|
+
relative = Path(relative_path)
|
|
1356
|
+
value = relative.as_posix()
|
|
1357
|
+
if not _safe_record_relative_path(value):
|
|
1358
|
+
return False
|
|
1359
|
+
try:
|
|
1360
|
+
if _path_has_link_component(root, relative):
|
|
1361
|
+
return False
|
|
1362
|
+
resolved_relative = (root / relative).resolve(strict=False).relative_to(root)
|
|
1363
|
+
except OSError, RuntimeError, ValueError:
|
|
1364
|
+
return False
|
|
1365
|
+
return _safe_record_relative_path(resolved_relative.as_posix())
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def _dropped_directory_within_root(root: Path, relative_dir: str | Path) -> bool:
|
|
1369
|
+
"""Reject dropped-record roots redirected away from their owned lexical layout."""
|
|
1370
|
+
relative = Path(relative_dir)
|
|
1371
|
+
if not _safe_dropped_directory_relative_path(relative):
|
|
1372
|
+
return False
|
|
1373
|
+
try:
|
|
1374
|
+
if _path_has_link_component(root, relative):
|
|
1375
|
+
return False
|
|
1376
|
+
resolved_relative = (root / relative).resolve(strict=False).relative_to(root)
|
|
1377
|
+
except OSError, RuntimeError, ValueError:
|
|
1378
|
+
return False
|
|
1379
|
+
return _safe_dropped_directory_relative_path(resolved_relative)
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def _record_directory_within_root(root: Path, relative_dir: str | Path) -> bool:
|
|
1383
|
+
"""Reject spill directories redirected away from their owned lexical layout."""
|
|
1384
|
+
relative = Path(relative_dir)
|
|
1385
|
+
if not owned_record_directory_relative_path(relative):
|
|
1386
|
+
return False
|
|
1387
|
+
try:
|
|
1388
|
+
if _path_has_link_component(root, relative):
|
|
1389
|
+
return False
|
|
1390
|
+
resolved_relative = (root / relative).resolve(strict=False).relative_to(root)
|
|
1391
|
+
except OSError, RuntimeError, ValueError:
|
|
1392
|
+
return False
|
|
1393
|
+
return _safe_record_directory_relative_path(resolved_relative)
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
def _path_has_link_component(root: Path, relative_path: Path) -> bool:
|
|
1397
|
+
"""Return whether an existing relative component is a symlink or junction."""
|
|
1398
|
+
current = root
|
|
1399
|
+
for part in relative_path.parts:
|
|
1400
|
+
current /= part
|
|
1401
|
+
if current.is_symlink() or current.is_junction():
|
|
1402
|
+
return True
|
|
1403
|
+
return False
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
def _evict_until_reservable(
|
|
1407
|
+
root: Path,
|
|
1408
|
+
quota: SpillQuota,
|
|
1409
|
+
required: int,
|
|
1410
|
+
*,
|
|
1411
|
+
protected_record_ids: set[str],
|
|
1412
|
+
evicted_relative_paths: set[str],
|
|
1413
|
+
) -> None:
|
|
1414
|
+
for record in quota.live_catalog_records():
|
|
1415
|
+
if record.record_id in protected_record_ids:
|
|
1416
|
+
continue
|
|
1417
|
+
path_is_safe = _record_path_within_root(root, record.relative_path)
|
|
1418
|
+
path = root / record.relative_path
|
|
1419
|
+
_append_catalog_line(
|
|
1420
|
+
root,
|
|
1421
|
+
{
|
|
1422
|
+
"record_id": record.record_id,
|
|
1423
|
+
"tombstone": True,
|
|
1424
|
+
"created_at": datetime.now(UTC).isoformat(),
|
|
1425
|
+
},
|
|
1426
|
+
)
|
|
1427
|
+
try:
|
|
1428
|
+
if path_is_safe:
|
|
1429
|
+
path.unlink(missing_ok=True)
|
|
1430
|
+
except OSError:
|
|
1431
|
+
# The tombstone is already authoritative. Restore the live catalog
|
|
1432
|
+
# entry when physical deletion failed so a later attempt can retry
|
|
1433
|
+
# without hiding the only record copy.
|
|
1434
|
+
try:
|
|
1435
|
+
_append_catalog_line(root, record.to_catalog_line())
|
|
1436
|
+
except OSError:
|
|
1437
|
+
logger.exception("Failed to restore spill catalog entry after eviction unlink failure")
|
|
1438
|
+
raise
|
|
1439
|
+
evicted_relative_paths.add(record.relative_path)
|
|
1440
|
+
quota.reclaim_record(record.relative_path)
|
|
1441
|
+
if quota.try_reserve(required):
|
|
1442
|
+
quota.release(required)
|
|
1443
|
+
return
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
def _rebuild_manifest_projections(
|
|
1447
|
+
root: Path,
|
|
1448
|
+
*,
|
|
1449
|
+
live_records: Sequence[CatalogRecord] | None = None,
|
|
1450
|
+
relative_dirs: Iterable[Path] | None = None,
|
|
1451
|
+
) -> None:
|
|
1452
|
+
source = _read_live_catalog(root) if live_records is None else live_records
|
|
1453
|
+
live = [record for record in source if _record_path_within_root(root, record.relative_path)]
|
|
1454
|
+
grouped: dict[Path, list[CatalogRecord]] = {}
|
|
1455
|
+
for record in live:
|
|
1456
|
+
grouped.setdefault(Path(record.relative_path).parent, []).append(record)
|
|
1457
|
+
|
|
1458
|
+
if relative_dirs is None:
|
|
1459
|
+
target_dirs = set(grouped)
|
|
1460
|
+
compactions = root / COMPACTIONS_DIR_NAME
|
|
1461
|
+
if compactions.is_dir():
|
|
1462
|
+
for path in compactions.rglob(MANIFEST_FILE_NAME):
|
|
1463
|
+
try:
|
|
1464
|
+
relative_dir = path.parent.relative_to(root)
|
|
1465
|
+
except ValueError:
|
|
1466
|
+
continue
|
|
1467
|
+
if _record_directory_within_root(root, relative_dir):
|
|
1468
|
+
target_dirs.add(relative_dir)
|
|
1469
|
+
else:
|
|
1470
|
+
target_dirs = {
|
|
1471
|
+
Path(relative_dir)
|
|
1472
|
+
for relative_dir in relative_dirs
|
|
1473
|
+
if _record_directory_within_root(root, Path(relative_dir))
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
for relative_dir in sorted(target_dirs, key=lambda path: path.as_posix()):
|
|
1477
|
+
records = grouped.get(relative_dir, [])
|
|
1478
|
+
directory = root / relative_dir
|
|
1479
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
1480
|
+
if not records:
|
|
1481
|
+
atomic_write_text(directory / MANIFEST_FILE_NAME, "# Dropped context records\n\n(No live records.)\n")
|
|
1482
|
+
continue
|
|
1483
|
+
lines = ["# Dropped context records", ""]
|
|
1484
|
+
for record in records:
|
|
1485
|
+
filename = Path(record.relative_path).name
|
|
1486
|
+
lines.append(
|
|
1487
|
+
f"- r{record.round} `{record.tool}` — [{filename}]({filename}) — "
|
|
1488
|
+
f"{record.bytes} bytes — {record.created_at}"
|
|
1489
|
+
)
|
|
1490
|
+
lines.append("")
|
|
1491
|
+
atomic_write_text(directory / MANIFEST_FILE_NAME, "\n".join(lines))
|
|
1492
|
+
|
|
1493
|
+
|
|
1494
|
+
def _policy_entries(
|
|
1495
|
+
groups: Sequence[ScopedGroup],
|
|
1496
|
+
*,
|
|
1497
|
+
record_dir: Path,
|
|
1498
|
+
absolute_turn: int,
|
|
1499
|
+
round_number: int,
|
|
1500
|
+
reason: str,
|
|
1501
|
+
) -> list[SpillManifestItem]:
|
|
1502
|
+
entries: list[SpillManifestItem] = []
|
|
1503
|
+
for sequence, group in enumerate(groups, start=1):
|
|
1504
|
+
entries.extend(
|
|
1505
|
+
_group_manifest_items(
|
|
1506
|
+
group,
|
|
1507
|
+
record_dir=record_dir,
|
|
1508
|
+
relative_path="",
|
|
1509
|
+
record_id="",
|
|
1510
|
+
absolute_turn=absolute_turn,
|
|
1511
|
+
round_number=round_number,
|
|
1512
|
+
sequence=sequence,
|
|
1513
|
+
no_record_reason=reason,
|
|
1514
|
+
)
|
|
1515
|
+
)
|
|
1516
|
+
return entries
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
def _group_manifest_items(
|
|
1520
|
+
group: ScopedGroup,
|
|
1521
|
+
*,
|
|
1522
|
+
record_dir: Path,
|
|
1523
|
+
relative_path: str,
|
|
1524
|
+
record_id: str,
|
|
1525
|
+
absolute_turn: int,
|
|
1526
|
+
round_number: int,
|
|
1527
|
+
sequence: int,
|
|
1528
|
+
no_record_reason: str = "",
|
|
1529
|
+
) -> list[SpillManifestItem]:
|
|
1530
|
+
calls = _group_calls(group)
|
|
1531
|
+
if not calls:
|
|
1532
|
+
text = _assistant_group_text(group)
|
|
1533
|
+
return [
|
|
1534
|
+
SpillManifestItem(
|
|
1535
|
+
record_id=record_id,
|
|
1536
|
+
group_id=group.group_id,
|
|
1537
|
+
record_dir=record_dir.as_posix(),
|
|
1538
|
+
relative_path=relative_path,
|
|
1539
|
+
turn=absolute_turn,
|
|
1540
|
+
round=round_number,
|
|
1541
|
+
sequence=sequence,
|
|
1542
|
+
tool="assistant",
|
|
1543
|
+
display_argument="",
|
|
1544
|
+
outcome="",
|
|
1545
|
+
size_chars=len(text),
|
|
1546
|
+
assistant_text=True,
|
|
1547
|
+
available=not no_record_reason,
|
|
1548
|
+
no_record_reason=no_record_reason,
|
|
1549
|
+
)
|
|
1550
|
+
]
|
|
1551
|
+
results = _group_results(group)
|
|
1552
|
+
items: list[SpillManifestItem] = []
|
|
1553
|
+
for call in calls:
|
|
1554
|
+
result = _matching_result(call, results)
|
|
1555
|
+
stamp = execution_stamp_from_metadata(result.additional_properties) if result is not None else None
|
|
1556
|
+
items.append(
|
|
1557
|
+
SpillManifestItem(
|
|
1558
|
+
record_id=record_id,
|
|
1559
|
+
group_id=group.group_id,
|
|
1560
|
+
record_dir=record_dir.as_posix(),
|
|
1561
|
+
relative_path=relative_path,
|
|
1562
|
+
turn=absolute_turn,
|
|
1563
|
+
round=round_number,
|
|
1564
|
+
sequence=sequence,
|
|
1565
|
+
tool=_call_tool_name(call),
|
|
1566
|
+
display_argument=_display_argument(call, stamp),
|
|
1567
|
+
outcome=_structured_outcome(result, stamp),
|
|
1568
|
+
size_chars=len(_result_record_text(result)) if result is not None else 0,
|
|
1569
|
+
available=not no_record_reason,
|
|
1570
|
+
no_record_reason=no_record_reason,
|
|
1571
|
+
)
|
|
1572
|
+
)
|
|
1573
|
+
return items
|
|
1574
|
+
|
|
1575
|
+
|
|
1576
|
+
def _group_calls(group: ScopedGroup) -> list[Content]:
|
|
1577
|
+
return [
|
|
1578
|
+
content for message in group.messages for content in message.contents if content.type in TOOL_CALL_CONTENT_TYPES
|
|
1579
|
+
]
|
|
1580
|
+
|
|
1581
|
+
|
|
1582
|
+
def _group_results(group: ScopedGroup) -> list[Content]:
|
|
1583
|
+
return [content for message in group.messages for content in message.contents if content.type.endswith("_result")]
|
|
1584
|
+
|
|
1585
|
+
|
|
1586
|
+
def _matching_result(call: Content, results: Sequence[Content]) -> Content | None:
|
|
1587
|
+
if call.call_id:
|
|
1588
|
+
return next((result for result in results if result.call_id == call.call_id), None)
|
|
1589
|
+
if call.type == "image_generation_tool_call" and call.image_id:
|
|
1590
|
+
return next(
|
|
1591
|
+
(
|
|
1592
|
+
result
|
|
1593
|
+
for result in results
|
|
1594
|
+
if result.type == "image_generation_tool_result" and result.image_id == call.image_id
|
|
1595
|
+
),
|
|
1596
|
+
None,
|
|
1597
|
+
)
|
|
1598
|
+
return None
|
|
1599
|
+
|
|
1600
|
+
|
|
1601
|
+
def _group_tool_name(group: ScopedGroup) -> str:
|
|
1602
|
+
calls = _group_calls(group)
|
|
1603
|
+
return _call_tool_name(calls[0]) if calls else "assistant"
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
def _call_tool_name(call: Content) -> str:
|
|
1607
|
+
if call.type == "function_call":
|
|
1608
|
+
value = call.name or "tool"
|
|
1609
|
+
else:
|
|
1610
|
+
value = call.tool_name or call.type.removesuffix("_tool_call") or "tool"
|
|
1611
|
+
return _strict_utf8_text(value)
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
def _strict_utf8_text(value: str) -> str:
|
|
1615
|
+
"""Make provider-controlled labels safe for paths and strict UTF-8 state."""
|
|
1616
|
+
return value.encode("utf-8", errors="backslashreplace").decode("utf-8")
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
def _requested_call_arguments(call: Content) -> str:
|
|
1620
|
+
"""Return the complete provider-visible input for every supported call shape."""
|
|
1621
|
+
if call.type in {"function_call", "hosted_tool_call", "mcp_server_tool_call", "search_tool_call"}:
|
|
1622
|
+
return _canonical_arguments(call.arguments)
|
|
1623
|
+
if call.type == "code_interpreter_tool_call":
|
|
1624
|
+
return _canonical_arguments({"inputs": _record_json_value(call.inputs or [])})
|
|
1625
|
+
if call.type == "image_generation_tool_call":
|
|
1626
|
+
return _canonical_arguments({"image_id": call.image_id})
|
|
1627
|
+
if call.type == "shell_tool_call":
|
|
1628
|
+
return _canonical_arguments(
|
|
1629
|
+
{
|
|
1630
|
+
"commands": call.commands or [],
|
|
1631
|
+
"max_output_length": call.max_output_length,
|
|
1632
|
+
"status": call.status,
|
|
1633
|
+
"timeout_ms": call.timeout_ms,
|
|
1634
|
+
}
|
|
1635
|
+
)
|
|
1636
|
+
return _canonical_arguments(_record_json_value(call.to_dict()))
|
|
1637
|
+
|
|
1638
|
+
|
|
1639
|
+
def _record_json_value(value: Any) -> Any:
|
|
1640
|
+
"""Convert nested call input to JSON state without embedding raw images."""
|
|
1641
|
+
if isinstance(value, Content):
|
|
1642
|
+
if is_image_content(value):
|
|
1643
|
+
return _image_placeholder(value)
|
|
1644
|
+
return _record_json_value(value.to_dict())
|
|
1645
|
+
if isinstance(value, Mapping):
|
|
1646
|
+
return {str(key): _record_json_value(item) for key, item in value.items()}
|
|
1647
|
+
if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
|
|
1648
|
+
return [_record_json_value(item) for item in value]
|
|
1649
|
+
return value
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
def _canonical_arguments(arguments: Any) -> str:
|
|
1653
|
+
if isinstance(arguments, str):
|
|
1654
|
+
try:
|
|
1655
|
+
parsed = json.loads(arguments)
|
|
1656
|
+
except ValueError:
|
|
1657
|
+
return arguments
|
|
1658
|
+
return json.dumps(parsed, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
|
|
1659
|
+
try:
|
|
1660
|
+
return canonicalize_effective_arguments(arguments if arguments is not None else {})
|
|
1661
|
+
except Exception:
|
|
1662
|
+
return json.dumps(arguments, ensure_ascii=False, sort_keys=True, default=str)
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
def _pretty_arguments(serialized: str) -> str:
|
|
1666
|
+
try:
|
|
1667
|
+
parsed = json.loads(serialized)
|
|
1668
|
+
except ValueError:
|
|
1669
|
+
return serialized
|
|
1670
|
+
return json.dumps(parsed, ensure_ascii=False, indent=2, sort_keys=True, default=str)
|
|
1671
|
+
|
|
1672
|
+
|
|
1673
|
+
def _effective_arguments_differ(requested: str, stamp: Mapping[str, str]) -> bool:
|
|
1674
|
+
digest = hashlib.sha256(requested.encode("utf-8", errors="surrogatepass")).hexdigest()[:16]
|
|
1675
|
+
return digest != stamp.get("effective_args_digest")
|
|
1676
|
+
|
|
1677
|
+
|
|
1678
|
+
def _structured_outcome(result: Content | None, stamp: Mapping[str, str] | None) -> str:
|
|
1679
|
+
if stamp is not None:
|
|
1680
|
+
if stamp.get("outcome") == "ok":
|
|
1681
|
+
return "ok"
|
|
1682
|
+
kind = stamp.get("error_kind")
|
|
1683
|
+
return f"error({kind})" if kind else "error(unknown)"
|
|
1684
|
+
if result is not None:
|
|
1685
|
+
kind = result.additional_properties.get(TOOL_ERROR_KIND_METADATA_KEY)
|
|
1686
|
+
if isinstance(kind, str) and kind:
|
|
1687
|
+
return f"error({kind})"
|
|
1688
|
+
shell_outcome = _shell_result_outcome(result)
|
|
1689
|
+
if shell_outcome is not None:
|
|
1690
|
+
return shell_outcome
|
|
1691
|
+
return "unknown"
|
|
1692
|
+
|
|
1693
|
+
|
|
1694
|
+
def _shell_result_outcome(result: Content) -> str | None:
|
|
1695
|
+
if result.type != "shell_tool_result" or not result.outputs:
|
|
1696
|
+
return None
|
|
1697
|
+
outputs = [output for output in result.outputs if output.type == "shell_command_output"]
|
|
1698
|
+
if not outputs:
|
|
1699
|
+
return None
|
|
1700
|
+
if any(output.timed_out is True for output in outputs):
|
|
1701
|
+
return "error(timeout)"
|
|
1702
|
+
if any(output.exit_code is not None and output.exit_code != 0 for output in outputs):
|
|
1703
|
+
return "error(exit_code)"
|
|
1704
|
+
if all(output.exit_code == 0 for output in outputs):
|
|
1705
|
+
return "ok"
|
|
1706
|
+
return None
|
|
1707
|
+
|
|
1708
|
+
|
|
1709
|
+
def _result_record_text(result: Content | None) -> str:
|
|
1710
|
+
if result is None:
|
|
1711
|
+
return ""
|
|
1712
|
+
if result.type in {"function_result", "hosted_tool_result", "search_tool_result"}:
|
|
1713
|
+
value = result.items or result.result
|
|
1714
|
+
elif result.type == "mcp_server_tool_result":
|
|
1715
|
+
value = result.output
|
|
1716
|
+
else:
|
|
1717
|
+
value = result.outputs
|
|
1718
|
+
return _render_result_value(value)
|
|
1719
|
+
|
|
1720
|
+
|
|
1721
|
+
def _render_result_value(value: Any) -> str:
|
|
1722
|
+
if isinstance(value, Content):
|
|
1723
|
+
if is_image_content(value):
|
|
1724
|
+
return _image_placeholder(value)
|
|
1725
|
+
if value.type == "text":
|
|
1726
|
+
return value.text or ""
|
|
1727
|
+
if value.type == "shell_command_output":
|
|
1728
|
+
return _render_shell_command_output(value)
|
|
1729
|
+
return json.dumps(value.to_dict(), ensure_ascii=False, indent=2, default=str)
|
|
1730
|
+
if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
|
|
1731
|
+
return "\n".join(part for item in value if (part := _render_result_value(item)))
|
|
1732
|
+
if isinstance(value, Mapping | list):
|
|
1733
|
+
return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True, default=str)
|
|
1734
|
+
return "" if value is None else str(value)
|
|
1735
|
+
|
|
1736
|
+
|
|
1737
|
+
def _render_shell_command_output(output: Content) -> str:
|
|
1738
|
+
blocks: list[str] = []
|
|
1739
|
+
if output.stdout is not None:
|
|
1740
|
+
blocks.append(f"[stdout]\n{output.stdout}")
|
|
1741
|
+
if output.stderr is not None:
|
|
1742
|
+
blocks.append(f"[stderr]\n{output.stderr}")
|
|
1743
|
+
if output.exit_code is not None:
|
|
1744
|
+
blocks.append(f"[exit_code]\n{output.exit_code}")
|
|
1745
|
+
if output.timed_out is not None:
|
|
1746
|
+
blocks.append(f"[timed_out]\n{str(output.timed_out).lower()}")
|
|
1747
|
+
return "\n\n".join(blocks)
|
|
1748
|
+
|
|
1749
|
+
|
|
1750
|
+
def _image_placeholder(content: Content) -> str:
|
|
1751
|
+
uri = content.uri or ""
|
|
1752
|
+
media_type = content.media_type or content.additional_properties.get("media_type") or "image"
|
|
1753
|
+
byte_length, identity = hashable_image_identity(uri)
|
|
1754
|
+
if uri.startswith("data:"):
|
|
1755
|
+
encoded = uri.split(",", 1)[1] if "," in uri else uri
|
|
1756
|
+
try:
|
|
1757
|
+
payload = base64.b64decode(encoded, validate=False)
|
|
1758
|
+
except ValueError, TypeError:
|
|
1759
|
+
payload = identity.encode("ascii", errors="ignore")
|
|
1760
|
+
digest = hashlib.sha256(payload).hexdigest()[:16]
|
|
1761
|
+
else:
|
|
1762
|
+
digest = hashlib.sha256(identity.encode("utf-8", errors="surrogatepass")).hexdigest()[:16]
|
|
1763
|
+
size = str(byte_length) if byte_length is not None else "unknown bytes"
|
|
1764
|
+
return f"[image: {media_type}, {size}, sha256:{digest}]"
|
|
1765
|
+
|
|
1766
|
+
|
|
1767
|
+
def _assistant_group_text(group: ScopedGroup) -> str:
|
|
1768
|
+
blocks: list[str] = []
|
|
1769
|
+
for message in group.messages:
|
|
1770
|
+
for content in message.contents:
|
|
1771
|
+
if content.type == "text" and content.text:
|
|
1772
|
+
blocks.append(content.text)
|
|
1773
|
+
elif content.type == "text_reasoning":
|
|
1774
|
+
if content.text:
|
|
1775
|
+
blocks.append(f"[reasoning]\n{content.text}")
|
|
1776
|
+
if content.protected_data:
|
|
1777
|
+
blocks.append(f"[protected reasoning data]\n{content.protected_data}")
|
|
1778
|
+
return "\n".join(blocks)
|
|
1779
|
+
|
|
1780
|
+
|
|
1781
|
+
def _supplemental_group_contents(group: ScopedGroup, matched_result_ids: set[int]) -> list[Content]:
|
|
1782
|
+
return [
|
|
1783
|
+
content
|
|
1784
|
+
for message in group.messages
|
|
1785
|
+
for content in message.contents
|
|
1786
|
+
if content.type not in TOOL_CALL_CONTENT_TYPES
|
|
1787
|
+
and content.type not in {"text", "text_reasoning"}
|
|
1788
|
+
and id(content) not in matched_result_ids
|
|
1789
|
+
]
|
|
1790
|
+
|
|
1791
|
+
|
|
1792
|
+
_DISPLAY_ARG_KEYS_BY_TOOL: dict[str, tuple[str, ...]] = {
|
|
1793
|
+
"read_file": ("path", "file_path"),
|
|
1794
|
+
"view_image": ("path", "file_path"),
|
|
1795
|
+
"write_file": ("path", "file_path"),
|
|
1796
|
+
"edit_file": ("path", "file_path"),
|
|
1797
|
+
"grep": ("pattern", "path"),
|
|
1798
|
+
"glob": ("pattern", "path"),
|
|
1799
|
+
"execute": ("command",),
|
|
1800
|
+
"run_command": ("command",),
|
|
1801
|
+
"run_shell": ("command",),
|
|
1802
|
+
"bash": ("command",),
|
|
1803
|
+
"cmd": ("command",),
|
|
1804
|
+
"csh": ("command",),
|
|
1805
|
+
"dash": ("command",),
|
|
1806
|
+
"fish": ("command",),
|
|
1807
|
+
"git_bash": ("command",),
|
|
1808
|
+
"ksh": ("command",),
|
|
1809
|
+
"powershell": ("command",),
|
|
1810
|
+
"pwsh": ("command",),
|
|
1811
|
+
"sh": ("command",),
|
|
1812
|
+
"tcsh": ("command",),
|
|
1813
|
+
"zsh": ("command",),
|
|
1814
|
+
"shell": ("commands",),
|
|
1815
|
+
"code_interpreter": ("inputs",),
|
|
1816
|
+
"image_generation": ("image_id",),
|
|
1817
|
+
"web_search": ("query",),
|
|
1818
|
+
"search": ("query",),
|
|
1819
|
+
"fetch": ("url",),
|
|
1820
|
+
"open_url": ("url",),
|
|
1821
|
+
"compress_context": ("marker_id",),
|
|
1822
|
+
"recall_context": ("name", "marker_id"),
|
|
1823
|
+
"load_skill": ("name",),
|
|
1824
|
+
"read_skill_resource": ("name", "path"),
|
|
1825
|
+
"run_skill_script": ("name", "path"),
|
|
1826
|
+
}
|
|
1827
|
+
# Shell-family tools also surface the model-stated intent after the command;
|
|
1828
|
+
# derived, so a newly registered shell name picks it up automatically.
|
|
1829
|
+
_EXTRA_DISPLAY_ARG_KEYS_BY_TOOL: dict[str, tuple[str, ...]] = {
|
|
1830
|
+
tool: ("reason",) for tool, keys in _DISPLAY_ARG_KEYS_BY_TOOL.items() if keys == ("command",)
|
|
1831
|
+
}
|
|
1832
|
+
# Catalog-line argument preview caps, sized to keep whole commands, paths, and
|
|
1833
|
+
# shell intents visible; the LAST_WORDS manifest's overall char/line budget
|
|
1834
|
+
# evicts oldest entries when a long listing overflows, so a longer preview
|
|
1835
|
+
# trades tail entries, not correctness. ``last_words_state`` derives its
|
|
1836
|
+
# stored-field bound from them.
|
|
1837
|
+
DISPLAY_ARGUMENT_DEFAULT_MAX_CHARS = 120
|
|
1838
|
+
# Paths middle-truncate (``D:\Repos\…\test.py``) so the filename tail stays.
|
|
1839
|
+
DISPLAY_ARGUMENT_PATH_MAX_CHARS = 256
|
|
1840
|
+
# Shell tools append the model-stated ``reason="…"`` after the command.
|
|
1841
|
+
DISPLAY_ARGUMENT_REASON_MAX_CHARS = 80
|
|
1842
|
+
_DISPLAY_ARGUMENT_KEY_CAPS = {
|
|
1843
|
+
"path": DISPLAY_ARGUMENT_PATH_MAX_CHARS,
|
|
1844
|
+
"file_path": DISPLAY_ARGUMENT_PATH_MAX_CHARS,
|
|
1845
|
+
"reason": DISPLAY_ARGUMENT_REASON_MAX_CHARS,
|
|
1846
|
+
}
|
|
1847
|
+
_MIDDLE_TRUNCATED_DISPLAY_KEYS = frozenset({"path", "file_path"})
|
|
1848
|
+
_SENSITIVE_KEY_RE = re.compile(r"(?:token|password|passwd|secret|api[_-]?key|authorization|credential)", re.IGNORECASE)
|
|
1849
|
+
_SENSITIVE_VALUE_RE = re.compile(
|
|
1850
|
+
r"(?:bearer\s+[A-Za-z0-9._~+/=-]+|(?:sk|ghp|github_pat|xox[baprs])[-_][A-Za-z0-9_-]{12,}|"
|
|
1851
|
+
r"eyJ[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{8,}\.)",
|
|
1852
|
+
re.IGNORECASE,
|
|
1853
|
+
)
|
|
1854
|
+
_SENSITIVE_URL_PARAMETER_NAMES = frozenset(
|
|
1855
|
+
{
|
|
1856
|
+
"accesskey",
|
|
1857
|
+
"accesstoken",
|
|
1858
|
+
"apikey",
|
|
1859
|
+
"auth",
|
|
1860
|
+
"authorization",
|
|
1861
|
+
"clientsecret",
|
|
1862
|
+
"credential",
|
|
1863
|
+
"credentials",
|
|
1864
|
+
"key",
|
|
1865
|
+
"passwd",
|
|
1866
|
+
"password",
|
|
1867
|
+
"privatekey",
|
|
1868
|
+
"secret",
|
|
1869
|
+
"sig",
|
|
1870
|
+
"signature",
|
|
1871
|
+
"token",
|
|
1872
|
+
}
|
|
1873
|
+
)
|
|
1874
|
+
|
|
1875
|
+
|
|
1876
|
+
def _display_argument(call: Content, stamp: Mapping[str, str] | None) -> str:
|
|
1877
|
+
serialized = stamp.get("effective_args") if stamp is not None else _requested_call_arguments(call)
|
|
1878
|
+
if serialized is None:
|
|
1879
|
+
return ""
|
|
1880
|
+
try:
|
|
1881
|
+
arguments = json.loads(serialized)
|
|
1882
|
+
except TypeError, ValueError:
|
|
1883
|
+
return ""
|
|
1884
|
+
if not isinstance(arguments, Mapping):
|
|
1885
|
+
return ""
|
|
1886
|
+
tool_name = _call_tool_name(call)
|
|
1887
|
+
segments: list[str] = []
|
|
1888
|
+
for key in _DISPLAY_ARG_KEYS_BY_TOOL.get(tool_name, ()):
|
|
1889
|
+
segment = _display_argument_segment(arguments, key)
|
|
1890
|
+
if segment is not None:
|
|
1891
|
+
segments.append(segment)
|
|
1892
|
+
break
|
|
1893
|
+
for key in _EXTRA_DISPLAY_ARG_KEYS_BY_TOOL.get(tool_name, ()):
|
|
1894
|
+
segment = _display_argument_segment(arguments, key)
|
|
1895
|
+
if segment is not None:
|
|
1896
|
+
segments.append(segment)
|
|
1897
|
+
return ", ".join(segments)
|
|
1898
|
+
|
|
1899
|
+
|
|
1900
|
+
def _display_argument_segment(arguments: Mapping[str, Any], key: str) -> str | None:
|
|
1901
|
+
"""Render one ``key=value`` preview segment; None when absent or filtered."""
|
|
1902
|
+
if key not in arguments or _SENSITIVE_KEY_RE.search(key):
|
|
1903
|
+
return None
|
|
1904
|
+
value = arguments[key]
|
|
1905
|
+
if key == "url" and _url_contains_credentials(value):
|
|
1906
|
+
return None
|
|
1907
|
+
encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str).replace("\n", "\\n")
|
|
1908
|
+
if _SENSITIVE_KEY_RE.search(encoded) or _SENSITIVE_VALUE_RE.search(encoded) or _mapping_has_sensitive_key(value):
|
|
1909
|
+
return None
|
|
1910
|
+
rendered = f"{key}={encoded}"
|
|
1911
|
+
cap = _DISPLAY_ARGUMENT_KEY_CAPS.get(key, DISPLAY_ARGUMENT_DEFAULT_MAX_CHARS)
|
|
1912
|
+
if len(rendered) <= cap:
|
|
1913
|
+
return rendered
|
|
1914
|
+
if key in _MIDDLE_TRUNCATED_DISPLAY_KEYS:
|
|
1915
|
+
# The filename tail is the informative end of a long path — drop the
|
|
1916
|
+
# middle, never the tail.
|
|
1917
|
+
head = (cap - 1) // 2
|
|
1918
|
+
return rendered[:head] + "…" + rendered[len(rendered) - (cap - 1 - head) :]
|
|
1919
|
+
return rendered[: cap - 1] + "…"
|
|
1920
|
+
|
|
1921
|
+
|
|
1922
|
+
def _url_contains_credentials(value: Any) -> bool:
|
|
1923
|
+
"""Return whether a display URL contains userinfo or secret-bearing parameters."""
|
|
1924
|
+
if not isinstance(value, str):
|
|
1925
|
+
return False
|
|
1926
|
+
try:
|
|
1927
|
+
parsed = urlsplit(value)
|
|
1928
|
+
except ValueError:
|
|
1929
|
+
return True
|
|
1930
|
+
if parsed.username is not None or parsed.password is not None:
|
|
1931
|
+
return True
|
|
1932
|
+
for component in (parsed.query, parsed.fragment):
|
|
1933
|
+
for name, _value in parse_qsl(component, keep_blank_values=True):
|
|
1934
|
+
normalized = re.sub(r"[^a-z0-9]", "", name.casefold())
|
|
1935
|
+
if _SENSITIVE_KEY_RE.search(name) or normalized in _SENSITIVE_URL_PARAMETER_NAMES:
|
|
1936
|
+
return True
|
|
1937
|
+
return False
|
|
1938
|
+
|
|
1939
|
+
|
|
1940
|
+
def _mapping_has_sensitive_key(value: Any) -> bool:
|
|
1941
|
+
if isinstance(value, Mapping):
|
|
1942
|
+
return any(
|
|
1943
|
+
_SENSITIVE_KEY_RE.search(str(key)) or _mapping_has_sensitive_key(nested) for key, nested in value.items()
|
|
1944
|
+
)
|
|
1945
|
+
if isinstance(value, list | tuple):
|
|
1946
|
+
return any(_mapping_has_sensitive_key(item) for item in value)
|
|
1947
|
+
return False
|