Repository: MoonshotAI/kimi-cli Branch: main Commit: 8283d785a6b1 Files: 818 Total size: 4.7 MB Directory structure: gitextract_akc1nk5y/ ├── .agents/ │ └── skills/ │ ├── codex-worker/ │ │ └── SKILL.md │ ├── feature-smoke-test/ │ │ ├── SKILL.md │ │ ├── references/ │ │ │ └── prompt-patterns.md │ │ └── scripts/ │ │ └── inspect_session.py │ ├── gen-changelog/ │ │ └── SKILL.md │ ├── gen-docs/ │ │ └── SKILL.md │ ├── gen-rust/ │ │ └── SKILL.md │ ├── pull-request/ │ │ └── SKILL.md │ ├── release/ │ │ └── SKILL.md │ ├── translate-docs/ │ │ └── SKILL.md │ └── worktree-status/ │ └── SKILL.md ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── 1-bug-report.yml │ │ ├── 2-feature-request.yml │ │ └── config.yml │ ├── actions/ │ │ └── macos-code-sign/ │ │ └── action.yml │ ├── dependabot.yml │ ├── pr-title-checker-config.json │ ├── pull_request_template.md │ └── workflows/ │ ├── ci-docs.yml │ ├── ci-kimi-cli.yml │ ├── ci-kimi-sdk.yml │ ├── ci-kosong.yml │ ├── ci-pykaos.yml │ ├── docs-pages.yml │ ├── pr-title-checker.yml │ ├── release-kimi-cli.yml │ ├── release-kimi-sdk.yml │ ├── release-kosong.yml │ ├── release-pykaos.yml │ ├── translator.yml │ └── typos.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version ├── AGENTS.md ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── NOTICE ├── README.md ├── SECURITY.md ├── docs/ │ ├── .gitignore │ ├── .pre-commit-config.yaml │ ├── .vitepress/ │ │ ├── config.ts │ │ └── theme/ │ │ ├── index.ts │ │ └── style.css │ ├── AGENTS.md │ ├── en/ │ │ ├── configuration/ │ │ │ ├── config-files.md │ │ │ ├── data-locations.md │ │ │ ├── env-vars.md │ │ │ ├── overrides.md │ │ │ └── providers.md │ │ ├── customization/ │ │ │ ├── agents.md │ │ │ ├── mcp.md │ │ │ ├── print-mode.md │ │ │ ├── skills.md │ │ │ └── wire-mode.md │ │ ├── faq.md │ │ ├── guides/ │ │ │ ├── getting-started.md │ │ │ ├── ides.md │ │ │ ├── integrations.md │ │ │ ├── interaction.md │ │ │ ├── sessions.md │ │ │ └── use-cases.md │ │ ├── index.md │ │ ├── reference/ │ │ │ ├── keyboard.md │ │ │ ├── kimi-acp.md │ │ │ ├── kimi-command.md │ │ │ ├── kimi-info.md │ │ │ ├── kimi-mcp.md │ │ │ ├── kimi-term.md │ │ │ ├── kimi-vis.md │ │ │ ├── kimi-web.md │ │ │ └── slash-commands.md │ │ └── release-notes/ │ │ ├── breaking-changes.md │ │ └── changelog.md │ ├── index.md │ ├── package.json │ ├── scripts/ │ │ └── sync-changelog.mjs │ └── zh/ │ ├── configuration/ │ │ ├── config-files.md │ │ ├── data-locations.md │ │ ├── env-vars.md │ │ ├── overrides.md │ │ └── providers.md │ ├── customization/ │ │ ├── agents.md │ │ ├── mcp.md │ │ ├── print-mode.md │ │ ├── skills.md │ │ └── wire-mode.md │ ├── faq.md │ ├── guides/ │ │ ├── getting-started.md │ │ ├── ides.md │ │ ├── integrations.md │ │ ├── interaction.md │ │ ├── sessions.md │ │ └── use-cases.md │ ├── index.md │ ├── reference/ │ │ ├── keyboard.md │ │ ├── kimi-acp.md │ │ ├── kimi-command.md │ │ ├── kimi-info.md │ │ ├── kimi-mcp.md │ │ ├── kimi-term.md │ │ ├── kimi-vis.md │ │ ├── kimi-web.md │ │ └── slash-commands.md │ └── release-notes/ │ ├── breaking-changes.md │ └── changelog.md ├── examples/ │ ├── .gitignore │ ├── custom-echo-soul/ │ │ ├── README.md │ │ ├── main.py │ │ └── pyproject.toml │ ├── custom-kimi-soul/ │ │ ├── README.md │ │ ├── main.py │ │ └── pyproject.toml │ ├── custom-tools/ │ │ ├── README.md │ │ ├── main.py │ │ ├── my_tools/ │ │ │ ├── __init__.py │ │ │ └── ls.py │ │ ├── myagent.yaml │ │ └── pyproject.toml │ ├── kimi-cli-stream-json/ │ │ ├── README.md │ │ ├── main.py │ │ └── pyproject.toml │ ├── kimi-cli-wire-messages/ │ │ ├── README.md │ │ ├── main.py │ │ └── pyproject.toml │ ├── kimi-psql/ │ │ ├── README.md │ │ ├── agent.yaml │ │ ├── main.py │ │ └── pyproject.toml │ └── sample-plugin/ │ ├── SKILL.md │ ├── plugin.json │ └── scripts/ │ ├── calc.ts │ └── greet.py ├── flake.nix ├── kimi.spec ├── klips/ │ ├── .pre-commit-config.yaml │ ├── klip-0-klip.md │ ├── klip-1-kimi-cli-monorepo.md │ ├── klip-10-agent-flow.md │ ├── klip-11-kimi-code-rename.md │ ├── klip-12-wire-initialize-external-tools.md │ ├── klip-14-kimi-code-oauth-login.md │ ├── klip-15-kagent-sidecar-integration.md │ ├── klip-2-acpkaos.md │ ├── klip-3-kimi-cli-user-docs.md │ ├── klip-6-setup-auto-refresh-models.md │ ├── klip-7-kimi-sdk.md │ ├── klip-8-config-and-skills-layout.md │ └── klip-9-shell-ui-flicker-mitigation.md ├── packages/ │ ├── kaos/ │ │ ├── .pre-commit-config.yaml │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── NOTICE │ │ ├── README.md │ │ ├── pyproject.toml │ │ ├── src/ │ │ │ └── kaos/ │ │ │ ├── __init__.py │ │ │ ├── _current.py │ │ │ ├── local.py │ │ │ ├── path.py │ │ │ ├── py.typed │ │ │ └── ssh.py │ │ └── tests/ │ │ ├── test_kaos_path.py │ │ ├── test_local_kaos.py │ │ ├── test_local_kaos_cmd.py │ │ ├── test_local_kaos_sh.py │ │ └── test_ssh_kaos.py │ ├── kimi-code/ │ │ ├── pyproject.toml │ │ └── src/ │ │ └── kimi_code/ │ │ └── __init__.py │ └── kosong/ │ ├── .pre-commit-config.yaml │ ├── CHANGELOG.md │ ├── LICENSE │ ├── NOTICE │ ├── README.md │ ├── pyproject.toml │ ├── src/ │ │ └── kosong/ │ │ ├── __init__.py │ │ ├── __main__.py │ │ ├── _generate.py │ │ ├── chat_provider/ │ │ │ ├── __init__.py │ │ │ ├── chaos.py │ │ │ ├── echo/ │ │ │ │ ├── __init__.py │ │ │ │ ├── dsl.py │ │ │ │ ├── echo.py │ │ │ │ └── scripted_echo.py │ │ │ ├── kimi.py │ │ │ ├── mock.py │ │ │ └── openai_common.py │ │ ├── contrib/ │ │ │ ├── __init__.py │ │ │ ├── chat_provider/ │ │ │ │ ├── __init__.py │ │ │ │ ├── anthropic.py │ │ │ │ ├── common.py │ │ │ │ ├── google_genai.py │ │ │ │ ├── openai_legacy.py │ │ │ │ └── openai_responses.py │ │ │ └── context/ │ │ │ ├── __init__.py │ │ │ └── linear.py │ │ ├── message.py │ │ ├── py.typed │ │ ├── tooling/ │ │ │ ├── __init__.py │ │ │ ├── empty.py │ │ │ ├── error.py │ │ │ ├── mcp.py │ │ │ └── simple.py │ │ └── utils/ │ │ ├── __init__.py │ │ ├── aio.py │ │ ├── jsonschema.py │ │ └── typing.py │ └── tests/ │ ├── api_snapshot_tests/ │ │ ├── common.py │ │ ├── test_anthropic.py │ │ ├── test_google_genai.py │ │ ├── test_kimi.py │ │ ├── test_openai_legacy.py │ │ └── test_openai_responses.py │ ├── test_chat_provider.py │ ├── test_context.py │ ├── test_echo_chat_provider.py │ ├── test_generate.py │ ├── test_json_schema_deref.py │ ├── test_kimi_stream_usage.py │ ├── test_message.py │ ├── test_openai_common.py │ ├── test_scripted_echo_chat_provider.py │ ├── test_step.py │ ├── test_tool_call.py │ └── test_tool_result.py ├── pyproject.toml ├── pytest.ini ├── scripts/ │ ├── build_vis.py │ ├── build_web.py │ ├── check_kimi_dependency_versions.py │ ├── check_version_tag.py │ ├── cleanup_tmp_sessions.py │ ├── install.ps1 │ └── install.sh ├── sdks/ │ └── kimi-sdk/ │ ├── CHANGELOG.md │ ├── LICENSE │ ├── NOTICE │ ├── README.md │ ├── pyproject.toml │ ├── src/ │ │ └── kimi_sdk/ │ │ ├── __init__.py │ │ └── py.typed │ └── tests/ │ └── test_smoke.py ├── src/ │ └── kimi_cli/ │ ├── __init__.py │ ├── __main__.py │ ├── acp/ │ │ ├── AGENTS.md │ │ ├── __init__.py │ │ ├── convert.py │ │ ├── kaos.py │ │ ├── mcp.py │ │ ├── server.py │ │ ├── session.py │ │ ├── tools.py │ │ ├── types.py │ │ └── version.py │ ├── agents/ │ │ ├── default/ │ │ │ ├── agent.yaml │ │ │ ├── sub.yaml │ │ │ └── system.md │ │ └── okabe/ │ │ └── agent.yaml │ ├── agentspec.py │ ├── app.py │ ├── auth/ │ │ ├── __init__.py │ │ ├── oauth.py │ │ └── platforms.py │ ├── background/ │ │ ├── __init__.py │ │ ├── ids.py │ │ ├── manager.py │ │ ├── models.py │ │ ├── store.py │ │ ├── summary.py │ │ └── worker.py │ ├── cli/ │ │ ├── __init__.py │ │ ├── __main__.py │ │ ├── _lazy_group.py │ │ ├── export.py │ │ ├── info.py │ │ ├── mcp.py │ │ ├── plugin.py │ │ ├── toad.py │ │ ├── vis.py │ │ └── web.py │ ├── config.py │ ├── constant.py │ ├── deps/ │ │ └── Makefile │ ├── exception.py │ ├── llm.py │ ├── metadata.py │ ├── notifications/ │ │ ├── __init__.py │ │ ├── llm.py │ │ ├── manager.py │ │ ├── models.py │ │ ├── notifier.py │ │ ├── store.py │ │ └── wire.py │ ├── plugin/ │ │ ├── __init__.py │ │ ├── manager.py │ │ └── tool.py │ ├── prompts/ │ │ ├── __init__.py │ │ ├── compact.md │ │ └── init.md │ ├── py.typed │ ├── session.py │ ├── session_state.py │ ├── share.py │ ├── skill/ │ │ ├── __init__.py │ │ └── flow/ │ │ ├── __init__.py │ │ ├── d2.py │ │ └── mermaid.py │ ├── skills/ │ │ ├── kimi-cli-help/ │ │ │ └── SKILL.md │ │ └── skill-creator/ │ │ └── SKILL.md │ ├── soul/ │ │ ├── __init__.py │ │ ├── agent.py │ │ ├── approval.py │ │ ├── compaction.py │ │ ├── context.py │ │ ├── denwarenji.py │ │ ├── dynamic_injection.py │ │ ├── dynamic_injections/ │ │ │ ├── __init__.py │ │ │ └── plan_mode.py │ │ ├── kimisoul.py │ │ ├── message.py │ │ ├── slash.py │ │ └── toolset.py │ ├── tools/ │ │ ├── AGENTS.md │ │ ├── __init__.py │ │ ├── ask_user/ │ │ │ ├── __init__.py │ │ │ └── description.md │ │ ├── background/ │ │ │ ├── __init__.py │ │ │ ├── list.md │ │ │ ├── output.md │ │ │ └── stop.md │ │ ├── display.py │ │ ├── dmail/ │ │ │ ├── __init__.py │ │ │ └── dmail.md │ │ ├── file/ │ │ │ ├── __init__.py │ │ │ ├── glob.md │ │ │ ├── glob.py │ │ │ ├── grep.md │ │ │ ├── grep_local.py │ │ │ ├── plan_mode.py │ │ │ ├── read.md │ │ │ ├── read.py │ │ │ ├── read_media.md │ │ │ ├── read_media.py │ │ │ ├── replace.md │ │ │ ├── replace.py │ │ │ ├── utils.py │ │ │ ├── write.md │ │ │ └── write.py │ │ ├── multiagent/ │ │ │ ├── __init__.py │ │ │ ├── create.md │ │ │ ├── create.py │ │ │ ├── task.md │ │ │ └── task.py │ │ ├── plan/ │ │ │ ├── __init__.py │ │ │ ├── description.md │ │ │ ├── enter.py │ │ │ ├── enter_description.md │ │ │ └── heroes.py │ │ ├── shell/ │ │ │ ├── __init__.py │ │ │ ├── bash.md │ │ │ └── powershell.md │ │ ├── test.py │ │ ├── think/ │ │ │ ├── __init__.py │ │ │ └── think.md │ │ ├── todo/ │ │ │ ├── __init__.py │ │ │ └── set_todo_list.md │ │ ├── utils.py │ │ └── web/ │ │ ├── __init__.py │ │ ├── fetch.md │ │ ├── fetch.py │ │ ├── search.md │ │ └── search.py │ ├── ui/ │ │ ├── __init__.py │ │ ├── acp/ │ │ │ └── __init__.py │ │ ├── print/ │ │ │ ├── __init__.py │ │ │ └── visualize.py │ │ └── shell/ │ │ ├── __init__.py │ │ ├── console.py │ │ ├── debug.py │ │ ├── echo.py │ │ ├── export_import.py │ │ ├── keyboard.py │ │ ├── mcp_status.py │ │ ├── oauth.py │ │ ├── placeholders.py │ │ ├── prompt.py │ │ ├── replay.py │ │ ├── setup.py │ │ ├── slash.py │ │ ├── startup.py │ │ ├── task_browser.py │ │ ├── update.py │ │ ├── usage.py │ │ └── visualize.py │ ├── utils/ │ │ ├── __init__.py │ │ ├── aiohttp.py │ │ ├── aioqueue.py │ │ ├── broadcast.py │ │ ├── changelog.py │ │ ├── clipboard.py │ │ ├── datetime.py │ │ ├── diff.py │ │ ├── editor.py │ │ ├── environment.py │ │ ├── envvar.py │ │ ├── export.py │ │ ├── frontmatter.py │ │ ├── io.py │ │ ├── logging.py │ │ ├── media_tags.py │ │ ├── message.py │ │ ├── path.py │ │ ├── proctitle.py │ │ ├── pyinstaller.py │ │ ├── rich/ │ │ │ ├── __init__.py │ │ │ ├── columns.py │ │ │ ├── markdown.py │ │ │ ├── markdown_sample.md │ │ │ ├── markdown_sample_short.md │ │ │ └── syntax.py │ │ ├── signals.py │ │ ├── slashcmd.py │ │ ├── string.py │ │ ├── subprocess_env.py │ │ ├── term.py │ │ └── typing.py │ ├── vis/ │ │ ├── __init__.py │ │ ├── api/ │ │ │ ├── __init__.py │ │ │ ├── sessions.py │ │ │ ├── statistics.py │ │ │ └── system.py │ │ └── app.py │ ├── web/ │ │ ├── __init__.py │ │ ├── api/ │ │ │ ├── __init__.py │ │ │ ├── config.py │ │ │ ├── open_in.py │ │ │ └── sessions.py │ │ ├── app.py │ │ ├── auth.py │ │ ├── models.py │ │ ├── runner/ │ │ │ ├── __init__.py │ │ │ ├── messages.py │ │ │ ├── process.py │ │ │ └── worker.py │ │ └── store/ │ │ ├── __init__.py │ │ └── sessions.py │ └── wire/ │ ├── __init__.py │ ├── file.py │ ├── jsonrpc.py │ ├── protocol.py │ ├── serde.py │ ├── server.py │ └── types.py ├── tests/ │ ├── __init__.py │ ├── acp/ │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_protocol_v1.py │ │ ├── test_session_notifications.py │ │ └── test_version.py │ ├── auth/ │ │ └── test_ascii_header.py │ ├── background/ │ │ ├── test_manager.py │ │ ├── test_store.py │ │ └── test_worker.py │ ├── conftest.py │ ├── core/ │ │ ├── test_agent_flow.py │ │ ├── test_agent_spec.py │ │ ├── test_ask_user_plan_mode.py │ │ ├── test_config.py │ │ ├── test_context.py │ │ ├── test_create_llm.py │ │ ├── test_default_agent.py │ │ ├── test_exceptions.py │ │ ├── test_inspect_plan_edit_target.py │ │ ├── test_kimisoul_ralph_loop.py │ │ ├── test_kimisoul_retry_recovery.py │ │ ├── test_kimisoul_slash_commands.py │ │ ├── test_kimisoul_steer.py │ │ ├── test_load_agent.py │ │ ├── test_load_agents_md.py │ │ ├── test_normalize_history.py │ │ ├── test_notifications.py │ │ ├── test_plan_mode.py │ │ ├── test_plan_mode_injection_provider.py │ │ ├── test_plan_mode_reminder.py │ │ ├── test_plan_slash.py │ │ ├── test_plugin.py │ │ ├── test_plugin_manager.py │ │ ├── test_plugin_tool.py │ │ ├── test_session.py │ │ ├── test_session_state.py │ │ ├── test_shell_mcp_status.py │ │ ├── test_simple_compaction.py │ │ ├── test_skill.py │ │ ├── test_soul_import_command.py │ │ ├── test_soul_message.py │ │ ├── test_startup_imports.py │ │ ├── test_startup_progress.py │ │ ├── test_status_formatting.py │ │ ├── test_str_replace_file_plan_mode.py │ │ ├── test_toolset.py │ │ ├── test_wire_message.py │ │ ├── test_wire_plan_mode.py │ │ ├── test_wire_server_steer.py │ │ └── test_write_file_plan_mode.py │ ├── e2e/ │ │ ├── __init__.py │ │ ├── shell_pty_helpers.py │ │ ├── test_basic_e2e.py │ │ ├── test_cli_error_output.py │ │ ├── test_media_e2e.py │ │ └── test_shell_pty_e2e.py │ ├── notifications/ │ │ └── test_notification_manager.py │ ├── test_additional_dirs_state.py │ ├── test_attachment_cache.py │ ├── test_clipboard.py │ ├── tools/ │ │ ├── test_additional_dirs.py │ │ ├── test_ask_user.py │ │ ├── test_background_tools.py │ │ ├── test_create_subagent.py │ │ ├── test_extract_key_argument.py │ │ ├── test_fetch_url.py │ │ ├── test_glob.py │ │ ├── test_grep.py │ │ ├── test_read_file.py │ │ ├── test_read_media_file.py │ │ ├── test_read_media_file_desc.py │ │ ├── test_shell_bash.py │ │ ├── test_shell_powershell.py │ │ ├── test_str_replace_file.py │ │ ├── test_tool_descriptions.py │ │ ├── test_tool_schemas.py │ │ └── test_write_file.py │ ├── ui_and_conv/ │ │ ├── test_acp_convert.py │ │ ├── test_acp_server_auth.py │ │ ├── test_export_import.py │ │ ├── test_file_completer.py │ │ ├── test_live_view_notifications.py │ │ ├── test_print_final_only.py │ │ ├── test_print_notifications.py │ │ ├── test_prompt_clipboard.py │ │ ├── test_prompt_external_editor.py │ │ ├── test_prompt_history.py │ │ ├── test_prompt_placeholders.py │ │ ├── test_prompt_tips.py │ │ ├── test_question_panel.py │ │ ├── test_replay.py │ │ ├── test_sanitize_surrogates.py │ │ ├── test_shell_editor_slash.py │ │ ├── test_shell_export_import_commands.py │ │ ├── test_shell_prompt_echo.py │ │ ├── test_shell_prompt_router.py │ │ ├── test_shell_run_placeholders.py │ │ ├── test_shell_slash_commands.py │ │ ├── test_shell_task_slash.py │ │ ├── test_slash_completer.py │ │ ├── test_status_block.py │ │ ├── test_task_browser.py │ │ ├── test_tool_call_block.py │ │ └── test_visualize_running_prompt.py │ ├── utils/ │ │ ├── test_atomic_json_write.py │ │ ├── test_broadcast_queue.py │ │ ├── test_changelog.py │ │ ├── test_diff_utils.py │ │ ├── test_editor.py │ │ ├── test_file_utils.py │ │ ├── test_frontmatter.py │ │ ├── test_is_within_workspace.py │ │ ├── test_list_directory.py │ │ ├── test_message_utils.py │ │ ├── test_pyinstaller_utils.py │ │ ├── test_result_builder.py │ │ ├── test_rich_markdown.py │ │ ├── test_slash_command.py │ │ ├── test_typing_utils.py │ │ ├── test_utils_environment.py │ │ └── test_utils_path.py │ ├── vis/ │ │ └── test_app.py │ └── web/ │ └── test_open_in.py ├── tests_ai/ │ ├── scripts/ │ │ ├── main.yaml │ │ ├── run.py │ │ └── worker.yaml │ ├── test_cli_loading_time.md │ ├── test_encoding_error_handling.md │ └── test_utf8_encoding.md ├── tests_e2e/ │ ├── AGENTS.md │ ├── __init__.py │ ├── test_mcp_cli.py │ ├── test_wire_approvals_tools.py │ ├── test_wire_config.py │ ├── test_wire_errors.py │ ├── test_wire_prompt.py │ ├── test_wire_protocol.py │ ├── test_wire_question.py │ ├── test_wire_real_llm.py │ ├── test_wire_sessions.py │ ├── test_wire_skills_mcp.py │ ├── test_wire_steer.py │ └── wire_helpers.py ├── vis/ │ ├── components.json │ ├── index.html │ ├── package.json │ ├── src/ │ │ ├── App.tsx │ │ ├── components/ │ │ │ ├── markdown.tsx │ │ │ └── ui/ │ │ │ ├── alert-dialog.tsx │ │ │ ├── select.tsx │ │ │ └── tooltip.tsx │ │ ├── features/ │ │ │ ├── context-viewer/ │ │ │ │ ├── assistant-message.tsx │ │ │ │ ├── context-space-map.tsx │ │ │ │ ├── context-viewer.tsx │ │ │ │ ├── tool-call-block.tsx │ │ │ │ └── user-message.tsx │ │ │ ├── dual-view/ │ │ │ │ └── dual-view.tsx │ │ │ ├── session-picker/ │ │ │ │ └── session-picker.tsx │ │ │ ├── sessions-explorer/ │ │ │ │ ├── explorer-toolbar.tsx │ │ │ │ ├── project-group.tsx │ │ │ │ ├── session-card.tsx │ │ │ │ └── sessions-explorer.tsx │ │ │ ├── state-viewer/ │ │ │ │ └── state-viewer.tsx │ │ │ ├── statistics/ │ │ │ │ └── statistics-view.tsx │ │ │ └── wire-viewer/ │ │ │ ├── decision-path.tsx │ │ │ ├── integrity-check.tsx │ │ │ ├── timeline-view.tsx │ │ │ ├── tool-call-detail.tsx │ │ │ ├── tool-stats-dashboard.tsx │ │ │ ├── turn-efficiency.tsx │ │ │ ├── turn-tree.tsx │ │ │ ├── usage-chart.tsx │ │ │ ├── wire-event-card.tsx │ │ │ ├── wire-filters.tsx │ │ │ └── wire-viewer.tsx │ │ ├── hooks/ │ │ │ └── use-theme.ts │ │ ├── index.css │ │ ├── lib/ │ │ │ ├── api.ts │ │ │ ├── cache.ts │ │ │ └── utils.ts │ │ └── main.tsx │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts └── web/ ├── .gitignore ├── biome.jsonc ├── components.json ├── index.html ├── openapi.json ├── openapitools.json ├── package.json ├── scripts/ │ └── generate-api.sh ├── src/ │ ├── App.tsx │ ├── bootstrap.tsx │ ├── components/ │ │ ├── ai-elements/ │ │ │ ├── chain-of-thought.tsx │ │ │ ├── code-block.tsx │ │ │ ├── confirmation.tsx │ │ │ ├── context.tsx │ │ │ ├── conversation.tsx │ │ │ ├── index.ts │ │ │ ├── loader.tsx │ │ │ ├── message.tsx │ │ │ ├── model-selector.tsx │ │ │ ├── prompt-input.tsx │ │ │ ├── reasoning.tsx │ │ │ ├── shimmer.tsx │ │ │ ├── streamdown.tsx │ │ │ ├── subagent-steps.tsx │ │ │ └── tool.tsx │ │ ├── error-boundary.tsx │ │ ├── kimi-cli-brand.tsx │ │ └── ui/ │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── badge.tsx │ │ ├── button-group.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── checkbox.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── diff/ │ │ │ ├── index.tsx │ │ │ ├── lazy.tsx │ │ │ ├── theme.css │ │ │ └── utils/ │ │ │ ├── guess-lang.ts │ │ │ ├── index.ts │ │ │ └── parse.ts │ │ ├── dropdown-menu.tsx │ │ ├── hover-card.tsx │ │ ├── input-group.tsx │ │ ├── input.tsx │ │ ├── kbd.tsx │ │ ├── progress.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sonner.tsx │ │ ├── switch.tsx │ │ ├── textarea.tsx │ │ ├── theme-toggle.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ └── tooltip.tsx │ ├── config/ │ │ └── media.ts │ ├── features/ │ │ ├── chat/ │ │ │ ├── chat-workspace-container.tsx │ │ │ ├── chat.tsx │ │ │ ├── components/ │ │ │ │ ├── activity-status-indicator.tsx │ │ │ │ ├── approval-dialog.tsx │ │ │ │ ├── assistant-message.tsx │ │ │ │ ├── attachment-button.tsx │ │ │ │ ├── chat-conversation.tsx │ │ │ │ ├── chat-prompt-composer.tsx │ │ │ │ ├── chat-workspace-header.tsx │ │ │ │ ├── open-in-menu.tsx │ │ │ │ ├── prompt-toolbar/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── open-in-button.tsx │ │ │ │ │ ├── toolbar-changes.tsx │ │ │ │ │ ├── toolbar-context.tsx │ │ │ │ │ ├── toolbar-queue.tsx │ │ │ │ │ └── toolbar-todo.tsx │ │ │ │ ├── question-dialog.tsx │ │ │ │ ├── session-info-popover.tsx │ │ │ │ └── virtualized-message-list.tsx │ │ │ ├── file-mention-menu.tsx │ │ │ ├── global-config-controls.tsx │ │ │ ├── message-search-dialog.tsx │ │ │ ├── message-search-utils.ts │ │ │ ├── queue-store.ts │ │ │ ├── slash-command-menu.tsx │ │ │ ├── useFileMentions.ts │ │ │ └── useSlashCommands.ts │ │ ├── sessions/ │ │ │ ├── create-session-dialog.tsx │ │ │ └── sessions.tsx │ │ └── tool/ │ │ ├── components/ │ │ │ └── display-content.tsx │ │ └── store.ts │ ├── hooks/ │ │ ├── types.ts │ │ ├── use-theme.ts │ │ ├── useGitDiffStats.ts │ │ ├── useGlobalConfig.ts │ │ ├── useSessionStream.ts │ │ ├── useSessions.ts │ │ ├── useVideoThumbnail.ts │ │ ├── utils.ts │ │ └── wireTypes.ts │ ├── index.css │ ├── lib/ │ │ ├── api/ │ │ │ ├── .openapi-generator/ │ │ │ │ ├── FILES │ │ │ │ └── VERSION │ │ │ ├── .openapi-generator-ignore │ │ │ ├── apis/ │ │ │ │ ├── ConfigApi.ts │ │ │ │ ├── DefaultApi.ts │ │ │ │ ├── OpenInApi.ts │ │ │ │ ├── SessionsApi.ts │ │ │ │ ├── WorkDirsApi.ts │ │ │ │ └── index.ts │ │ │ ├── docs/ │ │ │ │ ├── ConfigApi.md │ │ │ │ ├── ConfigModel.md │ │ │ │ ├── ConfigToml.md │ │ │ │ ├── CreateSessionRequest.md │ │ │ │ ├── DefaultApi.md │ │ │ │ ├── GenerateTitleRequest.md │ │ │ │ ├── GenerateTitleResponse.md │ │ │ │ ├── GitDiffStats.md │ │ │ │ ├── GitFileDiff.md │ │ │ │ ├── GlobalConfig.md │ │ │ │ ├── HTTPValidationError.md │ │ │ │ ├── ModelCapability.md │ │ │ │ ├── OpenInApi.md │ │ │ │ ├── OpenInRequest.md │ │ │ │ ├── OpenInResponse.md │ │ │ │ ├── ProviderType.md │ │ │ │ ├── Session.md │ │ │ │ ├── SessionStatus.md │ │ │ │ ├── SessionsApi.md │ │ │ │ ├── UpdateConfigTomlRequest.md │ │ │ │ ├── UpdateConfigTomlResponse.md │ │ │ │ ├── UpdateGlobalConfigRequest.md │ │ │ │ ├── UpdateGlobalConfigResponse.md │ │ │ │ ├── UpdateSessionRequest.md │ │ │ │ ├── UploadSessionFileResponse.md │ │ │ │ ├── ValidationError.md │ │ │ │ ├── ValidationErrorLocInner.md │ │ │ │ └── WorkDirsApi.md │ │ │ ├── index.ts │ │ │ ├── models/ │ │ │ │ ├── ConfigModel.ts │ │ │ │ ├── ConfigToml.ts │ │ │ │ ├── CreateSessionRequest.ts │ │ │ │ ├── GenerateTitleRequest.ts │ │ │ │ ├── GenerateTitleResponse.ts │ │ │ │ ├── GitDiffStats.ts │ │ │ │ ├── GitFileDiff.ts │ │ │ │ ├── GlobalConfig.ts │ │ │ │ ├── HTTPValidationError.ts │ │ │ │ ├── ModelCapability.ts │ │ │ │ ├── OpenInRequest.ts │ │ │ │ ├── OpenInResponse.ts │ │ │ │ ├── ProviderType.ts │ │ │ │ ├── Session.ts │ │ │ │ ├── SessionStatus.ts │ │ │ │ ├── UpdateConfigTomlRequest.ts │ │ │ │ ├── UpdateConfigTomlResponse.ts │ │ │ │ ├── UpdateGlobalConfigRequest.ts │ │ │ │ ├── UpdateGlobalConfigResponse.ts │ │ │ │ ├── UpdateSessionRequest.ts │ │ │ │ ├── UploadSessionFileResponse.ts │ │ │ │ ├── ValidationError.ts │ │ │ │ ├── ValidationErrorLocInner.ts │ │ │ │ └── index.ts │ │ │ └── runtime.ts │ │ ├── apiClient.ts │ │ ├── auth.ts │ │ ├── utils.ts │ │ └── version.ts │ ├── main.tsx │ └── react-scan.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .agents/skills/codex-worker/SKILL.md ================================================ --- name: codex-worker description: Spawn and manage multiple Codex CLI agents via tmux to work on tasks in parallel. Use whenever a task can be decomposed into independent subtasks (e.g. batch triage, parallel fixes, multi-file refactors). When codex and tmux are available, prefer this over the built-in Task tool for parallelism. --- # codex-worker Orchestrate multiple Codex CLI (`codex`) agents running in parallel, each in its own git worktree and tmux session. **When to use:** Whenever you identify that a task can be split into independent subtasks — don't wait for the user to ask for parallelism. Examples: - User says "triage all open issues updated in the last 7 days" → fetch the issue list, then spawn one codex worker per issue. - User says "refactor these 5 modules" → one worker per module. - User says "fix lint errors across packages" → one worker per package. **Replaces Task tool:** When `codex` and `tmux` are available in the environment, use codex workers instead of the built-in Task (subagent) tool. Codex workers are full-featured agents with their own file system access, shell, and isolated worktree — far more capable than subagents. ## Preflight check ```bash command -v codex && codex login status && command -v tmux ``` All three must succeed. The project must be a git repository. ## Naming convention Git branch and worktree directory share a **task name**: ``` -- ``` The tmux session adds a `codex-worker-` prefix so workers are easy to filter: | | Format | Example | |---|---|---| | Task name | `--` | `issue-836-prompt-dollar-sign` | | Git branch | same as task name | `issue-836-prompt-dollar-sign` | | Worktree dir | `.worktrees/` | `kimi-cli.worktrees/issue-836-prompt-dollar-sign` | | tmux session | `codex-worker-` | `codex-worker-issue-836-prompt-dollar-sign` | More examples: - `issue-518-mcp-config-isolation` - `fix-share-dir-skills-path` - `feat-ask-user-tool` - `refactor-jinja-templates` List only codex workers: `tmux ls | grep ^codex-worker-` ## Usage Prefer tmux + interactive codex for all tasks. It supports multi-turn dialogue, the user can `tmux attach` to inspect or intervene, and you can send follow-up prompts from outside. ### Spawn a worker ```bash NAME="issue-836-prompt-dollar-sign" # task name SESSION="codex-worker-$NAME" # tmux session name PROJECT_DIR="$(pwd)" WORKTREE_DIR="$PROJECT_DIR.worktrees" # 1. Create worktree (skip if exists) git worktree add "$WORKTREE_DIR/$NAME" -b "$NAME" main 2>/dev/null # 2. Launch interactive codex inside tmux tmux new-session -d -s "$SESSION" -x 200 -y 50 \ "cd $WORKTREE_DIR/$NAME && codex --dangerously-bypass-approvals-and-sandbox" ``` ### Send a prompt The Codex TUI needs time to initialize before it accepts input. After launching a session, **wait at least 5 seconds** before sending a prompt. Then send the text followed by `Enter`. If the prompt stays in the input field without being submitted, send an additional `Enter`. ```bash sleep 5 # wait for Codex TUI to initialize tmux send-keys -t "$SESSION" "Your prompt here" Enter # If it doesn't submit, send another Enter: # tmux send-keys -t "$SESSION" Enter ``` ### Peek at output ```bash tmux capture-pane -t "$SESSION" -p | tail -30 ``` ### Attach for hands-on interaction ```bash tmux attach -t "$SESSION" ``` ### Parallel fan-out ```bash TASKS=( "issue-518-mcp-config-isolation|Triage #518: MCP config 被子 agent 继承的隔离问题。分析根因,给出修复方案。" "issue-836-prompt-dollar-sign|Triage #836: prompt 包含 $ 时启动静默失败。分析根因,给出修复方案。" ) PROJECT_DIR="$(pwd)" WORKTREE_DIR="$PROJECT_DIR.worktrees" for entry in "${TASKS[@]}"; do NAME="${entry%%|*}" PROMPT="${entry#*|}" SESSION="codex-worker-$NAME" git worktree add "$WORKTREE_DIR/$NAME" -b "$NAME" main 2>/dev/null tmux new-session -d -s "$SESSION" -x 200 -y 50 \ "cd $WORKTREE_DIR/$NAME && codex --dangerously-bypass-approvals-and-sandbox" sleep 5 # wait for Codex TUI to fully initialize tmux send-keys -t "$SESSION" "$PROMPT" Enter done ``` ### Fallback: `codex exec` Only use `codex exec` when you explicitly don't need follow-up (e.g. CI, pure analysis with `-o` output). It does not support multi-turn dialogue. ```bash codex exec --dangerously-bypass-approvals-and-sandbox \ -o "/tmp/$NAME-result.md" \ "Your prompt here" ``` ## Lifecycle management List active workers: ```bash tmux ls | grep ^codex-worker- ``` Kill a finished worker: ```bash tmux kill-session -t "codex-worker-$NAME" ``` Clean up worktree after merging: ```bash tmux kill-session -t "codex-worker-$NAME" 2>/dev/null git worktree remove "$WORKTREE_DIR/$NAME" git branch -d "$NAME" ``` Batch cleanup of dead sessions: ```bash tmux list-sessions -F '#{session_name}:#{pane_dead}' \ | grep ':1$' \ | cut -d: -f1 \ | xargs -I{} tmux kill-session -t {} ``` ================================================ FILE: .agents/skills/feature-smoke-test/SKILL.md ================================================ --- name: feature-smoke-test description: 针对 Kimi Code CLI 的新增或变更功能,规划并执行可重复的端到端冒烟测试。从 git diff 推断功能边界,读取相关文档和代码,设计测试 prompt,以 --print 非交互模式运行本地 CLI,检查进程退出码和 session 产物,总结预期与实际行为之间的差异。发现问题时自动启动多路并行探查以定位根因。 --- 冒烟测试是运行时验证,不是写完 prompt 或读完代码就结束。必须实际执行、实际检查产物。 ## 确定测试范围 动手之前,先从代码变更推断功能边界: ```sh git diff main --name-only git diff main --stat ``` 根据变更文件集合,明确写下: - 被测的功能边界 - 用户可感知的行为变化 - 运行前的准备工作和运行后的清理工作 - 能够证明成功或失败的证据 如果功能涉及状态、异步、审批流或时序敏感逻辑,默认认为单条 prompt 不够。 ## 先读事实来源 读取定义该功能真实行为的最小文件集合: - 面向用户的文档、changelog 或设计笔记 - 暴露该功能的 agent prompt 或 tool prompt - 实现层入口 - 已有测试——当测试比文档更准确地描述行为时优先看测试 不要信任过时的 prompt 示例。先从代码或当前文档重建真实的工具接口。 ## 制定最小测试计划 默认覆盖三个场景: 1. 正常路径 2. 边界条件、非法输入或容量极限 3. 中断、重试、清理或恢复 每个场景记录: - `目标` - `前置准备` - `prompt 策略` - `成功信号` - `失败信号` - `需要检查的产物` 如果功能存在竞态条件,必须显式标注时序。用长时间运行的命令或刻意的等待来制造时序窗口,不要用"快速再跑一个"这种模糊说法。 ## 优先使用多轮 prompt 默认采用多轮流程: 1. 探索轮:让 agent 在阅读事实来源后复述当前真实接口 2. 执行轮:只执行一个场景 3. 观察轮:只读取输出、工具状态和产物文件,不扩大范围 4. 清理轮:停止、回滚或关闭有状态资源 仅对无状态的简单功能使用单轮 prompt。 多轮测试时,每一轮单独调用 CLI,在两轮之间检查上一轮的输出和产物,再决定下一轮的 prompt。不要把多轮 prompt 一次性塞进 stdin——那是盲写,无法根据上一轮结果调整。 测试时,显式要求 agent 先列举当前可用的工具,不要臆造历史工具名。可复用的 prompt 模板见 `references/prompt-patterns.md`。 ## 以非交互模式隔离运行 CLI 使用 `/tmp` 下的一次性目录作为 `--work-dir`,实现 session 隔离。CLI 的 session 路径由 `~/.kimi/sessions//` 决定,不同的 work-dir 自动产生独立的 session 命名空间,不会污染正常项目的 session。认证状态保留在 `~/.kimi` 下,无需额外配置。 ### 环境准备 ```sh SMOKE_DIR="$(mktemp -d /tmp/kimi-smoke-XXXXXX)" ``` 如果功能需要仓库上下文(读取代码、git 信息等),把仓库文件复制或软链到 `SMOKE_DIR`。如果功能会编辑文件,绝不要用活跃仓库作为 work-dir。 ### 默认执行方式 绝大多数场景使用这个模式: ```sh uv run python -m kimi_cli.cli \ --print \ --prompt "你的测试 prompt" \ --work-dir "$SMOKE_DIR" echo "exit_code=$?" ``` `--print` 会自动启用 `--yolo`(自动批准所有操作),适合无人值守的冒烟测试。 执行后**必须先检查退出码**:非零表示 CLI 本身崩溃或超时,应优先排查进程级错误,再看 session 产物。 ### 备选执行模式 当默认方式不满足需求时,按需选用: - **长 prompt**:通过 stdin 传入——`cat <<'PROMPT' | uv run python -m kimi_cli.cli --print --input-format text --work-dir "$SMOKE_DIR"` - **结构化输出**:加 `--output-format stream-json`,输出逐行 JSON,便于程序化解析 - **只看最终结果**:用 `--quiet`,等价于 `--print --output-format text --final-message-only` ### 注意事项 - 运行过程中记录这些路径:`SMOKE_DIR`、最终 session 目录(可通过 `inspect_session.py` 定位)、功能特定的输出文件。 ## 刻意执行 执行过程中维护一份简短的运行日志: - 使用的完整 prompt - 关键的工具调用或命令 - 功能产生的 task id、输出路径或审批 id - 时序敏感时记录时间戳 不要仅凭 assistant 的最终文本推断正确性。运行时文件和工具结果才是事实来源。 ## 检查 session 产物 首先检查: - `~/.kimi/sessions/.../context.jsonl` - `~/.kimi/sessions/.../wire.jsonl` - 功能创建的 session 级文件 使用 `scripts/inspect_session.py` 查找并汇总最新 session: ```sh uv run python .agents/skills/feature-smoke-test/scripts/inspect_session.py --share-dir ~/.kimi ``` 脚本退出码含义:0 = 正常汇总,1 = session 目录缺失或无法解析。 如果功能会创建后台任务、通知或附属文件,直接检查这些文件,不要只依赖模型摘要。 ## 汇报结论 将结果分为三类: - **已确认**的行为 - **与预期不符**的行为 - **仍有歧义**、需要更确定性复现的行为 对于每个 bug 或回归,记录: - 触发它的 prompt - 精确的 session 路径 - 证明它的产物路径 - 能推导出的最小复现步骤 ## 问题探查 当发现与预期不符的行为时,不要停在报告层面。启动并行探查流程定位根因: ### 探查策略 针对每个发现的问题,**同时启动多个独立的探查方向**(使用 Agent 工具并行执行)。根据问题的具体表现自行判断最有价值的探查角度,常见方向包括但不限于: - 从触发问题的入口沿调用链追踪实际执行路径 - 检查输入数据经过各处理阶段后的变化 - 检查持久化状态(session 文件、后台任务、通知记录等)是否一致 - 运行相关单元测试和集成测试,确认测试是否覆盖了出问题的路径 不必机械地覆盖所有方向。根据 session 产物中的具体异常信号,选择最可能命中根因的 2-3 个方向并行展开。 ### 探查输出 每条探查方向独立汇报: - 探查的具体方向和范围 - 发现的事实(附文件路径和行号) - 该方向的结论:已定位根因 / 已排除 / 需要进一步调查 ### 综合定位 汇总所有探查结果后,输出: - **根因**:一句话总结问题的本质原因 - **证据链**:从触发 prompt → 代码路径 → 出错点 → 产物表现的完整链路 - **修复建议**:最小改动方案,附具体文件和行号 - **回归风险**:修复后需要额外验证的相关功能 ================================================ FILE: .agents/skills/feature-smoke-test/references/prompt-patterns.md ================================================ # Prompt 模板 以下模板作为脚手架使用。运行前替换占位符。 ## 单轮还是多轮 满足以下任一条件时使用多轮: - 功能有状态 - 功能依赖时序或并发 - 功能需要审批、清理或恢复 - session 产物本身是证据的一部分 - 工具接口可能近期发生过变化 仅对无状态的窄范围检查使用单轮。 ## 变量 起草 prompt 前填写以下字段: - `` — 被测功能名称 - `` — 当前场景的目标 - `` — 需要阅读的源码路径 - `` — 执行约束 - `` — 成功信号 - `` — 失败信号 - `` — 需要检查的产物路径 - `` — session 目录路径 ## 探索 prompt ```text 我要验证 。 先阅读这些文件并只总结当前真实对外接口,不要假设旧文档、旧 prompt 或旧 tool 名称仍然正确: 然后给我一个最小 smoke test 计划,只包含: 1. happy path 2. 一个边界/异常场景 3. 一个清理、恢复或中断场景 每个场景都写清楚目标、预期信号和要检查的产物。 ``` ## 执行 prompt ```text 在当前 session 里只执行这个场景: 约束: 执行前先复述你将使用的工具或命令。执行时记录关键 task id、输出片段、文件路径和任何需要后续复盘的标识符。不要扩展到其他场景。 ``` ## 观察 prompt ```text 现在不要继续跑新的测试。 只读取并总结这次运行已经产生的状态和文件: 请明确指出哪些证据支持了预期,哪些证据反驳了预期,哪些地方仍然不确定。 ``` ## 复盘 prompt ```text 请根据这个 session 目录复盘整个 smoke test: 重点阅读 context.jsonl、wire.jsonl 和相关运行产物。输出: 1. 实际执行流程 2. 关键 tool 调用与结果 3. 与预期不一致的点 4. 最小复现步骤 ``` ## 兼容性校验 prompt ```text 在运行 smoke test 之前,先从提供的文档或代码中复述当前真实可用的工具及其准确名称。不要臆造旧版工具名。如果任务涉及状态或时序,将工作拆分为多轮而非一次性长回复。 ``` ================================================ FILE: .agents/skills/feature-smoke-test/scripts/inspect_session.py ================================================ #!/usr/bin/env python3 """Locate and summarize a Kimi CLI session for smoke-test review.""" from __future__ import annotations import argparse import json import sys from collections import Counter from pathlib import Path from typing import Any def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Locate and summarize a Kimi CLI session for smoke-test review." ) parser.add_argument("--share-dir", type=Path, help="Share dir that contains sessions/") parser.add_argument("--session-dir", type=Path, help="Explicit session directory to inspect") parser.add_argument( "--tail-lines", type=int, default=12, help="How many recent records to show" ) parser.add_argument( "--max-text", type=int, default=220, help="Maximum characters to show for any text preview", ) return parser.parse_args() def truncate(text: str, max_text: int) -> str: text = " ".join(text.split()) if len(text) <= max_text: return text return text[: max_text - 3] + "..." def extract_text(content: Any) -> str: if isinstance(content, str): return content if isinstance(content, list): parts: list[str] = [] for item in content: if not isinstance(item, dict): parts.append(str(item)) continue kind = item.get("type") if kind == "text" and isinstance(item.get("text"), str): parts.append(item["text"]) elif kind == "think" and isinstance(item.get("think"), str): parts.append(item["think"]) elif kind == "shell" and isinstance(item.get("command"), str): parts.append(item["command"]) else: parts.append(json.dumps(item, ensure_ascii=False)) return " ".join(parts) return json.dumps(content, ensure_ascii=False) def load_json(path: Path) -> dict[str, Any] | None: if not path.exists(): return None try: return json.loads(path.read_text()) except Exception: return None def iter_jsonl(path: Path) -> list[dict[str, Any]]: records: list[dict[str, Any]] = [] if not path.exists(): return records with path.open() as f: for line in f: line = line.strip() if not line: continue try: obj = json.loads(line) except json.JSONDecodeError: obj = {"_raw": line} records.append(obj) return records def find_latest_session(share_dir: Path) -> Path: sessions_root = share_dir / "sessions" if not sessions_root.exists(): raise FileNotFoundError(f"sessions directory not found: {sessions_root}") candidates: list[tuple[float, Path]] = [] for path in sessions_root.glob("*/*"): if not path.is_dir(): continue context_path = path / "context.jsonl" wire_path = path / "wire.jsonl" if context_path.exists(): mtime = context_path.stat().st_mtime elif wire_path.exists(): mtime = wire_path.stat().st_mtime else: mtime = path.stat().st_mtime candidates.append((mtime, path)) if not candidates: raise FileNotFoundError(f"no session directories found under: {sessions_root}") candidates.sort(key=lambda item: item[0], reverse=True) return candidates[0][1] def print_header(title: str) -> None: print() print(f"== {title} ==") def summarize_context_record(record: dict[str, Any], max_text: int) -> str: if "_raw" in record: return truncate(record["_raw"], max_text) role = record.get("role", "") if role == "_system_prompt": return "role=_system_prompt" if role == "_checkpoint": return f"role=_checkpoint id={record.get('id')}" if role == "_usage": return f"role=_usage token_count={record.get('token_count')}" text = truncate(extract_text(record.get("content")), max_text) if role == "assistant": tool_calls = record.get("tool_calls") or [] tool_names = [ call.get("function", {}).get("name") for call in tool_calls if isinstance(call, dict) and isinstance(call.get("function"), dict) ] parts: list[str] = [f"role={role}"] if tool_names: parts.append("tools=" + ",".join(name for name in tool_names if name)) if text: parts.append(f"text={text}") return " | ".join(parts) if role == "tool": parts = [f"role={role}"] if record.get("tool_call_id"): parts.append(f"tool_call_id={record['tool_call_id']}") if text: parts.append(f"text={text}") return " | ".join(parts) if text: return f"role={role} | text={text}" return f"role={role}" def summarize_wire_record(record: dict[str, Any], max_text: int) -> str: if "_raw" in record: return truncate(record["_raw"], max_text) message = record.get("message") if not isinstance(message, dict): return truncate(json.dumps(record, ensure_ascii=False), max_text) message_type = message.get("type", "") payload = message.get("payload", {}) parts = [f"type={message_type}"] if message_type == "StepBegin": parts.append(f"n={payload.get('n')}") elif message_type == "ContentPart": part_type = payload.get("type") parts.append(f"part={part_type}") if part_type in {"text", "think"}: raw = payload.get("text") or payload.get("think") or "" parts.append("text=" + truncate(raw, max_text)) elif message_type == "ToolCall": function = payload.get("function", {}) if isinstance(function, dict): parts.append(f"tool={function.get('name')}") elif message_type == "ApprovalRequest": parts.append(f"action={payload.get('action')}") if payload.get("description"): parts.append("desc=" + truncate(str(payload["description"]), max_text)) elif message_type == "TurnBegin": user_input = payload.get("user_input") or [] parts.append(f"user_parts={len(user_input)}") elif message_type == "StatusUpdate": parts.append(f"context_tokens={payload.get('context_tokens')}") return " | ".join(parts) def print_jsonl_summary(title: str, path: Path, tail_lines: int, max_text: int) -> None: if not path.exists(): print_header(title) print("missing") return records = iter_jsonl(path) print_header(title) print(path) print(f"records: {len(records)}") if path.name == "context.jsonl": counter = Counter(record.get("role", "") for record in records) print("roles:", ", ".join(f"{key}={value}" for key, value in sorted(counter.items()))) tail = records[-tail_lines:] for idx, record in enumerate(tail, start=max(1, len(records) - len(tail) + 1)): print(f"[{idx}] {summarize_context_record(record, max_text)}") else: counter = Counter( record.get("message", {}).get("type", "") if isinstance(record.get("message"), dict) else "" for record in records ) print("types:", ", ".join(f"{key}={value}" for key, value in sorted(counter.items()))) tail = records[-tail_lines:] for idx, record in enumerate(tail, start=max(1, len(records) - len(tail) + 1)): print(f"[{idx}] {summarize_wire_record(record, max_text)}") def print_file_inventory(session_dir: Path) -> None: print_header("Files") for path in sorted(session_dir.rglob("*")): if path.is_dir(): continue relative = path.relative_to(session_dir) size = path.stat().st_size print(f"{relative} ({size} bytes)") def tail_text_file(path: Path, tail_lines: int, max_text: int) -> list[str]: if not path.exists(): return [] lines = path.read_text(errors="replace").splitlines() return [truncate(line, max_text) for line in lines[-tail_lines:]] def print_task_summary(session_dir: Path, tail_lines: int, max_text: int) -> None: tasks_dir = session_dir / "tasks" if not tasks_dir.exists(): return task_dirs = sorted(path for path in tasks_dir.iterdir() if path.is_dir()) if not task_dirs: return print_header("Background Tasks") for task_dir in task_dirs: spec = load_json(task_dir / "spec.json") or {} runtime = load_json(task_dir / "runtime.json") or {} control = load_json(task_dir / "control.json") or {} consumer = load_json(task_dir / "consumer.json") or {} print(f"task_id: {task_dir.name}") print(f" description: {spec.get('description')}") print(f" kind: {spec.get('kind')}") print(f" status: {runtime.get('status')}") if runtime.get("exit_code") is not None: print(f" exit_code: {runtime.get('exit_code')}") if spec.get("cwd"): print(f" cwd: {spec.get('cwd')}") if spec.get("timeout_s") is not None: print(f" timeout_s: {spec.get('timeout_s')}") for key in ( "created_at", "started_at", "finished_at", "heartbeat_at", "failure_reason", "worker_pid", "child_pid", ): value = runtime.get(key) if value is not None: print(f" {key}: {value}") for key in ("kill_requested_at", "kill_reason"): value = control.get(key) if value is not None: print(f" {key}: {value}") for key in ("last_read_offset", "last_viewed_at"): value = consumer.get(key) if value is not None: print(f" {key}: {value}") output_path = task_dir / "output.log" if output_path.exists(): print(f" output_log: {output_path}") for line in tail_text_file(output_path, tail_lines, max_text): print(f" {line}") print() def main() -> int: args = parse_args() try: if args.session_dir: session_dir = args.session_dir.expanduser().resolve() elif args.share_dir: session_dir = find_latest_session(args.share_dir.expanduser().resolve()) else: print("error: pass --session-dir or --share-dir", file=sys.stderr) return 1 except FileNotFoundError as exc: print(f"error: {exc}", file=sys.stderr) return 1 if not session_dir.is_dir(): print(f"error: session directory does not exist: {session_dir}", file=sys.stderr) return 1 print(f"Session dir: {session_dir}") print_file_inventory(session_dir) print_jsonl_summary("Context", session_dir / "context.jsonl", args.tail_lines, args.max_text) print_jsonl_summary("Wire", session_dir / "wire.jsonl", args.tail_lines, args.max_text) print_task_summary(session_dir, args.tail_lines, args.max_text) return 0 if __name__ == "__main__": raise SystemExit(main()) ================================================ FILE: .agents/skills/gen-changelog/SKILL.md ================================================ --- name: gen-changelog description: Generate changelog entries for code changes. --- 根据当前分支相对于 main 分支的修改,生成更新日志条目并同步到文档站点。 ## 步骤 1. **分析变更**:查看 `git log main..HEAD --oneline` 和 `git diff main..HEAD --stat`,理解所有变更。 2. **更新源 CHANGELOG**:在根目录 `CHANGELOG.md` 的 `## Unreleased` 下添加条目;如果变更属于 `packages/` 或 `sdks/` 下的子包,同时更新对应目录的 `CHANGELOG.md`。 3. **同步英文文档 changelog**:运行 `node docs/scripts/sync-changelog.mjs` 将根 `CHANGELOG.md` 同步到 `docs/en/release-notes/changelog.md`。 4. **更新中文文档 changelog**:在 `docs/zh/release-notes/changelog.md` 的 `## 未发布` 下添加对应的中文翻译条目,遵循现有格式和用词规范(参考 `docs/AGENTS.md` 中的术语表和排版规范)。 5. **Breaking changes**(如有):如果变更包含破坏性变更(如移除/重命名选项、更改默认行为、迁移配置格式等),还需在 `docs/en/release-notes/breaking-changes.md` 和 `docs/zh/release-notes/breaking-changes.md` 的 `## Unreleased` / `## 未发布` 下添加对应条目,遵循现有的格式(版本标题 + 小节 + 受影响/迁移说明)。 ## 注意事项 - 条目风格遵循现有 CHANGELOG 的格式:`- 分类: 描述`(如 `- Core: ...`、`- Web: ...`)。 - 只写对用户有意义的变更,不写纯内部重构。 - 中文翻译应遵循 `docs/AGENTS.md` 中的术语映射和排版规范。 ================================================ FILE: .agents/skills/gen-docs/SKILL.md ================================================ --- name: gen-docs description: Update Kimi Code CLI user documentation. --- 现在我们正在为当前项目 Kimi Code CLI 编写和维护用户文档,文档内容在 docs 目录下,docs/AGENTS.md 中有对文档的说明。 我们现在对代码库有了一些修改,请你参考最近的 git commit、staged changes、changelog.md 等的内容,根据 AGENTS.md 中的信息,必要时找到实际的代码全文,确保理解了所有变更对产品用户体验的真实改变,然后逐页、逐段地检查和更新文档内容。 你应该首先确保英文 changelog 使用 `node docs/scripts/sync-changelog.mjs` 进行了同步,然后确保中文文档符合最新代码的行为,最后,使用 translate-docs skill 进行双语同步。 ================================================ FILE: .agents/skills/gen-rust/SKILL.md ================================================ --- name: gen-rust description: Sync Rust implementation with Python changes (exclude UI/login) by reviewing recent changes, mapping modules, porting logic, and updating tests. --- # gen-rust Use this skill when the user wants Rust (kagent/kosong/kaos) to stay logically identical to Python (kimi_cli/kosong/kaos), excluding UI and login/auth. This includes code and tests: Rust behavior and tests must be fully synchronized with Python changes. Note: The Rust binary is named `kagent`. User-facing CLI/output text in Rust must use `kagent` instead of `kimi` to match the Rust command name. ## Quick workflow 1) **Build a complete change inventory** Review recent changes to understand what needs syncing: ```sh # Check staged changes git diff --cached --name-only git diff --cached -- src packages # Check recent commits git log --oneline -20 -- src packages git diff HEAD~20..HEAD -- src packages # Review CHANGELOG.md for context head -50 CHANGELOG.md ``` 2) **Classify changes** - Exclude UI and login/auth changes (Shell/Print/ACP UI, login/logout commands). - Everything else must be mirrored in Rust. - Keep a small checklist: file -> change summary -> Rust target -> status. 3) **Map Python -> Rust** Common mappings: - `src/kimi_cli/llm.py` -> `rust/kagent/src/llm.rs` - `src/kimi_cli/soul/*` -> `rust/kagent/src/soul/*` - `src/kimi_cli/tools/*` -> `rust/kagent/src/tools/*` - `src/kimi_cli/utils/*` -> `rust/kagent/src/utils/*` - `src/kimi_cli/wire/*` -> `rust/kagent/src/wire/*` - `packages/kosong/*` -> `rust/kosong/*` - `packages/kaos/*` -> `rust/kaos/*` 4) **Port logic carefully** - Match error messages and tool output text exactly (tests often assert strings). - Preserve output types (text vs parts) and ordering. - For media/tool outputs, verify ContentPart wrapping and serialization. - If Python adds new helper modules, mirror minimal Rust utilities. - Use `rg` to find existing analogs and references. 5) **Update tests** - Update Rust tests that assert content/strings/parts. - Mirror Python unit and integration tests when they exist; add missing Rust tests so coverage matches intent. - Ensure E2E parity: use the existing Python E2E suite against the Rust binary by setting `KIMI_E2E_WIRE_CMD` (do not rewrite E2E in Rust). All E2E cases must pass or the gap must be documented. - Prefer targeted tests first (`cargo test -p kagent --test `), then full suite if asked. 6) **Verification is mandatory** - Run the full Rust test suite and ensure all Rust tests pass. - Run E2E tests with the wire command swapped to Rust (set `KIMI_E2E_WIRE_CMD`), and ensure they pass. 7) **Final report** - List synced files and logic. - Call out intentionally skipped UI/login changes. - List tests run and results (must include full Rust tests and Rust E2E with wire command override). ## Pitfalls to avoid - Skipping `llm.py`: it often changes model capability logic. - Using commit message filtering instead of full diff. - Forgetting to update Rust tests when output text/parts change. - Mixing UI/login changes into core sync. - Leaving test parity ambiguous; always state unit/integration/E2E status. ## Minimal diff checklist (template) - [ ] Recent changes reviewed (staged, commits, changelog) - [ ] Python diffs inspected for core logic - [ ] Rust mappings applied - [ ] Tests updated - [ ] Targeted tests run - [ ] Full Rust test suite passed - [ ] Rust E2E passed with `KIMI_E2E_WIRE_CMD` ================================================ FILE: .agents/skills/pull-request/SKILL.md ================================================ --- name: pull-request description: Create and submit a GitHub Pull Request. type: flow --- ```mermaid flowchart TB A(["BEGIN"]) --> B["当前分支有没有 dirty change?"] B -- 有 --> D(["END"]) B -- 没有 --> n1["确保当前分支是一个不同于 main 的独立分支"] n1 --> n2["根据当前分支相对于 main 分支的修改,push 并提交一个 PR(利用 gh 命令),用英文编写 PR 标题和 description,描述所做的更改。PR title 要符合先前的 commit message 规范(PR title 就是 squash merge 之后的 commit message)。"] n2 --> D ``` ================================================ FILE: .agents/skills/release/SKILL.md ================================================ --- name: release description: Execute the release workflow for Kimi Code CLI packages. type: flow --- ```d2 understand: |md Understand the release automation by reading AGENTS.md and .github/workflows/release*.yml. | check_changes: |md Check each package under packages/, sdks/, and repo root for changes since the last release (by tag). Note packages/kimi-code is a thin wrapper and must stay version-synced with kimi-cli. | has_changes: "Any packages changed?" confirm_versions: |md For each changed package, confirm the new version with the user. Follow the project versioning policy: patch is always 0, bump minor for any change, major only changes by explicit manual decision. | update_files: |md Update the relevant pyproject.toml (and rust/Cargo.toml if root version changes), CHANGELOG.md (keep the Unreleased header), and breaking-changes.md in both languages. | root_change: "Is the root package version changing?" sync_kimi_code: |md Sync packages/kimi-code/pyproject.toml version and dependency `kimi-cli==`. | sync_kagent: |md Sync rust/Cargo.toml workspace version to match the root package version. | uv_sync: "Run uv sync." gen_docs: |md Follow the gen-docs skill instructions to ensure docs are up to date. | new_branch: |md Create a new branch `bump--` (multiple packages can share one branch; name it appropriately). | open_pr: |md Commit all changes, push to remote, and open a PR with gh describing the updates. | monitor_pr: "Monitor the PR until it is merged." post_merge: |md After merge, switch to main, pull latest changes, and tell the user the git tag command needed for the final release tag (they will tag + push tags). Note: a single numeric tag releases kimi-cli, kimi-code, and kagent together. | BEGIN -> understand -> check_changes -> has_changes has_changes -> END: no has_changes -> confirm_versions: yes confirm_versions -> update_files -> root_change root_change -> sync_kimi_code: yes root_change -> uv_sync: no sync_kimi_code -> sync_kagent sync_kagent -> uv_sync uv_sync -> gen_docs -> new_branch -> open_pr -> monitor_pr -> post_merge -> END ``` ================================================ FILE: .agents/skills/translate-docs/SKILL.md ================================================ --- name: translate-docs description: Translate and sync bilingual documentation. --- 现在我们正在为当前项目 Kimi Code CLI 编写和维护用户文档,文档内容在 docs 目录下,docs/AGENTS.md 中有对文档的说明。 中文文档和英文 changelog 已经确保是正确符合预期的,现在请你逐页、逐段地翻译文档内容,确保中英双语保持同步。 ## 翻译方向 - **Changelog**: 以英文为准,翻译到中文 - **其他所有页面**: 以中文为准,翻译到英文 ## 注意事项 - 必要时可以参考代码文件以确保翻译的准确性 - 要保证不同语言的表达风格、结构标记等保持一致 - 但需要遵守两者可能不同的用词和排版偏好(主要是 sentence case、翻译对照之类的) ================================================ FILE: .agents/skills/worktree-status/SKILL.md ================================================ --- name: worktree-status description: Audit all git worktrees in the current project. Use when the user asks about worktree status, which branches are merged, which have uncommitted changes, or which worktrees can be safely cleaned up. --- # worktree-status Report the status of every git worktree for the current project, covering dirty state and merge status. ## When to use - User asks "which worktrees can I clean up?" - User asks "what's the status of my worktrees / branches?" - Before batch-cleaning worktrees, to avoid losing uncommitted work ## Procedure ### 1. Pull latest main (MANDATORY) You MUST pull latest main before any status checks. Without this, merge detection (both ancestry and content diff) will produce stale results and you may mistakenly conclude a branch is not merged. ```bash cd "$(git rev-parse --show-toplevel)" && git pull origin main ``` ### 2. Collect worktree info ```bash PROJECT_DIR="$(git rev-parse --show-toplevel)" for wt in $(git worktree list --porcelain | grep "^worktree " | sed 's/^worktree //' | grep -v "$PROJECT_DIR$"); do branch=$(git -C "$wt" branch --show-current 2>/dev/null) [ -z "$branch" ] && branch="(detached)" name=$(basename "$wt") # dirty? if [ -z "$(git -C "$wt" status --short 2>/dev/null)" ]; then dirty="clean" else dirty="DIRTY" fi # merged into origin/main? # NOTE: This project uses squash merges exclusively. `git merge-base # --is-ancestor` does NOT detect squash-merged branches. Always follow # up with a content diff (step 3) for branches that appear "not merged". if [ "$branch" != "(detached)" ]; then if git merge-base --is-ancestor "$branch" origin/main 2>/dev/null; then merged="merged" else merged="not merged (verify with content diff)" fi else merged="n/a" fi echo "" echo "[$name] branch=$branch $dirty $merged" if [ "$dirty" = "DIRTY" ]; then git -C "$wt" status --short 2>/dev/null | sed 's/^/ /' fi done ``` ### 3. Detect squash-merged branches (content diff) For any branch that shows "not merged", check whether the branch's changes are already in main. The correct method is: 1. Find the files the branch actually changed (relative to merge-base). 2. For each changed file, compare the branch version with main. If all files are identical, the branch was squash-merged. **⚠️ Do NOT use `git diff origin/main `** — that compares the two tips directly, so commits added to main *after* the branch diverged will show up as false differences. ```bash BRANCH="" BASE=$(git merge-base origin/main "$BRANCH") # List files the branch touched FILES=$(git diff --name-only "$BASE" "$BRANCH") # Compare each file between branch and current main for f in $FILES; do d=$(git diff "$BRANCH" origin/main -- "$f" | wc -l) if [ "$d" != "0" ]; then echo "❌ $f — differs" else echo "✅ $f — identical in main" fi done # All ✅ = squash-merged ``` ### 4. (Optional) Check for associated tmux sessions Only run this if `tmux` is available and relevant (e.g. worktrees were created by codex-worker or similar tooling). Skip if not applicable. ```bash tmux ls 2>/dev/null | grep -E 'codex-worker|' || true ``` ### 5. Present results **Always present results as a Markdown table.** Every worktree must appear as a row. Never use abbreviated or prose-only summaries. | Worktree | Branch | Dirty | Merged | Can clean? | |---|---|---|---|---| | `example-wt` | `feat-foo` | ✅ clean | ✅ squash-merged | ✅ | | `another-wt` | `fix-bar` | ⚠️ 3 files | ❌ not merged | ❌ dirty + not merged | | `detached-wt` | (detached) | ⚠️ 14 files | n/a | ❌ has uncommitted changes | Column definitions: - **Dirty**: `✅ clean` or `⚠️ N files` - **Merged**: `✅ merged` / `✅ squash-merged` (confirmed via content diff) / `❌ not merged` / `n/a` - **Can clean?**: `✅` only when merged (or squash-merged) AND clean Add extra columns (e.g. tmux session, notes) only when relevant. ### 6. Cleanup (only when asked) Only clean worktrees the user explicitly approves. For each: ```bash NAME="" git worktree remove "/path/to/$NAME" git branch -D "" # only if the branch is no longer needed ``` ================================================ FILE: .github/ISSUE_TEMPLATE/1-bug-report.yml ================================================ name: Bug Report description: Report an issue that should be fixed labels: - bug - needs triage body: - type: markdown attributes: value: | Thank you for submitting a bug report! It helps make Kimi Code CLI better for everyone. If you need help or support using Kimi Code CLI, and are not reporting a bug, please post on [kimi-cli/discussions](https://github.com/MoonshotAI/kimi-cli/discussions), where you can ask questions or engage with others on ideas for how to improve Kimi Code CLI. Make sure you are running the latest version of Kimi Code CLI (`uv tool upgrade kimi-cli` to upgrade). The bug you are experiencing may already have been fixed. Please try to include as much information as possible. - type: input id: version attributes: label: What version of Kimi Code CLI is running? description: Copy the output of `kimi --version` or `/version` validations: required: true - type: input id: plan attributes: label: Which open platform/subscription were you using? description: The one you selected when running `/login` or `/setup` validations: required: true - type: input id: model attributes: label: Which model were you using? description: The one you can see on the bottom status line, like `kimi-k2-turbo-preview`, `kimi-for-coding`, etc. - type: input id: platform attributes: label: What platform is your computer? description: | For MacOS and Linux: copy the output of `uname -mprs` For Windows: copy the output of `"$([Environment]::OSVersion | ForEach-Object VersionString) $(if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" })"` in the PowerShell console - type: textarea id: actual attributes: label: What issue are you seeing? description: Please include the full error messages and prompts with any private information redacted. If possible, please provide text instead of a screenshot. validations: required: true - type: textarea id: steps attributes: label: What steps can reproduce the bug? description: Explain the bug and provide a code snippet that can reproduce it. Please include session id and context usage if applicable. validations: required: true - type: textarea id: expected attributes: label: What is the expected behavior? description: If possible, please provide text instead of a screenshot. - type: textarea id: notes attributes: label: Additional information description: Is there anything else you think we should know? ================================================ FILE: .github/ISSUE_TEMPLATE/2-feature-request.yml ================================================ name: Feature Request description: Propose a new feature for Kimi Code CLI labels: - enhancement body: - type: markdown attributes: value: | Is Kimi Code CLI missing a feature that you'd like to see? Feel free to propose it here. Before you submit a feature: 1. Search existing issues for similar features. If you find one, 👍 it rather than opening a new one. 2. The Kimi Code CLI team will try to balance the varying needs of the community when prioritizing or rejecting new features. Please understand that not all features will be accepted. - type: textarea id: feature attributes: label: What feature would you like to see? validations: required: true - type: textarea id: notes attributes: label: Additional information description: Is there anything else you think we should know? ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: true contact_links: - name: Questions and General Discussion url: https://github.com/MoonshotAI/kimi-cli/discussions about: Have questions? Welcome to open a discussion! ================================================ FILE: .github/actions/macos-code-sign/action.yml ================================================ name: macos-code-sign description: Sign and notarize macOS PyInstaller binaries inputs: binary-path: description: Path to the binary to sign required: true apple-certificate-p12: description: Base64-encoded Apple signing certificate (P12) required: true apple-certificate-password: description: Password for the signing certificate required: true apple-notarization-key-p8: description: Base64-encoded Apple notarization key (P8) required: true apple-notarization-key-id: description: Apple notarization key ID required: true apple-notarization-issuer-id: description: Apple notarization issuer ID required: true runs: using: composite steps: - name: Import signing certificate shell: bash env: APPLE_CERTIFICATE_P12: ${{ inputs.apple-certificate-p12 }} APPLE_CERTIFICATE_PASSWORD: ${{ inputs.apple-certificate-password }} KEYCHAIN_PASSWORD: actions run: | set -euo pipefail # Decode certificate cert_path="${RUNNER_TEMP}/certificate.p12" echo "$APPLE_CERTIFICATE_P12" | base64 -d > "$cert_path" # Create temporary keychain keychain_path="${RUNNER_TEMP}/signing.keychain-db" security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path" security set-keychain-settings -lut 21600 "$keychain_path" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path" # Add to keychain search list security list-keychains -d user -s "$keychain_path" $(security list-keychains -d user | tr -d '"') security default-keychain -s "$keychain_path" # Import certificate security import "$cert_path" -k "$keychain_path" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$keychain_path" > /dev/null # Find signing identity IDENTITY=$(security find-identity -v -p codesigning "$keychain_path" | grep "Developer ID Application" | head -1 | sed -n 's/.*"\(Developer ID Application[^"]*\)".*/\1/p') if [[ -z "$IDENTITY" ]]; then echo "❌ No Developer ID Application identity found" security find-identity -v -p codesigning "$keychain_path" exit 1 fi echo "✅ Found signing identity: $IDENTITY" echo "APPLE_SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" echo "APPLE_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV" rm -f "$cert_path" - name: Sign PyInstaller binary and embedded libraries shell: bash env: BINARY_PATH: ${{ inputs.binary-path }} run: | set -euo pipefail echo "Signing PyInstaller binary: $BINARY_PATH" # PyInstaller onefile binaries embed libraries that get extracted at runtime. # We need to unpack, sign everything, and repack. # First, try signing the binary directly with --deep # For single-file PyInstaller executables, this should work codesign --deep --force --options runtime --timestamp \ --sign "$APPLE_SIGNING_IDENTITY" \ --keychain "$APPLE_KEYCHAIN_PATH" \ "$BINARY_PATH" echo "✅ Binary signed" codesign -dv --verbose=2 "$BINARY_PATH" - name: Notarize binary shell: bash env: BINARY_PATH: ${{ inputs.binary-path }} APPLE_NOTARIZATION_KEY_P8: ${{ inputs.apple-notarization-key-p8 }} APPLE_NOTARIZATION_KEY_ID: ${{ inputs.apple-notarization-key-id }} APPLE_NOTARIZATION_ISSUER_ID: ${{ inputs.apple-notarization-issuer-id }} run: | set -euo pipefail # Save API key key_path="${RUNNER_TEMP}/AuthKey.p8" echo "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$key_path" # Create zip for notarization binary_name=$(basename "$BINARY_PATH") zip_path="${RUNNER_TEMP}/${binary_name}.zip" ditto -c -k --keepParent "$BINARY_PATH" "$zip_path" echo "Submitting for notarization..." # Submit and wait result=$(xcrun notarytool submit "$zip_path" \ --key "$key_path" \ --key-id "$APPLE_NOTARIZATION_KEY_ID" \ --issuer "$APPLE_NOTARIZATION_ISSUER_ID" \ --wait \ --timeout 10m \ --output-format json 2>&1) || true echo "$result" status=$(echo "$result" | grep -o '"status":"[^"]*"' | cut -d'"' -f4 || echo "unknown") if [[ "$status" == "Accepted" ]]; then echo "✅ Notarization successful" else echo "⚠️ Notarization status: $status" # Get detailed log submission_id=$(echo "$result" | grep -o '"id":"[^"]*"' | cut -d'"' -f4 || echo "") if [[ -n "$submission_id" ]]; then echo "Fetching notarization log..." xcrun notarytool log "$submission_id" \ --key "$key_path" \ --key-id "$APPLE_NOTARIZATION_KEY_ID" \ --issuer "$APPLE_NOTARIZATION_ISSUER_ID" || true fi exit 1 fi # Cleanup rm -f "$key_path" "$zip_path" - name: Verify signature shell: bash env: BINARY_PATH: ${{ inputs.binary-path }} run: | set -euo pipefail echo "Verifying signature and notarization..." codesign -dv --verbose=2 "$BINARY_PATH" echo "" echo "Gatekeeper check:" spctl -a -vv "$BINARY_PATH" 2>&1 || true - name: Cleanup keychain if: always() shell: bash run: | if [[ -n "${APPLE_KEYCHAIN_PATH:-}" && -f "${APPLE_KEYCHAIN_PATH}" ]]; then security delete-keychain "$APPLE_KEYCHAIN_PATH" || true fi ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: - package-ecosystem: "uv" directory: "/" schedule: interval: "daily" ================================================ FILE: .github/pr-title-checker-config.json ================================================ { "LABEL": { "name": "Invalid PR Title", "color": "B60205" }, "CHECKS": { "regexp": "^(feat|fix|test|refactor|chore|style|docs|perf|build|ci|revert)(\\(.*\\))?:.*" }, "MESSAGES": { "failure": "The PR title is invalid. Please refer to https://www.conventionalcommits.org/en/v1.0.0/ for the convention." } } ================================================ FILE: .github/pull_request_template.md ================================================ ## Related Issue Resolve #(issue_number) ## Description ## Checklist - [ ] I have read the [CONTRIBUTING](https://github.com/MoonshotAI/kimi-cli/blob/main/CONTRIBUTING.md) document. - [ ] I have linked the related issue, if any. - [ ] I have added tests that prove my fix is effective or that my feature works. - [ ] I have run `make gen-changelog` to update the changelog. - [ ] I have run `make gen-docs` to update the user documentation. ================================================ FILE: .github/workflows/ci-docs.yml ================================================ name: CI (docs) on: pull_request: paths: - ".github/workflows/ci-docs.yml" - ".github/workflows/docs-pages.yml" - "docs/**" - "CHANGELOG.md" push: branches: - main paths: - ".github/workflows/ci-docs.yml" - ".github/workflows/docs-pages.yml" - "docs/**" - "CHANGELOG.md" jobs: build: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: "20" - name: Cache docs node_modules uses: actions/cache@v4 with: path: docs/node_modules key: ${{ runner.os }}-docs-node-modules-${{ hashFiles('docs/package.json') }} restore-keys: | ${{ runner.os }}-docs-node-modules- - name: Install docs dependencies working-directory: docs run: npm install --no-package-lock - name: Build docs working-directory: docs run: npm run build ================================================ FILE: .github/workflows/ci-kimi-cli.yml ================================================ name: CI (kimi-cli) on: pull_request: paths: - ".github/workflows/**" - "packages/**" - "src/**" - "tests/**" - "tests_e2e/**" - "tests_ai/**" - "web/**" - "pyproject.toml" - "uv.lock" push: branches: - main paths: - ".github/workflows/**" - "packages/**" - "src/**" - "tests/**" - "tests_e2e/**" - "tests_ai/**" - "web/**" - "pyproject.toml" - "uv.lock" env: NO_COLOR: "1" TERM: dumb jobs: check: runs-on: ubuntu-22.04 steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Python 3.14 id: setup-python uses: actions/setup-python@v5 with: python-version: "3.14" allow-prereleases: true - name: Set up uv uses: astral-sh/setup-uv@v1 with: version: "0.8.5" enable-cache: true cache-dependency-glob: uv.lock - name: Prepare building environment env: UV_PYTHON: ${{ steps.setup-python.outputs.python-path }} run: make prepare - name: Run checks env: UV_PYTHON: ${{ steps.setup-python.outputs.python-path }} run: make check-kimi-cli test: strategy: fail-fast: false matrix: python-version: ["3.12", "3.13", "3.14"] runs-on: ubuntu-22.04 steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} id: setup-python uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true - name: Set up uv uses: astral-sh/setup-uv@v1 with: version: "0.8.5" enable-cache: true cache-dependency-glob: uv.lock - name: Prepare building environment env: UV_PYTHON: ${{ steps.setup-python.outputs.python-path }} run: make prepare - name: Run tests env: UV_PYTHON: ${{ steps.setup-python.outputs.python-path }} PYTHONUTF8: "1" run: make test-kimi-cli build: strategy: fail-fast: true matrix: include: - runner: ubuntu-22.04 target: x86_64-unknown-linux-gnu binary_path: dist/onefile/kimi - runner: ubuntu-22.04-arm target: aarch64-unknown-linux-gnu binary_path: dist/onefile/kimi - runner: macos-14 target: aarch64-apple-darwin binary_path: dist/onefile/kimi - runner: windows-2022 target: x86_64-pc-windows-msvc binary_path: dist/onefile/kimi.exe runs-on: ${{ matrix.runner }} steps: - name: Checkout repository uses: actions/checkout@v4 - name: Install GNU Make (Windows) if: runner.os == 'Windows' run: choco install make -y - name: Set up Python 3.13 id: setup-python uses: actions/setup-python@v5 with: python-version: "3.13" - name: Set up uv uses: astral-sh/setup-uv@v1 with: version: "0.8.5" enable-cache: true cache-dependency-glob: uv.lock - name: Set up Node.js (web build) uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" cache-dependency-path: web/package-lock.json - name: Prepare building environment env: UV_PYTHON: ${{ steps.setup-python.outputs.python-path }} run: make prepare - name: Build standalone binary env: UV_PYTHON: ${{ steps.setup-python.outputs.python-path }} run: make build-bin - name: Smoke test binary --help shell: python run: | import os import subprocess binary = os.path.abspath(os.environ["BINARY_PATH"]) result = subprocess.run([binary, "--help"], capture_output=True, text=True, check=True) if "Kimi" not in result.stdout: raise SystemExit("'Kimi' not found in --help output") env: BINARY_PATH: ${{ matrix.binary_path }} - name: Upload binary artifact if: success() uses: actions/upload-artifact@v4 with: name: kimi-${{ matrix.target }} path: ${{ matrix.binary_path }} if-no-files-found: error retention-days: 7 release-validate: if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python 3.14 uses: actions/setup-python@v5 with: python-version: "3.14" allow-prereleases: true - name: Detect version bump id: version shell: python run: | import os import subprocess import tomllib output_path = os.environ["GITHUB_OUTPUT"] event_name = os.environ.get("GITHUB_EVENT_NAME", "") base_ref = os.environ.get("GITHUB_BASE_REF") if event_name != "pull_request" or not base_ref: with open(output_path, "a", encoding="utf-8") as output: output.write("bump=false\n") raise SystemExit(0) subprocess.run(["git", "fetch", "origin", base_ref, "--depth=1"], check=True) base_blob = subprocess.check_output( ["git", "show", f"origin/{base_ref}:pyproject.toml"], ) base_version = tomllib.loads(base_blob.decode())["project"]["version"] with open("pyproject.toml", "rb") as handle: head_version = tomllib.load(handle)["project"]["version"] bumped = base_version != head_version with open(output_path, "a", encoding="utf-8") as output: output.write(f"bump={'true' if bumped else 'false'}\n") output.write(f"base_version={base_version}\n") output.write(f"head_version={head_version}\n") - name: Show version bump info if: steps.version.outputs.bump == 'true' run: | echo "version bump: ${{ steps.version.outputs.base_version }} -> ${{ steps.version.outputs.head_version }}" - name: Check dependency versions if: steps.version.outputs.bump == 'true' run: | python scripts/check_kimi_dependency_versions.py \ --root-pyproject pyproject.toml \ --kosong-pyproject packages/kosong/pyproject.toml \ --pykaos-pyproject packages/kaos/pyproject.toml - name: Check kimi-code version alignment if: steps.version.outputs.bump == 'true' run: | python scripts/check_version_tag.py \ --pyproject packages/kimi-code/pyproject.toml \ --expected-version "${{ steps.version.outputs.head_version }}" - name: Check kimi-code dependency pin if: steps.version.outputs.bump == 'true' shell: python run: | import tomllib from pathlib import Path expected_version = "${{ steps.version.outputs.head_version }}" data = tomllib.loads(Path("packages/kimi-code/pyproject.toml").read_text()) deps = data["project"]["dependencies"] expected = f"kimi-cli=={expected_version}" if expected not in deps: raise SystemExit( "kimi-code must depend on " f"{expected}, got: {deps}" ) nix-test: strategy: fail-fast: true matrix: include: - runner: ubuntu-22.04 target: x86_64-unknown-linux-gnu binary_path: dist/kimi - runner: ubuntu-22.04-arm target: aarch64-unknown-linux-gnu binary_path: dist/kimi - runner: macos-14 target: aarch64-apple-darwin binary_path: dist/kimi runs-on: ${{ matrix.runner }} steps: - name: Checkout repository uses: actions/checkout@v4 - name: Install Nix uses: DeterminateSystems/nix-installer-action@main - name: Run nix package run: nix run .#kimi-cli -- --version && nix run . -- --help ================================================ FILE: .github/workflows/ci-kimi-sdk.yml ================================================ name: CI (kimi-sdk) on: pull_request: paths: - ".github/workflows/ci-kimi-sdk.yml" - "sdks/kimi-sdk/**" - "pyproject.toml" - "uv.lock" push: branches: - main paths: - ".github/workflows/ci-kimi-sdk.yml" - "sdks/kimi-sdk/**" - "pyproject.toml" - "uv.lock" jobs: test: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.12", "3.13", "3.14"] steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} id: setup-python uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true - name: Set up uv uses: astral-sh/setup-uv@v1 with: version: "0.8.5" enable-cache: true cache-dependency-glob: uv.lock - name: Install dependencies env: UV_PYTHON: ${{ steps.setup-python.outputs.python-path }} run: uv sync --frozen --all-extras --project sdks/kimi-sdk - name: Run checks env: UV_PYTHON: ${{ steps.setup-python.outputs.python-path }} run: make check-kimi-sdk - name: Run tests env: UV_PYTHON: ${{ steps.setup-python.outputs.python-path }} run: make test-kimi-sdk docs: runs-on: ubuntu-latest env: FOOTER_VERSION: ${{ github.ref_name }} steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Python 3.14 uses: actions/setup-python@v5 with: python-version: "3.14" allow-prereleases: true - name: Set up uv uses: astral-sh/setup-uv@v1 with: version: "0.8.5" enable-cache: true cache-dependency-glob: uv.lock - name: Install dependencies run: uv sync --frozen --all-extras --project sdks/kimi-sdk - name: Generate API documentation run: | uv run --project sdks/kimi-sdk pdoc kimi_sdk \ --docformat google \ --footer-text "kimi-sdk ${FOOTER_VERSION}" \ -o sdks/kimi-sdk/docs - name: Upload docs preview uses: actions/upload-artifact@v4 with: name: docs-preview path: sdks/kimi-sdk/docs ================================================ FILE: .github/workflows/ci-kosong.yml ================================================ name: CI (kosong) on: pull_request: paths: - ".github/workflows/ci-kosong.yml" - "packages/kosong/**" - "pyproject.toml" - "uv.lock" push: branches: - main paths: - ".github/workflows/ci-kosong.yml" - "packages/kosong/**" - "pyproject.toml" - "uv.lock" jobs: test: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.12", "3.13", "3.14"] steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} id: setup-python uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true - name: Set up uv uses: astral-sh/setup-uv@v1 with: version: "0.8.5" enable-cache: true cache-dependency-glob: uv.lock - name: Install dependencies env: UV_PYTHON: ${{ steps.setup-python.outputs.python-path }} run: uv sync --frozen --all-extras --project packages/kosong - name: Run checks env: UV_PYTHON: ${{ steps.setup-python.outputs.python-path }} run: make check-kosong - name: Run tests env: UV_PYTHON: ${{ steps.setup-python.outputs.python-path }} run: make test-kosong docs: runs-on: ubuntu-latest env: FOOTER_VERSION: ${{ github.ref_name }} steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Python 3.14 uses: actions/setup-python@v5 with: python-version: "3.14" allow-prereleases: true - name: Set up uv uses: astral-sh/setup-uv@v1 with: version: "0.8.5" enable-cache: true cache-dependency-glob: uv.lock - name: Install dependencies run: uv sync --frozen --all-extras --project packages/kosong - name: Generate API documentation run: | uv run --project packages/kosong pdoc kosong \ --docformat google \ --footer-text "kosong ${FOOTER_VERSION}" \ -o packages/kosong/docs - name: Upload docs preview uses: actions/upload-artifact@v4 with: name: docs-preview path: packages/kosong/docs ================================================ FILE: .github/workflows/ci-pykaos.yml ================================================ name: CI (pykaos) on: pull_request: paths: - ".github/workflows/ci-pykaos.yml" - "packages/kaos/**" - "pyproject.toml" - "uv.lock" push: branches: - main paths: - ".github/workflows/ci-pykaos.yml" - "packages/kaos/**" - "pyproject.toml" - "uv.lock" jobs: test: name: ${{ matrix.target }} python-${{ matrix.python-version }} runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: runner: [ubuntu-22.04, macos-15-intel, macos-14, windows-2022] python-version: ["3.12", "3.13", "3.14"] include: - runner: ubuntu-22.04 target: x86_64-unknown-linux-gnu - runner: macos-15-intel target: x86_64-apple-darwin - runner: macos-14 target: aarch64-apple-darwin - runner: windows-2022 target: x86_64-pc-windows-msvc defaults: run: shell: bash steps: - name: Checkout repository uses: actions/checkout@v4 - name: Install GNU Make (Windows) if: runner.os == 'Windows' shell: powershell run: choco install make -y - name: Set up Python ${{ matrix.python-version }} id: setup-python uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true - name: Set up uv uses: astral-sh/setup-uv@v1 with: version: "0.8.5" enable-cache: true cache-dependency-glob: uv.lock - name: Install dependencies env: UV_PYTHON: ${{ steps.setup-python.outputs.python-path }} run: uv sync --frozen --all-extras --project packages/kaos - name: Configure local SSH server if: matrix.runner == 'ubuntu-22.04' run: | set -euxo pipefail sudo apt-get update sudo apt-get install -y openssh-server sudo mkdir -p /run/sshd sudo sed -i 's/^#\?PasswordAuthentication .*/PasswordAuthentication no/' /etc/ssh/sshd_config sudo sed -i 's/^#\?PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config sudo sed -i 's@^#\?AuthorizedKeysFile .*@AuthorizedKeysFile .ssh/authorized_keys@' /etc/ssh/sshd_config sudo service ssh restart || sudo service ssh start mkdir -p "$HOME/.ssh" chmod 700 "$HOME/.ssh" ssh-keygen -q -t ed25519 -f "$HOME/.ssh/id_ci" -N "" cat "$HOME/.ssh/id_ci.pub" >> "$HOME/.ssh/authorized_keys" chmod 600 "$HOME/.ssh/authorized_keys" ssh-keyscan -H 127.0.0.1 >> "$HOME/.ssh/known_hosts" ssh-keyscan -H localhost >> "$HOME/.ssh/known_hosts" echo "KAOS_SSH_HOST=127.0.0.1" >> "$GITHUB_ENV" echo "KAOS_SSH_PORT=22" >> "$GITHUB_ENV" echo "KAOS_SSH_USERNAME=$USER" >> "$GITHUB_ENV" echo "KAOS_SSH_KEY_PATHS=$HOME/.ssh/id_ci" >> "$GITHUB_ENV" - name: Verify SSH connectivity if: matrix.runner == 'ubuntu-22.04' run: ssh -i "$HOME/.ssh/id_ci" -o BatchMode=yes -o StrictHostKeyChecking=yes "$USER@127.0.0.1" true - name: Run checks env: UV_PYTHON: ${{ steps.setup-python.outputs.python-path }} run: make check-pykaos - name: Run tests env: UV_PYTHON: ${{ steps.setup-python.outputs.python-path }} run: make test-pykaos ================================================ FILE: .github/workflows/docs-pages.yml ================================================ name: Docs (GitHub Pages) on: push: branches: - main permissions: contents: read pages: write id-token: write jobs: deploy: # Only run on the original repository, not on forks if: github.repository == 'MoonshotAI/kimi-cli' runs-on: ubuntu-latest environment: name: github-pages url: ${{ steps.deploy.outputs.page_url }} steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: "20" - name: Cache docs node_modules uses: actions/cache@v4 with: path: docs/node_modules key: ${{ runner.os }}-docs-node-modules-${{ hashFiles('docs/package.json') }} restore-keys: | ${{ runner.os }}-docs-node-modules- - name: Configure GitHub Pages id: pages uses: actions/configure-pages@v5 - name: Set VitePress base shell: bash env: BASE_PATH: ${{ steps.pages.outputs.base_path }} run: | set -euo pipefail if [[ -z "${BASE_PATH}" ]]; then base="/" else base="${BASE_PATH%/}/" fi echo "VITEPRESS_BASE=${base}" >> "$GITHUB_ENV" - name: Install docs dependencies working-directory: docs run: npm install --no-package-lock - name: Build docs working-directory: docs env: VITEPRESS_BASE: ${{ env.VITEPRESS_BASE }} run: npm run build - name: Add .nojekyll run: touch docs/.vitepress/dist/.nojekyll - name: Upload Pages artifact uses: actions/upload-pages-artifact@v3 with: path: docs/.vitepress/dist - name: Deploy to GitHub Pages id: deploy uses: actions/deploy-pages@v4 ================================================ FILE: .github/workflows/pr-title-checker.yml ================================================ name: PR Title Checker on: pull_request: types: [opened, edited, labeled] jobs: check: runs-on: ubuntu-latest name: pr-title-checker steps: - uses: thehanimo/pr-title-checker@v1.4.3 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} configuration_path: ".github/pr-title-checker-config.json" ================================================ FILE: .github/workflows/release-kimi-cli.yml ================================================ name: Release (kimi-cli) on: push: tags: - "[0-9]*" permissions: contents: write jobs: validate: name: Validate tag runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Python 3.14 uses: actions/setup-python@v5 with: python-version: "3.14" allow-prereleases: true - name: Check version tag run: | python scripts/check_version_tag.py \ --pyproject pyproject.toml \ --expected-version "${GITHUB_REF_NAME}" - name: Check kimi-code version tag run: | python scripts/check_version_tag.py \ --pyproject packages/kimi-code/pyproject.toml \ --expected-version "${GITHUB_REF_NAME}" - name: Check dependency versions run: | python scripts/check_kimi_dependency_versions.py \ --root-pyproject pyproject.toml \ --kosong-pyproject packages/kosong/pyproject.toml \ --pykaos-pyproject packages/kaos/pyproject.toml build: name: Build binaries (${{ matrix.target }}) needs: validate strategy: fail-fast: false matrix: include: - runner: ubuntu-22.04 target: x86_64-unknown-linux-gnu - runner: ubuntu-22.04-arm target: aarch64-unknown-linux-gnu - runner: macos-14 target: aarch64-apple-darwin - runner: windows-2022 target: x86_64-pc-windows-msvc - runner: windows-11-arm target: aarch64-pc-windows-msvc runs-on: ${{ matrix.runner }} env: KIMI_WEB_STRICT_VERSION: "1" KIMI_WEB_EXPECT_VERSION: ${{ github.ref_name }} steps: - name: Checkout repository uses: actions/checkout@v4 - name: Install GNU Make (Windows) if: runner.os == 'Windows' run: choco install make -y - name: Set up Rust uses: dtolnay/rust-toolchain@stable - name: Set up Python 3.14 uses: actions/setup-python@v5 with: python-version: "3.14" allow-prereleases: true - name: Set up uv uses: astral-sh/setup-uv@v1 with: version: "0.8.5" - name: Set up Node.js (web build) uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" cache-dependency-path: web/package-lock.json - name: Prepare building environment run: make prepare-build # macOS: Setup signing certificate before build - name: Setup macOS signing certificate if: runner.os == 'macOS' env: APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} KEYCHAIN_PASSWORD: actions run: | set -euo pipefail # Decode certificate cert_path="${RUNNER_TEMP}/certificate.p12" echo "$APPLE_CERTIFICATE_P12" | base64 -d > "$cert_path" # Create temporary keychain keychain_path="${RUNNER_TEMP}/signing.keychain-db" security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path" security set-keychain-settings -lut 21600 "$keychain_path" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path" # Add to keychain search list security list-keychains -d user -s "$keychain_path" $(security list-keychains -d user | tr -d '"') security default-keychain -s "$keychain_path" # Import certificate security import "$cert_path" -k "$keychain_path" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$keychain_path" > /dev/null # Find signing identity IDENTITY=$(security find-identity -v -p codesigning "$keychain_path" | grep "Developer ID Application" | head -1 | sed -n 's/.*"\(Developer ID Application[^"]*\)".*/\1/p') if [[ -z "$IDENTITY" ]]; then echo "❌ No Developer ID Application identity found" security find-identity -v -p codesigning "$keychain_path" exit 1 fi echo "✅ Found signing identity: $IDENTITY" echo "APPLE_SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" echo "APPLE_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV" rm -f "$cert_path" # Build onefile and onedir versions (all platforms) - name: Build standalone binary (onefile) run: make build-bin - name: Build standalone binary (onedir) env: PYINSTALLER_ONEDIR: "1" run: make build-bin-onedir # macOS: Sign onefile binary - name: Sign macOS onefile binary if: runner.os == 'macOS' run: | set -euo pipefail echo "Signing onefile binary..." codesign --deep --force --options runtime --timestamp \ --sign "$APPLE_SIGNING_IDENTITY" \ --keychain "$APPLE_KEYCHAIN_PATH" \ dist/onefile/kimi echo "✅ Onefile binary signed" codesign -dv --verbose=2 dist/onefile/kimi # macOS: Sign onedir binaries (all dylibs and executables) - name: Sign macOS onedir binaries if: runner.os == 'macOS' run: | set -euo pipefail echo "Signing onedir binaries..." # 1. Sign all dylibs and so files first (excluding those inside frameworks) find dist/onedir/kimi -type f \( -name "*.dylib" -o -name "*.so" \) ! -path "*.framework/*" | while read -r lib; do echo "Signing: $lib" codesign --force --options runtime --timestamp \ --sign "$APPLE_SIGNING_IDENTITY" \ --keychain "$APPLE_KEYCHAIN_PATH" \ "$lib" done # 2. Sign all frameworks with --deep (important for Python.framework) find dist/onedir/kimi -type d -name "*.framework" | while read -r framework; do echo "Signing framework: $framework" codesign --deep --force --options runtime --timestamp \ --sign "$APPLE_SIGNING_IDENTITY" \ --keychain "$APPLE_KEYCHAIN_PATH" \ "$framework" done # 3. Sign the main executable last echo "Signing main executable: dist/onedir/kimi/kimi" codesign --force --options runtime --timestamp \ --sign "$APPLE_SIGNING_IDENTITY" \ --keychain "$APPLE_KEYCHAIN_PATH" \ dist/onedir/kimi/kimi echo "✅ Onedir binaries signed" codesign -dv --verbose=2 dist/onedir/kimi/kimi codesign --verify --deep --strict dist/onedir/kimi/kimi && echo "✅ Deep verification passed" # macOS: Notarize onefile binary - name: Notarize macOS onefile binary if: runner.os == 'macOS' env: APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} run: | set -euo pipefail # Save API key key_path="${RUNNER_TEMP}/AuthKey.p8" echo "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$key_path" # Create zip for notarization (use --norsrc to avoid ._ AppleDouble files) zip_path="${RUNNER_TEMP}/kimi-onefile.zip" ditto -c -k --norsrc --keepParent dist/onefile/kimi "$zip_path" echo "Submitting onefile for notarization..." # Submit and capture output for status verification xcrun notarytool submit "$zip_path" \ --key "$key_path" \ --key-id "$APPLE_NOTARIZATION_KEY_ID" \ --issuer "$APPLE_NOTARIZATION_ISSUER_ID" \ --wait \ --timeout 15m \ 2>&1 | tee /tmp/notarize-onefile.log # Verify notarization was accepted if ! grep -q "status: Accepted" /tmp/notarize-onefile.log; then echo "❌ Onefile notarization failed!" cat /tmp/notarize-onefile.log # Get detailed error log from Apple submission_id=$(grep "id:" /tmp/notarize-onefile.log | head -1 | awk '{print $2}') if [[ -n "$submission_id" ]]; then echo "Fetching notarization log for submission: $submission_id" xcrun notarytool log "$submission_id" \ --key "$key_path" \ --key-id "$APPLE_NOTARIZATION_KEY_ID" \ --issuer "$APPLE_NOTARIZATION_ISSUER_ID" 2>&1 || true fi exit 1 fi echo "✅ Onefile notarization completed and accepted" # Verify signature and notarization status echo "Verifying onefile signature..." codesign -dv --verbose=2 dist/onefile/kimi echo "Verifying onefile notarization (online check)..." spctl -a -vvv -t install dist/onefile/kimi # Cleanup rm -f "$zip_path" /tmp/notarize-onefile.log # macOS: Notarize onedir binaries - name: Notarize macOS onedir binaries if: runner.os == 'macOS' env: APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} run: | set -euo pipefail # Save API key (might already exist from previous step) key_path="${RUNNER_TEMP}/AuthKey.p8" if [[ ! -f "$key_path" ]]; then echo "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$key_path" fi # Create zip for notarization (use --norsrc to avoid ._ AppleDouble files) zip_path="${RUNNER_TEMP}/kimi-onedir.zip" ditto -c -k --norsrc --keepParent dist/onedir/kimi "$zip_path" echo "Submitting onedir for notarization..." # Submit and capture output for status verification xcrun notarytool submit "$zip_path" \ --key "$key_path" \ --key-id "$APPLE_NOTARIZATION_KEY_ID" \ --issuer "$APPLE_NOTARIZATION_ISSUER_ID" \ --wait \ --timeout 15m \ 2>&1 | tee /tmp/notarize-onedir.log # Verify notarization was accepted if ! grep -q "status: Accepted" /tmp/notarize-onedir.log; then echo "❌ Onedir notarization failed!" cat /tmp/notarize-onedir.log # Get detailed error log from Apple submission_id=$(grep "id:" /tmp/notarize-onedir.log | head -1 | awk '{print $2}') if [[ -n "$submission_id" ]]; then echo "Fetching notarization log for submission: $submission_id" xcrun notarytool log "$submission_id" \ --key "$key_path" \ --key-id "$APPLE_NOTARIZATION_KEY_ID" \ --issuer "$APPLE_NOTARIZATION_ISSUER_ID" 2>&1 || true fi exit 1 fi echo "✅ Onedir notarization completed and accepted" # Verify signature and notarization status echo "Verifying onedir signature..." codesign -dv --verbose=2 dist/onedir/kimi/kimi echo "Verifying onedir notarization (online check)..." spctl -a -vvv -t install dist/onedir/kimi/kimi # Cleanup rm -f "$key_path" "$zip_path" /tmp/notarize-onedir.log # macOS: Cleanup keychain - name: Cleanup macOS keychain if: always() && runner.os == 'macOS' run: | if [[ -n "${APPLE_KEYCHAIN_PATH:-}" && -f "${APPLE_KEYCHAIN_PATH}" ]]; then security delete-keychain "$APPLE_KEYCHAIN_PATH" || true fi # Package onefile artifact (all platforms) - name: Package onefile artifact shell: python env: TAG: ${{ github.ref_name }} TARGET: ${{ matrix.target }} run: | import os import pathlib import tarfile import zipfile tag = os.environ["TAG"] target = os.environ["TARGET"] dist_dir = pathlib.Path("dist") artifacts_dir = pathlib.Path("artifacts") artifacts_dir.mkdir(parents=True, exist_ok=True) is_windows = "windows" in target is_macos = "apple-darwin" in target binary_name = "kimi.exe" if is_windows else "kimi" binary_path = dist_dir / "onefile" / binary_name if not binary_path.exists(): raise SystemExit(f"Binary not found at {binary_path}") # Determine archive format and name # - Windows: .zip # - macOS: .tar.gz # - Linux: .tar.gz if is_windows: archive_name = f"kimi-{tag}-{target}.zip" archive_path = artifacts_dir / archive_name with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive_file: archive_file.write(binary_path, arcname=binary_name) else: archive_name = f"kimi-{tag}-{target}.tar.gz" archive_path = artifacts_dir / archive_name with tarfile.open(archive_path, "w:gz") as archive_file: archive_file.add(binary_path, arcname="kimi") print(f"Built onefile artifact: {archive_path}") # Package onedir artifact (all platforms) - name: Package onedir artifact shell: python env: TAG: ${{ github.ref_name }} TARGET: ${{ matrix.target }} run: | import os import pathlib import tarfile import zipfile tag = os.environ["TAG"] target = os.environ["TARGET"] dist_dir = pathlib.Path("dist") artifacts_dir = pathlib.Path("artifacts") artifacts_dir.mkdir(parents=True, exist_ok=True) is_windows = "windows" in target # Windows: onedir is dist/onedir/kimi with kimi.exe inside # Others: onedir is dist/onedir/kimi with kimi inside onedir_path = dist_dir / "onedir" / "kimi" if not onedir_path.exists() or not onedir_path.is_dir(): raise SystemExit(f"Onedir directory not found at {onedir_path}") if is_windows: archive_name = f"kimi-{tag}-{target}-onedir.zip" archive_path = artifacts_dir / archive_name with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive_file: # Add the directory contents with kimi/ as the root for item in onedir_path.rglob("*"): if item.is_file(): arcname = f"kimi/{item.relative_to(onedir_path)}" archive_file.write(item, arcname=arcname) else: archive_name = f"kimi-{tag}-{target}-onedir.tar.gz" archive_path = artifacts_dir / archive_name with tarfile.open(archive_path, "w:gz") as archive_file: # Add the directory contents with kimi/ as the root for item in onedir_path.iterdir(): archive_file.add(item, arcname=f"kimi/{item.name}") print(f"Built onedir artifact: {archive_path}") - name: Set artifact name id: artifact shell: python run: | import os ref = os.environ["GITHUB_REF_NAME"].replace("/", "-") target = "${{ matrix.target }}" with open(os.environ["GITHUB_OUTPUT"], "a") as f: f.write(f"name=kimi-{ref}-{target}\n") - name: Upload artifact uses: actions/upload-artifact@v4 with: name: ${{ steps.artifact.outputs.name }} path: artifacts/* if-no-files-found: error retention-days: 7 release: name: Publish GitHub Release needs: build runs-on: ubuntu-latest steps: - name: Download all build artifacts uses: actions/download-artifact@v4 with: path: ./downloads merge-multiple: true - name: Show downloaded files run: ls -laR downloads - name: Generate per-file SHA256 sums shell: bash run: | set -euxo pipefail cd downloads shopt -s nullglob for f in *.tar.gz *.zip; do sha256sum "$f" > "$f.sha256" echo "sha256($(basename "$f"))=$(cut -d' ' -f1 "$f.sha256")" done ls -la - name: Create GitHub Release and upload assets uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} name: ${{ github.ref_name }} generate_release_notes: true files: | downloads/*.tar.gz downloads/*.zip downloads/*.tar.gz.sha256 downloads/*.zip.sha256 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} publish-python: name: Publish Python package needs: validate runs-on: ubuntu-latest env: KIMI_WEB_STRICT_VERSION: "1" KIMI_WEB_EXPECT_VERSION: ${{ github.ref_name }} steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Python 3.14 uses: actions/setup-python@v5 with: python-version: "3.14" allow-prereleases: true - name: Set up uv uses: astral-sh/setup-uv@v1 with: version: "0.8.5" - name: Set up Node.js (web build) uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" cache-dependency-path: web/package-lock.json - name: Build distributions run: make build-kimi-cli - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} packages-dir: dist ================================================ FILE: .github/workflows/release-kimi-sdk.yml ================================================ name: Release (kimi-sdk) on: push: tags: - "kimi-sdk-*" permissions: contents: read jobs: validate: name: Validate tag runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Python 3.14 uses: actions/setup-python@v5 with: python-version: "3.14" allow-prereleases: true - name: Check version tag run: | python scripts/check_version_tag.py \ --pyproject sdks/kimi-sdk/pyproject.toml \ --expected-version "${GITHUB_REF_NAME#kimi-sdk-}" publish: runs-on: ubuntu-latest needs: validate steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Python 3.14 uses: actions/setup-python@v5 with: python-version: "3.14" allow-prereleases: true - name: Set up uv uses: astral-sh/setup-uv@v1 with: version: "0.8.5" - name: Build distributions run: make build-kimi-sdk - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} packages-dir: dist/kimi-sdk ================================================ FILE: .github/workflows/release-kosong.yml ================================================ name: Release (kosong) on: push: tags: - "kosong-*" permissions: contents: read jobs: validate: name: Validate tag runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Python 3.14 uses: actions/setup-python@v5 with: python-version: "3.14" allow-prereleases: true - name: Check version tag run: | python scripts/check_version_tag.py \ --pyproject packages/kosong/pyproject.toml \ --expected-version "${GITHUB_REF_NAME#kosong-}" publish: runs-on: ubuntu-latest needs: validate steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Python 3.14 uses: actions/setup-python@v5 with: python-version: "3.14" allow-prereleases: true - name: Set up uv uses: astral-sh/setup-uv@v1 with: version: "0.8.5" - name: Build distributions run: make build-kosong - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} packages-dir: dist/kosong docs: runs-on: ubuntu-latest needs: validate steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Python 3.14 uses: actions/setup-python@v5 with: python-version: "3.14" allow-prereleases: true - name: Set up uv uses: astral-sh/setup-uv@v1 with: version: "0.8.5" - name: Install dependencies run: uv sync --frozen --all-extras --project packages/kosong - name: Generate API documentation run: | VERSION="${GITHUB_REF_NAME#kosong-}" uv run --project packages/kosong pdoc kosong \ --docformat google \ --footer-text "kosong ${VERSION}" \ -o packages/kosong/docs - name: Disable Jekyll processing run: touch packages/kosong/docs/.nojekyll - name: Publish docs to gh-pages env: KOSONG_PAGES_TOKEN: ${{ secrets.KOSONG_PAGES_TOKEN }} run: | set -euo pipefail VERSION="${GITHUB_REF_NAME#kosong-}" PAGES_REPO="https://x-access-token:${KOSONG_PAGES_TOKEN}@github.com/MoonshotAI/kosong.git" PAGES_DIR="${RUNNER_TEMP}/kosong-gh-pages" git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" rm -rf "$PAGES_DIR" git clone --depth 1 --branch gh-pages "$PAGES_REPO" "$PAGES_DIR" rsync -a --delete --exclude '.git' "packages/kosong/docs/" "$PAGES_DIR/" git -C "$PAGES_DIR" add -A if git -C "$PAGES_DIR" diff --cached --quiet; then echo "No documentation changes to publish." exit 0 fi git -C "$PAGES_DIR" commit -m "docs: update for ${VERSION}" git -C "$PAGES_DIR" push origin gh-pages ================================================ FILE: .github/workflows/release-pykaos.yml ================================================ name: Release (pykaos) on: push: tags: - "pykaos-*" permissions: contents: read jobs: validate: name: Validate tag runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Python 3.14 uses: actions/setup-python@v5 with: python-version: "3.14" allow-prereleases: true - name: Check version tag run: | python scripts/check_version_tag.py \ --pyproject packages/kaos/pyproject.toml \ --expected-version "${GITHUB_REF_NAME#pykaos-}" publish: runs-on: ubuntu-latest needs: validate steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Python 3.14 uses: actions/setup-python@v5 with: python-version: "3.14" allow-prereleases: true - name: Set up uv uses: astral-sh/setup-uv@v1 with: version: "0.8.5" - name: Build distributions run: make build-pykaos - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} packages-dir: dist/pykaos ================================================ FILE: .github/workflows/translator.yml ================================================ name: "Translator" on: issues: types: [opened, edited] issue_comment: types: [created, edited] discussion: types: [created, edited] discussion_comment: types: [created, edited] pull_request_target: types: [opened, edited] pull_request_review_comment: types: [created, edited] jobs: translate: permissions: issues: write discussions: write pull-requests: write runs-on: ubuntu-latest steps: - uses: lizheming/github-translate-action@1.1.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: IS_MODIFY_TITLE: true APPEND_TRANSLATION: true ================================================ FILE: .github/workflows/typos.yml ================================================ name: Typo checker on: [pull_request] jobs: run: name: Spell Check with Typos runs-on: ubuntu-latest steps: - name: Checkout Actions Repository uses: actions/checkout@v4 - name: Check spelling of the entire repository uses: crate-ci/typos@v1.38.1 ================================================ FILE: .gitignore ================================================ # Python-generated files __pycache__/ *.py[oc] build/ dist/ wheels/ *.egg-info # Virtual environments .venv # Project files .vscode .env .env.local /tests_local uv.toml .idea/* # Build dependencies src/kimi_cli/deps/bin src/kimi_cli/deps/tmp # Web build artifacts src/kimi_cli/web/static/assets/ # Vis build artifacts src/kimi_cli/vis/static/ # Generated reports tests_ai/report.json # nix build result result result-* # macOS files .DS_Store # Rust files target/ node_modules/ static/ .memo/ .entire .claude ================================================ FILE: .pre-commit-config.yaml ================================================ default_install_hook_types: - pre-commit repos: - repo: local hooks: - id: make-format-kimi-cli name: make format-kimi-cli entry: make format-kimi-cli language: system pass_filenames: false - id: make-check-kimi-cli name: make check-kimi-cli entry: make check-kimi-cli language: system pass_filenames: false ================================================ FILE: .python-version ================================================ 3.14 ================================================ FILE: AGENTS.md ================================================ # Kimi Code CLI ## Quick commands (use uv) - `make prepare` (sync deps for all workspace packages and install git hooks) - `make format` - `make check` - `make test` - `make ai-test` - `make build` / `make build-bin` If running tools directly, use `uv run ...`. ## Project overview Kimi Code CLI is a Python CLI agent for software engineering workflows. It supports an interactive shell UI, ACP server mode for IDE integrations, and MCP tool loading. ## Tech stack - Python 3.12+ (tooling configured for 3.14) - CLI framework: Typer - Async runtime: asyncio - LLM framework: kosong - MCP integration: fastmcp - Logging: loguru - Package management/build: uv + uv_build; PyInstaller for binaries - Tests: pytest + pytest-asyncio; lint/format: ruff; types: pyright + ty ## Architecture overview - **CLI entry**: `src/kimi_cli/cli.py` (Typer) parses flags (UI mode, agent spec, config, MCP) and routes into `KimiCLI` in `src/kimi_cli/app.py`. - **App/runtime setup**: `KimiCLI.create` loads config (`src/kimi_cli/config.py`), chooses a model/provider (`src/kimi_cli/llm.py`), builds a `Runtime` (`src/kimi_cli/soul/agent.py`), loads an agent spec, restores `Context`, then constructs `KimiSoul`. - **Agent specs**: YAML under `src/kimi_cli/agents/` loaded by `src/kimi_cli/agentspec.py`. Specs can `extend` base agents, select tools by import path, and define fixed subagents. System prompts live alongside specs; builtin args include `KIMI_NOW`, `KIMI_WORK_DIR`, `KIMI_WORK_DIR_LS`, `KIMI_AGENTS_MD`, `KIMI_SKILLS` (this file is injected via `KIMI_AGENTS_MD`). - **Tooling**: `src/kimi_cli/soul/toolset.py` loads tools by import path, injects dependencies, and runs tool calls. Built-in tools live in `src/kimi_cli/tools/` (shell, file, web, todo, multiagent, dmail, think). MCP tools are loaded via `fastmcp`; CLI management is in `src/kimi_cli/mcp.py` and stored in the share dir. - **Subagents**: `LaborMarket` in `src/kimi_cli/soul/agent.py` manages fixed and dynamic subagents. The Task tool (`src/kimi_cli/tools/multiagent/`) spawns them. - **Core loop**: `src/kimi_cli/soul/kimisoul.py` is the main agent loop. It accepts user input, handles slash commands (`src/kimi_cli/soul/slash.py`), appends to `Context` (`src/kimi_cli/soul/context.py`), calls the LLM (kosong), runs tools, and performs compaction (`src/kimi_cli/soul/compaction.py`) when needed. - **Approvals**: `src/kimi_cli/soul/approval.py` mediates user approvals for tool actions; the soul forwards approval requests over `Wire` for UI handling. - **UI/Wire**: `src/kimi_cli/soul/run_soul` connects `KimiSoul` to a `Wire` (`src/kimi_cli/wire/`) so UI loops can stream events. UIs live in `src/kimi_cli/ui/` (shell/print/acp/wire). - **Shell UI**: `src/kimi_cli/ui/shell/` handles interactive TUI input, shell command mode, and slash command autocomplete; it is the default interactive experience. - **Slash commands**: Soul-level commands live in `src/kimi_cli/soul/slash.py`; shell-level commands live in `src/kimi_cli/ui/shell/slash.py`. The shell UI exposes both and dispatches based on the registry. Standard skills register `/skill:` and load `SKILL.md` as a user prompt; flow skills register `/flow:` and execute the embedded flow. ## Major modules and interfaces - `src/kimi_cli/app.py`: `KimiCLI.create(...)` and `KimiCLI.run(...)` are the main programmatic entrypoints; this is what UI layers use. - `src/kimi_cli/soul/agent.py`: `Runtime` (config, session, builtins), `Agent` (system prompt + toolset), and `LaborMarket` (subagent registry). - `src/kimi_cli/soul/kimisoul.py`: `KimiSoul.run(...)` is the loop boundary; it emits Wire messages and executes tools via `KimiToolset`. - `src/kimi_cli/soul/context.py`: conversation history + checkpoints; used by DMail for checkpointed replies. - `src/kimi_cli/soul/toolset.py`: load tools, run tool calls, bridge to MCP tools. - `src/kimi_cli/ui/*`: shell/print/acp frontends; they consume `Wire` messages. - `src/kimi_cli/wire/*`: event types and transport used between soul and UI. ## Repo map - `src/kimi_cli/agents/`: built-in agent YAML specs and prompts - `src/kimi_cli/prompts/`: shared prompt templates - `src/kimi_cli/soul/`: core runtime/loop, context, compaction, approvals - `src/kimi_cli/tools/`: built-in tools - `src/kimi_cli/ui/`: UI frontends (shell/print/acp/wire) - `src/kimi_cli/acp/`: ACP server components - `packages/kosong/`, `packages/kaos/`: workspace deps + Kosong is an LLM abstraction layer designed for modern AI agent applications. It unifies message structures, asynchronous tool orchestration, and pluggable chat providers so you can build agents with ease and avoid vendor lock-in. + PyKAOS is a lightweight Python library providing an abstraction layer for agents to interact with operating systems. File operations and command executions via KAOS can be easily switched between local environment and remote systems over SSH. - `tests/`, `tests_ai/`: test suites - `klips`: Kimi Code CLI Improvement Proposals ## Conventions and quality - Python >=3.12 (ty config uses 3.14); line length 100. - Ruff handles lint + format (rules: E, F, UP, B, SIM, I); pyright + ty for type checks. - Tests use pytest + pytest-asyncio; files are `tests/test_*.py`. - CLI entry points: `kimi` / `kimi-cli` -> `src/kimi_cli/cli.py`. - User config: `~/.kimi/config.toml`; logs, sessions, and MCP config live in `~/.kimi/`. ## Git commit messages Conventional Commits format: ``` (): ``` Allowed types: `feat`, `fix`, `test`, `refactor`, `chore`, `style`, `docs`, `perf`, `build`, `ci`, `revert`. ## Versioning The project follows a **minor-bump-only** versioning scheme (`MAJOR.MINOR.PATCH`): - **Patch** version is always `0`. Never bump it. - **Minor** version is bumped for any change: new features, improvements, bug fixes, etc. - **Major** version is only changed by explicit manual decision; it stays unchanged during normal development. Examples: `0.68.0` → `0.69.0` → `0.70.0`; never `0.68.1`. This rule applies to all packages in the repo (root, `packages/*`, `sdks/*`) as well as release and skill workflows. ## Release workflow 1. Ensure `main` is up to date (pull latest). 2. Create a release branch, e.g. `bump-0.68` or `bump-pykaos-0.5.3`. 3. Update `CHANGELOG.md`: rename `[Unreleased]` to `[0.68] - YYYY-MM-DD`. 4. Update `pyproject.toml` version. 5. Run `uv sync` to align `uv.lock`. 6. Commit the branch and open a PR. 7. Merge the PR, then switch back to `main` and pull latest. 8. Tag and push: - `git tag 0.68` or `git tag pykaos-0.5.3` - `git push --tags` 9. GitHub Actions handles the release after tags are pushed. ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## Unreleased - Shell: Show the current working directory, git branch, dirty state, and ahead/behind sync status directly in the prompt toolbar - Shell: Surface active background bash task counts in the toolbar, rotate shortcut tips on a timer, and gracefully truncate the toolbar on narrow terminals to avoid overflow - Web: Fix tool execution status synchronization on cancel and approval — tools now correctly transition to `output-denied` state when generation is stopped, and show the loading spinner (instead of checkmark) while executing after approval - Web: Dismiss stale approval and question dialogs on session replay — when replaying a session or when the backend reports idle/stopped/error status, any pending approval/question dialogs are now properly dismissed to prevent orphaned interactive elements - Web: Enable inline math formula rendering — single-dollar inline math (`$...$`) is now supported in addition to block math (`$$...$$`) - Web: Improve Switch toggle proportions and alignment — the toggle track is now larger (36×20) with a consistent 16px thumb and smoother 16px travel animation ## 1.24.0 (2026-03-18) - Shell: Increase pasted text placeholder thresholds to 1000 characters or 15 lines (previously 300 characters or 3 lines), making voice/typeless workflows less disruptive - Core: Plan mode now supports multiple selectable approach options — when the agent's plan contains distinct alternative paths, `ExitPlanMode` can present 2–3 labeled choices for the user to pick which approach to execute; the chosen option is returned to the agent as the selected approach - Core: Persist plan session ID and file path across process restarts — the plan session identifier and file slug are saved to `SessionState`, so restarting Kimi Code mid-plan resumes the same plan file in `~/.kimi/plans/` instead of creating a new one - Core: Plan mode now supports incremental plan edits — the agent can use `StrReplaceFile` to surgically update sections of the plan file instead of rewriting the entire file with `WriteFile`, and non-plan file edits are now hard-blocked rather than requiring approval - Core: Defer MCP startup and surface loading progress — MCP servers now initialize asynchronously after the shell UI starts, with live progress indicators showing connection status; Shell displays connecting and ready states in the status area, Web shows server connection status - Core: Optimize lightweight startup paths — implement lazy-loading for CLI subcommands and version metadata, significantly reducing startup time for common commands like `--version` and `--help` - Build: Fix Nix `FileCollisionError` for `bin/kimi` — remove duplicate entry point from `kimi-code` package so `kimi-cli` owns `bin/kimi` exclusively - Shell: Preserve unsubmitted input across agent turns — text typed in the prompt while the agent is running is no longer lost when the turn ends; the user can press Enter to submit the draft as the next message - Shell: Fix Ctrl-C and Ctrl-D not working correctly after an agent run completes — keyboard interrupts and EOF were silently swallowed instead of showing the tip or exiting the shell ## 1.23.0 (2026-03-17) - Shell: Add background bash — the `Shell` tool now accepts `run_in_background=true` to launch long-running commands (builds, tests, servers) as background tasks, freeing the agent to continue working; new `TaskList`, `TaskOutput`, and `TaskStop` tools manage task lifecycle, and the system automatically notifies the agent when tasks reach a terminal state - Shell: Add `/task` slash command with interactive task browser — a three-column TUI to view, monitor, and manage background tasks with real-time refresh, output preview, and keyboard-driven stopping - Web: Fix global config not refreshing on other tabs when model is changed — when the model is changed in one tab, other tabs now detect the config update and automatically refresh their global config ## 1.22.0 (2026-03-13) - Shell: Collapse long pasted text into `[Pasted text #n]` placeholders — text pasted via `Ctrl-V` or bracketed paste that exceeds 300 characters or 3 lines is displayed as a compact placeholder token in the prompt buffer while the full content is sent to the model; the external editor (`Ctrl-O`) expands placeholders for editing and re-folds them on save - Shell: Cache pasted images as attachment placeholders — images pasted from the clipboard are stored on disk and shown as `[image:…]` tokens in the prompt, keeping the input buffer readable - Shell: Fix UTF-16 surrogate characters in pasted text causing serialization errors — lone surrogates from Windows clipboard data are now sanitized before storage, preventing `UnicodeEncodeError` in history writes and JSON serialization - Shell: Redesign slash command completion menu — replace the default completion popup with a full-width custom menu that shows command names and multi-line descriptions, with highlight and scroll support - Shell: Fix cancelled shell commands not properly terminating child processes — when a running command is cancelled, the subprocess is now explicitly killed to prevent orphaned processes ## 1.21.0 (2026-03-12) - Shell: Add inline running prompt with steer input — agent output is now rendered inside the prompt area while the model is running, and users can type and send follow-up messages (steers) without waiting for the turn to finish; approval requests and question panels are handled inline with keyboard navigation - Core: Change steer injection from synthetic tool calls to regular user messages — steer content is now appended as a standard user message instead of a fake `_steer` tool-call/tool-result pair, improving compatibility with context serialization and visualization - Wire: Add `SteerInput` event — a new Wire protocol event emitted when the user sends a follow-up steer message during a running turn - Shell: Echo user input after submission in agent mode — the prompt symbol and entered text are printed back to the terminal for a clearer conversation transcript - Shell: Improve session replay with steer inputs — replay now correctly reconstructs and displays steer messages alongside regular turns, and filters out internal system-reminder messages - Shell: Fix upgrade command in toast notifications — the upgrade command text is now sourced from a single `UPGRADE_COMMAND` constant for consistency - Core: Persist system prompt in `context.jsonl` — the system prompt is now written as the first record of the context file and frozen per session, so visualization tools can read the full conversation context and session restores reuse the original prompt instead of regenerating it - Vis: Add session directory shortcuts in `kimi vis` — open the current session folder directly from the session page, copy the raw session directory path with `Copy DIR`, and support opening directories on both macOS and Windows - Shell: Improve API key login UX — show a spinner during key verification, display a helpful hint when a 401 error suggests the wrong platform was selected, show a setup summary on success, and default thinking mode to "on" ## 1.20.0 (2026-03-11) - Web: Add plan mode toggle in web UI — switch control in the input toolbar with a dashed blue border on the composer when plan mode is active, and support setting plan mode via the `set_plan_mode` Wire protocol method - Core: Persist plan mode state across session restarts — `plan_mode` is saved to `SessionState` and restored when a session resumes - Core: Fix StatusUpdate not reflecting plan mode changes triggered by tools — send a corrected `StatusUpdate` after `EnterPlanMode`/`ExitPlanMode` tool execution so the client sees the up-to-date state - Core: Fix HTTP header values containing trailing whitespace/newlines on certain Linux systems (e.g. kernel 6.8.0-101) causing connection errors — strip whitespace from ASCII header values before sending - Core: Fix OpenAI Responses provider sending implicit `reasoning.effort=null` which breaks Responses-compatible endpoints that require reasoning — reasoning parameters are now omitted unless explicitly set - Vis: Add session download, import, export and delete — one-click ZIP download from session explorer and detail page, ZIP import into a dedicated `~/.kimi/imported_sessions/` directory with "Imported" filter toggle, `kimi export ` CLI command, and delete support for imported sessions with AlertDialog confirmation - Core: Fix context compaction failing when conversation contains media parts (images, audio, video) — switch from blacklist filtering (exclude `ThinkPart`) to whitelist filtering (only keep `TextPart`) to prevent unsupported content types from being sent to the compaction API - Web: Fix `@` file mention index not refreshing after switching sessions or when workspace files change — reset index on session switch, auto-refresh after 30s staleness, and support path-prefix search beyond the 500-file limit ## 1.19.0 (2026-03-10) - Core: Add plan mode — the agent can enter a planning phase (`EnterPlanMode`) where only read-only tools (Glob, Grep, ReadFile) are available, write a structured plan to a file, and present it for user approval (`ExitPlanMode`) before executing; toggle manually via `/plan` slash command or `Shift-Tab` keyboard shortcut - Vis: Add `kimi vis` command for launching an interactive visualization dashboard to inspect session traces — includes wire event timeline, context viewer, session explorer, and usage statistics - Web: Fix session stream state management — guard against null reference errors during state resets and preserve slash commands across session switches to avoid a brief empty gap ## 1.18.0 (2026-03-09) - ACP: Support embedded resource content in ACP mode so that Zed's `@` file references correctly include file contents - Core: Use `parameters_json_schema` instead of `parameters` in Google GenAI provider to bypass Pydantic validation that rejects standard JSON Schema metadata fields in MCP tools - Shell: Enhance `Ctrl-V` clipboard paste to support video files in addition to images — video file paths are inserted as text, and a crash when clipboard data is `None` is fixed - Core: Pass session ID as `user_id` metadata to Anthropic API - Web: Preserve slash commands on WebSocket reconnect and add automatic retry logic for session initialization ## 1.17.0 (2026-03-03) - Core: Add `/export` command to export current session context (messages, metadata) to a Markdown file, and `/import` command to import context from a file or another session ID into the current session - Shell: Show token counts (used/total) alongside context usage percentage in the status bar (e.g., `context: 42.0% (4.2k/10.0k)`) - Shell: Rotate keyboard shortcut tips in the toolbar — tips cycle through available shortcuts on each prompt submission to save horizontal space - MCP: Add loading indicators for MCP server connections — Shell displays a "Connecting to MCP servers..." spinner and Web shows a status message while MCP tools are being loaded - Web: Fix scrollable file list overflow in the toolbar changes panel - Core: Add `compaction_trigger_ratio` config option (default `0.85`) to control when auto-compaction triggers — compaction now fires when context usage reaches the configured ratio or when remaining space falls below `reserved_context_size`, whichever comes first - Core: Support custom instructions in `/compact` command (e.g., `/compact keep database discussions`) to guide what the compaction preserves - Web: Add URL action parameters (`?action=create` to open create-session dialog, `?action=create-in-dir&workDir=xxx` to create a session directly) for external integrations, and support Cmd/Ctrl+Click on new-session buttons to open session creation in a new browser tab - Web: Add todo list display in prompt toolbar — shows task progress with expandable panel when the `SetTodoList` tool is active - ACP: Add authentication check for session operations with `AUTH_REQUIRED` error responses for terminal-based login flow ## 1.16.0 (2026-02-27) - Web: Update ASCII logo banner to a new styled design - Core: Add `--add-dir` CLI option and `/add-dir` slash command to expand the workspace scope with additional directories — added directories are accessible to all file tools (read, write, glob, replace), persisted across sessions, and shown in the system prompt - Shell: Add `Ctrl-O` keyboard shortcut to open the current input in an external editor (`$VISUAL`/`$EDITOR`), with auto-detection fallback to VS Code, Vim, Vi, or Nano - Shell: Add `/editor` slash command to configure and switch the default external editor, with interactive selection and persistent config storage - Shell: Add `/new` slash command to create and switch to a new session without restarting Kimi Code CLI - Wire: Auto-hide `AskUserQuestion` tool when the client does not support the `supports_question` capability, preventing the LLM from invoking unsupported interactions - Core: Estimate context token count after compaction so context usage percentage is not reported as 0% - Web: Show context usage percentage with one decimal place for better precision ## 1.15.0 (2026-02-27) - Shell: Simplify input prompt by removing username prefix for a cleaner appearance - Shell: Add horizontal separator line and expanded keyboard shortcut hints to the toolbar - Shell: Add number key shortcuts (1–5) for quick option selection in question and approval panels, with redesigned bordered panel UI and keyboard hints - Shell: Add tab-style navigation for multi-question panels — use Left/Right arrows or Tab to switch between questions, with visual indicators for answered, current, and pending states, and automatic state restoration when revisiting a question - Shell: Allow Space key to submit single-select questions in the question panel - Web: Add tab-style navigation for multi-question dialogs with clickable tab bar, keyboard navigation, and state restoration when revisiting a question - Core: Set process title to "Kimi Code" (visible in `ps` / Activity Monitor / terminal tab title) and label web worker subprocesses as "kimi-code-worker" ## 1.14.0 (2026-02-26) - Shell: Make FetchURL tool's URL parameter a clickable hyperlink in the terminal - Tool: Add `AskUserQuestion` tool for presenting structured questions with predefined options during execution, supporting single-select, multi-select, and custom text input - Wire: Add `QuestionRequest` / `QuestionResponse` message types and capability negotiation for structured question interactions - Shell: Add interactive question panel for `AskUserQuestion` with keyboard-driven option selection - Web: Add `QuestionDialog` component for answering structured questions inline, replacing the prompt composer when a question is pending - Core: Persist session state across sessions — approval decisions (YOLO mode, auto-approved actions) and dynamic subagents are now saved and restored when resuming a session - Core: Use atomic JSON writes for metadata and session state files to prevent data corruption on crash - Wire: Add `steer` request to inject user messages into an active agent turn (protocol version 1.4) - Web: Allow Cmd/Ctrl+Click on FetchURL tool's URL parameter to open the link in a new browser tab, with platform-appropriate tooltip hint ## 1.13.0 (2026-02-24) - Core: Add automatic connection recovery that recreates the HTTP client on connection and timeout errors before retrying, improving resilience against transient network failures ## 1.12.0 (2026-02-11) - Web: Add subagent activity rendering to display subagent steps (thinking, tool calls, text) inside Task tool messages - Web: Add Think tool rendering as a lightweight reasoning-style block - Web: Replace emoji status indicators with Lucide icons for tool states and add category-specific icons for tool names - Web: Enhance Reasoning component with improved thinking labels and status icons - Web: Enhance Todo component with status icons and improved styling - Web: Implement WebSocket reconnection with automatic request resending and stale connection watchdog - Web: Enhance session creation dialog with command value handling - Web: Support tilde (`~`) expansion in session work directory paths - Web: Fix assistant message content overflow clipping - Wire: Fix deadlock when multiple subagents run concurrently by not blocking the UI loop on approval and tool-call requests - Wire: Clean up stale pending requests after agent turn ends - Web: Show placeholder text in prompt input with hints for slash commands and file mentions - Web: Fix Ctrl+C not working in uvicorn web server by restoring default SIGINT handler and terminal state after shell mode exits - Web: Improve session stop handling with proper async cleanup and timeout - ACP: Add protocol version negotiation framework for client-server compatibility - ACP: Add session resume method to restore session state (experimental) ## 1.11.0 (2026-02-10) - Web: Move context usage indicator from workspace header to prompt toolbar with a hover card showing detailed token usage breakdown - Web: Add folder indicator with work directory path to the bottom of the file changes panel - Web: Fix stderr not being restored when switching to web mode, which could suppress web server error output - Web: Fix port availability check by setting SO_REUSEADDR on the test socket ## 1.10.0 (2026-02-09) - Web: Add copy and fork action buttons to assistant messages for quick content copying and session forking - Web: Add keyboard shortcuts for approval actions — press `1` to approve, `2` to approve for session, `3` to decline - Web: Add message queueing — queue follow-up messages while the AI is processing; queued messages are sent automatically when the response completes - Web: Replace Git diff status bar with unified prompt toolbar showing activity status, message queue, and file changes in collapsible tabs - Web: Load global MCP configuration in web worker so web sessions can use MCP tools - Web: Improve mobile prompt input UX — reduce textarea min-height, add `autoComplete="off"`, and disable focus ring on small screens - Web: Handle models that stream text before thinking by ensuring thinking messages always appear before text in the message list - Web: Show more specific status messages during session connection ("Loading history...", "Starting environment..." instead of generic "Connecting...") - Web: Send error status when session environment initialization fails instead of leaving UI in a waiting state - Web: Auto-reconnect when no session status received within 15 seconds after history replay completes - Web: Use non-blocking file I/O in session streaming to avoid blocking the event loop during history replay ## 1.9.0 (2026-02-06) - Config: Add `default_yolo` config option to enable YOLO (auto-approve) mode by default - Config: Accept both `max_steps_per_turn` and `max_steps_per_run` as aliases for the loop control setting - Wire: Add `replay` request to stream recorded Wire events (protocol version 1.3) - Web: Add session fork feature to branch off a new session from any assistant response - Web: Add session archive feature with auto-archive for sessions older than 15 days - Web: Add multi-select mode for bulk archive, unarchive, and delete operations - Web: Add media preview for tool results (images/videos from ReadMediaFile) with clickable thumbnails - Web: Add shell command and todo list display components for tool outputs - Web: Add activity status indicator showing agent state (processing, waiting for approval, etc.) - Web: Add error fallback UI when images fail to load - Web: Redesign tool input UI with expandable parameters and syntax highlighting for long values - Web: Show compaction indicator when context is being compacted - Web: Improve auto-scroll behavior in chat for smoother following of new content - Web: Update `last_session_id` for work directory when session stream starts - Shell: Remove `Ctrl-/` keyboard shortcut that triggered `/help` command - Rust: Move the Rust implementation to `MoonshotAI/kimi-agent-rs` with independent releases; binary renamed to `kimi-agent` - Core: Preserve session id when reloading configuration so the session resumes correctly - Shell: Fix session replay showing messages that were cleared by `/clear` or `/reset` - Web: Fix approval request states not updating when session is interrupted or cancelled - Web: Fix IME composition issue when selecting slash commands - Web: Fix UI not clearing messages after `/clear`, `/reset`, or `/compact` commands ## 1.8.0 (2026-02-05) - CLI: Fix startup errors (e.g. invalid config files) being silently swallowed instead of displayed ## 1.7.0 (2026-02-05) - Rust: Add `kagent`, the Rust implementation of Kimi agent kernel with wire-mode support (experimental) - Auth: Fix OAuth token refresh conflicts when running multiple sessions simultaneously - Web: Add file mention menu (`@`) to reference uploaded attachments and workspace files with autocomplete - Web: Add slash command menu in chat input with autocomplete, keyboard navigation, and alias support - Web: Prompt to create directory when specified path doesn't exist during session creation - Web: Fix authentication token persistence by switching from sessionStorage to localStorage with 24-hour expiry - Web: Add server-side pagination for session list with virtualized scrolling for better performance - Web: Improve session and work directories loading with smarter caching and invalidation - Web: Fix WebSocket errors during history replay by checking connection state before sending - Web: Git diff status bar now shows untracked files (new files not yet added to git) - Web: Restrict sensitive APIs only in public mode; update origin enforcement logic ## 1.6 (2026-02-03) - Web: Add token-based authentication and access control for network mode (`--network`, `--lan-only`, `--public`) - Web: Add security options: `--auth-token`, `--allowed-origins`, `--restrict-sensitive-apis`, `--dangerously-omit-auth` - Web: Change `--host` option to bind to specific IP address; add automatic network address detection - Web: Fix WebSocket disconnect when creating new sessions - Web: Increase maximum image dimension from 1024 to 4096 pixels - Web: Improve UI responsiveness with enhanced hover effects and better layout handling - Wire: Add `TurnEnd` event to signal the completion of an agent turn (protocol version 1.2) - Core: Fix custom agent prompt files containing `$` causing silent startup failure ## 1.5 (2026-01-30) - Web: Add Git diff status bar showing uncommitted changes in session working directory - Web: Add "Open in" menu for opening files/directories in Terminal, VS Code, Cursor, or other local applications - Web: Add search functionality to filter sessions by title or working directory - Web: Improve session title display with proper overflow handling ## 1.4 (2026-01-30) - Shell: Merge `/login` and `/setup` commands; `/setup` is now an alias for `/login` - Shell: `/usage` now shows remaining quota percentage; add `/status` alias - Config: Add `KIMI_SHARE_DIR` environment variable to customize the share directory path (default: `~/.kimi`) - Web: Add new Web UI for browser-based interaction - CLI: Add `kimi web` subcommand to launch the Web UI server - Auth: Fix encoding error when device name or OS version contains non-ASCII characters - Auth: OAuth credentials are now stored in files instead of keyring; existing tokens are automatically migrated on startup - Auth: Fix authorization failure after the system sleeps or hibernates ## 1.3 (2026-01-28) - Auth: Fix authentication issue during agent turns - Tool: Wrap media content with descriptive tags in `ReadMediaFile` for better path traceability ## 1.2 (2026-01-27) - UI: Show description for `kimi-for-coding` model ## 1.1 (2026-01-27) - LLM: Fix `kimi-for-coding` model's capabilities ## 1.0 (2026-01-27) - Shell: Add `/login` and `/logout` slash commands for login and logout - CLI: Add `kimi login` and `kimi logout` subcommands - Core: Fix subagent approval request handling ## 0.88 (2026-01-26) - MCP: Remove `Mcp-Session-Id` header when connecting to MCP servers to fix compatibility ## 0.87 (2026-01-25) - Shell: Fix Markdown rendering error when HTML blocks appear outside any element - Skills: Add more user-level and project-level skills directory candidates - Core: Improve system prompt guidance for media file generation and processing tasks - Shell: Fix image pasting from clipboard on macOS ## 0.86 (2026-01-24) - Build: Fix binary builds ## 0.85 (2026-01-24) - Shell: Cache pasted images to disk for persistence across sessions - Shell: Deduplicate cached attachments based on content hash - Shell: Fix display of image/audio/video attachments in message history - Tool: Use file path as media identifier in `ReadMediaFile` for better traceability - Tool: Fix some MP4 files not being recognized as videos - Shell: Handle Ctrl-C during slash command execution - Shell: Fix shlex parsing error in shell mode when input contains invalid shell syntax - Shell: Fix stderr output from MCP servers and third-party libraries polluting shell UI - Wire: Graceful shutdown with proper cleanup of pending requests when connection closes or Ctrl-C is received ## 0.84 (2026-01-22) - Build: Add cross-platform standalone binary builds for Windows, macOS (with code signing and notarization), and Linux (x86_64 and ARM64) - Shell: Fix slash command autocomplete showing suggestions for exact command/alias matches - Tool: Treat SVG files as text instead of images - Flow: Support D2 markdown block strings (`|md` syntax) for multiline node labels in flow skills - Core: Fix possible "event loop is closed" error after running `/reload`, `/setup`, or `/clear` - Core: Fix panic when `/clear` is used in a continued session ## 0.83 (2026-01-21) - Tool: Add `ReadMediaFile` tool for reading image/video files; `ReadFile` now focuses on text files only - Skills: Flow skills now also register as `/skill:` commands (in addition to `/flow:`) ## 0.82 (2026-01-21) - Tool: Allow `WriteFile` and `StrReplaceFile` tools to edit/write files outside the working directory when using absolute paths - Tool: Upload videos to Kimi files API when using Kimi provider, replacing inline data URLs with `ms://` references - Config: Add `reserved_context_size` setting to customize auto-compaction trigger threshold (default: 50000 tokens) ## 0.81 (2026-01-21) - Skills: Add flow skill type with embedded Agent Flow (Mermaid/D2) in SKILL.md, invoked via `/flow:` commands - CLI: Remove `--prompt-flow` option; use flow skills instead - Core: Replace `/begin` command with `/flow:` commands for flow skills ## 0.80 (2026-01-20) - Wire: Add `initialize` method for exchanging client/server info, external tools registration and slash commands advertisement - Wire: Support external tool calls via Wire protocol - Wire: Rename `ApprovalRequestResolved` to `ApprovalResponse` (backwards-compatible) ## 0.79 (2026-01-19) - Skills: Add project-level skills support, discovered from `.agents/skills/` (or `.kimi/skills/`, `.claude/skills/`) - Skills: Unified skills discovery with layered loading (builtin → user → project); user-level skills now prefer `~/.config/agents/skills/` - Shell: Support fuzzy matching for slash command autocomplete - Shell: Enhanced approval request preview with shell command and diff content display, use `Ctrl-E` to expand full content - Wire: Add `ShellDisplayBlock` type for shell command display in approval requests - Shell: Reorder `/help` to show keyboard shortcuts before slash commands - Wire: Return proper JSON-RPC 2.0 error responses for invalid requests ## 0.78 (2026-01-16) - CLI: Add D2 flowchart format support for Prompt Flow (`.d2` extension) ## 0.77 (2026-01-15) - Shell: Fix line breaking in `/help` and `/changelog` fullscreen pager display - Shell: Use `/model` to toggle thinking mode instead of Tab key - Config: Add `default_thinking` config option (need to run `/model` to select thinking mode after upgrade) - LLM: Add `always_thinking` capability for models that always use thinking mode - CLI: Rename `--command`/`-c` to `--prompt`/`-p`, keep `--command`/`-c` as alias, remove `--query`/`-q` - Wire: Fix approval requests not responding properly in Wire mode - CLI: Add `--prompt-flow` option to load a Mermaid flowchart file as a Prompt Flow - Core: Add `/begin` slash command if a Prompt Flow is loaded to start the flow - Core: Replace Ralph Loop with Prompt Flow-based implementation ## 0.76 (2026-01-12) - Tool: Make `ReadFile` tool description reflect model capabilities for image/video support - Tool: Fix TypeScript files (`.ts`, `.tsx`, `.mts`, `.cts`) being misidentified as video files - Shell: Allow slash commands (`/help`, `/exit`, `/version`, `/changelog`, `/feedback`) in shell mode - Shell: Improve `/help` with fullscreen pager, showing slash commands, skills, and keyboard shortcuts - Shell: Improve `/changelog` and `/mcp` display with consistent bullet-style formatting - Shell: Show current model name in the bottom status bar - Shell: Add `Ctrl-/` shortcut to show help ## 0.75 (2026-01-09) - Tool: Improve `ReadFile` tool description - Skills: Add built-in `kimi-cli-help` skill to answer Kimi Code CLI usage and configuration questions ## 0.74 (2026-01-09) - ACP: Allow ACP clients to select and switch models (with thinking variants) - ACP: Add `terminal-auth` authentication method for setup flow - CLI: Deprecate `--acp` option in favor of `kimi acp` subcommand - Tool: Support reading image and video files in `ReadFile` tool ## 0.73 (2026-01-09) - Skills: Add built-in skill-creator skill shipped with the package - Tool: Expand `~` to the home directory in `ReadFile` paths - MCP: Ensure MCP tools finish loading before starting the agent loop - Wire: Fix Wire mode failing to accept valid `cancel` requests - Setup: Allow `/model` to switch between all available models for the selected provider - Lib: Re-export all Wire message types from `kimi_cli.wire.types`, as a replacement of `kimi_cli.wire.message` - Loop: Add `max_ralph_iterations` loop control config to limit extra Ralph iterations - Config: Rename `max_steps_per_run` to `max_steps_per_turn` in loop control config (backward-compatible) - CLI: Add `--max-steps-per-turn`, `--max-retries-per-step` and `--max-ralph-iterations` options to override loop control config - SlashCmd: Make `/yolo` toggle auto-approve mode - UI: Show a YOLO badge in the shell prompt ## 0.72 (2026-01-04) - Python: Fix installation on Python 3.14. ## 0.71 (2026-01-04) - ACP: Route file reads/writes and shell commands through ACP clients for synced edits/output - Shell: Add `/model` slash command to switch default models and reload when using the default config - Skills: Add `/skill:` slash commands to load `SKILL.md` instructions on demand - CLI: Add `kimi info` subcommand for version/protocol details (supports `--json`) - CLI: Add `kimi term` to launch the Toad terminal UI - Python: Bump the default tooling/CI version to 3.14 ## 0.70 (2025-12-31) - CLI: Add `--final-message-only` (and `--quiet` alias) to only output the final assistant message in print UI - LLM: Add `video_in` model capability and support video inputs ## 0.69 (2025-12-29) - Core: Support discovering skills in `~/.kimi/skills` or `~/.claude/skills` - Python: Lower the minimum required Python version to 3.12 - Nix: Add flake packaging; install with `nix profile install .#kimi-cli` or run `nix run .#kimi-cli` - CLI: Add `kimi-cli` script alias for invoking the CLI; can be run via `uvx kimi-cli` - Lib: Move LLM config validation into `create_llm` and return `None` when missing config ## 0.68 (2025-12-24) - CLI: Add `--config` and `--config-file` options to pass in config JSON/TOML - Core: Allow `Config` in addition to `Path` for the `config` parameter of `KimiCLI.create` - Tool: Include diff display blocks in `WriteFile` and `StrReplaceFile` approvals/results - Wire: Add display blocks to approval requests (including diffs) with backward-compatible defaults - ACP: Show file diff previews in tool results and approval prompts - ACP: Connect to MCP servers managed by ACP clients - ACP: Run shell commands in ACP client terminal if supported - Lib: Add `KimiToolset.find` method to find tools by class or name - Lib: Add `ToolResultBuilder.display` method to append display blocks to tool results - MCP: Add `kimi mcp auth` and related subcommands to manage MCP authorization ## 0.67 (2025-12-22) - ACP: Advertise slash commands in single-session ACP mode (`kimi --acp`) - MCP: Add `mcp.client` config section to configure MCP tool call timeout and other future options - Core: Improve default system prompt and `ReadFile` tool - UI: Fix Ctrl-C not working in some rare cases ## 0.66 (2025-12-19) - Lib: Provide `token_usage` and `message_id` in `StatusUpdate` Wire message - Lib: Add `KimiToolset.load_tools` method to load tools with dependency injection - Lib: Add `KimiToolset.load_mcp_tools` method to load MCP tools - Lib: Move `MCPTool` from `kimi_cli.tools.mcp` to `kimi_cli.soul.toolset` - Lib: Add `InvalidToolError`, `MCPConfigError` and `MCPRuntimeError` - Lib: Make the detailed Kimi Code CLI exception classes extend `ValueError` or `RuntimeError` - Lib: Allow passing validated `list[fastmcp.mcp_config.MCPConfig]` as `mcp_configs` for `KimiCLI.create` and `load_agent` - Lib: Fix exception raising for `KimiCLI.create`, `load_agent`, `KimiToolset.load_tools` and `KimiToolset.load_mcp_tools` - LLM: Add provider type `vertexai` to support Vertex AI - LLM: Rename Gemini Developer API provider type from `google_genai` to `gemini` - Config: Migrate config file from JSON to TOML - MCP: Connect to MCP servers in background and parallel to reduce startup time - MCP: Add `mcp-session-id` HTTP header when connecting to MCP servers - Lib: Split slash commands (prev "meta commands") into two groups: Shell-level and KimiSoul-level - Lib: Add `available_slash_commands` property to `Soul` protocol - ACP: Advertise slash commands `/init`, `/compact` and `/yolo` to ACP clients - SlashCmd: Add `/mcp` slash command to display MCP server and tool status ## 0.65 (2025-12-16) - Lib: Support creating named sessions via `Session.create(work_dir, session_id)` - CLI: Automatically create new session when specified session ID is not found - CLI: Delete empty sessions on exit and ignore sessions whose context file is empty when listing - UI: Improve session replaying - Lib: Add `model_config: LLMModel | None` and `provider_config: LLMProvider | None` properties to `LLM` class - MetaCmd: Add `/usage` meta command to show API usage for Kimi Code users ## 0.64 (2025-12-15) - UI: Fix UTF-16 surrogate characters input on Windows - Core: Add `/sessions` meta command to list existing sessions and switch to a selected one - CLI: Add `--session/-S` option to specify session ID to resume - MCP: Add `kimi mcp` subcommand group to manage global MCP config file `~/.kimi/mcp.json` ## 0.63 (2025-12-12) - Tool: Fix `FetchURL` tool incorrect output when fetching via service fails - Tool: Use `bash` instead of `sh` in `Shell` tool for better compatibility - Tool: Fix `Grep` tool unicode decoding error on Windows - ACP: Support ACP session continuation (list/load sessions) with `kimi acp` subcommand - Lib: Add `Session.find` and `Session.list` static methods to find and list sessions - ACP: Update agent plans on the client side when `SetTodoList` tool is called - UI: Prevent normal messages starting with `/` from being treated as meta commands ## 0.62 (2025-12-08) - ACP: Fix tool results (including Shell tool output) not being displayed in ACP clients like Zed - ACP: Fix compatibility with the latest version of Zed IDE (0.215.3) - Tool: Use PowerShell instead of CMD on Windows for better usability - Core: Fix startup crash when there is broken symbolic link in the working directory - Core: Add builtin `okabe` agent file with `SendDMail` tool enabled - CLI: Add `--agent` option to specify builtin agents like `default` and `okabe` - Core: Improve compaction logic to better preserve relevant information ## 0.61 (2025-12-04) - Lib: Fix logging when used as a library - Tool: Harden file path check to protect against shared-prefix escape - LLM: Improve compatibility with some third-party OpenAI Responses and Anthropic API providers ## 0.60 (2025-12-01) - LLM: Fix interleaved thinking for Kimi and OpenAI-compatible providers ## 0.59 (2025-11-28) - Core: Move context file location to `.kimi/sessions/{workdir_md5}/{session_id}/context.jsonl` - Lib: Move `WireMessage` type alias to `kimi_cli.wire.message` - Lib: Add `kimi_cli.wire.message.Request` type alias request messages (which currently only includes `ApprovalRequest`) - Lib: Add `kimi_cli.wire.message.is_event`, `is_request` and `is_wire_message` utility functions to check the type of wire messages - Lib: Add `kimi_cli.wire.serde` module for serialization and deserialization of wire messages - Lib: Change `StatusUpdate` Wire message to not using `kimi_cli.soul.StatusSnapshot` - Core: Record Wire messages to a JSONL file in session directory - Core: Introduce `TurnBegin` Wire message to mark the beginning of each agent turn - UI: Print user input again with a panel in shell mode - Lib: Add `Session.dir` property to get the session directory path - UI: Improve "Approve for session" experience when there are multiple parallel subagents - Wire: Reimplement Wire server mode (which is enabled with `--wire` option) - Lib: Rename `ShellApp` to `Shell`, `PrintApp` to `Print`, `ACPServer` to `ACP` and `WireServer` to `WireOverStdio` for better consistency - Lib: Rename `KimiCLI.run_shell_mode` to `run_shell`, `run_print_mode` to `run_print`, `run_acp_server` to `run_acp`, and `run_wire_server` to `run_wire_stdio` for better consistency - Lib: Add `KimiCLI.run` method to run a turn with given user input and yield Wire messages - Print: Fix stream-json print mode not flushing output properly - LLM: Improve compatibility with some OpenAI and Anthropic API providers - Core: Fix chat provider error after compaction when using Anthropic API ## 0.58 (2025-11-21) - Core: Fix field inheritance of agent spec files when using `extend` - Core: Support using MCP tools in subagents - Tool: Add `CreateSubagent` tool to create subagents dynamically (not enabled in default agent) - Tool: Use MoonshotFetch service in `FetchURL` tool for Kimi Code plan - Tool: Truncate Grep tool output to avoid exceeding token limit ## 0.57 (2025-11-20) - LLM: Fix Google GenAI provider when thinking toggle is not on - UI: Improve approval request wordings - Tool: Remove `PatchFile` tool - Tool: Rename `Bash`/`CMD` tool to `Shell` tool - Tool: Move `Task` tool to `kimi_cli.tools.multiagent` module ## 0.56 (2025-11-19) - LLM: Add support for Google GenAI provider ## 0.55 (2025-11-18) - Lib: Add `kimi_cli.app.enable_logging` function to enable logging when directly using `KimiCLI` class - Core: Fix relative path resolution in agent spec files - Core: Prevent from panic when LLM API connection failed - Tool: Optimize `FetchURL` tool for better content extraction - Tool: Increase MCP tool call timeout to 60 seconds - Tool: Provide better error message in `Glob` tool when pattern is `**` - ACP: Fix thinking content not displayed properly - UI: Minor UI improvements in shell mode ## 0.54 (2025-11-13) - Lib: Move `WireMessage` from `kimi_cli.wire.message` to `kimi_cli.wire` - Print: Fix `stream-json` output format missing the last assistant message - UI: Add warning when API key is overridden by `KIMI_API_KEY` environment variable - UI: Make a bell sound when there's an approval request - Core: Fix context compaction and clearing on Windows ## 0.53 (2025-11-12) - UI: Remove unnecessary trailing spaces in console output - Core: Throw error when there are unsupported message parts - MetaCmd: Add `/yolo` meta command to enable YOLO mode after startup - Tool: Add approval request for MCP tools - Tool: Disable `Think` tool in default agent - CLI: Restore thinking mode from last time when `--thinking` is not specified - CLI: Fix `/reload` not working in binary packed by PyInstaller ## 0.52 (2025-11-10) - CLI: Remove `--ui` option in favor of `--print`, `--acp`, and `--wire` flags (shell is still the default) - CLI: More intuitive session continuation behavior - Core: Add retry for LLM empty responses - Tool: Change `Bash` tool to `CMD` tool on Windows - UI: Fix completion after backspacing - UI: Fix code block rendering issues on light background colors ## 0.51 (2025-11-08) - Lib: Rename `Soul.model` to `Soul.model_name` - Lib: Rename `LLMModelCapability` to `ModelCapability` and move to `kimi_cli.llm` - Lib: Add `"thinking"` to `ModelCapability` - Lib: Remove `LLM.supports_image_in` property - Lib: Add required `Soul.model_capabilities` property - Lib: Rename `KimiSoul.set_thinking_mode` to `KimiSoul.set_thinking` - Lib: Add `KimiSoul.thinking` property - UI: Better checks and notices for LLM model capabilities - UI: Clear the screen for `/clear` meta command - Tool: Support auto-downloading ripgrep on Windows - CLI: Add `--thinking` option to start in thinking mode - ACP: Support thinking content in ACP mode ## 0.50 (2025-11-07) ### Changed - Improve UI look and feel - Improve Task tool observability ## 0.49 (2025-11-06) ### Fixed - Minor UX improvements ## 0.48 (2025-11-06) ### Added - Support Kimi K2 thinking mode ## 0.47 (2025-11-05) ### Fixed - Fix Ctrl-W not working in some environments - Do not load SearchWeb tool when the search service is not configured ## 0.46 (2025-11-03) ### Added - Introduce Wire over stdio for local IPC (experimental, subject to change) - Support Anthropic provider type ### Fixed - Fix binary packed by PyInstaller not working due to wrong entrypoint ## 0.45 (2025-10-31) ### Added - Allow `KIMI_MODEL_CAPABILITIES` environment variable to override model capabilities - Add `--no-markdown` option to disable markdown rendering - Support `openai_responses` LLM provider type ### Fixed - Fix crash when continuing a session ## 0.44 (2025-10-30) ### Changed - Improve startup time ### Fixed - Fix potential invalid bytes in user input ## 0.43 (2025-10-30) ### Added - Basic Windows support (experimental) - Display warnings when base URL or API key is overridden in environment variables - Support image input if the LLM model supports it - Replay recent context history when continuing a session ### Fixed - Ensure new line after executing shell commands ## 0.42 (2025-10-28) ### Added - Support Ctrl-J or Alt-Enter to insert a new line ### Changed - Change mode switch shortcut from Ctrl-K to Ctrl-X - Improve overall robustness ### Fixed - Fix ACP server `no attribute` error ## 0.41 (2025-10-26) ### Fixed - Fix a bug for Glob tool when no matching files are found - Ensure reading files with UTF-8 encoding ### Changed - Disable reading command/query from stdin in shell mode - Clarify the API platform selection in `/setup` meta command ## 0.40 (2025-10-24) ### Added - Support `ESC` key to interrupt the agent loop ### Fixed - Fix SSL certificate verification error in some rare cases - Fix possible decoding error in Bash tool ## 0.39 (2025-10-24) ### Fixed - Fix context compaction threshold check - Fix panic when SOCKS proxy is set in the shell session ## 0.38 (2025-10-24) - Minor UX improvements ## 0.37 (2025-10-24) ### Fixed - Fix update checking ## 0.36 (2025-10-24) ### Added - Add `/debug` meta command to debug the context - Add auto context compaction - Add approval request mechanism - Add `--yolo` option to automatically approve all actions - Render markdown content for better readability ### Fixed - Fix "unknown error" message when interrupting a meta command ## 0.35 (2025-10-22) ### Changed - Minor UI improvements - Auto download ripgrep if not found in the system - Always approve tool calls in `--print` mode - Add `/feedback` meta command ## 0.34 (2025-10-21) ### Added - Add `/update` meta command to check for updates and auto-update in background - Support running interactive shell commands in raw shell mode - Add `/setup` meta command to setup LLM provider and model - Add `/reload` meta command to reload configuration ## 0.33 (2025-10-18) ### Added - Add `/version` meta command - Add raw shell mode, which can be switched to by Ctrl-K - Show shortcuts in bottom status line ### Fixed - Fix logging redirection - Merge duplicated input histories ## 0.32 (2025-10-16) ### Added - Add bottom status line - Support file path auto-completion (`@filepath`) ### Fixed - Do not auto-complete meta command in the middle of user input ## 0.31 (2025-10-14) ### Fixed - Fix step interrupting by Ctrl-C, for real ## 0.30 (2025-10-14) ### Added - Add `/compact` meta command to allow manually compacting context ### Fixed - Fix `/clear` meta command when context is empty ## 0.29 (2025-10-14) ### Added - Support Enter key to accept completion in shell mode - Remember user input history across sessions in shell mode - Add `/reset` meta command as an alias for `/clear` ### Fixed - Fix step interrupting by Ctrl-C ### Changed - Disable `SendDMail` tool in Kimi Koder agent ## 0.28 (2025-10-13) ### Added - Add `/init` meta command to analyze the codebase and generate an `AGENTS.md` file - Add `/clear` meta command to clear the context ### Fixed - Fix `ReadFile` output ## 0.27 (2025-10-11) ### Added - Add `--mcp-config-file` and `--mcp-config` options to load MCP configs ### Changed - Rename `--agent` option to `--agent-file` ## 0.26 (2025-10-11) ### Fixed - Fix possible encoding error in `--output-format stream-json` mode ## 0.25 (2025-10-11) ### Changed - Rename package name `ensoul` to `kimi-cli` - Rename `ENSOUL_*` builtin system prompt arguments to `KIMI_*` - Further decouple `App` with `Soul` - Split `Soul` protocol and `KimiSoul` implementation for better modularity ## 0.24 (2025-10-10) ### Fixed - Fix ACP `cancel` method ## 0.23 (2025-10-09) ### Added - Add `extend` field to agent file to support agent file extension - Add `exclude_tools` field to agent file to support excluding tools - Add `subagents` field to agent file to support defining subagents ## 0.22 (2025-10-09) ### Changed - Improve `SearchWeb` and `FetchURL` tool call visualization - Improve search result output format ## 0.21 (2025-10-09) ### Added - Add `--print` option as a shortcut for `--ui print`, `--acp` option as a shortcut for `--ui acp` - Support `--output-format stream-json` to print output in JSON format - Add `SearchWeb` tool with `services.moonshot_search` configuration. You need to configure it with `"services": {"moonshot_search": {"api_key": "your-search-api-key"}}` in your config file. - Add `FetchURL` tool - Add `Think` tool - Add `PatchFile` tool, not enabled in Kimi Koder agent - Enable `SendDMail` and `Task` tool in Kimi Koder agent with better tool prompts - Add `ENSOUL_NOW` builtin system prompt argument ### Changed - Better-looking `/release-notes` - Improve tool descriptions - Improve tool output truncation ## 0.20 (2025-09-30) ### Added - Add `--ui acp` option to start Agent Client Protocol (ACP) server ## 0.19 (2025-09-29) ### Added - Support piped stdin for print UI - Support `--input-format=stream-json` for piped JSON input ### Fixed - Do not include `CHECKPOINT` messages in the context when `SendDMail` is not enabled ## 0.18 (2025-09-29) ### Added - Support `max_context_size` in LLM model configurations to configure the maximum context size (in tokens) ### Improved - Improve `ReadFile` tool description ## 0.17 (2025-09-29) ### Fixed - Fix step count in error message when exceeded max steps - Fix history file assertion error in `kimi_run` - Fix error handling in print mode and single command shell mode - Add retry for LLM API connection errors and timeout errors ### Changed - Increase default max-steps-per-run to 100 ## 0.16.0 (2025-09-26) ### Tools - Add `SendDMail` tool (disabled in Kimi Koder, can be enabled in custom agent) ### SDK - Session history file can be specified via `_history_file` parameter when creating a new session ## 0.15.0 (2025-09-26) - Improve tool robustness ## 0.14.0 (2025-09-25) ### Added - Add `StrReplaceFile` tool ### Improved - Emphasize the use of the same language as the user ## 0.13.0 (2025-09-25) ### Added - Add `SetTodoList` tool - Add `User-Agent` in LLM API calls ### Improved - Better system prompt and tool description - Better error messages for LLM ## 0.12.0 (2025-09-24) ### Added - Add `print` UI mode, which can be used via `--ui print` option - Add logging and `--debug` option ### Changed - Catch EOF error for better experience ## 0.11.1 (2025-09-22) ### Changed - Rename `max_retry_per_step` to `max_retries_per_step` ## 0.11.0 (2025-09-22) ### Added - Add `/release-notes` command - Add retry for LLM API errors - Add loop control configuration, e.g. `{"loop_control": {"max_steps_per_run": 50, "max_retry_per_step": 3}}` ### Changed - Better extreme cases handling in `read_file` tool - Prevent Ctrl-C from exiting the CLI, force the use of Ctrl-D or `exit` instead ## 0.10.1 (2025-09-18) - Make slash commands look slightly better - Improve `glob` tool ## 0.10.0 (2025-09-17) ### Added - Add `read_file` tool - Add `write_file` tool - Add `glob` tool - Add `task` tool ### Changed - Improve tool call visualization - Improve session management - Restore context usage when `--continue` a session ## 0.9.0 (2025-09-15) - Remove `--session` and `--continue` options ## 0.8.1 (2025-09-14) - Fix config model dumping ## 0.8.0 (2025-09-14) - Add `shell` tool and basic system prompt - Add tool call visualization - Add context usage count - Support interrupting the agent loop - Support project-level `AGENTS.md` - Support custom agent defined with YAML - Support oneshot task via `kimi -c` ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Kimi Code CLI Thank you for being interested in contributing to Kimi Code CLI! We welcome all kinds of contributions, including bug fixes, features, document improvements, typo fixes, etc. To maintain a high-quality codebase and user experience, we provide the following guidelines for contributions: 1. We only merge pull requests that aligns with our roadmap. For any pull request that introduces changes larger than 100 lines of code, we highly recommend discussing with us by [raising an issue](https://github.com/MoonshotAI/kimi-cli/issues) or in an existing issue before you start working on it. Otherwise your pull request may be closed or ignored without review. 2. We insist on high code quality. Please ensure your code is as good as, if not better than, the code written by frontier coding agents. Changes may be requested before your pull request can be merged. ## Prek hooks We use [prek](https://github.com/j178/prek) to run formatting and checks via git hooks. Recommended setup: 1. Run `make prepare` to sync dependencies and install the prek hooks. 2. Optionally run on all files before sending a PR: `prek run --all-files`. Manual setup (if you do not want to use `make prepare`): 1. Install prek (pick one): `uv tool install prek`, `pipx install prek`, or `pip install prek`. 2. Install the hooks in this repo: `prek install`. After installation, the hooks run on every commit. The repo uses prek workspace mode, so only the projects with changed files run their hooks. You can skip them for an intermediate commit with `git commit --no-verify`, or run them manually with `prek run --all-files`. The hooks execute the relevant `make format-*` and `make check-*` targets, so ensure dependencies are installed (`make prepare` or `uv sync`). ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: Makefile ================================================ .DEFAULT_GOAL := prepare .PHONY: help help: ## Show available make targets. @echo "Available make targets:" @awk 'BEGIN { FS = ":.*## " } /^[A-Za-z0-9_.-]+:.*## / { printf " %-20s %s\n", $$1, $$2 }' $(MAKEFILE_LIST) .PHONY: install-prek install-prek: ## Install prek and repo git hooks. @echo "==> Installing prek" @uv tool install prek @echo "==> Installing git hooks with prek" @uv tool run prek install .PHONY: prepare prepare: download-deps install-prek ## Sync dependencies for all workspace packages and install prek hooks. @echo "==> Syncing dependencies for all workspace packages" @uv sync --frozen --all-extras --all-packages .PHONY: prepare-build prepare-build: download-deps ## Sync dependencies for releases without workspace sources. @echo "==> Syncing dependencies for release builds (no sources)" @uv sync --all-extras --all-packages --no-sources # for kimi web development .PHONY: web-back web-front web-back: ## Start web backend with uvicorn (reload enabled). @LOG_LEVEL=DEBUG uv run uvicorn kimi_cli.web.app:create_app --factory --reload --port 5494 web-front: ## Start web frontend (vite dev server). @npm --prefix web run dev # for kimi vis development .PHONY: vis-back vis-front vis-back: ## Start vis backend with uvicorn (reload enabled). @LOG_LEVEL=DEBUG uv run uvicorn kimi_cli.vis.app:create_app --factory --reload --port 5495 vis-front: ## Start vis frontend (vite dev server). @npm --prefix vis run dev .PHONY: format format-kimi-cli format-kosong format-pykaos format-kimi-sdk format-web format: format-kimi-cli format-kosong format-pykaos format-kimi-sdk format-web ## Auto-format all workspace packages. format-kimi-cli: ## Auto-format Kimi Code CLI sources with ruff. @echo "==> Formatting Kimi Code CLI sources" @uv run ruff check --fix @uv run ruff format format-kosong: ## Auto-format kosong sources with ruff. @echo "==> Formatting kosong sources" @uv run --project packages/kosong --directory packages/kosong ruff check --fix @uv run --project packages/kosong --directory packages/kosong ruff format format-pykaos: ## Auto-format pykaos sources with ruff. @echo "==> Formatting pykaos sources" @uv run --project packages/kaos --directory packages/kaos ruff check --fix @uv run --project packages/kaos --directory packages/kaos ruff format format-kimi-sdk: ## Auto-format kimi-sdk sources with ruff. @echo "==> Formatting kimi-sdk sources" @uv run --project sdks/kimi-sdk --directory sdks/kimi-sdk ruff check --fix @uv run --project sdks/kimi-sdk --directory sdks/kimi-sdk ruff format format-web: ## Auto-format web sources with npm run format. @echo "==> Formatting web sources" @if command -v npm >/dev/null 2>&1; then \ npm --prefix web run format; \ else \ echo "npm not found. Install Node.js (npm) to run web formatting."; \ exit 1; \ fi .PHONY: check check-kimi-cli check-kosong check-pykaos check-kimi-sdk check-web check: check-kimi-cli check-kosong check-pykaos check-kimi-sdk check-web ## Run linting and type checks for all packages. check-kimi-cli: ## Run linting and type checks for Kimi Code CLI. @echo "==> Checking Kimi Code CLI (ruff + pyright + ty; ty is non-blocking)" @uv run ruff check @uv run ruff format --check @uv run pyright @uv run ty check || true check-kosong: ## Run linting and type checks for kosong. @echo "==> Checking kosong (ruff + pyright + ty; ty is non-blocking)" @uv run --project packages/kosong --directory packages/kosong ruff check @uv run --project packages/kosong --directory packages/kosong ruff format --check @uv run --project packages/kosong --directory packages/kosong pyright @uv run --project packages/kosong --directory packages/kosong ty check || true check-pykaos: ## Run linting and type checks for pykaos. @echo "==> Checking pykaos (ruff + pyright + ty; ty is non-blocking)" @uv run --project packages/kaos --directory packages/kaos ruff check @uv run --project packages/kaos --directory packages/kaos ruff format --check @uv run --project packages/kaos --directory packages/kaos pyright @uv run --project packages/kaos --directory packages/kaos ty check || true check-kimi-sdk: ## Run linting and type checks for kimi-sdk. @echo "==> Checking kimi-sdk (ruff + pyright + ty; ty is non-blocking)" @uv run --project sdks/kimi-sdk --directory sdks/kimi-sdk ruff check @uv run --project sdks/kimi-sdk --directory sdks/kimi-sdk ruff format --check @uv run --project sdks/kimi-sdk --directory sdks/kimi-sdk pyright @uv run --project sdks/kimi-sdk --directory sdks/kimi-sdk ty check || true check-web: ## Run linting and type checks for web. @echo "==> Checking web (biome + tsc)" @if command -v npm >/dev/null 2>&1; then \ npm --prefix web run lint && npm --prefix web run typecheck; \ else \ echo "npm not found. Install Node.js (npm) to run web checks."; \ exit 1; \ fi .PHONY: test test-kimi-cli test-kosong test-pykaos test-kimi-sdk test: test-kimi-cli test-kosong test-pykaos test-kimi-sdk ## Run all test suites. test-kimi-cli: ## Run Kimi Code CLI tests. @echo "==> Running Kimi Code CLI tests" @uv run pytest tests -vv @uv run pytest tests_e2e -vv test-kosong: ## Run kosong tests (including doctests). @echo "==> Running kosong tests" @uv run --project packages/kosong --directory packages/kosong pytest --doctest-modules -vv test-pykaos: ## Run pykaos tests. @echo "==> Running pykaos tests" @uv run --project packages/kaos --directory packages/kaos pytest tests -vv test-kimi-sdk: ## Run kimi-sdk tests. @echo "==> Running kimi-sdk tests" @uv run --project sdks/kimi-sdk --directory sdks/kimi-sdk pytest tests -vv .PHONY: build build-kimi-cli build-kosong build-pykaos build-kimi-sdk build-bin build-bin-onedir build: build-web build-vis build-kimi-cli build-kosong build-pykaos build-kimi-sdk ## Build Python packages for release. build-kimi-cli: build-web build-vis ## Build the kimi-cli and kimi-code sdists and wheels. @echo "==> Building kimi-cli distributions" @uv build --package kimi-cli --no-sources --out-dir dist @echo "==> Building kimi-code distributions" @uv build --package kimi-code --no-sources --out-dir dist build-kosong: ## Build the kosong sdist and wheel. @echo "==> Building kosong distributions" @uv build --package kosong --no-sources --out-dir dist/kosong build-pykaos: ## Build the pykaos sdist and wheel. @echo "==> Building pykaos distributions" @uv build --package pykaos --no-sources --out-dir dist/pykaos build-kimi-sdk: ## Build the kimi-sdk sdist and wheel. @echo "==> Building kimi-sdk distributions" @uv build --package kimi-sdk --no-sources --out-dir dist/kimi-sdk build-web: ## Build web UI and sync into kimi-cli package. @echo "==> Building web UI" @uv run scripts/build_web.py build-vis: ## Build vis UI and sync into kimi-cli package. @echo "==> Building vis UI" @uv run scripts/build_vis.py build-bin: build-web build-vis ## Build the standalone executable with PyInstaller (one-file mode). @echo "==> Building PyInstaller binary (one-file)" @uv run pyinstaller kimi.spec @mkdir -p dist/onefile @if [ -f dist/kimi.exe ]; then mv dist/kimi.exe dist/onefile/; elif [ -f dist/kimi ]; then mv dist/kimi dist/onefile/; fi build-bin-onedir: build-web build-vis ## Build the standalone executable with PyInstaller (one-dir mode). @echo "==> Building PyInstaller binary (one-dir)" @rm -rf dist/onedir dist/kimi @uv run pyinstaller kimi.spec @if [ -f dist/kimi/kimi-exe.exe ]; then mv dist/kimi/kimi-exe.exe dist/kimi/kimi.exe; elif [ -f dist/kimi/kimi-exe ]; then mv dist/kimi/kimi-exe dist/kimi/kimi; fi @mkdir -p dist/onedir && mv dist/kimi dist/onedir/ .PHONY: ai-test ai-test: ## Run the test suite with Kimi Code CLI. @echo "==> Running AI test suite" @uv run tests_ai/scripts/run.py tests_ai .PHONY: gen-changelog gen-docs gen-changelog: ## Generate changelog with Kimi Code CLI. @echo "==> Generating changelog" @uv run kimi --yolo --prompt /skill:gen-changelog gen-docs: ## Generate user docs with Kimi Code CLI. @echo "==> Generating user docs" @uv run kimi --yolo --prompt /skill:gen-docs include src/kimi_cli/deps/Makefile ================================================ FILE: NOTICE ================================================ Kimi Code CLI Copyright 2025 Moonshot AI This product includes software developed at Moonshot AI (https://www.moonshot.ai/). The Kimi Code CLI project (formerly Kimi CLI) contains or reuses code that is licensed under the Apache 2.0 license from the following projects: - OpenAI Codex (https://github.com/openai/codex) See: src/kimi_cli/skills/skill-creator/SKILL.md OpenAI Codex Copyright 2025 OpenAI ================================================ FILE: README.md ================================================ # Kimi Code CLI [![Commit Activity](https://img.shields.io/github/commit-activity/w/MoonshotAI/kimi-cli)](https://github.com/MoonshotAI/kimi-cli/graphs/commit-activity) [![Checks](https://img.shields.io/github/check-runs/MoonshotAI/kimi-cli/main)](https://github.com/MoonshotAI/kimi-cli/actions) [![Version](https://img.shields.io/pypi/v/kimi-cli)](https://pypi.org/project/kimi-cli/) [![Downloads](https://img.shields.io/pypi/dw/kimi-cli)](https://pypistats.org/packages/kimi-cli) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/MoonshotAI/kimi-cli) [Kimi Code](https://www.kimi.com/code/) | [Documentation](https://moonshotai.github.io/kimi-cli/en/) | [文档](https://moonshotai.github.io/kimi-cli/zh/) Kimi Code CLI is an AI agent that runs in the terminal, helping you complete software development tasks and terminal operations. It can read and edit code, execute shell commands, search and fetch web pages, and autonomously plan and adjust actions during execution. ## Getting Started See [Getting Started](https://moonshotai.github.io/kimi-cli/en/guides/getting-started.html) for how to install and start using Kimi Code CLI. ## Key Features ### Shell command mode Kimi Code CLI is not only a coding agent, but also a shell. You can switch the shell command mode by pressing `Ctrl-X`. In this mode, you can directly run shell commands without leaving Kimi Code CLI. ![](./docs/media/shell-mode.gif) > [!NOTE] > Built-in shell commands like `cd` are not supported yet. ### VS Code extension Kimi Code CLI can be integrated with [Visual Studio Code](https://code.visualstudio.com/) via the [Kimi Code VS Code Extension](https://marketplace.visualstudio.com/items?itemName=moonshot-ai.kimi-code). ![VS Code Extension](./docs/media/vscode.png) ### IDE integration via ACP Kimi Code CLI supports [Agent Client Protocol] out of the box. You can use it together with any ACP-compatible editor or IDE. [Agent Client Protocol]: https://github.com/agentclientprotocol/agent-client-protocol To use Kimi Code CLI with ACP clients, make sure to run Kimi Code CLI in the terminal and send `/login` to complete the login first. Then, you can configure your ACP client to start Kimi Code CLI as an ACP agent server with command `kimi acp`. For example, to use Kimi Code CLI with [Zed](https://zed.dev/) or [JetBrains](https://blog.jetbrains.com/ai/2025/12/bring-your-own-ai-agent-to-jetbrains-ides/), add the following configuration to your `~/.config/zed/settings.json` or `~/.jetbrains/acp.json` file: ```json { "agent_servers": { "Kimi Code CLI": { "command": "kimi", "args": ["acp"], "env": {} } } } ``` Then you can create Kimi Code CLI threads in IDE's agent panel. ![](./docs/media/acp-integration.gif) ### Zsh integration You can use Kimi Code CLI together with Zsh, to empower your shell experience with AI agent capabilities. Install the [zsh-kimi-cli](https://github.com/MoonshotAI/zsh-kimi-cli) plugin via: ```sh git clone https://github.com/MoonshotAI/zsh-kimi-cli.git \ ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/kimi-cli ``` > [!NOTE] > If you are using a plugin manager other than Oh My Zsh, you may need to refer to the plugin's README for installation instructions. Then add `kimi-cli` to your Zsh plugin list in `~/.zshrc`: ```sh plugins=(... kimi-cli) ``` After restarting Zsh, you can switch to agent mode by pressing `Ctrl-X`. ### MCP support Kimi Code CLI supports MCP (Model Context Protocol) tools. **`kimi mcp` sub-command group** You can manage MCP servers with `kimi mcp` sub-command group. For example: ```sh # Add streamable HTTP server: kimi mcp add --transport http context7 https://mcp.context7.com/mcp --header "CONTEXT7_API_KEY: ctx7sk-your-key" # Add streamable HTTP server with OAuth authorization: kimi mcp add --transport http --auth oauth linear https://mcp.linear.app/mcp # Add stdio server: kimi mcp add --transport stdio chrome-devtools -- npx chrome-devtools-mcp@latest # List added MCP servers: kimi mcp list # Remove an MCP server: kimi mcp remove chrome-devtools # Authorize an MCP server: kimi mcp auth linear ``` **Ad-hoc MCP configuration** Kimi Code CLI also supports ad-hoc MCP server configuration via CLI option. Given an MCP config file in the well-known MCP config format like the following: ```json { "mcpServers": { "context7": { "url": "https://mcp.context7.com/mcp", "headers": { "CONTEXT7_API_KEY": "YOUR_API_KEY" } }, "chrome-devtools": { "command": "npx", "args": ["-y", "chrome-devtools-mcp@latest"] } } } ``` Run `kimi` with `--mcp-config-file` option to connect to the specified MCP servers: ```sh kimi --mcp-config-file /path/to/mcp.json ``` ### More See more features in the [Documentation](https://moonshotai.github.io/kimi-cli/en/). ## Development To develop Kimi Code CLI, run: ```sh git clone https://github.com/MoonshotAI/kimi-cli.git cd kimi-cli make prepare # prepare the development environment ``` Then you can start working on Kimi Code CLI. Refer to the following commands after you make changes: ```sh uv run kimi # run Kimi Code CLI make format # format code make check # run linting and type checking make test # run tests make test-kimi-cli # run Kimi Code CLI tests only make test-kosong # run kosong tests only make test-pykaos # run pykaos tests only make build-web # build the web UI and sync it into the package (requires Node.js/npm) make build # build python packages make build-bin # build standalone binary make help # show all make targets ``` Note: `make build` and `make build-bin` automatically run `make build-web` to embed the web UI. ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions Currently, Kimi CLI only provides security support for the latest version. ## Reporting a Vulnerability Please report a vulnerability via the [MoonshotAI/kimi-cli - Security](https://github.com/MoonshotAI/kimi-cli/security) page, or open an [issue](https://github.com/MoonshotAI/kimi-cli/issues) if it can be published publicly. ================================================ FILE: docs/.gitignore ================================================ node_modules/ .vitepress/cache/ .vitepress/dist/ ================================================ FILE: docs/.pre-commit-config.yaml ================================================ orphan: true # Docs changes do not need pre-commit hooks. repos: [] ================================================ FILE: docs/.vitepress/config.ts ================================================ import { defineConfig } from 'vitepress' import { withMermaid } from 'vitepress-plugin-mermaid' import llmstxt from 'vitepress-plugin-llms' const rawBase = process.env.VITEPRESS_BASE const base = rawBase ? rawBase.startsWith('/') ? rawBase.endsWith('/') ? rawBase : `${rawBase}/` : `/${rawBase}/` : '/' export default withMermaid(defineConfig({ base, title: 'Kimi Code CLI Docs', description: 'Kimi Code CLI Documentation', locales: { zh: { label: '简体中文', lang: 'zh-CN', link: '/zh/', title: 'Kimi Code CLI 文档', description: 'Kimi Code CLI 用户文档', themeConfig: { nav: [ { text: '指南', link: '/zh/guides/getting-started', activeMatch: '/zh/guides/' }, { text: '定制化', link: '/zh/customization/mcp', activeMatch: '/zh/customization/' }, { text: '配置', link: '/zh/configuration/config-files', activeMatch: '/zh/configuration/' }, { text: '参考手册', link: '/zh/reference/kimi-command', activeMatch: '/zh/reference/' }, { text: '常见问题', link: '/zh/faq' }, { text: '发布说明', link: '/zh/release-notes/changelog', activeMatch: '/zh/release-notes/' }, ], sidebar: { '/zh/guides/': [ { text: '指南', items: [ { text: '开始使用', link: '/zh/guides/getting-started' }, { text: '常见使用案例', link: '/zh/guides/use-cases' }, { text: '交互与输入', link: '/zh/guides/interaction' }, { text: '会话与上下文', link: '/zh/guides/sessions' }, { text: '在 IDE 中使用', link: '/zh/guides/ides' }, { text: '集成到工具', link: '/zh/guides/integrations' }, ], }, ], '/zh/customization/': [ { text: '定制化', items: [ { text: 'Model Context Protocol', link: '/zh/customization/mcp' }, { text: 'Agent Skills', link: '/zh/customization/skills' }, { text: 'Agent 与子 Agent', link: '/zh/customization/agents' }, { text: 'Print 模式', link: '/zh/customization/print-mode' }, { text: 'Wire 模式', link: '/zh/customization/wire-mode' }, ], }, ], '/zh/configuration/': [ { text: '配置', items: [ { text: '配置文件', link: '/zh/configuration/config-files' }, { text: '平台与模型', link: '/zh/configuration/providers' }, { text: '配置覆盖', link: '/zh/configuration/overrides' }, { text: '环境变量', link: '/zh/configuration/env-vars' }, { text: '数据路径', link: '/zh/configuration/data-locations' }, ], }, ], '/zh/reference/': [ { text: '参考手册', items: [ { text: 'kimi 命令', link: '/zh/reference/kimi-command' }, { text: 'kimi info 子命令', link: '/zh/reference/kimi-info' }, { text: 'kimi acp 子命令', link: '/zh/reference/kimi-acp' }, { text: 'kimi mcp 子命令', link: '/zh/reference/kimi-mcp' }, { text: 'kimi term 子命令', link: '/zh/reference/kimi-term' }, { text: 'kimi vis 子命令', link: '/zh/reference/kimi-vis' }, { text: 'kimi web 子命令', link: '/zh/reference/kimi-web' }, { text: '斜杠命令', link: '/zh/reference/slash-commands' }, { text: '键盘快捷键', link: '/zh/reference/keyboard' }, ], }, ], '/zh/release-notes/': [ { text: '发布说明', items: [ { text: '变更记录', link: '/zh/release-notes/changelog' }, { text: '破坏性变更与迁移说明', link: '/zh/release-notes/breaking-changes' }, ], }, ], }, }, }, en: { label: 'English', lang: 'en-US', link: '/en/', title: 'Kimi Code CLI Docs', description: 'Kimi Code CLI User Documentation', themeConfig: { nav: [ { text: 'Guides', link: '/en/guides/getting-started', activeMatch: '/en/guides/' }, { text: 'Customization', link: '/en/customization/mcp', activeMatch: '/en/customization/' }, { text: 'Configuration', link: '/en/configuration/config-files', activeMatch: '/en/configuration/' }, { text: 'Reference', link: '/en/reference/kimi-command', activeMatch: '/en/reference/' }, { text: 'FAQ', link: '/en/faq' }, { text: 'Release Notes', link: '/en/release-notes/changelog', activeMatch: '/en/release-notes/' }, ], sidebar: { '/en/guides/': [ { text: 'Guides', items: [ { text: 'Getting Started', link: '/en/guides/getting-started' }, { text: 'Common Use Cases', link: '/en/guides/use-cases' }, { text: 'Interaction and Input', link: '/en/guides/interaction' }, { text: 'Sessions and Context', link: '/en/guides/sessions' }, { text: 'Using in IDEs', link: '/en/guides/ides' }, { text: 'Integrations with Tools', link: '/en/guides/integrations' }, ], }, ], '/en/customization/': [ { text: 'Customization', items: [ { text: 'Model Context Protocol', link: '/en/customization/mcp' }, { text: 'Agent Skills', link: '/en/customization/skills' }, { text: 'Agents and Subagents', link: '/en/customization/agents' }, { text: 'Print Mode', link: '/en/customization/print-mode' }, { text: 'Wire Mode', link: '/en/customization/wire-mode' }, ], }, ], '/en/configuration/': [ { text: 'Configuration', items: [ { text: 'Config Files', link: '/en/configuration/config-files' }, { text: 'Providers and Models', link: '/en/configuration/providers' }, { text: 'Config Overrides', link: '/en/configuration/overrides' }, { text: 'Environment Variables', link: '/en/configuration/env-vars' }, { text: 'Data Locations', link: '/en/configuration/data-locations' }, ], }, ], '/en/reference/': [ { text: 'Reference', items: [ { text: 'kimi Command', link: '/en/reference/kimi-command' }, { text: 'kimi info Subcommand', link: '/en/reference/kimi-info' }, { text: 'kimi acp Subcommand', link: '/en/reference/kimi-acp' }, { text: 'kimi mcp Subcommand', link: '/en/reference/kimi-mcp' }, { text: 'kimi term Subcommand', link: '/en/reference/kimi-term' }, { text: 'kimi vis Subcommand', link: '/en/reference/kimi-vis' }, { text: 'kimi web Subcommand', link: '/en/reference/kimi-web' }, { text: 'Slash Commands', link: '/en/reference/slash-commands' }, { text: 'Keyboard Shortcuts', link: '/en/reference/keyboard' }, ], }, ], '/en/release-notes/': [ { text: 'Release Notes', items: [ { text: 'Changelog', link: '/en/release-notes/changelog' }, { text: 'Breaking Changes and Migration', link: '/en/release-notes/breaking-changes' }, ], }, ], }, }, }, }, themeConfig: { outline: [2, 3], search: { provider: 'local' }, socialLinks: [ { icon: 'github', link: 'https://github.com/MoonshotAI/kimi-cli' }, ], }, vite: { plugins: [llmstxt()], }, })) ================================================ FILE: docs/.vitepress/theme/index.ts ================================================ import DefaultTheme from 'vitepress/theme' import './style.css' export default DefaultTheme ================================================ FILE: docs/.vitepress/theme/style.css ================================================ :root { --vp-c-brand-1: rgb(52, 118, 246); --vp-c-brand-2: rgb(72, 138, 255); --vp-c-brand-3: rgb(92, 158, 255); --vp-c-brand-soft: rgba(52, 118, 246, 0.14); } .dark { --vp-c-brand-1: rgb(72, 138, 255); --vp-c-brand-2: rgb(52, 118, 246); --vp-c-brand-3: rgb(92, 158, 255); --vp-c-brand-soft: rgba(52, 118, 246, 0.16); } ================================================ FILE: docs/AGENTS.md ================================================ # Documentation Agent Guide This repository uses VitePress for the documentation site. The current docs are structural scaffolds only; everything beyond the headings is placeholder guidance. The `Reference Code` blocks are there to guide future writing and should be removed once the docs are complete. ## Structure - Locales live under `docs/en/` and `docs/zh/` with mirrored paths and filenames. - Main sections (nav + sidebar) are: - Guides: getting-started, use-cases, interaction, sessions, ides, integrations - Customization: mcp, skills, agents, print-mode, wire-mode - Configuration: config-files, providers, overrides, env-vars, data-locations - Reference: kimi-command, kimi-acp, kimi-mcp, slash-commands, keyboard, tools, exit-codes - FAQ: setup, interaction, acp, mcp, print-wire, updates - Release notes: changelog, breaking-changes - Navigation and sidebar are defined in `docs/.vitepress/config.ts`. Any new or renamed page must be wired there for both locales. ## Source of truth - **Changelog page**: The English version (`docs/en/release-notes/changelog.md`) is the source of truth. It is auto-synced from the root `CHANGELOG.md` via script. The Chinese changelog should be translated from the English version. - **All other pages**: The Chinese version (`docs/zh/`) is the source of truth. English translations should be based on the Chinese docs. Only the Chinese documentation and the English changelog are manually reviewed. Other translations (e.g., English versions of non-changelog pages) may be auto-generated by AI agents. ## Authoring workflow - Each page is a scaffold: expand the bullets into prose while keeping the section ordering, and keep the `::: info Reference Code` blocks aligned with the relevant section. - For changelog: edit the root `CHANGELOG.md`, then run `npm run sync` to update the English docs. - For other pages: edit the Chinese version first, then translate to English. ## Naming conventions - Filenames are kebab-case and mirror across locales (same slug in `docs/en/` and `docs/zh/`). - Use consistent section labels that match the sidebar titles. - Use backticks for flags, commands, subcommands, command arguments, file paths, code identifiers, type names, field names, field values, and keyboard shortcuts. ## Wording conventions - Do not change H1 titles or nav/sidebar labels. - English H2+ headings use sentence case (only the first word capitalized unless it is a proper noun). Treat "Wire" as a proper noun; do not treat "agent", "shell mode", or "print mode" as proper nouns. - Chinese H2+ headings keep English words in sentence case; preserve proper nouns listed in the term table below. - Use `API key` in English and `API 密钥` in Chinese; keep `JSON`, `JSONL`, `OAuth`, `macOS`, and `uv` as-is. - Use straight double quotes with spaces for quoted content: `"被引内容"` (not curly quotes). Add a space before and after the quoted text when adjacent to CJK characters. Use corner brackets `「」` for special terms (e.g., `「工具」`, `「会话」`). - Prefer "终端" over "命令行" in Chinese when both are applicable (e.g., "运行在终端中", "终端界面", "终端操作"). - Use "工具调用" / "tool call", not "工具使用" / "tool use". - Use inline code for tool names (e.g., `Task`, `ReadFile`, `Shell`). Term mapping (Chinese <-> English, and proper noun handling): | Chinese | English | Proper noun (zh) | Proper noun (en) | | --- | --- | --- | --- | | Agent | agent | yes | no | | Shell | shell | yes | no | | Shell 模式 | shell mode | yes | no | | Print 模式 | print mode | yes | no | | Wire 模式 | Wire mode | yes | yes (Wire) | | Thinking 模式 | thinking mode | yes | no | | MCP | MCP | yes | yes | | ACP | ACP | yes | yes | | Kimi Code CLI | Kimi Code CLI | yes | yes | | Agent Skills | Agent Skills | yes | yes | | Skill | skill | yes | no | | 系统提示词 | system prompt | no | no | | 提示词 | prompt | no | no | | 会话 | session | no | no | | 上下文 | context | no | no | | 子 Agent | subagent | yes (Agent) | no | | API 密钥 | API key | yes | no | | JSON | JSON | yes | yes | | JSONL | JSONL | yes | yes | | OAuth | OAuth | yes | yes | | macOS | macOS | yes | yes | | uv | uv | yes | yes | | 审批请求 | approval request | no | no | | 斜杠命令 | slash command | no | no | | 工具调用 | tool call | no | no | | Frontmatter | frontmatter | yes | no | | User 消息 | user message | yes (User) | no | | Assistant 消息 | assistant message | yes (Assistant) | no | | Tool 消息 | tool message | yes (Tool) | no | | 轮次 | turn | no | no | | 供应商 | provider | no | no | | Prompt Flow | Prompt Flow | yes | yes | | Ralph 循环 | Ralph Loop | yes | yes | | Diff | diff | yes | no | JetBrains IDE terminology (Chinese UI translations): | English | Chinese | | --- | --- | | AI Chat | AI 聊天 | | Registry | 注册表 | | Configure ACP agents | (未翻译) | ## Typography - **Spacing around mixed content**: Add a space between Chinese characters and English words, numbers, inline code, or links. Exception: no space before full-width punctuation. - ✓ 在 Python 中使用 \`class\` 关键字 - ✗ 在Python中使用\`class\`关键字 - ✓ 详见 \[配置文件\](./config.md)。 - ✗ 详见\[配置文件\](./config.md)。 - **Full-width punctuation**: Use full-width punctuation in Chinese text: `,。;:?!()` not `, . ; : ? ! ( )`. - **Code block language**: Always specify language for fenced code blocks (e.g., ` ```sh `, ` ```toml `, ` ```json `). Exception: natural language examples (user prompts) may omit the language. - **Callout titles**: Use short category titles for callout blocks (`::: tip`, `::: warning`, `::: info`, `::: danger`). Put the detailed description in the block content, not the title. - Chinese: use `提示` for tip, `注意` for warning, `说明` for info, `警告` for danger. - English: use no title or short words like `Note` for warning. - ✓ `::: tip 提示` + content starting with the key point - ✓ `::: warning 注意` + content `\`KIMI_SHARE_DIR\` 不影响 Skills 的搜索路径。...` - ✗ `::: warning 不影响 Skills` (title too long, should be in content) - ✗ `::: tip Skills 路径独立于 KIMI_SHARE_DIR` (title too long) - **Version info blocks**: For version change callouts, use `::: info` with a category title (Added/Changed/Removed in English; 新增/变更/移除 in Chinese). The content should be a complete sentence. - ✓ `::: info 新增` + content `新增于 Wire 1.2。` - ✗ `::: info 新增于 Wire 1.2` (title too long) - ✓ `::: info Changed` + content `Renamed in Wire 1.1. ...` - ✗ `::: info Renamed in Wire 1.1` (title too long) ## Writing style - **Natural narrative**: Organize content like writing an article, guiding readers smoothly through the material. - **Avoid fragmentation**: Don't turn every point into a subheading; use paragraph transitions instead. - **Global perspective**: "Getting Started" introduces core concepts only; detailed usage belongs in later pages. - **Progressive depth**: Guides → Customization → Configuration → Reference, information deepens gradually. - **No "next steps"**: VitePress already provides prev/next navigation; don't add manual `::: tip 接下来` blocks at page end. ### Example: good vs bad Outline prompt: ``` * Install and upgrade * System requirements: Python 3.12+, recommend uv * Install, upgrade, uninstall steps ``` **Bad** (mechanical conversion to headings): ```markdown ## Install and upgrade ### System requirements - Python 3.12+ - Recommend uv ### Install ... ### Upgrade ... ``` **Good** (natural narrative): ```markdown ## Install and upgrade Kimi Code CLI requires Python 3.12+. We recommend using uv for installation and management. If you haven't installed uv yet, please refer to the uv installation docs first. Install Kimi Code CLI: (code block) Verify the installation: (code block) Upgrade to the latest version: (code block) ``` ## Build and preview - Docs are built with VitePress from `docs/`. - Common commands (run inside `docs/`): - `npm install` (or `bun install` if you use bun) - `npm run dev` - `npm run build` - `npm run preview` - The build output is `docs/.vitepress/dist`. ## Changelog syncing The English changelog (`docs/en/release-notes/changelog.md`) is auto-generated from the root `CHANGELOG.md`. Do not edit it manually. - The sync script is `docs/scripts/sync-changelog.mjs`. - It runs automatically before `npm run dev` and `npm run build`. - To run manually: `npm run sync` (from the `docs/` directory). - The script converts title format (`## [0.69] - 2025-12-29` → `## 0.69 (2025-12-29)`) and removes HTML comments. ================================================ FILE: docs/en/configuration/config-files.md ================================================ # Config Files Kimi Code CLI uses configuration files to manage API providers, models, services, and runtime parameters, supporting both TOML and JSON formats. ## Config file location The default configuration file is located at `~/.kimi/config.toml`. On first run, if the configuration file doesn't exist, Kimi Code CLI will automatically create a default configuration file. You can specify a different configuration file (TOML or JSON format) with the `--config-file` flag: ```sh kimi --config-file /path/to/config.toml ``` When calling Kimi Code CLI programmatically, you can also pass the complete configuration content directly via the `--config` flag: ```sh kimi --config '{"default_model": "kimi-for-coding", "providers": {...}, "models": {...}}' ``` ## Config items The configuration file contains the following top-level configuration items: | Item | Type | Description | | --- | --- | --- | | `default_model` | `string` | Default model name, must be a model defined in `models` | | `default_thinking` | `boolean` | Whether to enable thinking mode by default (defaults to `false`) | | `default_yolo` | `boolean` | Whether to enable YOLO (auto-approve) mode by default (defaults to `false`) | | `default_editor` | `string` | Default external editor command (e.g. `"vim"`, `"code --wait"`), auto-detects when empty | | `providers` | `table` | API provider configuration | | `models` | `table` | Model configuration | | `loop_control` | `table` | Agent loop control parameters | | `background` | `table` | Background task runtime parameters | | `services` | `table` | External service configuration (search, fetch) | | `mcp` | `table` | MCP client configuration | ### Complete configuration example ```toml default_model = "kimi-for-coding" default_thinking = false default_yolo = false default_editor = "" [providers.kimi-for-coding] type = "kimi" base_url = "https://api.kimi.com/coding/v1" api_key = "sk-xxx" [models.kimi-for-coding] provider = "kimi-for-coding" model = "kimi-for-coding" max_context_size = 262144 [loop_control] max_steps_per_turn = 100 max_retries_per_step = 3 max_ralph_iterations = 0 reserved_context_size = 50000 compaction_trigger_ratio = 0.85 [background] max_running_tasks = 4 keep_alive_on_exit = false [services.moonshot_search] base_url = "https://api.kimi.com/coding/v1/search" api_key = "sk-xxx" [services.moonshot_fetch] base_url = "https://api.kimi.com/coding/v1/fetch" api_key = "sk-xxx" [mcp.client] tool_call_timeout_ms = 60000 ``` ### `providers` `providers` defines API provider connection information. Each provider uses a unique name as key. | Field | Type | Required | Description | | --- | --- | --- | --- | | `type` | `string` | Yes | Provider type, see [Providers](./providers.md) for details | | `base_url` | `string` | Yes | API base URL | | `api_key` | `string` | Yes | API key | | `env` | `table` | No | Environment variables to set before creating provider instance | | `custom_headers` | `table` | No | Custom HTTP headers to attach to requests | Example: ```toml [providers.moonshot-cn] type = "kimi" base_url = "https://api.moonshot.cn/v1" api_key = "sk-xxx" custom_headers = { "X-Custom-Header" = "value" } ``` ### `models` `models` defines available models. Each model uses a unique name as key. | Field | Type | Required | Description | | --- | --- | --- | --- | | `provider` | `string` | Yes | Provider name to use, must be defined in `providers` | | `model` | `string` | Yes | Model identifier (model name used in API) | | `max_context_size` | `integer` | Yes | Maximum context length (in tokens) | | `capabilities` | `array` | No | Model capability list, see [Providers](./providers.md#model-capabilities) for details | Example: ```toml [models.kimi-k2-thinking-turbo] provider = "moonshot-cn" model = "kimi-k2-thinking-turbo" max_context_size = 262144 capabilities = ["thinking", "image_in"] ``` ### `loop_control` `loop_control` controls agent execution loop behavior. | Field | Type | Default | Description | | --- | --- | --- | --- | | `max_steps_per_turn` | `integer` | `100` | Maximum steps per turn (alias: `max_steps_per_run`) | | `max_retries_per_step` | `integer` | `3` | Maximum retries per step | | `max_ralph_iterations` | `integer` | `0` | Extra iterations after each user message; `0` disables; `-1` is unlimited | | `reserved_context_size` | `integer` | `50000` | Reserved token count for LLM response generation; auto-compaction triggers when `context_tokens + reserved_context_size >= max_context_size` | | `compaction_trigger_ratio` | `float` | `0.85` | Context usage ratio threshold for auto-compaction (0.5–0.99); auto-compaction triggers when `context_tokens >= max_context_size * compaction_trigger_ratio`, whichever condition is met first with `reserved_context_size` | ### `background` `background` controls background task runtime behavior. Background tasks are launched via the `Shell` tool with `run_in_background=true`. | Field | Type | Default | Description | | --- | --- | --- | --- | | `max_running_tasks` | `integer` | `4` | Maximum number of concurrent background tasks | | `keep_alive_on_exit` | `boolean` | `false` | Whether to keep background tasks running when CLI exits; default is to terminate all background tasks on exit | ### `services` `services` configures external services used by Kimi Code CLI. #### `moonshot_search` Configures web search service. When enabled, the `SearchWeb` tool becomes available. | Field | Type | Required | Description | | --- | --- | --- | --- | | `base_url` | `string` | Yes | Search service API URL | | `api_key` | `string` | Yes | API key | | `custom_headers` | `table` | No | Custom HTTP headers to attach to requests | #### `moonshot_fetch` Configures web fetch service. When enabled, the `FetchURL` tool prioritizes using this service to fetch webpage content. | Field | Type | Required | Description | | --- | --- | --- | --- | | `base_url` | `string` | Yes | Fetch service API URL | | `api_key` | `string` | Yes | API key | | `custom_headers` | `table` | No | Custom HTTP headers to attach to requests | ::: tip When configuring the Kimi Code platform using the `/login` command, search and fetch services are automatically configured. ::: ### `mcp` `mcp` configures MCP client behavior. | Field | Type | Default | Description | | --- | --- | --- | --- | | `client.tool_call_timeout_ms` | `integer` | `60000` | MCP tool call timeout (milliseconds) | ## JSON configuration migration If `~/.kimi/config.toml` doesn't exist but `~/.kimi/config.json` exists, Kimi Code CLI will automatically migrate the JSON configuration to TOML format and backup the original file as `config.json.bak`. `--config-file` specified configuration files are parsed based on file extension. `--config` passed configuration content is first attempted as JSON, then falls back to TOML if that fails. ================================================ FILE: docs/en/configuration/data-locations.md ================================================ # Data Locations Kimi Code CLI stores all data in the `~/.kimi/` directory under the user's home directory. This page describes the locations and purposes of various data files. ::: tip You can customize the share directory path by setting the `KIMI_SHARE_DIR` environment variable. See [Environment Variables](./env-vars.md#kimi-share-dir) for details. Note: `KIMI_SHARE_DIR` only affects the storage location of the runtime data listed above, not the [Agent Skills](../customization/skills.md) search paths. Skills, as cross-tool shared capability extensions, are a different type of data from application runtime data. ::: ## Directory structure ``` ~/.kimi/ ├── config.toml # Main configuration file ├── kimi.json # Metadata ├── mcp.json # MCP server configuration ├── credentials/ # OAuth credentials │ └── .json ├── sessions/ # Session data │ └── / │ └── / │ ├── context.jsonl │ ├── wire.jsonl │ └── state.json ├── imported_sessions/ # Imported session data (via kimi vis) │ └── / │ ├── context.jsonl │ ├── wire.jsonl │ └── state.json ├── plans/ # Plan mode plan files │ └── .md ├── user-history/ # Input history │ └── .jsonl └── logs/ # Logs └── kimi.log ``` ## Configuration and metadata ### `config.toml` Main configuration file, stores providers, models, services, and runtime parameters. See [Config Files](./config-files.md) for details. You can specify a configuration file at a different location with the `--config-file` flag. ### `kimi.json` Metadata file, stores Kimi Code CLI's runtime state, including: - `work_dirs`: List of working directories and their last used session IDs - `thinking`: Whether thinking mode was enabled in the last session This file is automatically managed by Kimi Code CLI and typically doesn't need manual editing. ### `mcp.json` MCP server configuration file, stores MCP servers added via the `kimi mcp add` command. See [MCP](../customization/mcp.md) for details. Example structure: ```json { "mcpServers": { "context7": { "url": "https://mcp.context7.com/mcp", "transport": "http", "headers": { "CONTEXT7_API_KEY": "ctx7sk-xxx" } } } } ``` ## Credentials OAuth credentials are stored in the `~/.kimi/credentials/` directory. After logging in to your Kimi account via `/login`, OAuth tokens are saved in this directory. Files in this directory have permissions set to read/write for the current user only (600) to protect sensitive information. ## Session data Session data is grouped by working directory and stored under `~/.kimi/sessions/`. Each working directory corresponds to a subdirectory named with the path's MD5 hash, and each session corresponds to a subdirectory named with the session ID. ### `context.jsonl` Context history file, stores the session's full context in JSON Lines (JSONL) format. The first line is a system prompt record (`_system_prompt`), followed by messages (user input, model response, tool calls, etc.) and internal records (checkpoints, token usage, etc.). The system prompt is generated and frozen at session creation time, and reused on session restore instead of being regenerated. Kimi Code CLI uses this file to restore session context when using `--continue` or `--session`. ### `wire.jsonl` Wire message log file, stores Wire events during the session in JSON Lines (JSONL) format. Used for session replay and extracting session titles. ### `state.json` Session state file, stores the session's runtime state, including: - `approval`: Approval decision state (YOLO mode on/off, auto-approved operation types) - `plan_mode`: Plan mode on/off status - `plan_session_id`: Unique identifier for the current plan session, used to associate the plan file - `plan_slug`: The file path identifier for the plan (the slug in `~/.kimi/plans/.md`), preserved so restarts resume the same file - `dynamic_subagents`: Dynamically created subagent definitions - `additional_dirs`: Additional workspace directories added via `--add-dir` or `/add-dir` When resuming a session, Kimi Code CLI reads this file to restore the session state. This file uses atomic writes to prevent data corruption on crash. ## Plan files Plan mode plan files are stored in the `~/.kimi/plans/` directory. Each plan session corresponds to a randomly named Markdown file (e.g. `.md`). The `plan_slug` is saved in `state.json`, so the same plan file is resumed after a process restart. Use `/plan clear` to delete the current plan session's file. ## Input history User input history is stored in the `~/.kimi/user-history/` directory. Each working directory corresponds to a `.jsonl` file named with the path's MD5 hash. Input history is used for history browsing (up/down arrow keys) and search (Ctrl-R) in shell mode. ## Logs Runtime logs are stored in `~/.kimi/logs/kimi.log`. Default log level is INFO, use the `--debug` flag to enable TRACE level. Log files are used for troubleshooting. When reporting bugs, please include relevant log content. ## Cleaning data Deleting the share directory (default `~/.kimi/`, or the path specified by `KIMI_SHARE_DIR`) completely clears all Kimi Code CLI data, including configuration, sessions, and history. To clean only specific data: | Need | Action | | --- | --- | | Reset configuration | Delete `~/.kimi/config.toml` | | Clear all sessions | Delete `~/.kimi/sessions/` directory | | Clear sessions for specific working directory | Use `/sessions` in shell mode to view and delete | | Clear plan files | Delete `~/.kimi/plans/` directory, or use `/plan clear` in plan mode | | Clear input history | Delete `~/.kimi/user-history/` directory | | Clear logs | Delete `~/.kimi/logs/` directory | | Clear MCP configuration | Delete `~/.kimi/mcp.json` or use `kimi mcp remove` | | Clear login credentials | Delete `~/.kimi/credentials/` directory or use `/logout` | ================================================ FILE: docs/en/configuration/env-vars.md ================================================ # Environment Variables Kimi Code CLI supports overriding configuration or controlling runtime behavior through environment variables. This page lists all supported environment variables. For detailed information on how environment variables override configuration files, see [Config Overrides](./overrides.md). ## Kimi environment variables The following environment variables take effect when using `kimi` type providers, used to override provider and model configuration. | Environment Variable | Description | | --- | --- | | `KIMI_BASE_URL` | API base URL | | `KIMI_API_KEY` | API key | | `KIMI_MODEL_NAME` | Model identifier | | `KIMI_MODEL_MAX_CONTEXT_SIZE` | Maximum context length (in tokens) | | `KIMI_MODEL_CAPABILITIES` | Model capabilities, comma-separated (e.g., `thinking,image_in`) | | `KIMI_MODEL_TEMPERATURE` | Generation parameter `temperature` | | `KIMI_MODEL_TOP_P` | Generation parameter `top_p` | | `KIMI_MODEL_MAX_TOKENS` | Generation parameter `max_tokens` | ### `KIMI_BASE_URL` Overrides the provider's `base_url` field in the configuration file. ```sh export KIMI_BASE_URL="https://api.moonshot.cn/v1" ``` ### `KIMI_API_KEY` Overrides the provider's `api_key` field in the configuration file. Used to inject API keys without modifying the configuration file, suitable for CI/CD environments. ```sh export KIMI_API_KEY="sk-xxx" ``` ### `KIMI_MODEL_NAME` Overrides the model's `model` field in the configuration file (the model identifier used in API calls). ```sh export KIMI_MODEL_NAME="kimi-k2-thinking-turbo" ``` ### `KIMI_MODEL_MAX_CONTEXT_SIZE` Overrides the model's `max_context_size` field in the configuration file. Must be a positive integer. ```sh export KIMI_MODEL_MAX_CONTEXT_SIZE="262144" ``` ### `KIMI_MODEL_CAPABILITIES` Overrides the model's `capabilities` field in the configuration file. Multiple capabilities are comma-separated, supported values are `thinking`, `always_thinking`, `image_in`, and `video_in`. ```sh export KIMI_MODEL_CAPABILITIES="thinking,image_in" ``` ### `KIMI_MODEL_TEMPERATURE` Sets the generation parameter `temperature`, controlling output randomness. Higher values produce more random output, lower values produce more deterministic output. ```sh export KIMI_MODEL_TEMPERATURE="0.7" ``` ### `KIMI_MODEL_TOP_P` Sets the generation parameter `top_p` (nucleus sampling), controlling output diversity. ```sh export KIMI_MODEL_TOP_P="0.9" ``` ### `KIMI_MODEL_MAX_TOKENS` Sets the generation parameter `max_tokens`, limiting the maximum tokens per response. ```sh export KIMI_MODEL_MAX_TOKENS="4096" ``` ## OpenAI-compatible environment variables The following environment variables take effect when using `openai_legacy` or `openai_responses` type providers. | Environment Variable | Description | | --- | --- | | `OPENAI_BASE_URL` | API base URL | | `OPENAI_API_KEY` | API key | ### `OPENAI_BASE_URL` Overrides the provider's `base_url` field in the configuration file. ```sh export OPENAI_BASE_URL="https://api.openai.com/v1" ``` ### `OPENAI_API_KEY` Overrides the provider's `api_key` field in the configuration file. ```sh export OPENAI_API_KEY="sk-xxx" ``` ## Other environment variables | Environment Variable | Description | | --- | --- | | `KIMI_SHARE_DIR` | Customize the share directory path (default: `~/.kimi`) | | `KIMI_CLI_NO_AUTO_UPDATE` | Disable automatic update check | ### `KIMI_SHARE_DIR` Customize the share directory path for Kimi Code CLI. The default path is `~/.kimi`, where configuration, sessions, logs, and other runtime data are stored. ```sh export KIMI_SHARE_DIR="/path/to/custom/kimi" ``` See [Data Locations](./data-locations.md) for details. ::: warning Note `KIMI_SHARE_DIR` does not affect [Agent Skills](../customization/skills.md) search paths. Skills are cross-tool shared capability extensions (compatible with Claude, Codex, etc.), which is a different type of data from application runtime data. To override Skills paths, use the `--skills-dir` flag. ::: ### `KIMI_CLI_NO_AUTO_UPDATE` When set to `1`, `true`, `t`, `yes`, or `y` (case-insensitive), disables background auto-update check in shell mode. ```sh export KIMI_CLI_NO_AUTO_UPDATE="1" ``` ::: tip If you installed Kimi Code CLI via Nix or other package managers, this environment variable is typically set automatically since updates are handled by the package manager. ::: ================================================ FILE: docs/en/configuration/overrides.md ================================================ # Config Overrides Kimi Code CLI configuration can be set through multiple methods, with different sources overriding each other by priority. ## Priority Configuration priority from highest to lowest: 1. **Environment variables** - Highest priority, for temporary overrides or CI/CD environments 2. **CLI flags** - Flags specified at startup 3. **Configuration file** - `~/.kimi/config.toml` or file specified via `--config-file` ## CLI flags ### Configuration file related | Flag | Description | | --- | --- | | `--config ` | Pass configuration content directly, overrides default config file | | `--config-file ` | Specify configuration file path, replaces default `~/.kimi/config.toml` | `--config` and `--config-file` cannot be used together. ### Model related | Flag | Description | | --- | --- | | `--model, -m ` | Specify model name to use | The model specified by `--model` must be defined in the configuration file's `models`. If not specified, uses `default_model` from the configuration file. ### Behavior related | Flag | Description | | --- | --- | | `--thinking` | Enable thinking mode | | `--no-thinking` | Disable thinking mode | | `--yolo, --yes, -y` | Auto-approve all operations | `--thinking` / `--no-thinking` overrides the thinking state saved from the last session. If not specified, uses the last session's state. ## Environment variable overrides Environment variables can override provider and model settings without modifying the configuration file. This is particularly useful in the following scenarios: - Injecting keys in CI/CD environments - Temporarily testing different API endpoints - Switching between multiple environments Environment variables take effect based on the current provider type: - `kimi` type providers: Use `KIMI_*` environment variables - `openai_legacy` or `openai_responses` type providers: Use `OPENAI_*` environment variables - Other provider types: Environment variable overrides not supported See [Environment Variables](./env-vars.md) for the complete list. Example: ```sh KIMI_API_KEY="sk-xxx" KIMI_MODEL_NAME="kimi-k2-thinking-turbo" kimi ``` ## Configuration priority example Assume the configuration file `~/.kimi/config.toml` contains: ```toml default_model = "kimi-for-coding" [providers.kimi-for-coding] type = "kimi" base_url = "https://api.kimi.com/coding/v1" api_key = "sk-config" [models.kimi-for-coding] provider = "kimi-for-coding" model = "kimi-for-coding" max_context_size = 262144 ``` Here are the configuration sources in different scenarios: | Scenario | `base_url` | `api_key` | `model` | | --- | --- | --- | --- | | `kimi` | Config file | Config file | Config file | | `KIMI_API_KEY=sk-env kimi` | Config file | Environment variable | Config file | | `kimi --model other` | Config file | Config file | CLI flag | | `KIMI_MODEL_NAME=k2 kimi` | Config file | Config file | Environment variable | ================================================ FILE: docs/en/configuration/providers.md ================================================ # Providers and Models Kimi Code CLI supports multiple LLM platforms, which can be configured via configuration files or the `/login` command. ## Platform selection The easiest way to configure is to run the `/login` command (alias `/setup`) in shell mode and follow the wizard to select platform and model: 1. Select an API platform 2. Enter your API key 3. Select a model from the available list After configuration, Kimi Code CLI will automatically save settings to `~/.kimi/config.toml` and reload. `/login` currently supports the following platforms: | Platform | Description | | --- | --- | | Kimi Code | Kimi Code platform, supports search and fetch services | | Moonshot AI Open Platform (moonshot.cn) | China region API endpoint | | Moonshot AI Open Platform (moonshot.ai) | Global region API endpoint | For other platforms, please manually edit the configuration file. ## Provider types The `type` field in `providers` configuration specifies the API provider type. Different types use different API protocols and client implementations. | Type | Description | | --- | --- | | `kimi` | Kimi API | | `openai_legacy` | OpenAI Chat Completions API | | `openai_responses` | OpenAI Responses API | | `anthropic` | Anthropic Claude API | | `gemini` | Google Gemini API | | `vertexai` | Google Vertex AI | ### `kimi` For connecting to Kimi API, including Kimi Code and Moonshot AI Open Platform. ```toml [providers.kimi-for-coding] type = "kimi" base_url = "https://api.kimi.com/coding/v1" api_key = "sk-xxx" ``` ### `openai_legacy` For platforms compatible with OpenAI Chat Completions API, including the official OpenAI API and various compatible services. ```toml [providers.openai] type = "openai_legacy" base_url = "https://api.openai.com/v1" api_key = "sk-xxx" ``` ### `openai_responses` For OpenAI Responses API (newer API format). ```toml [providers.openai-responses] type = "openai_responses" base_url = "https://api.openai.com/v1" api_key = "sk-xxx" ``` ### `anthropic` For connecting to Anthropic Claude API. ```toml [providers.anthropic] type = "anthropic" base_url = "https://api.anthropic.com" api_key = "sk-ant-xxx" ``` ### `gemini` For connecting to Google Gemini API. ```toml [providers.gemini] type = "gemini" base_url = "https://generativelanguage.googleapis.com" api_key = "xxx" ``` ### `vertexai` For connecting to Google Vertex AI. Requires setting necessary environment variables via the `env` field. ```toml [providers.vertexai] type = "vertexai" base_url = "https://xxx-aiplatform.googleapis.com" api_key = "" env = { GOOGLE_CLOUD_PROJECT = "your-project-id" } ``` ## Model capabilities The `capabilities` field in model configuration declares the capabilities supported by the model. This affects feature availability in Kimi Code CLI. | Capability | Description | | --- | --- | | `thinking` | Supports thinking mode (deep reasoning), can be toggled | | `always_thinking` | Always uses thinking mode (cannot be disabled) | | `image_in` | Supports image input | | `video_in` | Supports video input | ```toml [models.gemini-3-pro-preview] provider = "gemini" model = "gemini-3-pro-preview" max_context_size = 262144 capabilities = ["thinking", "image_in"] ``` ### `thinking` Declares that the model supports thinking mode. When enabled, the model performs deeper reasoning before answering, suitable for complex problems. In shell mode, you can use the `/model` command to switch models and thinking mode, or control it at startup with `--thinking` / `--no-thinking` flags. ### `always_thinking` Indicates the model always uses thinking mode and cannot be disabled. For example, models with "thinking" in their name like `kimi-k2-thinking-turbo` typically have this capability. When using such models, the `/model` command won't prompt for thinking mode toggle. ### `image_in` When image input capability is enabled, you can paste images in conversations (`Ctrl-V`). ### `video_in` When video input capability is enabled, you can send video content in conversations. ## Search and fetch services The `SearchWeb` and `FetchURL` tools depend on external services, currently only provided by the Kimi Code platform. When selecting the Kimi Code platform using `/login`, search and fetch services are automatically configured. | Service | Corresponding tool | Behavior when not configured | | --- | --- | --- | | `moonshot_search` | `SearchWeb` | Tool unavailable | | `moonshot_fetch` | `FetchURL` | Falls back to local fetching | When using other platforms, the `FetchURL` tool is still available but will fall back to local fetching. ================================================ FILE: docs/en/customization/agents.md ================================================ # Agents and Subagents An agent defines the AI's behavior, including system prompts, available tools, and subagents. You can use built-in agents or create custom agents. ## Built-in agents Kimi Code CLI provides two built-in agents. You can select one at startup with the `--agent` flag: ```sh kimi --agent okabe ``` ### `default` The default agent, suitable for general use. Enabled tools: `Task`, `AskUserQuestion`, `SetTodoList`, `Shell`, `ReadFile`, `ReadMediaFile`, `Glob`, `Grep`, `WriteFile`, `StrReplaceFile`, `SearchWeb`, `FetchURL`, `EnterPlanMode`, `ExitPlanMode`, `TaskList`, `TaskOutput`, `TaskStop` ### `okabe` An experimental agent for testing new prompts and tools. Adds `SendDMail` on top of `default`. ## Custom agent files Agents are defined in YAML format. Load a custom agent with the `--agent-file` flag: ```sh kimi --agent-file /path/to/my-agent.yaml ``` **Basic structure** ```yaml version: 1 agent: name: my-agent system_prompt_path: ./system.md tools: - "kimi_cli.tools.shell:Shell" - "kimi_cli.tools.file:ReadFile" - "kimi_cli.tools.file:WriteFile" ``` **Inheritance and overrides** Use `extend` to inherit another agent's configuration and only override what you need to change: ```yaml version: 1 agent: extend: default # Inherit from default agent system_prompt_path: ./my-prompt.md # Override system prompt exclude_tools: # Exclude certain tools - "kimi_cli.tools.web:SearchWeb" - "kimi_cli.tools.web:FetchURL" ``` `extend: default` inherits from the built-in default agent. You can also specify a relative path to inherit from another agent file. **Configuration fields** | Field | Description | Required | |-------|-------------|----------| | `extend` | Agent to inherit from, can be `default` or a relative path | No | | `name` | Agent name | Yes (optional when inheriting) | | `system_prompt_path` | System prompt file path, relative to agent file | Yes (optional when inheriting) | | `system_prompt_args` | Custom arguments passed to system prompt, merged when inheriting | No | | `tools` | Tool list, format is `module:ClassName` | Yes (optional when inheriting) | | `exclude_tools` | Tools to exclude | No | | `subagents` | Subagent definitions | No | ## System prompt built-in parameters The system prompt file is a Markdown template that can use `${VAR}` syntax to reference variables. Built-in variables include: | Variable | Description | |----------|-------------| | `${KIMI_NOW}` | Current time (ISO format) | | `${KIMI_WORK_DIR}` | Working directory path | | `${KIMI_WORK_DIR_LS}` | Working directory file list | | `${KIMI_AGENTS_MD}` | AGENTS.md file content (if exists) | | `${KIMI_SKILLS}` | Loaded skills list | | `${KIMI_ADDITIONAL_DIRS_INFO}` | Information about additional directories added via `--add-dir` or `/add-dir` | You can also define custom parameters via `system_prompt_args`: ```yaml agent: system_prompt_args: MY_VAR: "custom value" ``` Then use `${MY_VAR}` in the prompt. **System prompt example** ```markdown # My Agent You are a helpful assistant. Current time: ${KIMI_NOW}. Working directory: ${KIMI_WORK_DIR} ${MY_VAR} ``` ## Defining subagents in agent files Subagents can handle specific types of tasks. After defining subagents in an agent file, the main agent can launch them via the `Task` tool: ```yaml version: 1 agent: extend: default subagents: coder: path: ./coder-sub.yaml description: "Handle coding tasks" reviewer: path: ./reviewer-sub.yaml description: "Code review expert" ``` Subagent files are also standard agent format, typically inheriting from the main agent and excluding certain tools: ```yaml # coder-sub.yaml version: 1 agent: extend: ./agent.yaml # Inherit from main agent system_prompt_args: ROLE_ADDITIONAL: | You are now running as a subagent... exclude_tools: - "kimi_cli.tools.multiagent:Task" # Exclude Task tool to avoid nesting ``` ## How subagents run Subagents launched via the `Task` tool run in an isolated context and return results to the main agent when complete. Advantages of this approach: - Isolated context, avoiding pollution of main agent's conversation history - Multiple independent tasks can be processed in parallel - Subagents can have targeted system prompts ## Dynamic subagent creation `CreateSubagent` is an advanced tool that allows AI to dynamically define new subagent types at runtime (not enabled by default). Dynamically created subagents are persisted with the session state and automatically restored when resuming the session. To use it, add to your agent file: ```yaml agent: tools: - "kimi_cli.tools.multiagent:CreateSubagent" ``` ## Built-in tools list The following are all built-in tools in Kimi Code CLI. ### `Task` - **Path**: `kimi_cli.tools.multiagent:Task` - **Description**: Dispatch a subagent to execute a task. Subagents cannot access the main agent's context; all necessary information must be provided in the prompt. | Parameter | Type | Description | |-----------|------|-------------| | `description` | string | Short task description (3-5 words) | | `subagent_name` | string | Subagent name | | `prompt` | string | Detailed task description | ### `AskUserQuestion` - **Path**: `kimi_cli.tools.ask_user:AskUserQuestion` - **Description**: Present structured questions and options to the user during execution, collecting preferences or decisions. Suitable for scenarios where the user needs to choose between approaches, resolve ambiguous instructions, or provide requirements. Should not be overused — only call when the user's choice genuinely affects subsequent actions. | Parameter | Type | Description | |-----------|------|-------------| | `questions` | array | Questions list (1–4 questions) | | `questions[].question` | string | Question text, ending with `?` | | `questions[].header` | string | Short label, max 12 characters (e.g., `Auth`, `Style`) | | `questions[].options` | array | Available options (2–4), the system adds an "Other" option automatically | | `questions[].options[].label` | string | Option label (1–5 words), append `(Recommended)` for recommended options | | `questions[].options[].description` | string | Option description | | `questions[].multi_select` | bool | Allow multiple selections, default false | ### `SetTodoList` - **Path**: `kimi_cli.tools.todo:SetTodoList` - **Description**: Manage todo list, track task progress | Parameter | Type | Description | |-----------|------|-------------| | `todos` | array | Todo list items | | `todos[].title` | string | Todo item title | | `todos[].status` | string | Status: `pending`, `in_progress`, `done` | ### `Shell` - **Path**: `kimi_cli.tools.shell:Shell` - **Description**: Execute shell commands. Requires user approval. Uses the appropriate shell for the OS (bash/zsh on Unix, PowerShell on Windows). | Parameter | Type | Description | |-----------|------|-------------| | `command` | string | Command to execute | | `timeout` | int | Timeout in seconds, default 60, max 300 for foreground / 86400 for background | | `run_in_background` | bool | Whether to run as a background task, default false | | `description` | string | Short description for the background task, required when `run_in_background=true` | When `run_in_background=true`, the command is launched as a background task and the tool immediately returns a task ID, allowing the AI to continue working. The system automatically sends a notification when the task completes. Ideal for long-running builds, tests, watchers, and servers. ### `ReadFile` - **Path**: `kimi_cli.tools.file:ReadFile` - **Description**: Read text file content. Max 1000 lines per read, max 2000 characters per line. Files outside working directory require absolute paths. | Parameter | Type | Description | |-----------|------|-------------| | `path` | string | File path | | `line_offset` | int | Starting line number, default 1 | | `n_lines` | int | Number of lines to read, default/max 1000 | ### `ReadMediaFile` - **Path**: `kimi_cli.tools.file:ReadMediaFile` - **Description**: Read image or video files. Max file size 100MB. Only available when the model supports image/video input. Files outside working directory require absolute paths. | Parameter | Type | Description | |-----------|------|-------------| | `path` | string | File path | ### `Glob` - **Path**: `kimi_cli.tools.file:Glob` - **Description**: Match files and directories by pattern. Returns max 1000 matches, patterns starting with `**` not allowed. | Parameter | Type | Description | |-----------|------|-------------| | `pattern` | string | Glob pattern (e.g., `*.py`, `src/**/*.ts`) | | `directory` | string | Search directory, defaults to working directory | | `include_dirs` | bool | Include directories, default true | ### `Grep` - **Path**: `kimi_cli.tools.file:Grep` - **Description**: Search file content with regular expressions, based on ripgrep | Parameter | Type | Description | |-----------|------|-------------| | `pattern` | string | Regular expression pattern | | `path` | string | Search path, defaults to current directory | | `glob` | string | File filter (e.g., `*.js`) | | `type` | string | File type (e.g., `py`, `js`, `go`) | | `output_mode` | string | Output mode: `files_with_matches` (default), `content`, `count_matches` | | `-B` | int | Show N lines before match | | `-A` | int | Show N lines after match | | `-C` | int | Show N lines before and after match | | `-n` | bool | Show line numbers | | `-i` | bool | Case insensitive | | `multiline` | bool | Enable multiline matching | | `head_limit` | int | Limit output lines | ### `WriteFile` - **Path**: `kimi_cli.tools.file:WriteFile` - **Description**: Write files. Requires user approval. Absolute paths are required when writing files outside the working directory. | Parameter | Type | Description | |-----------|------|-------------| | `path` | string | Absolute path | | `content` | string | File content | | `mode` | string | `overwrite` (default) or `append` | ### `StrReplaceFile` - **Path**: `kimi_cli.tools.file:StrReplaceFile` - **Description**: Edit files using string replacement. Requires user approval. Absolute paths are required when editing files outside the working directory. | Parameter | Type | Description | |-----------|------|-------------| | `path` | string | Absolute path | | `edit` | object/array | Single edit or list of edits | | `edit.old` | string | Original string to replace | | `edit.new` | string | Replacement string | | `edit.replace_all` | bool | Replace all matches, default false | ### `SearchWeb` - **Path**: `kimi_cli.tools.web:SearchWeb` - **Description**: Search the web. Requires search service configuration (auto-configured on Kimi Code platform). | Parameter | Type | Description | |-----------|------|-------------| | `query` | string | Search keywords | | `limit` | int | Number of results, default 5, max 20 | | `include_content` | bool | Include page content, default false | ### `FetchURL` - **Path**: `kimi_cli.tools.web:FetchURL` - **Description**: Fetch webpage content, returns extracted main text. Uses fetch service if configured, otherwise uses local HTTP request. | Parameter | Type | Description | |-----------|------|-------------| | `url` | string | URL to fetch | ### `Think` - **Path**: `kimi_cli.tools.think:Think` - **Description**: Let the agent record thinking process, suitable for complex reasoning scenarios | Parameter | Type | Description | |-----------|------|-------------| | `thought` | string | Thinking content | ### `SendDMail` - **Path**: `kimi_cli.tools.dmail:SendDMail` - **Description**: Send delayed message (D-Mail), for checkpoint rollback scenarios | Parameter | Type | Description | |-----------|------|-------------| | `message` | string | Message to send | | `checkpoint_id` | int | Checkpoint ID to send back to (>= 0) | ### `EnterPlanMode` - **Path**: `kimi_cli.tools.plan.enter:EnterPlanMode` - **Description**: Request to enter plan mode. After calling, an approval request is presented to the user, who can approve or reject entering plan mode. In YOLO mode, this is only used when the user explicitly requests planning or when there is significant architectural ambiguity. See [Plan mode](../guides/interaction.md#plan-mode). This tool takes no parameters. ### `ExitPlanMode` - **Path**: `kimi_cli.tools.plan:ExitPlanMode` - **Description**: Submit a plan for user approval while in plan mode. Before calling, the plan must be written to the plan file. This tool reads the plan file content and presents it to the user for approval. The user can select an implementation path (exit plan mode and start execution), reject (stay in plan mode and wait for feedback), or provide revision comments. See [Plan mode](../guides/interaction.md#plan-mode). | Parameter | Type | Description | |-----------|------|-------------| | `options` | list \| null | When the plan contains multiple alternative implementation paths, list 2–3 options for the user to choose from. Each option has a `label` (1–8 word short name, may append "(Recommended)") and an optional `description` (brief summary). The labels "Approve", "Reject", and "Revise" are reserved and cannot be used. | ### `TaskList` - **Path**: `kimi_cli.tools.background:TaskList` - **Description**: List background tasks in the current session. Useful for re-enumerating task IDs after context compaction or checking which tasks are still running. | Parameter | Type | Description | |-----------|------|-------------| | `active_only` | bool | List only active tasks, default true | | `limit` | int | Maximum number of tasks to return (1–100), default 20 | ### `TaskOutput` - **Path**: `kimi_cli.tools.background:TaskOutput` - **Description**: Retrieve output and status of a background task. Supports blocking wait or non-blocking query. Returns structured task metadata and an output preview; use `ReadFile` with the returned `output_path` to read the full log if output is truncated. | Parameter | Type | Description | |-----------|------|-------------| | `task_id` | string | Task ID to query | | `block` | bool | Whether to wait for task completion, default true | | `timeout` | int | Maximum wait time in seconds when `block=true` (0–3600), default 30 | ### `TaskStop` - **Path**: `kimi_cli.tools.background:TaskStop` - **Description**: Stop a running background task. Requires user approval. Use only when a task must be cancelled; for normal completion, wait for the automatic notification. Not available in plan mode. | Parameter | Type | Description | |-----------|------|-------------| | `task_id` | string | Task ID to stop | | `reason` | string | Reason for stopping (optional), default "Stopped by TaskStop" | ### `CreateSubagent` - **Path**: `kimi_cli.tools.multiagent:CreateSubagent` - **Description**: Dynamically create subagents | Parameter | Type | Description | |-----------|------|-------------| | `name` | string | Unique name for the subagent, used to reference in `Task` tool | | `system_prompt` | string | System prompt defining agent's role, capabilities, and boundaries | ## Tool security boundaries **Workspace scope** - File reading and writing are typically done within the working directory (and additional directories added via `--add-dir` or `/add-dir`) - Absolute paths are required when reading files outside the workspace - Write and edit operations require user approval; absolute paths are required when operating on files outside the workspace **Approval mechanism** The following operations require user approval: | Operation | Approval required | |-----------|-------------------| | Shell command execution | Each execution | | File write/edit | Each operation | | MCP tool calls | Each call | | Stop background task | Each stop | ================================================ FILE: docs/en/customization/mcp.md ================================================ # Model Context Protocol [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) is an open protocol that allows AI models to safely interact with external tools and data sources. Kimi Code CLI supports connecting to MCP servers to extend AI capabilities. ## What is MCP MCP servers provide "tools" for AI to use. For example, a database MCP server can provide query tools that allow AI to execute SQL queries; a browser MCP server can let AI control browsers for automation tasks. Kimi Code CLI has built-in tools (file read/write, shell commands, web fetching, etc.). Through MCP, you can add more tools, such as: - Accessing specific APIs or databases - Controlling browsers or other applications - Integrating with third-party services (GitHub, Linear, Notion, etc.) ## MCP server management Use the [`kimi mcp`](../reference/kimi-mcp.md) command to manage MCP servers. **Add a server** Add an HTTP server: ```sh # Basic usage kimi mcp add --transport http context7 https://mcp.context7.com/mcp # With headers kimi mcp add --transport http context7 https://mcp.context7.com/mcp \ --header "CONTEXT7_API_KEY: your-key" # Using OAuth authentication kimi mcp add --transport http --auth oauth linear https://mcp.linear.app/mcp ``` Add a stdio server (local process): ```sh kimi mcp add --transport stdio chrome-devtools -- npx chrome-devtools-mcp@latest ``` **List servers** ```sh kimi mcp list ``` While Kimi Code CLI is running, you can also enter `/mcp` to view connected servers and loaded tools. **Remove a server** ```sh kimi mcp remove context7 ``` **OAuth authorization** For servers using OAuth, you need to complete authorization first: ```sh kimi mcp auth linear ``` This will open a browser to complete the OAuth flow. After successful authorization, Kimi Code CLI will save the token for future use. **Test a server** ```sh kimi mcp test context7 ``` ## MCP configuration file MCP server configuration is stored in `~/.kimi/mcp.json`, in a format compatible with other MCP clients: ```json { "mcpServers": { "context7": { "url": "https://mcp.context7.com/mcp", "headers": { "CONTEXT7_API_KEY": "your-key" } }, "chrome-devtools": { "command": "npx", "args": ["chrome-devtools-mcp@latest"], "env": { "SOME_VAR": "value" } } } } ``` **Temporary configuration loading** Use the `--mcp-config-file` flag to load a configuration file from another location: ```sh kimi --mcp-config-file /path/to/mcp.json ``` Use the `--mcp-config` flag to pass JSON configuration directly: ```sh kimi --mcp-config '{"mcpServers": {"test": {"url": "https://..."}}}' ``` ## Loading status MCP servers initialize asynchronously after the shell UI starts, so the interface is usable immediately. The shell status bar shows live connection progress, automatically switching to a ready state once all servers are connected. The web interface also reflects each server's connection status in real time. If multiple MCP servers are configured, loading may take a moment. The status bar progress indicator keeps you informed while connections are being established. ## Security MCP tools may access and operate external systems. Be aware of security risks. **Approval mechanism** Kimi Code CLI requests user confirmation for sensitive operations (such as file modifications and command execution). MCP tools follow the same approval mechanism, with all MCP tool calls prompting for confirmation. **Prompt injection risks** Content returned by MCP tools may contain malicious instructions attempting to trick the AI into performing dangerous operations. Kimi Code CLI marks tool return content to help the AI distinguish between tool output and user instructions, but you should still: - Only use MCP servers from trusted sources - Check whether AI-proposed operations are reasonable - Keep manual approval for high-risk operations ::: warning Note In YOLO mode, MCP tool operations will also be automatically approved. It's recommended to only use YOLO mode when you fully trust the MCP servers. ::: ================================================ FILE: docs/en/customization/print-mode.md ================================================ # Print Mode Print mode lets Kimi Code CLI run non-interactively, suitable for scripting and automation scenarios. ## Basic usage Use the `--print` flag to enable print mode: ```sh # Pass instructions via -p (or -c) kimi --print -p "List all Python files in the current directory" # Pass instructions via stdin echo "Explain what this code does" | kimi --print ``` Print mode characteristics: - **Non-interactive**: Exits automatically after executing instructions - **Auto-approval**: Implicitly enables `--yolo` mode, all operations are auto-approved - **Text output**: AI responses are output to stdout ## Final message only Use the `--final-message-only` option to only output the final assistant message, skipping intermediate tool call processes: ```sh kimi --print -p "Give me a Git commit message based on the current changes" --final-message-only ``` `--quiet` is a shortcut for `--print --output-format text --final-message-only`, suitable for scenarios where only the final result is needed: ```sh kimi --quiet -p "Give me a Git commit message based on the current changes" ``` ## JSON format Print mode supports JSON format for input and output, convenient for programmatic processing. Both input and output use the [Message](#message-format) format. **JSON output** Use `--output-format=stream-json` to output in JSONL (one JSON per line) format: ```sh kimi --print -p "Hello" --output-format=stream-json ``` Example output: ```jsonl {"role":"assistant","content":"Hello! How can I help you?"} ``` If the AI called tools, assistant messages and tool messages are output sequentially: ```jsonl {"role":"assistant","content":"Let me check the current directory.","tool_calls":[{"type":"function","id":"tc_1","function":{"name":"Shell","arguments":"{\"command\":\"ls\"}"}}]} {"role":"tool","tool_call_id":"tc_1","content":"file1.py\nfile2.py"} {"role":"assistant","content":"There are two Python files in the current directory."} ``` **JSON input** Use `--input-format=stream-json` to receive JSONL format input: ```sh echo '{"role":"user","content":"Hello"}' | kimi --print --input-format=stream-json --output-format=stream-json ``` In this mode, Kimi Code CLI continuously reads from stdin, processing and outputting responses for each user message received until stdin is closed. ## Message format Both input and output use a unified message format. **User message** ```json {"role": "user", "content": "Your question or instruction"} ``` Array-form content is also supported: ```json {"role": "user", "content": [{"type": "text", "text": "Your question"}]} ``` **Assistant message** ```json {"role": "assistant", "content": "Response content"} ``` Assistant message with tool calls: ```json { "role": "assistant", "content": "Let me execute this command.", "tool_calls": [ { "type": "function", "id": "tc_1", "function": { "name": "Shell", "arguments": "{\"command\":\"ls\"}" } } ] } ``` **Tool message** ```json {"role": "tool", "tool_call_id": "tc_1", "content": "Tool execution result"} ``` ## Use cases **CI/CD integration** Auto-generate code or perform checks in CI workflows: ```sh kimi --print -p "Check if there are any obvious security issues in the src/ directory, output a JSON format report" ``` **Batch processing** Combine with shell loops for batch file processing: ```sh for file in src/*.py; do kimi --print -p "Add type annotations to $file" done ``` **Integration with other tools** Use as a backend for other tools, communicating via JSON format: ```sh my-tool | kimi --print --input-format=stream-json --output-format=stream-json | process-output ``` ================================================ FILE: docs/en/customization/skills.md ================================================ # Agent Skills [Agent Skills](https://agentskills.io/) is an open format for adding specialized knowledge and workflows to AI agents. Kimi Code CLI supports loading Agent Skills to extend AI capabilities. ## What are Agent Skills A skill is a directory containing a `SKILL.md` file. When Kimi Code CLI starts, it discovers all skills and injects their names, paths, and descriptions into the system prompt. The AI will decide on its own whether to read the specific `SKILL.md` file to get detailed guidance based on the current task's needs. For example, you can create a "code style" skill to tell the AI your project's naming conventions, comment styles, etc.; or create a "security audit" skill to have the AI focus on specific security issues when reviewing code. ## Skill discovery Kimi Code CLI uses a layered loading mechanism to discover skills, loading in the following priority order (later ones override skills with the same name): **Built-in skills** Skills shipped with the package, providing basic capabilities. **User-level skills** Stored in the user's home directory, effective across all projects. Kimi Code CLI checks the following directories in priority order and uses the first one that exists: 1. `~/.config/agents/skills/` (recommended) 2. `~/.agents/skills/` 3. `~/.kimi/skills/` 4. `~/.claude/skills/` 5. `~/.codex/skills/` **Project-level skills** Stored in the project directory, only effective within that project's working directory. Kimi Code CLI checks the following directories in priority order and uses the first one that exists: 1. `.agents/skills/` (recommended) 2. `.kimi/skills/` 3. `.claude/skills/` 4. `.codex/skills/` You can also specify other directories with the `--skills-dir` flag, which skips user-level and project-level skill discovery: ```sh kimi --skills-dir /path/to/my-skills ``` ::: tip Skills paths are independent of [`KIMI_SHARE_DIR`](../configuration/env-vars.md#kimi-share-dir). `KIMI_SHARE_DIR` customizes the storage location for configuration, sessions, logs, and other runtime data, but does not affect Skills search paths. Skills are cross-tool shared capability extensions (compatible with Kimi CLI, Claude, Codex, and others), which is a different type of data from application runtime data. To override Skills paths, use the `--skills-dir` flag. ::: ## Built-in skills Kimi Code CLI includes the following built-in skills: - **kimi-cli-help**: Kimi Code CLI help. Answers questions about Kimi Code CLI installation, configuration, slash commands, keyboard shortcuts, MCP integration, providers, environment variables, and more. - **skill-creator**: Guide for creating skills. When you need to create a new skill (or update an existing skill) to extend Kimi's capabilities, you can use this skill to get detailed creation guidance and best practices. ## Creating a skill Creating a skill only requires two steps: 1. Create a subdirectory in the skills directory 2. Create a `SKILL.md` file in the subdirectory **Directory structure** A skill directory needs at least a `SKILL.md` file, and can also include auxiliary directories to organize more complex content: ``` ~/.config/agents/skills/ └── my-skill/ ├── SKILL.md # Required: main file ├── scripts/ # Optional: script files ├── references/ # Optional: reference documents └── assets/ # Optional: other resources ``` **`SKILL.md` format** `SKILL.md` uses YAML frontmatter to define metadata, followed by prompt content in Markdown format: ```markdown --- name: code-style description: My project's code style guidelines --- ## Code Style In this project, please follow these conventions: - Use 4-space indentation - Variable names use camelCase - Function names use snake_case - Every function needs a docstring - Lines should not exceed 100 characters ``` **Frontmatter fields** | Field | Description | Required | |-------|-------------|----------| | `name` | Skill name, 1-64 characters, only lowercase letters, numbers, and hyphens allowed; defaults to directory name if omitted | No | | `description` | Skill description, 1-1024 characters, explaining the skill's purpose and use cases; shows "No description provided." if omitted | No | | `license` | License name or file reference | No | | `compatibility` | Environment requirements, up to 500 characters | No | | `metadata` | Additional key-value attributes | No | **Best practices** - Keep `SKILL.md` under 500 lines, move detailed content to `scripts/`, `references/`, or `assets/` directories - Use relative paths in `SKILL.md` to reference other files - Provide clear step-by-step instructions, input/output examples, and edge case explanations ## Example skills **PowerPoint creation** ```markdown --- name: pptx description: Create and edit PowerPoint presentations --- ## PPT Creation Workflow When creating presentations, follow these steps: 1. Analyze content structure, plan slide outline 2. Choose appropriate color scheme and fonts 3. Use python-pptx library to generate .pptx files ## Design Principles - Each slide focuses on one topic - Keep text concise, use bullet points instead of long paragraphs - Maintain clear visual hierarchy with distinct titles, body, and notes - Use consistent colors, avoid more than 3 main colors ``` **Python project standards** ```markdown --- name: python-project description: Python project development standards, including code style, testing, and dependency management --- ## Python Development Standards - Use Python 3.14+ - Use ruff for code formatting and linting - Use pyright for type checking - Use pytest for testing - Use uv for dependency management Code style: - Line length limit 100 characters - Use type annotations - Public functions need docstrings ``` **Git commit conventions** ```markdown --- name: git-commits description: Git commit message conventions using Conventional Commits format --- ## Git Commit Conventions Use Conventional Commits format: type(scope): description Allowed types: feat, fix, docs, style, refactor, test, chore Examples: - feat(auth): add OAuth login support - fix(api): fix user query returning null - docs(readme): update installation instructions ``` ## Using slash commands to load a skill The `/skill:` slash command lets you save commonly used prompt templates as skills and quickly invoke them when needed. When you enter the command, Kimi Code CLI reads the corresponding `SKILL.md` file content and sends it to the Agent as a prompt. For example: - `/skill:code-style`: Load code style guidelines - `/skill:pptx`: Load PPT creation workflow - `/skill:git-commits fix user login issue`: Load Git commit conventions with an additional task description You can append additional text after the slash command, which will be added to the skill prompt as the user's specific request. ::: tip For regular conversations, the Agent will automatically decide whether to read skill content based on context, so you don't need to invoke it manually. ::: Skills allow you to codify your team's best practices and project standards, ensuring the AI always follows consistent standards. ## Flow skills Flow skills are a special skill type that embed an Agent Flow diagram in `SKILL.md`, used to define multi-step automated workflows. Unlike standard skills, flow skills are invoked via `/flow:` commands and automatically execute multiple conversation turns following the flow diagram. **Creating a flow skill** To create a flow skill, set `type: flow` in the frontmatter and include a Mermaid or D2 code block in the content: ````markdown --- name: code-review description: Code review workflow type: flow --- ```mermaid flowchart TD A([BEGIN]) --> B[Analyze code changes, list all modified files and features] B --> C{Is code quality acceptable?} C -->|Yes| D[Generate code review report] C -->|No| E[List issues and propose improvements] E --> B D --> F([END]) ``` ```` **Flow diagram format** Both Mermaid and D2 formats are supported: - **Mermaid**: Use ` ```mermaid ` code block, [Mermaid Playground](https://www.mermaidchart.com/play) can be used for editing and preview - **D2**: Use ` ```d2 ` code block, [D2 Playground](https://play.d2lang.com) can be used for editing and preview Flow diagrams must contain one `BEGIN` node and one `END` node. Regular node text is sent to the Agent as a prompt; decision nodes require the Agent to output `branch name` in the output to select the next step. **D2 format example** ``` BEGIN -> B -> C B: Analyze existing code, write design doc for XXX feature C: Review if design doc is detailed enough C -> B: No C -> D: Yes D: Start implementation D -> END ``` For multiline labels, you can use D2's block string syntax (`|md`): ``` BEGIN -> step -> END step: |md # Detailed instructions 1. Analyze code structure 2. Check for potential issues 3. Generate report | ``` **Executing a flow skill** Flow skills can be invoked in two ways: - `/flow:`: Execute the flow. The Agent will start from the `BEGIN` node and process each node according to the flow diagram definition until reaching the `END` node - `/skill:`: Like a standard skill, sends the `SKILL.md` content to the Agent as a prompt (does not automatically execute the flow) ```sh # Execute the flow /flow:code-review # Load as a standard skill /skill:code-review ``` ================================================ FILE: docs/en/customization/wire-mode.md ================================================ # Wire mode Wire mode is Kimi Code CLI's low-level communication protocol for structured bidirectional communication with external programs. ## What is Wire Wire is the message-passing layer used internally by Kimi Code CLI. When you interact via terminal, the Shell UI receives AI output through Wire and displays it; when you integrate with IDEs via ACP, the ACP server also communicates with the agent core through Wire. Wire mode (`--wire`) exposes this communication protocol, allowing external programs to interact directly with Kimi Code CLI. This is suitable for building custom UIs or embedding Kimi Code CLI into other applications. ```sh kimi --wire ``` ## Use cases Wire mode is mainly used for: - **Custom UI**: Build web, desktop, or mobile frontends for Kimi Code CLI - **Application integration**: Embed Kimi Code CLI into other applications - **Automated testing**: Programmatic testing of agent behavior ::: tip If you only need simple non-interactive input/output, [print mode](./print-mode.md) is simpler. Wire mode is for scenarios requiring full control and bidirectional communication. ::: ## Wire protocol Wire uses a JSON-RPC 2.0 based protocol for bidirectional communication via stdin/stdout. The current protocol version is `1.5`. Each message is a single line of JSON conforming to the JSON-RPC 2.0 specification. ### Protocol type definitions ```typescript /** JSON-RPC 2.0 request message base structure */ interface JSONRPCRequest { jsonrpc: "2.0" method: Method id: string params: Params } /** JSON-RPC 2.0 notification message (no id, no response needed) */ interface JSONRPCNotification { jsonrpc: "2.0" method: Method params: Params } /** JSON-RPC 2.0 success response */ interface JSONRPCSuccessResponse { jsonrpc: "2.0" id: string result: Result } /** JSON-RPC 2.0 error response */ interface JSONRPCErrorResponse { jsonrpc: "2.0" id: string error: JSONRPCError } interface JSONRPCError { code: number message: string data?: unknown } ``` ### `initialize` ::: info Added Added in Wire 1.1. Legacy clients can skip this request and send `prompt` directly. ::: - **Direction**: Client → Agent - **Type**: Request (requires response) Optional handshake request for negotiating protocol version, submitting external tool definitions, and retrieving the slash command list. ```typescript /** initialize request parameters */ interface InitializeParams { /** Protocol version */ protocol_version: string /** Client info, optional */ client?: ClientInfo /** External tool definitions, optional */ external_tools?: ExternalTool[] /** Client capabilities, optional */ capabilities?: ClientCapabilities } interface ClientCapabilities { /** Whether the client can handle QuestionRequest messages */ supports_question?: boolean /** Whether the client supports plan mode */ supports_plan_mode?: boolean } interface ClientInfo { name: string version?: string } interface ExternalTool { /** Tool name, must not conflict with built-in tools */ name: string /** Tool description */ description: string /** Parameter definition in JSON Schema format */ parameters: JSONSchema } /** initialize response result */ interface InitializeResult { /** Protocol version */ protocol_version: string /** Server info */ server: ServerInfo /** Available slash commands */ slash_commands: SlashCommandInfo[] /** External tool registration result, only returned when request includes external_tools */ external_tools?: ExternalToolsResult /** Server capabilities */ capabilities?: ServerCapabilities } interface ServerCapabilities { /** Whether the server supports sending QuestionRequest messages */ supports_question?: boolean } interface ServerInfo { name: string version: string } interface SlashCommandInfo { name: string description: string aliases: string[] } interface ExternalToolsResult { /** Successfully registered tool names */ accepted: string[] /** Failed tool registrations with reasons */ rejected: Array<{ name: string; reason: string }> } ``` **Request example** ```json {"jsonrpc": "2.0", "method": "initialize", "id": "550e8400-e29b-41d4-a716-446655440000", "params": {"protocol_version": "1.5", "client": {"name": "my-ui", "version": "1.0.0"}, "capabilities": {"supports_question": true}, "external_tools": [{"name": "open_in_ide", "description": "Open file in IDE", "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}]}} ``` **Success response example** ```json {"jsonrpc": "2.0", "id": "550e8400-e29b-41d4-a716-446655440000", "result": {"protocol_version": "1.5", "server": {"name": "Kimi Code CLI", "version": "1.14.0"}, "slash_commands": [{"name": "init", "description": "Analyze the codebase ...", "aliases": []}], "capabilities": {"supports_question": true}, "external_tools": {"accepted": ["open_in_ide"], "rejected": []}}} ``` If the server does not support the `initialize` method, the client will receive a `-32601 method not found` error and should automatically fall back to no-handshake mode. ### `prompt` - **Direction**: Client → Agent - **Type**: Request (requires response) Send user input and run an agent turn. After calling, the agent starts processing and sends `event` notifications and `request` messages during execution, returning a response only when the turn completes. ```typescript /** prompt request parameters */ interface PromptParams { /** User input, can be plain text or array of content parts */ user_input: string | ContentPart[] } /** prompt response result */ interface PromptResult { /** Turn end status */ status: "finished" | "cancelled" | "max_steps_reached" /** Number of steps executed when status is max_steps_reached */ steps?: number } ``` **Request example** ```json {"jsonrpc": "2.0", "method": "prompt", "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "params": {"user_input": "Hello"}} ``` **Success response example** ```json {"jsonrpc": "2.0", "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "result": {"status": "finished"}} ``` **Error response example** ```json {"jsonrpc": "2.0", "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "error": {"code": -32001, "message": "LLM is not set"}} ``` | code | Description | |------|-------------| | `-32000` | A turn is already in progress | | `-32001` | LLM not configured | | `-32002` | Specified LLM not supported | | `-32003` | LLM service error | ### `replay` ::: info Added Added in Wire 1.3. ::: - **Direction**: Client → Agent - **Type**: Request (requires response) Trigger a history replay. The server reads `wire.jsonl` from the session directory and re-sends the recorded `event` and `request` messages in order. Replay is read-only; clients should not respond to replayed `request` messages. If there is no history, the server returns `events: 0` and `requests: 0`. ```typescript /** replay request has no parameters, params can be empty object or omitted */ type ReplayParams = Record /** replay response result */ interface ReplayResult { /** Replay end status */ status: "finished" | "cancelled" /** Number of replayed events */ events: number /** Number of replayed requests */ requests: number } ``` **Request example** ```json {"jsonrpc": "2.0", "method": "replay", "id": "6ba7b812-9dad-11d1-80b4-00c04fd430c8"} ``` **Success response example** ```json {"jsonrpc": "2.0", "id": "6ba7b812-9dad-11d1-80b4-00c04fd430c8", "result": {"status": "finished", "events": 42, "requests": 3}} ``` ### `steer` ::: info Added Added in Wire 1.4. ::: - **Direction**: Client → Agent - **Type**: Request (requires response) Inject a user message into an active agent turn. Unlike `prompt`, `steer` does not start a new turn but injects the message into the currently running turn. The injected message is appended to the context as a standard user message after the current step finishes, allowing you to "steer" the AI's behavior before the next step begins. A `SteerInput` event is emitted when the message is consumed. ```typescript /** steer request parameters */ interface SteerParams { /** User input, can be plain text or array of content parts */ user_input: string | ContentPart[] } /** steer response result */ interface SteerResult { /** Fixed as "steered" */ status: "steered" } ``` **Request example** ```json {"jsonrpc": "2.0", "method": "steer", "id": "7ca7c810-9dad-11d1-80b4-00c04fd430c8", "params": {"user_input": "Use Python"}} ``` **Success response example** ```json {"jsonrpc": "2.0", "id": "7ca7c810-9dad-11d1-80b4-00c04fd430c8", "result": {"status": "steered"}} ``` **Error response example** If no turn is in progress: ```json {"jsonrpc": "2.0", "id": "7ca7c810-9dad-11d1-80b4-00c04fd430c8", "error": {"code": -32000, "message": "No agent turn is in progress"}} ``` ### `set_plan_mode` ::: info Added Added in Wire 1.4. ::: - **Direction**: Client → Agent - **Type**: Request (requires response) Set plan mode to a specific state. After calling, the agent updates plan mode and sends a `StatusUpdate` event with the new state. This feature requires capability negotiation: the client must declare `capabilities.supports_plan_mode: true` during `initialize` for the agent to enable plan mode tools (`EnterPlanMode`, `ExitPlanMode`). If the client does not declare support, these tools are automatically hidden from the LLM's tool list. Plan mode state is persisted to the session, so it survives process restarts and is restored when the session resumes. ```typescript /** set_plan_mode request parameters */ interface SetPlanModeParams { /** Whether to enable plan mode */ enabled: boolean } /** set_plan_mode response result */ interface SetPlanModeResult { /** Fixed as "ok" */ status: "ok" /** Plan mode state after the call */ plan_mode: boolean } ``` **Request example** ```json {"jsonrpc": "2.0", "method": "set_plan_mode", "id": "8da7d810-9dad-11d1-80b4-00c04fd430c8", "params": {"enabled": true}} ``` **Success response example** ```json {"jsonrpc": "2.0", "id": "8da7d810-9dad-11d1-80b4-00c04fd430c8", "result": {"status": "ok", "plan_mode": true}} ``` **Error response example** If plan mode is not supported in the current environment: ```json {"jsonrpc": "2.0", "id": "8da7d810-9dad-11d1-80b4-00c04fd430c8", "error": {"code": -32000, "message": "Plan mode is not supported"}} ``` ### `cancel` - **Direction**: Client → Agent - **Type**: Request (requires response) Cancel the currently running agent turn or replay. After calling, the in-progress `prompt` request will return `{"status": "cancelled"}`, and replay will return `{"status": "cancelled"}` with the message counts sent so far. ```typescript /** cancel request has no parameters, params can be empty object or omitted */ type CancelParams = Record /** cancel response result is empty object */ type CancelResult = Record ``` **Request example** ```json {"jsonrpc": "2.0", "method": "cancel", "id": "6ba7b811-9dad-11d1-80b4-00c04fd430c8"} ``` **Success response example** ```json {"jsonrpc": "2.0", "id": "6ba7b811-9dad-11d1-80b4-00c04fd430c8", "result": {}} ``` **Error response example** If no turn is in progress: ```json {"jsonrpc": "2.0", "id": "6ba7b811-9dad-11d1-80b4-00c04fd430c8", "error": {"code": -32000, "message": "No agent turn is in progress"}} ``` ### `event` - **Direction**: Agent → Client - **Type**: Notification (no response needed) Events emitted by the agent during a turn. No `id` field, client doesn't need to respond. ```typescript /** event notification parameters, contains serialized Wire message */ interface EventParams { type: string payload: object } ``` **Example** ```json {"jsonrpc": "2.0", "method": "event", "params": {"type": "ContentPart", "payload": {"type": "text", "text": "Hello"}}} ``` ### `request` - **Direction**: Agent → Client - **Type**: Request (requires response) Requests from the agent to the client, used for approval confirmation or external tool calls. The client must respond before the agent can continue execution. ```typescript /** request parameters, contains serialized Wire message */ interface RequestParams { type: "ApprovalRequest" | "ToolCallRequest" | "QuestionRequest" payload: ApprovalRequest | ToolCallRequest | QuestionRequest } ``` **Approval request example** ```json {"jsonrpc": "2.0", "method": "request", "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "params": {"type": "ApprovalRequest", "payload": {"id": "approval-1", "tool_call_id": "tc-1", "sender": "Shell", "action": "run shell command", "description": "Run command `ls`", "display": []}}} ``` **Approval response example** ```json {"jsonrpc": "2.0", "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "result": {"request_id": "approval-1", "response": "approve"}} ``` **External tool call request example** ```json {"jsonrpc": "2.0", "method": "request", "id": "a3bb189e-8bf9-3888-9912-ace4e6543002", "params": {"type": "ToolCallRequest", "payload": {"id": "tc-1", "name": "open_in_ide", "arguments": "{\"path\":\"README.md\"}"}}} ``` **External tool call response example** ```json {"jsonrpc": "2.0", "id": "a3bb189e-8bf9-3888-9912-ace4e6543002", "result": {"tool_call_id": "tc-1", "return_value": {"is_error": false, "output": "Opened", "message": "Opened README.md in IDE", "display": []}}} ``` ### Standard error codes All requests may return JSON-RPC 2.0 standard errors: | code | Description | |------|-------------| | `-32700` | Invalid JSON format | | `-32600` | Invalid request (e.g., missing required fields) | | `-32601` | Method not found | | `-32602` | Invalid method parameters | | `-32603` | Internal error | ## Wire message types Wire messages are transmitted via `event` and `request` methods, in format `{"type": "...", "payload": {...}}`. The following describes all message types using TypeScript-style type definitions. ```typescript /** Union type of all Wire messages */ type WireMessage = Event | Request /** Events: sent via event method, no response needed */ type Event = | TurnBegin | TurnEnd | StepBegin | StepInterrupted | CompactionBegin | CompactionEnd | StatusUpdate | ContentPart | ToolCall | ToolCallPart | ToolResult | ApprovalResponse | SubagentEvent | SteerInput /** Requests: sent via request method, require response */ type Request = ApprovalRequest | ToolCallRequest | QuestionRequest ``` ### `TurnBegin` Turn started. ```typescript interface TurnBegin { /** User input, can be plain text or array of content parts */ user_input: string | ContentPart[] } ``` ### `TurnEnd` ::: info Added Added in Wire 1.2. ::: Turn ended. This event is sent after all other events in the turn. If the turn is interrupted, this event may be omitted. ```typescript interface TurnEnd { // No additional fields } ``` ### `StepBegin` Step started. ```typescript interface StepBegin { /** Step number, starting from 1 */ n: number } ``` ### `StepInterrupted` Step interrupted, no additional fields. ### `CompactionBegin` Context compaction started, no additional fields. ### `CompactionEnd` Context compaction ended, no additional fields. ### `StatusUpdate` Status update. ```typescript interface StatusUpdate { /** Context usage ratio, float between 0-1, may be absent in JSON */ context_usage?: number | null /** Number of tokens currently in the context, may be absent in JSON */ context_tokens?: number | null /** Maximum number of tokens the context can hold, may be absent in JSON */ max_context_tokens?: number | null /** Token usage stats for current step, may be absent in JSON */ token_usage?: TokenUsage | null /** Message ID for current step, may be absent in JSON */ message_id?: string | null /** Whether plan mode (read-only) is active, null means no change, may be absent in JSON */ plan_mode?: boolean | null } interface TokenUsage { /** Input tokens excluding input_cache_read and input_cache_creation */ input_other: number /** Total output tokens */ output: number /** Cached input tokens */ input_cache_read: number /** Input tokens used for cache creation, currently only Anthropic API supports this field */ input_cache_creation: number } ``` ### `ContentPart` Message content part. Serialized with `type` as `"ContentPart"`, specific type distinguished by `payload.type`. ```typescript type ContentPart = | TextPart | ThinkPart | ImageURLPart | AudioURLPart | VideoURLPart interface TextPart { type: "text" /** Text content */ text: string } interface ThinkPart { type: "think" /** Thinking content */ think: string /** Encrypted thinking content or signature, may be absent in JSON */ encrypted?: string | null } interface ImageURLPart { type: "image_url" image_url: { /** Image URL, can be data URI (e.g., data:image/png;base64,...) */ url: string /** Image ID for distinguishing different images, may be absent in JSON */ id?: string | null } } interface AudioURLPart { type: "audio_url" audio_url: { /** Audio URL, can be data URI (e.g., data:audio/aac;base64,...) */ url: string /** Audio ID for distinguishing different audio, may be absent in JSON */ id?: string | null } } interface VideoURLPart { type: "video_url" video_url: { /** Video URL, can be data URI (e.g., data:video/mp4;base64,...) */ url: string /** Video ID for distinguishing different video, may be absent in JSON */ id?: string | null } } ``` ### `ToolCall` Tool call. ```typescript interface ToolCall { /** Fixed as "function" */ type: "function" /** Tool call ID */ id: string function: { /** Tool name */ name: string /** JSON-format argument string, may be absent in JSON */ arguments?: string | null } /** Extra info, may be absent in JSON */ extras?: object | null } ``` ### `ToolCallPart` Tool call argument fragment (streaming). ```typescript interface ToolCallPart { /** Argument fragment for streaming tool call arguments, may be absent in JSON */ arguments_part?: string | null } ``` ### `ToolResult` Tool execution result. ```typescript interface ToolResult { /** Corresponding tool call ID */ tool_call_id: string return_value: ToolReturnValue } interface ToolReturnValue { /** Whether this is an error */ is_error: boolean /** Output content returned to model */ output: string | ContentPart[] /** Explanatory message for model */ message: string /** Display blocks shown to user */ display: DisplayBlock[] /** Extra debug info, may be absent in JSON */ extras?: object | null } ``` ### `ApprovalResponse` ::: info Changed Renamed in Wire 1.1. Formerly `ApprovalRequestResolved`. The old name is still accepted for backwards compatibility. ::: Approval response event, indicates an approval request has been completed. ```typescript interface ApprovalResponse { /** Approval request ID */ request_id: string /** Approval result */ response: "approve" | "approve_for_session" | "reject" } ``` ### `SubagentEvent` Subagent event. ```typescript interface SubagentEvent { /** Associated Task tool call ID */ task_tool_call_id: string /** Event from subagent, nested Wire message format */ event: { type: string; payload: object } } ``` ### `SteerInput` ::: info Added Added in Wire 1.5. ::: Indicates that the user appended follow-up input to the current running turn. This event is emitted after the current step finishes and the input is appended to context, before the next step begins. ```typescript interface SteerInput { /** User input, can be plain text or array of content parts */ user_input: string | ContentPart[] } ``` ### `ApprovalRequest` Approval request, sent via `request` method, client must respond before agent can continue. ```typescript interface ApprovalRequest { /** Request ID, used when responding */ id: string /** Associated tool call ID */ tool_call_id: string /** Sender (tool name) */ sender: string /** Action description */ action: string /** Detailed description */ description: string /** Display blocks shown to user, may be absent in JSON, defaults to [] */ display?: DisplayBlock[] } ``` **Response format** Client needs to return `ApprovalResponse` as the response result: ```typescript interface ApprovalResponse { request_id: string response: "approve" | "approve_for_session" | "reject" } ``` | response | Description | |----------|-------------| | `approve` | Approve this operation | | `approve_for_session` | Approve similar operations for this session | | `reject` | Reject operation | ### `ToolCallRequest` External tool call request, sent via `request` method. When the agent calls an external tool registered via `initialize`, this request is sent. The client must execute the tool and return a `ToolResult`. ```typescript interface ToolCallRequest { /** Tool call ID */ id: string /** Tool name */ name: string /** JSON-format argument string, may be absent in JSON */ arguments?: string | null } ``` **Response format** Client needs to return `ToolResult` as the response result: ```typescript interface ToolResult { tool_call_id: string return_value: ToolReturnValue } ``` ### `QuestionRequest` ::: info Added Added in Wire 1.4. ::: Structured question request, sent via `request` method. When the agent uses the `AskUserQuestion` tool, this request is sent. The client must respond before the agent can continue execution. This feature requires capability negotiation: the client must declare `capabilities.supports_question: true` during `initialize` for the agent to send `QuestionRequest`. If the client does not declare support, the `AskUserQuestion` tool is automatically hidden from the LLM's tool list, preventing the LLM from invoking unsupported interactions. ```typescript interface QuestionRequest { /** Request ID, used when responding */ id: string /** Associated tool call ID */ tool_call_id: string /** Questions list (1–4 questions) */ questions: QuestionItem[] } interface QuestionItem { /** Question text */ question: string /** Short label, max 12 characters */ header?: string /** Available options (2–4) */ options: QuestionOption[] /** Whether multiple options can be selected */ multi_select?: boolean } interface QuestionOption { /** Option label */ label: string /** Option description */ description?: string } ``` **Request example** ```json {"jsonrpc": "2.0", "method": "request", "id": "b1a2c3d4-e5f6-7890-abcd-ef1234567890", "params": {"type": "QuestionRequest", "payload": {"id": "q-1", "tool_call_id": "tc-1", "questions": [{"question": "Which language should I use?", "header": "Lang", "options": [{"label": "Python", "description": "Widely used, large ecosystem"}, {"label": "Rust", "description": "High performance, memory safe"}], "multi_select": false}]}}} ``` **Response format** Client needs to return `QuestionResponse` as the response result: ```typescript interface QuestionResponse { /** Corresponding request ID */ request_id: string /** Answer mapping, key is question text, value is selected option label(s) (comma-separated for multi-select) */ answers: Record } ``` **Response example** ```json {"jsonrpc": "2.0", "id": "b1a2c3d4-e5f6-7890-abcd-ef1234567890", "result": {"request_id": "q-1", "answers": {"Which language should I use?": "Python"}}} ``` If the client does not support structured questions or the user dismisses the question panel, return empty `answers`: ```json {"jsonrpc": "2.0", "id": "b1a2c3d4-e5f6-7890-abcd-ef1234567890", "result": {"request_id": "q-1", "answers": {}}} ``` ### `DisplayBlock` Display block types used in the `display` field of `ToolResult` and `ApprovalRequest`. ```typescript type DisplayBlock = | UnknownDisplayBlock | BriefDisplayBlock | DiffDisplayBlock | TodoDisplayBlock | ShellDisplayBlock /** Fallback for unrecognized display block types */ interface UnknownDisplayBlock { /** Any type identifier */ type: string /** Raw data */ data: object } interface BriefDisplayBlock { type: "brief" /** Brief text content */ text: string } interface DiffDisplayBlock { type: "diff" /** File path */ path: string /** Original content */ old_text: string /** New content */ new_text: string } interface TodoDisplayBlock { type: "todo" /** Todo list items */ items: TodoDisplayItem[] } interface TodoDisplayItem { /** Todo item title */ title: string /** Status */ status: "pending" | "in_progress" | "done" } interface ShellDisplayBlock { type: "shell" /** Language identifier for syntax highlighting (e.g., "sh", "powershell") */ language: string /** Shell command content */ command: string } ``` ## Kimi Agent (Rust) Wire server ::: warning Note Kimi Agent is currently experimental. APIs and behavior may change in future releases. ::: Kimi Agent (Rust) is the Rust implementation of the Kimi Code CLI kernel, designed specifically for Wire mode. If you only need the Wire protocol service, Kimi Agent (Rust) offers a more lightweight alternative. The Rust implementation lives in [`MoonshotAI/kimi-agent-rs`](https://github.com/MoonshotAI/kimi-agent-rs). ### Features - **Full Wire protocol compatibility**: Uses the same Wire protocol as Python's `kimi --wire`, existing clients need no modifications - **Smaller footprint**: Single statically-linked binary, no Python runtime required - **Faster startup**: Native compilation provides faster startup times - **Same configuration**: Uses the same config file (`~/.kimi/config.toml`) and session directories ### Limitations - **Wire mode only**: No Shell/Print/ACP UI - **Kimi provider only**: Does not support OpenAI, Anthropic, or other providers - **No Kimi account login**: No `login`/`logout` subcommands or `/login`, `/logout` slash commands; requires manual API key configuration - **No `--prompt`/`--command`**: Wire server does not accept initial prompts - **Local execution only**: No SSH Kaos support - **Different MCP OAuth storage**: Kimi Agent stores credentials in `~/.kimi/credentials/mcp_auth.json`, while Python version uses `~/.fastmcp/oauth-mcp-client-cache/`; they are incompatible ### Installation Download pre-built binaries from [GitHub Releases](https://github.com/MoonshotAI/kimi-agent-rs/releases): ```sh # macOS (Apple Silicon) curl -L https://github.com/MoonshotAI/kimi-agent-rs/releases/latest/download/kimi-agent-aarch64-apple-darwin.tar.gz | tar xz sudo mv kimi-agent /usr/local/bin/ # Linux (x86_64) curl -L https://github.com/MoonshotAI/kimi-agent-rs/releases/latest/download/kimi-agent-x86_64-unknown-linux-gnu.tar.gz | tar xz sudo mv kimi-agent /usr/local/bin/ ``` ### Usage Kimi Agent runs in Wire mode by default: ```sh kimi-agent ``` Common options are the same as the `kimi` command: ```sh # Specify work directory kimi-agent --work-dir /path/to/project # Continue previous session kimi-agent --continue # Use specific session kimi-agent --session # Use specific model kimi-agent --model k2 # YOLO mode (skip approvals) kimi-agent --yolo ``` Subcommands: ```sh # Show version and environment info kimi-agent info # Manage MCP servers kimi-agent mcp list kimi-agent mcp add [args...] kimi-agent mcp remove ``` ### Version synchronization Kimi Agent is released independently from Kimi Code CLI. See `MoonshotAI/kimi-agent-rs` release notes for compatibility and sync status. ================================================ FILE: docs/en/faq.md ================================================ # FAQ ## Installation and authentication ### Empty model list during `/login` If you see "No models available for the selected platform" error when running the `/login` (or `/setup`) command, it may be due to: - **Invalid or expired API key**: Check if your API key is correct and still valid. - **Network connection issues**: Confirm you can access the API service addresses (such as `api.kimi.com` or `api.moonshot.cn`). ### Invalid API key Possible reasons for an invalid API key: - **Key input error**: Check for extra spaces or missing characters. - **Key expired or revoked**: Confirm the key status in the platform console. - **Environment variable override**: Check if `KIMI_API_KEY` or `OPENAI_API_KEY` environment variables are overriding the key in the config file. You can run `echo $KIMI_API_KEY` to check. ### Membership expired or quota exhausted If you're using the Kimi Code platform, you can check your current quota and membership status with the `/usage` command. If the quota is exhausted or membership expired, you need to renew or upgrade at [Kimi Code](https://kimi.com/coding). ## Interaction issues ### `cd` command doesn't work in shell mode Executing the `cd` command in shell mode won't change Kimi Code CLI's working directory. This is because each shell command executes in an independent subprocess, and directory changes only take effect within that process. If you need to change working directory: - **Exit and restart**: Run the `kimi` command again in the target directory. - **Use `--work-dir` flag**: Specify working directory at startup, like `kimi --work-dir /path/to/project`. - **Use absolute paths in commands**: Execute commands with absolute paths directly, like `ls /path/to/dir`. ### Image paste fails When using `Ctrl-V` to paste an image, if you see "Current model does not support image input", it means the current model doesn't support image input. Solutions: - **Switch to an image-capable model**: Use a model that supports the `image_in` capability. - **Check clipboard content**: Make sure the clipboard contains actual image data, not just a file path to an image. ## ACP issues ### IDE cannot connect to Kimi Code CLI If your IDE (like Zed or JetBrains IDEs) cannot connect to Kimi Code CLI, check the following: - **Confirm Kimi Code CLI is installed**: Run `kimi --version` to confirm successful installation. - **Check configuration path**: Ensure the Kimi Code CLI path in IDE configuration is correct. You can typically use `kimi acp` as the command. - **Check uv path**: If installed via uv, ensure `~/.local/bin` is in PATH. You can use an absolute path like `/Users/yourname/.local/bin/kimi acp`. - **Check logs**: Examine error messages in `~/.kimi/logs/kimi.log`. ## MCP issues ### MCP server startup fails After adding an MCP server, if tools aren't loaded or there are errors, it may be due to: - **Command doesn't exist**: For stdio type servers, ensure the command (like `npx`) is in PATH. You can configure with an absolute path. - **Configuration format error**: Check if `~/.kimi/mcp.json` is valid JSON. Run `kimi mcp list` to view current configuration. Debugging steps: ```sh # View configured servers kimi mcp list # Test if server is working kimi mcp test ``` ### OAuth authorization fails For MCP servers that require OAuth authorization (like Linear), if authorization fails: - **Check network connection**: Ensure you can access the authorization server. - **Re-authorize**: Run `kimi mcp auth ` to authorize again. - **Reset authorization**: If authorization info is corrupted, run `kimi mcp reset-auth ` to clear it and retry. ### Header format error When adding HTTP type MCP servers, header format should be `KEY: VALUE` (with a space after the colon). For example: ```sh # Correct kimi mcp add --transport http context7 https://mcp.context7.com/mcp --header "CONTEXT7_API_KEY: your-key" # Wrong (missing space or using equals sign) kimi mcp add --transport http context7 https://mcp.context7.com/mcp --header "CONTEXT7_API_KEY=your-key" ``` ## Print/Wire mode issues ### Invalid JSONL input format When using `--input-format stream-json`, input must be valid JSONL (one JSON object per line). Common issues: - **JSON format error**: Ensure each line is a complete JSON object without syntax errors. - **Encoding issues**: Ensure input uses UTF-8 encoding. - **Line ending issues**: Windows users should check if line endings are `\n` rather than `\r\n`. Correct input format example: ```json {"role": "user", "content": "Hello"} ``` ### No output in print mode If there's no output in `--print` mode, it may be: - **No input provided**: You need to provide input via `--prompt` (or `--command`) or stdin. For example: `kimi --print --prompt "Hello"`. - **Output is buffered**: Try using `--output-format stream-json` for streaming output. - **Configuration incomplete**: Ensure API key and model are configured via `/login`. ## Updates and upgrades ### macOS slow first run macOS's Gatekeeper security mechanism checks new programs on first run, causing slow startup. Solutions: - **Wait for check to complete**: Be patient on first run; subsequent launches will return to normal. - **Add to Developer Tools**: Add your terminal application in "System Settings → Privacy & Security → Developer Tools". ### How to upgrade Kimi Code CLI Use uv to upgrade to the latest version: ```sh uv tool upgrade kimi-cli --no-cache ``` Adding `--no-cache` ensures you get the latest version. ### How to disable auto-update check If you don't want Kimi Code CLI to check for updates in the background, set the environment variable: ```sh export KIMI_CLI_NO_AUTO_UPDATE=1 ``` You can add this line to your shell configuration file (like `~/.zshrc` or `~/.bashrc`). ================================================ FILE: docs/en/guides/getting-started.md ================================================ # Getting Started ## What is Kimi Code CLI Kimi Code CLI is an AI agent that runs in the terminal, helping you complete software development tasks and terminal operations. It can read and edit code, execute shell commands, search and fetch web pages, and autonomously plan and adjust actions during execution. Kimi Code CLI is suited for: - **Writing and modifying code**: Implementing new features, fixing bugs, refactoring code - **Understanding projects**: Exploring unfamiliar codebases, answering architecture and implementation questions - **Automating tasks**: Batch processing files, running builds and tests, executing scripts Kimi Code CLI supports the following usage modes: - **[Interactive CLI (`kimi`)](../reference/kimi-command.md)**: Chat with AI in the terminal using natural language or execute shell commands directly - **[Browser UI (`kimi web`)](../reference/kimi-web.md)**: Open a graphical interface in your local browser, with session management, file references, code highlighting, and more - **[Agent integration (`kimi acp`)](../reference/kimi-acp.md)**: Run as a service and integrate with [IDEs](./ides.md) and other local agent clients via the [Agent Client Protocol] ::: info Tip If you encounter issues or have suggestions, please provide feedback on [GitHub Issues](https://github.com/MoonshotAI/kimi-cli/issues). ::: [Agent Client Protocol]: https://agentclientprotocol.com/ ## Installation Run the installation script to complete the installation. The script will first install [uv](https://docs.astral.sh/uv/) (a Python package manager), then install Kimi Code CLI via uv: ```sh # Linux / macOS curl -LsSf https://code.kimi.com/install.sh | bash ``` ```powershell # Windows (PowerShell) Invoke-RestMethod https://code.kimi.com/install.ps1 | Invoke-Expression ``` Verify the installation: ```sh kimi --version ``` ::: tip Due to macOS security checks, the first run of the `kimi` command may take longer. You can add your terminal application in "System Settings → Privacy & Security → Developer Tools" to speed up subsequent launches. ::: If you already have uv installed, you can also run: ```sh uv tool install --python 3.13 kimi-cli ``` ::: tip Kimi Code CLI supports Python 3.12–3.14, with Python 3.13 recommended. ::: ## Upgrade and uninstall Upgrade to the latest version: ```sh uv tool upgrade kimi-cli --no-cache ``` Uninstall Kimi Code CLI: ```sh uv tool uninstall kimi-cli ``` ## First run Run the `kimi` command in the project directory where you want to work to start Kimi Code CLI: ```sh cd your-project kimi ``` On first launch, you need to configure your API source. Enter the `/login` command to start configuration: ``` /login ``` After execution, first select a platform. We recommend **Kimi Code**, which automatically opens a browser for OAuth authorization; selecting other platforms requires entering an API key. After configuration, Kimi Code CLI will automatically save the settings and reload. See [Providers](../configuration/providers.md) for details. Now you can chat with Kimi Code CLI directly using natural language. Try describing a task you want to complete, for example: ``` Show me the directory structure of this project ``` ::: tip If the project doesn't have an `AGENTS.md` file, you can run the `/init` command to have Kimi Code CLI analyze the project and generate this file, helping the AI better understand the project structure and conventions. ::: Enter `/help` to view all available [slash commands](../reference/slash-commands.md) and usage tips. ================================================ FILE: docs/en/guides/ides.md ================================================ # Using in IDEs Kimi Code CLI supports integration with IDEs through the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/), allowing you to use AI-assisted programming directly within your editor. ## Prerequisites Before configuring your IDE, make sure you have installed Kimi Code CLI and completed the `/login` configuration. ## Using in Zed [Zed](https://zed.dev/) is a modern IDE that supports ACP. Add the following to Zed's configuration file `~/.config/zed/settings.json`: ```json { "agent_servers": { "Kimi Code CLI": { "type": "custom", "command": "kimi", "args": ["acp"], "env": {} } } } ``` Configuration notes: - `type`: Fixed value `"custom"` - `command`: Path to the Kimi Code CLI command. If `kimi` is not in PATH, use the full path - `args`: Startup arguments. `acp` enables ACP mode - `env`: Environment variables, usually left empty After saving the configuration, you can create Kimi Code CLI sessions in Zed's Agent panel. ## Using in JetBrains IDEs JetBrains IDEs (IntelliJ IDEA, PyCharm, WebStorm, etc.) support ACP through the AI Chat plugin. If you don't have a JetBrains AI subscription, you can enable `llm.enable.mock.response` in the Registry to use the AI Chat feature. Press Shift twice to search for "Registry" to open it. In the AI Chat panel menu, click "Configure ACP agents" and add the following configuration: ```json { "agent_servers": { "Kimi Code CLI": { "command": "~/.local/bin/kimi", "args": ["acp"], "env": {} } } } ``` `command` needs to be the full path. You can run `which kimi` in the terminal to get it. After saving, you can select Kimi Code CLI in the AI Chat Agent selector. ================================================ FILE: docs/en/guides/integrations.md ================================================ # Integrations with Tools Besides using in the terminal and IDEs, Kimi Code CLI can also be integrated with other tools. ## Zsh plugin [zsh-kimi-cli](https://github.com/MoonshotAI/zsh-kimi-cli) is a Zsh plugin that lets you quickly switch to Kimi Code CLI in Zsh. **Installation** If you use Oh My Zsh, you can install it like this: ```sh git clone https://github.com/MoonshotAI/zsh-kimi-cli.git \ ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/kimi-cli ``` Then add the plugin in `~/.zshrc`: ```sh plugins=(... kimi-cli) ``` Reload the Zsh configuration: ```sh source ~/.zshrc ``` **Usage** After installation, press `Ctrl-X` in Zsh to quickly switch to Kimi Code CLI without manually typing the `kimi` command. ::: tip If you use other Zsh plugin managers (like zinit, zplug, etc.), please refer to the [zsh-kimi-cli repository](https://github.com/MoonshotAI/zsh-kimi-cli) README for installation instructions. ::: ================================================ FILE: docs/en/guides/interaction.md ================================================ # Interaction and Input Kimi Code CLI provides rich interaction features to help you collaborate efficiently with AI. ## Agent and shell mode Kimi Code CLI has two input modes: - **Agent mode**: The default mode, where input is sent to the AI for processing - **Shell mode**: Execute shell commands directly without leaving Kimi Code CLI Press `Ctrl-X` to switch between the two modes. The current mode is displayed in the bottom status bar. In shell mode, you can execute commands just like in a regular terminal: ```sh $ ls -la $ git status $ npm run build ``` Shell mode also supports some slash commands, including `/help`, `/exit`, `/version`, `/editor`, `/changelog`, `/feedback`, `/export`, `/import`, and `/task`. ::: warning Note In shell mode, each command executes independently. Commands that change the environment like `cd` or `export` won't affect subsequent commands. ::: ## Plan mode Plan mode is a read-only planning mode that lets the AI design an implementation plan before writing code, preventing wasted effort in the wrong direction. In plan mode, the AI can only use read-only tools (`Glob`, `Grep`, `ReadFile`) to explore the codebase — it cannot modify any files or execute commands. The AI writes its plan to a dedicated plan file, then submits it to you for approval. You can approve, reject, or provide revision feedback. ### Entering plan mode There are three ways to enter plan mode: - **Keyboard shortcut**: Press `Shift-Tab` to toggle plan mode - **Slash command**: Enter `/plan` or `/plan on` - **AI-initiated**: When facing complex tasks, the AI may request to enter plan mode via the `EnterPlanMode` tool — you can accept or decline When plan mode is active, the prompt changes to `📋` and a blue `plan` badge appears in the status bar. ### Reviewing plans When the AI finishes its plan, it submits it for approval via `ExitPlanMode`. The approval panel shows the full plan content, and you can: - **Approve / select an approach**: If the plan contains multiple alternative implementation paths, the AI lists 2–3 labeled options (e.g. "Option A", "Option B (Recommended)") for you to choose from — selecting one exits plan mode and tells the AI which path to follow. If the plan has a single path, an **Approve** button is shown instead. - **Reject**: Decline the plan, stay in plan mode, and provide feedback via conversation - **Revise**: Enter revision notes — the AI will update the plan and resubmit Press `Ctrl-E` to view the full plan content in a fullscreen pager. ### Managing plan mode Use the `/plan` command to manage plan mode: - `/plan`: Toggle plan mode - `/plan on`: Enable plan mode - `/plan off`: Disable plan mode - `/plan view`: View the current plan content - `/plan clear`: Clear the current plan file ## Thinking mode Thinking mode allows the AI to think more deeply before responding, suitable for handling complex problems. You can use the `/model` command to switch models and thinking mode. After selecting a model, if the model supports thinking mode, the system will ask whether to enable it. You can also enable it at startup with the `--thinking` flag: ```sh kimi --thinking ``` ::: tip Thinking mode requires support from the current model. Some models (like `kimi-k2-thinking-turbo`) always use thinking mode and cannot be disabled. ::: ## Sending messages while running (steer) While the AI is executing a task, you can type and send follow-up messages in the input box without waiting for the current turn to finish. This feature is called "steering" and allows you to adjust the AI's direction mid-turn. Steer messages are appended to the context after the current step completes, and the AI will see and respond to your message before the next step begins. Approval requests and question panels are also handled inline with keyboard navigation during agent execution. Any text you type in the input box during a turn but haven't yet submitted is preserved when the turn ends — it won't be lost. You can press `Enter` to send it as the next message, or continue editing. ::: tip Steer messages do not interrupt the AI's currently executing step — they are processed between steps. To interrupt immediately, use `Ctrl-C`. ::: ## Background tasks When the AI needs to run long-running commands (such as building a project, running a test suite, or starting a development server), it can launch them as background tasks. Background tasks run in a separate process, allowing the AI to continue handling other requests without waiting for the command to finish. How background tasks work: 1. The AI uses the `Shell` tool with `run_in_background=true` to launch the command 2. The tool immediately returns a task ID, and the AI continues with other work 3. When the task completes, the system automatically notifies the AI, which will inform you of the results You can use the `/task` slash command to open the interactive task browser, where you can view the status and output of all background tasks in real time. See [Slash commands reference](../reference/slash-commands.md#task) for details. ::: tip By default, up to 4 background tasks can run simultaneously. This can be adjusted in the `[background]` section of the config file. All background tasks are terminated when the CLI exits by default. See [Configuration files](../configuration/config-files.md#background). ::: ## Multi-line input Sometimes you need to enter multiple lines, such as pasting a code snippet or error log. Press `Ctrl-J` or `Alt-Enter` to insert a newline instead of sending the message immediately. After finishing your input, press `Enter` to send the complete message. ## Clipboard and media paste Press `Ctrl-V` to paste text, images, or video files from the clipboard. In agent mode, longer pasted text (over 1000 characters or 15 lines) is automatically collapsed into a `[Pasted text #n]` placeholder in the input box to keep the interface clean. The full content is still expanded and sent to the model when submitting. When using an external editor (`Ctrl-O`), placeholders are automatically expanded to the original text; unmodified portions are re-collapsed after saving. If the clipboard contains an image, Kimi Code CLI caches the image to disk and displays it as an `[image:…]` placeholder in the input box. After sending the message, the AI can see and analyze the image. If the clipboard contains a video file, its file path is inserted as text into the input box. ::: tip Image input requires the model to support the `image_in` capability. Video input requires the `video_in` capability. ::: ## Slash commands Slash commands are special instructions starting with `/`, used to execute Kimi Code CLI's built-in features, such as `/help`, `/login`, `/sessions`, etc. After typing `/`, a list of available commands will automatically appear. For the complete list of slash commands, see the [slash commands reference](../reference/slash-commands.md). ## @ path completion When you type `@` in a message, Kimi Code CLI will auto-complete file and directory paths in the working directory. This allows you to conveniently reference files in your project: ``` Check if there are any issues with @src/components/Button.tsx ``` After typing `@`, start entering the filename and matching completions will appear. Press `Tab` or `Enter` to select a completion. ## Structured questions During execution, the AI may need you to make choices to determine the next direction. In such cases, the AI will use the `AskUserQuestion` tool to present structured questions and options. The question panel displays the question description and available options. You can select using the keyboard: - Use arrow keys (up / down) to navigate options - Press `Enter` to confirm selection - Press `Space` to toggle selection in multi-select mode - Select "Other" to enter custom text - Press `Esc` to skip the question Each question supports 2–4 predefined options, and the AI will set appropriate options and descriptions based on the current task context. If there are multiple questions to answer, the panel displays them as tabs — use Left/Right arrow keys or `Tab` to switch between questions. Answered questions are marked as completed, and switching back to an answered question restores the previous selection. ::: tip The AI only uses this tool when your choice genuinely affects subsequent actions. For decisions that can be inferred from context, the AI will decide on its own and continue execution. ::: ## Approvals and confirmations When the AI needs to perform operations that may have an impact (such as modifying files or running commands), Kimi Code CLI will request your confirmation. The confirmation prompt will show operation details, including shell command and file diff previews. If the content is long and truncated, you can press `Ctrl-E` to expand and view the full content. You can choose: - **Allow**: Execute this operation - **Allow for this session**: Automatically approve similar operations in the current session (this decision is persisted with the session and automatically restored when resuming) - **Reject**: Do not execute this operation If you trust the AI's operations, or you're running Kimi Code CLI in a safe isolated environment, you can enable "YOLO mode" to automatically approve all requests: ```sh # Enable at startup kimi --yolo # Or toggle during runtime /yolo ``` You can also set `default_yolo = true` in the config file to enable YOLO mode by default on every startup. See [Configuration files](../configuration/config-files.md). When YOLO mode is enabled, a yellow YOLO badge appears in the status bar at the bottom. Enter `/yolo` again to disable it. ::: warning Note YOLO mode skips all confirmations. Make sure you understand the potential risks. It's recommended to only use this in controlled environments. ::: ================================================ FILE: docs/en/guides/sessions.md ================================================ # Sessions and Context Kimi Code CLI automatically saves your conversation history, allowing you to continue previous work at any time. ## Session resuming Each time you start Kimi Code CLI, a new session is created. While running, you can also enter the `/new` command to create and switch to a new session at any time, without exiting the program. If you want to continue a previous conversation, there are several ways: **Continue the most recent session** Use the `--continue` flag to continue the most recent session in the current working directory: ```sh kimi --continue ``` **Switch to a specific session** Use the `--session` flag to switch to a session with a specific ID: ```sh kimi --session abc123 ``` **Switch sessions during runtime** Enter `/sessions` (or `/resume`) to view all sessions in the current working directory, and use arrow keys to select the session you want to switch to: ``` /sessions ``` The list shows each session's title and last update time, helping you find the conversation you want to continue. **Startup replay** When you continue an existing session, Kimi Code CLI will replay the previous conversation history so you can quickly understand the context. During replay, previous messages and AI responses will be displayed. ## Session state persistence In addition to conversation history, Kimi Code CLI also automatically saves and restores the session's runtime state. When you resume a session, the following states are automatically restored: - **Approval decisions**: YOLO mode on/off status, operation types approved via "allow for this session" - **Plan mode**: Plan mode on/off status - **Dynamic subagents**: Subagent definitions created via the `CreateSubagent` tool during the session - **Additional directories**: Workspace directories added via `--add-dir` or `/add-dir` This means you don't need to reconfigure these settings each time you resume a session. For example, if you approved auto-execution of certain shell commands in your previous session, those approvals remain in effect after resuming. ## Export and import Kimi Code CLI supports exporting session context to a file, or importing context from external files and other sessions. **Export a session** Enter `/export` to export the current session's complete conversation history as a Markdown file: ``` /export ``` The exported file includes session metadata, a conversation overview, and the complete conversation organized by turns. You can also specify an output path: ``` /export ~/exports/my-session.md ``` **Import context** Enter `/import` to import context from a file or another session. The imported content is appended as reference information to the current session: ``` /import ./previous-session-export.md /import abc12345 ``` Common text-based file formats are supported (Markdown, source code, configuration files, etc.). You can also pass a session ID to import the complete conversation history from that session. ::: tip Exported files may contain sensitive information (such as code snippets, file paths, etc.). Please review before sharing. ::: ## Clear and compact As the conversation progresses, the context grows longer. Kimi Code CLI will automatically compress the context when needed to ensure the conversation can continue. You can also manually manage the context using slash commands: **Clear context** Enter `/clear` to clear all context in the current session and start a fresh conversation: ``` /clear ``` After clearing, the AI will forget all previous conversation content. You usually don't need to use this command; for new tasks, starting a new session is a better choice. **Compact context** Enter `/compact` to have the AI summarize the current conversation and replace the original context with the summary: ``` /compact ``` You can also append custom instructions after the command to tell the AI what content to prioritize preserving during compaction: ``` /compact keep the database-related discussion ``` Compacting preserves key information while reducing token consumption. This is useful when the conversation is long but you still want to retain some context. ::: tip The bottom status bar displays the current context usage with token counts (e.g., `context: 42.0% (4.2k/10.0k)`), helping you understand when you need to clear or compact. ::: ::: tip `/clear` and `/reset` clear the conversation context but do not reset session state (such as approval decisions, dynamic subagents, and additional directories). To start completely fresh, it's recommended to create a new session. ::: ================================================ FILE: docs/en/guides/use-cases.md ================================================ # Common Use Cases Kimi Code CLI can help you complete various software development and general tasks. Here are some typical scenarios. ## Implementing new features When you need to add new features to your project, simply describe your requirements in natural language. Kimi Code CLI will automatically read relevant code, understand the project structure, and then make modifications. ``` Add pagination to the user list page, showing 20 records per page ``` Kimi Code CLI typically works through a "Read → Edit → Verify" workflow: 1. **Read**: Search and read relevant code, understand existing implementation 2. **Edit**: Write or modify code, following the project's coding style 3. **Verify**: Run tests or builds to ensure changes don't introduce issues If you're not satisfied with the changes, you can tell Kimi Code CLI to adjust: ``` The pagination component style doesn't match the rest of the project, reference the Button component's style ``` ## Fixing bugs Describe the problem you're encountering, and Kimi Code CLI will help you locate the cause and fix it: ``` After user login, when redirecting to the home page, it occasionally shows logged out status. Help me investigate ``` For problems with clear error messages, you can paste the error log directly: ``` When running npm test, I get this error: TypeError: Cannot read property 'map' of undefined at UserList.render (src/components/UserList.jsx:15:23) Please fix it ``` You can also have Kimi Code CLI run commands to reproduce and verify the issue: ``` Run the tests, and if there are any failing cases, fix them ``` ## Understanding projects Kimi Code CLI can help you explore and understand unfamiliar codebases: ``` What's the overall architecture of this project? Where is the entry file? ``` ``` How is the user authentication flow implemented? What files are involved? ``` ``` Explain what the src/core/scheduler.py file does ``` If you encounter parts you don't understand while reading code, you can ask anytime: ``` What's the difference between useCallback and useMemo? Why use useCallback here? ``` ## Automating small tasks Kimi Code CLI can perform various repetitive small tasks: ``` Change all var declarations to const or let in .js files under the src directory ``` ``` Add documentation comments to all public functions without docstrings ``` ``` Generate unit tests for this API module ``` ``` Update all dependencies in package.json to the latest version, then run tests to make sure there are no issues ``` ## Automating general tasks Beyond code-related tasks, Kimi Code CLI can also handle some general scenarios. **Research tasks** ``` Research Python async web frameworks for me, compare the pros and cons of FastAPI, Starlette, and Sanic ``` **Data analysis** ``` Analyze the access logs in the logs directory, count the call frequency and average response time for each endpoint ``` **Batch file processing** ``` Convert all PNG images in the images directory to JPEG format, save to the output directory ``` ================================================ FILE: docs/en/index.md ================================================ --- layout: home hero: name: Kimi Code CLI text: Intelligent Command Line Assistant tagline: Technical Preview actions: - theme: brand text: Getting Started link: /en/guides/getting-started - theme: alt text: GitHub link: https://github.com/MoonshotAI/kimi-cli --- ================================================ FILE: docs/en/reference/keyboard.md ================================================ # Keyboard Shortcuts Kimi Code CLI shell mode supports the following keyboard shortcuts. ## Shortcuts list | Shortcut | Function | |----------|----------| | `Ctrl-X` | Toggle agent/shell mode | | `Shift-Tab` | Toggle plan mode (read-only research and planning) | | `Ctrl-O` | Edit in external editor (`$VISUAL`/`$EDITOR`) | | `Ctrl-J` | Insert newline | | `Alt-Enter` | Insert newline (same as `Ctrl-J`) | | `Ctrl-V` | Paste (supports images and video files) | | `Ctrl-E` | Expand full approval request content | | `1`–`3` | Quick select approval option | | `1`–`5` | Select question option by number | | `Ctrl-D` | Exit Kimi Code CLI | | `Ctrl-C` | Interrupt current operation | ## Mode switching ### `Ctrl-X`: Toggle agent/shell mode Press `Ctrl-X` in the input box to switch between two modes: - **Agent mode**: Input is sent to AI agent for processing - **Shell mode**: Input is executed as local shell command The prompt changes based on current mode: - Agent mode: `✨` (normal) or `💫` (thinking mode) - Plan mode: `📋` - Shell mode: `$` ## Plan mode ### `Shift-Tab`: Toggle plan mode Press `Shift-Tab` to enable or disable plan mode. In plan mode, the AI can only use read-only tools to explore the codebase, writing an implementation plan to a plan file and submitting it for your approval. When enabled, the prompt changes to `📋` and a blue `plan` badge appears in the status bar. You can also use the `/plan` slash command to manage plan mode. See [Plan mode](../guides/interaction.md#plan-mode) for details. ## External editor ### `Ctrl-O`: Edit in external editor Press `Ctrl-O` to open an external editor (e.g., VS Code, Vim) to edit the current input content. The editor is selected in the following priority: 1. Editor configured via `/editor` command 2. `$VISUAL` environment variable 3. `$EDITOR` environment variable 4. Auto-detect: `code --wait` (VS Code) → `vim` → `vi` → `nano` Use the `/editor` command to interactively switch editors, or specify directly, e.g., `/editor vim`. After saving and exiting the editor, the edited content replaces the current input. If you quit without saving (e.g., `:q!` in Vim), the input remains unchanged. If the input contains pasted text placeholders, the editor automatically expands them to the original text for editing; unmodified portions are re-collapsed into placeholders after saving. Useful for writing multi-line prompts, complex code snippets, etc. ## Multi-line input ### `Ctrl-J` / `Alt-Enter`: Insert newline By default, pressing `Enter` submits the input. To enter multi-line content, use: - `Ctrl-J`: Insert newline at any position - `Alt-Enter`: Insert newline at any position Useful for entering multi-line code snippets or formatted text. ## Clipboard operations ### `Ctrl-V`: Paste Paste clipboard content into the input box. Supports: - **Text**: In agent mode, text longer than 1000 characters or 15 lines is automatically collapsed into a `[Pasted text #n]` placeholder to keep the input box clean; the full content is expanded and sent to the model when submitting. When using `Ctrl-O` to open an external editor, placeholders are automatically expanded to the original text, and unmodified portions are re-collapsed after saving - **Images**: Cached to disk and displayed as an `[image:xxx.png,WxH]` placeholder; the actual image data is sent along with the message to the model (requires model image input support) - **Video files**: File path is inserted as text into the input box (requires model video input support) ::: tip Image pasting requires the model to support `image_in` capability. Video pasting requires the model to support `video_in` capability. ::: ## Approval request operations ### `Ctrl-E`: Expand full content When approval request preview content is truncated, press `Ctrl-E` to view the full content in a fullscreen pager. When preview is truncated, a "... (truncated, ctrl-e to expand)" hint is displayed. Useful for viewing longer shell commands or file diff content. ### Number key quick selection In the approval panel, press `1`–`3` to directly select and submit the corresponding approval option without navigating with arrow keys first. ## Structured question operations When the AI uses the `AskUserQuestion` tool to ask you a question, the question panel supports the following keyboard operations: | Shortcut | Function | |----------|----------| | `↑` / `↓` | Navigate options | | `←` / `→` / `Tab` | Switch between questions (multi-question mode) | | `1`–`5` | Select option by number (auto-submits for single-select, toggles for multi-select) | | `Space` | Submit selection in single-select mode, toggle selection in multi-select mode | | `Enter` | Confirm selection | | `Esc` | Skip question | When the AI asks multiple questions at once, the question panel displays them as tabs. Use `←` / `→` or `Tab` to switch between questions. Answered questions are marked as complete, and switching back to a previously answered question restores your earlier selection. ## Exit and interrupt ### `Ctrl-D`: Exit Press `Ctrl-D` when the input box is empty to exit Kimi Code CLI. ### `Ctrl-C`: Interrupt - In input box: Clear current input - During agent execution: Interrupt current operation - During slash command execution: Interrupt command ## Completion operations In agent mode, a completion menu is automatically displayed while typing: | Trigger | Completion content | |---------|-------------------| | `/` | Slash commands | | `@` | File paths in working directory | Completion operations: - Arrow keys to select - `Enter` to confirm selection - `Esc` to close menu - Continue typing to filter options ## Status bar The bottom status bar displays: - Current time - Current mode (agent/shell) and model name (in agent mode) - YOLO badge (yellow, when enabled) - Plan badge (blue, when enabled) - Shortcut hints - Context usage The status bar automatically refreshes to update information. ================================================ FILE: docs/en/reference/kimi-acp.md ================================================ # `kimi acp` Subcommand The `kimi acp` command starts a multi-session ACP (Agent Client Protocol) server. ```sh kimi acp ``` ## Description ACP is a standardized protocol that allows IDEs and other clients to interact with AI agents. ## Use cases - IDE plugin integration (e.g., JetBrains, Zed) - Custom ACP client development - Multi-session concurrent processing For using Kimi Code CLI in IDEs, see [Using in IDEs](../guides/ides.md). ## Authentication The ACP server checks user authentication status before creating or loading sessions. If the user is not logged in, the server returns an `AUTH_REQUIRED` error (code `-32000`) with available authentication method details. Upon receiving this error, the client should guide the user to run the `kimi login` command in the terminal to complete login. Once logged in, subsequent ACP requests will proceed normally. ================================================ FILE: docs/en/reference/kimi-command.md ================================================ # `kimi` Command `kimi` is the main command for Kimi Code CLI, used to start interactive sessions or execute single queries. ```sh kimi [OPTIONS] COMMAND [ARGS] ``` ## Basic information | Option | Short | Description | |--------|-------|-------------| | `--version` | `-V` | Show version number and exit | | `--help` | `-h` | Show help message and exit | | `--verbose` | | Output detailed runtime information | | `--debug` | | Log debug information (output to `~/.kimi/logs/kimi.log`) | ## Agent configuration | Option | Description | |--------|-------------| | `--agent NAME` | Use built-in agent, options: `default`, `okabe` | | `--agent-file PATH` | Use custom agent file | `--agent` and `--agent-file` are mutually exclusive. See [Agents and Subagents](../customization/agents.md) for details. ## Configuration files | Option | Description | |--------|-------------| | `--config STRING` | Load TOML/JSON configuration string | | `--config-file PATH` | Load configuration file (default `~/.kimi/config.toml`) | `--config` and `--config-file` are mutually exclusive. Both configuration strings and files support TOML and JSON formats. See [Config Files](../configuration/config-files.md) for details. ## Model selection | Option | Short | Description | |--------|-------|-------------| | `--model NAME` | `-m` | Specify LLM model, overrides default model in config file | ## Working directory | Option | Short | Description | |--------|-------|-------------| | `--work-dir PATH` | `-w` | Specify working directory (default current directory) | | `--add-dir PATH` | | Add an additional directory to the workspace scope, can be specified multiple times | The working directory determines the root directory for file operations. Relative paths work within the working directory; absolute paths are required to access files outside it. `--add-dir` expands the workspace scope to include directories outside the working directory, making all file tools able to access files in those directories. Added directories are persisted with the session state. You can also add directories at runtime via the [`/add-dir`](./slash-commands.md#add-dir) slash command. ## Session management | Option | Short | Description | |--------|-------|-------------| | `--continue` | `-C` | Continue the previous session in the current working directory | | `--session ID` | `-S` | Resume session with specified ID, creates new session if not exists | `--continue` and `--session` are mutually exclusive. ## Input and commands | Option | Short | Description | |--------|-------|-------------| | `--prompt TEXT` | `-p` | Pass user prompt, doesn't enter interactive mode | | `--command TEXT` | `-c` | Alias for `--prompt` | When using `--prompt` (or `--command`), Kimi Code CLI exits after processing the query (unless `--print` is specified, results are still displayed in interactive mode). ## Loop control | Option | Description | |--------|-------------| | `--max-steps-per-turn N` | Maximum steps per turn, overrides `loop_control.max_steps_per_turn` in config file | | `--max-retries-per-step N` | Maximum retries per step, overrides `loop_control.max_retries_per_step` in config file | | `--max-ralph-iterations N` | Number of iterations for Ralph Loop mode; `0` disables; `-1` is unlimited | ### Ralph Loop [Ralph](https://ghuntley.com/ralph/) is a technique that puts an agent in a loop: the same prompt is fed again and again so the agent can keep iterating one big task. When `--max-ralph-iterations` is not `0`, Kimi Code CLI enters Ralph Loop mode and automatically loops through task execution until the agent outputs `STOP` or the iteration limit is reached. ## UI modes | Option | Description | |--------|-------------| | `--print` | Run in print mode (non-interactive), implicitly enables `--yolo` | | `--quiet` | Shortcut for `--print --output-format text --final-message-only` | | `--acp` | Run in ACP server mode (deprecated, use `kimi acp` instead) | | `--wire` | Run in Wire server mode (experimental) | The four options are mutually exclusive, only one can be selected. Default is shell mode. See [Print Mode](../customization/print-mode.md) and [Wire Mode](../customization/wire-mode.md) for details. ## Print mode options The following options are only effective in `--print` mode: | Option | Description | |--------|-------------| | `--input-format FORMAT` | Input format: `text` (default) or `stream-json` | | `--output-format FORMAT` | Output format: `text` (default) or `stream-json` | | `--final-message-only` | Only output the final assistant message | `stream-json` format uses JSONL (one JSON object per line) for programmatic integration. ## MCP configuration | Option | Description | |--------|-------------| | `--mcp-config-file PATH` | Load MCP config file, can be specified multiple times | | `--mcp-config JSON` | Load MCP config JSON string, can be specified multiple times | Default loads `~/.kimi/mcp.json` (if exists). See [Model Context Protocol](../customization/mcp.md) for details. ## Approval control | Option | Short | Description | |--------|-------|-------------| | `--yolo` | `-y` | Auto-approve all operations | | `--yes` | | Alias for `--yolo` | | `--auto-approve` | | Alias for `--yolo` | ::: warning Note In YOLO mode, all file modifications and shell commands are automatically executed. Use with caution. ::: ## Thinking mode | Option | Description | |--------|-------------| | `--thinking` | Enable thinking mode | | `--no-thinking` | Disable thinking mode | Thinking mode requires model support. If not specified, uses the last session's setting. ## Skills configuration | Option | Description | |--------|-------------| | `--skills-dir PATH` | Specify skills directory, skipping auto-discovery | When not specified, Kimi Code CLI automatically discovers user-level and project-level skills directories in priority order. See [Agent Skills](../customization/skills.md) for details. ## Subcommands | Subcommand | Description | |------------|-------------| | [`kimi login`](#kimi-login) | Log in to your Kimi account | | [`kimi logout`](#kimi-logout) | Log out from your Kimi account | | [`kimi info`](./kimi-info.md) | Display version and protocol information | | [`kimi acp`](./kimi-acp.md) | Start multi-session ACP server | | [`kimi mcp`](./kimi-mcp.md) | Manage MCP server configuration | | [`kimi term`](./kimi-term.md) | Launch the Toad terminal UI | | [`kimi export`](#kimi-export) | Export a session as a ZIP file | | [`kimi vis`](./kimi-vis.md) | Launch the Agent Tracing Visualizer (Technical Preview) | | [`kimi web`](./kimi-web.md) | Start the Web UI server | ### `kimi login` Log in to your Kimi account. This automatically opens a browser; complete account authorization and available models will be automatically configured. ```sh kimi login ``` ### `kimi logout` Log out from your Kimi account. This clears stored OAuth credentials and removes related configuration from the config file. ```sh kimi logout ``` ### `kimi export` Export the data of a specified session as a ZIP file. The ZIP contains all files in the session directory (`context.jsonl`, `wire.jsonl`, `state.json`, etc.). ```sh kimi export [-o ] ``` | Argument / Option | Description | |--------|-------------| | `` | Session ID to export | | `--output, -o` | Output ZIP file path (defaults to `session-.zip` in the current directory) | ::: info Added Added in version 1.20. ::: ### `kimi vis` ::: warning Note Technical Preview feature, may be unstable. ::: Launch the Agent Tracing Visualizer to view and analyze session traces in a browser. ```sh kimi vis [OPTIONS] ``` | Option | Short | Description | |--------|-------|-------------| | `--port INTEGER` | `-p` | Port number to bind to (default: `5495`) | | `--open / --no-open` | | Automatically open browser (default: enabled) | | `--reload` | | Enable auto-reload (development mode) | See [Agent Tracing Visualizer](./kimi-vis.md) for details. ### `kimi web` Start the Web UI server to access Kimi Code CLI through a browser. ```sh kimi web [OPTIONS] ``` If the default port is in use, the server will pick the next available port (by default `5494`–`5503`) and print a notice in the terminal. | Option | Short | Description | |--------|-------|-------------| | `--host TEXT` | `-h` | Host address to bind to (default: `127.0.0.1`) | | `--port INTEGER` | `-p` | Port number to bind to (default: `5494`) | | `--reload` | | Enable auto-reload (development mode) | | `--open / --no-open` | | Automatically open browser (default: enabled) | Examples: ```sh # Default startup, automatically opens browser kimi web # Specify port kimi web --port 8080 # Don't automatically open browser kimi web --no-open # Bind to all network interfaces (allow LAN access) kimi web --host 0.0.0.0 ``` See [Web UI](./kimi-web.md) for details. ================================================ FILE: docs/en/reference/kimi-info.md ================================================ # `kimi info` Subcommand `kimi info` displays version and protocol information for Kimi Code CLI. ```sh kimi info [--json] ``` ## Options | Option | Description | |--------|-------------| | `--json` | Output in JSON format | ## Output | Field | Description | |-------|-------------| | `kimi_cli_version` | Kimi Code CLI version number | | `agent_spec_versions` | List of supported agent spec versions | | `wire_protocol_version` | Wire protocol version | | `python_version` | Python runtime version | ## Examples **Text output** ```sh $ kimi info kimi-cli version: 1.20.0 agent spec versions: 1 wire protocol: 1.5 python version: 3.13.1 ``` **JSON output** ```sh $ kimi info --json {"kimi_cli_version": "1.20.0", "agent_spec_versions": ["1"], "wire_protocol_version": "1.5", "python_version": "3.13.1"} ``` ================================================ FILE: docs/en/reference/kimi-mcp.md ================================================ # `kimi mcp` Subcommand `kimi mcp` is used to manage MCP (Model Context Protocol) server configurations. For concepts and usage of MCP, see [Model Context Protocol](../customization/mcp.md). ```sh kimi mcp COMMAND [ARGS] ``` ## `add` Add an MCP server configuration. ```sh kimi mcp add [OPTIONS] NAME [TARGET_OR_COMMAND...] ``` **Arguments** | Argument | Description | |----------|-------------| | `NAME` | Server name, used for identification and reference | | `TARGET_OR_COMMAND...` | URL for `http` mode; command for `stdio` mode (must start with `--`) | **Options** | Option | Short | Description | |--------|-------|-------------| | `--transport TYPE` | `-t` | Transport type: `stdio` (default) or `http` | | `--env KEY=VALUE` | `-e` | Environment variable (`stdio` only), can be specified multiple times | | `--header KEY:VALUE` | `-H` | HTTP header (`http` only), can be specified multiple times | | `--auth TYPE` | `-a` | Authentication type (e.g., `oauth`, `http` only) | ## `list` List all configured MCP servers. ```sh kimi mcp list ``` Output includes: - Configuration file path - Name, transport type, and target for each server - Authorization status for OAuth servers ## `remove` Remove an MCP server configuration. ```sh kimi mcp remove NAME ``` **Arguments** | Argument | Description | |----------|-------------| | `NAME` | Name of server to remove | ## `auth` Authorize an MCP server that uses OAuth. ```sh kimi mcp auth NAME ``` This will open a browser for the OAuth authorization flow. After successful authorization, the token is cached for future use. **Arguments** | Argument | Description | |----------|-------------| | `NAME` | Name of server to authorize | ::: tip Only servers added with `--auth oauth` require this command. ::: ## `reset-auth` Clear the cached OAuth token for an MCP server. ```sh kimi mcp reset-auth NAME ``` **Arguments** | Argument | Description | |----------|-------------| | `NAME` | Name of server to reset authorization | After clearing, you need to run `kimi mcp auth` again to re-authorize. ## `test` Test connection to an MCP server and list available tools. ```sh kimi mcp test NAME ``` **Arguments** | Argument | Description | |----------|-------------| | `NAME` | Name of server to test | Output includes: - Connection status - Number of available tools - Tool names and descriptions ================================================ FILE: docs/en/reference/kimi-term.md ================================================ # `kimi term` Subcommand The `kimi term` command launches the [Toad](https://github.com/batrachianai/toad) terminal UI, a modern terminal interface built with [Textual](https://textual.textualize.io/). ```sh kimi term [OPTIONS] ``` ## Description [Toad](https://github.com/batrachianai/toad) is a graphical terminal interface for Kimi Code CLI that communicates with the Kimi Code CLI backend via the ACP protocol. It provides a richer interactive experience with better output rendering and layout. When you run `kimi term`, it automatically starts a `kimi acp` server in the background, and Toad connects to it as an ACP client. ## Options All extra options are passed through to the internal `kimi acp` command. For example: ```sh kimi term --work-dir /path/to/project --model kimi-k2 ``` Common options: | Option | Description | |--------|-------------| | `--work-dir PATH` | Specify working directory | | `--model NAME` | Specify model | | `--yolo` | Auto-approve all operations | For the full list of options, see [`kimi` command](./kimi-command.md). ## System requirements ::: warning Note `kimi term` requires Python 3.14+. If you installed Kimi Code CLI with an older Python version, you need to reinstall with Python 3.14: ```sh uv tool install --python 3.14 kimi-cli ``` ::: ================================================ FILE: docs/en/reference/kimi-vis.md ================================================ # Agent Tracing Visualizer ::: warning Note Agent Tracing Visualizer is currently in Technical Preview and may be unstable. Features and interface may change in future releases. ::: Agent Tracing Visualizer is a browser-based visualization dashboard for inspecting and analyzing Kimi Code CLI session traces. It helps you understand agent behavior, view Wire event timelines, analyze context usage, and browse historical sessions. ## Launch Run `kimi vis` in the terminal to start the Visualizer: ```sh kimi vis ``` The server automatically opens a browser after startup. The default address is `http://127.0.0.1:5495`. If the default port is in use, the server will pick the next available port (by default `5495`–`5504`) and print the access URL in the terminal. ## Command-line options | Option | Short | Description | |--------|-------|-------------| | `--port INTEGER` | `-p` | Port number to bind to (default: `5495`) | | `--open / --no-open` | | Automatically open browser (default: `--open`) | | `--reload` | | Enable auto-reload (development mode) | Examples: ```sh # Specify port kimi vis --port 8080 # Don't automatically open browser kimi vis --no-open ``` ## Features ### Wire event timeline Displays the complete Wire event flow as a timeline, including turn start/end, step execution, tool calls and results. Supports event filtering and detailed information viewing. ### Context viewer Visualizes session context content, including user messages, assistant messages, and tool calls. Helps you understand what the agent "sees" at each step. ### Session explorer Browse and search all historical sessions, grouped by project. View detailed information for each session, including working directory, creation time, and message count. ### Session directory shortcuts At the top of the session detail page, you can use `Open Dir` to open the current session directory directly. On macOS this opens Finder; on Windows it opens Explorer. `Copy DIR` copies the raw session directory path so you can continue debugging in a terminal, editor, or issue report. ### Session download and export You can export session data as a ZIP file for offline analysis or sharing. - **ZIP download**: Click the download button in the session explorer or session detail page to download the session directory as a ZIP file - **CLI export**: Use the `kimi export ` command to export a specified session as a ZIP file ### Session import Supports importing ZIP-format session data into the Visualizer for viewing. Imported sessions are stored in a dedicated `~/.kimi/imported_sessions/` directory, separate from regular sessions. In the session explorer, you can use the "Imported" filter toggle to switch between viewing imported sessions. Imported sessions support deletion, with a confirmation dialog before removal. ### Usage statistics Displays token usage statistics and charts, including input/output token distribution and cache hit rates. ================================================ FILE: docs/en/reference/kimi-web.md ================================================ # Web UI Web UI provides a browser-based interactive interface, allowing you to use all features of Kimi Code CLI in a web page. Compared to the terminal interface, Web UI offers a richer visual experience, more flexible session management, and more convenient file operations. ## Starting Web UI Run the `kimi web` command in your terminal to start the Web UI server: ```sh kimi web ``` After the server starts, it will automatically open your browser to access the Web UI. The default address is `http://127.0.0.1:5494`. If the default port is occupied, the server will automatically try the next available port (default range `5494`–`5503`) and print the access address in the terminal. ## Command line options ### Network configuration | Option | Short | Description | |--------|-------|-------------| | `--host TEXT` | `-h` | Bind to specific IP address | | `--network` | `-n` | Enable network access (bind to `0.0.0.0`) | | `--port INTEGER` | `-p` | Specify port number (default: `5494`) | By default, Web UI only listens on the local loopback address `127.0.0.1`, allowing access only from the local machine. If you want to access Web UI from a local network or the public internet, you can use the `--network` option or specify `--host`: ```sh # Bind to all network interfaces, allowing LAN access kimi web --network # Bind to a specific IP address kimi web --host 192.168.1.100 ``` ::: warning Note When enabling network access, be sure to configure access control options (such as `--auth-token` and `--lan-only`) to ensure security. See [Access control](#access-control). ::: ### Browser control | Option | Description | |--------|-------------| | `--open / --no-open` | Automatically open browser on startup (default: `--open`) | Use `--no-open` to prevent automatically opening the browser: ```sh kimi web --no-open ``` ### Development options | Option | Description | |--------|-------------| | `--reload` | Enable auto-reload (for development) | Use `--reload` to automatically restart the server after code changes: ```sh kimi web --reload ``` ::: info Note The `--reload` option is only for development purposes and is not needed for daily use. ::: ### Access control Web UI provides multi-layer access control mechanisms to ensure service security. | Option | Description | |--------|-------------| | `--auth-token TEXT` | Set Bearer Token for API authentication | | `--allowed-origins TEXT` | Set allowed Origin list (comma-separated) | | `--lan-only / --public` | Only allow LAN access (default) or allow public access | | `--restrict-sensitive-apis / --no-restrict-sensitive-apis` | Restrict sensitive API access (config write, open-in, file access limits) | | `--dangerously-omit-auth` | Disable authentication checks (dangerous, trusted networks only) | ::: info Added Access control options added in version 1.6. ::: #### Access token authentication Use `--auth-token` to set an access token. Clients need to include `Authorization: Bearer ` in the HTTP request header to access the API: ```sh kimi web --network --auth-token my-secret-token ``` ::: tip The access token should be a randomly generated string with at least 32 characters. You can use `openssl rand -hex 32` to generate a random token. ::: #### Origin checking Use `--allowed-origins` to restrict the origin domains that can access Web UI: ```sh kimi web --network --allowed-origins "https://example.com,https://app.example.com" ``` ::: tip When using `--network` or `--host` to enable network access, it is recommended to configure `--allowed-origins` to prevent Cross-Site Request Forgery (CSRF) attacks. ::: #### Network access scope By default, Web UI uses `--lan-only` mode, only allowing access from the local network (private IP address ranges). If you need to allow public access, use the `--public` option: ```sh kimi web --network --public --auth-token my-secret-token ``` ::: danger Warning Using the `--public` option will allow access from any IP address. Be sure to configure `--auth-token` and `--allowed-origins` to ensure security. ::: #### Restricting sensitive APIs Use `--restrict-sensitive-apis` to disable some sensitive API features: - Config file writing - Open-in functionality (opening local files, directories, applications) - File access restrictions ```sh kimi web --network --restrict-sensitive-apis ``` In `--public` mode, `--restrict-sensitive-apis` is enabled by default; in `--lan-only` mode (default), it is not enabled. ::: tip When you need to expose Web UI to untrusted network environments, it is recommended to enable the `--restrict-sensitive-apis` option. ::: #### Disabling authentication (not recommended) In trusted private network environments, you can use `--dangerously-omit-auth` to skip all authentication checks: ```sh kimi web --dangerously-omit-auth ``` ::: danger Warning The `--dangerously-omit-auth` option completely disables authentication and access control. It should only be used in fully trusted network environments (such as offline local development environments). Do not use this option on the public internet or untrusted local networks. ::: ## Switching from terminal to Web UI If you are using Kimi Code CLI in shell mode in the terminal, you can enter the `/web` command to quickly switch to Web UI: ``` /web ``` After execution, Kimi Code CLI will automatically start the Web UI server and open the current session in the browser. You can continue the conversation in Web UI, and the session history will remain synchronized. ## Web UI features ### Session management Web UI provides a convenient session management interface: - **Session list**: View all historical sessions, including session title and working directory - **Session search**: Quickly filter sessions by title or working directory - **Create session**: Create a new session with a specified working directory; if the specified path doesn't exist, you will be prompted to confirm creating the directory. Supports Cmd/Ctrl+Click on new-session buttons to open session creation in a new tab - **Switch session**: Switch to different sessions with one click - **Session fork**: Create a branching session from any assistant response, exploring different directions without affecting the original session - **Session archive**: Sessions older than 15 days are automatically archived. You can also archive manually. Archived sessions don't appear in the main list but can be unarchived at any time - **Bulk operations**: Bulk archive, unarchive, or delete sessions in multi-select mode ::: info Added Session search feature added in version 1.5. Directory auto-creation prompt added in version 1.7. Session fork, archive, and bulk operations added in version 1.9. ::: ### Prompt toolbar Web UI provides a unified prompt toolbar above the input box, displaying various information in collapsible tabs: - **Context usage**: Shows the current context usage percentage. Hover to view detailed token usage breakdown (including input/output tokens, cache read/write, etc.) - **Activity status**: Shows the current agent state (processing, waiting for approval, etc.) - **Message queue**: Queue follow-up messages while the AI is processing; queued messages are sent automatically when the current response completes - **File changes**: Detects Git repository status, showing the number of new, modified, and deleted files (including untracked files). Click to view a detailed list of changes - **Todo list**: When the `SetTodoList` tool is active, shows task progress with support for expanding to view the detailed list - **Plan mode**: Toggle plan mode on/off from the input toolbar. When plan mode is active, the composer displays a dashed blue border. Plan mode can also be set programmatically via the `set_plan_mode` Wire protocol method ::: info Changed Git diff status bar added in version 1.5. Activity status indicator added in version 1.9. Version 1.10 unified it into the prompt toolbar. Version 1.11 moved the context usage indicator to the prompt toolbar. Plan mode toggle added in version 1.20. ::: ### Open-in functionality Web UI supports opening files or directories in local applications: - **Open in Terminal**: Open directory in terminal - **Open in VS Code**: Open file or directory in VS Code - **Open in Cursor**: Open file or directory in Cursor - **Open in System**: Open with system default application ::: info Added Open-in functionality added in version 1.5. ::: ::: warning Note Open-in functionality requires browser support for Custom Protocol Handler. This feature is disabled when using the `--restrict-sensitive-apis` option. ::: ### Slash commands Web UI supports slash commands. Type `/` in the input box to open the command menu: - **Autocomplete**: Filter matching commands as you type - **Keyboard navigation**: Use up/down arrow keys to select commands, Enter to confirm - **Alias support**: Support command alias matching, e.g., `/h` matches `/help` ### File mentions Web UI supports file mentions. Type `@` in the input box to open the file mention menu, allowing you to reference files in your conversation: - **Uploaded attachments**: Mention files attached to the current message - **Workspace files**: Mention existing files in the current session's working directory - **Autocomplete**: Filter matching files by name or path as you type - **Keyboard navigation**: Use up/down arrow keys to select files, Enter or Tab to confirm, Escape to cancel ### Message actions Assistant messages provide the following action buttons: - **Copy**: Copy message content to clipboard with one click - **Fork**: Create a branching session from the current response ::: info Added Copy and fork buttons added in version 1.10. ::: ### Structured questions When the AI uses the `AskUserQuestion` tool, Web UI displays a structured question dialog in the chat area, replacing the input box at the bottom. The question dialog shows the question description and available options, supporting single-select, multi-select, and custom text input. When the AI asks multiple questions at once, the dialog shows a tab bar at the top listing all questions, with support for click navigation, keyboard navigation, and restoring previous selections when revisiting answered questions. After answering all questions, the dialog closes automatically and the AI continues execution based on your choices. ::: info Added Structured questions added in version 1.14. ::: ### Approval keyboard shortcuts When the agent sends an approval request, you can use keyboard shortcuts to respond quickly: | Shortcut | Action | |----------|--------| | `1` | Approve | | `2` | Approve for session | | `3` | Decline | ::: info Added Approval keyboard shortcuts added in version 1.10. ::: ### Tool output Web UI provides rich display for tool call output: - **Media preview**: Images and videos read by the `ReadMediaFile` tool are displayed as clickable thumbnails - **Shell commands**: `Shell` tool commands and output are rendered with dedicated components - **Todo list**: `SetTodoList` tool items are displayed as a structured list - **Tool input parameters**: Redesigned tool input UI with expandable parameter details and syntax highlighting for long values - **Context compaction**: A compaction indicator is shown when context compaction is in progress - **Quick URL open**: The URL parameter of the `FetchURL` tool supports Cmd/Ctrl+Click to open the link in a new tab ::: info Added Media preview, shell command, and todo list display components added in version 1.9. Quick URL open added in version 1.14. ::: ### Rich media support Web UI supports viewing and pasting various types of rich media content: - **Images**: Display images directly in the chat interface - **Code highlighting**: Automatic code block recognition and highlighting - **Markdown rendering**: Support for full Markdown syntax ### Responsive layout Web UI uses responsive design and displays well on screens of different sizes: - Desktop: Sidebar + main content area layout - Mobile: Collapsible drawer-style sidebar ::: info Changed Responsive layout improved in version 1.6 with enhanced hover effects and better layout handling. ::: ### URL action parameters Web UI supports URL parameters to trigger specific actions, making it easy to integrate from external tools or scripts: | Parameter | Description | |-----------|-------------| | `?action=create` | Open the create-session dialog | | `?action=create-in-dir&workDir=` | Directly create a session in the specified working directory | Examples: ``` http://127.0.0.1:5494?action=create http://127.0.0.1:5494?action=create-in-dir&workDir=/path/to/project ``` ## Examples ### Local use The simplest usage, accessible only from the local machine: ```sh kimi web ``` ### LAN sharing Share Web UI on the local network with access token protection: ```sh kimi web --network --auth-token $(openssl rand -hex 32) ``` After execution, the terminal will display the access address and token. Other devices can access through that address and enter the token in the browser for authentication. ### Public access Deploy Web UI in a public internet environment (requires careful security configuration): ```sh kimi web \ --host 0.0.0.0 \ --public \ --auth-token $(openssl rand -hex 32) \ --allowed-origins "https://yourdomain.com" \ --restrict-sensitive-apis ``` ### Development Enable auto-reload for development purposes: ```sh kimi web --reload --no-open ``` ## Technical details Web UI is built on the following technologies: - **Backend**: FastAPI + WebSocket - **Frontend**: React + TypeScript + Vite - **API protocol**: Complies with OpenAPI specification, see `web/openapi.json` Web UI communicates with Kimi Code CLI's Wire mode via WebSocket, enabling real-time bidirectional data transmission. ================================================ FILE: docs/en/reference/slash-commands.md ================================================ # Slash Commands Slash commands are built-in commands for Kimi Code CLI, used to control sessions, configuration, and debugging. Enter a command starting with `/` in the input box to trigger. ::: tip Shell mode Some slash commands are also available in shell mode, including `/help`, `/exit`, `/version`, `/editor`, `/changelog`, `/feedback`, `/export`, `/import`, and `/task`. ::: ## Help and info ### `/help` Display help information. Shows keyboard shortcuts, all available slash commands, and loaded skills in a fullscreen pager. Press `q` to exit. Aliases: `/h`, `/?` ### `/version` Display Kimi Code CLI version number. ### `/changelog` Display the changelog for recent versions. Alias: `/release-notes` ### `/feedback` Open the GitHub Issues page to submit feedback. ## Account and configuration ### `/login` Log in or configure an API platform. After execution, first select a platform: - **Kimi Code**: Automatically opens a browser for OAuth authorization - **Other platforms**: Enter an API key, then select an available model After configuration, settings are automatically saved to `~/.kimi/config.toml` and reloaded. See [Providers](../configuration/providers.md) for details. Alias: `/setup` ::: tip This command is only available when using the default configuration file. If a configuration was specified via `--config` or `--config-file`, this command cannot be used. ::: ### `/logout` Log out from the current platform. This clears stored credentials and removes related configuration from the config file. After logout, Kimi Code CLI will automatically reload the configuration. ### `/model` Switch models and thinking mode. This command first refreshes the available models list from the API platform. When called without arguments, displays an interactive selection interface where you first select a model, then choose whether to enable thinking mode (if the model supports it). After selection, Kimi Code CLI will automatically update the configuration file and reload. ::: tip This command is only available when using the default configuration file. If a configuration was specified via `--config` or `--config-file`, this command cannot be used. ::: ### `/editor` Set the external editor. When called without arguments, displays an interactive selection interface; you can also specify the editor command directly, e.g., `/editor vim`. After configuration, pressing `Ctrl-O` will open this editor to edit the current input content. See [Keyboard shortcuts](./keyboard.md#external-editor) for details. ### `/reload` Reload the configuration file without exiting Kimi Code CLI. ### `/debug` Display debug information for the current context, including: - Number of messages and tokens - Number of checkpoints - Complete message history Debug information is displayed in a pager, press `q` to exit. ### `/usage` Display API usage and quota information, showing quota usage with progress bars and remaining percentages. Alias: `/status` ::: tip This command only works with the Kimi Code platform. ::: ### `/mcp` Display currently connected MCP servers and loaded tools. See [Model Context Protocol](../customization/mcp.md) for details. Output includes: - Server connection status (green indicates connected) - List of tools provided by each server ## Session management ### `/new` Create a new session and switch to it immediately, without exiting Kimi Code CLI. If the current session has no content, the empty session directory is automatically cleaned up. ### `/sessions` List all sessions in the current working directory, allowing switching to other sessions. Alias: `/resume` Use arrow keys to select a session, press `Enter` to confirm switch, press `Ctrl-C` to cancel. ### `/export` Export the current session context to a Markdown file for archiving or sharing. Usage: - `/export`: Export to the current working directory with an auto-generated filename (format: `kimi-export--.md`) - `/export `: Export to the specified path. If the path is a directory, the filename is auto-generated; if it is a file path, the content is written directly to that file The exported file includes: - Session metadata (session ID, export time, working directory, message count, token count) - Conversation overview (topic, number of turns, tool call count) - Complete conversation history organized by turns, including user messages, AI responses, tool calls, and tool results ### `/import` Import context from a file or another session into the current session. The imported content is appended as reference context, and the AI can use this information to inform subsequent interactions. Usage: - `/import `: Import from a file. Supports common text-based formats such as Markdown, plain text, source code, and configuration files; binary files (e.g., images, PDFs, archives) are not supported - `/import `: Import from the specified session ID. Cannot import the current session into itself ### `/clear` Clear the current session's context and start a new conversation. Alias: `/reset` ### `/compact` Manually compact the context to reduce token usage. You can append custom instructions after the command to tell the AI which information to prioritize preserving during compaction, e.g., `/compact preserve database-related discussions`. When the context is too long, Kimi Code CLI will automatically trigger compaction. This command allows manually triggering the compaction process. ## Skills ### `/skill:` Load a specific skill, sending the `SKILL.md` content to the Agent as a prompt. This command works for both standard skills and flow skills. For example: - `/skill:code-style`: Load code style guidelines - `/skill:pptx`: Load PPT creation workflow - `/skill:git-commits fix user login issue`: Load the skill with an additional task description You can append additional text after the command, which will be added to the skill prompt. See [Agent Skills](../customization/skills.md) for details. ::: tip Flow skills can also be invoked via `/skill:`, which loads the content as a standard skill without automatically executing the flow. To execute the flow, use `/flow:` instead. ::: ### `/flow:` Execute a specific flow skill. Flow skills embed an Agent Flow diagram in `SKILL.md`. After execution, the Agent will start from the `BEGIN` node and process each node according to the flow diagram definition until reaching the `END` node. For example: - `/flow:code-review`: Execute code review workflow - `/flow:release`: Execute release workflow ::: tip Flow skills can also be invoked via `/skill:`, which loads the content as a standard skill without automatically executing the flow. ::: See [Agent Skills](../customization/skills.md#flow-skills) for details. ## Workspace ### `/add-dir` Add an additional directory to the workspace scope. Once added, the directory is accessible to all file tools (`ReadFile`, `WriteFile`, `Glob`, `Grep`, `StrReplaceFile`, etc.) and its directory listing is shown in the system prompt. Added directories are persisted with the session state and automatically restored when resuming. Usage: - `/add-dir `: Add the specified directory to the workspace - `/add-dir`: Without arguments, list already added additional directories ::: tip Directories already within the working directory do not need to be added, as they are already accessible. You can also add directories at startup via the `--add-dir` option. See [`kimi` command](./kimi-command.md#working-directory) for details. ::: ## Others ### `/init` Analyze the current project and generate an `AGENTS.md` file. This command starts a temporary sub-session to analyze the codebase structure and generate a project description document, helping the Agent better understand the project. ### `/plan` Toggle plan mode. In plan mode, the AI can only use read-only tools to explore the codebase, writing an implementation plan to a plan file and submitting it for your approval. See [Plan mode](../guides/interaction.md#plan-mode) for details. Usage: - `/plan`: Toggle plan mode - `/plan on`: Enable plan mode - `/plan off`: Disable plan mode - `/plan view`: View the current plan content - `/plan clear`: Clear the current plan file When plan mode is enabled, the prompt changes to `📋` and a blue `plan` badge appears in the status bar. ### `/task` Open the interactive task browser to view, monitor, and manage background tasks. The task browser is a three-column TUI: - **Left column**: Task list showing task ID, status, and description - **Middle column**: Detailed information for the selected task, including ID, status, description, timestamps, exit code, etc. - **Right column**: Output preview showing the last few lines Supported keyboard shortcuts: | Shortcut | Action | |----------|--------| | `Enter` / `O` | View the selected task's full output in a pager | | `S` | Request to stop the selected task (requires confirmation) | | `Tab` | Toggle filter mode (all / active tasks only) | | `R` | Refresh the task list | | `Q` / `Esc` | Exit the browser | The task browser automatically refreshes every second, showing real-time task status changes. ::: tip Background tasks are started by the AI using the `Shell` tool with `run_in_background=true`. The system automatically notifies the AI when background tasks complete. ::: ### `/yolo` Toggle YOLO mode. When enabled, all operations are automatically approved and a yellow YOLO badge appears in the status bar; enter the command again to disable. ::: warning Note YOLO mode skips all confirmations. Make sure you understand the potential risks. ::: ### `/web` Switch to Web UI. Kimi Code CLI will start a Web UI server and open the current session in your browser, allowing you to continue the conversation in the Web UI. See [Web UI](./kimi-web.md) for details. ## Command completion After typing `/` in the input box, a list of available commands is automatically displayed. Continue typing to filter commands with fuzzy matching support, press Enter to select. For example, typing `/ses` will match `/sessions`, and `/clog` will match `/changelog`. Command aliases are also supported, such as typing `/h` to match `/help`. ================================================ FILE: docs/en/release-notes/breaking-changes.md ================================================ # Breaking changes and migration This page documents breaking changes in Kimi Code CLI releases and provides migration guidance. ## Unreleased ## 0.81 - Prompt Flow replaced by Flow Skills ### `--prompt-flow` option removed The `--prompt-flow` CLI option has been removed. Use flow skills instead. - **Affected**: Scripts and automation using `--prompt-flow` to load Mermaid/D2 flowcharts - **Migration**: Create a flow skill with embedded Agent Flow in `SKILL.md` and invoke via `/flow:` ### `/begin` command replaced The `/begin` slash command has been replaced with `/flow:` commands. - **Affected**: Users who used `/begin` to start a loaded Prompt Flow - **Migration**: Use `/flow:` to invoke flow skills directly ## 0.77 - Thinking mode and CLI option changes ### Thinking mode setting migration change After upgrading from `0.76`, the thinking mode setting is no longer automatically preserved. The previous `thinking` state stored in `~/.kimi/kimi.json` is no longer used; instead, thinking mode is now managed via the `default_thinking` configuration option in `~/.kimi/config.toml`, but values are not automatically migrated from legacy `metadata`. - **Affected**: Users who previously had thinking mode enabled - **Migration**: Reconfigure thinking mode after upgrading: - Use the `/model` command to select model and set thinking mode (interactive) - Or manually add to `~/.kimi/config.toml`: ```toml default_thinking = true # Set to true if you want thinking mode enabled by default ``` ### `--query` option removed The `--query` (`-q`) option has been removed. Use `--prompt` as the primary option, with `--command` as an alias. - **Affected**: Scripts and automation using `--query` or `-q` - **Migration**: - `--query` / `-q` → `--prompt` / `-p` - Or continue using `--command` / `-c` ## 0.74 - ACP command change ### `--acp` option deprecated The `--acp` option has been deprecated. Use the `kimi acp` subcommand instead. - **Affected**: Scripts and IDE configurations using `kimi --acp` - **Migration**: `kimi --acp` → `kimi acp` ## 0.66 - Config file and provider type ### Config file format migration The config file format has been migrated from JSON to TOML. - **Affected**: Users with `~/.kimi/config.json` - **Migration**: Kimi Code CLI will automatically read the old JSON config, but manual migration to TOML is recommended - **New location**: `~/.kimi/config.toml` JSON config example: ```json { "default_model": "kimi-k2-0711", "providers": { "kimi": { "type": "kimi", "base_url": "https://api.kimi.com/coding/v1", "api_key": "your-key" } } } ``` Equivalent TOML config: ```toml default_model = "kimi-k2-0711" [providers.kimi] type = "kimi" base_url = "https://api.kimi.com/coding/v1" api_key = "your-key" ``` ### `google_genai` provider type renamed The provider type for Gemini Developer API has been renamed from `google_genai` to `gemini`. - **Affected**: Users with `type = "google_genai"` in their config - **Migration**: Change the `type` value to `"gemini"` - **Compatibility**: `google_genai` still works but updating is recommended ## 0.57 - Tool changes ### `Shell` tool The `Bash` tool (or `CMD` on Windows) has been unified and renamed to `Shell`. - **Affected**: Agent files referencing `Bash` or `CMD` tools - **Migration**: Change tool references to `Shell` ### `Task` tool moved to `multiagent` module The `Task` tool has been moved from `kimi_cli.tools.task` to `kimi_cli.tools.multiagent`. - **Affected**: Custom tools importing the `Task` tool - **Migration**: Change import path to `from kimi_cli.tools.multiagent import Task` ### `PatchFile` tool removed The `PatchFile` tool has been removed. - **Affected**: Agent configs using the `PatchFile` tool - **Alternative**: Use `StrReplaceFile` tool for file modifications ## 0.52 - CLI option changes ### `--ui` option removed The `--ui` option has been removed in favor of separate flags. - **Affected**: Scripts using `--ui print`, `--ui acp`, or `--ui wire` - **Migration**: - `--ui print` → `--print` - `--ui acp` → `kimi acp` - `--ui wire` → `--wire` ## 0.42 - Keyboard shortcut changes ### Mode switch shortcut The agent/shell mode toggle shortcut has changed from `Ctrl-K` to `Ctrl-X`. - **Affected**: Users accustomed to using `Ctrl-K` for mode switching - **Migration**: Use `Ctrl-X` to toggle modes ## 0.27 - CLI option rename ### `--agent` option renamed The `--agent` option has been renamed to `--agent-file`. - **Affected**: Scripts using `--agent` to specify custom agent files - **Migration**: Change `--agent` to `--agent-file` - **Note**: `--agent` is now used to specify built-in agents (e.g., `default`, `okabe`) ## 0.25 - Package name change ### Package renamed from `ensoul` to `kimi-cli` - **Affected**: Code or scripts using the `ensoul` package name - **Migration**: - Installation: `pip install ensoul` → `pip install kimi-cli` or `uv tool install kimi-cli` - Command: `ensoul` → `kimi` ### `ENSOUL_*` parameter prefix changed The system prompt built-in parameter prefix has changed from `ENSOUL_*` to `KIMI_*`. - **Affected**: Custom agent files using `ENSOUL_*` parameters - **Migration**: Change parameter prefix to `KIMI_*` (e.g., `ENSOUL_NOW` → `KIMI_NOW`) ================================================ FILE: docs/en/release-notes/changelog.md ================================================ # Changelog This page documents the changes in each Kimi Code CLI release. ## Unreleased - Shell: Show the current working directory, git branch, dirty state, and ahead/behind sync status directly in the prompt toolbar - Shell: Surface active background bash task counts in the toolbar, rotate shortcut tips on a timer, and gracefully truncate the toolbar on narrow terminals to avoid overflow - Web: Fix tool execution status synchronization on cancel and approval — tools now correctly transition to `output-denied` state when generation is stopped, and show the loading spinner (instead of checkmark) while executing after approval - Web: Dismiss stale approval and question dialogs on session replay — when replaying a session or when the backend reports idle/stopped/error status, any pending approval/question dialogs are now properly dismissed to prevent orphaned interactive elements - Web: Enable inline math formula rendering — single-dollar inline math (`$...$`) is now supported in addition to block math (`$$...$$`) - Web: Improve Switch toggle proportions and alignment — the toggle track is now larger (36×20) with a consistent 16px thumb and smoother 16px travel animation ## 1.24.0 (2026-03-18) - Shell: Increase pasted text placeholder thresholds to 1000 characters or 15 lines (previously 300 characters or 3 lines), making voice/typeless workflows less disruptive - Core: Plan mode now supports multiple selectable approach options — when the agent's plan contains distinct alternative paths, `ExitPlanMode` can present 2–3 labeled choices for the user to pick which approach to execute; the chosen option is returned to the agent as the selected approach - Core: Persist plan session ID and file path across process restarts — the plan session identifier and file slug are saved to `SessionState`, so restarting Kimi Code mid-plan resumes the same plan file in `~/.kimi/plans/` instead of creating a new one - Core: Plan mode now supports incremental plan edits — the agent can use `StrReplaceFile` to surgically update sections of the plan file instead of rewriting the entire file with `WriteFile`, and non-plan file edits are now hard-blocked rather than requiring approval - Core: Defer MCP startup and surface loading progress — MCP servers now initialize asynchronously after the shell UI starts, with live progress indicators showing connection status; Shell displays connecting and ready states in the status area, Web shows server connection status - Core: Optimize lightweight startup paths — implement lazy-loading for CLI subcommands and version metadata, significantly reducing startup time for common commands like `--version` and `--help` - Build: Fix Nix `FileCollisionError` for `bin/kimi` — remove duplicate entry point from `kimi-code` package so `kimi-cli` owns `bin/kimi` exclusively - Shell: Preserve unsubmitted input across agent turns — text typed in the prompt while the agent is running is no longer lost when the turn ends; the user can press Enter to submit the draft as the next message - Shell: Fix Ctrl-C and Ctrl-D not working correctly after an agent run completes — keyboard interrupts and EOF were silently swallowed instead of showing the tip or exiting the shell ## 1.23.0 (2026-03-17) - Shell: Add background bash — the `Shell` tool now accepts `run_in_background=true` to launch long-running commands (builds, tests, servers) as background tasks, freeing the agent to continue working; new `TaskList`, `TaskOutput`, and `TaskStop` tools manage task lifecycle, and the system automatically notifies the agent when tasks reach a terminal state - Shell: Add `/task` slash command with interactive task browser — a three-column TUI to view, monitor, and manage background tasks with real-time refresh, output preview, and keyboard-driven stopping - Web: Fix global config not refreshing on other tabs when model is changed — when the model is changed in one tab, other tabs now detect the config update and automatically refresh their global config ## 1.22.0 (2026-03-13) - Shell: Collapse long pasted text into `[Pasted text #n]` placeholders — text pasted via `Ctrl-V` or bracketed paste that exceeds 300 characters or 3 lines is displayed as a compact placeholder token in the prompt buffer while the full content is sent to the model; the external editor (`Ctrl-O`) expands placeholders for editing and re-folds them on save - Shell: Cache pasted images as attachment placeholders — images pasted from the clipboard are stored on disk and shown as `[image:…]` tokens in the prompt, keeping the input buffer readable - Shell: Fix UTF-16 surrogate characters in pasted text causing serialization errors — lone surrogates from Windows clipboard data are now sanitized before storage, preventing `UnicodeEncodeError` in history writes and JSON serialization - Shell: Redesign slash command completion menu — replace the default completion popup with a full-width custom menu that shows command names and multi-line descriptions, with highlight and scroll support - Shell: Fix cancelled shell commands not properly terminating child processes — when a running command is cancelled, the subprocess is now explicitly killed to prevent orphaned processes ## 1.21.0 (2026-03-12) - Shell: Add inline running prompt with steer input — agent output is now rendered inside the prompt area while the model is running, and users can type and send follow-up messages (steers) without waiting for the turn to finish; approval requests and question panels are handled inline with keyboard navigation - Core: Change steer injection from synthetic tool calls to regular user messages — steer content is now appended as a standard user message instead of a fake `_steer` tool-call/tool-result pair, improving compatibility with context serialization and visualization - Wire: Add `SteerInput` event — a new Wire protocol event emitted when the user sends a follow-up steer message during a running turn - Shell: Echo user input after submission in agent mode — the prompt symbol and entered text are printed back to the terminal for a clearer conversation transcript - Shell: Improve session replay with steer inputs — replay now correctly reconstructs and displays steer messages alongside regular turns, and filters out internal system-reminder messages - Shell: Fix upgrade command in toast notifications — the upgrade command text is now sourced from a single `UPGRADE_COMMAND` constant for consistency - Core: Persist system prompt in `context.jsonl` — the system prompt is now written as the first record of the context file and frozen per session, so visualization tools can read the full conversation context and session restores reuse the original prompt instead of regenerating it - Vis: Add session directory shortcuts in `kimi vis` — open the current session folder directly from the session page, copy the raw session directory path with `Copy DIR`, and support opening directories on both macOS and Windows - Shell: Improve API key login UX — show a spinner during key verification, display a helpful hint when a 401 error suggests the wrong platform was selected, show a setup summary on success, and default thinking mode to "on" ## 1.20.0 (2026-03-11) - Web: Add plan mode toggle in web UI — switch control in the input toolbar with a dashed blue border on the composer when plan mode is active, and support setting plan mode via the `set_plan_mode` Wire protocol method - Core: Persist plan mode state across session restarts — `plan_mode` is saved to `SessionState` and restored when a session resumes - Core: Fix StatusUpdate not reflecting plan mode changes triggered by tools — send a corrected `StatusUpdate` after `EnterPlanMode`/`ExitPlanMode` tool execution so the client sees the up-to-date state - Core: Fix HTTP header values containing trailing whitespace/newlines on certain Linux systems (e.g. kernel 6.8.0-101) causing connection errors — strip whitespace from ASCII header values before sending - Core: Fix OpenAI Responses provider sending implicit `reasoning.effort=null` which breaks Responses-compatible endpoints that require reasoning — reasoning parameters are now omitted unless explicitly set - Vis: Add session download, import, export and delete — one-click ZIP download from session explorer and detail page, ZIP import into a dedicated `~/.kimi/imported_sessions/` directory with "Imported" filter toggle, `kimi export ` CLI command, and delete support for imported sessions with AlertDialog confirmation - Core: Fix context compaction failing when conversation contains media parts (images, audio, video) — switch from blacklist filtering (exclude `ThinkPart`) to whitelist filtering (only keep `TextPart`) to prevent unsupported content types from being sent to the compaction API - Web: Fix `@` file mention index not refreshing after switching sessions or when workspace files change — reset index on session switch, auto-refresh after 30s staleness, and support path-prefix search beyond the 500-file limit ## 1.19.0 (2026-03-10) - Core: Add plan mode — the agent can enter a planning phase (`EnterPlanMode`) where only read-only tools (Glob, Grep, ReadFile) are available, write a structured plan to a file, and present it for user approval (`ExitPlanMode`) before executing; toggle manually via `/plan` slash command or `Shift-Tab` keyboard shortcut - Vis: Add `kimi vis` command for launching an interactive visualization dashboard to inspect session traces — includes wire event timeline, context viewer, session explorer, and usage statistics - Web: Fix session stream state management — guard against null reference errors during state resets and preserve slash commands across session switches to avoid a brief empty gap ## 1.18.0 (2026-03-09) - ACP: Support embedded resource content in ACP mode so that Zed's `@` file references correctly include file contents - Core: Use `parameters_json_schema` instead of `parameters` in Google GenAI provider to bypass Pydantic validation that rejects standard JSON Schema metadata fields in MCP tools - Shell: Enhance `Ctrl-V` clipboard paste to support video files in addition to images — video file paths are inserted as text, and a crash when clipboard data is `None` is fixed - Core: Pass session ID as `user_id` metadata to Anthropic API - Web: Preserve slash commands on WebSocket reconnect and add automatic retry logic for session initialization ## 1.17.0 (2026-03-03) - Core: Add `/export` command to export current session context (messages, metadata) to a Markdown file, and `/import` command to import context from a file or another session ID into the current session - Shell: Show token counts (used/total) alongside context usage percentage in the status bar (e.g., `context: 42.0% (4.2k/10.0k)`) - Shell: Rotate keyboard shortcut tips in the toolbar — tips cycle through available shortcuts on each prompt submission to save horizontal space - MCP: Add loading indicators for MCP server connections — Shell displays a "Connecting to MCP servers..." spinner and Web shows a status message while MCP tools are being loaded - Web: Fix scrollable file list overflow in the toolbar changes panel - Core: Add `compaction_trigger_ratio` config option (default `0.85`) to control when auto-compaction triggers — compaction now fires when context usage reaches the configured ratio or when remaining space falls below `reserved_context_size`, whichever comes first - Core: Support custom instructions in `/compact` command (e.g., `/compact keep database discussions`) to guide what the compaction preserves - Web: Add URL action parameters (`?action=create` to open create-session dialog, `?action=create-in-dir&workDir=xxx` to create a session directly) for external integrations, and support Cmd/Ctrl+Click on new-session buttons to open session creation in a new browser tab - Web: Add todo list display in prompt toolbar — shows task progress with expandable panel when the `SetTodoList` tool is active - ACP: Add authentication check for session operations with `AUTH_REQUIRED` error responses for terminal-based login flow ## 1.16.0 (2026-02-27) - Web: Update ASCII logo banner to a new styled design - Core: Add `--add-dir` CLI option and `/add-dir` slash command to expand the workspace scope with additional directories — added directories are accessible to all file tools (read, write, glob, replace), persisted across sessions, and shown in the system prompt - Shell: Add `Ctrl-O` keyboard shortcut to open the current input in an external editor (`$VISUAL`/`$EDITOR`), with auto-detection fallback to VS Code, Vim, Vi, or Nano - Shell: Add `/editor` slash command to configure and switch the default external editor, with interactive selection and persistent config storage - Shell: Add `/new` slash command to create and switch to a new session without restarting Kimi Code CLI - Wire: Auto-hide `AskUserQuestion` tool when the client does not support the `supports_question` capability, preventing the LLM from invoking unsupported interactions - Core: Estimate context token count after compaction so context usage percentage is not reported as 0% - Web: Show context usage percentage with one decimal place for better precision ## 1.15.0 (2026-02-27) - Shell: Simplify input prompt by removing username prefix for a cleaner appearance - Shell: Add horizontal separator line and expanded keyboard shortcut hints to the toolbar - Shell: Add number key shortcuts (1–5) for quick option selection in question and approval panels, with redesigned bordered panel UI and keyboard hints - Shell: Add tab-style navigation for multi-question panels — use Left/Right arrows or Tab to switch between questions, with visual indicators for answered, current, and pending states, and automatic state restoration when revisiting a question - Shell: Allow Space key to submit single-select questions in the question panel - Web: Add tab-style navigation for multi-question dialogs with clickable tab bar, keyboard navigation, and state restoration when revisiting a question - Core: Set process title to "Kimi Code" (visible in `ps` / Activity Monitor / terminal tab title) and label web worker subprocesses as "kimi-code-worker" ## 1.14.0 (2026-02-26) - Shell: Make FetchURL tool's URL parameter a clickable hyperlink in the terminal - Tool: Add `AskUserQuestion` tool for presenting structured questions with predefined options during execution, supporting single-select, multi-select, and custom text input - Wire: Add `QuestionRequest` / `QuestionResponse` message types and capability negotiation for structured question interactions - Shell: Add interactive question panel for `AskUserQuestion` with keyboard-driven option selection - Web: Add `QuestionDialog` component for answering structured questions inline, replacing the prompt composer when a question is pending - Core: Persist session state across sessions — approval decisions (YOLO mode, auto-approved actions) and dynamic subagents are now saved and restored when resuming a session - Core: Use atomic JSON writes for metadata and session state files to prevent data corruption on crash - Wire: Add `steer` request to inject user messages into an active agent turn (protocol version 1.4) - Web: Allow Cmd/Ctrl+Click on FetchURL tool's URL parameter to open the link in a new browser tab, with platform-appropriate tooltip hint ## 1.13.0 (2026-02-24) - Core: Add automatic connection recovery that recreates the HTTP client on connection and timeout errors before retrying, improving resilience against transient network failures ## 1.12.0 (2026-02-11) - Web: Add subagent activity rendering to display subagent steps (thinking, tool calls, text) inside Task tool messages - Web: Add Think tool rendering as a lightweight reasoning-style block - Web: Replace emoji status indicators with Lucide icons for tool states and add category-specific icons for tool names - Web: Enhance Reasoning component with improved thinking labels and status icons - Web: Enhance Todo component with status icons and improved styling - Web: Implement WebSocket reconnection with automatic request resending and stale connection watchdog - Web: Enhance session creation dialog with command value handling - Web: Support tilde (`~`) expansion in session work directory paths - Web: Fix assistant message content overflow clipping - Wire: Fix deadlock when multiple subagents run concurrently by not blocking the UI loop on approval and tool-call requests - Wire: Clean up stale pending requests after agent turn ends - Web: Show placeholder text in prompt input with hints for slash commands and file mentions - Web: Fix Ctrl+C not working in uvicorn web server by restoring default SIGINT handler and terminal state after shell mode exits - Web: Improve session stop handling with proper async cleanup and timeout - ACP: Add protocol version negotiation framework for client-server compatibility - ACP: Add session resume method to restore session state (experimental) ## 1.11.0 (2026-02-10) - Web: Move context usage indicator from workspace header to prompt toolbar with a hover card showing detailed token usage breakdown - Web: Add folder indicator with work directory path to the bottom of the file changes panel - Web: Fix stderr not being restored when switching to web mode, which could suppress web server error output - Web: Fix port availability check by setting SO_REUSEADDR on the test socket ## 1.10.0 (2026-02-09) - Web: Add copy and fork action buttons to assistant messages for quick content copying and session forking - Web: Add keyboard shortcuts for approval actions — press `1` to approve, `2` to approve for session, `3` to decline - Web: Add message queueing — queue follow-up messages while the AI is processing; queued messages are sent automatically when the response completes - Web: Replace Git diff status bar with unified prompt toolbar showing activity status, message queue, and file changes in collapsible tabs - Web: Load global MCP configuration in web worker so web sessions can use MCP tools - Web: Improve mobile prompt input UX — reduce textarea min-height, add `autoComplete="off"`, and disable focus ring on small screens - Web: Handle models that stream text before thinking by ensuring thinking messages always appear before text in the message list - Web: Show more specific status messages during session connection ("Loading history...", "Starting environment..." instead of generic "Connecting...") - Web: Send error status when session environment initialization fails instead of leaving UI in a waiting state - Web: Auto-reconnect when no session status received within 15 seconds after history replay completes - Web: Use non-blocking file I/O in session streaming to avoid blocking the event loop during history replay ## 1.9.0 (2026-02-06) - Config: Add `default_yolo` config option to enable YOLO (auto-approve) mode by default - Config: Accept both `max_steps_per_turn` and `max_steps_per_run` as aliases for the loop control setting - Wire: Add `replay` request to stream recorded Wire events (protocol version 1.3) - Web: Add session fork feature to branch off a new session from any assistant response - Web: Add session archive feature with auto-archive for sessions older than 15 days - Web: Add multi-select mode for bulk archive, unarchive, and delete operations - Web: Add media preview for tool results (images/videos from ReadMediaFile) with clickable thumbnails - Web: Add shell command and todo list display components for tool outputs - Web: Add activity status indicator showing agent state (processing, waiting for approval, etc.) - Web: Add error fallback UI when images fail to load - Web: Redesign tool input UI with expandable parameters and syntax highlighting for long values - Web: Show compaction indicator when context is being compacted - Web: Improve auto-scroll behavior in chat for smoother following of new content - Web: Update `last_session_id` for work directory when session stream starts - Shell: Remove `Ctrl-/` keyboard shortcut that triggered `/help` command - Rust: Move the Rust implementation to `MoonshotAI/kimi-agent-rs` with independent releases; binary renamed to `kimi-agent` - Core: Preserve session id when reloading configuration so the session resumes correctly - Shell: Fix session replay showing messages that were cleared by `/clear` or `/reset` - Web: Fix approval request states not updating when session is interrupted or cancelled - Web: Fix IME composition issue when selecting slash commands - Web: Fix UI not clearing messages after `/clear`, `/reset`, or `/compact` commands ## 1.8.0 (2026-02-05) - CLI: Fix startup errors (e.g. invalid config files) being silently swallowed instead of displayed ## 1.7.0 (2026-02-05) - Rust: Add `kagent`, the Rust implementation of Kimi agent kernel with wire-mode support (experimental) - Auth: Fix OAuth token refresh conflicts when running multiple sessions simultaneously - Web: Add file mention menu (`@`) to reference uploaded attachments and workspace files with autocomplete - Web: Add slash command menu in chat input with autocomplete, keyboard navigation, and alias support - Web: Prompt to create directory when specified path doesn't exist during session creation - Web: Fix authentication token persistence by switching from sessionStorage to localStorage with 24-hour expiry - Web: Add server-side pagination for session list with virtualized scrolling for better performance - Web: Improve session and work directories loading with smarter caching and invalidation - Web: Fix WebSocket errors during history replay by checking connection state before sending - Web: Git diff status bar now shows untracked files (new files not yet added to git) - Web: Restrict sensitive APIs only in public mode; update origin enforcement logic ## 1.6 (2026-02-03) - Web: Add token-based authentication and access control for network mode (`--network`, `--lan-only`, `--public`) - Web: Add security options: `--auth-token`, `--allowed-origins`, `--restrict-sensitive-apis`, `--dangerously-omit-auth` - Web: Change `--host` option to bind to specific IP address; add automatic network address detection - Web: Fix WebSocket disconnect when creating new sessions - Web: Increase maximum image dimension from 1024 to 4096 pixels - Web: Improve UI responsiveness with enhanced hover effects and better layout handling - Wire: Add `TurnEnd` event to signal the completion of an agent turn (protocol version 1.2) - Core: Fix custom agent prompt files containing `$` causing silent startup failure ## 1.5 (2026-01-30) - Web: Add Git diff status bar showing uncommitted changes in session working directory - Web: Add "Open in" menu for opening files/directories in Terminal, VS Code, Cursor, or other local applications - Web: Add search functionality to filter sessions by title or working directory - Web: Improve session title display with proper overflow handling ## 1.4 (2026-01-30) - Shell: Merge `/login` and `/setup` commands; `/setup` is now an alias for `/login` - Shell: `/usage` now shows remaining quota percentage; add `/status` alias - Config: Add `KIMI_SHARE_DIR` environment variable to customize the share directory path (default: `~/.kimi`) - Web: Add new Web UI for browser-based interaction - CLI: Add `kimi web` subcommand to launch the Web UI server - Auth: Fix encoding error when device name or OS version contains non-ASCII characters - Auth: OAuth credentials are now stored in files instead of keyring; existing tokens are automatically migrated on startup - Auth: Fix authorization failure after the system sleeps or hibernates ## 1.3 (2026-01-28) - Auth: Fix authentication issue during agent turns - Tool: Wrap media content with descriptive tags in `ReadMediaFile` for better path traceability ## 1.2 (2026-01-27) - UI: Show description for `kimi-for-coding` model ## 1.1 (2026-01-27) - LLM: Fix `kimi-for-coding` model's capabilities ## 1.0 (2026-01-27) - Shell: Add `/login` and `/logout` slash commands for login and logout - CLI: Add `kimi login` and `kimi logout` subcommands - Core: Fix subagent approval request handling ## 0.88 (2026-01-26) - MCP: Remove `Mcp-Session-Id` header when connecting to MCP servers to fix compatibility ## 0.87 (2026-01-25) - Shell: Fix Markdown rendering error when HTML blocks appear outside any element - Skills: Add more user-level and project-level skills directory candidates - Core: Improve system prompt guidance for media file generation and processing tasks - Shell: Fix image pasting from clipboard on macOS ## 0.86 (2026-01-24) - Build: Fix binary builds ## 0.85 (2026-01-24) - Shell: Cache pasted images to disk for persistence across sessions - Shell: Deduplicate cached attachments based on content hash - Shell: Fix display of image/audio/video attachments in message history - Tool: Use file path as media identifier in `ReadMediaFile` for better traceability - Tool: Fix some MP4 files not being recognized as videos - Shell: Handle Ctrl-C during slash command execution - Shell: Fix shlex parsing error in shell mode when input contains invalid shell syntax - Shell: Fix stderr output from MCP servers and third-party libraries polluting shell UI - Wire: Graceful shutdown with proper cleanup of pending requests when connection closes or Ctrl-C is received ## 0.84 (2026-01-22) - Build: Add cross-platform standalone binary builds for Windows, macOS (with code signing and notarization), and Linux (x86_64 and ARM64) - Shell: Fix slash command autocomplete showing suggestions for exact command/alias matches - Tool: Treat SVG files as text instead of images - Flow: Support D2 markdown block strings (`|md` syntax) for multiline node labels in flow skills - Core: Fix possible "event loop is closed" error after running `/reload`, `/setup`, or `/clear` - Core: Fix panic when `/clear` is used in a continued session ## 0.83 (2026-01-21) - Tool: Add `ReadMediaFile` tool for reading image/video files; `ReadFile` now focuses on text files only - Skills: Flow skills now also register as `/skill:` commands (in addition to `/flow:`) ## 0.82 (2026-01-21) - Tool: Allow `WriteFile` and `StrReplaceFile` tools to edit/write files outside the working directory when using absolute paths - Tool: Upload videos to Kimi files API when using Kimi provider, replacing inline data URLs with `ms://` references - Config: Add `reserved_context_size` setting to customize auto-compaction trigger threshold (default: 50000 tokens) ## 0.81 (2026-01-21) - Skills: Add flow skill type with embedded Agent Flow (Mermaid/D2) in SKILL.md, invoked via `/flow:` commands - CLI: Remove `--prompt-flow` option; use flow skills instead - Core: Replace `/begin` command with `/flow:` commands for flow skills ## 0.80 (2026-01-20) - Wire: Add `initialize` method for exchanging client/server info, external tools registration and slash commands advertisement - Wire: Support external tool calls via Wire protocol - Wire: Rename `ApprovalRequestResolved` to `ApprovalResponse` (backwards-compatible) ## 0.79 (2026-01-19) - Skills: Add project-level skills support, discovered from `.agents/skills/` (or `.kimi/skills/`, `.claude/skills/`) - Skills: Unified skills discovery with layered loading (builtin → user → project); user-level skills now prefer `~/.config/agents/skills/` - Shell: Support fuzzy matching for slash command autocomplete - Shell: Enhanced approval request preview with shell command and diff content display, use `Ctrl-E` to expand full content - Wire: Add `ShellDisplayBlock` type for shell command display in approval requests - Shell: Reorder `/help` to show keyboard shortcuts before slash commands - Wire: Return proper JSON-RPC 2.0 error responses for invalid requests ## 0.78 (2026-01-16) - CLI: Add D2 flowchart format support for Prompt Flow (`.d2` extension) ## 0.77 (2026-01-15) - Shell: Fix line breaking in `/help` and `/changelog` fullscreen pager display - Shell: Use `/model` to toggle thinking mode instead of Tab key - Config: Add `default_thinking` config option (need to run `/model` to select thinking mode after upgrade) - LLM: Add `always_thinking` capability for models that always use thinking mode - CLI: Rename `--command`/`-c` to `--prompt`/`-p`, keep `--command`/`-c` as alias, remove `--query`/`-q` - Wire: Fix approval requests not responding properly in Wire mode - CLI: Add `--prompt-flow` option to load a Mermaid flowchart file as a Prompt Flow - Core: Add `/begin` slash command if a Prompt Flow is loaded to start the flow - Core: Replace Ralph Loop with Prompt Flow-based implementation ## 0.76 (2026-01-12) - Tool: Make `ReadFile` tool description reflect model capabilities for image/video support - Tool: Fix TypeScript files (`.ts`, `.tsx`, `.mts`, `.cts`) being misidentified as video files - Shell: Allow slash commands (`/help`, `/exit`, `/version`, `/changelog`, `/feedback`) in shell mode - Shell: Improve `/help` with fullscreen pager, showing slash commands, skills, and keyboard shortcuts - Shell: Improve `/changelog` and `/mcp` display with consistent bullet-style formatting - Shell: Show current model name in the bottom status bar - Shell: Add `Ctrl-/` shortcut to show help ## 0.75 (2026-01-09) - Tool: Improve `ReadFile` tool description - Skills: Add built-in `kimi-cli-help` skill to answer Kimi Code CLI usage and configuration questions ## 0.74 (2026-01-09) - ACP: Allow ACP clients to select and switch models (with thinking variants) - ACP: Add `terminal-auth` authentication method for setup flow - CLI: Deprecate `--acp` option in favor of `kimi acp` subcommand - Tool: Support reading image and video files in `ReadFile` tool ## 0.73 (2026-01-09) - Skills: Add built-in skill-creator skill shipped with the package - Tool: Expand `~` to the home directory in `ReadFile` paths - MCP: Ensure MCP tools finish loading before starting the agent loop - Wire: Fix Wire mode failing to accept valid `cancel` requests - Setup: Allow `/model` to switch between all available models for the selected provider - Lib: Re-export all Wire message types from `kimi_cli.wire.types`, as a replacement of `kimi_cli.wire.message` - Loop: Add `max_ralph_iterations` loop control config to limit extra Ralph iterations - Config: Rename `max_steps_per_run` to `max_steps_per_turn` in loop control config (backward-compatible) - CLI: Add `--max-steps-per-turn`, `--max-retries-per-step` and `--max-ralph-iterations` options to override loop control config - SlashCmd: Make `/yolo` toggle auto-approve mode - UI: Show a YOLO badge in the shell prompt ## 0.72 (2026-01-04) - Python: Fix installation on Python 3.14. ## 0.71 (2026-01-04) - ACP: Route file reads/writes and shell commands through ACP clients for synced edits/output - Shell: Add `/model` slash command to switch default models and reload when using the default config - Skills: Add `/skill:` slash commands to load `SKILL.md` instructions on demand - CLI: Add `kimi info` subcommand for version/protocol details (supports `--json`) - CLI: Add `kimi term` to launch the Toad terminal UI - Python: Bump the default tooling/CI version to 3.14 ## 0.70 (2025-12-31) - CLI: Add `--final-message-only` (and `--quiet` alias) to only output the final assistant message in print UI - LLM: Add `video_in` model capability and support video inputs ## 0.69 (2025-12-29) - Core: Support discovering skills in `~/.kimi/skills` or `~/.claude/skills` - Python: Lower the minimum required Python version to 3.12 - Nix: Add flake packaging; install with `nix profile install .#kimi-cli` or run `nix run .#kimi-cli` - CLI: Add `kimi-cli` script alias for invoking the CLI; can be run via `uvx kimi-cli` - Lib: Move LLM config validation into `create_llm` and return `None` when missing config ## 0.68 (2025-12-24) - CLI: Add `--config` and `--config-file` options to pass in config JSON/TOML - Core: Allow `Config` in addition to `Path` for the `config` parameter of `KimiCLI.create` - Tool: Include diff display blocks in `WriteFile` and `StrReplaceFile` approvals/results - Wire: Add display blocks to approval requests (including diffs) with backward-compatible defaults - ACP: Show file diff previews in tool results and approval prompts - ACP: Connect to MCP servers managed by ACP clients - ACP: Run shell commands in ACP client terminal if supported - Lib: Add `KimiToolset.find` method to find tools by class or name - Lib: Add `ToolResultBuilder.display` method to append display blocks to tool results - MCP: Add `kimi mcp auth` and related subcommands to manage MCP authorization ## 0.67 (2025-12-22) - ACP: Advertise slash commands in single-session ACP mode (`kimi --acp`) - MCP: Add `mcp.client` config section to configure MCP tool call timeout and other future options - Core: Improve default system prompt and `ReadFile` tool - UI: Fix Ctrl-C not working in some rare cases ## 0.66 (2025-12-19) - Lib: Provide `token_usage` and `message_id` in `StatusUpdate` Wire message - Lib: Add `KimiToolset.load_tools` method to load tools with dependency injection - Lib: Add `KimiToolset.load_mcp_tools` method to load MCP tools - Lib: Move `MCPTool` from `kimi_cli.tools.mcp` to `kimi_cli.soul.toolset` - Lib: Add `InvalidToolError`, `MCPConfigError` and `MCPRuntimeError` - Lib: Make the detailed Kimi Code CLI exception classes extend `ValueError` or `RuntimeError` - Lib: Allow passing validated `list[fastmcp.mcp_config.MCPConfig]` as `mcp_configs` for `KimiCLI.create` and `load_agent` - Lib: Fix exception raising for `KimiCLI.create`, `load_agent`, `KimiToolset.load_tools` and `KimiToolset.load_mcp_tools` - LLM: Add provider type `vertexai` to support Vertex AI - LLM: Rename Gemini Developer API provider type from `google_genai` to `gemini` - Config: Migrate config file from JSON to TOML - MCP: Connect to MCP servers in background and parallel to reduce startup time - MCP: Add `mcp-session-id` HTTP header when connecting to MCP servers - Lib: Split slash commands (prev "meta commands") into two groups: Shell-level and KimiSoul-level - Lib: Add `available_slash_commands` property to `Soul` protocol - ACP: Advertise slash commands `/init`, `/compact` and `/yolo` to ACP clients - SlashCmd: Add `/mcp` slash command to display MCP server and tool status ## 0.65 (2025-12-16) - Lib: Support creating named sessions via `Session.create(work_dir, session_id)` - CLI: Automatically create new session when specified session ID is not found - CLI: Delete empty sessions on exit and ignore sessions whose context file is empty when listing - UI: Improve session replaying - Lib: Add `model_config: LLMModel | None` and `provider_config: LLMProvider | None` properties to `LLM` class - MetaCmd: Add `/usage` meta command to show API usage for Kimi Code users ## 0.64 (2025-12-15) - UI: Fix UTF-16 surrogate characters input on Windows - Core: Add `/sessions` meta command to list existing sessions and switch to a selected one - CLI: Add `--session/-S` option to specify session ID to resume - MCP: Add `kimi mcp` subcommand group to manage global MCP config file `~/.kimi/mcp.json` ## 0.63 (2025-12-12) - Tool: Fix `FetchURL` tool incorrect output when fetching via service fails - Tool: Use `bash` instead of `sh` in `Shell` tool for better compatibility - Tool: Fix `Grep` tool unicode decoding error on Windows - ACP: Support ACP session continuation (list/load sessions) with `kimi acp` subcommand - Lib: Add `Session.find` and `Session.list` static methods to find and list sessions - ACP: Update agent plans on the client side when `SetTodoList` tool is called - UI: Prevent normal messages starting with `/` from being treated as meta commands ## 0.62 (2025-12-08) - ACP: Fix tool results (including Shell tool output) not being displayed in ACP clients like Zed - ACP: Fix compatibility with the latest version of Zed IDE (0.215.3) - Tool: Use PowerShell instead of CMD on Windows for better usability - Core: Fix startup crash when there is broken symbolic link in the working directory - Core: Add builtin `okabe` agent file with `SendDMail` tool enabled - CLI: Add `--agent` option to specify builtin agents like `default` and `okabe` - Core: Improve compaction logic to better preserve relevant information ## 0.61 (2025-12-04) - Lib: Fix logging when used as a library - Tool: Harden file path check to protect against shared-prefix escape - LLM: Improve compatibility with some third-party OpenAI Responses and Anthropic API providers ## 0.60 (2025-12-01) - LLM: Fix interleaved thinking for Kimi and OpenAI-compatible providers ## 0.59 (2025-11-28) - Core: Move context file location to `.kimi/sessions/{workdir_md5}/{session_id}/context.jsonl` - Lib: Move `WireMessage` type alias to `kimi_cli.wire.message` - Lib: Add `kimi_cli.wire.message.Request` type alias request messages (which currently only includes `ApprovalRequest`) - Lib: Add `kimi_cli.wire.message.is_event`, `is_request` and `is_wire_message` utility functions to check the type of wire messages - Lib: Add `kimi_cli.wire.serde` module for serialization and deserialization of wire messages - Lib: Change `StatusUpdate` Wire message to not using `kimi_cli.soul.StatusSnapshot` - Core: Record Wire messages to a JSONL file in session directory - Core: Introduce `TurnBegin` Wire message to mark the beginning of each agent turn - UI: Print user input again with a panel in shell mode - Lib: Add `Session.dir` property to get the session directory path - UI: Improve "Approve for session" experience when there are multiple parallel subagents - Wire: Reimplement Wire server mode (which is enabled with `--wire` option) - Lib: Rename `ShellApp` to `Shell`, `PrintApp` to `Print`, `ACPServer` to `ACP` and `WireServer` to `WireOverStdio` for better consistency - Lib: Rename `KimiCLI.run_shell_mode` to `run_shell`, `run_print_mode` to `run_print`, `run_acp_server` to `run_acp`, and `run_wire_server` to `run_wire_stdio` for better consistency - Lib: Add `KimiCLI.run` method to run a turn with given user input and yield Wire messages - Print: Fix stream-json print mode not flushing output properly - LLM: Improve compatibility with some OpenAI and Anthropic API providers - Core: Fix chat provider error after compaction when using Anthropic API ## 0.58 (2025-11-21) - Core: Fix field inheritance of agent spec files when using `extend` - Core: Support using MCP tools in subagents - Tool: Add `CreateSubagent` tool to create subagents dynamically (not enabled in default agent) - Tool: Use MoonshotFetch service in `FetchURL` tool for Kimi Code plan - Tool: Truncate Grep tool output to avoid exceeding token limit ## 0.57 (2025-11-20) - LLM: Fix Google GenAI provider when thinking toggle is not on - UI: Improve approval request wordings - Tool: Remove `PatchFile` tool - Tool: Rename `Bash`/`CMD` tool to `Shell` tool - Tool: Move `Task` tool to `kimi_cli.tools.multiagent` module ## 0.56 (2025-11-19) - LLM: Add support for Google GenAI provider ## 0.55 (2025-11-18) - Lib: Add `kimi_cli.app.enable_logging` function to enable logging when directly using `KimiCLI` class - Core: Fix relative path resolution in agent spec files - Core: Prevent from panic when LLM API connection failed - Tool: Optimize `FetchURL` tool for better content extraction - Tool: Increase MCP tool call timeout to 60 seconds - Tool: Provide better error message in `Glob` tool when pattern is `**` - ACP: Fix thinking content not displayed properly - UI: Minor UI improvements in shell mode ## 0.54 (2025-11-13) - Lib: Move `WireMessage` from `kimi_cli.wire.message` to `kimi_cli.wire` - Print: Fix `stream-json` output format missing the last assistant message - UI: Add warning when API key is overridden by `KIMI_API_KEY` environment variable - UI: Make a bell sound when there's an approval request - Core: Fix context compaction and clearing on Windows ## 0.53 (2025-11-12) - UI: Remove unnecessary trailing spaces in console output - Core: Throw error when there are unsupported message parts - MetaCmd: Add `/yolo` meta command to enable YOLO mode after startup - Tool: Add approval request for MCP tools - Tool: Disable `Think` tool in default agent - CLI: Restore thinking mode from last time when `--thinking` is not specified - CLI: Fix `/reload` not working in binary packed by PyInstaller ## 0.52 (2025-11-10) - CLI: Remove `--ui` option in favor of `--print`, `--acp`, and `--wire` flags (shell is still the default) - CLI: More intuitive session continuation behavior - Core: Add retry for LLM empty responses - Tool: Change `Bash` tool to `CMD` tool on Windows - UI: Fix completion after backspacing - UI: Fix code block rendering issues on light background colors ## 0.51 (2025-11-08) - Lib: Rename `Soul.model` to `Soul.model_name` - Lib: Rename `LLMModelCapability` to `ModelCapability` and move to `kimi_cli.llm` - Lib: Add `"thinking"` to `ModelCapability` - Lib: Remove `LLM.supports_image_in` property - Lib: Add required `Soul.model_capabilities` property - Lib: Rename `KimiSoul.set_thinking_mode` to `KimiSoul.set_thinking` - Lib: Add `KimiSoul.thinking` property - UI: Better checks and notices for LLM model capabilities - UI: Clear the screen for `/clear` meta command - Tool: Support auto-downloading ripgrep on Windows - CLI: Add `--thinking` option to start in thinking mode - ACP: Support thinking content in ACP mode ## 0.50 (2025-11-07) - Improve UI look and feel - Improve Task tool observability ## 0.49 (2025-11-06) - Minor UX improvements ## 0.48 (2025-11-06) - Support Kimi K2 thinking mode ## 0.47 (2025-11-05) - Fix Ctrl-W not working in some environments - Do not load SearchWeb tool when the search service is not configured ## 0.46 (2025-11-03) - Introduce Wire over stdio for local IPC (experimental, subject to change) - Support Anthropic provider type - Fix binary packed by PyInstaller not working due to wrong entrypoint ## 0.45 (2025-10-31) - Allow `KIMI_MODEL_CAPABILITIES` environment variable to override model capabilities - Add `--no-markdown` option to disable markdown rendering - Support `openai_responses` LLM provider type - Fix crash when continuing a session ## 0.44 (2025-10-30) - Improve startup time - Fix potential invalid bytes in user input ## 0.43 (2025-10-30) - Basic Windows support (experimental) - Display warnings when base URL or API key is overridden in environment variables - Support image input if the LLM model supports it - Replay recent context history when continuing a session - Ensure new line after executing shell commands ## 0.42 (2025-10-28) - Support Ctrl-J or Alt-Enter to insert a new line - Change mode switch shortcut from Ctrl-K to Ctrl-X - Improve overall robustness - Fix ACP server `no attribute` error ## 0.41 (2025-10-26) - Fix a bug for Glob tool when no matching files are found - Ensure reading files with UTF-8 encoding - Disable reading command/query from stdin in shell mode - Clarify the API platform selection in `/setup` meta command ## 0.40 (2025-10-24) - Support `ESC` key to interrupt the agent loop - Fix SSL certificate verification error in some rare cases - Fix possible decoding error in Bash tool ## 0.39 (2025-10-24) - Fix context compaction threshold check - Fix panic when SOCKS proxy is set in the shell session ## 0.38 (2025-10-24) - Minor UX improvements ## 0.37 (2025-10-24) - Fix update checking ## 0.36 (2025-10-24) - Add `/debug` meta command to debug the context - Add auto context compaction - Add approval request mechanism - Add `--yolo` option to automatically approve all actions - Render markdown content for better readability - Fix "unknown error" message when interrupting a meta command ## 0.35 (2025-10-22) - Minor UI improvements - Auto download ripgrep if not found in the system - Always approve tool calls in `--print` mode - Add `/feedback` meta command ## 0.34 (2025-10-21) - Add `/update` meta command to check for updates and auto-update in background - Support running interactive shell commands in raw shell mode - Add `/setup` meta command to setup LLM provider and model - Add `/reload` meta command to reload configuration ## 0.33 (2025-10-18) - Add `/version` meta command - Add raw shell mode, which can be switched to by Ctrl-K - Show shortcuts in bottom status line - Fix logging redirection - Merge duplicated input histories ## 0.32 (2025-10-16) - Add bottom status line - Support file path auto-completion (`@filepath`) - Do not auto-complete meta command in the middle of user input ## 0.31 (2025-10-14) - Fix step interrupting by Ctrl-C, for real ## 0.30 (2025-10-14) - Add `/compact` meta command to allow manually compacting context - Fix `/clear` meta command when context is empty ## 0.29 (2025-10-14) - Support Enter key to accept completion in shell mode - Remember user input history across sessions in shell mode - Add `/reset` meta command as an alias for `/clear` - Fix step interrupting by Ctrl-C - Disable `SendDMail` tool in Kimi Koder agent ## 0.28 (2025-10-13) - Add `/init` meta command to analyze the codebase and generate an `AGENTS.md` file - Add `/clear` meta command to clear the context - Fix `ReadFile` output ## 0.27 (2025-10-11) - Add `--mcp-config-file` and `--mcp-config` options to load MCP configs - Rename `--agent` option to `--agent-file` ## 0.26 (2025-10-11) - Fix possible encoding error in `--output-format stream-json` mode ## 0.25 (2025-10-11) - Rename package name `ensoul` to `kimi-cli` - Rename `ENSOUL_*` builtin system prompt arguments to `KIMI_*` - Further decouple `App` with `Soul` - Split `Soul` protocol and `KimiSoul` implementation for better modularity ## 0.24 (2025-10-10) - Fix ACP `cancel` method ## 0.23 (2025-10-09) - Add `extend` field to agent file to support agent file extension - Add `exclude_tools` field to agent file to support excluding tools - Add `subagents` field to agent file to support defining subagents ## 0.22 (2025-10-09) - Improve `SearchWeb` and `FetchURL` tool call visualization - Improve search result output format ## 0.21 (2025-10-09) - Add `--print` option as a shortcut for `--ui print`, `--acp` option as a shortcut for `--ui acp` - Support `--output-format stream-json` to print output in JSON format - Add `SearchWeb` tool with `services.moonshot_search` configuration. You need to configure it with `"services": {"moonshot_search": {"api_key": "your-search-api-key"}}` in your config file. - Add `FetchURL` tool - Add `Think` tool - Add `PatchFile` tool, not enabled in Kimi Koder agent - Enable `SendDMail` and `Task` tool in Kimi Koder agent with better tool prompts - Add `ENSOUL_NOW` builtin system prompt argument - Better-looking `/release-notes` - Improve tool descriptions - Improve tool output truncation ## 0.20 (2025-09-30) - Add `--ui acp` option to start Agent Client Protocol (ACP) server ## 0.19 (2025-09-29) - Support piped stdin for print UI - Support `--input-format=stream-json` for piped JSON input - Do not include `CHECKPOINT` messages in the context when `SendDMail` is not enabled ## 0.18 (2025-09-29) - Support `max_context_size` in LLM model configurations to configure the maximum context size (in tokens) - Improve `ReadFile` tool description ## 0.17 (2025-09-29) - Fix step count in error message when exceeded max steps - Fix history file assertion error in `kimi_run` - Fix error handling in print mode and single command shell mode - Add retry for LLM API connection errors and timeout errors - Increase default max-steps-per-run to 100 ## 0.16.0 (2025-09-26) - Add `SendDMail` tool (disabled in Kimi Koder, can be enabled in custom agent) - Session history file can be specified via `_history_file` parameter when creating a new session ## 0.15.0 (2025-09-26) - Improve tool robustness ## 0.14.0 (2025-09-25) - Add `StrReplaceFile` tool - Emphasize the use of the same language as the user ## 0.13.0 (2025-09-25) - Add `SetTodoList` tool - Add `User-Agent` in LLM API calls - Better system prompt and tool description - Better error messages for LLM ## 0.12.0 (2025-09-24) - Add `print` UI mode, which can be used via `--ui print` option - Add logging and `--debug` option - Catch EOF error for better experience ## 0.11.1 (2025-09-22) - Rename `max_retry_per_step` to `max_retries_per_step` ## 0.11.0 (2025-09-22) - Add `/release-notes` command - Add retry for LLM API errors - Add loop control configuration, e.g. `{"loop_control": {"max_steps_per_run": 50, "max_retry_per_step": 3}}` - Better extreme cases handling in `read_file` tool - Prevent Ctrl-C from exiting the CLI, force the use of Ctrl-D or `exit` instead ## 0.10.1 (2025-09-18) - Make slash commands look slightly better - Improve `glob` tool ## 0.10.0 (2025-09-17) - Add `read_file` tool - Add `write_file` tool - Add `glob` tool - Add `task` tool - Improve tool call visualization - Improve session management - Restore context usage when `--continue` a session ## 0.9.0 (2025-09-15) - Remove `--session` and `--continue` options ## 0.8.1 (2025-09-14) - Fix config model dumping ## 0.8.0 (2025-09-14) - Add `shell` tool and basic system prompt - Add tool call visualization - Add context usage count - Support interrupting the agent loop - Support project-level `AGENTS.md` - Support custom agent defined with YAML - Support oneshot task via `kimi -c` ================================================ FILE: docs/index.md ================================================ --- layout: home hero: name: Kimi Code CLI text: ' ' actions: - theme: brand text: 简体中文 link: /zh/ - theme: alt text: English link: /en/ --- ================================================ FILE: docs/package.json ================================================ { "name": "kimi-cli-docs", "private": true, "type": "module", "scripts": { "sync": "node scripts/sync-changelog.mjs", "dev": "npm run sync && vitepress dev", "build": "npm run sync && vitepress build", "preview": "vitepress preview" }, "devDependencies": { "vitepress": "^1.5.0" }, "dependencies": { "mermaid": "^11.12.2", "vitepress-plugin-llms": "^1.10.0", "vitepress-plugin-mermaid": "^2.0.17" } } ================================================ FILE: docs/scripts/sync-changelog.mjs ================================================ #!/usr/bin/env node /** * Sync CHANGELOG.md to docs/en/release-notes/changelog.md * * This script copies the content from the root CHANGELOG.md to the docs site, * with only formatting changes (title format). * * Run from the docs directory: node scripts/sync-changelog.mjs */ import { readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const docsDir = join(__dirname, ".."); const rootDir = join(docsDir, ".."); const sourcePath = join(rootDir, "CHANGELOG.md"); const targetPath = join(docsDir, "en/release-notes/changelog.md"); const HEADER = `# Changelog This page documents the changes in each Kimi Code CLI release. `; // Read the source file let content = readFileSync(sourcePath, "utf-8"); // Remove the HTML comment block at the top content = content.replace(/\n*/g, ""); // Remove the "# Changelog" title (we'll add our own header) content = content.replace(/^# Changelog\n+/, ""); // Convert title format: ## [0.69] - 2025-12-29 -> ## 0.69 (2025-12-29) content = content.replace( /^## \[([^\]]+)\] - (\d{4}-\d{1,2}-\d{1,2})/gm, "## $1 ($2)" ); // Remove subsection headers like ### Added, ### Changed, ### Fixed content = content.replace(/^### (Added|Changed|Fixed|Improved|Tools|SDK)\n+/gm, ""); // Write the target file writeFileSync(targetPath, HEADER + content.trim() + "\n"); console.log(`Synced changelog to ${targetPath}`); ================================================ FILE: docs/zh/configuration/config-files.md ================================================ # 配置文件 Kimi Code CLI 使用配置文件管理 API 供应商、模型、服务和运行参数,支持 TOML 和 JSON 两种格式。 ## 配置文件位置 默认配置文件位于 `~/.kimi/config.toml`。首次运行时,如果配置文件不存在,Kimi Code CLI 会自动创建一个默认的配置文件。 你可以通过 `--config-file` 参数指定其他配置文件(TOML 或 JSON 格式均可): ```sh kimi --config-file /path/to/config.toml ``` 在程序化调用 Kimi Code CLI 时,也可以通过 `--config` 参数直接传入完整的配置内容: ```sh kimi --config '{"default_model": "kimi-for-coding", "providers": {...}, "models": {...}}' ``` ## 配置项 配置文件包含以下顶层配置项: | 配置项 | 类型 | 说明 | | --- | --- | --- | | `default_model` | `string` | 默认使用的模型名称,必须是 `models` 中定义的模型 | | `default_thinking` | `boolean` | 默认是否开启 Thinking 模式(默认为 `false`) | | `default_yolo` | `boolean` | 默认是否开启 YOLO(自动审批)模式(默认为 `false`) | | `default_editor` | `string` | 默认外部编辑器命令(如 `"vim"`、`"code --wait"`),为空时自动检测 | | `providers` | `table` | API 供应商配置 | | `models` | `table` | 模型配置 | | `loop_control` | `table` | Agent 循环控制参数 | | `background` | `table` | 后台任务运行参数 | | `services` | `table` | 外部服务配置(搜索、抓取) | | `mcp` | `table` | MCP 客户端配置 | ### 完整配置示例 ```toml default_model = "kimi-for-coding" default_thinking = false default_yolo = false default_editor = "" [providers.kimi-for-coding] type = "kimi" base_url = "https://api.kimi.com/coding/v1" api_key = "sk-xxx" [models.kimi-for-coding] provider = "kimi-for-coding" model = "kimi-for-coding" max_context_size = 262144 [loop_control] max_steps_per_turn = 100 max_retries_per_step = 3 max_ralph_iterations = 0 reserved_context_size = 50000 compaction_trigger_ratio = 0.85 [background] max_running_tasks = 4 keep_alive_on_exit = false [services.moonshot_search] base_url = "https://api.kimi.com/coding/v1/search" api_key = "sk-xxx" [services.moonshot_fetch] base_url = "https://api.kimi.com/coding/v1/fetch" api_key = "sk-xxx" [mcp.client] tool_call_timeout_ms = 60000 ``` ### `providers` `providers` 定义 API 供应商连接信息。每个供应商使用一个唯一的名称作为 key。 | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `type` | `string` | 是 | 供应商类型,详见 [平台与模型](./providers.md) | | `base_url` | `string` | 是 | API 基础 URL | | `api_key` | `string` | 是 | API 密钥 | | `env` | `table` | 否 | 创建供应商实例前设置的环境变量 | | `custom_headers` | `table` | 否 | 请求时附加的自定义 HTTP 头 | 示例: ```toml [providers.moonshot-cn] type = "kimi" base_url = "https://api.moonshot.cn/v1" api_key = "sk-xxx" custom_headers = { "X-Custom-Header" = "value" } ``` ### `models` `models` 定义可用的模型。每个模型使用一个唯一的名称作为 key。 | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `provider` | `string` | 是 | 使用的供应商名称,必须在 `providers` 中定义 | | `model` | `string` | 是 | 模型标识符(API 中使用的模型名称) | | `max_context_size` | `integer` | 是 | 最大上下文长度(token 数) | | `capabilities` | `array` | 否 | 模型能力列表,详见 [平台与模型](./providers.md#模型能力) | 示例: ```toml [models.kimi-k2-thinking-turbo] provider = "moonshot-cn" model = "kimi-k2-thinking-turbo" max_context_size = 262144 capabilities = ["thinking", "image_in"] ``` ### `loop_control` `loop_control` 控制 Agent 执行循环的行为。 | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `max_steps_per_turn` | `integer` | `100` | 单轮最大步数(别名:`max_steps_per_run`) | | `max_retries_per_step` | `integer` | `3` | 单步最大重试次数 | | `max_ralph_iterations` | `integer` | `0` | 每个 User 消息后额外自动迭代次数;`0` 表示关闭;`-1` 表示无限 | | `reserved_context_size` | `integer` | `50000` | 预留给 LLM 响应生成的 token 数量;当 `context_tokens + reserved_context_size >= max_context_size` 时自动触发压缩 | | `compaction_trigger_ratio` | `float` | `0.85` | 触发自动压缩的上下文使用率阈值(0.5–0.99);当 `context_tokens >= max_context_size * compaction_trigger_ratio` 时自动触发压缩,与 `reserved_context_size` 条件取先触发者 | ### `background` `background` 控制后台任务的运行行为。后台任务通过 `Shell` 工具的 `run_in_background=true` 参数启动。 | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `max_running_tasks` | `integer` | `4` | 同时运行的最大后台任务数 | | `keep_alive_on_exit` | `boolean` | `false` | CLI 退出时是否保留后台任务运行;默认退出时终止所有后台任务 | ### `services` `services` 配置 Kimi Code CLI 使用的外部服务。 #### `moonshot_search` 配置网页搜索服务,启用后 `SearchWeb` 工具可用。 | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `base_url` | `string` | 是 | 搜索服务 API URL | | `api_key` | `string` | 是 | API 密钥 | | `custom_headers` | `table` | 否 | 请求时附加的自定义 HTTP 头 | #### `moonshot_fetch` 配置网页抓取服务,启用后 `FetchURL` 工具优先使用此服务抓取网页内容。 | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `base_url` | `string` | 是 | 抓取服务 API URL | | `api_key` | `string` | 是 | API 密钥 | | `custom_headers` | `table` | 否 | 请求时附加的自定义 HTTP 头 | ::: tip 提示 使用 `/login` 命令配置 Kimi Code 平台时,搜索和抓取服务会自动配置。 ::: ### `mcp` `mcp` 配置 MCP 客户端行为。 | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `client.tool_call_timeout_ms` | `integer` | `60000` | MCP 工具调用超时时间(毫秒) | ## JSON 配置迁移 如果 `~/.kimi/config.toml` 不存在但 `~/.kimi/config.json` 存在,Kimi Code CLI 会自动将 JSON 配置迁移到 TOML 格式,并将原文件备份为 `config.json.bak`。 `--config-file` 指定的配置文件根据扩展名自动选择解析方式。`--config` 传入的配置内容会先尝试按 JSON 解析,失败后再尝试 TOML。 ================================================ FILE: docs/zh/configuration/data-locations.md ================================================ # 数据路径 Kimi Code CLI 将所有数据存储在用户主目录下的 `~/.kimi/` 目录中。本页介绍各类数据文件的位置和用途。 ::: tip 提示 可以通过设置 `KIMI_SHARE_DIR` 环境变量来自定义共享目录路径。详见 [环境变量](./env-vars.md#kimi-share-dir)。 注意:`KIMI_SHARE_DIR` 仅影响上述运行时数据的存储位置,不影响 [Agent Skills](../customization/skills.md) 的搜索路径。Skills 作为跨工具共享的能力扩展,与运行时数据是不同类型的数据。 ::: ## 目录结构 ``` ~/.kimi/ ├── config.toml # 主配置文件 ├── kimi.json # 元数据 ├── mcp.json # MCP 服务器配置 ├── credentials/ # OAuth 凭据 │ └── .json ├── sessions/ # 会话数据 │ └── / │ └── / │ ├── context.jsonl │ ├── wire.jsonl │ └── state.json ├── imported_sessions/ # 导入的会话数据(通过 kimi vis 导入) │ └── / │ ├── context.jsonl │ ├── wire.jsonl │ └── state.json ├── plans/ # Plan 模式方案文件 │ └── .md ├── user-history/ # 输入历史 │ └── .jsonl └── logs/ # 日志 └── kimi.log ``` ## 配置与元数据 ### `config.toml` 主配置文件,存储供应商、模型、服务和运行参数。详见 [配置文件](./config-files.md)。 可以通过 `--config-file` 参数指定其他位置的配置文件。 ### `kimi.json` 元数据文件,存储 Kimi Code CLI 的运行状态,包括: - `work_dirs`: 工作目录列表及其最后使用的会话 ID - `thinking`: 上次会话是否启用 thinking 模式 此文件由 Kimi Code CLI 自动管理,通常不需要手动编辑。 ### `mcp.json` MCP 服务器配置文件,存储通过 `kimi mcp add` 命令添加的 MCP 服务器。详见 [MCP](../customization/mcp.md)。 示例结构: ```json { "mcpServers": { "context7": { "url": "https://mcp.context7.com/mcp", "transport": "http", "headers": { "CONTEXT7_API_KEY": "ctx7sk-xxx" } } } } ``` ## 凭据 OAuth 凭据存储在 `~/.kimi/credentials/` 目录下。通过 `/login` 登录 Kimi 账号后,OAuth token 会保存在此目录中。 此目录中的文件权限设置为仅当前用户可读写(600),以保护敏感信息。 ## 会话数据 会话数据按工作目录分组存储在 `~/.kimi/sessions/` 下。每个工作目录对应一个以路径 MD5 哈希命名的子目录,每个会话对应一个以会话 ID 命名的子目录。 ### `context.jsonl` 上下文历史文件,以 JSONL 格式存储会话的完整上下文。文件第一行是系统提示词记录(`_system_prompt`),后续每行是一条消息(用户输入、模型回复、工具调用等)或内部记录(检查点、token 用量等)。 系统提示词在会话创建时生成并冻结,会话恢复时直接复用而不重新生成。 Kimi Code CLI 使用此文件在 `--continue` 或 `--session` 时恢复会话上下文。 ### `wire.jsonl` Wire 消息记录文件,以 JSONL 格式存储会话中的 Wire 事件。用于会话回放和提取会话标题。 ### `state.json` 会话状态文件,存储会话的运行状态,包括: - `approval`:审批决策状态(YOLO 模式开关、已自动批准的操作类型) - `plan_mode`:Plan 模式的开关状态 - `plan_session_id`:当前 Plan 会话的唯一标识符,用于关联 plan 文件 - `plan_slug`:Plan 文件的路径标识(即 `~/.kimi/plans/.md` 中的 slug),会话重启后可恢复到同一文件 - `dynamic_subagents`:动态创建的子 Agent 定义 - `additional_dirs`:通过 `--add-dir` 或 `/add-dir` 添加的额外工作区目录 恢复会话时,Kimi Code CLI 会读取此文件还原会话状态。此文件使用原子写入,防止崩溃时数据损坏。 ## Plan 方案文件 Plan 模式的方案文件存储在 `~/.kimi/plans/` 目录下。每个 Plan 会话对应一个随机命名的 Markdown 文件(即 `.md`)。 `plan_slug` 保存在 `state.json` 中,会话重启后仍能恢复到同一方案文件。使用 `/plan clear` 命令可以清除当前 Plan 会话的方案文件。 ## 输入历史 用户输入历史存储在 `~/.kimi/user-history/` 目录下。每个工作目录对应一个以路径 MD5 哈希命名的 `.jsonl` 文件。 输入历史用于 Shell 模式下的历史浏览(上下方向键)和搜索(Ctrl-R)。 ## 日志 运行日志存储在 `~/.kimi/logs/kimi.log`。默认日志级别为 INFO,使用 `--debug` 参数可启用 TRACE 级别。 日志文件用于排查问题。如需报告 bug,请附上相关日志内容。 ## 清理数据 删除共享目录(默认 `~/.kimi/`,或 `KIMI_SHARE_DIR` 指定的路径)可以完全清理 Kimi Code CLI 的所有数据,包括配置、会话和历史。 如只需清理部分数据: | 需求 | 操作 | | --- | --- | | 重置配置 | 删除 `~/.kimi/config.toml` | | 清理所有会话 | 删除 `~/.kimi/sessions/` 目录 | | 清理特定工作目录的会话 | 在 Shell 模式下使用 `/sessions` 查看并删除 | | 清理 Plan 方案文件 | 删除 `~/.kimi/plans/` 目录,或在 Plan 模式下使用 `/plan clear` | | 清理输入历史 | 删除 `~/.kimi/user-history/` 目录 | | 清理日志 | 删除 `~/.kimi/logs/` 目录 | | 清理 MCP 配置 | 删除 `~/.kimi/mcp.json` 或使用 `kimi mcp remove` | | 清理登录凭据 | 删除 `~/.kimi/credentials/` 目录或使用 `/logout` | ================================================ FILE: docs/zh/configuration/env-vars.md ================================================ # 环境变量 Kimi Code CLI 支持通过环境变量覆盖配置或控制运行行为。本页列出所有支持的环境变量。 关于环境变量如何覆盖配置文件的详细说明,请参阅 [配置覆盖](./overrides.md)。 ## Kimi 环境变量 以下环境变量在使用 `kimi` 类型的供应商时生效,用于覆盖供应商和模型配置。 | 环境变量 | 说明 | | --- | --- | | `KIMI_BASE_URL` | API 基础 URL | | `KIMI_API_KEY` | API 密钥 | | `KIMI_MODEL_NAME` | 模型标识符 | | `KIMI_MODEL_MAX_CONTEXT_SIZE` | 最大上下文长度(token 数) | | `KIMI_MODEL_CAPABILITIES` | 模型能力,逗号分隔(如 `thinking,image_in`) | | `KIMI_MODEL_TEMPERATURE` | 生成参数 `temperature` | | `KIMI_MODEL_TOP_P` | 生成参数 `top_p` | | `KIMI_MODEL_MAX_TOKENS` | 生成参数 `max_tokens` | ### `KIMI_BASE_URL` 覆盖配置文件中供应商的 `base_url` 字段。 ```sh export KIMI_BASE_URL="https://api.moonshot.cn/v1" ``` ### `KIMI_API_KEY` 覆盖配置文件中供应商的 `api_key` 字段。用于在不修改配置文件的情况下注入 API 密钥,适合 CI/CD 环境。 ```sh export KIMI_API_KEY="sk-xxx" ``` ### `KIMI_MODEL_NAME` 覆盖配置文件中模型的 `model` 字段(API 调用时使用的模型标识符)。 ```sh export KIMI_MODEL_NAME="kimi-k2-thinking-turbo" ``` ### `KIMI_MODEL_MAX_CONTEXT_SIZE` 覆盖配置文件中模型的 `max_context_size` 字段。必须是正整数。 ```sh export KIMI_MODEL_MAX_CONTEXT_SIZE="262144" ``` ### `KIMI_MODEL_CAPABILITIES` 覆盖配置文件中模型的 `capabilities` 字段。多个能力用逗号分隔,支持的值为 `thinking`、`always_thinking`、`image_in` 和 `video_in`。 ```sh export KIMI_MODEL_CAPABILITIES="thinking,image_in" ``` ### `KIMI_MODEL_TEMPERATURE` 设置生成参数 `temperature`,控制输出的随机性。值越高输出越随机,值越低输出越确定。 ```sh export KIMI_MODEL_TEMPERATURE="0.7" ``` ### `KIMI_MODEL_TOP_P` 设置生成参数 `top_p`(nucleus sampling),控制输出的多样性。 ```sh export KIMI_MODEL_TOP_P="0.9" ``` ### `KIMI_MODEL_MAX_TOKENS` 设置生成参数 `max_tokens`,限制单次回复的最大 token 数。 ```sh export KIMI_MODEL_MAX_TOKENS="4096" ``` ## OpenAI 兼容环境变量 以下环境变量在使用 `openai_legacy` 或 `openai_responses` 类型的供应商时生效。 | 环境变量 | 说明 | | --- | --- | | `OPENAI_BASE_URL` | API 基础 URL | | `OPENAI_API_KEY` | API 密钥 | ### `OPENAI_BASE_URL` 覆盖配置文件中供应商的 `base_url` 字段。 ```sh export OPENAI_BASE_URL="https://api.openai.com/v1" ``` ### `OPENAI_API_KEY` 覆盖配置文件中供应商的 `api_key` 字段。 ```sh export OPENAI_API_KEY="sk-xxx" ``` ## 其他环境变量 | 环境变量 | 说明 | | --- | --- | | `KIMI_SHARE_DIR` | 自定义共享目录路径(默认 `~/.kimi`) | | `KIMI_CLI_NO_AUTO_UPDATE` | 禁用自动更新检查 | ### `KIMI_SHARE_DIR` 自定义 Kimi Code CLI 的共享目录路径。默认路径为 `~/.kimi`,配置、会话、日志等运行时数据存储在此目录下。 ```sh export KIMI_SHARE_DIR="/path/to/custom/kimi" ``` 详见 [数据路径](./data-locations.md)。 ::: warning 注意 `KIMI_SHARE_DIR` 不影响 [Agent Skills](../customization/skills.md) 的搜索路径。Skills 是跨工具共享的能力扩展(与 Claude、Codex 等兼容),与应用运行时数据是不同类型的数据。如需覆盖 Skills 路径,请使用 `--skills-dir` 参数。 ::: ### `KIMI_CLI_NO_AUTO_UPDATE` 设置为 `1`、`true`、`t`、`yes` 或 `y`(不区分大小写)时,禁用 Shell 模式下的后台自动更新检查。 ```sh export KIMI_CLI_NO_AUTO_UPDATE="1" ``` ::: tip 提示 如果你通过 Nix 或其他包管理器安装 Kimi Code CLI,通常会自动设置此环境变量,因为更新由包管理器处理。 ::: ================================================ FILE: docs/zh/configuration/overrides.md ================================================ # 配置覆盖 Kimi Code CLI 的配置可以通过多种方式设置,不同来源的配置按优先级覆盖。 ## 优先级 配置的优先级从高到低为: 1. **环境变量** - 最高优先级,用于临时覆盖或 CI/CD 环境 2. **CLI 参数** - 启动时指定的参数 3. **配置文件** - `~/.kimi/config.toml` 或通过 `--config-file` 指定的文件 ## CLI 参数 ### 配置文件相关 | 参数 | 说明 | | --- | --- | | `--config ` | 直接传入配置内容,覆盖默认配置文件 | | `--config-file ` | 指定配置文件路径,替代默认的 `~/.kimi/config.toml` | `--config` 和 `--config-file` 不能同时使用。 ### 模型相关 | 参数 | 说明 | | --- | --- | | `--model, -m ` | 指定使用的模型名称 | `--model` 指定的模型必须在配置文件的 `models` 中定义。如果未指定,使用配置文件中的 `default_model`。 ### 行为相关 | 参数 | 说明 | | --- | --- | | `--thinking` | 启用 thinking 模式 | | `--no-thinking` | 禁用 thinking 模式 | | `--yolo, --yes, -y` | 自动批准所有操作 | `--thinking` / `--no-thinking` 会覆盖上次会话保存的 thinking 状态。如果不指定,使用上次会话的状态。 ## 环境变量覆盖 环境变量可以在不修改配置文件的情况下覆盖供应商和模型设置。这在以下场景特别有用: - CI/CD 环境中注入密钥 - 临时测试不同的 API 端点 - 在多个环境间切换 环境变量根据当前使用的供应商类型来决定是否生效: - `kimi` 类型的供应商:使用 `KIMI_*` 环境变量 - `openai_legacy` 或 `openai_responses` 类型的供应商:使用 `OPENAI_*` 环境变量 - 其他类型的供应商:不支持环境变量覆盖 完整的环境变量列表请参阅 [环境变量](./env-vars.md)。 示例: ```sh KIMI_API_KEY="sk-xxx" KIMI_MODEL_NAME="kimi-k2-thinking-turbo" kimi ``` ## 配置优先级示例 假设配置文件 `~/.kimi/config.toml` 内容如下: ```toml default_model = "kimi-for-coding" [providers.kimi-for-coding] type = "kimi" base_url = "https://api.kimi.com/coding/v1" api_key = "sk-config" [models.kimi-for-coding] provider = "kimi-for-coding" model = "kimi-for-coding" max_context_size = 262144 ``` 以下是不同场景的配置来源: | 场景 | `base_url` | `api_key` | `model` | | --- | --- | --- | --- | | `kimi` | 配置文件 | 配置文件 | 配置文件 | | `KIMI_API_KEY=sk-env kimi` | 配置文件 | 环境变量 | 配置文件 | | `kimi --model other` | 配置文件 | 配置文件 | CLI 参数 | | `KIMI_MODEL_NAME=k2 kimi` | 配置文件 | 配置文件 | 环境变量 | ================================================ FILE: docs/zh/configuration/providers.md ================================================ # 平台与模型 Kimi Code CLI 支持多种 LLM 平台,可以通过配置文件或 `/login` 命令进行配置。 ## 平台选择 最简单的配置方式是在 Shell 模式下运行 `/login` 命令(别名 `/setup`),按照向导完成平台和模型的选择: 1. 选择 API 平台 2. 输入 API 密钥 3. 从可用模型列表中选择模型 配置完成后,Kimi Code CLI 会自动保存设置到 `~/.kimi/config.toml` 并重新加载。 `/login` 目前支持以下平台: | 平台 | 说明 | | --- | --- | | Kimi Code | Kimi Code 平台,支持搜索和抓取服务 | | Moonshot AI 开放平台 (moonshot.cn) | 中国区 API 端点 | | Moonshot AI Open Platform (moonshot.ai) | 全球区 API 端点 | 如需使用其他平台,请手动编辑配置文件。 ## 供应商类型 `providers` 配置中的 `type` 字段指定 API 供应商类型。不同类型使用不同的 API 协议和客户端实现。 | 类型 | 说明 | | --- | --- | | `kimi` | Kimi API | | `openai_legacy` | OpenAI Chat Completions API | | `openai_responses` | OpenAI Responses API | | `anthropic` | Anthropic Claude API | | `gemini` | Google Gemini API | | `vertexai` | Google Vertex AI | ### `kimi` 用于连接 Kimi API,包括 Kimi Code 和 Moonshot AI 开放平台。 ```toml [providers.kimi-for-coding] type = "kimi" base_url = "https://api.kimi.com/coding/v1" api_key = "sk-xxx" ``` ### `openai_legacy` 兼容 OpenAI Chat Completions API 的平台,包括 OpenAI 官方 API 和各种兼容服务。 ```toml [providers.openai] type = "openai_legacy" base_url = "https://api.openai.com/v1" api_key = "sk-xxx" ``` ### `openai_responses` 用于 OpenAI Responses API(较新的 API 格式)。 ```toml [providers.openai-responses] type = "openai_responses" base_url = "https://api.openai.com/v1" api_key = "sk-xxx" ``` ### `anthropic` 用于连接 Anthropic Claude API。 ```toml [providers.anthropic] type = "anthropic" base_url = "https://api.anthropic.com" api_key = "sk-ant-xxx" ``` ### `gemini` 用于连接 Google Gemini API。 ```toml [providers.gemini] type = "gemini" base_url = "https://generativelanguage.googleapis.com" api_key = "xxx" ``` ### `vertexai` 用于连接 Google Vertex AI。需要通过 `env` 字段设置必要的环境变量。 ```toml [providers.vertexai] type = "vertexai" base_url = "https://xxx-aiplatform.googleapis.com" api_key = "" env = { GOOGLE_CLOUD_PROJECT = "your-project-id" } ``` ## 模型能力 模型配置中的 `capabilities` 字段声明模型支持的能力。这会影响 Kimi Code CLI 的功能可用性。 | 能力 | 说明 | | --- | --- | | `thinking` | 支持 Thinking 模式(深度思考),可开关 | | `always_thinking` | 始终使用 Thinking 模式(不可关闭) | | `image_in` | 支持图片输入 | | `video_in` | 支持视频输入 | ```toml [models.gemini-3-pro-preview] provider = "gemini" model = "gemini-3-pro-preview" max_context_size = 262144 capabilities = ["thinking", "image_in"] ``` ### `thinking` 声明模型支持 Thinking 模式。启用后,模型会在回答前进行更深入的推理,适合复杂问题。在 Shell 模式下,可以通过 `/model` 命令切换模型和 Thinking 模式,或在启动时通过 `--thinking` / `--no-thinking` 参数控制。 ### `always_thinking` 表示模型始终使用 Thinking 模式,无法关闭。例如 `kimi-k2-thinking-turbo` 等名称中包含 "thinking" 的模型通常具有此能力。使用这类模型时,`/model` 命令不会提示选择 Thinking 模式的开关。 ### `image_in` 启用图片输入能力后,可以在对话中粘贴图片(`Ctrl-V`)。 ### `video_in` 启用视频输入能力后,可以在对话中发送视频内容。 ## 搜索和抓取服务 `SearchWeb` 和 `FetchURL` 工具依赖外部服务,目前仅 Kimi Code 平台提供这些服务。 使用 `/login` 选择 Kimi Code 平台时,搜索和抓取服务会自动配置。 | 服务 | 对应工具 | 未配置时的行为 | | --- | --- | --- | | `moonshot_search` | `SearchWeb` | 工具不可用 | | `moonshot_fetch` | `FetchURL` | 回退到本地抓取 | 使用其他平台时,`FetchURL` 工具仍可使用,但会回退到本地抓取。 ================================================ FILE: docs/zh/customization/agents.md ================================================ # Agent 与子 Agent Agent 定义了 AI 的行为方式,包括系统提示词、可用工具和子 Agent。你可以使用内置 Agent,也可以创建自定义 Agent。 ## 内置 Agent Kimi Code CLI 提供两个内置 Agent。启动时可以通过 `--agent` 参数选择: ```sh kimi --agent okabe ``` ### `default` 默认 Agent,适合通常情况使用。启用的工具: `Task`、`AskUserQuestion`、`SetTodoList`、`Shell`、`ReadFile`、`ReadMediaFile`、`Glob`、`Grep`、`WriteFile`、`StrReplaceFile`、`SearchWeb`、`FetchURL`、`EnterPlanMode`、`ExitPlanMode`、`TaskList`、`TaskOutput`、`TaskStop` ### `okabe` 实验性 Agent,用于实验新的提示词和工具。在 `default` 的基础上额外启用 `SendDMail`。 ## 自定义 Agent 文件 Agent 使用 YAML 格式定义。通过 `--agent-file` 参数加载自定义 Agent: ```sh kimi --agent-file /path/to/my-agent.yaml ``` **基本结构** ```yaml version: 1 agent: name: my-agent system_prompt_path: ./system.md tools: - "kimi_cli.tools.shell:Shell" - "kimi_cli.tools.file:ReadFile" - "kimi_cli.tools.file:WriteFile" ``` **继承与覆盖** 使用 `extend` 可以继承其他 Agent 的配置,只覆盖需要修改的部分: ```yaml version: 1 agent: extend: default # 继承默认 Agent system_prompt_path: ./my-prompt.md # 覆盖系统提示词 exclude_tools: # 排除某些工具 - "kimi_cli.tools.web:SearchWeb" - "kimi_cli.tools.web:FetchURL" ``` `extend: default` 会继承内置的默认 Agent。你也可以指定相对路径继承其他 Agent 文件。 **配置字段** | 字段 | 说明 | 是否必填 | |------|------|----------| | `extend` | 继承的 Agent,可以是 `default` 或相对路径 | 否 | | `name` | Agent 名称 | 是(继承时可省略) | | `system_prompt_path` | 系统提示词文件路径,相对于 Agent 文件 | 是(继承时可省略) | | `system_prompt_args` | 传递给系统提示词的自定义参数,继承时会合并 | 否 | | `tools` | 工具列表,格式为 `模块:类名` | 是(继承时可省略) | | `exclude_tools` | 要排除的工具 | 否 | | `subagents` | 子 Agent 定义 | 否 | ## 系统提示词内置参数 系统提示词文件是一个 Markdown 模板,可以使用 `${VAR}` 语法引用变量。内置变量包括: | 变量 | 说明 | |------|------| | `${KIMI_NOW}` | 当前时间(ISO 格式) | | `${KIMI_WORK_DIR}` | 工作目录路径 | | `${KIMI_WORK_DIR_LS}` | 工作目录文件列表 | | `${KIMI_AGENTS_MD}` | AGENTS.md 文件内容(如果存在) | | `${KIMI_SKILLS}` | 加载的 Skills 列表 | | `${KIMI_ADDITIONAL_DIRS_INFO}` | 通过 `--add-dir` 或 `/add-dir` 添加的额外目录信息 | 你也可以通过 `system_prompt_args` 定义自定义参数: ```yaml agent: system_prompt_args: MY_VAR: "自定义值" ``` 然后在提示词中使用 `${MY_VAR}`。 **系统提示词示例** ```markdown # My Agent You are a helpful assistant. Current time: ${KIMI_NOW}. Working directory: ${KIMI_WORK_DIR} ${MY_VAR} ``` ## 在 Agent 文件中定义子 Agent 子 Agent 可以处理特定类型的任务。在 Agent 文件中定义子 Agent 后,主 Agent 可以通过 `Task` 工具启动它们: ```yaml version: 1 agent: extend: default subagents: coder: path: ./coder-sub.yaml description: "处理编码任务" reviewer: path: ./reviewer-sub.yaml description: "代码审查专家" ``` 子 Agent 文件也是标准的 Agent 格式,通常会继承主 Agent 并排除某些工具: ```yaml # coder-sub.yaml version: 1 agent: extend: ./agent.yaml # 继承主 Agent system_prompt_args: ROLE_ADDITIONAL: | 你现在作为子 Agent 运行... exclude_tools: - "kimi_cli.tools.multiagent:Task" # 排除 Task 工具,避免嵌套 ``` ## 子 Agent 的运行方式 通过 `Task` 工具启动的子 Agent 会在独立的上下文中运行,完成后将结果返回给主 Agent。这种方式的优势: - 隔离上下文,避免污染主 Agent 的对话历史 - 可以并行处理多个独立任务 - 子 Agent 可以有针对性的系统提示词 ## 动态创建子 Agent `CreateSubagent` 是一个高级工具,允许 AI 在运行时动态定义新的子 Agent 类型(默认未启用)。动态创建的子 Agent 会随会话状态持久化,恢复会话时自动还原。如需使用,在 Agent 文件中添加: ```yaml agent: tools: - "kimi_cli.tools.multiagent:CreateSubagent" ``` ## 内置工具列表 以下是 Kimi Code CLI 内置的所有工具。 ### `Task` - **路径**:`kimi_cli.tools.multiagent:Task` - **描述**:调度子 Agent 执行任务。子 Agent 无法访问主 Agent 的上下文,需在 prompt 中提供所有必要信息。 | 参数 | 类型 | 说明 | |------|------|------| | `description` | string | 任务简短描述(3-5 词) | | `subagent_name` | string | 子 Agent 名称 | | `prompt` | string | 任务详细描述 | ### `AskUserQuestion` - **路径**:`kimi_cli.tools.ask_user:AskUserQuestion` - **描述**:在执行过程中向用户展示结构化问题和选项,收集用户偏好或决策。适用于需要用户在多个方案中做出选择、解决模糊指令或收集需求信息的场景。不应过度使用——只在用户的选择真正影响后续操作时才调用。 | 参数 | 类型 | 说明 | |------|------|------| | `questions` | array | 问题列表(1–4 个问题) | | `questions[].question` | string | 问题文本,以 `?` 结尾 | | `questions[].header` | string | 短标签,最多 12 字符(如 `Auth`、`Style`) | | `questions[].options` | array | 可选项(2–4 个),系统会自动添加 "Other" 选项 | | `questions[].options[].label` | string | 选项标签(1–5 词),推荐选项可追加 `(Recommended)` | | `questions[].options[].description` | string | 选项说明 | | `questions[].multi_select` | bool | 是否允许多选,默认 false | ### `SetTodoList` - **路径**:`kimi_cli.tools.todo:SetTodoList` - **描述**:管理待办事项列表,跟踪任务进度 | 参数 | 类型 | 说明 | |------|------|------| | `todos` | array | 待办事项列表 | | `todos[].title` | string | 待办事项标题 | | `todos[].status` | string | 状态:`pending`、`in_progress`、`done` | ### `Shell` - **路径**:`kimi_cli.tools.shell:Shell` - **描述**:执行 Shell 命令。需要用户审批。根据操作系统使用对应的 Shell(Unix 使用 bash/zsh,Windows 使用 PowerShell)。 | 参数 | 类型 | 说明 | |------|------|------| | `command` | string | 要执行的命令 | | `timeout` | int | 超时时间(秒),默认 60,前台最大 300 / 后台最大 86400 | | `run_in_background` | bool | 是否作为后台任务运行,默认 false | | `description` | string | 后台任务的简短描述,`run_in_background=true` 时必填 | 设置 `run_in_background=true` 后,命令会作为后台任务启动,工具立即返回任务 ID,AI 可以继续执行其他操作。任务完成时系统自动发送通知。适用于耗时的构建、测试、监控等场景。 ### `ReadFile` - **路径**:`kimi_cli.tools.file:ReadFile` - **描述**:读取文本文件内容。单次最多读取 1000 行,每行最多 2000 字符。工作目录外的文件需使用绝对路径。 | 参数 | 类型 | 说明 | |------|------|------| | `path` | string | 文件路径 | | `line_offset` | int | 起始行号,默认 1 | | `n_lines` | int | 读取行数,默认/最大 1000 | ### `ReadMediaFile` - **路径**:`kimi_cli.tools.file:ReadMediaFile` - **描述**:读取图片或视频文件。文件最大 100MB。仅当模型支持图片/视频输入时可用。工作目录外的文件需使用绝对路径。 | 参数 | 类型 | 说明 | |------|------|------| | `path` | string | 文件路径 | ### `Glob` - **路径**:`kimi_cli.tools.file:Glob` - **描述**:按模式匹配文件和目录。最多返回 1000 个匹配项,不允许以 `**` 开头的模式。 | 参数 | 类型 | 说明 | |------|------|------| | `pattern` | string | Glob 模式(如 `*.py`、`src/**/*.ts`) | | `directory` | string | 搜索目录,默认工作目录 | | `include_dirs` | bool | 是否包含目录,默认 true | ### `Grep` - **路径**:`kimi_cli.tools.file:Grep` - **描述**:使用正则表达式搜索文件内容,基于 ripgrep 实现 | 参数 | 类型 | 说明 | |------|------|------| | `pattern` | string | 正则表达式模式 | | `path` | string | 搜索路径,默认当前目录 | | `glob` | string | 文件过滤(如 `*.js`) | | `type` | string | 文件类型(如 `py`、`js`、`go`) | | `output_mode` | string | 输出模式:`files_with_matches`(默认)、`content`、`count_matches` | | `-B` | int | 显示匹配行前 N 行 | | `-A` | int | 显示匹配行后 N 行 | | `-C` | int | 显示匹配行前后 N 行 | | `-n` | bool | 显示行号 | | `-i` | bool | 忽略大小写 | | `multiline` | bool | 启用多行匹配 | | `head_limit` | int | 限制输出行数 | ### `WriteFile` - **路径**:`kimi_cli.tools.file:WriteFile` - **描述**:写入文件。写入操作需要用户审批。写入工作目录外文件时,必须使用绝对路径。 | 参数 | 类型 | 说明 | |------|------|------| | `path` | string | 绝对路径 | | `content` | string | 文件内容 | | `mode` | string | `overwrite`(默认)或 `append` | ### `StrReplaceFile` - **路径**:`kimi_cli.tools.file:StrReplaceFile` - **描述**:使用字符串替换编辑文件。编辑操作需要用户审批。编辑工作目录外文件时,必须使用绝对路径。 | 参数 | 类型 | 说明 | |------|------|------| | `path` | string | 绝对路径 | | `edit` | object/array | 单个编辑或编辑列表 | | `edit.old` | string | 要替换的原字符串 | | `edit.new` | string | 替换后的字符串 | | `edit.replace_all` | bool | 是否替换所有匹配项,默认 false | ### `SearchWeb` - **路径**:`kimi_cli.tools.web:SearchWeb` - **描述**:搜索网页。需要配置搜索服务(Kimi Code 平台自动配置)。 | 参数 | 类型 | 说明 | |------|------|------| | `query` | string | 搜索关键词 | | `limit` | int | 结果数量,默认 5,最大 20 | | `include_content` | bool | 是否包含页面内容,默认 false | ### `FetchURL` - **路径**:`kimi_cli.tools.web:FetchURL` - **描述**:抓取网页内容,返回提取的主要文本内容。如果配置了抓取服务会优先使用,否则使用本地 HTTP 请求。 | 参数 | 类型 | 说明 | |------|------|------| | `url` | string | 要抓取的 URL | ### `Think` - **路径**:`kimi_cli.tools.think:Think` - **描述**:让 Agent 记录思考过程,适用于复杂推理场景 | 参数 | 类型 | 说明 | |------|------|------| | `thought` | string | 思考内容 | ### `SendDMail` - **路径**:`kimi_cli.tools.dmail:SendDMail` - **描述**:发送延迟消息(D-Mail),用于检查点回滚场景 | 参数 | 类型 | 说明 | |------|------|------| | `message` | string | 要发送的消息 | | `checkpoint_id` | int | 要发送回的检查点 ID(>= 0) | ### `EnterPlanMode` - **路径**:`kimi_cli.tools.plan.enter:EnterPlanMode` - **描述**:请求进入 Plan 模式。调用后会向用户展示审批请求,用户可以选择同意或拒绝进入 Plan 模式。在 YOLO 模式下,仅在用户明确要求规划或存在重大架构歧义时使用。详见 [Plan 模式](../guides/interaction.md#plan-模式)。 此工具不接受参数。 ### `ExitPlanMode` - **路径**:`kimi_cli.tools.plan:ExitPlanMode` - **描述**:在 Plan 模式下完成方案后提交审批。调用前需先将方案写入 plan 文件,此工具会读取 plan 文件内容并展示给用户审批。用户可以选择某个实施路径(退出 Plan 模式并开始执行)、拒绝(保持 Plan 模式等待反馈)或提供修改意见。详见 [Plan 模式](../guides/interaction.md#plan-模式)。 | 参数 | 类型 | 说明 | |------|------|------| | `options` | list \| null | 当方案包含多个可选实施路径时,列出 2–3 个选项供用户选择。每个选项有 `label`(1–8 个词的简短标签,可附加 "(Recommended)")和可选的 `description`(方案摘要)。不可使用 "Approve"、"Reject"、"Revise" 作为标签名。 | ### `TaskList` - **路径**:`kimi_cli.tools.background:TaskList` - **描述**:列出当前会话中的后台任务。适用于上下文压缩后重新获取任务 ID,或检查哪些任务仍在运行。 | 参数 | 类型 | 说明 | |------|------|------| | `active_only` | bool | 是否仅列出活跃任务,默认 true | | `limit` | int | 返回的最大任务数(1–100),默认 20 | ### `TaskOutput` - **路径**:`kimi_cli.tools.background:TaskOutput` - **描述**:获取后台任务的输出和状态。支持阻塞等待或非阻塞查询。返回结构化的任务元数据和输出预览;如果输出被截断,可使用 `ReadFile` 分页读取完整日志。 | 参数 | 类型 | 说明 | |------|------|------| | `task_id` | string | 要查询的任务 ID | | `block` | bool | 是否等待任务完成,默认 true | | `timeout` | int | `block=true` 时的最大等待秒数(0–3600),默认 30 | ### `TaskStop` - **路径**:`kimi_cli.tools.background:TaskStop` - **描述**:停止正在运行的后台任务。需要用户审批。仅在任务必须取消时使用;对于正常完成的任务,应等待自动通知。在 Plan 模式下不可用。 | 参数 | 类型 | 说明 | |------|------|------| | `task_id` | string | 要停止的任务 ID | | `reason` | string | 停止原因(可选),默认 "Stopped by TaskStop" | ### `CreateSubagent` - **路径**:`kimi_cli.tools.multiagent:CreateSubagent` - **描述**:动态创建子 Agent | 参数 | 类型 | 说明 | |------|------|------| | `name` | string | 子 Agent 的唯一名称,用于在 `Task` 工具中引用 | | `system_prompt` | string | 定义 Agent 角色、能力和边界的系统提示词 | ## 工具安全边界 **工作区范围** - 文件读写通常在工作目录(及通过 `--add-dir` 或 `/add-dir` 添加的额外目录)内进行 - 读取工作区外文件需使用绝对路径 - 写入和编辑操作都需要用户审批;操作工作区外文件时,必须使用绝对路径 **审批机制** 以下操作需要用户审批: | 操作 | 审批要求 | |------|---------| | Shell 命令执行 | 每次执行 | | 文件写入/编辑 | 每次操作 | | MCP 工具调用 | 每次调用 | | 停止后台任务 | 每次停止 | ================================================ FILE: docs/zh/customization/mcp.md ================================================ # Model Context Protocol [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) 是一个开放协议,让 AI 模型可以安全地与外部工具和数据源交互。Kimi Code CLI 支持连接 MCP 服务器,扩展 AI 的能力。 ## MCP 是什么 MCP 服务器提供「工具」给 AI 使用。例如,一个数据库 MCP 服务器可以提供查询工具,让 AI 能够执行 SQL 查询;一个浏览器 MCP 服务器可以让 AI 控制浏览器进行自动化操作。 Kimi Code CLI 内置了一些工具(文件读写、Shell 命令、网页抓取等),通过 MCP 你可以添加更多工具,比如: - 访问特定 API 或数据库 - 控制浏览器或其他应用 - 与第三方服务集成(GitHub、Linear、Notion 等) ## MCP 服务器管理 使用 [`kimi mcp`](../reference/kimi-mcp.md) 命令管理 MCP 服务器。 **添加服务器** 添加 HTTP 服务器: ```sh # 基本用法 kimi mcp add --transport http context7 https://mcp.context7.com/mcp # 带 Header kimi mcp add --transport http context7 https://mcp.context7.com/mcp \ --header "CONTEXT7_API_KEY: your-key" # 使用 OAuth 认证 kimi mcp add --transport http --auth oauth linear https://mcp.linear.app/mcp ``` 添加 stdio 服务器(本地进程): ```sh kimi mcp add --transport stdio chrome-devtools -- npx chrome-devtools-mcp@latest ``` **列出服务器** ```sh kimi mcp list ``` 在 Kimi Code CLI 运行时,也可以输入 `/mcp` 查看已连接的服务器和加载的工具。 **移除服务器** ```sh kimi mcp remove context7 ``` **OAuth 授权** 对于使用 OAuth 的服务器,需要先完成授权: ```sh kimi mcp auth linear ``` 这会打开浏览器完成 OAuth 流程。授权成功后,Kimi Code CLI 会保存 token 供后续使用。 **测试服务器** ```sh kimi mcp test context7 ``` ## MCP 配置文件 MCP 服务器配置存储在 `~/.kimi/mcp.json`,格式与其他 MCP 客户端兼容: ```json { "mcpServers": { "context7": { "url": "https://mcp.context7.com/mcp", "headers": { "CONTEXT7_API_KEY": "your-key" } }, "chrome-devtools": { "command": "npx", "args": ["chrome-devtools-mcp@latest"], "env": { "SOME_VAR": "value" } } } } ``` **临时加载配置** 使用 `--mcp-config-file` 参数可以加载其他位置的配置文件: ```sh kimi --mcp-config-file /path/to/mcp.json ``` 使用 `--mcp-config` 参数可以直接传入 JSON 配置: ```sh kimi --mcp-config '{"mcpServers": {"test": {"url": "https://..."}}}' ``` ## 加载状态 MCP 服务器在 Shell UI 启动后异步初始化,不会阻塞界面的使用。Shell 底部状态栏会实时显示连接进度,连接完成后自动切换为就绪状态。Web 界面也会同步显示各服务器的连接状态。 如果配置了多个 MCP 服务器,加载时间可能较长,状态栏的进度指示可以帮助你了解当前连接情况。 ## 安全性 MCP 工具可能会访问和操作外部系统,需要注意安全风险。 **审批机制** Kimi Code CLI 对敏感操作(如文件修改、命令执行)会请求用户确认。MCP 工具也遵循同样的审批机制,所有 MCP 工具调用都会弹出确认提示。 **提示词注入风险** MCP 工具返回的内容可能包含恶意指令,试图诱导 AI 执行危险操作。Kimi Code CLI 会对工具返回内容进行标记,帮助 AI 区分工具输出和用户指令,但你仍应: - 只使用可信来源的 MCP 服务器 - 检查 AI 提议的操作是否合理 - 对于高风险操作保持手动审批 ::: warning 注意 在 YOLO 模式下,MCP 工具的操作也会被自动批准。建议仅在完全信任 MCP 服务器的情况下使用 YOLO 模式。 ::: ================================================ FILE: docs/zh/customization/print-mode.md ================================================ # Print 模式 Print 模式让 Kimi Code CLI 以非交互方式运行,适合脚本调用和自动化场景。 ## 基本用法 使用 `--print` 参数启用 Print 模式: ```sh # 通过 -p 传入指令(或 -c) kimi --print -p "列出当前目录的所有 Python 文件" # 通过 stdin 传入指令 echo "解释这段代码的作用" | kimi --print ``` Print 模式的特点: - **非交互**:执行完指令后自动退出 - **自动审批**:隐式启用 `--yolo` 模式,所有操作自动批准 - **文本输出**:AI 的回复输出到 stdout ## 仅输出最终消息 使用 `--final-message-only` 选项可以只输出最终的 assistant 消息,跳过中间的工具调用过程: ```sh kimi --print -p "根据当前变更给我一个 Git commit message" --final-message-only ``` `--quiet` 是 `--print --output-format text --final-message-only` 的快捷方式,适合只需要最终结果的场景: ```sh kimi --quiet -p "根据当前变更给我一个 Git commit message" ``` ## JSON 格式 Print 模式支持 JSON 格式的输入和输出,方便程序化处理。输入和输出都使用 [Message](#message-格式) 格式。 **JSON 输出** 使用 `--output-format=stream-json` 以 JSONL(每行一个 JSON)格式输出: ```sh kimi --print -p "你好" --output-format=stream-json ``` 输出示例: ```jsonl {"role":"assistant","content":"你好!有什么可以帮助你的吗?"} ``` 如果 AI 调用了工具,会依次输出 assistant 消息和 tool 消息: ```jsonl {"role":"assistant","content":"让我查看一下当前目录。","tool_calls":[{"type":"function","id":"tc_1","function":{"name":"Shell","arguments":"{\"command\":\"ls\"}"}}]} {"role":"tool","tool_call_id":"tc_1","content":"file1.py\nfile2.py"} {"role":"assistant","content":"当前目录有两个 Python 文件。"} ``` **JSON 输入** 使用 `--input-format=stream-json` 接收 JSONL 格式的输入: ```sh echo '{"role":"user","content":"你好"}' | kimi --print --input-format=stream-json --output-format=stream-json ``` 这种模式下,Kimi Code CLI 会持续读取 stdin,每收到一条用户消息就处理并输出响应,直到 stdin 关闭。 ## Message 格式 输入和输出都使用统一的 Message 格式。 **User 消息** ```json {"role": "user", "content": "你的问题或指令"} ``` 也可以使用数组形式的 content: ```json {"role": "user", "content": [{"type": "text", "text": "你的问题"}]} ``` **Assistant 消息** ```json {"role": "assistant", "content": "回复内容"} ``` 带工具调用的助手消息: ```json { "role": "assistant", "content": "让我执行这个命令。", "tool_calls": [ { "type": "function", "id": "tc_1", "function": { "name": "Shell", "arguments": "{\"command\":\"ls\"}" } } ] } ``` **Tool 消息** ```json {"role": "tool", "tool_call_id": "tc_1", "content": "工具执行结果"} ``` ## 使用场景 **CI/CD 集成** 在 CI 流程中自动生成代码或执行检查: ```sh kimi --print -p "检查 src/ 目录下是否有明显的安全问题,输出 JSON 格式的报告" ``` **批量处理** 结合 shell 循环批量处理文件: ```sh for file in src/*.py; do kimi --print -p "为 $file 添加类型注解" done ``` **与其他工具集成** 作为其他工具的后端,通过 JSON 格式进行通信: ```sh my-tool | kimi --print --input-format=stream-json --output-format=stream-json | process-output ``` ================================================ FILE: docs/zh/customization/skills.md ================================================ # Agent Skills [Agent Skills](https://agentskills.io/) 是一个开放格式,用于为 AI Agent 添加专业知识和工作流程。Kimi Code CLI 支持加载 Agent Skills,扩展 AI 的能力。 ## Agent Skills 是什么 一个 Skill 就是一个包含 `SKILL.md` 文件的目录。Kimi Code CLI 启动时会发现所有 Skills,并将它们的名称、路径和描述注入到系统提示词中。AI 会根据当前任务的需要,自行决定是否读取具体的 `SKILL.md` 文件来获取详细指引。 例如,你可以创建一个「代码风格」Skill,告诉 AI 你项目的命名规范、注释风格等;或者创建一个「安全审计」Skill,让 AI 在审查代码时关注特定的安全问题。 ## Skill 发现 Kimi Code CLI 采用分层加载机制发现 Skills,按以下优先级加载(后加载的会覆盖同名 Skill): **内置 Skills** 随软件包安装的 Skills,提供基础能力。 **用户级 Skills** 存放在用户主目录中,在所有项目中生效。Kimi Code CLI 会按优先级检查以下目录,使用第一个存在的目录: 1. `~/.config/agents/skills/`(推荐) 2. `~/.agents/skills/` 3. `~/.kimi/skills/` 4. `~/.claude/skills/` 5. `~/.codex/skills/` **项目级 Skills** 存放在项目目录中,仅在该项目工作目录下生效。Kimi Code CLI 会按优先级检查以下目录,使用第一个存在的目录: 1. `.agents/skills/`(推荐) 2. `.kimi/skills/` 3. `.claude/skills/` 4. `.codex/skills/` 你也可以通过 `--skills-dir` 参数指定其他目录,此时会跳过用户级和项目级 Skills 的发现: ```sh kimi --skills-dir /path/to/my-skills ``` ::: tip 提示 Skills 路径独立于 [`KIMI_SHARE_DIR`](../configuration/env-vars.md#kimi-share-dir)。`KIMI_SHARE_DIR` 用于自定义配置、会话、日志等运行时数据的存储位置,不影响 Skills 的搜索路径。Skills 是跨工具共享的能力扩展(支持 Kimi CLI、Claude、Codex 等多个工具共用),与应用运行时数据是不同类型的数据。如需覆盖 Skills 路径,请使用 `--skills-dir` 参数。 ::: ## 内置 Skills Kimi Code CLI 内置了以下 Skills: - **kimi-cli-help**:Kimi Code CLI 帮助。解答关于 Kimi Code CLI 安装、配置、斜杠命令、键盘快捷键、MCP 集成、供应商、环境变量等问题。 - **skill-creator**:Skill 创建指南。当你需要创建新的 Skill(或更新现有 Skill)来扩展 Kimi 的能力时,可以使用此 Skill 获取详细的创建指导和最佳实践。 ## 创建 Skill 创建一个 Skill 只需要两步: 1. 在 skills 目录下创建一个子目录 2. 在子目录中创建 `SKILL.md` 文件 **目录结构** 一个 Skill 目录至少需要包含 `SKILL.md` 文件,也可以包含辅助目录来组织更复杂的内容: ``` ~/.config/agents/skills/ └── my-skill/ ├── SKILL.md # 必需:主文件 ├── scripts/ # 可选:脚本文件 ├── references/ # 可选:参考文档 └── assets/ # 可选:其他资源 ``` **`SKILL.md` 格式** `SKILL.md` 使用 YAML Frontmatter 定义元数据,后面是 Markdown 格式的提示词内容: ```markdown --- name: code-style description: 我的项目代码风格规范 --- ## 代码风格 在这个项目中,请遵循以下规范: - 使用 4 空格缩进 - 变量名使用 camelCase - 函数名使用 snake_case - 每个函数都需要 docstring - 单行不超过 100 字符 ``` **Frontmatter 字段** | 字段 | 说明 | 是否必填 | |------|------|----------| | `name` | Skill 名称,1-64 字符,只能使用小写字母、数字和连字符;省略时默认使用目录名 | 否 | | `description` | Skill 描述,1-1024 字符,说明 Skill 的用途和使用场景;省略时显示 "No description provided." | 否 | | `license` | 许可证名称或文件引用 | 否 | | `compatibility` | 环境要求说明,最多 500 字符 | 否 | | `metadata` | 额外的键值对属性 | 否 | **最佳实践** - 保持 `SKILL.md` 在 500 行以内,将详细内容移到 `scripts/`、`references/` 或 `assets/` 目录 - 在 `SKILL.md` 中使用相对路径引用其他文件 - 提供清晰的步骤指引、输入输出示例和边界情况说明 ## 示例 Skill **PPT 制作** ```markdown --- name: pptx description: 创建和编辑 PowerPoint 演示文稿 --- ## PPT 制作流程 创建演示文稿时,遵循以下步骤: 1. 分析内容结构,规划幻灯片大纲 2. 选择合适的配色方案和字体 3. 使用 python-pptx 库生成 .pptx 文件 ## 设计原则 - 每页幻灯片聚焦一个主题 - 文字简洁,使用要点而非长段落 - 保持视觉层次清晰,标题、正文、注释有明确区分 - 配色统一,避免超过 3 种主色 ``` **Python 项目规范** ```markdown --- name: python-project description: Python 项目开发规范,包括代码风格、测试和依赖管理 --- ## Python 开发规范 - 使用 Python 3.14+ - 使用 ruff 进行代码格式化和 lint - 使用 pyright 进行类型检查 - 测试使用 pytest - 依赖管理使用 uv 代码风格: - 行长度限制 100 字符 - 使用类型注解 - 公开函数需要 docstring ``` **Git 提交规范** ```markdown --- name: git-commits description: Git 提交信息规范,使用 Conventional Commits 格式 --- ## Git 提交规范 使用 Conventional Commits 格式: 类型(范围): 描述 允许的类型:feat, fix, docs, style, refactor, test, chore 示例: - feat(auth): 添加 OAuth 登录支持 - fix(api): 修复用户查询返回空值的问题 - docs(readme): 更新安装说明 ``` ## 使用斜杠命令加载 Skill `/skill:` 斜杠命令让你可以将常用的提示词模板保存为 Skill,需要时快速调用。输入命令后,Kimi Code CLI 会读取对应的 `SKILL.md` 文件内容,并将其作为提示词发送给 Agent。 例如: - `/skill:code-style`:加载代码风格规范 - `/skill:pptx`:加载 PPT 制作流程 - `/skill:git-commits 修复用户登录问题`:加载 Git 提交规范,同时附带额外的任务描述 斜杠命令后面可以附带额外的文本,这些内容会追加到 Skill 提示词之后,作为用户的具体请求。 ::: tip 提示 如果只是普通对话,Agent 会根据上下文自动判断是否需要读取 Skill 内容,不需要手动调用。 ::: Skills 让你可以将团队的最佳实践和项目规范固化下来,确保 AI 始终遵循一致的标准。 ## Flow Skills Flow Skill 是一种特殊的 Skill 类型,它在 `SKILL.md` 中内嵌 Agent Flow 流程图,用于定义多步骤的自动化工作流。与普通 Skill 不同,Flow Skill 通过 `/flow:` 命令调用,会按照流程图自动执行多个对话轮次。 **创建 Flow Skill** 创建 Flow Skill 需要在 Frontmatter 中设置 `type: flow`,并在内容中包含 Mermaid 或 D2 格式的流程图代码块: ````markdown --- name: code-review description: 代码审查工作流 type: flow --- ```mermaid flowchart TD A([BEGIN]) --> B[分析代码变更,列出所有修改的文件和功能] B --> C{代码质量是否达标?} C -->|是| D[生成代码审查报告] C -->|否| E[列出问题并提出改进建议] E --> B D --> F([END]) ``` ```` **流程图格式** 支持 Mermaid 和 D2 两种格式: - **Mermaid**:使用 ` ```mermaid ` 代码块,[Mermaid Playground](https://www.mermaidchart.com/play) 可用于编辑和预览 - **D2**:使用 ` ```d2 ` 代码块,[D2 Playground](https://play.d2lang.com) 可用于编辑和预览 流程图必须包含一个 `BEGIN` 节点和一个 `END` 节点。普通节点的文本作为提示词发送给 Agent;分支节点需要 Agent 在输出中使用 `分支名` 选择下一步。 **D2 格式示例** ``` BEGIN -> B -> C B: 分析现有代码,为 XXX 功能编写设计文档 C: Review 设计文档是否足够详细 C -> B: 否 C -> D: 是 D: 开始实现 D -> END ``` 对于多行标签,可以使用 D2 的块字符串语法(`|md`): ``` BEGIN -> step -> END step: |md # 详细指引 1. 分析代码结构 2. 检查潜在问题 3. 生成报告 | ``` **执行 Flow Skill** Flow Skill 可以通过两种方式调用: - `/flow:`:执行流程,Agent 会从 `BEGIN` 节点开始,按照流程图定义依次处理每个节点,直到到达 `END` 节点 - `/skill:`:与普通 Skill 一样,将 `SKILL.md` 内容作为提示词发送给 Agent(不自动执行流程) ```sh # 执行流程 /flow:code-review # 作为普通 Skill 加载 /skill:code-review ``` ================================================ FILE: docs/zh/customization/wire-mode.md ================================================ # Wire 模式 Wire 模式是 Kimi Code CLI 的底层通信协议,用于与外部程序进行结构化的双向通信。 ## Wire 是什么 Wire 是 Kimi Code CLI 内部使用的消息传递层。当你使用终端交互时,Shell UI 通过 Wire 接收 AI 的输出并显示;当你使用 ACP 集成到 IDE 时,ACP 服务器也通过 Wire 与 Agent 核心通信。 Wire 模式(`--wire`)将这个通信协议暴露出来,允许外部程序直接与 Kimi Code CLI 交互。这适用于构建自定义 UI 或将 Kimi Code CLI 嵌入到其他应用中。 ```sh kimi --wire ``` ## 使用场景 Wire 模式主要用于: - **自定义 UI**:构建 Web、桌面或移动端的 Kimi Code CLI 前端 - **应用集成**:将 Kimi Code CLI 嵌入到其他应用程序中 - **自动化测试**:对 Agent 行为进行程序化测试 ::: tip 提示 如果你只需要简单的非交互输入输出,使用 [Print 模式](./print-mode.md) 更简单。Wire 模式适合需要完整控制和双向通信的场景。 ::: ## Wire 协议 Wire 使用基于 JSON-RPC 2.0 的协议,通过 stdin/stdout 进行双向通信。当前协议版本为 `1.5`。每条消息是一行 JSON,符合 JSON-RPC 2.0 规范。 ### 协议类型定义 ```typescript /** JSON-RPC 2.0 请求消息基础结构 */ interface JSONRPCRequest { jsonrpc: "2.0" method: Method id: string params: Params } /** JSON-RPC 2.0 通知消息(无 id,无需响应) */ interface JSONRPCNotification { jsonrpc: "2.0" method: Method params: Params } /** JSON-RPC 2.0 成功响应 */ interface JSONRPCSuccessResponse { jsonrpc: "2.0" id: string result: Result } /** JSON-RPC 2.0 错误响应 */ interface JSONRPCErrorResponse { jsonrpc: "2.0" id: string error: JSONRPCError } interface JSONRPCError { code: number message: string data?: unknown } ``` ### `initialize` ::: info 新增 新增于 Wire 1.1。旧版 Client 可跳过此请求,直接发送 `prompt`。 ::: - **方向**:Client → Agent - **类型**:Request(需要响应) 可选握手请求,用于协商协议版本、提交外部工具定义并获取斜杠命令列表。 ```typescript /** initialize 请求参数 */ interface InitializeParams { /** 协议版本 */ protocol_version: string /** Client 信息,可选 */ client?: ClientInfo /** 外部工具定义列表,可选 */ external_tools?: ExternalTool[] /** Client 能力声明,可选 */ capabilities?: ClientCapabilities } interface ClientCapabilities { /** 是否支持处理 QuestionRequest 消息 */ supports_question?: boolean /** 是否支持 Plan 模式 */ supports_plan_mode?: boolean } interface ClientInfo { name: string version?: string } interface ExternalTool { /** 工具名称,不可与内置工具冲突 */ name: string /** 工具描述 */ description: string /** JSON Schema 格式的参数定义 */ parameters: JSONSchema } /** initialize 响应结果 */ interface InitializeResult { /** 协议版本 */ protocol_version: string /** Server 信息 */ server: ServerInfo /** 可用的斜杠命令列表 */ slash_commands: SlashCommandInfo[] /** 外部工具注册结果,仅当请求中包含 external_tools 时返回 */ external_tools?: ExternalToolsResult /** Server 能力声明 */ capabilities?: ServerCapabilities } interface ServerCapabilities { /** 是否支持发送 QuestionRequest 消息 */ supports_question?: boolean } interface ServerInfo { name: string version: string } interface SlashCommandInfo { name: string description: string aliases: string[] } interface ExternalToolsResult { /** 成功注册的工具名称列表 */ accepted: string[] /** 注册失败的工具及原因 */ rejected: Array<{ name: string; reason: string }> } ``` **请求示例** ```json {"jsonrpc": "2.0", "method": "initialize", "id": "550e8400-e29b-41d4-a716-446655440000", "params": {"protocol_version": "1.5", "client": {"name": "my-ui", "version": "1.0.0"}, "capabilities": {"supports_question": true}, "external_tools": [{"name": "open_in_ide", "description": "Open file in IDE", "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}]}} ``` **成功响应示例** ```json {"jsonrpc": "2.0", "id": "550e8400-e29b-41d4-a716-446655440000", "result": {"protocol_version": "1.5", "server": {"name": "Kimi Code CLI", "version": "1.14.0"}, "slash_commands": [{"name": "init", "description": "Analyze the codebase ...", "aliases": []}], "capabilities": {"supports_question": true}, "external_tools": {"accepted": ["open_in_ide"], "rejected": []}}} ``` 若 Server 不支持 `initialize` 方法,Client 会收到 `-32601 method not found` 错误,应自动降级到无握手模式。 ### `prompt` - **方向**:Client → Agent - **类型**:Request(需要响应) 发送用户输入并运行 Agent 轮次。调用后 Agent 开始处理,期间会发送 `event` 通知和 `request` 请求,直到轮次完成才返回响应。 ```typescript /** prompt 请求参数 */ interface PromptParams { /** 用户输入,可以是纯文本或内容片段数组 */ user_input: string | ContentPart[] } /** prompt 响应结果 */ interface PromptResult { /** 轮次结束状态 */ status: "finished" | "cancelled" | "max_steps_reached" /** 当 status 为 max_steps_reached 时,包含已执行的步数 */ steps?: number } ``` **请求示例** ```json {"jsonrpc": "2.0", "method": "prompt", "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "params": {"user_input": "你好"}} ``` **成功响应示例** ```json {"jsonrpc": "2.0", "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "result": {"status": "finished"}} ``` **错误响应示例** ```json {"jsonrpc": "2.0", "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "error": {"code": -32001, "message": "LLM is not set"}} ``` | code | 说明 | |------|------| | `-32000` | 已有轮次正在进行中 | | `-32001` | 未配置 LLM | | `-32002` | 不支持指定的 LLM | | `-32003` | LLM 服务错误 | ### `replay` ::: info 新增 新增于 Wire 1.3。 ::: - **方向**:Client → Agent - **类型**:Request(需要响应) 触发历史回放。Server 读取会话目录中的 `wire.jsonl`,按顺序重新发送已记录的 `event` 和 `request` 消息。回放是只读的,Client 不应对回放中的 `request` 消息作出响应。如果没有历史记录,Server 直接返回 `events: 0`、`requests: 0`。 ```typescript /** replay 请求无参数,params 可以是空对象或省略 */ type ReplayParams = Record /** replay 响应结果 */ interface ReplayResult { /** 回放结束状态 */ status: "finished" | "cancelled" /** 回放的 event 数量 */ events: number /** 回放的 request 数量 */ requests: number } ``` **请求示例** ```json {"jsonrpc": "2.0", "method": "replay", "id": "6ba7b812-9dad-11d1-80b4-00c04fd430c8"} ``` **成功响应示例** ```json {"jsonrpc": "2.0", "id": "6ba7b812-9dad-11d1-80b4-00c04fd430c8", "result": {"status": "finished", "events": 42, "requests": 3}} ``` ### `steer` ::: info 新增 新增于 Wire 1.4。 ::: - **方向**:Client → Agent - **类型**:Request(需要响应) 在 Agent 轮次进行中注入用户消息。与 `prompt` 不同,`steer` 不会开始新的轮次,而是将消息注入到当前正在进行的轮次中。注入的消息会在当前步骤完成后作为标准用户消息追加到上下文中,从而在下一步骤开始前”引导” AI 的行为。消息被消费时会发出 `SteerInput` 事件。 ```typescript /** steer 请求参数 */ interface SteerParams { /** 用户输入,可以是纯文本或内容片段数组 */ user_input: string | ContentPart[] } /** steer 响应结果 */ interface SteerResult { /** 固定为 "steered" */ status: "steered" } ``` **请求示例** ```json {"jsonrpc": "2.0", "method": "steer", "id": "7ca7c810-9dad-11d1-80b4-00c04fd430c8", "params": {"user_input": "用 Python 实现"}} ``` **成功响应示例** ```json {"jsonrpc": "2.0", "id": "7ca7c810-9dad-11d1-80b4-00c04fd430c8", "result": {"status": "steered"}} ``` **错误响应示例** 如果当前没有轮次在进行: ```json {"jsonrpc": "2.0", "id": "7ca7c810-9dad-11d1-80b4-00c04fd430c8", "error": {"code": -32000, "message": "No agent turn is in progress"}} ``` ### `set_plan_mode` ::: info 新增 新增于 Wire 1.4。 ::: - **方向**:Client → Agent - **类型**:Request(需要响应) 将 Plan 模式设置为指定状态。调用后 Agent 会更新 Plan 模式并通过 `StatusUpdate` 事件通知新的状态。 此功能需要能力协商:Client 在 `initialize` 时通过 `capabilities.supports_plan_mode: true` 声明支持后,Agent 才会启用 Plan 模式相关工具(`EnterPlanMode`、`ExitPlanMode`)。如果 Client 未声明支持,这些工具会从 LLM 的工具列表中自动隐藏。 Plan 模式状态会持久化到会话中,因此在进程重启后可以恢复。 ```typescript /** set_plan_mode 请求参数 */ interface SetPlanModeParams { /** 是否启用 Plan 模式 */ enabled: boolean } /** set_plan_mode 响应结果 */ interface SetPlanModeResult { /** 固定为 "ok" */ status: "ok" /** 调用后的 Plan 模式状态 */ plan_mode: boolean } ``` **请求示例** ```json {"jsonrpc": "2.0", "method": "set_plan_mode", "id": "8da7d810-9dad-11d1-80b4-00c04fd430c8", "params": {"enabled": true}} ``` **成功响应示例** ```json {"jsonrpc": "2.0", "id": "8da7d810-9dad-11d1-80b4-00c04fd430c8", "result": {"status": "ok", "plan_mode": true}} ``` **错误响应示例** 如果当前环境不支持 Plan 模式: ```json {"jsonrpc": "2.0", "id": "8da7d810-9dad-11d1-80b4-00c04fd430c8", "error": {"code": -32000, "message": "Plan mode is not supported"}} ``` ### `cancel` - **方向**:Client → Agent - **类型**:Request(需要响应) 取消当前正在进行的 Agent 轮次或回放。调用后,正在进行的 `prompt` 请求会返回 `{"status": "cancelled"}`,回放会返回 `{"status": "cancelled"}` 及已发送的消息计数。 ```typescript /** cancel 请求无参数,params 可以是空对象或省略 */ type CancelParams = Record /** cancel 响应结果为空对象 */ type CancelResult = Record ``` **请求示例** ```json {"jsonrpc": "2.0", "method": "cancel", "id": "6ba7b811-9dad-11d1-80b4-00c04fd430c8"} ``` **成功响应示例** ```json {"jsonrpc": "2.0", "id": "6ba7b811-9dad-11d1-80b4-00c04fd430c8", "result": {}} ``` **错误响应示例** 如果当前没有轮次在进行: ```json {"jsonrpc": "2.0", "id": "6ba7b811-9dad-11d1-80b4-00c04fd430c8", "error": {"code": -32000, "message": "No agent turn is in progress"}} ``` ### `event` - **方向**:Agent → Client - **类型**:Notification(无需响应) Agent 在轮次进行过程中发出的事件通知。没有 `id` 字段,Client 无需响应。 ```typescript /** event 通知参数,包含序列化后的 Wire 消息 */ interface EventParams { type: string payload: object } ``` **示例** ```json {"jsonrpc": "2.0", "method": "event", "params": {"type": "ContentPart", "payload": {"type": "text", "text": "Hello"}}} ``` ### `request` - **方向**:Agent → Client - **类型**:Request(需要响应) Agent 向 Client 发出的请求,用于审批确认或外部工具调用。Client 必须响应后 Agent 才能继续执行。 ```typescript /** request 请求参数,包含序列化后的 Wire 消息 */ interface RequestParams { type: "ApprovalRequest" | "ToolCallRequest" | "QuestionRequest" payload: ApprovalRequest | ToolCallRequest | QuestionRequest } ``` **审批请求示例** ```json {"jsonrpc": "2.0", "method": "request", "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "params": {"type": "ApprovalRequest", "payload": {"id": "approval-1", "tool_call_id": "tc-1", "sender": "Shell", "action": "run shell command", "description": "Run command `ls`", "display": []}}} ``` **审批响应示例** ```json {"jsonrpc": "2.0", "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "result": {"request_id": "approval-1", "response": "approve"}} ``` **外部工具调用请求示例** ```json {"jsonrpc": "2.0", "method": "request", "id": "a3bb189e-8bf9-3888-9912-ace4e6543002", "params": {"type": "ToolCallRequest", "payload": {"id": "tc-1", "name": "open_in_ide", "arguments": "{\"path\":\"README.md\"}"}}} ``` **外部工具调用响应示例** ```json {"jsonrpc": "2.0", "id": "a3bb189e-8bf9-3888-9912-ace4e6543002", "result": {"tool_call_id": "tc-1", "return_value": {"is_error": false, "output": "Opened", "message": "Opened README.md in IDE", "display": []}}} ``` ### 标准错误码 所有请求都可能返回 JSON-RPC 2.0 标准错误: | code | 说明 | |------|------| | `-32700` | 无效的 JSON 格式 | | `-32600` | 无效的请求(如缺少必要字段) | | `-32601` | 方法不存在 | | `-32602` | 无效的方法参数 | | `-32603` | 内部错误 | ## Wire 消息类型 Wire 消息通过 `event` 和 `request` 方法传递,格式为 `{"type": "...", "payload": {...}}`。以下使用 TypeScript 风格的类型定义描述所有消息类型。 ```typescript /** 所有 Wire 消息的联合类型 */ type WireMessage = Event | Request /** 事件:通过 event 方法发送,无需响应 */ type Event = | TurnBegin | TurnEnd | StepBegin | StepInterrupted | CompactionBegin | CompactionEnd | StatusUpdate | ContentPart | ToolCall | ToolCallPart | ToolResult | ApprovalResponse | SubagentEvent | SteerInput /** 请求:通过 request 方法发送,需要响应 */ type Request = ApprovalRequest | ToolCallRequest | QuestionRequest ``` ### `TurnBegin` 轮次开始。 ```typescript interface TurnBegin { /** 用户输入,可以是纯文本或内容片段数组 */ user_input: string | ContentPart[] } ``` ### `TurnEnd` ::: info 新增 新增于 Wire 1.2。 ::: 轮次结束。此事件在轮次的所有其他事件之后发送。如果轮次被中断,此事件可能不会发送。 ```typescript interface TurnEnd { // 无额外字段 } ``` ### `StepBegin` 步骤开始。 ```typescript interface StepBegin { /** 步骤编号,从 1 开始 */ n: number } ``` ### `StepInterrupted` 步骤被中断,无额外字段。 ### `CompactionBegin` 上下文压缩开始,无额外字段。 ### `CompactionEnd` 上下文压缩结束,无额外字段。 ### `StatusUpdate` 状态更新。 ```typescript interface StatusUpdate { /** 上下文使用率,0-1 之间的浮点数,JSON 中可能不存在 */ context_usage?: number | null /** 当前上下文中的 token 数量,JSON 中可能不存在 */ context_tokens?: number | null /** 上下文可容纳的最大 token 数量,JSON 中可能不存在 */ max_context_tokens?: number | null /** 当前步骤的 token 用量统计,JSON 中可能不存在 */ token_usage?: TokenUsage | null /** 当前步骤的消息 ID,JSON 中可能不存在 */ message_id?: string | null /** Plan 模式是否激活,null 表示状态未变更,JSON 中可能不存在 */ plan_mode?: boolean | null } interface TokenUsage { /** 不包括 input_cache_read 和 input_cache_creation 的输入 token 数 */ input_other: number /** 总输出 token 数 */ output: number /** 缓存的输入 token 数 */ input_cache_read: number /** 用于缓存创建的输入 token 数,目前仅 Anthropic API 支持此字段 */ input_cache_creation: number } ``` ### `ContentPart` 消息内容片段。序列化时 `type` 为 `"ContentPart"`,具体类型由 `payload.type` 区分。 ```typescript type ContentPart = | TextPart | ThinkPart | ImageURLPart | AudioURLPart | VideoURLPart interface TextPart { type: "text" /** 文本内容 */ text: string } interface ThinkPart { type: "think" /** 思考内容 */ think: string /** 加密的思考内容或签名,JSON 中可能不存在 */ encrypted?: string | null } interface ImageURLPart { type: "image_url" image_url: { /** 图片 URL,可以是 data URI(如 data:image/png;base64,...) */ url: string /** 图片 ID,用于区分不同图片,JSON 中可能不存在 */ id?: string | null } } interface AudioURLPart { type: "audio_url" audio_url: { /** 音频 URL,可以是 data URI(如 data:audio/aac;base64,...) */ url: string /** 音频 ID,用于区分不同音频,JSON 中可能不存在 */ id?: string | null } } interface VideoURLPart { type: "video_url" video_url: { /** 视频 URL,可以是 data URI(如 data:video/mp4;base64,...) */ url: string /** 视频 ID,用于区分不同视频,JSON 中可能不存在 */ id?: string | null } } ``` ### `ToolCall` 工具调用。 ```typescript interface ToolCall { /** 固定为 "function" */ type: "function" /** 工具调用 ID */ id: string function: { /** 工具名称 */ name: string /** JSON 格式的参数字符串,JSON 中可能不存在 */ arguments?: string | null } /** 额外信息,JSON 中可能不存在 */ extras?: object | null } ``` ### `ToolCallPart` 工具调用参数片段(流式)。 ```typescript interface ToolCallPart { /** 参数片段,用于流式传输工具调用参数,JSON 中可能不存在 */ arguments_part?: string | null } ``` ### `ToolResult` 工具执行结果。 ```typescript interface ToolResult { /** 对应的工具调用 ID */ tool_call_id: string return_value: ToolReturnValue } interface ToolReturnValue { /** 是否为错误 */ is_error: boolean /** 返回给模型的输出内容 */ output: string | ContentPart[] /** 给模型的解释性消息 */ message: string /** 显示给用户的内容块 */ display: DisplayBlock[] /** 额外调试信息,JSON 中可能不存在 */ extras?: object | null } ``` ### `ApprovalResponse` ::: info 变更 重命名于 Wire 1.1。原名 `ApprovalRequestResolved`,旧名称仍可使用以保持向后兼容。 ::: 审批响应事件,表示审批请求已完成。 ```typescript interface ApprovalResponse { /** 审批请求 ID */ request_id: string /** 审批结果 */ response: "approve" | "approve_for_session" | "reject" } ``` ### `SubagentEvent` 子 Agent 事件。 ```typescript interface SubagentEvent { /** 关联的 Task 工具调用 ID */ task_tool_call_id: string /** 子 Agent 产生的事件,嵌套的 Wire 消息格式 */ event: { type: string; payload: object } } ``` ### `SteerInput` ::: info 新增 新增于 Wire 1.5。 ::: 表示用户在当前运行中的轮次追加了后续输入。此事件在当前步骤完成且输入被追加到上下文之后、下一步骤开始之前发出。 ```typescript interface SteerInput { /** 用户输入,可以是纯文本或内容片段数组 */ user_input: string | ContentPart[] } ``` ### `ApprovalRequest` 审批请求,通过 `request` 方法发送,Client 必须响应后 Agent 才能继续。 ```typescript interface ApprovalRequest { /** 请求 ID,用于响应时引用 */ id: string /** 关联的工具调用 ID */ tool_call_id: string /** 发起者(工具名称) */ sender: string /** 操作描述 */ action: string /** 详细说明 */ description: string /** 显示给用户的内容块,JSON 中可能不存在,默认为 [] */ display?: DisplayBlock[] } ``` **响应格式** Client 需要返回 `ApprovalResponse` 作为响应结果: ```typescript interface ApprovalResponse { request_id: string response: "approve" | "approve_for_session" | "reject" } ``` | response | 说明 | |----------|------| | `approve` | 批准本次操作 | | `approve_for_session` | 批准本会话中的同类操作 | | `reject` | 拒绝操作 | ### `ToolCallRequest` 外部工具调用请求,通过 `request` 方法发送。当 Agent 调用 `initialize` 时注册的外部工具时,会发送此请求。Client 必须执行工具并返回 `ToolResult`。 ```typescript interface ToolCallRequest { /** 工具调用 ID */ id: string /** 工具名称 */ name: string /** JSON 格式的参数字符串,JSON 中可能不存在 */ arguments?: string | null } ``` **响应格式** Client 需要返回 `ToolResult` 作为响应结果: ```typescript interface ToolResult { tool_call_id: string return_value: ToolReturnValue } ``` ### `QuestionRequest` ::: info 新增 新增于 Wire 1.4。 ::: 结构化问答请求,通过 `request` 方法发送。当 Agent 使用 `AskUserQuestion` 工具时,会发送此请求。Client 必须响应后 Agent 才能继续执行。 此功能需要能力协商:Client 在 `initialize` 时通过 `capabilities.supports_question: true` 声明支持后,Agent 才会发送 `QuestionRequest`。如果 Client 未声明支持,`AskUserQuestion` 工具会从 LLM 的工具列表中自动隐藏,避免 LLM 调用不受支持的交互。 ```typescript interface QuestionRequest { /** 请求 ID,用于响应时引用 */ id: string /** 关联的工具调用 ID */ tool_call_id: string /** 问题列表(1–4 个问题) */ questions: QuestionItem[] } interface QuestionItem { /** 问题文本 */ question: string /** 短标签,最多 12 个字符 */ header?: string /** 可选项(2–4 个) */ options: QuestionOption[] /** 是否允许多选 */ multi_select?: boolean } interface QuestionOption { /** 选项标签 */ label: string /** 选项说明 */ description?: string } ``` **请求示例** ```json {"jsonrpc": "2.0", "method": "request", "id": "b1a2c3d4-e5f6-7890-abcd-ef1234567890", "params": {"type": "QuestionRequest", "payload": {"id": "q-1", "tool_call_id": "tc-1", "questions": [{"question": "Which language should I use?", "header": "Lang", "options": [{"label": "Python", "description": "Widely used, large ecosystem"}, {"label": "Rust", "description": "High performance, memory safe"}], "multi_select": false}]}}} ``` **响应格式** Client 需要返回 `QuestionResponse` 作为响应结果: ```typescript interface QuestionResponse { /** 对应的请求 ID */ request_id: string /** 答案映射,键为问题文本,值为选中的选项标签(多选时用逗号分隔) */ answers: Record } ``` **响应示例** ```json {"jsonrpc": "2.0", "id": "b1a2c3d4-e5f6-7890-abcd-ef1234567890", "result": {"request_id": "q-1", "answers": {"Which language should I use?": "Python"}}} ``` 如果 Client 不支持结构化问答或用户关闭了问题面板,可以返回空的 `answers`: ```json {"jsonrpc": "2.0", "id": "b1a2c3d4-e5f6-7890-abcd-ef1234567890", "result": {"request_id": "q-1", "answers": {}}} ``` ### `DisplayBlock` `ToolResult` 和 `ApprovalRequest` 的 `display` 字段使用的显示块类型。 ```typescript type DisplayBlock = | UnknownDisplayBlock | BriefDisplayBlock | DiffDisplayBlock | TodoDisplayBlock | ShellDisplayBlock /** 无法识别的显示块类型的 fallback */ interface UnknownDisplayBlock { /** 任意类型标识 */ type: string /** 原始数据 */ data: object } interface BriefDisplayBlock { type: "brief" /** 简短的文本内容 */ text: string } interface DiffDisplayBlock { type: "diff" /** 文件路径 */ path: string /** 原始内容 */ old_text: string /** 新内容 */ new_text: string } interface TodoDisplayBlock { type: "todo" /** 待办事项列表 */ items: TodoDisplayItem[] } interface TodoDisplayItem { /** 待办事项标题 */ title: string /** 状态 */ status: "pending" | "in_progress" | "done" } interface ShellDisplayBlock { type: "shell" /** 语法高亮的语言标识(如 "sh"、"powershell") */ language: string /** Shell 命令内容 */ command: string } ``` ## Kimi Agent(Rust)Wire Server ::: warning 注意 Kimi Agent 目前为实验性功能,API 和行为可能在后续版本中发生变化。 ::: Kimi Agent (Rust) 是 Kimi Code CLI 内核的 Rust 实现,专为 Wire 模式设计。如果你只需要 Wire 协议服务,Kimi Agent (Rust) 提供了一个更轻量的选择。Rust 实现位于 [`MoonshotAI/kimi-agent-rs`](https://github.com/MoonshotAI/kimi-agent-rs)。 ### 特点 - **Wire 协议完全兼容**:与 Python 版 `kimi --wire` 使用相同的 Wire 协议,现有客户端无需修改 - **更小的体积**:单一静态链接二进制,无需 Python 运行时 - **更快的启动**:原生编译,启动速度更快 - **相同的配置**:使用相同的配置文件(`~/.kimi/config.toml`)和会话目录 ### 限制 - **仅支持 Wire 模式**:没有 Shell/Print/ACP UI - **仅支持 Kimi 供应商**:不支持 OpenAI、Anthropic 等其他供应商 - **无 Kimi 账号登录功能**:没有 `login`/`logout` 子命令和 `/login`、`/logout` 斜杠命令,需要手动配置 API 密钥 - **不支持 `--prompt`/`--command`**:Wire 服务器不接受初始提示词 - **仅支持本地执行**:没有 SSH Kaos 支持 - **MCP OAuth 存储位置不同**:Kimi Agent 存储在 `~/.kimi/credentials/mcp_auth.json`,Python 版存储在 `~/.fastmcp/oauth-mcp-client-cache/`,两者不兼容 ### 安装 从 [GitHub Releases](https://github.com/MoonshotAI/kimi-agent-rs/releases) 下载预编译的二进制文件: ```sh # macOS (Apple Silicon) curl -L https://github.com/MoonshotAI/kimi-agent-rs/releases/latest/download/kimi-agent-aarch64-apple-darwin.tar.gz | tar xz sudo mv kimi-agent /usr/local/bin/ # Linux (x86_64) curl -L https://github.com/MoonshotAI/kimi-agent-rs/releases/latest/download/kimi-agent-x86_64-unknown-linux-gnu.tar.gz | tar xz sudo mv kimi-agent /usr/local/bin/ ``` ### 使用 Kimi Agent 默认运行 Wire 模式: ```sh kimi-agent ``` 常用选项与 `kimi` 命令相同: ```sh # 指定工作目录 kimi-agent --work-dir /path/to/project # 继续上一个会话 kimi-agent --continue # 使用指定会话 kimi-agent --session # 使用指定模型 kimi-agent --model k2 # YOLO 模式(跳过审批) kimi-agent --yolo ``` 子命令: ```sh # 显示版本和环境信息 kimi-agent info # 管理 MCP 服务器 kimi-agent mcp list kimi-agent mcp add [args...] kimi-agent mcp remove ``` ### 版本同步 Kimi Agent 与 Kimi Code CLI 独立发版。兼容性与同步状态以 `MoonshotAI/kimi-agent-rs` 的发布说明为准。 ================================================ FILE: docs/zh/faq.md ================================================ # 常见问题 ## 安装与鉴权 ### `/login` 时模型列表为空 如果在运行 `/login`(或 `/setup`)命令时看到 "No models available for the selected platform" 错误,可能是以下原因: - **API 密钥无效或过期**:检查你输入的 API 密钥是否正确,以及是否仍有效。 - **网络连接问题**:确认能正常访问 API 服务地址(如 `api.kimi.com` 或 `api.moonshot.cn`)。 ### API 密钥无效 API 密钥无效可能的原因: - **密钥输入错误**:检查是否有多余的空格或遗漏的字符。 - **密钥已过期或被撤销**:在平台控制台确认密钥状态。 - **环境变量覆盖**:检查是否有 `KIMI_API_KEY` 或 `OPENAI_API_KEY` 环境变量覆盖了配置文件中的密钥。可以运行 `echo $KIMI_API_KEY` 检查。 ### 会员过期或配额用尽 如果你使用 Kimi Code 平台,可以通过 `/usage` 命令查看当前的配额和会员状态。如果配额用尽或会员过期,需要在 [Kimi Code](https://kimi.com/coding) 续费或升级。 ## 交互问题 ### Shell 模式中 `cd` 命令无效 在 Shell 模式中执行 `cd` 命令不会改变 Kimi Code CLI 的工作目录。这是因为每次 Shell 命令在独立的子进程中执行,目录切换只在该进程内生效。 如果需要切换工作目录: - **退出并重新启动**:在目标目录中重新运行 `kimi` 命令。 - **使用 `--work-dir` 参数**:启动时指定工作目录,如 `kimi --work-dir /path/to/project`。 - **在命令中使用绝对路径**:直接使用绝对路径执行命令,如 `ls /path/to/dir`。 ### 粘贴图片失败 使用 `Ctrl-V` 粘贴图片时,如果提示 "Current model does not support image input",说明当前模型不支持图片输入。 解决方法: - **切换到支持图片的模型**:使用支持 `image_in` 能力的模型。 - **检查剪贴板内容**:确保剪贴板中确实有图片数据,而非图片文件的路径。 ## ACP 问题 ### IDE 无法连接到 Kimi Code CLI 如果 IDE(如 Zed 或 JetBrains IDE)无法连接到 Kimi Code CLI,请检查以下几点: - **确认 Kimi Code CLI 已安装**:运行 `kimi --version` 确认安装成功。 - **检查配置路径**:确保 IDE 配置中的 Kimi Code CLI 路径正确。通常可以使用 `kimi acp` 作为命令。 - **检查 uv 路径**:如果使用 uv 安装,确保 `~/.local/bin` 在 PATH 中。可以使用绝对路径,如 `/Users/yourname/.local/bin/kimi acp`。 - **查看日志**:检查 `~/.kimi/logs/kimi.log` 中的错误信息。 ## MCP 问题 ### MCP 服务启动失败 添加 MCP 服务器后,如果工具未加载或报错,可能是以下原因: - **命令不存在**:对于 stdio 类型的服务器,确保命令(如 `npx`)在 PATH 中。可以使用绝对路径配置。 - **配置格式错误**:检查 `~/.kimi/mcp.json` 是否为有效的 JSON 格式。运行 `kimi mcp list` 查看当前配置。 调试步骤: ```sh # 查看已配置的服务器 kimi mcp list # 测试服务器是否正常 kimi mcp test ``` ### OAuth 授权失败 对于需要 OAuth 授权的 MCP 服务器(如 Linear),如果授权失败: - **检查网络连接**:确保能访问授权服务器。 - **重新授权**:运行 `kimi mcp auth ` 重新进行授权。 - **重置授权**:如果授权信息损坏,可以运行 `kimi mcp reset-auth ` 清除后重试。 ### Header 格式错误 添加 HTTP 类型的 MCP 服务器时,Header 格式应为 `KEY: VALUE`(冒号后有空格)。例如: ```sh # 正确 kimi mcp add --transport http context7 https://mcp.context7.com/mcp --header "CONTEXT7_API_KEY: your-key" # 错误(缺少空格或使用等号) kimi mcp add --transport http context7 https://mcp.context7.com/mcp --header "CONTEXT7_API_KEY=your-key" ``` ## Print/Wire 模式问题 ### JSONL 输入格式无效 使用 `--input-format stream-json` 时,输入必须是有效的 JSONL(每行一个 JSON 对象)。常见问题: - **JSON 格式错误**:确保每行是完整的 JSON 对象,没有语法错误。 - **编码问题**:确保输入使用 UTF-8 编码。 - **换行符问题**:Windows 用户注意检查换行符是否为 `\n` 而非 `\r\n`。 正确的输入格式示例: ```json {"role": "user", "content": "你好"} ``` ### Print 模式无输出 如果 `--print` 模式下没有输出,可能是: - **未提供输入**:需要通过 `--prompt`(或 `--command`)或 stdin 提供输入。例如:`kimi --print --prompt "你好"`。 - **输出被缓冲**:尝试使用 `--output-format stream-json` 获取流式输出。 - **配置未完成**:确保已通过 `/login` 配置 API 密钥和模型。 ## 更新与升级 ### macOS 首次运行缓慢 macOS 的 Gatekeeper 安全机制会在首次运行新程序时进行检查,导致启动变慢。解决方法: - **等待检查完成**:首次运行时耐心等待,后续启动会恢复正常。 - **添加到开发者工具**:在「系统设置 → 隐私与安全性 → 开发者工具」中添加你的终端应用。 ### 如何升级 Kimi Code CLI 使用 uv 升级到最新版本: ```sh uv tool upgrade kimi-cli --no-cache ``` 添加 `--no-cache` 参数可以确保获取最新版本。 ### 如何禁用自动更新检查 如果不希望 Kimi Code CLI 在后台检查更新,可以设置环境变量: ```sh export KIMI_CLI_NO_AUTO_UPDATE=1 ``` 可以将此行添加到你的 shell 配置文件(如 `~/.zshrc` 或 `~/.bashrc`)中。 ================================================ FILE: docs/zh/guides/getting-started.md ================================================ # 开始使用 ## Kimi Code CLI 是什么 Kimi Code CLI 是一个运行在终端中的 AI Agent,帮助你完成软件开发任务和终端操作。它可以阅读和编辑代码、执行 Shell 命令、搜索和抓取网页,并在执行过程中自主规划和调整行动。 Kimi Code CLI 适合以下场景: - **编写和修改代码**:实现新功能、修复 bug、重构代码 - **理解项目**:探索陌生的代码库,解答架构和实现问题 - **自动化任务**:批量处理文件、执行构建和测试、运行脚本 Kimi Code CLI 支持以下几种使用方式: - **[交互式命令行(`kimi`)](../reference/kimi-command.md)**:在终端中以 Shell 方式与 AI 对话,支持自然语言描述任务或直接执行 Shell 命令 - **[浏览器界面(`kimi web`)](../reference/kimi-web.md)**:在本地浏览器中打开图形界面,支持会话管理、文件引用、代码高亮等 - **[Agent 集成(`kimi acp`)](../reference/kimi-acp.md)**:以服务方式运行,通过 [Agent Client Protocol] 集成到 [IDE](./ides.md) 和其他本地 Agent 客户端中 ::: info 提示 如果你遇到问题或有建议,欢迎在 [GitHub Issues](https://github.com/MoonshotAI/kimi-cli/issues) 反馈。 ::: [Agent Client Protocol]: https://agentclientprotocol.com/ ## 安装 运行安装脚本即可完成安装。脚本会先安装 [uv](https://docs.astral.sh/uv/)(Python 包管理工具),再通过 uv 安装 Kimi Code CLI: ```sh # Linux / macOS curl -LsSf https://code.kimi.com/install.sh | bash ``` ```powershell # Windows (PowerShell) Invoke-RestMethod https://code.kimi.com/install.ps1 | Invoke-Expression ``` 验证安装是否成功: ```sh kimi --version ``` ::: tip 提示 由于 macOS 的安全检查机制,首次运行 `kimi` 命令可能需要较长时间。可以在「系统设置 → 隐私与安全性 → 开发者工具」中添加你的终端应用来加速后续启动。 ::: 如果你已经安装了 uv,也可以直接运行: ```sh uv tool install --python 3.13 kimi-cli ``` ::: tip 提示 Kimi Code CLI 支持 Python 3.12-3.14,但建议使用 3.13 以获得最佳兼容性。 ::: ## 升级与卸载 升级到最新版本: ```sh uv tool upgrade kimi-cli --no-cache ``` 卸载 Kimi Code CLI: ```sh uv tool uninstall kimi-cli ``` ## 第一次运行 在你想要工作的项目目录中运行 `kimi` 命令启动 Kimi Code CLI: ```sh cd your-project kimi ``` 首次启动时,你需要配置 API 来源。输入 `/login` 命令开始配置: ``` /login ``` 执行后首先选择平台。推荐选择 **Kimi Code**,会自动打开浏览器进行 OAuth 授权;选择其他平台则需要输入 API 密钥。配置完成后 Kimi Code CLI 会自动保存设置并重新加载。详见 [平台与模型](../configuration/providers.md)。 现在你可以直接用自然语言和 Kimi Code CLI 对话了。试着描述你想完成的任务,比如: ``` 帮我看一下这个项目的目录结构 ``` ::: tip 提示 如果项目中没有 `AGENTS.md` 文件,可以运行 `/init` 命令让 Kimi Code CLI 分析项目并生成该文件,帮助 AI 更好地理解项目结构和规范。 ::: 输入 `/help` 可以查看所有可用的 [斜杠命令](../reference/slash-commands.md) 和使用提示。 ================================================ FILE: docs/zh/guides/ides.md ================================================ # 在 IDE 中使用 Kimi Code CLI 支持通过 [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) 集成到 IDE 中,让你在编辑器内直接使用 AI 辅助编程。 ## 前置准备 在配置 IDE 之前,请确保已安装 Kimi Code CLI 并完成 `/login` 配置。 ## 在 Zed 中使用 [Zed](https://zed.dev/) 是一个支持 ACP 的现代 IDE。 在 Zed 的配置文件 `~/.config/zed/settings.json` 中添加: ```json { "agent_servers": { "Kimi Code CLI": { "type": "custom", "command": "kimi", "args": ["acp"], "env": {} } } } ``` 配置说明: - `type`:固定值 `"custom"` - `command`:Kimi Code CLI 的命令路径,如果 `kimi` 不在 PATH 中,需要使用完整路径 - `args`:启动参数,`acp` 启用 ACP 模式 - `env`:环境变量,通常留空即可 保存配置后,在 Zed 的 Agent 面板中就可以创建 Kimi Code CLI 会话了。 ## 在 JetBrains IDE 中使用 JetBrains 系列 IDE(IntelliJ IDEA、PyCharm、WebStorm 等)通过 AI 聊天插件支持 ACP。 如果你没有 JetBrains AI 订阅,可以在注册表中启用 `llm.enable.mock.response` 来使用 AI 聊天功能。连按两次 Shift 搜索 "注册表" 即可打开。 在 AI 聊天面板的菜单中点击 "Configure ACP agents",添加以下配置: ```json { "agent_servers": { "Kimi Code CLI": { "command": "~/.local/bin/kimi", "args": ["acp"], "env": {} } } } ``` `command` 需要使用完整路径,可以在终端中运行 `which kimi` 获取。保存后,在 AI 聊天的 Agent 选择器中就可以选择 Kimi Code CLI 了。 ================================================ FILE: docs/zh/guides/integrations.md ================================================ # 集成到工具 除了在终端和 IDE 中使用,Kimi Code CLI 还可以集成到其他工具中。 ## Zsh 插件 [zsh-kimi-cli](https://github.com/MoonshotAI/zsh-kimi-cli) 是一个 Zsh 插件,让你可以在 Zsh 中快速切换到 Kimi Code CLI。 **安装** 如果你使用 Oh My Zsh,可以这样安装: ```sh git clone https://github.com/MoonshotAI/zsh-kimi-cli.git \ ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/kimi-cli ``` 然后在 `~/.zshrc` 中添加插件: ```sh plugins=(... kimi-cli) ``` 重新加载 Zsh 配置: ```sh source ~/.zshrc ``` **使用** 安装后,在 Zsh 中按 `Ctrl-X` 可以快速切换到 Kimi Code CLI,无需手动输入 `kimi` 命令。 ::: tip 提示 如果你使用其他 Zsh 插件管理器(如 zinit、zplug 等),请参考 [zsh-kimi-cli 仓库](https://github.com/MoonshotAI/zsh-kimi-cli) 的 README 了解安装方法。 ::: ================================================ FILE: docs/zh/guides/interaction.md ================================================ # 交互与输入 Kimi Code CLI 提供了丰富的交互功能,帮助你高效地与 AI 协作。 ## Agent 与 Shell 模式 Kimi Code CLI 有两种输入模式: - **Agent 模式**:默认模式,输入的内容会发送给 AI 处理 - **Shell 模式**:直接执行 Shell 命令,无需离开 Kimi Code CLI 按 `Ctrl-X` 可以在两种模式之间切换。当前模式会显示在底部状态栏中。 在 Shell 模式下,你可以像在普通终端中一样执行命令: ```sh $ ls -la $ git status $ npm run build ``` Shell 模式也支持部分斜杠命令,包括 `/help`、`/exit`、`/version`、`/editor`、`/changelog`、`/feedback`、`/export`、`/import` 和 `/task`。 ::: warning 注意 Shell 模式中每个命令独立执行,`cd`、`export` 等改变环境的命令不会影响后续命令。 ::: ## Plan 模式 Plan 模式是一种只读的规划模式,让 AI 在动手编码之前先制定实施方案,避免在错误方向上浪费精力。 在 Plan 模式下,AI 只能使用只读工具(`Glob`、`Grep`、`ReadFile`)探索代码库,不能修改任何文件或执行命令。AI 会将方案写入一个专门的 plan 文件,然后提交给你审批。你可以选择批准、拒绝或提供修改意见。 ### 进入 Plan 模式 有三种方式进入 Plan 模式: - **快捷键**:按 `Shift-Tab` 切换 Plan 模式的开关 - **斜杠命令**:输入 `/plan` 或 `/plan on` - **AI 主动触发**:面对复杂任务时,AI 可能会通过 `EnterPlanMode` 工具请求进入 Plan 模式,你可以选择同意或拒绝 进入 Plan 模式后,提示符会变为 `📋`,底部状态栏会显示蓝色的 `plan` 标识。 ### 审批方案 AI 完成方案后会通过 `ExitPlanMode` 提交审批。审批面板会显示完整的方案内容,你可以: - **批准执行**:如果方案包含多个可选实施路径,AI 会列出 2–3 个带标签的选项(如 "方案 A"、"方案 B (Recommended)")供你选择,选中后 AI 退出 Plan 模式并按该路径执行;如果方案只有一条路径,则显示 **Approve** 按钮 - **Reject**:拒绝方案,保持 Plan 模式,你可以在对话中提供反馈 - **Revise**:输入修改意见,AI 会据此修订方案并重新提交 按 `Ctrl-E` 可以在全屏分页器中查看完整方案内容。 ### 管理 Plan 模式 使用 `/plan` 命令可以管理 Plan 模式: - `/plan`:切换 Plan 模式开关 - `/plan on`:开启 Plan 模式 - `/plan off`:关闭 Plan 模式 - `/plan view`:查看当前方案内容 - `/plan clear`:清除当前方案文件 ## Thinking 模式 Thinking 模式让 AI 在回答前进行更深入的思考,适合处理复杂问题。 你可以通过 `/model` 命令切换模型和 Thinking 模式。在选择模型后,如果模型支持 Thinking 模式,系统会询问是否开启。也可以在启动时通过 `--thinking` 参数启用: ```sh kimi --thinking ``` ::: tip 提示 Thinking 模式需要当前模型支持。部分模型(如 `kimi-k2-thinking-turbo`)始终使用 Thinking 模式,无法关闭。 ::: ## 运行中发送消息(steer) 当 AI 正在执行任务时,你可以直接在输入框中输入并发送后续消息,无需等待当前轮次结束。这个功能称为 "引导"(steer),可以在 AI 运行过程中调整其方向。 发送的引导消息会在当前步骤完成后追加到上下文中,AI 会在下一步骤中看到并响应你的消息。在 AI 运行期间,审批请求和问答面板也支持内联键盘交互。 ::: tip 提示 引导消息不会中断 AI 当前正在执行的步骤,而是在步骤间被处理。如果需要立即中断,请使用 `Ctrl-C`。 ::: ## 后台任务 当 AI 需要执行耗时较长的命令(如构建项目、运行测试套件、启动开发服务器)时,可以将命令作为后台任务启动。后台任务在独立进程中运行,AI 可以继续处理其他请求,无需等待命令完成。 后台任务的工作流程: 1. AI 使用 `Shell` 工具的 `run_in_background=true` 参数启动命令 2. 工具立即返回任务 ID,AI 继续处理其他工作 3. 任务完成后,系统自动通知 AI,AI 会告知你执行结果 你可以使用 `/task` 斜杠命令打开交互式任务浏览器,实时查看所有后台任务的状态和输出。详见 [斜杠命令参考](../reference/slash-commands.md#task)。 ::: tip 提示 默认最多同时运行 4 个后台任务,可在配置文件的 `[background]` 节中调整。CLI 退出时默认会终止所有后台任务。详见 [配置文件](../configuration/config-files.md#background)。 ::: ## 多行输入 有时你需要输入多行内容,比如贴入一段代码或错误日志。按 `Ctrl-J` 或 `Alt-Enter` 可以插入换行,而不是直接发送消息。 输入完成后,按 `Enter` 发送整条消息。 ## 剪贴板与媒体粘贴 按 `Ctrl-V` 可以粘贴剪贴板中的文本、图片或视频文件。 在 Agent 模式下,较长的粘贴文本(超过 1000 字符或 15 行)会自动折叠为 `[Pasted text #n]` 占位符显示在输入框中,保持界面整洁。完整内容仍会在发送时展开并传递给模型。使用外部编辑器(`Ctrl-O`)时,占位符会自动展开为原始文本,保存后未修改的部分重新折叠。 如果剪贴板中是图片,Kimi Code CLI 会将图片缓存到磁盘并在输入框中显示为 `[image:…]` 占位符。发送消息后,AI 可以看到并分析这张图片。如果剪贴板中是视频文件,其文件路径会以文本形式插入输入框。 ::: tip 提示 图片输入需要当前模型支持 `image_in` 能力,视频输入需要支持 `video_in` 能力。 ::: ## 斜杠命令 斜杠命令是以 `/` 开头的特殊指令,用于执行 Kimi Code CLI 的内置功能,如 `/help`、`/login`、`/sessions` 等。输入 `/` 后会自动显示可用命令列表。完整的斜杠命令列表请参考 [斜杠命令参考](../reference/slash-commands.md)。 ## @ 路径补全 在消息中输入 `@` 后,Kimi Code CLI 会自动补全工作目录中的文件和目录路径。这让你可以方便地引用项目中的文件: ``` 帮我看一下 @src/components/Button.tsx 这个文件有没有问题 ``` 输入 `@` 后开始输入文件名,会显示匹配的补全项。按 `Tab` 或 `Enter` 选择补全项。 ## 结构化问答 在执行过程中,AI 可能需要你做出选择来决定下一步方向。此时 AI 会使用 `AskUserQuestion` 工具向你展示结构化的问题和选项。 问题面板会显示问题描述和可选项,你可以通过键盘选择: - 使用方向键(上 / 下)浏览选项 - 按 `Enter` 确认选择 - 按 `Space` 切换多选模式下的选中状态 - 选择 "Other" 选项可以输入自定义文本 - 按 `Esc` 跳过问题 每个问题支持 2–4 个预定义选项,AI 会根据当前任务上下文设置合适的选项和说明。如果有多个问题需要回答,面板会以标签页形式展示,使用左右方向键或 `Tab` 键在问题间切换,已回答的问题会标记为已完成状态,切换回已回答的问题时会恢复之前的选择。 ::: tip 提示 AI 只会在你的选择真正影响后续操作时才使用此工具。对于能从上下文推断的决策,AI 会自行判断并继续执行。 ::: ## 审批与确认 当 AI 需要执行可能有影响的操作(如修改文件、运行命令)时,Kimi Code CLI 会请求你的确认。 确认提示会显示操作的详情,包括 Shell 命令和文件 Diff 预览。如果内容较长被截断,可以按 `Ctrl-E` 展开查看完整内容。你可以选择: - **允许**:执行这次操作 - **本会话允许**:在当前会话中自动批准同类操作(此决策会随会话持久化,恢复会话时自动还原) - **拒绝**:不执行此操作 如果你信任 AI 的操作,或者你正在安全的隔离环境中运行 Kimi Code CLI,可以启用「YOLO 模式」来自动批准所有请求: ```sh # 启动时启用 kimi --yolo # 或在运行中切换 /yolo ``` 你也可以在配置文件中设置 `default_yolo = true`,每次启动时默认开启 YOLO 模式。详见 [配置文件](../configuration/config-files.md)。 开启 YOLO 模式后,底部状态栏会显示黄色的 YOLO 标识。再次输入 `/yolo` 可关闭。 ::: warning 注意 YOLO 模式会跳过所有确认,请确保你了解可能的风险。建议仅在可控环境中使用。 ::: ================================================ FILE: docs/zh/guides/sessions.md ================================================ # 会话与上下文 Kimi Code CLI 会自动保存你的对话历史,方便你随时继续之前的工作。 ## 会话续接 每次启动 Kimi Code CLI 时,都会创建一个新的会话。在运行过程中,你也可以输入 `/new` 命令随时创建并切换到一个新会话,无需退出程序。 如果你想继续之前的对话,有几种方式: **继续最近的会话** 使用 `--continue` 参数可以继续当前工作目录下最近的会话: ```sh kimi --continue ``` **切换到指定会话** 使用 `--session` 参数可以切换到指定 ID 的会话: ```sh kimi --session abc123 ``` **在运行中切换会话** 输入 `/sessions`(或 `/resume`)可以查看当前工作目录的所有会话列表,使用方向键选择要切换的会话: ``` /sessions ``` 列表会显示每个会话的标题和最后更新时间,帮助你找到想要继续的对话。 **启动回放** 当你继续一个已有会话时,Kimi Code CLI 会回放之前的对话历史,让你快速了解上下文。回放过程中会显示之前的消息和 AI 的回复。 ## 会话状态持久化 除了对话历史,Kimi Code CLI 还会自动保存和恢复会话的运行状态。当你恢复一个会话时,以下状态会自动还原: - **审批决策**:YOLO 模式的开关状态、通过 "本会话允许" 批准过的操作类型 - **Plan 模式**:Plan 模式的开关状态 - **动态子 Agent**:通过 `CreateSubagent` 工具在会话中创建的子 Agent 定义 - **额外目录**:通过 `--add-dir` 或 `/add-dir` 添加的工作区目录 这意味着你不需要在每次恢复会话时重新配置这些设置。例如,如果你在上次会话中批准了某类 Shell 命令的自动执行,恢复会话后这些批准仍然有效。 ## 导出与导入 Kimi Code CLI 支持将会话上下文导出为文件,或从外部文件和其他会话导入上下文。 **导出会话** 输入 `/export` 可以将当前会话的完整对话历史导出为 Markdown 文件: ``` /export ``` 导出文件包含会话元数据、对话概览和按轮次组织的完整对话记录。你也可以指定输出路径: ``` /export ~/exports/my-session.md ``` **导入上下文** 输入 `/import` 可以从文件或其他会话导入上下文。导入的内容会作为参考信息附加到当前会话中: ``` /import ./previous-session-export.md /import abc12345 ``` 支持导入常见的文本格式文件(Markdown、代码、配置文件等)。你也可以传入一个会话 ID,从该会话导入完整的对话历史。 ::: tip 提示 导出文件可能包含敏感信息(如代码片段、文件路径等),分享前请注意检查。 ::: ## 清空与压缩 随着对话的进行,上下文会越来越长。Kimi Code CLI 会在需要的时候自动对上下文进行压缩,确保对话能够继续。 你也可以使用斜杠命令手动管理上下文: **清空上下文** 输入 `/clear` 可以清空当前会话的所有上下文,重新开始对话: ``` /clear ``` 清空后,AI 会忘记之前的所有对话内容。通常你不需要使用这个命令,对于新任务,开启新的会话会是更好的选择。 **压缩上下文** 输入 `/compact` 可以让 AI 总结当前的对话,并用总结替换原有的上下文: ``` /compact ``` 你也可以在命令后附带自定义指引,告诉 AI 在压缩时优先保留哪些内容: ``` /compact 保留数据库相关的讨论 ``` 压缩会保留关键信息,同时减少 token 消耗。这在对话很长但你还想保留一些上下文时很有用。 ::: tip 提示 底部状态栏会显示当前的上下文使用率和 Token 数量(如 `context: 42.0% (4.2k/10.0k)`),帮助你了解何时需要清空或压缩。 ::: ::: tip 提示 `/clear` 和 `/reset` 会清空对话上下文,但不会重置会话状态(如审批决策、动态子 Agent 和额外目录)。如需完全重新开始,建议创建一个新会话。 ::: ================================================ FILE: docs/zh/guides/use-cases.md ================================================ # 常见使用案例 Kimi Code CLI 可以帮助你完成多种软件开发和通用任务,以下是一些典型场景。 ## 实现新功能 当你需要为项目添加新功能时,直接用自然语言描述需求即可。Kimi Code CLI 会自动阅读相关代码、理解项目结构,然后进行修改。 ``` 给用户列表页面添加分页功能,每页显示 20 条记录 ``` Kimi Code CLI 通常会按照「读 → 改 → 验证」的流程工作: 1. **阅读**:搜索和阅读相关代码,理解现有实现 2. **修改**:编写或修改代码,遵循项目的代码风格 3. **验证**:运行测试或构建,确保修改没有引入问题 如果你对修改不满意,可以直接告诉 Kimi Code CLI 调整方向: ``` 分页组件的样式和项目其他地方不一致,参考 Button 组件的样式 ``` ## 修复 bug 描述你遇到的问题,Kimi Code CLI 会帮你定位原因并修复: ``` 用户登录后跳转到首页时,偶尔会显示未登录状态,帮我排查一下 ``` 对于有明确错误信息的问题,可以直接贴上错误日志: ``` 运行 npm test 时出现这个错误: TypeError: Cannot read property 'map' of undefined at UserList.render (src/components/UserList.jsx:15:23) 帮我修复 ``` 你也可以让 Kimi Code CLI 运行命令来复现和验证问题: ``` 运行测试,如果有失败的用例就修复它们 ``` ## 理解项目 Kimi Code CLI 可以帮你探索和理解不熟悉的代码库: ``` 这个项目的整体架构是怎样的?入口文件在哪里? ``` ``` 用户认证的流程是怎么实现的?涉及哪些文件? ``` ``` 解释一下 src/core/scheduler.py 这个文件的作用 ``` 如果你在阅读代码时遇到不理解的部分,可以随时提问: ``` useCallback 和 useMemo 有什么区别?这里为什么要用 useCallback? ``` ## 自动化小任务 Kimi Code CLI 可以执行各种重复性的小任务: ``` 把 src 目录下所有 .js 文件的 var 声明改成 const 或 let ``` ``` 给所有没有 docstring 的公开函数添加文档注释 ``` ``` 生成这个 API 模块的单元测试 ``` ``` 更新 package.json 中所有依赖到最新版本,然后运行测试确保没有问题 ``` ## 自动化通用任务 除了代码相关的任务,Kimi Code CLI 也可以处理一些通用场景。 **调研任务** ``` 帮我调研一下 Python 的异步 Web 框架,比较 FastAPI、Starlette 和 Sanic 的优缺点 ``` **数据分析** ``` 分析 logs 目录下的访问日志,统计每个接口的调用次数和平均响应时间 ``` **批量文件处理** ``` 把 images 目录下的所有 PNG 图片转换为 JPEG 格式,保存到 output 目录 ``` ================================================ FILE: docs/zh/index.md ================================================ --- layout: home hero: name: Kimi Code CLI text: 你的终端智能助手 tagline: 技术预览版 actions: - theme: brand text: 开始使用 link: /zh/guides/getting-started - theme: alt text: GitHub link: https://github.com/MoonshotAI/kimi-cli --- ================================================ FILE: docs/zh/reference/keyboard.md ================================================ # 键盘快捷键 Kimi Code CLI Shell 模式支持以下键盘快捷键。 ## 快捷键列表 | 快捷键 | 功能 | |--------|------| | `Ctrl-X` | 切换 Agent/Shell 模式 | | `Shift-Tab` | 切换 Plan 模式(只读研究与规划) | | `Ctrl-O` | 在外部编辑器中编辑(`$VISUAL`/`$EDITOR`) | | `Ctrl-J` | 插入换行 | | `Alt-Enter` | 插入换行(同 `Ctrl-J`) | | `Ctrl-V` | 粘贴(支持图片和视频文件) | | `Ctrl-E` | 展开审批请求完整内容 | | `1`–`3` | 审批面板快速选择 | | `1`–`5` | 问题面板按编号选择选项 | | `Ctrl-D` | 退出 Kimi Code CLI | | `Ctrl-C` | 中断当前操作 | ## 模式切换 ### `Ctrl-X`:切换 Agent/Shell 模式 在输入框中按 `Ctrl-X` 可在两种模式间切换: - **Agent 模式**:输入发送给 AI Agent 处理 - **Shell 模式**:输入作为本地 Shell 命令执行 提示符会根据当前模式变化: - Agent 模式:`✨`(普通)或 `💫`(Thinking 模式) - Plan 模式:`📋` - Shell 模式:`$` ## Plan 模式 ### `Shift-Tab`:切换 Plan 模式 按 `Shift-Tab` 可以开启或关闭 Plan 模式。Plan 模式下 AI 只能使用只读工具探索代码库,将实施方案写入 plan 文件后提交给你审批。 开启时提示符变为 `📋`,状态栏显示蓝色的 `plan` 标识。也可以使用 `/plan` 斜杠命令管理 Plan 模式。详见 [Plan 模式](../guides/interaction.md#plan-模式)。 ## 外部编辑器 ### `Ctrl-O`:在外部编辑器中编辑 按 `Ctrl-O` 会打开外部编辑器(如 VS Code、Vim)编辑当前输入内容。编辑器按以下优先级选择: 1. `/editor` 命令配置的编辑器 2. `$VISUAL` 环境变量 3. `$EDITOR` 环境变量 4. 自动检测:`code --wait`(VS Code)→ `vim` → `vi` → `nano` 使用 `/editor` 命令可交互式切换编辑器,也可直接指定,如 `/editor vim`。 在编辑器中保存退出后,编辑后的内容会替换当前输入框内容。如果不保存退出(如 Vim 中 `:q!`),输入框内容保持不变。如果输入中包含粘贴文本占位符,编辑器会自动展开为原始文本供你编辑,保存后未修改的部分会重新折叠为占位符。 适用于编写多行 prompt、复杂代码片段等场景。 ## 多行输入 ### `Ctrl-J` / `Alt-Enter`:插入换行 默认情况下,按 `Enter` 会提交输入。如需输入多行内容,可使用: - `Ctrl-J`:在任意位置插入换行 - `Alt-Enter`:在任意位置插入换行 适用于输入多行代码片段或格式化文本。 ## 剪贴板操作 ### `Ctrl-V`:粘贴 粘贴剪贴板内容到输入框。支持: - **文本**:在 Agent 模式下,超过 1000 字符或 15 行的文本会自动折叠为 `[Pasted text #n]` 占位符,保持输入框整洁;完整内容在发送时展开传递给模型。使用 `Ctrl-O` 打开外部编辑器时,占位符会自动展开为原始文本,保存后重新折叠 - **图片**:缓存到磁盘并显示为 `[image:xxx.png,WxH]` 占位符,实际图片数据在发送时一并传递给模型(需模型支持图片输入) - **视频文件**:文件路径以文本形式插入输入框(需模型支持视频输入) ::: tip 提示 图片粘贴需要模型支持 `image_in` 能力,视频粘贴需要模型支持 `video_in` 能力。 ::: ## 审批请求操作 ### `Ctrl-E`:展开完整内容 当审批请求的预览内容被截断时,按 `Ctrl-E` 可以在全屏分页器中查看完整内容。预览被截断时会显示 "... (truncated, ctrl-e to expand)" 提示。 适用于查看较长的 Shell 命令或文件 Diff 内容。 ### 数字键快速选择 在审批面板中,按 `1`–`3` 可以直接选中并提交对应的审批选项,无需先用方向键选择再按 `Enter`。 ## 结构化问答操作 当 AI 使用 `AskUserQuestion` 工具向你提问时,问题面板支持以下键盘操作: | 快捷键 | 功能 | |--------|------| | `↑` / `↓` | 浏览选项 | | `←` / `→` / `Tab` | 切换问题(多问题模式) | | `1`–`5` | 按编号选择选项(单选时自动提交,多选时切换选中状态) | | `Space` | 单选模式下提交选择,多选模式下切换选中状态 | | `Enter` | 确认选择 | | `Esc` | 跳过问题 | 当 AI 一次提出多个问题时,问题面板会以标签页形式展示,使用 `←` / `→` 或 `Tab` 可在问题间切换,已回答的问题会标记为已完成状态,切换回已回答的问题时会恢复之前的选择。 ## 退出与中断 ### `Ctrl-D`:退出 在输入框为空时按 `Ctrl-D` 退出 Kimi Code CLI。 ### `Ctrl-C`:中断 - 在输入框中:清空当前输入 - Agent 运行时:中断当前操作 - 斜杠命令执行时:中断命令 ## 补全操作 在 Agent 模式下,输入时会自动显示补全菜单: | 触发 | 补全内容 | |------|---------| | `/` | 斜杠命令 | | `@` | 工作目录文件路径 | 补全操作: - 方向键选择 - `Enter` 确认选择 - `Esc` 关闭菜单 - 继续输入过滤选项 ## 状态栏 底部状态栏显示: - 当前时间 - 当前模式(agent/shell)和模型名称(Agent 模式下显示) - YOLO 标识(开启时显示黄色标识) - Plan 标识(开启时显示蓝色标识) - 快捷键提示 - 上下文使用率 状态栏会自动刷新更新信息。 ================================================ FILE: docs/zh/reference/kimi-acp.md ================================================ # `kimi acp` 子命令 `kimi acp` 命令启动一个支持多会话的 ACP (Agent Client Protocol) 服务器。 ```sh kimi acp ``` ## 说明 ACP 是一种标准化协议,允许 IDE 和其他客户端与 AI Agent 进行交互。 ## 使用场景 - IDE 插件集成(如 JetBrains、Zed) - 自定义 ACP 客户端开发 - 多会话并发处理 如需在 IDE 中使用 Kimi Code CLI,请参阅 [在 IDE 中使用](../guides/ides.md)。 ## 认证 ACP 服务器在创建或加载会话前会检查用户认证状态。如果未登录,服务器会返回 `AUTH_REQUIRED` 错误(错误码 `-32000`),并携带可用的认证方式信息。 客户端收到此错误后,应引导用户在终端中执行 `kimi login` 命令完成登录。登录成功后,后续的 ACP 请求即可正常执行。 ================================================ FILE: docs/zh/reference/kimi-command.md ================================================ # `kimi` 命令 `kimi` 是 Kimi Code CLI 的主命令,用于启动交互式会话或执行单次查询。 ```sh kimi [OPTIONS] COMMAND [ARGS] ``` ## 基本信息 | 选项 | 简写 | 说明 | |------|------|------| | `--version` | `-V` | 显示版本号并退出 | | `--help` | `-h` | 显示帮助信息并退出 | | `--verbose` | | 输出详细运行信息 | | `--debug` | | 记录调试日志(输出到 `~/.kimi/logs/kimi.log`) | ## Agent 配置 | 选项 | 说明 | |------|------| | `--agent NAME` | 使用内置 Agent,可选值:`default`、`okabe` | | `--agent-file PATH` | 使用自定义 Agent 文件 | `--agent` 和 `--agent-file` 互斥,不能同时使用。详见 [Agent 与子 Agent](../customization/agents.md)。 ## 配置文件 | 选项 | 说明 | |------|------| | `--config STRING` | 加载 TOML/JSON 配置字符串 | | `--config-file PATH` | 加载配置文件(默认 `~/.kimi/config.toml`) | `--config` 和 `--config-file` 互斥。配置字符串和文件均支持 TOML 和 JSON 格式。详见 [配置文件](../configuration/config-files.md)。 ## 模型选择 | 选项 | 简写 | 说明 | |------|------|------| | `--model NAME` | `-m` | 指定 LLM 模型,覆盖配置文件中的默认模型 | ## 工作目录 | 选项 | 简写 | 说明 | |------|------|------| | `--work-dir PATH` | `-w` | 指定工作目录(默认当前目录) | | `--add-dir PATH` | | 添加额外目录到工作区范围,可多次指定 | 工作目录决定了文件操作的根目录。在工作目录内可使用相对路径,操作工作目录外的文件需使用绝对路径。 `--add-dir` 可以将工作目录之外的目录纳入工作区范围,使所有文件工具可以访问该目录中的文件。添加的目录会随会话状态持久化。运行中也可以通过 [`/add-dir`](./slash-commands.md#add-dir) 斜杠命令添加。 ## 会话管理 | 选项 | 简写 | 说明 | |------|------|------| | `--continue` | `-C` | 继续当前工作目录的上一个会话 | | `--session ID` | `-S` | 恢复指定 ID 的会话,若不存在则创建新会话 | `--continue` 和 `--session` 互斥。 ## 输入与命令 | 选项 | 简写 | 说明 | |------|------|------| | `--prompt TEXT` | `-p` | 传入用户提示,不进入交互模式 | | `--command TEXT` | `-c` | `--prompt` 的别名 | 使用 `--prompt`(或 `--command`)时,Kimi Code CLI 会处理完查询后退出(除非指定 `--print`,否则仍以交互模式显示结果)。 ## 循环控制 | 选项 | 说明 | |------|------| | `--max-steps-per-turn N` | 单轮最大步数,覆盖配置文件中的 `loop_control.max_steps_per_turn` | | `--max-retries-per-step N` | 单步最大重试次数,覆盖配置文件中的 `loop_control.max_retries_per_step` | | `--max-ralph-iterations N` | Ralph 循环模式的迭代次数;`0` 表示关闭;`-1` 表示无限 | ### Ralph 循环 [Ralph](https://ghuntley.com/ralph/) 是一种把 Agent 放进循环的技术:同一条提示词会被反复喂给 Agent,让它围绕一个任务持续迭代。 当 `--max-ralph-iterations` 非 `0` 时,Kimi Code CLI 会进入 Ralph 循环模式,自动循环执行任务,直到 Agent 输出 `STOP` 或达到迭代上限。 ## UI 模式 | 选项 | 说明 | |------|------| | `--print` | 以 Print 模式运行(非交互式),隐式启用 `--yolo` | | `--quiet` | `--print --output-format text --final-message-only` 的快捷方式 | | `--acp` | 以 ACP 服务器模式运行(已弃用,请使用 `kimi acp`) | | `--wire` | 以 Wire 服务器模式运行(实验性) | 四个选项互斥,只能选择一个。默认使用 Shell 模式。详见 [Print 模式](../customization/print-mode.md) 和 [Wire 模式](../customization/wire-mode.md)。 ## Print 模式选项 以下选项仅在 `--print` 模式下有效: | 选项 | 说明 | |------|------| | `--input-format FORMAT` | 输入格式:`text`(默认)或 `stream-json` | | `--output-format FORMAT` | 输出格式:`text`(默认)或 `stream-json` | | `--final-message-only` | 仅输出最终的 assistant 消息 | `stream-json` 格式使用 JSONL(每行一个 JSON 对象),用于程序化集成。 ## MCP 配置 | 选项 | 说明 | |------|------| | `--mcp-config-file PATH` | 加载 MCP 配置文件,可多次指定 | | `--mcp-config JSON` | 加载 MCP 配置 JSON 字符串,可多次指定 | 默认加载 `~/.kimi/mcp.json`(如果存在)。详见 [Model Context Protocol](../customization/mcp.md)。 ## 审批控制 | 选项 | 简写 | 说明 | |------|------|------| | `--yolo` | `-y` | 自动批准所有操作 | | `--yes` | | `--yolo` 的别名 | | `--auto-approve` | | `--yolo` 的别名 | ::: warning 注意 YOLO 模式下,所有文件修改和 Shell 命令都会自动执行,请谨慎使用。 ::: ## Thinking 模式 | 选项 | 说明 | |------|------| | `--thinking` | 启用 thinking 模式 | | `--no-thinking` | 禁用 thinking 模式 | Thinking 模式需要模型支持。如果不指定,使用上次会话的设置。 ## Skills 配置 | 选项 | 说明 | |------|------| | `--skills-dir PATH` | 指定 skills 目录,跳过自动发现 | 不指定时,Kimi Code CLI 会按优先级自动发现用户级和项目级 Skills 目录。详见 [Agent Skills](../customization/skills.md)。 ## 子命令 | 子命令 | 说明 | |--------|------| | [`kimi login`](#kimi-login) | 登录 Kimi 账号 | | [`kimi logout`](#kimi-logout) | 登出 Kimi 账号 | | [`kimi info`](./kimi-info.md) | 显示版本和协议信息 | | [`kimi acp`](./kimi-acp.md) | 启动多会话 ACP 服务器 | | [`kimi mcp`](./kimi-mcp.md) | 管理 MCP 服务器配置 | | [`kimi term`](./kimi-term.md) | 启动 Toad 终端 UI | | [`kimi export`](#kimi-export) | 导出会话为 ZIP 文件 | | [`kimi vis`](./kimi-vis.md) | 启动 Agent Tracing Visualizer(技术预览) | | [`kimi web`](./kimi-web.md) | 启动 Web UI 服务器 | ### `kimi login` 登录 Kimi 账号。执行后会自动打开浏览器,完成账号授权后自动配置可用的模型。 ```sh kimi login ``` ### `kimi logout` 登出 Kimi 账号。会清理存储的 OAuth 凭据并移除配置文件中的相关配置。 ```sh kimi logout ``` ### `kimi export` 将指定会话的数据导出为 ZIP 文件。ZIP 中包含会话目录下的所有文件(`context.jsonl`、`wire.jsonl`、`state.json` 等)。 ```sh kimi export [-o ] ``` | 参数 / 选项 | 说明 | |------|------| | `` | 要导出的会话 ID | | `--output, -o` | 输出 ZIP 文件路径(默认为当前目录下的 `session-.zip`) | ::: info 新增 新增于 1.20 版本。 ::: ### `kimi vis` ::: warning 注意 技术预览功能,可能不稳定。 ::: 启动 Agent Tracing Visualizer,通过浏览器查看和分析会话追踪数据。 ```sh kimi vis [OPTIONS] ``` | 选项 | 简写 | 说明 | |------|------|------| | `--port INTEGER` | `-p` | 绑定的端口号(默认:`5495`) | | `--open / --no-open` | | 自动打开浏览器(默认:启用) | | `--reload` | | 启用自动重载(开发模式) | 详见 [Agent Tracing Visualizer](./kimi-vis.md)。 ### `kimi web` 启动 Web UI 服务器,通过浏览器访问 Kimi Code CLI。 ```sh kimi web [OPTIONS] ``` 如果默认端口被占用,服务器会自动尝试下一个可用端口(默认范围 `5494`–`5503`),并在终端打印提示。 | 选项 | 简写 | 说明 | |------|------|------| | `--host TEXT` | `-h` | 绑定的主机地址(默认:`127.0.0.1`) | | `--port INTEGER` | `-p` | 绑定的端口号(默认:`5494`) | | `--reload` | | 启用自动重载(开发模式) | | `--open / --no-open` | | 自动打开浏览器(默认:启用) | 示例: ```sh # 默认启动,自动打开浏览器 kimi web # 指定端口 kimi web --port 8080 # 不自动打开浏览器 kimi web --no-open # 绑定到所有网络接口(允许局域网访问) kimi web --host 0.0.0.0 ``` 详见 [Web UI](./kimi-web.md)。 ================================================ FILE: docs/zh/reference/kimi-info.md ================================================ # `kimi info` 子命令 `kimi info` 显示 Kimi Code CLI 的版本和协议信息。 ```sh kimi info [--json] ``` ## 选项 | 选项 | 说明 | |------|------| | `--json` | 以 JSON 格式输出 | ## 输出内容 | 字段 | 说明 | |------|------| | `kimi_cli_version` | Kimi Code CLI 版本号 | | `agent_spec_versions` | 支持的 Agent 规格版本列表 | | `wire_protocol_version` | Wire 协议版本 | | `python_version` | Python 运行时版本 | ## 示例 **文本输出** ```sh $ kimi info kimi-cli version: 1.20.0 agent spec versions: 1 wire protocol: 1.5 python version: 3.13.1 ``` **JSON 输出** ```sh $ kimi info --json {"kimi_cli_version": "1.20.0", "agent_spec_versions": ["1"], "wire_protocol_version": "1.5", "python_version": "3.13.1"} ``` ================================================ FILE: docs/zh/reference/kimi-mcp.md ================================================ # `kimi mcp` 子命令 `kimi mcp` 用于管理 MCP (Model Context Protocol) 服务器配置。关于 MCP 的概念和使用方式,详见 [Model Context Protocol](../customization/mcp.md)。 ```sh kimi mcp COMMAND [ARGS] ``` ## `add` 添加 MCP 服务器配置。 ```sh kimi mcp add [OPTIONS] NAME [TARGET_OR_COMMAND...] ``` **参数** | 参数 | 说明 | |------|------| | `NAME` | 服务器名称,用于标识和引用 | | `TARGET_OR_COMMAND...` | `http` 模式为 URL;`stdio` 模式为命令(需以 `--` 开头) | **选项** | 选项 | 简写 | 说明 | |------|------|------| | `--transport TYPE` | `-t` | 传输类型:`stdio`(默认)或 `http` | | `--env KEY=VALUE` | `-e` | 环境变量(仅 `stdio`),可多次指定 | | `--header KEY:VALUE` | `-H` | HTTP Header(仅 `http`),可多次指定 | | `--auth TYPE` | `-a` | 认证类型(如 `oauth`,仅 `http`) | ## `list` 列出所有已配置的 MCP 服务器。 ```sh kimi mcp list ``` 输出包括: - 配置文件路径 - 每个服务器的名称、传输类型和目标 - OAuth 服务器的授权状态 ## `remove` 移除 MCP 服务器配置。 ```sh kimi mcp remove NAME ``` **参数** | 参数 | 说明 | |------|------| | `NAME` | 要移除的服务器名称 | ## `auth` 对使用 OAuth 的 MCP 服务器进行授权。 ```sh kimi mcp auth NAME ``` 执行后会打开浏览器进行 OAuth 授权流程。授权成功后,token 会被缓存以供后续使用。 **参数** | 参数 | 说明 | |------|------| | `NAME` | 要授权的服务器名称 | ::: tip 提示 只有使用 `--auth oauth` 添加的服务器才需要执行此命令。 ::: ## `reset-auth` 清除 MCP 服务器的 OAuth 缓存 token。 ```sh kimi mcp reset-auth NAME ``` **参数** | 参数 | 说明 | |------|------| | `NAME` | 要重置授权的服务器名称 | 清除后需要重新执行 `kimi mcp auth` 进行授权。 ## `test` 测试与 MCP 服务器的连接并列出可用工具。 ```sh kimi mcp test NAME ``` **参数** | 参数 | 说明 | |------|------| | `NAME` | 要测试的服务器名称 | 输出包括: - 连接状态 - 可用工具数量 - 工具名称和描述 ================================================ FILE: docs/zh/reference/kimi-term.md ================================================ # `kimi term` 子命令 `kimi term` 命令启动 [Toad](https://github.com/batrachianai/toad) 终端 UI,这是一个基于 [Textual](https://textual.textualize.io/) 的现代终端界面。 ```sh kimi term [OPTIONS] ``` ## 说明 [Toad](https://github.com/batrachianai/toad) 是 Kimi Code CLI 的图形化终端界面,通过 ACP 协议与 Kimi Code CLI 后端通信。它提供了更丰富的交互体验,包括更好的输出渲染和界面布局。 运行 `kimi term` 时,会自动在后台启动一个 `kimi acp` 服务器,Toad 作为 ACP 客户端连接到该服务器。 ## 选项 所有额外的选项会透传给内部的 `kimi acp` 命令。例如: ```sh kimi term --work-dir /path/to/project --model kimi-k2 ``` 常用选项: | 选项 | 说明 | |------|------| | `--work-dir PATH` | 指定工作目录 | | `--model NAME` | 指定模型 | | `--yolo` | 自动批准所有操作 | 完整选项请参阅 [`kimi` 命令](./kimi-command.md)。 ## 系统要求 ::: warning 注意 `kimi term` 需要 Python 3.14+。如果你使用较低版本的 Python 安装了 Kimi Code CLI,需要重新用 Python 3.14 安装才能使用此功能: ```sh uv tool install --python 3.14 kimi-cli ``` ::: ================================================ FILE: docs/zh/reference/kimi-vis.md ================================================ # Agent Tracing Visualizer ::: warning 注意 Agent Tracing Visualizer 目前为技术预览版(Technical Preview),功能和界面可能在后续版本中发生变化。 ::: Agent Tracing Visualizer 是一个基于浏览器的可视化仪表板,用于检查和分析 Kimi Code CLI 的会话追踪数据。它可以帮助你理解 Agent 的行为、查看 Wire 事件时间线、分析上下文使用情况,以及浏览历史会话。 ## 启动 在终端中运行 `kimi vis` 命令启动 Visualizer: ```sh kimi vis ``` 服务器启动后会自动打开浏览器。默认地址为 `http://127.0.0.1:5495`。 如果默认端口被占用,服务器会自动尝试下一个可用端口(默认范围 `5495`–`5504`),并在终端打印访问地址。 ## 命令行选项 | 选项 | 简写 | 说明 | |------|------|------| | `--port INTEGER` | `-p` | 指定端口号(默认:`5495`) | | `--open / --no-open` | | 启动时自动打开浏览器(默认:`--open`) | | `--reload` | | 启用自动重载(用于开发调试) | 示例: ```sh # 指定端口 kimi vis --port 8080 # 不自动打开浏览器 kimi vis --no-open ``` ## 功能 ### Wire 事件时间线 以时间线形式展示 Wire 事件的完整流程,包括轮次(Turn)的开始和结束、步骤(Step)的执行、工具调用和返回结果等。支持事件过滤和详细信息查看。 ### 上下文查看器 可视化展示会话的上下文内容,包括 User 消息、Assistant 消息和工具调用。帮助理解 Agent 在每个步骤中 "看到" 的信息。 ### 会话浏览器 浏览和搜索所有历史会话,按项目分组展示。可以查看每个会话的详细信息,包括工作目录、创建时间和消息数量。 ### 会话目录快捷操作 在会话详情页顶部,可以使用 `Open Dir` 直接打开当前会话目录。该操作在 macOS 上调用 Finder,在 Windows 上调用 Explorer。`Copy DIR` 会复制当前会话目录的原始路径,便于你在终端、编辑器或问题报告中继续排查。 ### 会话下载与导出 可以将会话数据导出为 ZIP 文件,方便离线分析或分享。 - **ZIP 下载**:在会话浏览器和会话详情页中点击下载按钮,即可将会话目录打包为 ZIP 文件下载 - **CLI 导出**:使用 `kimi export ` 命令将指定会话导出为 ZIP 文件 ### 会话导入 支持将 ZIP 格式的会话数据导入到 Visualizer 中查看。导入的会话存储在独立的 `~/.kimi/imported_sessions/` 目录中,不会与正常会话混淆。 在会话浏览器中可以通过 "Imported" 筛选器切换查看导入的会话。导入的会话支持删除操作,删除前会弹出确认对话框。 ### 用量统计 展示 Token 用量的统计数据和图表,包括输入和输出 Token 的分布、缓存命中率等信息。 ================================================ FILE: docs/zh/reference/kimi-web.md ================================================ # Web UI Web UI 提供了基于浏览器的交互界面,让你可以在网页中使用 Kimi Code CLI 的所有功能。相比终端界面,Web UI 提供了更丰富的视觉体验、更灵活的会话管理以及更便捷的文件操作。 ## 启动 Web UI 在终端中运行 `kimi web` 命令启动 Web UI 服务器: ```sh kimi web ``` 服务器启动后会自动打开浏览器访问 Web UI。默认地址为 `http://127.0.0.1:5494`。 如果默认端口被占用,服务器会自动尝试下一个可用端口(默认范围 `5494`–`5503`),并在终端打印访问地址。 ## 命令行选项 ### 网络配置 | 选项 | 简写 | 说明 | |------|------|------| | `--host TEXT` | `-h` | 绑定到指定的 IP 地址 | | `--network` | `-n` | 启用网络访问(绑定到 `0.0.0.0`) | | `--port INTEGER` | `-p` | 指定端口号(默认:`5494`) | 默认情况下,Web UI 只监听本地回环地址 `127.0.0.1`,仅允许本机访问。 如果你想在局域网或公网中访问 Web UI,可以使用 `--network` 选项或指定 `--host`: ```sh # 绑定到所有网络接口,允许局域网访问 kimi web --network # 绑定到指定 IP 地址 kimi web --host 192.168.1.100 ``` ::: warning 注意 当启用网络访问时,请务必配置访问控制选项(如 `--auth-token` 和 `--lan-only`)以确保安全。详见 [访问控制](#访问控制)。 ::: ### 浏览器控制 | 选项 | 说明 | |------|------| | `--open / --no-open` | 启动时自动打开浏览器(默认:`--open`) | 使用 `--no-open` 可以禁止自动打开浏览器: ```sh kimi web --no-open ``` ### 开发选项 | 选项 | 说明 | |------|------| | `--reload` | 启用自动重载(用于开发调试) | 使用 `--reload` 可以在代码修改后自动重启服务器: ```sh kimi web --reload ``` ::: info 说明 `--reload` 选项仅用于开发调试,日常使用不需要启用。 ::: ### 访问控制 Web UI 提供了多层访问控制机制,确保服务的安全性。 | 选项 | 说明 | |------|------| | `--auth-token TEXT` | 设置 Bearer Token 用于 API 认证 | | `--allowed-origins TEXT` | 设置允许的 Origin 列表(逗号分隔) | | `--lan-only / --public` | 仅允许局域网访问(默认)或允许公网访问 | | `--restrict-sensitive-apis / --no-restrict-sensitive-apis` | 限制敏感 API 访问(配置写入、open-in、文件访问限制) | | `--dangerously-omit-auth` | 禁用认证检查(危险,仅限受信任的网络环境) | ::: info 新增 访问控制选项新增于 1.6 版本。 ::: #### 访问令牌认证 使用 `--auth-token` 可以设置访问令牌,客户端需要在 HTTP 请求头中携带 `Authorization: Bearer ` 才能访问 API: ```sh kimi web --network --auth-token my-secret-token ``` ::: tip 提示 访问令牌应该是一个随机生成的字符串,建议至少包含 32 个字符。可以使用 `openssl rand -hex 32` 生成随机令牌。 ::: #### Origin 检查 使用 `--allowed-origins` 可以限制允许访问 Web UI 的来源域名: ```sh kimi web --network --allowed-origins "https://example.com,https://app.example.com" ``` ::: tip 提示 当使用 `--network` 或 `--host` 启用网络访问时,建议配置 `--allowed-origins` 以防止跨站请求伪造(CSRF)攻击。 ::: #### 网络访问范围 默认情况下,Web UI 使用 `--lan-only` 模式,只允许来自局域网(私有 IP 地址段)的访问。如果需要允许公网访问,可以使用 `--public` 选项: ```sh kimi web --network --public --auth-token my-secret-token ``` ::: danger 警告 使用 `--public` 选项会允许任何 IP 地址访问 Web UI,请务必配置 `--auth-token` 和 `--allowed-origins` 以确保安全。 ::: #### 限制敏感 API 使用 `--restrict-sensitive-apis` 可以禁用一些敏感的 API 功能: - 配置文件写入 - Open-in 功能(打开本地文件、目录、应用) - 文件访问限制 ```sh kimi web --network --restrict-sensitive-apis ``` 在 `--public` 模式下,`--restrict-sensitive-apis` 默认启用;在 `--lan-only` 模式(默认)下则不启用。 ::: tip 提示 当你需要将 Web UI 暴露给不受信任的网络环境时,建议启用 `--restrict-sensitive-apis` 选项。 ::: #### 禁用认证(不推荐) 在受信任的私有网络环境中,你可以使用 `--dangerously-omit-auth` 跳过所有认证检查: ```sh kimi web --dangerously-omit-auth ``` ::: danger 警告 `--dangerously-omit-auth` 选项会完全禁用认证和访问控制,仅应在完全受信任的网络环境中使用(如断网的本地开发环境)。不要在公网或不受信任的局域网中使用此选项。 ::: ## 从终端切换到 Web UI 如果你正在终端的 Shell 模式中使用 Kimi Code CLI,可以输入 `/web` 命令快速切换到 Web UI: ``` /web ``` 执行后,Kimi Code CLI 会自动启动 Web UI 服务器并在浏览器中打开当前会话。你可以继续在 Web UI 中进行对话,会话历史会保持同步。 ## Web UI 功能特性 ### 会话管理 Web UI 提供了便捷的会话管理界面: - **会话列表**:查看所有历史会话,包括会话标题和工作目录 - **会话搜索**:通过标题或工作目录快速筛选会话 - **创建会话**:指定工作目录创建新会话;如果指定的路径不存在,会提示确认是否创建目录。支持 Cmd/Ctrl+点击新建会话按钮在新标签页中打开会话创建 - **切换会话**:一键切换到不同的会话 - **会话分支**:从任意 Assistant 回复处创建分支会话,在不影响原会话的情况下探索不同方向 - **会话归档**:超过 15 天的会话会自动归档,你也可以手动归档。归档的会话不会出现在主列表中,但可以随时取消归档 - **批量操作**:在多选模式下批量归档、取消归档或删除会话 ::: info 新增 会话搜索功能新增于 1.5 版本。目录自动创建提示新增于 1.7 版本。会话分支、归档和批量操作新增于 1.9 版本。 ::: ### 提示工具栏 Web UI 在输入框上方提供统一的提示工具栏,以可折叠标签页的形式展示多种信息: - **上下文用量**:显示当前上下文的使用百分比,悬停可查看详细的 Token 用量明细(包括输入/输出 Token、缓存读取/写入等) - **活动状态**:显示 Agent 当前状态(处理中、等待审批等) - **消息队列**:在 AI 处理过程中可以排队发送后续消息,待当前回复完成后自动发送 - **文件变更**:检测 Git 仓库状态,显示新增、修改和删除的文件数量(包含未跟踪文件),点击可查看详细的变更列表 - **待办事项**:当 `SetTodoList` 工具处于活动状态时,显示任务进度,支持展开查看详细列表 - **Plan 模式**:在输入工具栏中切换 Plan 模式开关。Plan 模式激活时,输入框显示蓝色虚线边框。也可以通过 `set_plan_mode` Wire 协议方法程序化设置 ::: info 变更 Git diff 状态栏新增于 1.5 版本。1.9 版本添加了活动状态指示器。1.10 版本将其统一为提示工具栏。1.11 版本将上下文用量指示器移至提示工具栏。1.20 版本新增 Plan 模式切换。 ::: ### Open-in 功能 Web UI 支持在本地应用中打开文件或目录: - **Open in Terminal**:在终端中打开目录 - **Open in VS Code**:在 VS Code 中打开文件或目录 - **Open in Cursor**:在 Cursor 中打开文件或目录 - **Open in System**:使用系统默认应用打开 ::: info 新增 Open-in 功能新增于 1.5 版本。 ::: ::: warning 注意 Open-in 功能需要浏览器支持 Custom Protocol Handler 特性。当使用 `--restrict-sensitive-apis` 选项时,此功能会被禁用。 ::: ### 斜杠命令 Web UI 支持斜杠命令,在输入框中输入 `/` 即可打开命令菜单: - **自动补全**:输入命令名称时自动过滤匹配项 - **键盘导航**:使用上下方向键选择命令,Enter 确认 - **别名支持**:支持命令别名匹配,如 `/h` 匹配 `/help` ### 文件提及 Web UI 支持文件提及功能,在输入框中输入 `@` 即可打开文件提及菜单,可以在对话中引用文件: - **已上传附件**:提及当前消息中已添加的附件文件 - **工作区文件**:提及当前会话工作目录中的已有文件 - **自动补全**:输入时按文件名或路径自动过滤匹配项 - **键盘导航**:使用上下方向键选择文件,Enter 或 Tab 确认,Escape 取消 ### 消息操作 Assistant 消息提供以下操作按钮: - **复制**:一键复制消息内容到剪贴板 - **分支**:从当前回复处创建分支会话 ::: info 新增 复制和分支按钮新增于 1.10 版本。 ::: ### 结构化问答 当 AI 使用 `AskUserQuestion` 工具时,Web UI 会在聊天区域中展示结构化的问题对话框,替代底部的输入框。问题对话框显示问题描述和可选项,支持单选、多选以及自定义文本输入。当 AI 一次提出多个问题时,对话框顶部会以标签栏形式展示问题列表,支持点击切换、键盘导航,以及切换回已答问题时恢复之前的选择。回答所有问题后,对话框自动关闭,AI 根据你的选择继续执行。 ::: info 新增 结构化问答功能新增于 1.14 版本。 ::: ### 审批键盘快捷键 当 Agent 发起审批请求时,你可以使用键盘快捷键快速响应: | 快捷键 | 操作 | |--------|------| | `1` | 批准 | | `2` | 本次会话批准 | | `3` | 拒绝 | ::: info 新增 审批键盘快捷键新增于 1.10 版本。 ::: ### 工具输出 Web UI 对工具调用的输出提供了丰富的展示方式: - **媒体预览**:`ReadMediaFile` 工具读取的图片和视频会以可点击的缩略图形式展示 - **Shell 命令**:`Shell` 工具的命令和输出以专用组件渲染 - **Todo 列表**:`SetTodoList` 工具的待办事项以结构化列表展示 - **工具输入参数**:重新设计的工具输入 UI,支持展开查看参数详情,长值带有语法高亮 - **上下文压缩**:上下文压缩进行时会显示压缩指示器 - **URL 快速打开**:`FetchURL` 工具的 URL 参数支持 Cmd/Ctrl+点击在新标签页中打开链接 ::: info 新增 媒体预览、Shell 命令和 Todo 列表显示组件新增于 1.9 版本。URL 快速打开功能新增于 1.14 版本。 ::: ### 富媒体支持 Web UI 支持查看和粘贴多种类型的富媒体内容: - **图片**:直接在聊天界面中显示图片 - **代码高亮**:自动识别和高亮代码块 - **Markdown 渲染**:支持完整的 Markdown 语法 ### 响应式布局 Web UI 采用响应式设计,可以在不同尺寸的屏幕上良好显示: - 桌面端:侧边栏 + 主内容区布局 - 移动端:可折叠的抽屉式侧边栏 ::: info 变更 响应式布局改进于 1.6 版本,增强了悬停效果和布局处理。 ::: ### URL 操作参数 Web UI 支持通过 URL 参数触发特定操作,方便从外部工具或脚本中集成: | 参数 | 说明 | |------|------| | `?action=create` | 打开创建会话对话框 | | `?action=create-in-dir&workDir=` | 直接在指定工作目录下创建会话 | 示例: ``` http://127.0.0.1:5494?action=create http://127.0.0.1:5494?action=create-in-dir&workDir=/path/to/project ``` ## 示例 ### 本地使用 最简单的使用方式,只在本机访问: ```sh kimi web ``` ### 局域网共享 在局域网中共享 Web UI,使用访问令牌保护: ```sh kimi web --network --auth-token $(openssl rand -hex 32) ``` 执行后,终端会显示访问地址和令牌。其他设备可以通过该地址访问,并在浏览器中输入令牌进行认证。 ### 公网访问 在公网环境中部署 Web UI(需要谨慎配置安全选项): ```sh kimi web \ --host 0.0.0.0 \ --public \ --auth-token $(openssl rand -hex 32) \ --allowed-origins "https://yourdomain.com" \ --restrict-sensitive-apis ``` ### 开发调试 启用自动重载功能,方便开发调试: ```sh kimi web --reload --no-open ``` ## 技术说明 Web UI 基于以下技术构建: - **后端**:FastAPI + WebSocket - **前端**:React + TypeScript + Vite - **API 协议**:符合 OpenAPI 规范,详见 `web/openapi.json` Web UI 通过 WebSocket 与 Kimi Code CLI 的 Wire 模式通信,实现实时的双向数据传输。 ================================================ FILE: docs/zh/reference/slash-commands.md ================================================ # 斜杠命令 斜杠命令是 Kimi Code CLI 的内置命令,用于控制会话、配置和调试。在输入框中输入 `/` 开头的命令即可触发。 ::: tip Shell 模式 部分斜杠命令在 Shell 模式下也可以使用,包括 `/help`、`/exit`、`/version`、`/editor`、`/changelog`、`/feedback`、`/export`、`/import` 和 `/task`。 ::: ## 帮助与信息 ### `/help` 显示帮助信息。在全屏分页器中列出键盘快捷键、所有可用的斜杠命令以及已加载的 Skills。按 `q` 退出。 别名:`/h`、`/?` ### `/version` 显示 Kimi Code CLI 版本号。 ### `/changelog` 显示最近版本的变更记录。 别名:`/release-notes` ### `/feedback` 打开 GitHub Issues 页面提交反馈。 ## 账号与配置 ### `/login` 登录或配置 API 平台。执行后首先选择平台: - **Kimi Code**:自动打开浏览器进行 OAuth 授权登录 - **其他平台**:输入 API 密钥,然后选择可用模型 配置完成后自动保存到 `~/.kimi/config.toml` 并重新加载。详见 [平台与模型](../configuration/providers.md)。 别名:`/setup` ::: tip 提示 此命令仅在使用默认配置文件时可用。如果通过 `--config` 或 `--config-file` 指定了配置,则无法使用此命令。 ::: ### `/logout` 登出当前平台。会清理存储的凭据并移除配置文件中的相关配置。登出后 Kimi Code CLI 会自动重新加载配置。 ### `/model` 切换模型和 Thinking 模式。 此命令会先从 API 平台刷新可用模型列表。不带参数调用时,显示交互式选择界面,首先选择模型,然后选择是否开启 Thinking 模式(如果模型支持)。 选择完成后,Kimi Code CLI 会自动更新配置文件并重新加载。 ::: tip 提示 此命令仅在使用默认配置文件时可用。如果通过 `--config` 或 `--config-file` 指定了配置,则无法使用此命令。 ::: ### `/editor` 设置外部编辑器。不带参数调用时,显示交互式选择界面;也可以直接指定编辑器命令,如 `/editor vim`。配置后按 `Ctrl-O` 会使用此编辑器打开当前输入内容。详见 [键盘快捷键](./keyboard.md#外部编辑器)。 ### `/reload` 重新加载配置文件,无需退出 Kimi Code CLI。 ### `/debug` 显示当前上下文的调试信息,包括: - 消息数量和 token 数 - 检查点数量 - 完整的消息历史 调试信息会在分页器中显示,按 `q` 退出。 ### `/usage` 显示 API 用量和配额信息,以进度条和剩余百分比的形式展示各类配额的使用情况。 别名:`/status` ::: tip 提示 此命令仅适用于 Kimi Code 平台。 ::: ### `/mcp` 显示当前连接的 MCP 服务器和加载的工具。详见 [Model Context Protocol](../customization/mcp.md)。 输出包括: - 服务器连接状态(绿色表示已连接) - 每个服务器提供的工具列表 ## 会话管理 ### `/new` 创建一个新会话并立即切换过去,无需退出 Kimi Code CLI。如果当前会话没有任何内容,会自动清理空会话目录。 ### `/sessions` 列出当前工作目录下的所有会话,可切换到其他会话。 别名:`/resume` 使用方向键选择会话,按 `Enter` 确认切换,按 `Ctrl-C` 取消。 ### `/export` 将当前会话的上下文导出为 Markdown 文件,方便归档或分享。 用法: - `/export`:导出到当前工作目录,文件名自动生成(格式为 `kimi-export-<会话ID前8位>-<时间戳>.md`) - `/export `:导出到指定路径。如果路径是目录,文件名会自动生成;如果是文件路径,则直接写入该文件 导出文件包含: - 会话元数据(会话 ID、导出时间、工作目录、消息数、token 数) - 对话概览(主题、轮次数、工具调用次数) - 完整的对话历史,按轮次组织,包括用户消息、AI 回复、工具调用和工具结果 ### `/import` 从文件或其他会话导入上下文到当前会话。导入的内容会作为参考上下文附加到当前对话中,AI 可以利用这些信息来辅助后续的交互。 用法: - `/import `:从文件导入。支持 Markdown、文本、代码、配置文件等常见文本格式;不支持二进制文件(如图片、PDF、压缩包) - `/import `:从指定会话 ID 导入。不能导入当前会话自身 ### `/clear` 清空当前会话的上下文,开始新的对话。 别名:`/reset` ### `/compact` 手动压缩上下文,减少 token 使用。可以在命令后附带自定义指引,告诉 AI 在压缩时优先保留哪些信息,例如 `/compact 保留数据库相关的讨论`。 当上下文过长时,Kimi Code CLI 会自动触发压缩。此命令可手动触发压缩过程。 ## Skills ### `/skill:` 加载指定的 Skill,将 `SKILL.md` 内容作为提示词发送给 Agent。此命令适用于普通 Skill 和 Flow Skill。 例如: - `/skill:code-style`:加载代码风格规范 - `/skill:pptx`:加载 PPT 制作流程 - `/skill:git-commits 修复用户登录问题`:加载 Skill 并附带额外的任务描述 命令后面可以附带额外的文本,这些内容会追加到 Skill 提示词之后。详见 [Agent Skills](../customization/skills.md)。 ::: tip 提示 Flow Skill 也可以通过 `/skill:` 调用,此时作为普通 Skill 加载内容,不会自动执行流程。如需执行流程,请使用 `/flow:`。 ::: ### `/flow:` 执行指定的 Flow Skill。Flow Skill 在 `SKILL.md` 中内嵌 Agent Flow 流程图,执行后 Agent 会从 `BEGIN` 节点开始,按照流程图定义依次处理每个节点,直到到达 `END` 节点。 例如: - `/flow:code-review`:执行代码审查工作流 - `/flow:release`:执行发布工作流 ::: tip 提示 Flow Skill 也可以通过 `/skill:` 调用,此时作为普通 Skill 加载内容,不会自动执行流程。 ::: 详见 [Agent Skills](../customization/skills.md#flow-skills)。 ## 工作区 ### `/add-dir` 将额外目录添加到工作区范围。添加后,该目录对所有文件工具(`ReadFile`、`WriteFile`、`Glob`、`Grep`、`StrReplaceFile` 等)可用,并会在系统提示词中展示目录结构。添加的目录会随会话状态持久化,恢复会话时自动还原。 用法: - `/add-dir `:添加指定目录到工作区 - `/add-dir`:不带参数时列出已添加的额外目录 ::: tip 提示 已在工作目录内的目录无需添加,因为它们已经可访问。也可以在启动时通过 `--add-dir` 参数添加,详见 [`kimi` 命令](./kimi-command.md#工作目录)。 ::: ## 其他 ### `/init` 分析当前项目并生成 `AGENTS.md` 文件。 此命令会启动一个临时子会话分析代码库结构,生成项目说明文档,帮助 Agent 更好地理解项目。 ### `/plan` 切换 Plan 模式。Plan 模式下 AI 只能使用只读工具探索代码库,将实施方案写入 plan 文件后提交给你审批。详见 [Plan 模式](../guides/interaction.md#plan-模式)。 用法: - `/plan`:切换 Plan 模式开关 - `/plan on`:开启 Plan 模式 - `/plan off`:关闭 Plan 模式 - `/plan view`:查看当前方案内容 - `/plan clear`:清除当前方案文件 开启 Plan 模式后,提示符变为 `📋`,底部状态栏显示蓝色的 `plan` 标识。 ### `/task` 打开交互式任务浏览器,查看、监控和管理后台任务。 任务浏览器为三列 TUI 界面: - **左列**:任务列表,显示任务 ID、状态和描述 - **中列**:选中任务的详细信息,包括 ID、状态、描述、时间、exit code 等 - **右列**:最后几行输出预览 支持以下键盘操作: | 快捷键 | 功能 | |--------|------| | `Enter` / `O` | 在分页器中查看选中任务的完整输出 | | `S` | 请求停止选中任务(需确认) | | `Tab` | 切换过滤模式(全部 / 仅活跃任务) | | `R` | 刷新任务列表 | | `Q` / `Esc` | 退出浏览器 | 任务浏览器每秒自动刷新,实时显示任务状态变化。 ::: tip 提示 后台任务通过 AI 使用 `Shell` 工具的 `run_in_background=true` 参数启动。当后台任务完成时,系统会自动通知 AI。 ::: ### `/yolo` 切换 YOLO 模式。开启后自动批准所有操作,底部状态栏会显示黄色的 YOLO 标识;再次输入可关闭。 ::: warning 注意 YOLO 模式会跳过所有确认,请确保你了解可能的风险。 ::: ### `/web` 切换到 Web UI。执行后 Kimi Code CLI 会启动 Web UI 服务器并在浏览器中打开当前会话,你可以在 Web UI 中继续对话。详见 [Web UI](./kimi-web.md)。 ## 命令补全 在输入框中输入 `/` 后,会自动显示可用命令列表。继续输入可过滤命令,支持模糊匹配,按 Enter 选择。 例如,输入 `/ses` 会匹配到 `/sessions`,输入 `/clog` 会匹配到 `/changelog`。命令的别名也支持匹配,例如输入 `/h` 会匹配到 `/help`。 ================================================ FILE: docs/zh/release-notes/breaking-changes.md ================================================ # 破坏性变更与迁移说明 本页面记录 Kimi Code CLI 各版本中的破坏性变更及对应的迁移指引。 ## 未发布 ## 0.81 - Prompt Flow 被 Flow Skills 取代 ### `--prompt-flow` 选项移除 `--prompt-flow` CLI 选项已移除,请改用 flow skills。 - **受影响**:使用 `--prompt-flow` 加载 Mermaid/D2 流程图的脚本和自动化 - **迁移**:创建包含嵌入式 Agent Flow 的 flow skill(在 `SKILL.md` 中),并通过 `/flow:` 调用 ### `/begin` 命令被替换 `/begin` 斜杠命令已被 `/flow:` 命令替换。 - **受影响**:使用 `/begin` 启动已加载 Prompt Flow 的用户 - **迁移**:使用 `/flow:` 直接调用 flow skills ## 0.77 - Thinking 模式与 CLI 选项变更 ### Thinking 模式设置迁移调整 从 `0.76` 升级后,Thinking 模式设置不再自动保留。此前保存在 `~/.kimi/kimi.json` 中的 `thinking` 状态不再使用,改为通过 `~/.kimi/config.toml` 中的 `default_thinking` 配置项管理,但不会自动从旧版 `metadata` 迁移。 - **受影响**:此前启用 Thinking 模式的用户 - **迁移**:升级后需重新设置 Thinking 模式: - 使用 `/model` 命令选择模型时设置 Thinking 模式(交互式) - 或手动在 `~/.kimi/config.toml` 中添加: ```toml default_thinking = true # 如需默认启用 Thinking 模式 ``` ### `--query` 选项移除 `--query`(`-q`)已移除,改用 `--prompt` 作为主推参数,`--command` 作为别名。 - **受影响**:使用 `--query` 或 `-q` 的脚本与自动化 - **迁移**: - `--query` / `-q` → `--prompt` / `-p` - 或继续使用 `--command` / `-c` ## 0.74 - ACP 命令变更 ### `--acp` 选项弃用 `--acp` 选项已弃用,请使用 `kimi acp` 子命令。 - **受影响**:使用 `kimi --acp` 的脚本和 IDE 配置 - **迁移**:`kimi --acp` → `kimi acp` ## 0.66 - 配置文件与供应商类型 ### 配置文件格式迁移 配置文件格式从 JSON 迁移至 TOML。 - **受影响**:使用 `~/.kimi/config.json` 的用户 - **迁移**:Kimi Code CLI 会自动读取旧的 JSON 配置,但建议手动迁移到 TOML 格式 - **新位置**:`~/.kimi/config.toml` JSON 配置示例: ```json { "default_model": "kimi-k2-0711", "providers": { "kimi": { "type": "kimi", "base_url": "https://api.kimi.com/coding/v1", "api_key": "your-key" } } } ``` 对应的 TOML 配置: ```toml default_model = "kimi-k2-0711" [providers.kimi] type = "kimi" base_url = "https://api.kimi.com/coding/v1" api_key = "your-key" ``` ### `google_genai` 供应商类型重命名 Gemini Developer API 的供应商类型从 `google_genai` 重命名为 `gemini`。 - **受影响**:配置中使用 `type = "google_genai"` 的用户 - **迁移**:将配置中的 `type` 值改为 `"gemini"` - **兼容性**:`google_genai` 仍可使用,但建议更新 ## 0.57 - 工具变更 ### `Shell` 工具 `Bash` 工具(Windows 上为 `CMD`)统一重命名为 `Shell`。 - **受影响**:Agent 文件中引用 `Bash` 或 `CMD` 工具的配置 - **迁移**:将工具引用改为 `Shell` ### `Task` 工具移至 `multiagent` 模块 `Task` 工具从 `kimi_cli.tools.task` 移至 `kimi_cli.tools.multiagent` 模块。 - **受影响**:自定义工具中导入 `Task` 工具的代码 - **迁移**:将导入路径改为 `from kimi_cli.tools.multiagent import Task` ### `PatchFile` 工具移除 `PatchFile` 工具已移除。 - **受影响**:使用 `PatchFile` 工具的 Agent 配置 - **替代**:使用 `StrReplaceFile` 工具进行文件修改 ## 0.52 - CLI 选项变更 ### `--ui` 选项移除 `--ui` 选项已移除,改用独立的标志位。 - **受影响**:使用 `--ui print`、`--ui acp`、`--ui wire` 的脚本 - **迁移**: - `--ui print` → `--print` - `--ui acp` → `kimi acp` - `--ui wire` → `--wire` ## 0.42 - 快捷键变更 ### 模式切换快捷键 Agent/Shell 模式切换快捷键从 `Ctrl-K` 改为 `Ctrl-X`。 - **受影响**:习惯使用 `Ctrl-K` 切换模式的用户 - **迁移**:使用 `Ctrl-X` 切换模式 ## 0.27 - CLI 选项重命名 ### `--agent` 选项重命名 `--agent` 选项重命名为 `--agent-file`。 - **受影响**:使用 `--agent` 指定自定义 Agent 文件的脚本 - **迁移**:将 `--agent` 改为 `--agent-file` - **注意**:`--agent` 现在用于指定内置 Agent(如 `default`、`okabe`) ## 0.25 - 包名变更 ### 包名从 `ensoul` 改为 `kimi-cli` - **受影响**:使用 `ensoul` 包名的代码或脚本 - **迁移**: - 安装:`pip install ensoul` → `pip install kimi-cli` 或 `uv tool install kimi-cli` - 命令:`ensoul` → `kimi` ### `ENSOUL_*` 参数前缀变更 系统提示词内置参数前缀从 `ENSOUL_*` 改为 `KIMI_*`。 - **受影响**:自定义 Agent 文件中使用 `ENSOUL_*` 参数的配置 - **迁移**:将参数前缀改为 `KIMI_*`(如 `ENSOUL_NOW` → `KIMI_NOW`) ================================================ FILE: docs/zh/release-notes/changelog.md ================================================ # 变更记录 本页面记录 Kimi Code CLI 各版本的变更内容。 ## 未发布 - Shell:在提示工具栏中显示当前工作目录、Git 分支、脏状态以及与远端的 ahead/behind 同步状态 - Shell:在工具栏中显示活跃后台 Bash 任务数量,按时间轮换快捷键提示,并在窄终端中优雅截断内容以避免溢出 - Web:修复取消和审批时工具执行状态同步问题——停止生成时工具现在正确过渡到 `output-denied` 状态,审批通过后执行期间显示加载动画(而非勾选图标) - Web:会话重放时消除过期的审批和问答对话框——重放会话或后端报告 idle/stopped/error 状态时,所有待处理的审批/问答对话框现在会被正确消除,防止产生孤立的交互元素 - Web:支持行内数学公式渲染——除块级数学公式(`$$...$$`)外,新增支持单美元符号行内数学公式(`$...$`) - Web:优化 Switch 切换开关的比例和对齐——切换轨道现在更大(36×20),拇指按钮保持 16px 并具备更平滑的 16px 位移动画 ## 1.24.0 (2026-03-18) - Shell:提高长文本粘贴自动折叠阈值至 1000 字符或 15 行(之前为 300 字符或 3 行),改善语音/无键盘输入等场景下的体验 - Core:Plan 模式现在支持多选方案——当 Agent 的计划包含多个不同路径时,`ExitPlanMode` 可展示 2–3 个带标签的选项供用户选择执行哪一个方案;用户选择的方案会作为选定路径返回给 Agent - Core:跨进程重启持久化 Plan 会话 ID 和文件路径——Plan 会话标识符和文件 slug 保存到 `SessionState`,重启 Kimi Code 后会继续使用 `~/.kimi/plans/` 下的同一计划文件,而非创建新文件 - Core:Plan 模式现在支持增量编辑计划文件——Agent 可以使用 `StrReplaceFile` 精准更新计划文件的特定部分,而无需通过 `WriteFile` 重写整个文件;同时非计划文件的编辑现在会被直接阻止,而非弹出审批请求 - Core:延迟 MCP 启动并展示加载进度——MCP 服务器现在在 Shell UI 启动后异步初始化,并提供实时进度指示器显示连接状态;Shell 在状态区域显示连接中和就绪状态,Web 显示服务器连接状态 - Core:优化轻量级启动路径——对 CLI 子命令和版本元数据实现延迟加载,显著缩短 `--version` 和 `--help` 等常用命令的启动时间 - Build:修复 Nix `FileCollisionError` for `bin/kimi`——从 `kimi-code` 包中移除重复的入口点,使 `kimi-cli` 独占 `bin/kimi` - Shell:Agent 运行期间保留用户未提交的输入——在模型运行时在提示符中键入的文本不再在轮次结束时丢失,用户可以按回车键将草稿作为下一条消息提交 - Shell:修复 Agent 运行结束后 Ctrl-C 和 Ctrl-D 无法正常工作的问题——键盘中断和 EOF 信号被静默吞没,而非显示提示信息或退出 Shell ## 1.23.0 (2026-03-17) - Shell:新增后台 Bash——`Shell` 工具现在支持 `run_in_background=true` 参数,可将耗时命令(构建、测试、服务)作为后台任务启动,Agent 无需等待即可继续工作;新增 `TaskList`、`TaskOutput`、`TaskStop` 工具管理任务生命周期,任务到达终止态时系统自动通知 Agent - Shell:新增 `/task` 斜杠命令与交互式任务浏览器——三列 TUI 界面,支持查看、监控和管理后台任务,提供实时刷新、输出预览和键盘驱动的任务停止操作 - Web:修复切换模型后其他标签页全局配置未刷新的问题——在某个标签页中切换模型时,其他标签页现在能检测到配置更新并自动刷新全局配置 ## 1.22.0 (2026-03-13) - Shell:长文本粘贴自动折叠为 `[Pasted text #n]` 占位符——通过 `Ctrl-V` 或括号粘贴输入的超过 300 字符或 3 行的文本在提示缓冲区中显示为紧凑的占位符标记,完整内容在发送给模型时展开;外部编辑器(`Ctrl-O`)打开时自动展开占位符,保存后重新折叠 - Shell:粘贴的图片缓存为附件占位符——从剪贴板粘贴的图片存储到磁盘,在提示中显示为 `[image:…]` 标记,保持输入缓冲区整洁 - Shell:修复粘贴文本中 UTF-16 surrogate 字符导致序列化错误的问题——来自 Windows 剪贴板的孤立 surrogate 字符现在在存储前即被清洗,防止历史记录写入和 JSON 序列化时出现 `UnicodeEncodeError` - Shell:重新设计斜杠命令补全菜单——使用全宽自定义菜单替代默认的补全弹窗,展示命令名称和多行描述,支持高亮和滚动 - Shell:修复取消的 Shell 命令未正确终止子进程的问题——当运行中的命令被取消时,子进程现在会被显式杀死,防止产生孤儿进程 ## 1.21.0 (2026-03-12) - Shell:新增内联运行提示与 steer 输入——模型运行时 Agent 输出直接渲染在提示区域内,用户无需等待轮次结束即可输入并发送后续消息(steer);审批请求和问答面板支持内联键盘交互 - Core:将 steer 注入方式从合成工具调用改为常规 User 消息——steer 内容现作为标准 User 消息追加到上下文,而非伪造的 `_steer` 工具调用/工具结果对,改善了上下文序列化和可视化的兼容性 - Wire:新增 `SteerInput` 事件——当用户在运行中的轮次发送后续 steer 消息时触发的新 Wire 协议事件 - Shell:Agent 模式下提交后回显用户输入——提示符和输入文本会打印回终端,使对话记录更清晰 - Shell:改进会话回放对 steer 输入的支持——回放现在能正确重建并展示 steer 消息与常规轮次,并过滤内部 system-reminder 消息 - Shell:修复 toast 通知中升级命令不一致的问题——升级命令文本统一从 `UPGRADE_COMMAND` 常量获取 - Core:在 `context.jsonl` 中持久化系统提示词——系统提示词作为上下文文件的第一条记录写入,并在会话生命周期内冻结,使可视化工具能读取完整对话上下文,会话恢复时复用原始提示词而非重新生成 - Vis:为 `kimi vis` 新增会话目录快捷操作——可在会话页面直接打开当前会话文件夹,使用 `Copy DIR` 复制原始会话目录路径,并支持在 macOS 和 Windows 上打开目录 - Shell:优化 API 密钥登录体验——验证密钥时显示加载动画,当 401 错误可能因选错平台导致时显示提示信息,登录成功后展示配置摘要,并将 Thinking 模式默认设为开启 ## 1.20.0 (2026-03-11) - Web:新增 Web UI 中的 Plan 模式切换——在输入工具栏中添加开关控件,Plan 模式激活时输入框显示蓝色虚线边框,并支持通过 `set_plan_mode` Wire 协议方法设置 Plan 模式 - Core:Plan 模式状态跨会话持久化——将 `plan_mode` 保存到 `SessionState`,会话恢复时自动还原 - Core:修复工具触发的 Plan 模式变更未正确反映在 StatusUpdate 中的问题——在 `EnterPlanMode`/`ExitPlanMode` 工具执行后发送更新的 `StatusUpdate`,确保客户端看到最新状态 - Core:修复部分 Linux 系统(如内核版本 6.8.0-101)上 HTTP 请求头包含尾部空白/换行符导致连接错误的问题——发送前对 ASCII 请求头值执行空白裁剪 - Core:修复 OpenAI Responses provider 隐式发送 `reasoning.effort=null` 导致需要推理的 Responses 兼容端点报错的问题——现在仅在显式设置时才发送推理参数 - Vis:新增会话下载、导入、导出与删除功能——在会话浏览器和详情页支持一键 ZIP 下载,支持将 ZIP 文件导入到独立的 `~/.kimi/imported_sessions/` 目录并通过"Imported"筛选器切换查看,新增 `kimi export ` CLI 命令,支持删除导入的会话并提供 AlertDialog 二次确认 - Core:修复对话包含媒体内容(图片、音频、视频)时上下文压缩失败的问题——将过滤策略从黑名单(排除 `ThinkPart`)改为白名单(仅保留 `TextPart`),防止不支持的内容类型被发送到压缩 API - Web:修复 `@` 文件提及索引在切换会话或工作区文件变更后不刷新的问题——切换会话时重置索引,30 秒过期自动刷新,输入路径前缀可查找超出 500 文件上限的文件 ## 1.19.0 (2026-03-10) - Core:新增 Plan 模式——AI 在编码前先制定实施方案并提交审批。Plan 模式下仅允许使用只读工具(`Glob`、`Grep`、`ReadFile`)探索代码库,将方案写入 plan 文件后通过 `ExitPlanMode` 提交审批,用户可批准、拒绝或提供修改意见;支持 `Shift-Tab` 快捷键和 `/plan` 斜杠命令切换 - Vis:新增 `kimi vis` 命令,启动交互式可视化仪表板以检查会话追踪——包括 Wire 事件时间线、上下文查看器、会话浏览器和用量统计 - Web:修复会话流状态管理问题——修复状态重置时的空引用错误,并在切换会话时保留斜杠命令,避免初始化响应返回前出现短暂的空白 ## 1.18.0 (2026-03-09) - ACP:支持 ACP 模式下的嵌入式资源内容,使 Zed 的 `@` 文件引用能够正确包含文件内容 - Core:在 Google GenAI provider 中使用 `parameters_json_schema` 替代 `parameters`,绕过 Pydantic 校验对 MCP 工具中标准 JSON Schema 元数据字段的拒绝 - Shell:增强 `Ctrl-V` 剪贴板粘贴功能,支持粘贴视频文件——视频文件路径以文本形式插入输入框,同时修复剪贴板数据为 `None` 时的崩溃问题 - Core:将会话 ID 作为 `user_id` 元数据传递给 Anthropic API - Web:修复 WebSocket 重连时斜杠命令丢失的问题,并为会话初始化添加自动重试逻辑 ## 1.17.0 (2026-03-03) - Core:新增 `/export` 命令,支持将当前会话上下文(消息、元数据)导出为 Markdown 文件;新增 `/import` 命令,支持从文件或其他会话 ID 导入上下文到当前会话 - Shell:在状态栏上下文用量旁显示 Token 数量(已用/总量),如 `context: 42.0% (4.2k/10.0k)` - Shell:工具栏快捷键提示改为轮转显示——每次提交后循环展示不同快捷键提示,节省横向空间 - MCP:为 MCP 服务器连接添加加载指示器——Shell 在连接 MCP 服务器时显示 "Connecting to MCP servers..." 加载动画,Web 在 MCP 工具加载期间显示状态消息 - Web:修复工具栏变更面板中文件列表滚动溢出的问题 - Core:新增 `compaction_trigger_ratio` 配置项(默认 `0.85`),用于控制自动压缩的触发时机——当上下文用量达到配置比例或剩余空间低于 `reserved_context_size` 时触发压缩,以先满足的条件为准 - Core:`/compact` 命令支持自定义指令(如 `/compact keep database discussions`),可指导压缩时重点保留的内容 - Web:新增 URL 操作参数(`?action=create` 打开创建会话对话框,`?action=create-in-dir&workDir=xxx` 直接创建会话)用于外部集成,支持 Cmd/Ctrl+点击新建会话按钮在新标签页中打开会话创建 - Web:在提示输入工具栏中添加待办列表显示——当 `SetTodoList` 工具激活时,显示任务进度并支持展开面板查看详情 - ACP:为会话操作添加认证检查,未认证时返回 `AUTH_REQUIRED` 错误响应,支持终端登录流程 ## 1.16.0 (2026-02-27) - Web:更新 ASCII Logo 横幅为新的样式设计 - Core:新增 `--add-dir` CLI 选项和 `/add-dir` 斜杠命令,支持将额外目录添加到工作区范围——添加的目录可被所有文件工具(读取、写入、glob、替换)访问,跨会话持久化保存,并在系统提示词中展示 - Shell:新增 `Ctrl-O` 快捷键,在外部编辑器中编辑当前输入内容(`$VISUAL`/`$EDITOR`),支持自动检测 VS Code、Vim、Vi 或 Nano - Shell:新增 `/editor` 斜杠命令,可交互式配置和切换默认外部编辑器,设置持久保存到配置文件 - Shell:新增 `/new` 斜杠命令,无需重启 Kimi Code CLI 即可创建并切换到新会话 - Wire:当客户端不支持 `supports_question` 能力时,自动隐藏 `AskUserQuestion` 工具,避免 LLM 调用不受支持的交互 - Core:在压缩后估算上下文 Token 数量,使上下文用量百分比不再显示为 0% - Web:上下文用量百分比显示精确到一位小数,提升精度 ## 1.15.0 (2026-02-27) - Shell:精简输入提示符,移除用户名前缀以获得更简洁的外观 - Shell:在工具栏中添加水平分隔线和更完整的键盘快捷键提示 - Shell:为问题面板和审批面板添加数字键(1–5)快速选择选项,并以带边框的面板和键盘提示重新设计交互界面 - Shell:为多问题面板添加标签式导航——使用左右方向键或 Tab 键在问题间切换,并以可视化指示器区分已答、当前和待答状态,重新访问已答问题时自动恢复选择状态 - Shell:在问题面板中支持使用空格键提交单选问题 - Web:为多问题对话框添加标签式导航,支持可点击标签栏、键盘导航,以及重新访问已答问题时恢复选择状态 - Core:将进程标题设置为 "Kimi Code"(在 `ps` / 活动监视器 / 终端标签页标题中可见),并将 Web Worker 子进程标记为 "kimi-code-worker" ## 1.14.0 (2026-02-26) - Shell:在终端中将 `FetchURL` 工具的 URL 参数显示为可点击的超链接 - Tool:新增 `AskUserQuestion` 工具,支持在执行过程中向用户展示结构化问题和预定义选项,支持单选、多选和自定义文本输入 - Wire:新增 `QuestionRequest` / `QuestionResponse` 消息类型和能力协商机制,用于结构化问答交互 - Shell:新增 `AskUserQuestion` 交互式问题面板,支持键盘驱动的选项选择 - Web:新增 `QuestionDialog` 组件,支持在界面内展示并回答结构化问题,问题待回答时替代提示输入框 - Core:支持会话状态跨会话持久化——审批决策(YOLO 模式、自动批准的操作)和动态子 Agent 现在会被保存,并在恢复会话时自动还原 - Core:对元数据和会话状态文件使用原子化 JSON 写入,防止崩溃时数据损坏 - Wire:新增 `steer` 请求,可在 Agent 轮次进行中注入用户消息(协议版本 1.4) - Web:支持在 `FetchURL` 工具的 URL 参数上使用 Cmd/Ctrl+点击在新标签页中打开链接,并显示适合当前平台的提示信息 ## 1.13.0 (2026-02-24) - Core:添加自动连接恢复机制,在连接错误和超时错误时重建 HTTP 客户端并重试,提升对瞬时网络故障的容错能力 ## 1.12.0 (2026-02-11) - Web:添加子 Agent 活动渲染,在 Task 工具消息中展示子 Agent 步骤(思考、工具调用、文本) - Web:添加 Think 工具渲染,以轻量级推理风格块展示 - Web:将 emoji 状态指示器替换为 Lucide 图标,并为工具名称添加分类图标 - Web:改进 Reasoning 组件,优化思考标签和状态图标 - Web:改进 Todo 组件,添加状态图标并优化样式 - Web:实现 WebSocket 断线重连,支持自动重发请求和连接超时监控 - Web:改进创建会话对话框的命令值处理 - Web:支持会话工作目录路径中的波浪号(`~`)展开 - Web:修复 Assistant 消息内容溢出被裁剪的问题 - Wire:修复多个子 Agent 并发运行时的死锁问题,不再在审批请求和工具调用请求上阻塞 UI 循环 - Wire:Agent 轮次结束后清理残留的待处理请求 - Web:在提示输入框中显示引导占位文本,提示可使用斜杠命令和 @ 引用文件 - Web:修复在 uvicorn Web 服务器中 Ctrl+C 无法使用的问题,在 Shell 模式退出后恢复默认的 SIGINT 信号处理程序和终端状态 - Web:改进会话停止处理,使用正确的异步清理和超时机制 - ACP:添加协议版本协商框架,用于客户端与服务端之间的兼容性校验 - ACP:添加会话恢复方法,用于恢复会话状态(实验性) ## 1.11.0 (2026-02-10) - Web:将上下文用量指示器从工作区标题栏移至提示工具栏,悬停时显示详细的 Token 用量明细 - Web:在文件变更面板底部添加文件夹指示器,显示工作目录路径 - Web:修复切换到 Web 模式时未恢复 stderr 的问题,该问题可能导致 Web 服务器的错误输出被抑制 - Web:修复端口可用性检查,在测试套接字上设置 SO_REUSEADDR ## 1.10.0 (2026-02-09) - Web:为 Assistant 消息添加复制和分支(fork)操作按钮,支持快速复制内容和创建分支会话 - Web:为审批操作添加键盘快捷键——按 `1` 批准、`2` 本次会话批准、`3` 拒绝 - Web:添加消息队列功能——在 AI 处理过程中可排队发送后续消息,待当前回复完成后自动发送 - Web:将 Git diff 状态栏替换为统一的提示工具栏,以可折叠标签页展示活动状态、消息队列和文件变更 - Web:在 Web Worker 中加载全局 MCP 配置,使 Web 会话可以使用 MCP 工具 - Web:改进移动端提示输入框体验——缩小 textarea 最小高度、添加 `autoComplete="off"`、在小屏幕上禁用聚焦边框 - Web:处理部分模型先输出文本再输出思考过程的情况,确保思考消息始终显示在文本消息之前 - Web:在会话连接过程中显示更具体的状态信息("Loading history..."、"Starting environment..." 替代通用的 "Connecting...") - Web:会话环境初始化失败时发送错误状态,而非让 UI 一直处于等待状态 - Web:历史回放完成后 15 秒内未收到会话状态时自动重连 - Web:会话流中使用非阻塞文件 I/O,避免历史回放期间阻塞事件循环 ## 1.9.0 (2026-02-06) - Config:添加 `default_yolo` 配置项,支持默认开启 YOLO(自动审批)模式 - Config:支持 `max_steps_per_turn` 和 `max_steps_per_run` 作为循环控制设置的别名 - Wire:新增 `replay` 请求,用于回放已记录的 Wire 事件(协议版本 1.3) - Web:添加会话分支(fork)功能,可以从任意 Assistant 回复处创建新的分支会话 - Web:添加会话归档功能,自动归档超过 15 天的会话 - Web:添加多选模式,支持批量归档、取消归档和删除操作 - Web:添加工具结果的媒体预览(ReadMediaFile 的图片/视频),支持可点击缩略图 - Web:添加 Shell 命令和 Todo 列表的工具输出显示组件 - Web:添加活动状态指示器,显示 Agent 状态(处理中、等待审批等) - Web:添加图片加载失败时的错误回退 UI - Web:重新设计工具输入 UI,支持可展开参数和长值的语法高亮 - Web:上下文压缩时显示压缩指示器 - Web:改进聊天中的自动滚动行为,更流畅地跟随新内容 - Web:会话流开始时更新工作目录的最近会话 ID(`last_session_id`) - Shell:移除 `Ctrl-/` 快捷键(此前用于触发 `/help` 命令) - Rust:Rust 版实现迁移到 `MoonshotAI/kimi-agent-rs` 并独立发版;二进制更名为 `kimi-agent` - Core:重新加载配置时保留会话 ID,确保会话正确恢复 - Shell:修复会话回放时显示已被 `/clear` 或 `/reset` 清除的消息的问题 - Web:修复会话中断或取消时审批请求状态未更新的问题 - Web:修复选择斜杠命令时的输入法组合问题 - Web:修复执行 `/clear`、`/reset` 或 `/compact` 命令后 UI 未清空消息的问题 ## 1.8.0 (2026-02-05) - CLI:修复启动错误(如无效的配置文件)被静默吞掉而不显示的问题 ## 1.7.0 (2026-02-05) - Rust:添加 `kagent`,Kimi Agent 内核的 Rust 实现,支持 Wire 模式(实验性) - Auth:修复多个会话同时运行时的 OAuth 令牌刷新冲突 - Web:添加文件提及菜单(`@`),支持引用已上传附件和工作区文件,带自动补全功能 - Web:添加斜杠命令菜单,支持自动补全、键盘导航和别名匹配 - Web:修复认证令牌持久化问题,从 sessionStorage 切换到 localStorage 并设置 24 小时过期 - Web:创建会话时,若指定的路径不存在则提示创建目录 - Web:为会话列表添加服务端分页和虚拟滚动,提升性能 - Web:改进会话和工作目录加载,采用更智能的缓存和失效策略 - Web:修复历史记录回放时的 WebSocket 错误,发送前检查连接状态 - Web:Git diff 状态栏现在显示未跟踪文件(尚未添加到 git 的新文件) - Web:仅在 public 模式下限制敏感 API;更新 origin 执行逻辑 ## 1.6 (2026-02-03) - Web:为网络模式添加基于 Token 的认证和访问控制(`--network`、`--lan-only`、`--public`) - Web:添加安全选项:`--auth-token`、`--allowed-origins`、`--restrict-sensitive-apis`、`--dangerously-omit-auth` - Web:变更 `--host` 选项,用于绑定到指定 IP 地址;添加自动网络地址检测 - Web:修复创建新会话时 WebSocket 断开连接的问题 - Web:将最大图片尺寸从 1024 提升至 4096 像素 - Web:通过增强的悬停效果和更好的布局处理改进 UI 响应性 - Wire:添加 `TurnEnd` 事件,用于标识 Agent 轮次的完成(协议版本 1.2) - Core:修复包含 `$` 的自定义 Agent 提示词文件导致静默启动失败的问题 ## 1.5 (2026-01-30) - Web:添加 Git diff 状态栏,显示会话工作目录中的未提交更改 - Web:添加 "Open in" 菜单,用于在终端、VS Code、Cursor 或其他本地应用中打开文件/目录 - Web:添加会话搜索功能,支持按标题或工作目录过滤会话 - Web:改进会话标题显示,优化溢出处理 ## 1.4 (2026-01-30) - Shell:合并 `/login` 和 `/setup` 命令,`/setup` 现为 `/login` 的别名 - Shell:`/usage` 命令现在显示剩余配额百分比;添加 `/status` 别名 - Config:添加 `KIMI_SHARE_DIR` 环境变量,用于自定义共享目录路径(默认 `~/.kimi`) - Web:新增 Web UI,支持基于浏览器的交互 - CLI:添加 `kimi web` 子命令以启动 Web UI 服务器 - Auth:修复设备名称或操作系统版本包含非 ASCII 字符时的编码错误 - Auth:OAuth 凭据现在存储在文件中而非 keyring;启动时自动迁移现有令牌 - Auth:修复系统休眠或睡眠后的授权失败问题 ## 1.3 (2026-01-28) - Auth:修复 Agent 轮次期间的认证问题 - Tool:为 `ReadMediaFile` 中的媒体内容添加描述性标签,提高路径可追溯性 ## 1.2 (2026-01-27) - UI:显示 `kimi-for-coding` 模型的说明 ## 1.1 (2026-01-27) - LLM:修复 `kimi-for-coding` 模型的能力 ## 1.0 (2026-01-27) - Shell:添加 `/login` 和 `/logout` 斜杠命令,用于登录和登出 - CLI:添加 `kimi login` 和 `kimi logout` 子命令 - Core:修复子 Agent 审批请求处理问题 ## 0.88 (2026-01-26) - MCP:移除连接 MCP 服务器时的 `Mcp-Session-Id` header 以修复兼容性问题 ## 0.87 (2026-01-25) - Shell:修复 HTML 块出现在元素外时的 Markdown 渲染错误 - Skills:添加更多用户级和项目级 Skills 目录候选 - Core:改进系统提示词中的媒体文件生成和处理任务指引 - Shell:修复 macOS 上从剪贴板粘贴图片的问题 ## 0.86 (2026-01-24) - Build:修复二进制构建问题 ## 0.85 (2026-01-24) - Shell:粘贴的图片缓存到磁盘,支持跨会话持久化 - Shell:基于内容哈希去重缓存的附件 - Shell:修复消息历史中图片/音频/视频附件的显示 - Tool:使用文件路径作为 `ReadMediaFile` 中的媒体标识符,提高可追溯性 - Tool:修复部分 MP4 文件无法识别为视频的问题 - Shell:执行斜杠命令时支持 Ctrl-C 中断 - Shell:修复 Shell 模式下输入不符合 Shell 语法的内容时的解析错误 - Shell:修复 MCP 服务器和第三方库的 stderr 输出污染 Shell UI 的问题 - Wire:优雅关闭,当连接关闭或收到 Ctrl-C 时正确清理待处理请求 ## 0.84 (2026-01-22) - Build:添加跨平台独立二进制构建,支持 Windows、macOS(含代码签名和公证)和 Linux(x86_64 和 ARM64) - Shell:修复斜杠命令自动补全在输入完整命令/别名时仍显示建议的问题 - Tool:将 SVG 文件作为文本而非图片处理 - Flow:支持 D2 markdown 块字符串(`|md` 语法),用于 Flow Skill 中的多行节点标签 - Core:修复运行 `/reload`、`/setup` 或 `/clear` 后可能出现的 "event loop is closed" 错误 - Core:修复在续接会话中使用 `/clear` 时的崩溃问题 ## 0.83 (2026-01-21) - Tool:添加 `ReadMediaFile` 工具用于读取图片/视频文件;`ReadFile` 现在仅用于读取文本文件 - Skills:Flow Skills 现在也注册为 `/skill:` 命令(除了 `/flow:`) ## 0.82 (2026-01-21) - Tool:`WriteFile` 和 `StrReplaceFile` 工具支持使用绝对路径编辑/写入工作目录外的文件 - Tool:使用 Kimi 供应商时,视频文件上传到 Kimi Files API,使用 `ms://` 引用替代 inline data URL - Config:添加 `reserved_context_size` 配置项,自定义自动压缩触发阈值(默认 50000 tokens) ## 0.81 (2026-01-21) - Skills:添加 Flow Skill 类型,在 SKILL.md 中内嵌 Agent Flow(Mermaid/D2),通过 `/flow:` 命令调用 - CLI:移除 `--prompt-flow` 选项,改用 Flow Skills - Core:用 `/flow:` 命令替代原来的 `/begin` 命令 ## 0.80 (2026-01-20) - Wire:添加 `initialize` 方法,用于交换客户端/服务端信息、注册外部工具和公布斜杠命令 - Wire:支持通过 Wire 协议调用外部工具 - Wire:将 `ApprovalRequestResolved` 重命名为 `ApprovalResponse`(向后兼容) ## 0.79 (2026-01-19) - Skills:添加项目级 Skills 支持,从 `.agents/skills/`(或 `.kimi/skills/`、`.claude/skills/`)发现 - Skills:统一 Skills 发现机制,采用分层加载(内置 → 用户 → 项目);用户级 Skills 现在优先使用 `~/.config/agents/skills/` - Shell:斜杠命令自动补全支持模糊匹配 - Shell:增强审批请求预览,显示 Shell 命令和 Diff 内容,使用 `Ctrl-E` 展开完整内容 - Wire:添加 `ShellDisplayBlock` 类型,用于在审批请求中显示 Shell 命令 - Shell:调整 `/help` 显示顺序,将键盘快捷键移至斜杠命令之前 - Wire:对无效请求返回符合 JSON-RPC 2.0 规范的错误响应 ## 0.78 (2026-01-16) - CLI:为 Prompt Flow 添加 D2 流程图格式支持(`.d2` 扩展名) ## 0.77 (2026-01-15) - Shell:修复 `/help` 和 `/changelog` 全屏分页显示中的换行问题 - Shell:使用 `/model` 命令切换 Thinking 模式,取代 Tab 键 - Config:添加 `default_thinking` 配置项(升级后需运行 `/model` 选择 Thinking 模式) - LLM:为始终使用 Thinking 模式的模型添加 `always_thinking` 能力 - CLI:将 `--command`/`-c` 重命名为 `--prompt`/`-p`,保留 `--command`/`-c` 作为别名,移除 `--query`/`-q` - Wire:修复 Wire 模式下审批请求无法正常响应的问题 - CLI:添加 `--prompt-flow` 选项,加载 Mermaid 流程图文件作为 Prompt Flow - Core:加载 Prompt Flow 后添加 `/begin` 斜杠命令以启动流程 - Core:使用基于 Prompt Flow 的实现替换旧的 Ralph 循环 ## 0.76 (2026-01-12) - Tool:让 `ReadFile` 工具描述根据模型能力动态反映图片/视频支持情况 - Tool:修复 TypeScript 文件(`.ts`、`.tsx`、`.mts`、`.cts`)被误识别为视频文件的问题 - Shell:允许在 Shell 模式下使用部分斜杠命令(`/help`、`/exit`、`/version`、`/changelog`、`/feedback`) - Shell:改进 `/help` 显示,使用全屏分页器,展示斜杠命令、Skills 和键盘快捷键 - Shell:改进 `/changelog` 和 `/mcp` 显示,采用一致的项目符号格式 - Shell:在底部状态栏显示当前模型名称 - Shell:添加 `Ctrl-/` 快捷键显示帮助 ## 0.75 (2026-01-09) - Tool:改进 `ReadFile` 工具描述 - Skills:添加内置 `kimi-cli-help` Skill,解答 Kimi Code CLI 使用和配置问题 ## 0.74 (2026-01-09) - ACP:允许 ACP 客户端选择和切换模型(包含 Thinking 变体) - ACP:添加 `terminal-auth` 认证方式,用于配置流程 - CLI:弃用 `--acp` 选项,请使用 `kimi acp` 子命令 - Tool:`ReadFile` 工具现支持读取图片和视频文件 ## 0.73 (2026-01-09) - Skills:添加随软件包发布的内置 skill-creator Skill - Tool:在 `ReadFile` 路径中将 `~` 展开为用户主目录 - MCP:确保 MCP 工具加载完成后再开始 Agent 循环 - Wire:修复 Wire 模式无法接受有效 `cancel` 请求的问题 - Setup:`/model` 命令现在可以切换所选供应商的所有可用模型 - Lib:从 `kimi_cli.wire.types` 重新导出所有 Wire 消息类型,作为 `kimi_cli.wire.message` 的替代 - Loop:添加 `max_ralph_iterations` 循环控制配置,限制额外的 Ralph 迭代次数 - Config:将循环控制配置中的 `max_steps_per_run` 重命名为 `max_steps_per_turn`(向后兼容) - CLI:添加 `--max-steps-per-turn`、`--max-retries-per-step` 和 `--max-ralph-iterations` 选项,覆盖循环控制配置 - SlashCmd:`/yolo` 命令现在切换 YOLO 模式 - UI:在 Shell 模式的提示符中显示 YOLO 标识 ## 0.72 (2026-01-04) - Python:修复在 Python 3.14 上的安装问题 ## 0.71 (2026-01-04) - ACP:通过 ACP 客户端路由文件读写和 Shell 命令,实现同步编辑/输出 - Shell:添加 `/model` 斜杠命令,在使用默认配置时切换默认模型并重新加载 - Skills:添加 `/skill:` 斜杠命令,按需加载 `SKILL.md` 指引 - CLI:添加 `kimi info` 子命令,显示版本和协议信息(支持 `--json`) - CLI:添加 `kimi term` 命令,启动 Toad 终端 UI - Python:将默认工具/CI 版本升级到 3.14 ## 0.70 (2025-12-31) - CLI:添加 `--final-message-only`(及 `--quiet` 别名),在 Print 模式下仅输出最终的 assistant 消息 - LLM:添加 `video_in` 模型能力,支持视频输入 ## 0.69 (2025-12-29) - Core:支持在 `~/.kimi/skills` 或 `~/.claude/skills` 中发现 Skills - Python:降低最低 Python 版本要求至 3.12 - Nix:添加 flake 打包支持;可通过 `nix profile install .#kimi-cli` 安装或 `nix run .#kimi-cli` 运行 - CLI:添加 `kimi-cli` 脚本别名;可通过 `uvx kimi-cli` 运行 - Lib:将 LLM 配置验证移入 `create_llm`,配置缺失时返回 `None` ## 0.68 (2025-12-24) - CLI:添加 `--config` 和 `--config-file` 选项,支持传入 JSON/TOML 配置 - Core:`KimiCLI.create` 的 `config` 参数现在除了 `Path` 也支持 `Config` 类型 - Tool:在 `WriteFile` 和 `StrReplaceFile` 的审批/结果中包含 diff 显示块 - Wire:在审批请求中添加显示块(包括 diff),保持向后兼容 - ACP:在工具结果和审批提示中显示文件 diff 预览 - ACP:连接 ACP 客户端管理的 MCP 服务器 - ACP:如果支持,在 ACP 客户端终端中运行 Shell 命令 - Lib:添加 `KimiToolset.find` 方法,按类或名称查找工具 - Lib:添加 `ToolResultBuilder.display` 方法,向工具结果追加显示块 - MCP:添加 `kimi mcp auth` 及相关子命令,管理 MCP 授权 ## 0.67 (2025-12-22) - ACP:在单会话 ACP 模式(`kimi --acp`)中广播斜杠命令 - MCP:添加 `mcp.client` 配置节,用于配置 MCP 工具调用超时等选项 - Core:改进默认系统提示词和 `ReadFile` 工具 - UI:修复某些罕见情况下 Ctrl-C 不工作的问题 ## 0.66 (2025-12-19) - Lib:在 `StatusUpdate` Wire 消息中提供 `token_usage` 和 `message_id` - Lib:添加 `KimiToolset.load_tools` 方法,支持依赖注入加载工具 - Lib:添加 `KimiToolset.load_mcp_tools` 方法,加载 MCP 工具 - Lib:将 `MCPTool` 从 `kimi_cli.tools.mcp` 移至 `kimi_cli.soul.toolset` - Lib:添加 `InvalidToolError`、`MCPConfigError` 和 `MCPRuntimeError` 异常类 - Lib:使 Kimi Code CLI 详细异常类扩展 `ValueError` 或 `RuntimeError` - Lib:`KimiCLI.create` 和 `load_agent` 的 `mcp_configs` 参数支持传入验证后的 `list[fastmcp.mcp_config.MCPConfig]` - Lib:修复 `KimiCLI.create`、`load_agent`、`KimiToolset.load_tools` 和 `KimiToolset.load_mcp_tools` 的异常抛出 - LLM:添加 `vertexai` 供应商类型,支持 Vertex AI - LLM:将 Gemini Developer API 的供应商类型从 `google_genai` 重命名为 `gemini` - Config:配置文件从 JSON 迁移至 TOML - MCP:后台并行连接 MCP 服务器,减少启动时间 - MCP:连接 MCP 服务器时添加 `mcp-session-id` HTTP 头 - Lib:将斜杠命令(原"元命令")拆分为两组:Shell 级和 KimiSoul 级 - Lib:在 `Soul` 协议中添加 `available_slash_commands` 属性 - ACP:向 ACP 客户端广播 `/init`、`/compact` 和 `/yolo` 斜杠命令 - SlashCmd:添加 `/mcp` 斜杠命令,显示 MCP 服务器和工具状态 ## 0.65 (2025-12-16) - Lib:支持通过 `Session.create(work_dir, session_id)` 创建命名会话 - CLI:指定的会话 ID 不存在时自动创建新会话 - CLI:退出时删除空会话,列表中忽略上下文文件为空的会话 - UI:改进会话回放 - Lib:在 `LLM` 类中添加 `model_config: LLMModel | None` 和 `provider_config: LLMProvider | None` 属性 - MetaCmd:添加 `/usage` 元命令,为 Kimi Code 用户显示 API 使用情况 ## 0.64 (2025-12-15) - UI:修复 Windows 上 UTF-16 代理字符输入问题 - Core:添加 `/sessions` 元命令,列出现有会话并切换到选中的会话 - CLI:添加 `--session/-S` 选项,指定要恢复的会话 ID - MCP:添加 `kimi mcp` 子命令组,管理全局 MCP 配置文件 `~/.kimi/mcp.json` ## 0.63 (2025-12-12) - Tool:修复 `FetchURL` 工具通过服务获取失败时输出不正确的问题 - Tool:在 `Shell` 工具中使用 `bash` 而非 `sh`,提高兼容性 - Tool:修复 Windows 上 `Grep` 工具的 Unicode 解码错误 - ACP:通过 `kimi acp` 子命令支持 ACP 会话续接(列出/加载会话) - Lib:添加 `Session.find` 和 `Session.list` 静态方法,查找和列出会话 - ACP:调用 `SetTodoList` 工具时在客户端更新 Agent 计划 - UI:防止以 `/` 开头的普通消息被误当作元命令处理 ## 0.62 (2025-12-08) - ACP:修复工具结果(包括 Shell 工具输出)在 Zed 等 ACP 客户端中不显示的问题 - ACP:修复与最新版 Zed IDE (0.215.3) 的兼容性 - Tool:Windows 上使用 PowerShell 替代 CMD,提升可用性 - Core:修复工作目录中存在损坏符号链接时的启动崩溃 - Core:添加内置 `okabe` Agent 文件,启用 `SendDMail` 工具 - CLI:添加 `--agent` 选项,指定内置 Agent(如 `default`、`okabe`) - Core:改进压缩逻辑,更好地保留相关信息 ## 0.61 (2025-12-04) - Lib:修复作为库使用时的日志问题 - Tool:加强文件路径检查,防止共享前缀逃逸 - LLM:改进与部分第三方 OpenAI Responses 和 Anthropic API 供应商的兼容性 ## 0.60 (2025-12-01) - LLM:修复 Kimi 和 OpenAI 兼容供应商的交错思考问题 ## 0.59 (2025-11-28) - Core:将上下文文件位置移至 `.kimi/sessions/{workdir_md5}/{session_id}/context.jsonl` - Lib:将 `WireMessage` 类型别名移至 `kimi_cli.wire.message` - Lib:添加 `kimi_cli.wire.message.Request` 类型别名,用于请求消息(目前仅包含 `ApprovalRequest`) - Lib:添加 `kimi_cli.wire.message.is_event`、`is_request` 和 `is_wire_message` 工具函数,检查 Wire 消息类型 - Lib:添加 `kimi_cli.wire.serde` 模块,用于 Wire 消息的序列化和反序列化 - Lib:修改 `StatusUpdate` Wire 消息,不再使用 `kimi_cli.soul.StatusSnapshot` - Core:在会话目录中记录 Wire 消息到 JSONL 文件 - Core:引入 `TurnBegin` Wire 消息,标记每个 Agent 轮次的开始 - UI:Shell 模式下用面板重新打印用户输入 - Lib:添加 `Session.dir` 属性,获取会话目录路径 - UI:改进多个并行子代理时的"本会话批准"体验 - Wire:重新实现 Wire 服务器模式(通过 `--wire` 选项启用) - Lib:重命名类以保持一致性:`ShellApp` → `Shell`,`PrintApp` → `Print`,`ACPServer` → `ACP`,`WireServer` → `WireOverStdio` - Lib:重命名方法以保持一致性:`KimiCLI.run_shell_mode` → `run_shell`,`run_print_mode` → `run_print`,`run_acp_server` → `run_acp`,`run_wire_server` → `run_wire_stdio` - Lib:添加 `KimiCLI.run` 方法,使用给定用户输入运行一轮并产生 Wire 消息 - Print:修复 stream-json 打印模式输出刷新不正确的问题 - LLM:改进与部分 OpenAI 和 Anthropic API 供应商的兼容性 - Core:修复使用 Anthropic API 时压缩后的聊天供应商错误 ## 0.58 (2025-11-21) - Core:修复使用 `extend` 时 Agent 规格文件的字段继承问题 - Core:支持在子代理中使用 MCP 工具 - Tool:添加 `CreateSubagent` 工具,动态创建子代理(默认 Agent 中未启用) - Tool:Kimi Code 方案在 `FetchURL` 工具中使用 MoonshotFetch 服务 - Tool:截断 Grep 工具输出,避免超出 token 限制 ## 0.57 (2025-11-20) - LLM:修复思考开关未开启时的 Google GenAI 供应商问题 - UI:改进审批请求措辞 - Tool:移除 `PatchFile` 工具 - Tool:将 `Bash`/`CMD` 工具重命名为 `Shell` 工具 - Tool:将 `Task` 工具移至 `kimi_cli.tools.multiagent` 模块 ## 0.56 (2025-11-19) - LLM:添加 Google GenAI 供应商支持 ## 0.55 (2025-11-18) - Lib:添加 `kimi_cli.app.enable_logging` 函数,直接使用 `KimiCLI` 类时启用日志 - Core:修复 Agent 规格文件中的相对路径解析 - Core:防止 LLM API 连接失败时 panic - Tool:优化 `FetchURL` 工具,改进内容提取 - Tool:将 MCP 工具调用超时增加到 60 秒 - Tool:在 `Glob` 工具中提供更好的错误消息(当模式为 `**` 时) - ACP:修复思考内容显示不正确的问题 - UI:Shell 模式的小幅 UI 改进 ## 0.54 (2025-11-13) - Lib:将 `WireMessage` 从 `kimi_cli.wire.message` 移至 `kimi_cli.wire` - Print:修复 `stream-json` 输出格式缺少最后一条助手消息的问题 - UI:当 API 密钥被 `KIMI_API_KEY` 环境变量覆盖时添加警告 - UI:审批请求时发出提示音 - Core:修复 Windows 上的上下文压缩和清除问题 ## 0.53 (2025-11-12) - UI:移除控制台输出中不必要的尾部空格 - Core:存在不支持的消息部分时抛出错误 - MetaCmd:添加 `/yolo` 元命令,启动后启用 YOLO 模式 - Tool:为 MCP 工具添加审批请求 - Tool:在默认 Agent 中禁用 `Think` 工具 - CLI:未指定 `--thinking` 时恢复上次的思考模式 - CLI:修复 PyInstaller 打包的二进制文件中 `/reload` 不工作的问题 ## 0.52 (2025-11-10) - CLI:移除 `--ui` 选项,改用 `--print`、`--acp` 和 `--wire` 标志(Shell 仍为默认) - CLI:更直观的会话续接行为 - Core:为 LLM 空响应添加重试 - Tool:Windows 上将 `Bash` 工具改为 `CMD` 工具 - UI:修复退格后的补全问题 - UI:修复浅色背景下代码块的渲染问题 ## 0.51 (2025-11-08) - Lib:将 `Soul.model` 重命名为 `Soul.model_name` - Lib:将 `LLMModelCapability` 重命名为 `ModelCapability` 并移至 `kimi_cli.llm` - Lib:在 `ModelCapability` 中添加 `"thinking"` - Lib:移除 `LLM.supports_image_in` 属性 - Lib:添加必需的 `Soul.model_capabilities` 属性 - Lib:将 `KimiSoul.set_thinking_mode` 重命名为 `KimiSoul.set_thinking` - Lib:添加 `KimiSoul.thinking` 属性 - UI:改进 LLM 模型能力检查和提示 - UI:`/clear` 元命令时清屏 - Tool:支持 Windows 上自动下载 ripgrep - CLI:添加 `--thinking` 选项,以思考模式启动 - ACP:ACP 模式支持思考内容 ## 0.50 (2025-11-07) - 改进 UI 外观和体验 - 改进 Task 工具可观测性 ## 0.49 (2025-11-06) - 小幅用户体验改进 ## 0.48 (2025-11-06) - 支持 Kimi K2 思考模式 ## 0.47 (2025-11-05) - 修复某些环境下 Ctrl-W 不工作的问题 - 搜索服务未配置时不加载 SearchWeb 工具 ## 0.46 (2025-11-03) - 引入 Wire over stdio 用于本地 IPC(实验性,可能变更) - 支持 Anthropic 供应商类型 - 修复 PyInstaller 打包的二进制文件因入口点错误而无法工作的问题 ## 0.45 (2025-10-31) - 允许 `KIMI_MODEL_CAPABILITIES` 环境变量覆盖模型能力 - 添加 `--no-markdown` 选项禁用 Markdown 渲染 - 支持 `openai_responses` LLM 供应商类型 - 修复续接会话时的崩溃问题 ## 0.44 (2025-10-30) - 改进启动时间 - 修复用户输入中可能出现的无效字节 ## 0.43 (2025-10-30) - 基础 Windows 支持(实验性) - 环境变量覆盖 base URL 或 API 密钥时显示警告 - 如果 LLM 模型支持,则支持图片输入 - 续接会话时回放近期上下文历史 - 确保执行 Shell 命令后换行 ## 0.42 (2025-10-28) - 支持 Ctrl-J 或 Alt-Enter 插入换行 - 模式切换快捷键从 Ctrl-K 改为 Ctrl-X - 改进整体健壮性 - 修复 ACP 服务器 `no attribute` 错误 ## 0.41 (2025-10-26) - 修复 Glob 工具未找到匹配文件时的 bug - 确保使用 UTF-8 编码读取文件 - Shell 模式下禁用从 stdin 读取命令/查询 - 澄清 `/setup` 元命令中的 API 平台选择 ## 0.40 (2025-10-24) - 支持 `ESC` 键中断 Agent 循环 - 修复某些罕见情况下的 SSL 证书验证错误 - 修复 Bash 工具中可能的解码错误 ## 0.39 (2025-10-24) - 修复上下文压缩阈值检查 - 修复 Shell 会话中设置 SOCKS 代理时的 panic ## 0.38 (2025-10-24) - 小幅用户体验改进 ## 0.37 (2025-10-24) - 修复更新检查 ## 0.36 (2025-10-24) - 添加 `/debug` 元命令用于调试上下文 - 添加自动上下文压缩 - 添加审批请求机制 - 添加 `--yolo` 选项自动批准所有操作 - 渲染 Markdown 内容以提高可读性 - 修复中断元命令时的"未知错误"消息 ## 0.35 (2025-10-22) - 小幅 UI 改进 - 系统中未找到 ripgrep 时自动下载 - `--print` 模式下始终批准工具调用 - 添加 `/feedback` 元命令 ## 0.34 (2025-10-21) - 添加 `/update` 元命令检查更新,并在后台自动更新 - 支持在原始 Shell 模式下运行交互式 Shell 命令 - 添加 `/setup` 元命令设置 LLM 供应商和模型 - 添加 `/reload` 元命令重新加载配置 ## 0.33 (2025-10-18) - 添加 `/version` 元命令 - 添加原始 Shell 模式,可通过 Ctrl-K 切换 - 在底部状态栏显示快捷键 - 修复日志重定向 - 合并重复的输入历史 ## 0.32 (2025-10-16) - 添加底部状态栏 - 支持文件路径自动补全(`@filepath`) - 不在用户输入中间自动补全元命令 ## 0.31 (2025-10-14) - 真正修复 Ctrl-C 中断步骤的问题 ## 0.30 (2025-10-14) - 添加 `/compact` 元命令,允许手动压缩上下文 - 修复上下文为空时的 `/clear` 元命令 ## 0.29 (2025-10-14) - Shell 模式下支持 Enter 键接受补全 - Shell 模式下跨会话记住用户输入历史 - 添加 `/reset` 元命令作为 `/clear` 的别名 - 修复 Ctrl-C 中断步骤的问题 - 在 Kimi Koder Agent 中禁用 `SendDMail` 工具 ## 0.28 (2025-10-13) - 添加 `/init` 元命令分析代码库并生成 `AGENTS.md` 文件 - 添加 `/clear` 元命令清除上下文 - 修复 `ReadFile` 输出 ## 0.27 (2025-10-11) - 添加 `--mcp-config-file` 和 `--mcp-config` 选项加载 MCP 配置 - 将 `--agent` 选项重命名为 `--agent-file` ## 0.26 (2025-10-11) - 修复 `--output-format stream-json` 模式下可能的编码错误 ## 0.25 (2025-10-11) - 将包名从 `ensoul` 重命名为 `kimi-cli` - 将 `ENSOUL_*` 内置系统提示词参数重命名为 `KIMI_*` - 进一步解耦 `App` 与 `Soul` - 拆分 `Soul` 协议和 `KimiSoul` 实现以提高模块化 ## 0.24 (2025-10-10) - 修复 ACP `cancel` 方法 ## 0.23 (2025-10-09) - 在 Agent 文件中添加 `extend` 字段支持 Agent 文件扩展 - 在 Agent 文件中添加 `exclude_tools` 字段支持排除工具 - 在 Agent 文件中添加 `subagents` 字段支持定义子代理 ## 0.22 (2025-10-09) - 改进 `SearchWeb` 和 `FetchURL` 工具调用可视化 - 改进搜索结果输出格式 ## 0.21 (2025-10-09) - 添加 `--print` 选项作为 `--ui print` 的快捷方式,`--acp` 选项作为 `--ui acp` 的快捷方式 - 支持 `--output-format stream-json` 以 JSON 格式输出 - 添加 `SearchWeb` 工具,使用 `services.moonshot_search` 配置。需要在配置文件中配置 `"services": {"moonshot_search": {"api_key": "your-search-api-key"}}` - 添加 `FetchURL` 工具 - 添加 `Think` 工具 - 添加 `PatchFile` 工具,Kimi Koder Agent 中未启用 - 在 Kimi Koder Agent 中启用 `SendDMail` 和 `Task` 工具,改进工具提示词 - 添加 `ENSOUL_NOW` 内置系统提示词参数 - 改进 `/release-notes` 外观 - 改进工具描述 - 改进工具输出截断 ## 0.20 (2025-09-30) - 添加 `--ui acp` 选项启动 Agent Client Protocol (ACP) 服务器 ## 0.19 (2025-09-29) - print UI 支持管道输入的 stdin - 支持 `--input-format=stream-json` 用于管道输入的 JSON - 未启用 `SendDMail` 时不在上下文中包含 `CHECKPOINT` 消息 ## 0.18 (2025-09-29) - 支持 LLM 模型配置中的 `max_context_size`,配置最大上下文大小(token 数) - 改进 `ReadFile` 工具描述 ## 0.17 (2025-09-29) - 修复超过最大步数时错误消息中的步数 - 修复 `kimi_run` 中的历史文件断言错误 - 修复 print 模式和单命令 Shell 模式中的错误处理 - 为 LLM API 连接错误和超时错误添加重试 - 将默认 max-steps-per-run 增加到 100 ## 0.16.0 (2025-09-26) - 添加 `SendDMail` 工具(Kimi Koder 中禁用,可在自定义 Agent 中启用) - 可通过 `_history_file` 参数在创建新会话时指定会话历史文件 ## 0.15.0 (2025-09-26) - 改进工具健壮性 ## 0.14.0 (2025-09-25) - 添加 `StrReplaceFile` 工具 - 强调使用与用户相同的语言 ## 0.13.0 (2025-09-25) - 添加 `SetTodoList` 工具 - 在 LLM API 调用中添加 `User-Agent` - 改进系统提示词和工具描述 - 改进 LLM 错误消息 ## 0.12.0 (2025-09-24) - 添加 `print` UI 模式,可通过 `--ui print` 选项使用 - 添加日志和 `--debug` 选项 - 捕获 EOF 错误以改善体验 ## 0.11.1 (2025-09-22) - 将 `max_retry_per_step` 重命名为 `max_retries_per_step` ## 0.11.0 (2025-09-22) - 添加 `/release-notes` 命令 - 为 LLM API 错误添加重试 - 添加循环控制配置,如 `{"loop_control": {"max_steps_per_run": 50, "max_retry_per_step": 3}}` - 改进 `read_file` 工具的极端情况处理 - 禁止 Ctrl-C 退出 CLI,强制使用 Ctrl-D 或 `exit` 退出 ## 0.10.1 (2025-09-18) - 小幅改进斜杠命令外观 - 改进 `glob` 工具 ## 0.10.0 (2025-09-17) - 添加 `read_file` 工具 - 添加 `write_file` 工具 - 添加 `glob` 工具 - 添加 `task` 工具 - 改进工具调用可视化 - 改进会话管理 - `--continue` 会话时恢复上下文使用量 ## 0.9.0 (2025-09-15) - 移除 `--session` 和 `--continue` 选项 ## 0.8.1 (2025-09-14) - 修复配置模型转储 ## 0.8.0 (2025-09-14) - 添加 `shell` 工具和基础系统提示词 - 添加工具调用可视化 - 添加上下文使用量计数 - 支持中断 Agent 循环 - 支持项目级 `AGENTS.md` - 支持 YAML 定义的自定义 Agent - 支持通过 `kimi -c` 执行一次性任务 ================================================ FILE: examples/.gitignore ================================================ uv.lock ================================================ FILE: examples/custom-echo-soul/README.md ================================================ # Example: Custom Echo Soul This example demonstrates how to write a custom `Soul` (agent loop) implementation that can be used with Kimi Code CLI's `Shell` UI. ```sh cd examples/custom-echo-soul uv sync --reinstall uv run main.py ``` ================================================ FILE: examples/custom-echo-soul/main.py ================================================ import asyncio from typing import Any from kimi_cli.llm import ALL_MODEL_CAPABILITIES, ModelCapability from kimi_cli.soul import StatusSnapshot, wire_send from kimi_cli.ui.shell import Shell from kimi_cli.utils.slashcmd import SlashCommand from kimi_cli.wire.types import ContentPart, StepBegin, TextPart class EchoSoul: def __init__(self) -> None: pass @property def name(self) -> str: return "EchoSoul" @property def model_name(self) -> str: return "mock" @property def model_capabilities(self) -> set[ModelCapability]: return ALL_MODEL_CAPABILITIES @property def status(self) -> StatusSnapshot: return StatusSnapshot(context_usage=0.0) @property def available_slash_commands(self) -> list[SlashCommand[Any]]: return [] async def run(self, user_input: str | list[ContentPart]) -> None: wire_send(StepBegin(n=1)) if isinstance(user_input, str): wire_send(TextPart(text=user_input)) else: for part in user_input: wire_send(part) if __name__ == "__main__": soul = EchoSoul() ui = Shell(soul) asyncio.run(ui.run()) ================================================ FILE: examples/custom-echo-soul/pyproject.toml ================================================ [project] name = "custom-echo-soul" version = "0.1.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.13" dependencies = ["kimi-cli", "kosong"] [tool.uv.sources] kimi-cli = { path = "../../" } ================================================ FILE: examples/custom-kimi-soul/README.md ================================================ # Example: Custom Kimi Soul This example demonstrates how to extend the `KimiSoul` (builtin agent loop) to customize its behavior and use it with the `Shell` UI. ```sh cd examples/custom-kimi-soul uv sync --reinstall uv run main.py ``` ================================================ FILE: examples/custom-kimi-soul/main.py ================================================ import asyncio import os from pathlib import Path from typing import override from kaos.path import KaosPath from kosong.tooling import CallableTool2, ToolError, ToolOk, Toolset from kosong.tooling.simple import SimpleToolset from pydantic import BaseModel, Field, SecretStr from kimi_cli.auth.oauth import OAuthManager from kimi_cli.config import LLMModel, LLMProvider, get_default_config from kimi_cli.llm import LLM, create_llm from kimi_cli.session import Session from kimi_cli.soul.agent import Agent, Runtime from kimi_cli.soul.context import Context from kimi_cli.soul.kimisoul import KimiSoul from kimi_cli.ui.shell import Shell from kimi_cli.wire.types import ContentPart, ToolReturnValue class HakimiSoul(KimiSoul): @staticmethod async def create( llm: LLM | None, system_prompt: str, toolset: Toolset, session: Session | None = None, work_dir: Path | None = None, ) -> "HakimiSoul": config = get_default_config() kaos_work_dir = KaosPath.unsafe_from_local_path(work_dir) if work_dir else KaosPath.cwd() session = session or await Session.create(kaos_work_dir) runtime = await Runtime.create( config=config, oauth=OAuthManager(config), llm=llm, session=session, yolo=True, ) agent = Agent( name="HakimiAgent", system_prompt=system_prompt, toolset=toolset, runtime=runtime, ) context = Context(session.context_file) return HakimiSoul(agent, context=context) @property @override def name(self) -> str: return "Hakimi" @override async def run(self, user_input: str | list[ContentPart]) -> None: if not self._context.history: await self._context.restore() await super().run(user_input) class MyBashParams(BaseModel): command: str = Field(description="The bash command to execute.") class MyBashTool(CallableTool2): name: str = "MyBashTool" description: str = "A tool to execute bash commands." params: type[MyBashParams] = MyBashParams async def __call__(self, params: MyBashParams) -> ToolReturnValue: import subprocess result = subprocess.run(params.command, shell=True, capture_output=True, text=True) if result.returncode != 0: return ToolError( output=result.stdout, message=f"Command failed with error: {result.stderr}", brief="Bash command failed", ) return ToolOk(output=result.stdout) async def main(): toolset = SimpleToolset() toolset += MyBashTool() soul = await HakimiSoul.create( llm=create_llm( LLMProvider( type="kimi", base_url=os.getenv("KIMI_BASE_URL") or "https://api.moonshot.ai/v1", api_key=SecretStr(os.getenv("KIMI_API_KEY") or ""), ), LLMModel( provider="kimi", model="kimi-k2-turbo-preview", max_context_size=250_000, ), ), system_prompt="You are Hakimi, an AI assistant that helps users with various tasks.", toolset=toolset, ) ui = Shell(soul) await ui.run() if __name__ == "__main__": asyncio.run(main()) ================================================ FILE: examples/custom-kimi-soul/pyproject.toml ================================================ [project] name = "custom-kimi-soul" version = "0.1.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.13" dependencies = ["kimi-cli", "kosong"] [tool.uv.sources] kimi-cli = { path = "../../" } ================================================ FILE: examples/custom-tools/README.md ================================================ # Example: Custom Tools This example demonstrates how to write custom tools for Kimi Code CLI and add them to your agent spec file. ```sh cd examples/custom-tools uv sync --reinstall uv run main.py ``` ================================================ FILE: examples/custom-tools/main.py ================================================ import asyncio from pathlib import Path from kaos.path import KaosPath from kimi_cli.app import KimiCLI, enable_logging from kimi_cli.session import Session async def main(): enable_logging() session = await Session.create(KaosPath.cwd()) myagent = Path(__file__).parent / "myagent.yaml" instance = await KimiCLI.create(session, agent_file=myagent) await instance.run_print( input_format="text", output_format="text", command="What tools do you have?", ) if __name__ == "__main__": asyncio.run(main()) ================================================ FILE: examples/custom-tools/my_tools/__init__.py ================================================ ================================================ FILE: examples/custom-tools/my_tools/ls.py ================================================ from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnValue from pydantic import BaseModel, Field class Params(BaseModel): directory: str = Field(description="The directory to list files from.", default=".") class Ls(CallableTool2): name: str = "Ls" description: str = "List files in a directory." params: type[Params] = Params async def __call__(self, params: Params) -> ToolReturnValue: import os try: files = os.listdir(params.directory) output = "\n".join(files) return ToolOk(output=output) except Exception as e: return ToolError( output="", message=str(e), brief="Failed to list files", ) ================================================ FILE: examples/custom-tools/myagent.yaml ================================================ version: 1 agent: extend: default tools: - "kimi_cli.tools.multiagent:Task" - "kimi_cli.tools.todo:SetTodoList" - "kimi_cli.tools.shell:Shell" - "kimi_cli.tools.file:ReadFile" - "kimi_cli.tools.file:Glob" - "kimi_cli.tools.file:Grep" - "kimi_cli.tools.file:WriteFile" - "kimi_cli.tools.file:StrReplaceFile" - "kimi_cli.tools.web:SearchWeb" - "kimi_cli.tools.web:FetchURL" - "my_tools.ls:Ls" # custom tool ================================================ FILE: examples/custom-tools/pyproject.toml ================================================ [project] name = "custom-tools" version = "0.1.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.13" dependencies = ["kimi-cli", "kosong"] [tool.uv.sources] kimi-cli = { path = "../../" } ================================================ FILE: examples/kimi-cli-stream-json/README.md ================================================ # Example: Kimi Code CLI Stream JSON This example demonstrates how to run Kimi Code CLI in a subprocess and interact with it using JSON messages over standard input and output. ```sh cd examples/kimi-cli-stream-json uv run main.py ``` ================================================ FILE: examples/kimi-cli-stream-json/main.py ================================================ import asyncio import json import os KIMI_CLI_COMMAND = "uv run --project ../../ kimi" async def main(): proc = await asyncio.create_subprocess_exec( *KIMI_CLI_COMMAND.split(), "--work-dir", os.getcwd(), "--print", "--input-format", "stream-json", "--output-format", "stream-json", stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, ) assert proc.stdout is not None, "stdout is None" assert proc.stdin is not None, "stdin is None" user_message = { "role": "user", "content": "How many lines of code are there in the current working directory?", } proc.stdin.write(json.dumps(user_message).encode("utf-8") + b"\n") await proc.stdin.drain() while True: line = await proc.stdout.readline() if not line: break message = json.loads(line.decode("utf-8")) print("Received message:", message) if __name__ == "__main__": asyncio.run(main()) ================================================ FILE: examples/kimi-cli-stream-json/pyproject.toml ================================================ [project] name = "kimi-cli-stream-json" version = "0.1.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.13" dependencies = [] ================================================ FILE: examples/kimi-cli-wire-messages/README.md ================================================ # Example: Kimi Code CLI Wire Messages This example demonstrates how to create and run a Kimi Code CLI instance with raw Wire message output. ```sh cd examples/kimi-cli-wire-messages uv sync --reinstall uv run main.py ``` ================================================ FILE: examples/kimi-cli-wire-messages/main.py ================================================ import asyncio from kaos.path import KaosPath from rich import print from kimi_cli.app import KimiCLI, enable_logging from kimi_cli.session import Session async def main(): enable_logging() session = await Session.create(KaosPath.cwd()) instance = await KimiCLI.create(session) user_input = "Hello!" async for msg in instance.run( user_input=user_input, cancel_event=asyncio.Event(), merge_wire_messages=True, ): print(msg) # print the last assistant message print(instance.soul.context.history[-1]) if __name__ == "__main__": asyncio.run(main()) ================================================ FILE: examples/kimi-cli-wire-messages/pyproject.toml ================================================ [project] name = "kimi-cli-wire-messages" version = "0.1.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.13" dependencies = [ "kimi-cli", "rich", ] [tool.uv.sources] kimi-cli = { path = "../../" } ================================================ FILE: examples/kimi-psql/README.md ================================================ # kimi-psql AI-assisted PostgreSQL interactive terminal. ## Features - **AI Mode** (default): Natural language to SQL - AI executes read-only queries - **PSQL Mode**: Full interactive psql experience (Ctrl-X to switch) - **Read-only by design**: AI mode uses read-only transactions for safety ## Usage ```sh cd examples/kimi-psql uv sync --reinstall # Connection URL with password uv run main.py --conninfo 'postgresql://user:pass@host/db' # Traditional psql arguments with PGPASSWORD env var PGPASSWORD=yourpass uv run main.py -h localhost -U postgres -d mydb ``` ## Example ``` kimi-psql✨ show all users who registered last month • Used ExecuteSql ({"sql": "SELECT * FROM users WHERE ..."}) id | name | created_at ---+-------+------------ 42 | Alice | 2024-11-15 kimi-psql✨ ^X # Switch to PSQL mode postgres=# \d users ... ``` ================================================ FILE: examples/kimi-psql/agent.yaml ================================================ version: 1 agent: extend: default name: kimi-psql system_prompt_args: ROLE_ADDITIONAL: | You are now a PostgreSQL assistant with read-only access to a PostgreSQL database. Database Tools: - ExecuteSql: Execute read-only SQL queries in the connected PostgreSQL database When the user asks about data or wants to run queries: 1. Use the ExecuteSql tool to run the appropriate SQL query 2. Use proper PostgreSQL SQL syntax (psql meta-commands like \d, \dt are NOT supported) 3. For database introspection, use SQL queries from information_schema or pg_catalog Examples: - User: "show all tables" -> Use ExecuteSql with: SELECT tablename FROM pg_tables WHERE schemaname = 'public'; - User: "describe users table" -> Use ExecuteSql with: SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'users'; - User: "count users" -> Use ExecuteSql with: SELECT COUNT(*) FROM users; - User: "find orders from last week" -> Use ExecuteSql with: SELECT * FROM orders WHERE created_at >= NOW() - INTERVAL '7 days'; - User: "save this query to a file" -> Use WriteFile to save the SQL query - User: "run the migration.sql file" -> Use ReadFile to read the SQL file, then explain what it does (ExecuteSql is read-only) For write operations (INSERT, UPDATE, DELETE), return the SQL in a markdown code block for the user to execute manually. ================================================ FILE: examples/kimi-psql/main.py ================================================ """ kimi-psql: AI-assisted PostgreSQL interactive terminal. Usage: uv run main.py -h localhost -p 5432 -U postgres -d mydb """ import asyncio import contextlib import fcntl import os import pty import select import signal import sys import termios import tty from enum import Enum from pathlib import Path from typing import LiteralString, cast import psycopg import typer from kaos.path import KaosPath from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnValue from prompt_toolkit import PromptSession from prompt_toolkit.formatted_text import FormattedText from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.patch_stdout import patch_stdout from pydantic import BaseModel, Field, SecretStr from rich.console import Console from rich.panel import Panel from rich.text import Text from kimi_cli.auth.oauth import OAuthManager from kimi_cli.config import LLMModel, LLMProvider from kimi_cli.llm import LLM, create_llm from kimi_cli.session import Session from kimi_cli.soul import LLMNotSet, LLMNotSupported, MaxStepsReached, RunCancelled, run_soul from kimi_cli.soul.agent import Runtime from kimi_cli.soul.context import Context from kimi_cli.soul.kimisoul import KimiSoul from kimi_cli.ui.shell.visualize import visualize from kimi_cli.wire.types import StatusUpdate class ExecuteSqlParams(BaseModel): """Parameters for ExecuteSql tool.""" sql: str = Field(description="The SQL query to execute in the connected PostgreSQL database") class ExecuteSql(CallableTool2[ExecuteSqlParams]): """Execute read-only SQL query in the connected PostgreSQL database.""" name: str = "ExecuteSql" description: str = ( "Execute a READ-ONLY SQL query in the connected PostgreSQL database. " "Use this tool for SELECT queries and database introspection queries. " "This tool CANNOT execute write operations (INSERT, UPDATE, DELETE, DROP, etc.). " "For write operations, return the SQL in a markdown code block for the user to " "execute manually. " "Note: psql meta-commands (\\d, \\dt, etc.) are NOT supported - use SQL queries " "instead (e.g., SELECT * FROM pg_tables WHERE schemaname = 'public')." ) params: type[ExecuteSqlParams] = ExecuteSqlParams def __init__(self, conninfo: str): """ Initialize ExecuteSql tool with database connection info. Args: conninfo: PostgreSQL connection string (e.g., "host=localhost port=5432 dbname=mydb user=postgres") """ super().__init__() self._conninfo = conninfo async def __call__(self, params: ExecuteSqlParams) -> ToolReturnValue: try: # Connect and execute in read-only transaction async with ( await psycopg.AsyncConnection.connect(self._conninfo, autocommit=False) as conn, conn.cursor() as cur, ): # Set read-only mode await conn.set_read_only(True) # Cast to LiteralString for type checker - SQL is validated at runtime await cur.execute(cast(LiteralString, params.sql)) # Check if query returns results if cur.description: rows = await cur.fetchall() if not rows: return ToolOk(output="Query returned no rows.") # Format as table columns = [desc[0] for desc in cur.description] col_widths = [len(col) for col in columns] # Calculate column widths for row in rows: for i, val in enumerate(row): col_widths[i] = max(col_widths[i], len(str(val))) # Build table lines = [] # Header header = " | ".join(col.ljust(col_widths[i]) for i, col in enumerate(columns)) lines.append(header) lines.append("-" * len(header)) # Rows for row in rows: line = " | ".join( str(val).ljust(col_widths[i]) for i, val in enumerate(row) ) lines.append(line) lines.append(f"\n({len(rows)} row{'s' if len(rows) != 1 else ''})") return ToolOk(output="\n".join(lines)) else: # Non-SELECT query (should not happen in read-only mode) return ToolOk(output="Query executed successfully (no results).") except psycopg.errors.ReadOnlySqlTransaction as e: return ToolError( message=f"Cannot execute write operation in read-only mode: {e}", brief="Write operation not allowed", ) except Exception as e: return ToolError(message=f"SQL execution error: {e}", brief="SQL error") console = Console() # ============================================================================ # PsqlProcess: PTY-based psql subprocess management # ============================================================================ class PsqlProcess: """Manages a psql subprocess with PTY support for full interactive experience.""" def __init__(self, psql_args: list[str]): self.psql_args = psql_args self._master_fd: int | None = None self._pid: int | None = None self._running = False self._original_termios: list | None = None def start(self) -> None: """Spawn psql in a pseudo-terminal.""" # Save original terminal settings if sys.stdin.isatty(): self._original_termios = termios.tcgetattr(sys.stdin) pid, master_fd = pty.fork() if pid == 0: # Child process: exec psql os.execvp("psql", self.psql_args) else: # Parent process self._pid = pid self._master_fd = master_fd self._running = True # Set master fd to non-blocking flags = fcntl.fcntl(master_fd, fcntl.F_GETFL) fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) # Sync terminal size self._sync_window_size() # Handle window resize signal.signal(signal.SIGWINCH, self._handle_sigwinch) def _sync_window_size(self) -> None: """Sync PTY window size with current terminal.""" if self._master_fd is None: return if sys.stdin.isatty(): winsize = fcntl.ioctl(sys.stdin, termios.TIOCGWINSZ, b"\x00" * 8) fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, winsize) def _handle_sigwinch(self, signum: int, frame: object) -> None: """Handle terminal window resize.""" self._sync_window_size() def read(self, timeout: float = 0.1) -> bytes: """Read output from psql (non-blocking with timeout).""" if self._master_fd is None: return b"" ready, _, _ = select.select([self._master_fd], [], [], timeout) if ready: try: return os.read(self._master_fd, 4096) except OSError: return b"" return b"" def write(self, data: bytes) -> None: """Write input to psql.""" if self._master_fd is None: return os.write(self._master_fd, data) def is_running(self) -> bool: """Check if psql process is still running.""" if self._pid is None: return False try: pid, status = os.waitpid(self._pid, os.WNOHANG) if pid != 0: self._running = False return self._running except ChildProcessError: self._running = False return False def stop(self) -> None: """Terminate psql process and restore terminal.""" if self._pid is not None: try: os.kill(self._pid, signal.SIGTERM) os.waitpid(self._pid, 0) except (ProcessLookupError, ChildProcessError): pass if self._master_fd is not None: with contextlib.suppress(OSError): os.close(self._master_fd) # Restore original terminal settings if self._original_termios and sys.stdin.isatty(): termios.tcsetattr(sys.stdin, termios.TCSADRAIN, self._original_termios) self._running = False @property def master_fd(self) -> int | None: return self._master_fd # ============================================================================ # PsqlMode: Operation mode enumeration # ============================================================================ class PsqlMode(Enum): AI = "ai" # AI assistance mode (default) PSQL = "psql" # Direct psql interaction def toggle(self) -> "PsqlMode": return PsqlMode.PSQL if self == PsqlMode.AI else PsqlMode.AI # ============================================================================ # PsqlSoul: SQL generation specialized Soul # ============================================================================ async def create_psql_soul(llm: LLM | None, conninfo: str) -> KimiSoul: """Create a KimiSoul configured for PostgreSQL with ExecuteSql tool and standard kimi-cli tools.""" from typing import cast from kimi_cli.config import load_config from kimi_cli.soul.agent import load_agent from kimi_cli.soul.toolset import KimiToolset config = load_config() kaos_work_dir = KaosPath.cwd() session = await Session.create(kaos_work_dir) runtime = await Runtime.create( config=config, oauth=OAuthManager(config), llm=llm, session=session, yolo=True, # Auto-approve read-only SQL queries ) # Load agent from configuration agent_file = Path(__file__).parent / "agent.yaml" agent = await load_agent(agent_file, runtime, mcp_configs=[]) # Add custom ExecuteSql tool to the loaded agent cast(KimiToolset, agent.toolset).add(ExecuteSql(conninfo)) context = Context(session.context_file) return KimiSoul(agent, context=context) # ============================================================================ # PsqlShell: Main TUI orchestrator # ============================================================================ class PsqlShell: """Main TUI orchestrator for kimi-psql.""" PROMPT_SYMBOL_AI = "✨" PROMPT_SYMBOL_PSQL = "$" def __init__(self, soul: KimiSoul, psql_process: PsqlProcess): self.soul = soul self._psql_process = psql_process self._mode = PsqlMode.AI self._switch_requested = False self._prompt_session: PromptSession[str] | None = None self._psql_entered_before = False # Track if we've entered PSQL mode before def _create_prompt_session(self) -> PromptSession[str]: """Create a prompt_toolkit session with Ctrl-X binding.""" kb = KeyBindings() @kb.add("c-x", eager=True) def _(event) -> None: """Switch to PSQL mode on Ctrl-X.""" self._switch_requested = True event.app.exit(result="") def get_prompt() -> FormattedText: symbol = self.PROMPT_SYMBOL_AI if self._mode == PsqlMode.AI else self.PROMPT_SYMBOL_PSQL return FormattedText([("bold fg:blue", f"kimi-psql{symbol} ")]) def get_bottom_toolbar() -> FormattedText: mode_str = self._mode.value.upper() return FormattedText( [ ("bg:#333333 fg:#ffffff", f" [{mode_str}] "), ("bg:#333333 fg:#888888", " | ctrl-x: switch mode | ctrl-d: exit "), ] ) return PromptSession( message=get_prompt, key_bindings=kb, bottom_toolbar=get_bottom_toolbar, ) async def run(self) -> None: """Main event loop.""" # Create prompt session self._prompt_session = self._create_prompt_session() # Print welcome message self._print_welcome() try: while self._psql_process.is_running(): if self._mode == PsqlMode.AI: await self._run_ai_mode() else: await self._run_psql_mode() except KeyboardInterrupt: console.print("\n[grey50]Bye![/grey50]") finally: self._psql_process.stop() def _print_welcome(self) -> None: """Print welcome message.""" console.print( Panel( Text.from_markup( "[bold]Welcome to kimi-psql![/bold]\n" "[grey50]AI-assisted PostgreSQL interactive terminal[/grey50]\n\n" "[cyan]Ctrl-X[/cyan]: Switch between AI and PSQL mode\n" "[cyan]Ctrl-D[/cyan]: Exit" ), border_style="blue", expand=False, ) ) console.print(f"[grey50]Current mode: [bold]{self._mode.value.upper()}[/bold][/grey50]\n") async def _run_ai_mode(self) -> None: """Handle AI assistance mode using prompt_toolkit with run_soul + visualize.""" if not self._prompt_session: return self._switch_requested = False try: with patch_stdout(raw=True): user_input = await self._prompt_session.prompt_async() except EOFError: raise KeyboardInterrupt from None except KeyboardInterrupt: console.print() return # Check if mode switch was requested if self._switch_requested: self._switch_mode() return user_input = user_input.strip() if not user_input: return # Check for exit commands if user_input.lower() in ["exit", "quit", "\\q"]: raise KeyboardInterrupt # Run soul with visualize (same as kimi-cli shell) cancel_event = asyncio.Event() try: await run_soul( self.soul, user_input, lambda wire: visualize( wire.ui_side(merge=False), initial_status=StatusUpdate(context_usage=self.soul.status.context_usage), cancel_event=cancel_event, ), cancel_event, ) except LLMNotSet: console.print("[red]LLM not set, run `kimi /setup` to configure[/red]") except LLMNotSupported as e: console.print(f"[red]{e}[/red]") except MaxStepsReached as e: console.print(f"[yellow]{e}[/yellow]") except RunCancelled: console.print("[red]Interrupted by user[/red]") except Exception as e: console.print(f"[red]Error: {e}[/red]") async def _run_psql_mode(self) -> None: """Handle direct psql interaction with full PTY pass-through.""" if not self._psql_process or self._psql_process.master_fd is None: return console.print( "[grey50]Entering PSQL mode. Press Ctrl-X to switch back to AI mode.[/grey50]" ) # Flush any pending output from psql before entering raw mode while True: chunk = self._psql_process.read(timeout=0.05) if chunk: sys.stdout.write(chunk.decode("utf-8", errors="replace")) sys.stdout.flush() else: break # Save terminal settings and set raw mode old_settings = None if sys.stdin.isatty(): old_settings = termios.tcgetattr(sys.stdin) tty.setraw(sys.stdin) master_fd = self._psql_process.master_fd # Only send newline to refresh prompt if we've entered PSQL mode before # First time, psql already shows its prompt after startup if self._psql_entered_before: self._psql_process.write(b"\n") self._psql_entered_before = True try: while self._psql_process.is_running(): # Wait for input from either stdin or psql readable, _, _ = select.select([sys.stdin, master_fd], [], [], 0.1) for fd in readable: if fd == sys.stdin: # Read from user data = os.read(sys.stdin.fileno(), 1024) if not data: return # Check for Ctrl-X (0x18) if b"\x18" in data: # Restore terminal and switch mode if old_settings: termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings) self._switch_mode() return # Forward to psql self._psql_process.write(data) elif fd == master_fd: # Read from psql and display try: data = os.read(master_fd, 4096) if data: os.write(sys.stdout.fileno(), data) except OSError: break finally: # Restore terminal settings if old_settings and sys.stdin.isatty(): termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings) def _switch_mode(self) -> None: """Switch between AI and PSQL mode.""" self._mode = self._mode.toggle() console.print(f"\n[yellow]Switched to {self._mode.value.upper()} mode[/yellow]\n") # ============================================================================ # CLI Entry Point # ============================================================================ app = typer.Typer( name="kimi-psql", help="AI-assisted PostgreSQL interactive terminal", add_completion=False, ) @app.command() def main( dbname: str = typer.Argument(None, help="Database name (same as psql)"), username_arg: str = typer.Argument(None, help="Database user (same as psql)"), host: str = typer.Option(None, "-h", "--host", help="Database server host"), port: int = typer.Option(None, "-p", "--port", help="Database server port"), username: str = typer.Option(None, "-U", "--username", help="Database user"), dbname_opt: str = typer.Option(None, "-d", "--dbname", help="Database name"), conninfo: str = typer.Option( None, "--conninfo", help="PostgreSQL connection URL (e.g., postgresql://user:pass@host/db)" ), ) -> None: """ Start kimi-psql: AI-assisted PostgreSQL interactive terminal. Usage is compatible with psql: kimi-psql mydb kimi-psql mydb postgres kimi-psql -h localhost -U postgres -d mydb kimi-psql --conninfo postgresql://user:pass@host/db """ # Resolve dbname and username (positional takes precedence over options) final_dbname = dbname or dbname_opt final_username = username_arg or username asyncio.run(_run_async(host, port, final_username, final_dbname, conninfo=conninfo)) async def _run_async( host: str | None, port: int | None, username: str | None, dbname: str | None, conninfo: str | None = None, config_file: Path | None = None, ) -> None: """Async entry point.""" from kimi_cli.config import load_config from kimi_cli.llm import augment_provider_with_env_vars # If conninfo URL is provided, use it directly if conninfo: # For psql, just pass the connection URL psql_args = ["psql", conninfo] # For psycopg, use the URL as-is conninfo_str = conninfo else: # Build psql command args psql_args = ["psql"] if host: psql_args.extend(["-h", host]) if port: psql_args.extend(["-p", str(port)]) if username: psql_args.extend(["-U", username]) if dbname: psql_args.extend(["-d", dbname]) # Build connection info for psycopg conninfo_parts = [] if host: conninfo_parts.append(f"host={host}") if port: conninfo_parts.append(f"port={port}") if username: conninfo_parts.append(f"user={username}") if dbname: conninfo_parts.append(f"dbname={dbname}") conninfo_str = " ".join(conninfo_parts) # Load config (same as kimi-cli) config = load_config(config_file) model: LLMModel | None = None provider: LLMProvider | None = None # Try to use config file if config.default_model: model = config.models.get(config.default_model) if model: provider = config.providers.get(model.provider) # Fallback to defaults if not model: model = LLMModel(provider="kimi", model="", max_context_size=250_000) if not provider: provider = LLMProvider(type="kimi", base_url="", api_key=SecretStr("")) # Override with environment variables env_overrides = augment_provider_with_env_vars(provider, model) if not provider.base_url or not model.model: console.print("[red]LLM not configured. Run `kimi /setup` to configure.[/red]") return if env_overrides: console.print(f"[grey50]Using env overrides: {', '.join(env_overrides.keys())}[/grey50]") # Create LLM llm = create_llm(provider, model) # Create Soul with ExecuteSql tool (uses psycopg for read-only queries) soul = await create_psql_soul(llm, conninfo_str) # Start psql process (only for user's PSQL mode) psql_process = PsqlProcess(psql_args) psql_process.start() # Create and run shell shell = PsqlShell(soul, psql_process) await shell.run() if __name__ == "__main__": app() ================================================ FILE: examples/kimi-psql/pyproject.toml ================================================ [project] name = "kimi-psql" version = "0.1.0" description = "AI-assisted PostgreSQL interactive terminal" readme = "README.md" requires-python = ">=3.13" dependencies = ["kimi-cli", "kosong", "typer", "rich", "psycopg[binary]>=3.0"] [tool.uv.sources] kimi-cli = { path = "../../" } ================================================ FILE: examples/sample-plugin/SKILL.md ================================================ --- name: sample-plugin description: | Sample plugin demonstrating the Skills + Tools model. Includes a Python tool (greeting) and a TypeScript tool (calculator). --- # Sample Plugin A demo plugin with two tools in different languages, showing that plugin tools are language-agnostic. ## Tools | Tool | Language | Description | |------|----------|-------------| | `py_greet` | Python | Generate a greeting in en/zh/ja | | `ts_calc` | TypeScript | Evaluate a math expression | ## Usage - "greet Alice in Chinese" -> use `py_greet` with name="Alice", lang="zh" - "what is 42 * 17 + 3" -> use `ts_calc` with expression="42 * 17 + 3" ================================================ FILE: examples/sample-plugin/plugin.json ================================================ { "name": "sample-plugin", "version": "1.0.0", "description": "Sample plugin demonstrating Skills + Tools with both Python and TypeScript tools", "tools": [ { "name": "py_greet", "description": "Generate a greeting message (Python tool)", "command": ["python3", "scripts/greet.py"], "parameters": { "type": "object", "properties": { "name": { "type": "string", "description": "Name to greet" }, "lang": { "type": "string", "enum": ["en", "zh", "ja"], "description": "Language: en=English, zh=Chinese, ja=Japanese" } }, "required": ["name"] } }, { "name": "ts_calc", "description": "Evaluate a math expression and return the result (TypeScript tool)", "command": ["npx", "tsx", "scripts/calc.ts"], "parameters": { "type": "object", "properties": { "expression": { "type": "string", "description": "Math expression to evaluate, e.g. '2 + 3 * 4'" } }, "required": ["expression"] } } ] } ================================================ FILE: examples/sample-plugin/scripts/calc.ts ================================================ #!/usr/bin/env npx tsx /** TypeScript tool: evaluate a math expression. */ const chunks: Buffer[] = []; process.stdin.on("data", (chunk) => chunks.push(chunk)); process.stdin.on("end", () => { const raw = Buffer.concat(chunks).toString("utf-8").trim(); const params = raw ? JSON.parse(raw) : {}; const expression: string = params.expression ?? "0"; // Safe evaluation: only allow numbers and basic operators if (!/^[\d\s+\-*/.()]+$/.test(expression)) { console.error(`Invalid expression: ${expression}`); process.exit(1); } try { const result = Function(`"use strict"; return (${expression})`)(); console.log(`${expression} = ${result}`); } catch (e) { console.error(`Evaluation error: ${e}`); process.exit(1); } }); ================================================ FILE: examples/sample-plugin/scripts/greet.py ================================================ #!/usr/bin/env python3 """Python tool: generate a greeting message.""" import json import sys GREETINGS = { "en": "Hello, {name}! Welcome!", "zh": "你好,{name}!欢迎!", "ja": "こんにちは、{name}さん!ようこそ!", } params = json.loads(sys.stdin.read()) if not sys.stdin.isatty() else {} name = params.get("name", "World") lang = params.get("lang", "en") template = GREETINGS.get(lang, GREETINGS["en"]) print(template.format(name=name)) ================================================ FILE: flake.nix ================================================ { description = "kimi-cli flake"; inputs = { nixpkgs.url = "github:nixos/nixpkgs?ref=nixpkgs-unstable"; systems.url = "github:nix-systems/default"; pyproject-nix = { url = "github:pyproject-nix/pyproject.nix"; inputs.nixpkgs.follows = "nixpkgs"; }; uv2nix = { url = "github:pyproject-nix/uv2nix"; inputs.pyproject-nix.follows = "pyproject-nix"; inputs.nixpkgs.follows = "nixpkgs"; }; pyproject-build-systems = { url = "github:pyproject-nix/build-system-pkgs"; inputs.pyproject-nix.follows = "pyproject-nix"; inputs.uv2nix.follows = "uv2nix"; inputs.nixpkgs.follows = "nixpkgs"; }; }; outputs = { self, nixpkgs, systems, pyproject-nix, uv2nix, pyproject-build-systems, }: let allSystems = import systems; forAllSystems = f: nixpkgs.lib.genAttrs allSystems ( system: let pkgs = import nixpkgs { inherit system; config.allowUnfree = true; }; in f { inherit system pkgs; } ); in { packages = forAllSystems ( { pkgs, ... }: let kimi-cli = let inherit (pkgs) lib callPackage python313 runCommand ripgrep stdenvNoCC makeWrapper versionCheckHook ; python = python313; pyproject = lib.importTOML ./pyproject.toml; workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./.; }; overlay = workspace.mkPyprojectOverlay { sourcePreference = "wheel"; }; extraBuildOverlay = final: prev: { # Add setuptools build dependency for ripgrepy ripgrepy = prev.ripgrepy.overrideAttrs (old: { nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ final.setuptools ]; }); # Replace README symlink with real file for Nix builds. "kimi-code" = prev."kimi-code".overrideAttrs (old: { postPatch = (old.postPatch or "") + '' rm -f README.md cp ${./README.md} README.md ''; }); }; pythonSet = (callPackage pyproject-nix.build.packages { inherit python; }).overrideScope ( lib.composeManyExtensions [ pyproject-build-systems.overlays.wheel overlay extraBuildOverlay ] ); kimiCliPackage = pythonSet.mkVirtualEnv "kimi-cli-virtual-env-${pyproject.project.version}" workspace.deps.default; in stdenvNoCC.mkDerivation ({ pname = "kimi-cli"; version = pyproject.project.version; dontUnpack = true; nativeBuildInputs = [ makeWrapper ]; buildInputs = [ ripgrep ]; installPhase = '' runHook preInstall mkdir -p $out/bin makeWrapper ${kimiCliPackage}/bin/kimi $out/bin/kimi \ --prefix PATH : ${lib.makeBinPath [ ripgrep ]} \ --set KIMI_CLI_NO_AUTO_UPDATE "1" runHook postInstall ''; nativeInstallCheckInputs = [ versionCheckHook ]; versionCheckProgramArg = "--version"; doInstallCheck = true; meta = { description = "Kimi Code CLI is a new CLI agent that can help you with your software development tasks and terminal operations"; license = lib.licenses.asl20; sourceProvenance = with lib.sourceTypes; [ fromSource ]; maintainers = with lib.maintainers; [ xiaoxiangmoe ]; mainProgram = "kimi"; }; }); in { inherit kimi-cli; default = kimi-cli; } ); formatter = forAllSystems ({ pkgs, ... }: pkgs.nixfmt-tree); }; } ================================================ FILE: kimi.spec ================================================ # -*- mode: python ; coding: utf-8 -*- import os from kimi_cli.utils.pyinstaller import datas, hiddenimports # Read codesign identity from environment variable (for macOS signing in CI) codesign_identity = os.environ.get("APPLE_SIGNING_IDENTITY", None) # Read build mode from environment variable (onedir mode for directory-based distribution) onedir_mode = os.environ.get("PYINSTALLER_ONEDIR", "0") == "1" a = Analysis( ["src/kimi_cli/cli/__main__.py"], pathex=[], binaries=[], datas=datas, hiddenimports=hiddenimports, hookspath=[], hooksconfig={}, runtime_hooks=[], excludes=[], noarchive=False, optimize=0, ) pyz = PYZ(a.pure) if onedir_mode: # one-dir mode: EXE contains only scripts, binaries/datas collected separately # Use a different name for EXE to avoid conflict with COLLECT directory exe = EXE( pyz, a.scripts, exclude_binaries=True, name="kimi-exe", debug=False, bootloader_ignore_signals=False, strip=False, upx=True, upx_exclude=[], runtime_tmpdir=None, console=True, disable_windowed_traceback=False, argv_emulation=False, target_arch=None, codesign_identity=codesign_identity, entitlements_file=None, ) coll = COLLECT( exe, a.binaries, a.datas, strip=False, upx=True, upx_exclude=[], name="kimi", ) else: # one-file mode (default): all binaries/datas bundled into single executable exe = EXE( pyz, a.scripts, a.binaries, a.datas, [], name="kimi", debug=False, bootloader_ignore_signals=False, strip=False, upx=True, upx_exclude=[], runtime_tmpdir=None, console=True, disable_windowed_traceback=False, argv_emulation=False, target_arch=None, codesign_identity=codesign_identity, entitlements_file=None, ) ================================================ FILE: klips/.pre-commit-config.yaml ================================================ orphan: true # Docs changes do not need pre-commit hooks. repos: [] ================================================ FILE: klips/klip-0-klip.md ================================================ --- Author: "@stdrc" Updated: 2026-01-07 Status: Implemented --- # KLIP-0: Kimi CLI Improvement Proposal ## Kimi CLI 的前世今生 Kimi CLI 起源于 2025 年 9 月 1 日晚上开始的一个 side project——「Ensoul」。Ensoul 是一个命令行程序,功能是加载指定的 agent 文件(其中包含 system prompt 和要启用的 mshtools 中的 tool list),进入 REPL 接收用户 prompt,对用户 prompt 运行 agent loop。项目名字叫「Ensoul」是因为这个过程很像在给一个「死的」agent 文件「赋予灵魂」,让它「活起来」。 Ensoul 最初的目标是让不懂代码的 PM 能够利用当时已有的内部 agent 开发框架——「YAMAHA」。YAMAHA 是硬凑出来的名字,全称是「Yet Another Moonshot Agent, Hallucination Avoided」,它是更早已存在的专用于跑 GAIA benchmark 的「YAMA」的重写版。重写后的 YAMAHA 发展成了一个更为通用的 agent 开发框架,提供一些 agent 的构建单元,比如「ChatProvider」「Message」「Context」「Tool」「Toolset」——「Kosong」即脱胎于此。 Kosong 在马来语的意思是「空」,如此命名是希望它只提供「机制」,不提供「策略」,它不含有任何「实际的东西」,却又什么都蕴含了。「空即是色,色即是空」。当 Ensoul 逐渐取代 YAMAHA 的位置,又进而演变成 Kimi CLI 时,YAMAHA 中最通用的那部分东西,沉淀到了 Kosong。现在的 Kosong 包含 LLM 抽象层和 agent 开发原语(其中最为关键的是 `step` 函数),是 Kimi CLI 最关键的基石。它的存在使得 Kimi CLI 的核心 agent loop——「KimiSoul」的实现只需要 400 行 Python 代码。 现在回到 Kimi CLI。CLI 的全称是「Command Line Interface」,是所有运行在终端的命令行界面程序的统称,类似于所有图形界面的程序都称为「GUI」程序,所有运行在浏览器的程序都称为「Web」程序。当意识到 Ensoul「就是」Kimi CLI 时,我们把命令的名字改成了 `kimi`。它从一开始就不只是一个 coding agent,而是运行在命令行界面的 Kimi 智能助理,人们应该期待它可以做任何事,以命令行界面的形式。 那么它应该长什么样?「没有人想在终端里用聊天界面」是我们的早期共识。在 Claude Code 之前,人们只会在终端里用 shell,以及用 shell 运行其他命令行程序,如 `npm` `python` `rclone`;而一般大众则更是从来没有打开过终端。我们认为 Claude Code 把 chat UI 放到终端里完全是因为这样开发起来最快。GUI 是需要时间的,而且需要项目有更多人力资源,终端的 chat UI 似乎是一种可以很快推出的、谁都不想要但谁都能勉强用的形式。我们在最开始就认为,人们需要三种形式的 agent——面向大众的图形界面 agent、面向程序员的 AI-shell、面向程序员的 IDE 集成 agent。Kimi CLI 的第一步,是成为 AI-shell,至少长得像个 AI-shell。 但 UI 不是本质问题。无论表现为什么形态,内核是一样的。CLI 程序是一个非常理想的提供 agent 内核的形式。就像 MCP 工具最广泛使用的形式是通过 `npx` 运行并在 stdio 上通过 JSON-RPC 通信,Kimi CLI 在 shell UI 之外,提供了 Print 模式和 Wire 模式,可以在 stdio 上通过特定的格式接受用户 prompt 和推送 agent 行为事件。基于 Wire 模式,我们有了内部的 Web UI,和正在开发的 VS Code 扩展。除此之外,我们通过 ACP 模式提供 ACP 服务端(同样走 stdio 通信),支持接入任何 ACP 客户端,这使得 Kimi CLI 可以接入 JetBrains 和 Zed 等 IDE,也可以接入 DeepChat、Alma 这样的本地通用 agent 客户端。我们最开始所畅想的三种形式,正在一一出现并变得可用。 仅仅如此还不够,从 Ensoul 的第一天开始,它就是支持定制化的。Kimi CLI 内核的能力不仅限于提供一个预定义好的 agent。像诞生第一天那样,Kimi CLI 支持通过 agent 文件定制 system prompt 和 tool list。同时,我们也支持了通过 MCP tools 和 skills 扩展 Kimi CLI 的能力,使每个用户可以以独特的方式使用 Kimi CLI。除了使用 `kimi` 命令,还可以把 Kimi CLI 安装为 Python 依赖,直接使用其中模块解耦良好的 agent kernel 和 UI 组件,构建上层应用程序。下一步,我们将会对 Kimi CLI 的 Wire 模式做进一步封装,形成 Kimi Agent SDK,使得用 Python、Nodejs、Go 等各种语言的用户可以更方便地构建 agent 应用。 「Lead, don't follow」是我们收到的最好的鼓励。鉴于我们更年轻,不可避免地落后于 Claude Code、OpenCode 等优秀项目,但我们绝不盲目 follow 它们。Kimi CLI 所有的想法、功能都是从零开始自然发生的,所有架构都是从零思考的。对于其中的许多部分,我们发现它与先驱产品不谋而合,比如 Wire 模式和 ACP 非常接近,Kimi Agent SDK 与 Claude Agent SDK 的架构也非常相似,但这不影响我们从第一性原理思考事情的本质。我们相信最终有一天我们可以 lead 一些事情。 ## Kimi CLI Improvement Proposal Kimi CLI 内核的大厦已经初具稳定的形状,现在我觉得是时候引入一个机制让 Kimi CLI 的开发以更 scalable 的方式进行,同时也是作为我们对下一代软件开发范式的探索。 Code is cheap,这已经是所有人的共识了。提出 pull request 现在已经没有成本,完全不需要人的思考,就可以写出几百上千行代码,可以完成功能,也能通过所有测试。但这不代表价值,无脑地堆砌 agent 的代码只会造成不可控的屎山。当代码本身变得没有价值,代码架构、可扩展性、稳定性、产品决策的重要性反而更为凸显。这其实并不是现在才应该认识到的,Linux kernel 创始人 Linus Torvalds 有句著名的说法「Bad programmers worry about the code. Good programmers worry about data structures and their relationships.」就是这意思。当我们有了良好的数据结构和关系,功能代码会自动生长出来,这时候 agent 写的代码也会是美的。 因此,KLIP 应该强调数据结构和关系的变化。未来,对于稍大的功能,Kimi CLI 的一个典型工作流程应该是: 1. 无脑给 agent 提出需求,看看会写出什么 1. 可以迭代或重写获得一个足够证明思路可行的东西 2. 与此同时,程序员思考此功能所需的「本质修改」,也就是对架构、数据类、协议、模块接口的修改 3. 程序员和 agent 共同撰写和迭代 KLIP,详细描述所有「本质修改」 1. 应尽量使用伪代码和图示,既不空中楼阁,也不追求细化到每一行代码的变化 4. 让其他人 review KLIP,根据反馈,调整 KLIP 和 feature 分支可能已经存在的原型代码 1. 要保持 KLIP 更新,始终反映「本质修改」 5. 从 KLIP,用 agent 生成具体的代码实现 1. 代码实现也可能在迭代 KLIP 的过程中就已经成熟了,这没问题 6. 用最少的精力 review 具体的代码变更,合并 这其实和过去大型软件的迭代过程非常类似,区别在于,KLIP 和代码可以同时迭代,当 KLIP 被 accept 时,代码几乎已经可用了,而不会出现(最好是不会),KLIP 想得很好,但实现出来跟想象差别很大的情况。实际上这和 Linux kernel、CPython、C++ 这类更严肃的分布式开发的超大型软件是一致的,这些软件的贡献者在提出提案时,往往已经写好了一个可以工作的原型。Agent 的辅助可以让我们更好地实践这个高标准的流程。 让我们看看会发生什么。 ================================================ FILE: klips/klip-1-kimi-cli-monorepo.md ================================================ --- Author: "@stdrc" Updated: 2025-12-29 Status: Implemented --- # KLIP-1: Move Kosong and PyKAOS to Kimi CLI Monorepo 下面是一份「可执行的操作计划」,把我们前面确定的方案全部串起来,并加入你新补充的 tag 规则(`kosong-0.20.0`;`pykaos-0.2.0`;`kimi-cli` 仍是 `0.68`/`0.68.1` 这种纯数字)。 ## 1. 确定目标目录与命名 先定死,后面所有脚本/CI 都依赖它。 1. monorepo(目标仓库)仍然叫 `kimi-cli`,且 `kimi-cli` 包仍放在仓库根目录(保持你现在的结构/习惯)。 2. `kosong` 放到 `packages/kosong`,`pykaos` 放到 `packages/kaos`(目录叫 `kaos`,但 Python 包/发行名是 `pykaos`)。 3. 三个包的 `project.name`(PyPI 包名)分别是:`kimi-cli`、`kosong`、`pykaos`。 4. tag 约定: - `kimi-cli`:`0.68` / `0.68.1`(纯数字开头) - `kosong`:`kosong-0.20.0`(无 v) - `pykaos`:`pykaos-0.2.0`(无 v) ## 2. 把 uv workspace 配好 开发时三包联动;发布时仍是三个独立包。 1. 在仓库根 `pyproject.toml`(`kimi-cli`)里开启 workspace:`tool.uv.workspace.members = ["packages/kosong", "packages/kaos"]`。 2. 在仓库根 `pyproject.toml`(`kimi-cli`)里加 `tool.uv.sources`,把依赖映射到 workspace member: - `kosong = { workspace = true }` - `pykaos = { workspace = true }` 3. `kimi-cli` 的对外依赖(`project.dependencies`)写「发布后要生效的版本范围」,例如依赖 `kosong`、`pykaos` 的范围约束(你们自己决定兼容策略,上界不要省略)。开发时 uv 会用 workspace 里的本地包覆盖同名依赖;发布时用户仍会从 PyPI 拉对应版本。 ## 3. 把 kosong、kaos 迁入 monorepo 同时保留 commit 历史但不带 tags。 对每个源仓库(`kosong`、`kaos`)都按下面流程做(在 `kimi-cli` 仓库里操作): 1. 添加 remote。 2. 禁用该 remote 的 tags 拉取:`git config remote..tagOpt --no-tags`。 3. fetch 只抓默认分支(main/master)且不抓 tags:`git fetch --no-tags `。 4. 用 `git subtree add` 把它导入到指定目录: - `kosong`:`--prefix=packages/kosong` - `kaos`:`--prefix=packages/kaos` 这样 commit 历史会进入 monorepo,但你不会把原仓库的 tags 带进来(因为你根本不抓 tags,也不推 tags)。 ## 4. 迁移后对代码做「最小必要改动」 确保三包仍能独立构建与发布。 1. 确认 `packages/kosong/pyproject.toml` 的 `[project]` 配置仍完整(name/version/dependencies 等)。 2. 确认 `packages/kaos/pyproject.toml` 的 `[project].name` 是 `pykaos`(不是 `kaos`)。目录名可以是 `kaos`,不影响发行名。 3. 如果 `kimi-cli` 里原来是通过相对路径/本地 editable 依赖来引用 `kosong`/`kaos`,把它们改为正常依赖(`kosong`、`pykaos`),并靠 `tool.uv.sources` 在本地走 workspace。 4. 在 monorepo 根做一次全量自检: - `uv sync`(或你们现在用的等价命令) - `uv run -m pytest` / `uv run kimi-cli` 的基本命令(按你们项目实际) 目标是:workspace 内能同时开发运行。 ## 5. 把 CI/Release workflow 拆成三个 让 tag 触发互斥,且只有 `kimi-cli` 创建 GitHub Release。 核心原则:只有「纯数字 tag」触发 `kimi-cli` release;只有 `kosong-` 触发 `kosong` 发布;只有 `pykaos-` 触发 `pykaos` 发布。这样用户在 `kimi-cli` 仓库的 GitHub Releases 页只会看到 `kimi-cli` 的 release。 ### 5.1 kimi-cli release workflow 保留你现有行为:tag 触发 -> build -> publish -> create GitHub Release。 - **触发**:`on push tags` 仅匹配数字开头(例如 `[0-9]*`)。 - **版本校验**:tag 本身就是版本号,必须等于根 `pyproject.toml` 的 `[project].version`,不一致直接 fail。 - **构建**:`uv build --package kimi-cli`,并且建议发布路径上用 `--no-sources` 做一次「发布语义」构建,避免 workspace sources 掩盖问题。 - **发布**:继续用你当前的 PyPI publish 方式。 - **Release**:继续用你当前的「基于 tag 创建 GitHub Release」的步骤(保持对用户体验不变)。 ### 5.2 kosong 发布 workflow 不创建 GitHub Release,只发 PyPI + 生成 docs。 - **触发**:`on push tags` 匹配 `kosong-*`。 - **版本校验**:从 tag 去掉前缀 `kosong-` 得到版本号,必须等于 `packages/kosong/pyproject.toml` 的 `[project].version`。 - **构建**:`uv build --package kosong`(注意 package 名是 `project.name`,不是目录名),输出到 `dist/kosong`。 - **发布**:只把 `dist/kosong` 下的产物发 PyPI。 - **不创建 GitHub Release**:workflow 里不要调用任何 release 创建 action。 ### 5.3 pykaos 发布 workflow 不创建 GitHub Release,只发 PyPI。 - **触发**:`on push tags` 匹配 `pykaos-*`。 - **版本校验**:从 tag 去掉前缀 `pykaos-` 得到版本号,必须等于 `packages/kaos/pyproject.toml` 的 `[project].version`。 - **构建**:`uv build --package pykaos`,输出到 `dist/pykaos`。 - **发布**:只把 `dist/pykaos` 下的产物发 PyPI。 - **不创建 GitHub Release**。 ## 6. 发版时的「tag 与版本一致」校验实现方式 建议统一为一个可复用脚本。 1. 在仓库里加一个小脚本,例如 `scripts/check_version_tag.py`: - 输入:包的 pyproject 路径 + 期望版本(由 tag 派生)。 - 逻辑:读 `tomllib` -> `project.version` -> 比较 -> 不一致 `exit 1`。 2. 三个 workflow 在 build 前都调用它: - `kimi-cli`:期望版本 = `${GITHUB_REF_NAME}`,pyproject = `./pyproject.toml` - `kosong`:期望版本 = 去掉 `kosong-`,pyproject = `packages/kosong/pyproject.toml` - `pykaos`:期望版本 = 去掉 `pykaos-`,pyproject = `packages/kaos/pyproject.toml` ## 7. kosong 文档 URL 不变的实现 关键:保留旧仓库作为 Pages 承载。 你要「搬代码但 URL 不变」,实际等价于:`MoonshotAI/kosong` 这个仓库必须继续存在并继续作为 GitHub Pages 的站点源;只是文档内容不再在那边构建,而是在 monorepo 构建后推送过去。 具体落地步骤: 1. 在旧的 `MoonshotAI/kosong` 仓库里,把它转为「承载站点的空壳仓库」: - main 分支可以只留 README(指向新 monorepo),并建议归档/锁写入,避免误提交。 2. 把该仓库的 GitHub Pages 设置为「从 gh-pages 分支发布」(Deploy from a branch)。 3. 在 monorepo 的 `release-kosong` workflow 里新增 docs 部署步骤: - 在 monorepo 里按你原 workflow 的方式生成 docs(你现在是 `uv run pdoc … -o docs`,并创建 `docs/.nojekyll`)。 - 把生成的 docs 内容推送到旧仓库 `MoonshotAI/kosong` 的 gh-pages 分支(覆盖更新)。 4. 权限:给 monorepo 配一个能写 `MoonshotAI/kosong` 的凭据: - 推荐 fine-grained PAT(仅对该仓库 `contents:write`),作为 monorepo 的 secret(例如 `KOSONG_PAGES_TOKEN`)。 这样访问 URL 仍然是原来的 URL,但文档内容来自 monorepo 的发版构建产物。 ## 8. 最终切换/上线顺序 降低风险的执行顺序。 1. 在 `kimi-cli` 仓库开迁移分支,先完成 workspace 配置与 subtree 导入,跑通本地开发与测试。 2. 把三个 release workflow 都先改成「仅在特定 tag 前缀触发」,并在 PR 环境用手工 dispatch 或临时 tag 在测试 PyPI/私有 index 验证(如果你们有的话;没有就用 dry-run 构建检查)。 3. 先发布一个 `pykaos-` 与 `kosong-` 的小版本(哪怕只是 patch),验证: - tag -> 版本校验能挡住错误 - PyPI 包发布产物正确 - kosong 文档被成功推到旧仓库且 URL 不变 4. 最后按原方式发布 `kimi-cli` 的数字 tag(`0.68.1` 之类),验证 GitHub Release 页仍只出现 `kimi-cli`。 --- 如果你希望我把「最终三个 workflow 的 YAML 骨架」直接写出来(包含:tag 解析、版本校验脚本调用、`uv build --package`、发布、以及 kosong docs 推送到旧仓库 gh-pages),我可以按你们现有的 `release.yml` 结构(你给的 `MoonshotAI/kosong` 版本)做「最小改动迁移版」,确保你们维护成本最低。 ================================================ FILE: klips/klip-10-agent-flow.md ================================================ --- Author: "@stdrc" Updated: 2026-01-20 Status: Implemented --- # KLIP-10: Agent Flow (Agent Skill 扩展) ## 背景 当前 Kimi CLI 只能通过交互式输入或 `--command` 单次输入驱动对话。希望支持一种 "agent flow",让用户用 Mermaid 或 D2 flowchart 描述流程,每个节点对应一次对话轮次, 并能根据分支节点的选择继续走向不同的下一节点。Agent Flow 作为 Agent Skill 的扩展, 通过 `SKILL.md` 中的元数据声明类型,并从流程图代码块解析得到。 示例见 `flowchart.mmd`:用 `BEGIN`/`END` 包住流程,中间节点为 prompt,分支节点用 出边 label 表示分支值。 ## 目标 - Agent Skill 支持 `type: standard | flow` 元数据(默认 standard)。 - flow 类型 skill 从 `SKILL.md` 中的第一个 Mermaid/D2 代码块解析流程。 - Flow 作为 `Skill.flow` 存储,并在 `KimiSoul` 中通过 `/flow:` 触发执行。 - standard 类型 skill 仍使用 `/skill:`,system prompt 中继续列出 name/description/path。 - 分支节点会在 user input 中补充可选分支值,要求 LLM 在回复末尾输出 `{值}`,并据此选择下一节点。 - 在同一 session/context 中持续推进,直到抵达 `END`。 ## 非目标 - 不支持完整 Mermaid/D2 语法,仅支持各自的最小子集。 - 不引入新的 UI(依旧使用 shell UI 输出)。 - 不处理子图、样式、链接、点击事件等 Mermaid 特性。 ## 设计概览 ### 1) Mermaid flowchart 最小子集 仅支持以下语法(足够覆盖示例): - Header:`flowchart TD` / `flowchart LR` / `graph TD`(其余方向忽略)。 - 注释行:`%% ...`。 - 节点:`ID[文本]` / `ID([文本])` / `ID{文本}`(形状仅用于携带 label,语义上忽略)。 - 节点内容支持引号包裹:`ID["含特殊字符的文本"]`,引号内可包含 `]`、`}`、`|` 等。 - 边:`A --> B`、`A -->|label| B`、`A -- label --> B`。 - 允许边上内联节点定义:`A([BEGIN]) --> B[...]`。 其他样式与布局相关语法(如 `classDef`/`style`/`linkStyle`/`subgraph`)会被忽略,不报错。 ### 2) D2 flowchart 最小子集 支持以下语法(足够覆盖示例): - 注释行:`# ...`。 - 节点:`ID: label`(label 省略时使用 ID)。 - 边:`A -> B`、`A -> B: label`,允许链式 `A -> B -> C`(label 仅作用于最后一段)。 - 节点 ID:字母数字或 `_` 开头,允许 `.` `/` `-`。 忽略:属性路径(如 `foo.bar`)与 `{ ... }` 块。 ### 3) 图结构与校验 数据结构(位于 `src/kimi_cli/skill/flow/__init__.py`,`PromptFlow` 更名为 `Flow`): ```python FlowNodeKind = Literal["begin", "end", "task", "decision"] @dataclass(frozen=True, slots=True) class FlowNode: id: str label: str | list[ContentPart] # 支持富文本内容 kind: FlowNodeKind @dataclass(frozen=True, slots=True) class FlowEdge: src: str dst: str label: str | None @dataclass(slots=True) class Flow: nodes: dict[str, FlowNode] outgoing: dict[str, list[FlowEdge]] begin_id: str end_id: str ``` 异常层次结构: ```python class FlowError(ValueError): """Base error for flow parsing/validation.""" class FlowParseError(FlowError): """Raised when flowchart parsing fails.""" class FlowValidationError(FlowError): """Raised when a flowchart fails validation.""" ``` 校验规则: - `BEGIN`/`END` 通过节点文本(label)匹配,大小写不敏感。 - 必须且只能有一个 `BEGIN`、一个 `END`。 - `BEGIN` 能连通到 `END`。 - 如果某节点有多个出边,则每条边必须有非空 label,且 label 不能重复。 - 单出边节点允许 label 缺失或为空(label 会被忽略)。 - 未显式声明的节点允许隐式创建(label 默认使用节点 ID),以保持常见用法。 ### 4) Agent Flow 发现与加载 Agent Flow 与 Agent Skill 复用同一套 discovery 逻辑,目录来源保持不变: - 内置技能:`src/kimi_cli/skills/` - 用户技能:`~/.config/agents/skills`(含历史兼容路径) - 项目技能:`/.agents/skills`(含历史兼容路径) skill 元数据: - `type: standard | flow`,默认 `standard`。 - flow skill 会在 `SKILL.md` 中查找第一个 `mermaid` 或 `d2` fenced codeblock, 并解析为 `Flow` 存入 `Skill.flow`。 - 未找到有效流程图或解析失败时,记录日志并将其作为普通 skill 处理。 ### 5) FlowRunner 与 KimiSoul 扩展 提取独立的 `FlowRunner` 类处理 flow 执行逻辑,`KimiSoul` 通过持有 `_flow_runners` 来支持 agent flow。同时重构 slash command 机制,将 skill commands 也改为实例级别 (不再全局注册)。 **FlowRunner 类**(位于 `src/kimi_cli/soul/kimisoul.py`): ```python class FlowRunner: def __init__( self, flow: Flow, *, name: str | None = None, max_moves: int = DEFAULT_MAX_FLOW_MOVES, ) -> None: self._flow = flow self._name = name self._max_moves = max_moves async def run(self, soul: KimiSoul, args: str) -> None: """执行 flow 遍历,通过 /flow: 触发。""" ... async def _execute_flow_node( self, soul: KimiSoul, node: FlowNode, edges: list[FlowEdge], ) -> tuple[str | None, int]: """执行单个节点,返回 (下一节点 ID, 使用的步数)。""" ... @staticmethod def _build_flow_prompt(node: FlowNode, edges: list[FlowEdge]) -> str | list[ContentPart]: """构建节点 prompt,多出边节点会附加选择指引。""" ... @staticmethod def _match_flow_edge(edges: list[FlowEdge], choice: str | None) -> str | None: """根据 choice 匹配出边。""" ... @staticmethod def ralph_loop( user_message: Message, max_ralph_iterations: int, ) -> FlowRunner: """创建 Ralph 模式的循环流程。""" ... ``` **修改 KimiSoul**: ```python class KimiSoul: def __init__( self, agent: Agent, *, context: Context, ): # ... 现有初始化 ... # 在 init 时构造 slash commands,避免每次 run 重复构造 self._slash_commands = self._build_slash_commands() self._slash_command_map = self._index_slash_commands(self._slash_commands) def _build_slash_commands(self) -> list[SlashCommand[Any]]: commands: list[SlashCommand[Any]] = list(soul_slash_registry.list_commands()) # 实例级别:skill commands(standard) for skill in self._runtime.skills.values(): if skill.type != "standard": continue commands.append(SlashCommand( name=f"skill:{skill.name}", func=self._make_skill_runner(skill), description=skill.description or "", aliases=[], )) # 实例级别:/flow:(flow skills) for skill in self._runtime.skills.values(): if skill.type != "flow" or skill.flow is None: continue runner = FlowRunner(skill.flow, name=skill.name) commands.append(SlashCommand( name=f"flow:{skill.name}", func=runner.run, description=f"Start the agent flow '{skill.name}'", aliases=[], )) return commands def _find_slash_command(self, name: str) -> SlashCommand[Any] | None: return self._slash_command_map.get(name) @property def available_slash_commands(self) -> list[SlashCommand[Any]]: return self._slash_commands ``` 运行规则: - `KimiSoul` 根据 `Skill.type` 生成 `/skill:` 或 `/flow:`。 - `available_slash_commands` 统一返回:静态命令 + skill commands + flow commands。 - `run` 方法查找实例命令(而非静态 registry),支持动态命令。 - `/flow:` 触发 `FlowRunner.run` 执行 flow 遍历。 - 节点是否需要选择由出边数量决定(多出边即分支)。 分支节点的 prompt 组装(示意): ``` {node.label} Available branches: - 是 - 否 Reply with a choice using .... ``` 选择解析: - 从本次 run 后新增的最后一条 assistant message 读取文本。 - 使用正则 `r"([^<]*)"` 抽取**最后一个** choice 标签的值,trim 后精确匹配出边 label。 - 不强制 choice 在末尾,因为 LLM 可能在 choice 后追加解释文字。 - 使用 `[^<]*` 而非 `.*?` 避免跨标签匹配。 - 若缺失或无匹配:自动重试(追加"必须按格式输出"的提示)。 为防止死循环,内置 `max_moves`(默认 1000)作为硬上限;到达上限则抛出 `MaxStepsReached`。 ### 6) Ralph 模式 Ralph 模式是一种特殊的自动迭代模式,通过 `--max-ralph-iterations` 参数启用。 它会自动将用户输入包装成一个带 CONTINUE/STOP 分支的循环流程: ```python @staticmethod def ralph_loop( user_message: Message, max_ralph_iterations: int, ) -> FlowRunner: """ 创建 Ralph 模式的循环流程: BEGIN → R1(执行用户 prompt) → R2(决策节点) → CONTINUE(回到 R2) / STOP → END """ ... ``` 在 `KimiSoul.run` 中,如果启用了 Ralph 模式,会自动创建 Ralph 循环流程: ```python if self._loop_control.max_ralph_iterations != 0: runner = FlowRunner.ralph_loop( user_message, self._loop_control.max_ralph_iterations, ) await runner.run(self, "") return ``` ### 7) CLI 集成 Agent Flow 通过 skill discovery 自动加载,不新增 CLI 参数。只要 `SKILL.md` 中声明 `type: flow` 并包含流程图代码块,即可通过 `/flow:` 使用。 ### 8) 错误处理与用户反馈 - 解析错误:通过 `FlowParseError` 指出 Mermaid/D2 语法问题(包含行号)。 - 校验错误:通过 `FlowValidationError` 指出图结构问题。 - flow skill 无有效流程图:记录日志并降级为普通 skill。 - 运行时错误:日志记录当前节点、分支选择失败原因。 - choice 无效:自动重试,追加提示要求按格式输出。 - 输出日志:`logger.info`/`logger.warning` 记录节点推进与选择结果,便于调试。 ## 兼容性与边界 - 仅支持 flowchart,且只解析上述最小子集。 - `BEGIN`/`END` 只通过 label 识别;如果用户用其它词,需要显式改名。 - 允许循环图;但会受到 `max_moves` 限制。 - flow 名称与 skill 名称一致。 - 分支 label 要求短且稳定;建议避免多行或包含特殊字符。 - `FlowNode.label` 支持 `str | list[ContentPart]`,可用于 Ralph 模式等内部场景。 ## 关键参考位置 - CLI 入口:`src/kimi_cli/cli/__init__.py` - Skill 解析:`src/kimi_cli/skill/__init__.py` - Flow 解析:`src/kimi_cli/skill/flow/mermaid.py` / `src/kimi_cli/skill/flow/d2.py` - Flow 数据结构:`src/kimi_cli/skill/flow/__init__.py` - `KimiSoul` 与 `FlowRunner`:`src/kimi_cli/soul/kimisoul.py` - `SlashCommand`:`src/kimi_cli/utils/slashcmd.py` - 静态 soul commands:`src/kimi_cli/soul/slash.py` - Shell UI:`src/kimi_cli/ui/shell/__init__.py` - Mermaid 示例:`flowchart.mmd` ================================================ FILE: klips/klip-11-kimi-code-rename.md ================================================ --- Author: "@stdrc" Updated: 2026-01-26 Status: Implemented --- # KLIP-11: Rebrand Kimi CLI -> Kimi Code CLI (Docs + UI Copy) ## 背景 - 项目仓库与 PyPI 主包仍为 `kimi-cli`,Python 导入路径为 `kimi_cli`。 - 已存在 `kimi-code` 包作为薄包装以保留名称,但不计划切换主包名。 - 当前需求是最小化改动:仅更新用户可见文案与模型提示词中的品牌为「Kimi Code CLI」。 ## 目标 - 用户文档与 README 统一品牌为 **Kimi Code CLI**。 - Shell UI 与 ACP/Wire 相关的用户可见文案统一品牌为 **Kimi Code CLI**。 - 默认 system prompt 与内置技能提示词统一品牌为 **Kimi Code CLI**。 - 保持命令与包名不变:`kimi` 命令、`kimi-cli` 包、`kimi_cli` 导入路径继续使用。 - `kimi-code` 继续维护以防名称被占用,但不作为主安装路径。 ## 非目标/约束 - 不更改包名/导入路径/命令名。 - 不更改 User-Agent、更新 URL、二进制路径。 - 不更改仓库名、文档站点 URL、构建/发布流程。 - 不改历史变更记录中的事实表述(如旧包名迁移说明)。 ## 仓库扫描(用户可见文案) 需要改名的文档主要集中在以下位置(均含大量 `Kimi CLI` 文案): - **顶层文档**:`README.md`, `CONTRIBUTING.md`, `CHANGELOG.md` - **文档站点配置/入口**:`docs/.vitepress/config.ts`, `docs/index.md`, `docs/en/index.md`, `docs/zh/index.md`, `docs/package.json` - **文档内容**:`docs/en/**`, `docs/zh/**`, `docs/AGENTS.md` - **示例说明**:`examples/*/README.md` - **Shell UI**:`src/kimi_cli/ui/shell/*` - **运行时品牌名称**:`src/kimi_cli/constant.py`, `src/kimi_cli/acp/server.py`, `src/kimi_cli/cli/__init__.py`, `src/kimi_cli/wire/server.py` - **系统提示词**:`src/kimi_cli/agents/default/system.md` - **内置技能提示词**:`src/kimi_cli/skills/*/SKILL.md` - **测试快照**:`tests/core/test_default_agent.py` ## 文案规则(避免误导) - 正文、标题用 **Kimi Code CLI**。 - 命令/包名保持现状:`kimi`, `kimi-cli`, `zsh-kimi-cli` 等不要改。 - 与实际输出绑定的字段名/命令保持不变,例如 `kimi_cli_version`、`uv tool upgrade kimi-cli`。 - 历史说明保留真实名称(如 “rename package name `ensoul` to `kimi-cli`”)。 ## 已完成 - README 与文档站点入口统一品牌为 Kimi Code CLI(保留 `kimi-cli` 的 repo/徽章/链接)。 - 文档正文(`docs/en/**`, `docs/zh/**`, `docs/AGENTS.md`)与示例 README 完成文案替换, 代码块/输出示例中保留实际命令与字段名。 - Shell UI 文案与 ACP/Wire 可见文案完成替换(欢迎语、提示语、setup、更新提示)。 - 默认 system prompt 与内置 skills 提示词完成替换,避免模型沿用旧品牌回复。 - 相关测试快照同步更新(默认 agent prompt)。 - 站点同步脚本与项目级 AGENTS 文案同步更新。 - `packages/kimi-code/` 作为薄包装包已存在,随 `kimi-cli` 版本发布。 ## 已确认 - 文档站点 URL 继续保留 `moonshotai.github.io/kimi-cli`。 - 用户文档不提 `kimi-code` 包名,仅在内部维护该占位包。 ================================================ FILE: klips/klip-12-wire-initialize-external-tools.md ================================================ --- Author: "@stdrc" Updated: 2026-01-14 Status: Implemented --- # KLIP-12: Wire 初始化协商与外部工具调用 ## Summary 为 Wire 模式引入 client-to-server 的 `initialize` 握手,支持 client 提交 `external_tools` 定义、server 回传 soul-level `slash_commands` 列表,并扩展 `request` 方法以承载 `ToolCallRequest`(外部工具调用请求)。新增 `ApprovalResponse` 类型,与 `ToolResult` 对称,统一 `request` 的响应语义。 ## 背景与动机 当前 Wire 协议(`docs/zh/customization/wire-mode.md` + `src/kimi_cli/wire/*`, `src/kimi_cli/ui/wire/*`)只包含: - `prompt`/`cancel`(client -> server) - `event`/`request`(server -> client;`request` 仅用于审批) 缺口: - 缺少初始化协商:client 无法在会话开始时提交能力与扩展信息。 - 外部工具无法接入:client 自带的工具(例如 IDE 内部工具)不能注册给模型使用。 - Slash commands 无法被外部 UI 感知:client 只能硬编码或忽略,无法展示/补全。 - `request` 返回结构不统一:审批返回是一个特化结构,无法复用给 tool 请求。 因此需要一个结构化的初始化协商和对称的 request/response 模型。 ## 目标 - 新增 `initialize` 请求,支持 client 提供 `external_tools`,server 返回 soul-level `slash_commands`。 - 将 server -> client 的 tool 调用请求标准化为 `request` 方法,params 为 `ToolCallRequest`。 - 引入 `ApprovalResponse` 类型(必要时重命名现有 Response literal),让 `request` 的返回类型统一为 `ApprovalResponse | ToolResult`。 - 保持向后兼容:旧 client 仍可直接 `prompt`。 ## 非目标 - 不改变 `ToolCall`/`ToolResult` 的核心结构。 - 不引入新的传输通道(仍为 JSON-RPC over stdio)。 - 不讨论外部工具的权限或安全策略(由 client 自行处理)。 ## 设计概览 ### 1) `initialize` 握手 新增 client -> server 的 JSON-RPC 请求 `initialize`。它是可选但推荐的握手: - client 提交 `external_tools`、`protocol_version`。 - server 返回协商后的 `protocol_version`、`slash_commands`(仅 soul-level)等。 若 client 不发送 `initialize`,服务端行为保持现状:不注册 external tools,也不推送 slash command 列表。 ### 2) ExternalToolCall 请求 扩展 `request` 方法语义: - 现状:`request` 仅携带 `ApprovalRequest`,响应为审批结果。 - 目标:`request` 可携带 `ApprovalRequest | ToolCallRequest`。 - `ApprovalRequest` 表示审批。 - `ToolCallRequest` 表示 ExternalToolCall(server 请求 client 执行外部工具)。 响应类型统一为:`ApprovalResponse | ToolResult`。 ### 3) ApprovalResponse 类型 将审批响应抽象为 `ApprovalResponse`,与 `ToolResult` 对称: - `ApprovalResponse` 对应 `ApprovalRequest`。 - `ToolResult` 对应 `ToolCall`/`ToolCallRequest`。 如果需要消除命名冲突,现有 `Response` literal 可改名为 `ApprovalResponseKind`。 ## 协议变更细节 ### `initialize` 请求 #### Request ```json { "jsonrpc": "2.0", "method": "initialize", "id": "init-1", "params": { "protocol_version": "1.1", "client": {"name": "my-ui", "version": "0.3.0"}, "external_tools": [ { "name": "open_in_ide", "description": "Open file in IDE", "parameters": { "type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"] } } ] } } ``` #### Types (TS 风格) ```ts interface InitializeParams { protocol_version: string client?: { name: string; version?: string } external_tools?: ExternalTool[] } interface ExternalTool { name: string description: string parameters: object // JSON Schema } ``` ### `initialize` 响应 ```json { "jsonrpc": "2.0", "id": "init-1", "result": { "protocol_version": "1.1", "server": {"name": "kimi-cli", "version": "0.68.0"}, "slash_commands": [ {"name": "init", "description": "Analyze the codebase ...", "aliases": []}, {"name": "compact", "description": "Compact the context", "aliases": []} ], "external_tools": { "accepted": ["open_in_ide"], "rejected": [{"name": "shell", "reason": "conflicts with builtin tool"}] } } } ``` #### Types ```ts interface InitializeResult { protocol_version: string server: { name: string; version: string } slash_commands: SlashCommand[] external_tools?: { accepted: string[] rejected: { name: string; reason: string }[] } } interface SlashCommand { name: string description: string aliases: string[] } ``` 备注: - `slash_commands` 仅包含 soul-level 命令: `src/kimi_cli/soul/slash.py` registry + 动态 skills(`KimiSoul._register_skill_commands`)。 - `external_tools` 的接受/拒绝结果可选,用于反馈命名冲突或 schema 校验失败。 ## ExternalToolCall 与 ApprovalResponse ### Wire 请求类型扩展 ```ts type Request = ApprovalRequest | ToolCallRequest // ToolCallRequest 在 request 语境下即 ExternalToolCall interface ToolCallRequest { id: string name: string arguments?: string | null // JSON string } ``` ### 请求响应类型 ```ts type RequestResult = ApprovalResponse | ToolResult interface ApprovalResponse { request_id: string response: ApprovalResponseKind } type ApprovalResponseKind = "approve" | "approve_for_session" | "reject" ``` ### ExternalToolCall 示例 Server -> Client: ```json {"jsonrpc":"2.0","method":"request","id":"tc-1","params":{ "type":"ToolCallRequest", "payload":{"id":"tc-1","name":"open_in_ide","arguments":"{\"path\":\"README.md\"}"} }} ``` Client -> Server: ```json {"jsonrpc":"2.0","id":"tc-1","result":{ "tool_call_id":"tc-1", "return_value":{ "is_error":false, "output":"Opened", "message":"Opened README.md", "display":[] } }} ``` ### ApprovalRequest 示例(保持兼容) Server -> Client: ```json {"jsonrpc":"2.0","method":"request","id":"req-1","params":{ "type":"ApprovalRequest", "payload":{"id":"req-1","tool_call_id":"tc-9","sender":"Shell","action":"run shell", "description":"Run command `ls`","display":[]} }} ``` Client -> Server: ```json {"jsonrpc":"2.0","id":"req-1","result":{ "request_id":"req-1", "response":"approve" }} ``` ## Server 侧行为 ### 初始化协商 - `WireOverStdio` 新增 `_handle_initialize`: - 解析 `external_tools`。 - 将外部工具注册到 `KimiToolset`(新增 `WireExternalTool`)。 - 若同名外部工具已存在,则按最新 schema/描述覆盖更新。 - 采集 `KimiSoul.available_slash_commands` 生成 `slash_commands`。 - 返回协商结果。 ### 外部工具执行 - `WireExternalTool` 以工具代理的形式加入 toolset。 - 当模型触发该工具: - server 通过 Wire `request` 发送 `ToolCallRequest` 给 client。 - 等待 client 返回 `ToolResult`。 - 将 `ToolResult.return_value` 作为 tool 执行结果回传给模型。 ### 事件流 - `ToolCall` 和 `ToolResult` 仍可作为 `event` 对 UI 可视化输出。 - External tool 的执行结果同时参与 `event` 流与 `request` 响应,可用于录像/回放。 ## Client 侧变化 ### 启动流程 1. 建立 stdio 连接。 2. 发送 `initialize`: - 提交 `external_tools`。 - 可携带 client 名称与版本。 3. 接收 `slash_commands`: - 用于 UI 展示与自动补全。 4. 进入交互阶段(`prompt`/`cancel`)。 ### `request` 处理逻辑 收到 `request` 时根据 params 类型分派: - `ApprovalRequest` -> 弹出审批 UI -> 返回 `ApprovalResponse`。 - `ToolCallRequest` -> 执行 external tool -> 返回 `ToolResult`。 对未知类型返回 JSON-RPC error 并记录日志。 ## 兼容性与降级策略 - 旧 client:不发 `initialize`,协议维持 v1.0 行为。 - 新 client + 旧 server:`initialize` 可能返回 JSON-RPC method not found(-32601), client 应自动降级并继续使用 v1.0。 - 若 `external_tools` 校验失败或重名,server 在 `initialize` result 中标记为 rejected, 并忽略该工具。 - 旧类型名 `ApprovalRequestResolved` 在反序列化时仍可被识别。 ## 实施步骤(建议) 1. 协议与类型层 - `src/kimi_cli/wire/types.py`: - `Request = ApprovalRequest | ToolCallRequest`。 - 新增 `ApprovalResponse`(保留旧 `ApprovalRequestResolved` 类型名兼容)。 - `src/kimi_cli/wire/serde.py` 无需改动(由 Envelope 支持新类型)。 2. JSON-RPC 层 - `src/kimi_cli/ui/wire/jsonrpc.py`: - 添加 `JSONRPCInitializeMessage`。 - `JSONRPCInMessage`/`OutMessage` 增加 `initialize`。 3. Wire 服务端 - `src/kimi_cli/ui/wire/__init__.py`: - 实现 `_handle_initialize`。 - 增强 `_pending_requests` 以支持 `ToolCallRequest`。 4. 工具层 - `src/kimi_cli/soul/toolset.py`: - 新增 `WireExternalTool`,内部通过 Wire 请求执行。 5. 协议版本与文档 - `src/kimi_cli/ui/wire/protocol.py` 提升协议版本。 - 更新 `docs/zh/customization/wire-mode.md` 并新增 external tools 章节。 ## 最终效果与用法 - external tools 成为 Wire session 可协商的能力,client 可以把自己的工具直接暴露给模型。 - 外部 UI 可以动态展示 soul-level slash commands,不再硬编码。 - `request` 方法在语义与类型上统一(审批与外部工具调用共用一套请求框架)。 - 旧 client 无需修改即可继续工作。 ## 关键参考位置 - Wire 协议与类型:`src/kimi_cli/wire/types.py`, `src/kimi_cli/wire/serde.py` - Wire JSON-RPC:`src/kimi_cli/ui/wire/jsonrpc.py`, `src/kimi_cli/ui/wire/__init__.py` - Slash commands:`src/kimi_cli/soul/slash.py`, `src/kimi_cli/utils/slashcmd.py` - Wire 文档:`docs/zh/customization/wire-mode.md` ================================================ FILE: klips/klip-14-kimi-code-oauth-login.md ================================================ --- Author: "@stdrc" Updated: 2026-01-24 Status: Implemented --- # KLIP-14: Kimi Code OAuth /login ## 背景与现状 * `/setup` 位于 `src/kimi_cli/ui/shell/setup.py`:选择平台 -> 输入 API key -> 拉取模型 -> 写入 `config.providers` / `config.models` / `default_model`,并在 Kimi Code 平台时自动配置 `services.moonshot_search` / `services.moonshot_fetch`。 * Kimi Code 平台在 `src/kimi_cli/auth/platforms.py` 中定义,`base_url` 为 `https://api.kimi.com/coding/v1`。 * 现有配置以 API key 作为 `Authorization: Bearer `,`/usage` 也依赖该 Bearer。 ## 目标 * 为 Kimi Code 平台提供基于 OAuth 的 `/login` 斜杠命令,替代手动 API key 输入。 * 提供 `/logout` 与 `kimi logout`,清理 OAuth 凭据并撤销本地授权状态。 * OAuth 流程基于 Device Authorization Grant(后端现有实现),CLI 轮询 token endpoint 获取 access_token;如后续支持,可扩展为 Authorization Code + PKCE。 * 登录成功后与 `/setup` 一致:拉取模型、写入托管 provider/model、设置默认模型和 search/fetch 服务。 * Token 可自动刷新,过期后尽量无感恢复。 ## 非目标 * 不支持 Moonshot Open Platform 等其他平台。 * 不替代 `/setup` 或移除 API key 方案。 * 不实现完整账户管理或多账号切换。 ## 设计概览 ### 1) Kimi Code OAuth 端点与要求(Device Authorization Grant) 后端当前提供 Device Authorization Grant(RFC 8628),CLI 需要对接实际端点: * OAuth host(可配置): * 默认:`https://auth.kimi.com` * 可用环境变量覆盖:`KIMI_CODE_OAUTH_HOST` 或 `KIMI_OAUTH_HOST` * Public client: * `client_id`: `17e5f671-d194-4dfb-9706-5516cb48c098` * 不需要 client secret * 端点: * `POST /api/oauth/device_authorization` * `POST /api/oauth/token`(device_code + refresh_token) * Scope(若后端要求): * 当前实现仅发送 `client_id`,未携带 scope * 典型返回字段: * `user_code` / `device_code` * `verification_uri` / `verification_uri_complete` * `expires_in` / `interval` **请求头(真实后端要求)** 所有 token 相关请求需要附带设备信息头(示例值按实际环境生成): ```python from kimi_cli.constant import VERSION import platform import socket COMMON_HEADERS = { "X-Msh-Platform": "kimi_cli", "X-Msh-Version": VERSION, "X-Msh-Device-Name": platform.node() or socket.gethostname(), "X-Msh-Device-Model": "", "X-Msh-Os-Version": platform.version(), "X-Msh-Device-Id": "", } ``` * `X-Msh-Platform` 固定为 `kimi_cli`。 * `X-Msh-Version` 使用 `kimi_cli.constant.VERSION`(实际版本号)。 * `X-Msh-Device-Name` 使用设备名(`platform.node()` / `socket.gethostname()`)。 * `X-Msh-Device-Model` 使用系统名 + 版本号 + 架构(如 `Windows 11 AMD64`、 `macOS 15.1.1 arm64`)。 * `X-Msh-Os-Version` 使用 `platform.version()`(与 `Environment.os_version` 一致)。 * `X-Msh-Device-Id` 为稳定 UUID,首次生成后持久化,建议存放于 `~/.kimi/device_id` 并设置权限 `0600`。 ### 2) /login UX 流程 1. `/login` 与 `kimi login` 仅支持 Kimi Code 平台;若不是默认 config location 则直接拒绝。 2. `POST /api/oauth/device_authorization` 获取 `verification_uri_complete` 与 `user_code`。 3. 直接 `webbrowser.open(verification_uri_complete)`,同时打印 Verification URL (`verification_uri_complete` 通常已包含 user_code)。 4. 按 `interval` 轮询 `POST /api/oauth/token`, `grant_type=urn:ietf:params:oauth:grant-type:device_code`。 * 仅特判 `expired_token` -> 重新发起 `/login` * 其他错误 -> 继续按 interval 等待(不特殊处理 `slow_down`) 5. 交换成功 -> 保存 tokens,拉取模型,写入托管 provider/model,设置默认模型和 search/fetch 服务(流程同 `/setup`),access_token 同时用于 LLM/search/fetch。 6. Shell `/login` 成功后触发 `Reload`;`kimi login` 仅执行登录流程并退出。 ### 3) 用户授权提示 CLI 提示用户打开浏览器并输入 user code,不再需要本地回调或手动拷贝 code: ``` Please visit the following URL and enter the user code to authorize: Verification URL: {verification_uri_complete} ``` 注意:`ApproveDeviceGrant` 是 Web 侧的审批接口,仅用于测试,CLI 不应调用。 ### 4) /logout UX 流程 1. `/logout` 与 `kimi logout` 仅支持 Kimi Code 平台;若不是默认 config location 则直接拒绝。 2. 清理凭据存储: * keychain:删除 `service=kimi-code` + `key=oauth/kimi-code` * 文件:删除 `~/.kimi/credentials/kimi-code.json` 3. 更新 `config.toml`(仅默认位置): * 删除 `providers."managed:kimi-code"` 整体配置 * 删除 `models` 中所有 `provider = "managed:kimi-code"` 的条目 * 若 `default_model` 指向被删除的模型,则清空 `default_model` * `services.moonshot_search = None` * `services.moonshot_fetch = None` 4. Shell `/logout` 成功后触发 `Reload`;`kimi logout` 仅执行退出流程并退出。 ### 5) Token 与凭据存储(最佳实践) 优先使用系统凭据存储,避免将 access_token / refresh_token 明文落盘: * 首选:OS keychain(`keyring`) * service: `kimi-code` * key: `oauth/kimi-code` * value: JSON(access_token、refresh_token、expires_at、scope、token_type) * 兜底:`~/.kimi/credentials/kimi-code.json`,权限 `0600` `config.toml` 仅保存非敏感元信息与引用,不直接写入 token。`expires_at` 与 `scope` 也放在 凭据存储中以避免重复更新。provider 与 services 都使用同一套 oauth 引用,运行时通过 `runtime.oauth` 读取 access_token 并注入调用路径(内存态),不支持退化为写入 `config.toml`: ```toml [providers."managed:kimi-code"] type = "kimi" base_url = "https://api.kimi.com/coding/v1" api_key = "" oauth = { storage = "keyring", key = "oauth/kimi-code" } # keyring 不可用时为 file [services.moonshot_search] base_url = "https://api.kimi.com/coding/v1/search" api_key = "" oauth = { storage = "keyring", key = "oauth/kimi-code" } # keyring 不可用时为 file [services.moonshot_fetch] base_url = "https://api.kimi.com/coding/v1/fetch" api_key = "" oauth = { storage = "keyring", key = "oauth/kimi-code" } # keyring 不可用时为 file ``` `api_key` 为空字符串仅作为占位,运行时注入 access_token。 若 keychain 不可用,使用 `~/.kimi/credentials/kimi-code.json`;不允许写入 `config.toml`。 ### 6) Token 刷新策略 * 每次用户 prompt 触发时,在后台读取凭据存储中的 `expires_at` 并尽量刷新: * 若已过期则强制刷新;若剩余时间 < 5 分钟则后台刷新 * 挂载点:`KimiSoul.run(...)` 开始时触发 `ensure_fresh` * 刷新流程(带上上面的设备信息 headers): * `grant_type=refresh_token` * `refresh_token`, `client_id` * 刷新成功: * 更新凭据存储中的 access_token / refresh_token / expires_at * 更新内存中的 `api_key`(仅对 `Kimi` provider 生效) * 刷新失败: * 仅记录日志警告,不触发 UI 提示或 `Reload` ### 7) LLM 与工具的热更新策略 * 目标:刷新 token 后不打断用户输入与对话。 * LLM 热更新: * 当前实现直接更新 `Kimi` chat provider 的 `client.api_key`,不触发重建或 Reload。 * 搜索/抓取: * `SearchWeb` / `FetchURL` 每次调用从 `runtime.oauth.resolve_api_key(...)` 获取 token, 不缓存 api_key,刷新后立即生效。 ### 8) 与 /setup 的关系 * `/setup` 仍保留 API key 交互,OAuth 仅通过 `/login`。 * `/login` 使用与 `/setup` 相同的托管命名空间: * provider key: `managed:kimi-code` * model key: `kimi-code/` * 可选:未来在 `/setup` 中提供 “Login with browser (OAuth)” 入口,但非本次目标。 ## 边界与兼容性 * 如果用户使用 `--config` / `--config-file`,直接拒绝 `/login`(避免凭据落在非默认路径)。 * 只要平台提供 `search_url` / `fetch_url` 就会写入 `services` 配置。 * OAuth 模型和 API 兼容性与当前 Bearer key 完全一致。 ## 待确认事项 * Device Authorization 是否强制要求 `scope`,以及 scope 的最终命名(当前实现未发送)。 ## 关键参考位置 * `/setup` 入口:`src/kimi_cli/ui/shell/setup.py` * 平台定义:`src/kimi_cli/auth/platforms.py` * 配置结构:`src/kimi_cli/config.py` * Kimi provider:`packages/kosong/src/kosong/chat_provider/kimi.py` ================================================ FILE: klips/klip-15-kagent-sidecar-integration.md ================================================ --- Author: "@stdrc" Updated: 2026-01-26 Status: Draft --- # KLIP-15: kagent Rust kernel 以 sidecar 方式接入 kimi-cli ## 背景与现状 * Python 版 kimi-cli 的 Agent kernel 由 `KimiSoul` 驱动(`src/kimi_cli/soul/kimisoul.py`)。 * UI(shell/print)与 ACP server 通过 Wire 事件与 kernel 交互。 * Wire 协议已稳定,详见 `docs/zh/customization/wire-mode.md`(JSON-RPC 2.0 + stdio)。 * Rust 版 kagent 已实现相同协议与核心逻辑,目标是替换 Python kernel,但**保留 Python UI/ACP**。 ## 目标 * 在 **不删除** Python kernel 的前提下,引入 Rust kagent 作为默认或可选 kernel。 * Python 侧仍负责:UI(shell/print)、ACP server、配置/会话/技能发现。 * kernel 实现通过 **stdio wire 协议** 与 Python 通讯,保持与现有外部 Wire 客户端一致。 * 支持 **fallback**:Rust kernel 启动失败或运行异常时回退到 Python kernel。 * 打包/发布流程支持多平台(Linux/macOS/Windows,含 Linux ARM),并能在 wheel 中携带 kagent 二进制。 ## 非目标 * 不把 Rust kernel 直接嵌入 Python 进程(不做 Pyo3 绑定)。 * 不移除 Python kernel 代码;仅在运行时切换。 * 不修改 wire 协议。 ## 方案概览(sidecar + stdio wire) 将 Rust kagent 视为 **实现 wire 协议的外部 server**,Python 通过一个 `WireBackedSoul`(代理 Soul)启动子进程并转发消息: ``` Python UI/ACP <-> Python Wire <-> WireBackedSoul <-> stdio <-> kagent ``` * Python UI/ACP 仍只感知本地 `Wire`,无需改动。 * `WireBackedSoul` 实现 Soul 接口(`run()`/`status`/`available_slash_commands`),用 Rust 进程替代 `KimiSoul` 执行。 * 所有 Approval/ToolCall/StatusUpdate 事件由 Rust 发送,Python 仅做**消息转发与本地 UI 适配**。 ## 详细设计 ### 1) 新增 WireBackedSoul **职责** * 启动/管理 Rust kagent 进程(`kagent --wire`)。 * 通过 stdio 与 Rust kernel 进行 JSON-RPC 交互。 * 将 Rust 发来的 `event` 透传为 `wire_send(...)`(送到 Python UI/ACP)。 * 将 Rust 发来的 `request`(Approval/ToolCall)映射为本地 Wire 请求,收集 UI 响应,再回写给 Rust。 **最小行为** * `initialize`:可选握手,获取 slash commands / server info。 * `prompt`:触发一轮执行,Rust 侧持续发送事件与请求,直到返回 `PromptResult`。 * `cancel`:转发取消请求给 Rust。 **对象模型** * `WireBackedSoul` 持有: - `process`(subprocess handle) - `client`(wire client,负责 JSON-RPC 的 request/response) - `status`(来自 StatusUpdate 事件) - `slash_commands`(来自 initialize result) ### 2) Approval/ToolCall 转发 Rust -> Python: * Rust 通过 wire `request` 发送 `ApprovalRequest` / `ToolCallRequest`。 * Python 侧创建本地 `ApprovalRequest`/`ToolCallRequest` 对象,`wire_send` 到 UI。 * UI resolve 后,Python 将结果作为 JSON-RPC response 回写给 Rust。 关键点:不复制/重建 Rust-side pending future,而是**用本地 wire 作为交互表面**,保证 UI 行为与现有一致。 ### 3) 进程生命周期与容错 * 启动:`kagent --wire`(必要时附加 `--config` 或环境变量) * 退出: - 正常:Rust 自行退出;Python 处理 EOF 并结束 run - 异常:Python 检测 stderr/exit code,回退到 Python kernel 或报错 * 取消:调用 wire `cancel` 请求 * 失败回退:可配置 `kernel = rust` 或 `kernel = python`;当 rust 失败时自动 fallback ### 4) 运行时选择与配置 建议增加运行时切换方式(优先级从高到低): 1. CLI flag:`--kernel rust|python`(默认可为 `rust`) 2. 环境变量:`KIMI_KERNEL=rust|python` 3. 配置文件:`[runtime] kernel = "rust"` 在 `KimiCLI.create` 中选择 `KimiSoul` 或 `WireBackedSoul`。 ### 5) 打包与分发(maturin) **策略**:maturin 构建 Python package + 打包 sidecar 二进制。 * 产物: - Python wheel(包含 `kagent` 可执行文件) - Python 代码负责定位并调用该二进制 * 运行时查找优先级: 1) `KIMI_KERNEL_BIN` 环境变量 2) package 内嵌二进制路径 3) 系统 PATH **平台矩阵** * Linux x86_64 / ARM64 * macOS ARM64 * Windows x86_64 ### 6) 兼容与迁移策略 * Python kernel 保留并可显式启用。 * Rust kernel 失败可自动 fallback。 * 既有 wire/client 协议不变。 * e2e 测试通过 `KIMI_E2E_WIRE_CMD` 指定 Rust kernel。 ## 测试与验证 * Rust:`cargo fmt` / `cargo check` / `cargo test` * Python:现有 UI/ACP 测试继续 * e2e:`KIMI_E2E_WIRE_CMD=... uv run pytest tests_e2e` * CI:增加多平台 Rust + e2e 覆盖 ## 替代方案(不选) **Pyo3 绑定(in-process Rust kernel)** * 优点:更低延迟、无进程管理。 * 缺点:绑定维护成本高、生命周期/async 与 GIL 复杂、隔离性差。 结论:sidecar 模式更符合现有 wire 设计与业界实践(binary + Python wrapper)。 ## 开放问题 * Rust kernel 是否需要从 Python 侧注入更多 runtime 信息(如 workdir listing / skills)? * 是否需要在 wire `initialize` 中扩展 metadata(如 kernel capabilities / feature flags)? * 失败回退是否应默认启用,还是仅在 `kernel=auto` 时启用? ================================================ FILE: klips/klip-2-acpkaos.md ================================================ --- Author: "@stdrc" Updated: 2025-12-29 Status: Implemented --- # KLIP-2: ACPKaos, a LocalKaos variant that redirects operations to ACP clients ## Summary Build ACPKaos as a near-drop-in LocalKaos variant. It behaves like LocalKaos for almost everything, but redirects the few operations that let ACP clients observe what the agent did: file reads/writes and terminal commands. This keeps tool behavior unchanged while making ACP the execution backend. ## Motivation * We want ACP clients (e.g. Zed) to observe file edits and command execution. * Tool-level ACP replacements are functional but not the most fundamental design. * KAOS already abstracts OS operations; ACP fits naturally as a KAOS backend. * Implementing ACP behavior as tools duplicates logic already present in core tools; ACPKaos eliminates that repetition by moving the integration down a layer. ## Constraints and references * Use ACP only for methods the client explicitly advertises: `fs/read_text_file`, `fs/write_text_file`, and `terminal/*`. See [ACP initialization](https://agentclientprotocol.com/protocol/initialization), [ACP file system](https://agentclientprotocol.com/protocol/file-system), and [ACP terminals](https://agentclientprotocol.com/protocol/terminals). * All other operations should pass through to LocalKaos. * Keep behavior of existing tools (`Shell`, `ReadFile`, `WriteFile`, `StrReplaceFile`) unchanged. * Capability flags are independent: `readTextFile` and `writeTextFile` may be enabled separately. The implementation must not call unsupported ACP methods. ## Current baseline (no new behavior assumed) * KAOS is a contextvar-based abstraction with LocalKaos as default. * Tools call KAOS: * `Shell` -> `kaos.exec`. * `ReadFile` -> `KaosPath.exists/is_file/read_lines`. * `WriteFile` / `StrReplaceFile` -> `KaosPath.read_text/write_text/append_text`. * ACP integration today is tool-level (terminal replacement); an ACP-backed file tool swap has been experimented with locally but is not merged. ## Design: ACPKaos in one page ACPKaos wraps LocalKaos and overrides only the minimal surface needed by tools. Everything else delegates to LocalKaos. ### Minimal overrides * `exec` -> ACP terminal operations. * `readtext` -> ACP `fs/read_text_file`. * `writetext` -> ACP `fs/write_text_file` (append uses ACP only when both read+write are supported; otherwise fall back to LocalKaos). * `readlines` -> optional: implement ACP paging, or update `ReadFile` to use `readtext` and split lines. * `stat` -> keep LocalKaos (optional ACP fallback if unsaved buffers matter). ### Known limitation (unsaved buffers) ACP `fs/read_text_file` can expose editor buffers that are not yet saved on disk. However, the current tool chain checks `KaosPath.exists/is_file` before reading; those checks use LocalKaos and will return false for buffer-only files. For now, we accept this limitation and keep `stat/exists/is_file` local. If we later want unsaved buffers to work end-to-end, we must revisit these checks. ### Pseudo-code (intent, not syntax) ```Plain ACPKaos { init(client, session_id, caps, fallback=local_kaos) # bind ACP vs local functions once, based on caps self._readtext = caps.fs.readTextFile ? acp_readtext : fallback.readtext self._writetext = caps.fs.writeTextFile ? acp_writetext : fallback.writetext self._exec = caps.terminal ? acp_exec : fallback.exec self._appendtext = (caps.fs.readTextFile && caps.fs.writeTextFile) ? acp_appendtext : fallback_appendtext # implemented via fallback.writetext(mode="a") # pass-throughs pathclass/normpath/gethome/getcwd/chdir/stat/iterdir/glob/readbytes/writebytes/mkdir -> fallback readtext(path): return self._readtext(abs(path)) readlines(path): # split readtext into lines (keeps ReadFile behavior unchanged) text = self._readtext(abs(path)) return text.splitlines(keepends=True) writetext(path, data, mode): if mode == "a": return self._appendtext(abs(path), data) return self._writetext(abs(path), data, mode) exec(args...): return self._exec(args...) } ``` ### ACPProcess (terminal adapter, intent only) ```Plain ACPProcess (implements KaosProcess) { # Required because Shell expects a KaosProcess-compatible object. spawn(args): terminal_id = client.create_terminal(command, args, session_id, cwd=abs(cwd), outputByteLimit=limit) start background poll (terminal_output) to refresh output stdout/stderr: ACP has no stderr split; choose stdout-only and document it. wait(): concurrently: wait_for_exit for authoritative status terminal_output for incremental output handle truncation: if truncated or output no longer contains last_seen_tail -> reset delta base and note truncation finally: terminal/release (MUST), even on error/cancel; release kills running commands kill(): client.terminal/kill } ``` ## Integration points * Create ACPKaos per ACP session, holding `client`, `session_id`, and `client_capabilities`. * Set `current_kaos` for the ACP session run (contextvars are task-local); do this inside `prompt` so it covers a full turn. * Keep `kaos.chdir` behavior intact; ACPKaos should delegate `chdir` to LocalKaos. * Decide on tool-level replacements: preferred is to skip replacements when ACPKaos is active; transitional is to leave replacements as fallback for environments without ACPKaos. * ACP calls must use absolute paths to avoid `chdir` surprises. ## Validation * Unit tests for ACPKaos: * read/write calls hit ACP when caps allow. * append uses read + write. * exec returns output and exit codes. * Integration tests: run `Shell`, `ReadFile`, `WriteFile`, `StrReplaceFile` with ACPKaos active. * Manual test in Zed: read unsaved buffer, write changes, run command and confirm UI updates. * Tests will need a mocked ACP client; we can mirror patterns from the ACP Python SDK tests when implementing. ================================================ FILE: klips/klip-3-kimi-cli-user-docs.md ================================================ --- Author: "@stdrc" Updated: 2025-12-30 Status: Implemented --- # KLIP-3: Kimi CLI User Documentation 以下为后续文档大纲的层级约定: * `##` 二级标题:文档主导航 tab 链接(顶层主题)。 * `###` 三级标题:侧边栏链接(进入具体页面或分组)。 * 一级无序列表:该页面/分组下的内容块或子主题。 * 二级无序列表:内容要点与写作提示,不要求一一对应页内小标题;其中 `参考代码` 统一列出该一级条目需要参考的代码位置。 ## 指南 / Guides ### 开始使用 / Getting Started * Kimi CLI 是什么 / What is Kimi CLI * 适用场景 * 技术预览状态说明 * 参考代码: `src/kimi_cli/app.py`, `src/kimi_cli/cli.py`, `src/kimi_cli/soul/`, `src/kimi_cli/ui/`, `src/kimi_cli/tools/`, `README.md`, `src/kimi_cli/tools/file/`, `src/kimi_cli/tools/shell/`, `src/kimi_cli/tools/web/`, `src/kimi_cli/soul/toolset.py`, `CHANGELOG.md`, `src/kimi_cli/constant.py`, `src/kimi_cli/utils/changelog.py` * 安装与升级 / Install and upgrade * 系统要求 / System requirements * Python 3.13+ * 推荐使用 uv * 参考代码: `pyproject.toml`, `README.md`, `Makefile` * 安装 / Installation * 参考代码: `README.md`, `pyproject.toml`, `scripts/` * 升级 / Upgrade * 参考代码: `README.md`, `src/kimi_cli/ui/shell/update.py`, `src/kimi_cli/ui/shell/__init__.py` * 卸载 / Uninstall * 参考代码: `README.md` * 第一次运行 / First run * 启动 Kimi CLI / Launch Kimi CLI * 在项目目录运行 `kimi` * 参考代码: `src/kimi_cli/cli.py`, `src/kimi_cli/app.py`, `pyproject.toml`, `README.md` * 配置平台与模型 / Configure platform and model * 使用 `/setup` 配置 * 参考代码: `src/kimi_cli/ui/shell/setup.py`, `src/kimi_cli/config.py`, `src/kimi_cli/llm.py`, `src/kimi_cli/app.py`, `src/kimi_cli/ui/shell/slash.py` * 发现更多用法 / Discover more usage * 使用 `/help` 查看 * 参考代码: `src/kimi_cli/ui/shell/slash.py`, `src/kimi_cli/soul/slash.py`, `src/kimi_cli/utils/slashcmd.py` ### 常见使用案例 / Common Use Cases * 实现新功能 / Implement new feature * 读 → 改 → 验证 * 参考代码: `src/kimi_cli/tools/file/`, `src/kimi_cli/tools/shell/`, `src/kimi_cli/soul/kimisoul.py`, `src/kimi_cli/soul/toolset.py`, `src/kimi_cli/tools/file/read.py`, `src/kimi_cli/tools/file/write.py`, `src/kimi_cli/tools/shell/__init__.py` * 修复 bug / Fix bugs * 参考代码: `src/kimi_cli/tools/shell/__init__.py`, `src/kimi_cli/tools/file/`, `src/kimi_cli/ui/shell/debug.py`, `src/kimi_cli/ui/shell/usage.py` * 理解项目 / Understand the codebase * 参考代码: `src/kimi_cli/tools/file/glob.py`, `src/kimi_cli/tools/file/grep_local.py`, `src/kimi_cli/tools/file/read.py`, `src/kimi_cli/utils/path.py` * 自动化小任务 / Automate small tasks * 参考代码: `src/kimi_cli/tools/shell/__init__.py`, `src/kimi_cli/tools/todo/`, `src/kimi_cli/tools/multiagent/task.py`, `src/kimi_cli/soul/toolset.py` * 自动化通用任务 / Automate general tasks * 通用 topic 的 deep research 任务 * 数据分析任务 ### 交互与输入 / Interaction and input * Agent 与 Shell 模式 / Agent vs Shell mode * Ctrl-X 切换模式 * Shell 模式运行本地命令 * 参考代码: `src/kimi_cli/ui/shell/__init__.py`, `src/kimi_cli/ui/shell/prompt.py`, `src/kimi_cli/ui/shell/keyboard.py`, `src/kimi_cli/soul/kimisoul.py`, `src/kimi_cli/tools/shell/__init__.py`, `src/kimi_cli/utils/environment.py`, `src/kimi_cli/tools/shell/powershell.md` * Thinking 模式 / Thinking mode * Tab 或 `--thinking` 切换 * 需模型支持 * 参考代码: `src/kimi_cli/llm.py`, `src/kimi_cli/soul/kimisoul.py`, `src/kimi_cli/ui/shell/prompt.py`, `src/kimi_cli/config.py`, `src/kimi_cli/ui/shell/keyboard.py`, `src/kimi_cli/cli.py` * 多行输入 / Multi-line input * Ctrl-J 或 Alt-Enter * 参考代码: `src/kimi_cli/ui/shell/prompt.py`, `src/kimi_cli/ui/shell/keyboard.py` * 剪贴板与图片粘贴 / Clipboard and image paste * Ctrl-V 粘贴 * 需模型支持 `image_in` * 参考代码: `src/kimi_cli/utils/clipboard.py`, `src/kimi_cli/ui/shell/prompt.py`, `src/kimi_cli/ui/shell/keyboard.py`, `src/kimi_cli/llm.py`, `src/kimi_cli/config.py` * 斜杠命令 / Slash commands * 参考代码: `src/kimi_cli/ui/shell/slash.py`, `src/kimi_cli/soul/slash.py`, `src/kimi_cli/utils/slashcmd.py` * @ 路径补全 / @ path completion * 参考代码: `src/kimi_cli/ui/shell/prompt.py`, `src/kimi_cli/utils/path.py`, `src/kimi_cli/tools/file/glob.py` * 审批与确认 / Approvals * 一次 / 本会话 / 拒绝 * `--yolo` 或 `/yolo` * 参考代码: `src/kimi_cli/soul/approval.py`, `src/kimi_cli/ui/shell/visualize.py`, `src/kimi_cli/tools/file/write.py`, `src/kimi_cli/tools/shell/__init__.py`, `src/kimi_cli/cli.py`, `src/kimi_cli/ui/shell/slash.py` ### 会话与上下文 / Sessions and context * 会话续接 / Session resuming * `--continue`、`--session`、`/sessions` * 启动回放 * 参考代码: `src/kimi_cli/session.py`, `src/kimi_cli/metadata.py`, `src/kimi_cli/ui/shell/replay.py`, `src/kimi_cli/share.py`, `src/kimi_cli/cli.py`, `src/kimi_cli/ui/shell/slash.py`, `src/kimi_cli/wire/serde.py` * 清空与压缩 / Clear and compact * `/clear`(别名 `/reset`) * `/compact` * 参考代码: `src/kimi_cli/ui/shell/slash.py`, `src/kimi_cli/soul/compaction.py`, `src/kimi_cli/soul/context.py` ### 在 IDE 中使用 / Using in IDEs * 在 Zed 中使用 / Use in Zed * `--acp` 参数 * IDE 配置 * 参考代码: `src/kimi_cli/acp/`, `src/kimi_cli/ui/acp/__init__.py`, `src/kimi_cli/cli.py`, `src/kimi_cli/acp/AGENTS.md`, `README.md`, `src/kimi_cli/app.py` * 在 JetBrains IDE 中使用 / Use in JetBrains IDEs * 同上 / Same as above ### 集成到工具 / Integrations with tools * Zsh 插件 / Zsh plugin * 快捷切换 * 参考代码: `README.md`, `src/kimi_cli/ui/shell/keyboard.py` ## 定制化 / Customization ### Model Context Protocol / Model Context Protocol * MCP 是什么 / What is MCP * 参考代码: `src/kimi_cli/mcp.py`, `src/kimi_cli/soul/toolset.py`, `src/kimi_cli/acp/mcp.py`, `src/kimi_cli/tools/` * `kimi mcp` 子命令 / `kimi mcp` subcommands * 参考代码: `src/kimi_cli/mcp.py`, `src/kimi_cli/cli.py` * MCP 配置文件 / MCP config files * `~/.kimi/mcp.json` * `--mcp-config-file` * `--mcp-config` * 参考代码: `src/kimi_cli/mcp.py`, `src/kimi_cli/share.py`, `src/kimi_cli/cli.py` * 安全性 / Security * 审批请求 * 工具提示词注入风险 * 参考代码: `src/kimi_cli/soul/approval.py`, `src/kimi_cli/soul/toolset.py`, `src/kimi_cli/tools/utils.py`, `src/kimi_cli/tools/file/`, `src/kimi_cli/ui/shell/visualize.py` ### Agent Skills * Agent Skills 是什么 / What are Agent Skills * 参考代码: `src/kimi_cli/skill.py`, `src/kimi_cli/soul/agent.py`, `src/kimi_cli/utils/frontmatter.py` * Skill 发现 / Skill discovery * `~/.kimi/skills` * 回退 `~/.claude/skills` * `--skills-dir` * 参考代码: `src/kimi_cli/skill.py`, `src/kimi_cli/soul/agent.py`, `src/kimi_cli/share.py`, `src/kimi_cli/cli.py` ### Agent 与子 Agent / Agents and subagents * 内置 Agent / Built-in agents * `default` * `okabe` * 参考代码: `src/kimi_cli/agents/`, `src/kimi_cli/agentspec.py`, `src/kimi_cli/agents/default/agent.yaml`, `src/kimi_cli/agents/okabe/agent.yaml` * 自定义 Agent 文件 / Custom agent file * YAML 格式 * `extend` 与 `exclude_tools` * 参考代码: `src/kimi_cli/agentspec.py`, `src/kimi_cli/soul/agent.py`, `src/kimi_cli/agents/`, `src/kimi_cli/soul/toolset.py` * 系统提示词内置参数 / System prompt built-in parameters * `KIMI_NOW` * `KIMI_WORK_DIR` * `KIMI_WORK_DIR_LS` * `KIMI_AGENTS_MD` * `KIMI_SKILLS` * 参考代码: `src/kimi_cli/soul/agent.py`, `src/kimi_cli/tools/file/read.py`, `src/kimi_cli/soul/slash.py`, `src/kimi_cli/skill.py`, `src/kimi_cli/utils/datetime.py`, `src/kimi_cli/utils/path.py` * 在 Agent 文件中定义子 Agent / Define subagents in agent file * 参考代码: `src/kimi_cli/agents/default/sub.yaml`, `src/kimi_cli/agentspec.py` * 动态子 Agent 与任务调度 / Dynamic subagents and task scheduling * `CreateSubagent` 工具 * 参考代码: `src/kimi_cli/tools/multiagent/task.py`, `src/kimi_cli/tools/multiagent/create.py`, `src/kimi_cli/soul/agent.py`, `src/kimi_cli/soul/toolset.py`, `src/kimi_cli/agents/default/sub.yaml` ### Print 模式 / Print Mode * 无交互运行 / Non-interactive run * `--print` + `--command` 或 stdin * 隐式开启 `--yolo` * 参考代码: `src/kimi_cli/ui/print/__init__.py`, `src/kimi_cli/ui/print/visualize.py`, `src/kimi_cli/cli.py`, `src/kimi_cli/app.py`, `src/kimi_cli/soul/approval.py` * Stream JSON 格式 / Stream JSON format * `--input-format=stream-json` * `--output-format=stream-json` * JSONL Message * 参考代码: `src/kimi_cli/cli.py`, `src/kimi_cli/ui/print/visualize.py`, `src/kimi_cli/wire/message.py`, `src/kimi_cli/wire/serde.py`, `src/kimi_cli/ui/print/__init__.py` ### Wire 模式 / Wire Mode * Wire 是什么 / What is Wire * 参考代码: `src/kimi_cli/wire/`, `src/kimi_cli/ui/wire/__init__.py` * Wire 协议 / Wire protocol * JSON-RPC * Method 等 * 参考代码: `src/kimi_cli/ui/wire/jsonrpc.py`, `src/kimi_cli/wire/message.py`, `src/kimi_cli/wire/serde.py` * Wire 消息 / Wire messages * 完整类型与 schema * 参考代码: `src/kimi_cli/wire/message.py`, `src/kimi_cli/wire/serde.py` ## 配置 / Configuration ### 配置文件 / Config files * 配置文件位置 / Config file location * `~/.kimi/config.toml` * 参考代码: `src/kimi_cli/config.py`, `src/kimi_cli/share.py`, `README.md` * 配置项 / Config items * providers * models * loop control * services * MCP client * 参考代码: `src/kimi_cli/config.py`, `src/kimi_cli/llm.py`, `src/kimi_cli/mcp.py`, `src/kimi_cli/soul/kimisoul.py`, `src/kimi_cli/tools/web/` * JSON 支持与迁移 / JSON support and migration * `config.json` 迁移 * `--config`/`--config-file` 仍可以用 JSON * 参考代码: `src/kimi_cli/config.py`, `src/kimi_cli/cli.py` ### 平台与模型 / Providers and models * 平台选择 / Platform selection * `/setup` * 参考代码: `src/kimi_cli/ui/shell/setup.py`, `src/kimi_cli/config.py`, `src/kimi_cli/ui/shell/slash.py` * Provider 类型 / Provider types * `kimi` * `openai_legacy` * `openai_responses` * `anthropic` * `gemini/google_genai` * `vertexai` * 参考代码: `src/kimi_cli/llm.py`, `src/kimi_cli/config.py`, `src/kimi_cli/ui/shell/setup.py` * 模型能力与限制 / Model capabilities and limits * thinking * image\_in * 参考代码: `src/kimi_cli/llm.py`, `src/kimi_cli/soul/kimisoul.py`, `src/kimi_cli/soul/message.py`, `src/kimi_cli/ui/shell/prompt.py`, `src/kimi_cli/config.py` * 搜索/抓取服务 / Search and fetch services * 启用条件 * 参考代码: `src/kimi_cli/tools/web/search.py`, `src/kimi_cli/tools/web/fetch.py`, `src/kimi_cli/config.py`, `src/kimi_cli/ui/shell/setup.py` ### 配置覆盖 / Config overrides * CLI 参数与配置文件 / CLI flags vs config * 参考代码: `src/kimi_cli/cli.py`, `src/kimi_cli/config.py` * 环境变量覆盖 / Environment overrides * 参考代码: `src/kimi_cli/utils/envvar.py`, `src/kimi_cli/llm.py` ### 环境变量 / Environment variables * Kimi 环境变量 / Kimi environment variables * `KIMI_BASE_URL` * `KIMI_API_KEY` * `KIMI_MODEL_NAME` * `KIMI_MODEL_MAX_CONTEXT_SIZE` * `KIMI_MODEL_CAPABILITIES` * `KIMI_MODEL_TEMPERATURE` * `KIMI_MODEL_TOP_P` * `KIMI_MODEL_MAX_TOKENS` * 参考代码: `src/kimi_cli/utils/envvar.py`, `src/kimi_cli/config.py`, `src/kimi_cli/llm.py` * OpenAI 兼容环境变量 / OpenAI-compatible environment variables * `OPENAI_BASE_URL` * `OPENAI_API_KEY` * 参考代码: `src/kimi_cli/utils/envvar.py`, `src/kimi_cli/llm.py`, `src/kimi_cli/config.py` * 其他环境变量 / Other environment variables * `KIMI_CLI_NO_AUTO_UPDATE` * 参考代码: `src/kimi_cli/utils/envvar.py`, `src/kimi_cli/ui/shell/update.py` ### 数据路径 / Data locations * 配置与元数据 / Config and metadata * `~/.kimi/config.toml` * `~/.kimi/kimi.json` * `~/.kimi/mcp.json` * 参考代码: `src/kimi_cli/share.py`, `src/kimi_cli/metadata.py`, `src/kimi_cli/config.py`, `src/kimi_cli/mcp.py` * 会话数据 / Session data * `~/.kimi/sessions/.../context.jsonl` * `~/.kimi/sessions/.../wire.jsonl` * 参考代码: `src/kimi_cli/session.py`, `src/kimi_cli/wire/serde.py`, `src/kimi_cli/soul/context.py`, `src/kimi_cli/wire/message.py` * 输入历史 / Input history * `~/.kimi/user-history/...` * 参考代码: `src/kimi_cli/ui/shell/prompt.py`, `src/kimi_cli/share.py` * 日志 / Logs * `~/.kimi/logs/kimi.log` * 参考代码: `src/kimi_cli/utils/logging.py`, `src/kimi_cli/app.py`, `src/kimi_cli/share.py` ## 参考手册 / Reference ### `kimi` 命令 / `kimi` command * 全局参数 / Global flags * `--version`、`--help`、`--verbose`、`--debug` * `--agent`、`--agent-file` * `--config`、`--config-file` * `--model` * `--work-dir` * `--continue`、`--session` * `--command` / `--query` * `--print`、`--input-format`、`--output-format` * `--acp`、`--wire` * `--mcp-config-file`、`--mcp-config` * `--yolo` / `--auto-approve` / `--yes` * `--thinking` / `--no-thinking` * `--skills-dir` * 参考代码: `src/kimi_cli/cli.py`, `src/kimi_cli/app.py`, `src/kimi_cli/constant.py`, `src/kimi_cli/agentspec.py`, `src/kimi_cli/config.py`, `src/kimi_cli/llm.py`, `src/kimi_cli/session.py`, `src/kimi_cli/ui/print/__init__.py`, `src/kimi_cli/ui/acp/__init__.py`, `src/kimi_cli/ui/wire/__init__.py`, `src/kimi_cli/mcp.py`, `src/kimi_cli/soul/approval.py`, `src/kimi_cli/skill.py` ### `kimi acp` 命令 / `kimi acp` command * 启动 ACP multi-session 服务器,现在还没有被 ACP 客户端广泛支持 / Start an ACP multi-session server, which is not widely supported by ACP clients yet. * 参考代码: `src/kimi_cli/cli.py`, `src/kimi_cli/acp/__init__.py`, `src/kimi_cli/ui/acp/__init__.py` ### `kimi mcp` 子命令 / `kimi mcp` subcommands * 服务器管理 / Server management * `add`、`list`、`remove` * 参考代码: `src/kimi_cli/mcp.py`, `src/kimi_cli/cli.py` * 认证与测试 / Auth and test * `auth`、`reset-auth`、`test` * 参考代码: `src/kimi_cli/mcp.py`, `src/kimi_cli/soul/toolset.py`, `src/kimi_cli/acp/mcp.py` ### 斜杠命令 / Slash commands * 帮助与信息 / Help and info * `/help`、`/version`、`/release-notes`、`/feedback` * 参考代码: `src/kimi_cli/ui/shell/slash.py`, `src/kimi_cli/soul/slash.py`, `src/kimi_cli/utils/changelog.py` * 配置与调试 / Config and debug * `/setup`、`/reload`、`/debug`、`/usage` * 参考代码: `src/kimi_cli/ui/shell/slash.py`, `src/kimi_cli/ui/shell/debug.py`, `src/kimi_cli/ui/shell/setup.py`, `src/kimi_cli/ui/shell/usage.py` * 会话管理 / Session management * `/clear`(别名 `/reset`) * `/sessions`(别名 `/resume`) * 参考代码: `src/kimi_cli/ui/shell/slash.py`, `src/kimi_cli/session.py`, `src/kimi_cli/soul/context.py` * 其他 / Others * `/mcp`、`/init`、`/compact`、`/yolo` * 参考代码: `src/kimi_cli/ui/shell/slash.py`, `src/kimi_cli/soul/slash.py`, `src/kimi_cli/mcp.py`, `src/kimi_cli/soul/compaction.py`, `src/kimi_cli/soul/approval.py` ### 内置工具 / Built-in tools * 默认启用工具 / Default tools * `Task`、`SetTodoList`、`Shell`、`ReadFile`、`Glob`、`Grep`、`WriteFile`、`StrReplaceFile`、`SearchWeb`、`FetchURL` * 参考代码: `src/kimi_cli/agents/default/agent.yaml`, `src/kimi_cli/tools/`, `src/kimi_cli/tools/utils.py` * 可选工具 / Optional tools * `Think`、`SendDMail`、`CreateSubagent` * 需在 Agent 文件中启用 * 参考代码: `src/kimi_cli/agents/default/sub.yaml`, `src/kimi_cli/tools/`, `src/kimi_cli/tools/think/`, `src/kimi_cli/tools/dmail/`, `src/kimi_cli/tools/multiagent/create.py`, `src/kimi_cli/agents/default/agent.yaml`, `src/kimi_cli/agentspec.py` * 工具安全边界与审批 / Tool security and approvals * 工作目录限制 * diff 预览 * 参考代码: `src/kimi_cli/soul/approval.py`, `src/kimi_cli/soul/toolset.py`, `src/kimi_cli/tools/file/`, `src/kimi_cli/tools/shell/__init__.py`, `src/kimi_cli/utils/path.py`, `src/kimi_cli/tools/file/write.py`, `src/kimi_cli/tools/file/diff_utils.py`, `src/kimi_cli/ui/shell/visualize.py` ### 退出码与失败模式 / Exit codes and failure modes * 退出码语义与触发条件(正常结束、配置错误、运行中断等) * 与 UI/模式相关的失败场景说明(Shell/Print/Wire/ACP) * 参考代码: `src/kimi_cli/cli.py`, `src/kimi_cli/app.py`, `src/kimi_cli/exception.py`, `src/kimi_cli/soul/__init__.py` ### 键盘快捷键 / Keyboard shortcuts * Ctrl-X:切换模式 * 参考代码: `src/kimi_cli/ui/shell/keyboard.py`, `src/kimi_cli/ui/shell/__init__.py` * Tab:切换 thinking * 参考代码: `src/kimi_cli/ui/shell/keyboard.py`, `src/kimi_cli/llm.py` * Ctrl-J / Alt-Enter:换行 * 参考代码: `src/kimi_cli/ui/shell/keyboard.py`, `src/kimi_cli/ui/shell/prompt.py` * Ctrl-V:粘贴 * 参考代码: `src/kimi_cli/ui/shell/keyboard.py`, `src/kimi_cli/utils/clipboard.py` * Ctrl-D:退出 * 参考代码: `src/kimi_cli/ui/shell/keyboard.py`, `src/kimi_cli/ui/shell/__init__.py` ## 常见问题 / FAQ ### 安装与鉴权 / Setup and auth * 模型列表为空 * 参考代码: `src/kimi_cli/ui/shell/setup.py`, `src/kimi_cli/config.py`, `src/kimi_cli/llm.py` * API key 无效 * 参考代码: `src/kimi_cli/ui/shell/setup.py`, `src/kimi_cli/config.py`, `src/kimi_cli/utils/envvar.py` * 会员过期 * 参考代码: `src/kimi_cli/ui/shell/usage.py`, `src/kimi_cli/ui/shell/setup.py` ### 交互问题 / Interaction issues * Shell 模式 `cd` 无效 * 参考代码: `src/kimi_cli/tools/shell/__init__.py`, `src/kimi_cli/utils/environment.py`, `src/kimi_cli/tools/shell/bash.md` * Thinking 模式不可用 * 参考代码: `src/kimi_cli/llm.py`, `src/kimi_cli/config.py`, `src/kimi_cli/ui/shell/prompt.py` ### ACP 问题 / ACP issues * 连接失败 * 参考代码: `src/kimi_cli/acp/server.py`, `src/kimi_cli/acp/session.py`, `src/kimi_cli/ui/acp/__init__.py` * 工作目录不一致 * 参考代码: `src/kimi_cli/acp/session.py`, `src/kimi_cli/session.py`, `src/kimi_cli/share.py` ### MCP 问题 / MCP issues * 服务启动失败 * 参考代码: `src/kimi_cli/mcp.py`, `src/kimi_cli/soul/toolset.py` * OAuth 授权失败 * 参考代码: `src/kimi_cli/mcp.py`, `src/kimi_cli/acp/mcp.py` * Header 格式错误 * 参考代码: `src/kimi_cli/mcp.py`, `src/kimi_cli/soul/toolset.py` ### Print/Wire 模式问题 / Print/Wire mode issues * JSONL 输入无效 * 参考代码: `src/kimi_cli/ui/print/__init__.py`, `src/kimi_cli/wire/serde.py` * 无输出 * 参考代码: `src/kimi_cli/ui/print/__init__.py`, `src/kimi_cli/ui/wire/__init__.py` * 格式不匹配 * 参考代码: `src/kimi_cli/wire/message.py`, `src/kimi_cli/wire/serde.py` ### 更新与升级 / Updates * macOS 首次运行变慢 * 参考代码: `src/kimi_cli/ui/shell/update.py`, `src/kimi_cli/tools/file/grep_local.py` * uv 升级步骤 * 参考代码: `README.md`, `src/kimi_cli/ui/shell/update.py` ## 发布说明 / Release Notes ### 变更记录 / Changelog * 版本号、发布日期、变更内容 / Version number, release date, changes * 参考代码: `CHANGELOG.md`, `src/kimi_cli/utils/changelog.py`, `src/kimi_cli/constant.py`, `README.md` ### 破坏性变更与迁移说明 / Breaking changes and migration * 破坏性变更清单与迁移指引 * 受影响范围、替代方案、回滚提示 * 参考代码: `CHANGELOG.md`, `src/kimi_cli/utils/changelog.py` ================================================ FILE: klips/klip-6-setup-auto-refresh-models.md ================================================ --- Author: "@stdrc" Updated: 2026-01-07 Status: Implemented --- # KLIP-6: /setup 平台模型自动刷新与托管命名空间 ## 背景与现状(最终实现) * `/setup` 位于 `src/kimi_cli/ui/shell/setup.py`:选择平台、输入 API key、调用 `list_models(platform, api_key)` 获取并过滤模型,然后写入 `config.providers` 与 `config.models`,并设置 `default_model` 为用户选中的模型。 * `Config` 定义在 `src/kimi_cli/config.py`:`providers` 与 `models` 平级,`default_model` 必须指向 `models` 中的键,且每个 `LLMModel.provider` 必须存在于 `providers`。 * `/setup` 使用托管命名空间(`managed:`)写入 provider/model,避免覆盖用户自定义配置。 ## 目标 * 在 `/model` 斜杠命令触发时自动刷新 `/setup` 所配置平台的模型列表,并写回配置文件。 * 自动刷新只覆盖“/setup 管理的模型”,不影响用户自行配置的 provider/model。 * 保持 CLI 可用性:默认模型仍可正常加载,`/model` 列表可用。 * 适用于所有启动方式:只要使用 `/model` 命令且配置中存在 `/setup` 托管 provider,并且使用默认配置文件位置,才会自动刷新。 ## 设计概览 ### 1) 托管命名空间(区分自动管理与用户自定义) 为 `/setup` 管理的 provider/model 引入保留命名空间,避免和用户配置冲突: * provider key:`managed:` * model key:`/` 模型条目仍保留真实 `model` 字段(API 端模型名),`provider` 字段指向上述 provider key。 示例: ```toml [providers."managed:moonshot-cn"] type = "kimi" base_url = "https://api.moonshot.cn/v1" api_key = "sk-xxx" [models."moonshot-cn/kimi-k2-thinking-turbo"] provider = "managed:moonshot-cn" model = "kimi-k2-thinking-turbo" max_context_size = 262144 ``` 这样可以做到: * `/setup` 管理的模型可以被“强制覆盖”。 * 用户仍可自由定义 `providers.moonshot-cn`、`models.kimi-k2-thinking-turbo` 等同名项,不会被覆盖。 ### 2) 识别“/setup 平台”的最小信息源 将 `/setup` 平台清单抽到公共模块(例如 `src/kimi_cli/auth/platforms.py`),提供: * `id`、`name`、`base_url` * `search_url`、`fetch_url`(可选) * `allowed_prefixes`(过滤模型前缀) `/setup` 与自动刷新都基于同一份平台定义。 ### 3) 自动刷新机制(/model 触发) 在 `/model` 命令(`src/kimi_cli/ui/shell/slash.py`)触发刷新逻辑,仅在默认配置文件位置时启用: 1. 仅当 `config.is_from_default_location` 为真时继续,否则直接跳过刷新。 2. 扫描 `providers` 中以 `managed:` 开头的条目,视为托管平台;若没有托管 provider,则不刷新。 3. 对每个平台调用 `{base_url}/models`,并在 `list_models` 内按 `allowed_prefixes` 过滤。 4. 生成/更新 `models` 中对应的 `/...` 条目: * 更新 `max_context_size` * 移除已经下线的模型条目 5. 若发生变化:写回 config 文件,并同步更新内存中的 `runtime.config`,使 `/model` 立即可见。 写回策略: * 自动刷新仅在默认配置路径启用,因此写回总是落到默认 config 文件。 * 非默认配置(`--config` / `--config-file`)不会触发自动刷新。 错误处理:网络/鉴权失败时记录日志并跳过该平台,`/model` 继续展示已有配置。 ### 4) `/setup` 行为调整 `/setup` 写入配置时使用托管命名空间,并写入全部过滤后的模型: * provider:`managed:` * model:`/` * 将过滤后的模型全量写入 `models`(同一 provider 下旧模型先清理) * `default_model` 指向托管 model key(用户选择的模型 `selected_model_id`) * `services.moonshot_search` / `services.moonshot_fetch` 保持现有行为 ### 5) UI 展示优化(可选) `/model` 列表可以显示更友好的 label: * 显示 `model.model` 作为主名字 * 将 `managed:` 的 provider 显示为平台名(直接使用 `Platform.name`) * 选择时仍用真实 key,避免破坏现有逻辑 * 说明:`/model` 的持久切换只在默认配置文件可写时生效(现有约束),与“仅默认位置自动刷新”的策略一致 ## 迁移策略(不做) 为了保持简单与低风险,不做任何自动迁移。仅对通过新版 `/setup` 写入的托管 provider/model 生效。 ## 兼容性与边界 * 如果用户显式使用 `--config`(字符串)或 `--config-file` 指定文件,自动刷新不会触发。 * 若 `default_model` 指向的托管模型被 API 下线,自动回退到该平台列表中的第一个模型。 * 仅影响 `/setup` 平台;自定义 provider/model 不受影响。 ## 实施步骤(建议) 1. 抽出平台定义模块,供 `/setup` 与自动刷新共享。 2. 调整 `/setup` 写入逻辑(命名空间 + default_model)。 3. 在 `/model` 触发自动刷新逻辑。 4. `/model` 展示逻辑优化(仅 UI 层)。 5. (可选)测试覆盖刷新与写入逻辑。 ## 关键参考位置 * `/setup`:`src/kimi_cli/ui/shell/setup.py` * 配置结构:`src/kimi_cli/config.py` * 平台与模型刷新:`src/kimi_cli/auth/platforms.py` * `/model`:`src/kimi_cli/ui/shell/slash.py` ================================================ FILE: klips/klip-7-kimi-sdk.md ================================================ --- Author: "@stdrc" Updated: 2026-01-08 Status: Implemented --- # KLIP-7: Kimi SDK (thin wrapper around Kosong) ## Summary Add `sdks/kimi-sdk` as a lightweight Python SDK for Kimi. It provides the Kimi provider and agent building blocks (`generate/step`, message, tooling) in a flat module. The first version is a thin re-export to keep risk low and ship fast. Docs publishing is deferred for v1. ## Goals - Provide an OpenAI-SDK-like entry point: `from kimi_sdk import Kimi, generate, step, Message`. - Keep only Kosong's Kimi provider and agent primitives; no other providers. - Minimal implementation and maintenance: re-export, no behavior changes. - Export all content parts supported by the Kimi chat provider, plus display blocks. ## Non-goals - No new HTTP client layer; reuse Kosong's Kimi provider as-is. - No changes to Kimi request/response semantics. - No Kosong split or refactor in the first version. ## Package layout (flat module) ``` sdks/kimi-sdk/ pyproject.toml README.md CHANGELOG.md LICENSE / NOTICE src/kimi_sdk/ __init__.py py.typed ``` ### Module responsibilities - `kimi_sdk.__init__` - Re-export the full public surface (`Kimi`, `KimiStreamedMessage`, `generate`, `step`, `GenerateResult`, `Message`, `SimpleToolset`, tooling types, provider errors, content parts, display blocks). - Provide an explicit `__all__` grouped by category to keep the surface Kimi-focused. - Include a minimal agent loop example in the module docstring. - No `kimi_sdk.*` submodules; all public API lives at the top level. Note: `kimi_sdk` does not expose `kosong.contrib` or other providers, even via re-export. ## Public API (top-level) Exports (grouped in `__all__`): ```python from kimi_sdk import ( # providers Kimi, KimiStreamedMessage, StreamedMessagePart, ThinkingEffort, # provider errors APIConnectionError, APIEmptyResponseError, APIStatusError, APITimeoutError, ChatProviderError, # messages and content parts Message, Role, ContentPart, TextPart, ThinkPart, ImageURLPart, AudioURLPart, VideoURLPart, ToolCall, ToolCallPart, # tooling Tool, CallableTool, CallableTool2, Toolset, SimpleToolset, ToolReturnValue, ToolOk, ToolError, ToolResult, ToolResultFuture, # display blocks DisplayBlock, BriefDisplayBlock, UnknownDisplayBlock, # generation generate, step, GenerateResult, StepResult, TokenUsage, ) ``` Example usage: ```python from kimi_sdk import Kimi, Message, generate kimi = Kimi( base_url="https://api.moonshot.ai/v1", api_key="sk-xxx", model="kimi-k2-turbo-preview", ) history = [Message(role="user", content="Who are you?")] result = await generate(chat_provider=kimi, system_prompt="You are a helper.", tools=[], history=history) ``` ## Dependency strategy ### Phase 1 (MVP: direct dependency on Kosong) - `kimi-sdk` is a thin wrapper that depends on `kosong` with a strict upper bound. - Pros: minimal code, consistent behavior. - Cons: it pulls Kosong's provider dependencies too (acceptable for v1). Suggested dependency range: ``` dependencies = [ "kosong>=0.37.0,<0.38.0" ] ``` No lockstep requirement. `kimi-sdk` releases independently; the dependency upper bound ensures compatibility while allowing Kosong updates that are unrelated to Kimi (e.g. contrib providers). ## Versioning & Release ### Version strategy - Independent semver for `kimi-sdk`. - Compatibility is enforced by the `kosong` dependency range rather than lockstep versioning. ### Tag naming Add a new tag prefix: - `kimi-sdk-0.1.0` ### Release workflow Add `.github/workflows/release-kimi-sdk.yml`: - Trigger: tags `kimi-sdk-*` - Version validation: `scripts/check_version_tag.py` - Build: `make build-kimi-sdk` - Publish: `pypa/gh-action-pypi-publish` - No docs publish in v1. Update Makefile with: - `build-kimi-sdk` - `check-kimi-sdk` - `format-kimi-sdk` - `test-kimi-sdk` ## Testing ### Unit tests (sdks/kimi-sdk) Basic behavior smoke test: - `tests/test_smoke.py` - Use `respx` or `httpx.MockTransport` to stub Kimi responses - Ensure `generate/step` returns `Message` and `TokenUsage` ### CI Add `ci-kimi-sdk.yml`: - Reuse Makefile targets: - `make check-kimi-sdk` - `make test-kimi-sdk` - Structure should mirror `ci-kosong.yml`. ## Documentation - `sdks/kimi-sdk/README.md` with usage examples using `kimi_sdk` imports. - `kimi_sdk/__init__.py` docstring includes a minimal agent loop example; rely on underlying Kosong docstrings for detailed API descriptions. - Docs publishing is deferred for v1. ## Migration & Compatibility - Migration from `kosong` is only import path changes. - Environment variables keep the same semantics (`KIMI_API_KEY`, `KIMI_BASE_URL`). ## Decisions - Keep `kimi-sdk` thin (no Kosong split). - No `python -m kimi_sdk` demo entry for v1. - Docs repo name: `MoonshotAI/kimi-sdk`. - Skip docs publishing for v1. ================================================ FILE: klips/klip-8-config-and-skills-layout.md ================================================ --- Author: "@xxchan" Updated: 2026-01-14 Status: Implemented --- # KLIP-8: Unified Skills Discovery ## Motivation > "Skills should not need vendor-specific directory layouts, duplicate copies, or symlink hacks to be usable across clients." Coding agent ecosystems are fragmented with vendor-specific layouts. Users must duplicate skills or maintain symlinks. This proposal unifies skill discovery to be compatible with existing tools. ## Scope - Skills discovery - Future: `mcp.json` (not this KLIP) ## Non-goals - `~/.kimi/config.toml` and other Kimi-specific config - `~/.local/share/kimi/` data directories ## Skills Discovery Two-level logic: 1. **Layered merge**: builtin → user → project all loaded; same-name skills overridden by later layers 2. **Directory lookup**: within each layer, check candidates by priority; stop at first existing directory **User level** (by priority): - `~/.config/agents/skills/` — canonical, recommended - `~/.kimi/skills/` — legacy fallback - `~/.claude/skills/` — legacy fallback **Project level**: - `.agents/skills/` Built-in skills load only when the KAOS backend is `LocalKaos` or `ACPKaos`. `--skills-dir` overrides user/project discovery; only specified directory is used (built-ins still load when supported). ## References - [agentskills#15](https://github.com/agentskills/agentskills/issues/15): proposal to standardize `.agents/skills/` - [Amp](https://ampcode.com/manual#agent-skills): `~/.config/agents/`, `.agents/skills/` ================================================ FILE: klips/klip-9-shell-ui-flicker-mitigation.md ================================================ --- Author: "@stdrc" Updated: 2026-01-19 Status: Implemented --- # KLIP-9: Shell UI 闪烁缓解 — Pager 展开方案 ## 问题背景 ### 终端渲染的根本限制 终端有两个区域:**viewport**(可见区域,可原地更新)和 **scrollback**(历史区域,不可变)。 当 Live display 内容高度超过 viewport: 1. 顶部内容被推入 scrollback 2. Scrollback 不可变,光标无法定位 3. 任何更新都需要清除整个 scrollback 并重绘 → **闪烁** ### 当前问题 1. **Approval Request 过高**:Shell tool 的命令直接放在 `description` 中,长命令导致 panel 过高 2. **Display 字段未渲染**:`ApprovalRequest.display` 字段(包含 DiffDisplayBlock)在 UI 中**完全没有渲染** 3. **无法查看完整内容**:用户无法看到被截断的完整信息 ## 方案设计 ### 核心思路 1. **统一行预算**:所有内容共享固定行数预算(4 行),按顺序渲染直到预算用完 2. **Ctrl+E 展开到 Pager**:使用 Rich 的 `console.pager(styles=True)` 显示完整内容 3. **修复 display 字段渲染**:正确显示 DiffDisplayBlock 和 ShellDisplayBlock ### 为什么用 Pager 1. **已有实践**:项目在 `/help`、`/context`、`/debug history` 中已使用 `console.pager()` 2. **Alternate Screen**:Pager(less)使用 alternate screen,与 Live display 完全隔离 3. **零闪烁**:退出 pager 后,终端恢复到之前状态,Live display 继续工作 4. **功能丰富**:支持搜索(/)、滚动(j/k)、翻页(Space)等 ### UI 设计 #### 截断显示(默认) 无边框设计,内容区最多显示 4 行: ``` ⚠ shell is requesting approval to Run command: pip install requests pandas numpy matplotlib \ scikit-learn tensorflow torch transformers \ fastapi uvicorn sqlalchemy alembic pytest ... (truncated, ctrl-e to expand) → Approve once Approve for this session Reject, tell Kimi CLI what to do instead ``` #### 文件编辑的 Diff 显示 同一文件多个 hunk 时,后续 hunk 使用 `⋮` 表示省略的中间行: ``` ⚠ str_replace is requesting approval to Edit file: src/main.ts @@ -10,3 +10,5 @@ import { foo } from './foo'; -import { bar } from './bar'; ... (truncated, ctrl-e to expand) → Approve once ... ``` 多个 hunk 完整显示时(pager 内): ``` src/main.ts @@ -10,3 +10,5 @@ import { foo } from './foo'; -import { bar } from './bar'; +import { bar, baz } from './bar'; +import { qux } from './qux'; ⋮ @@ -50,3 +52,4 @@ export function main() { - const result = foo() + bar(); + const result = foo() + bar() + baz() + qux(); ``` #### Pager 全屏视图(Ctrl+E) 按 Ctrl+E 后进入系统 pager(通常是 less),复用预渲染的内容,显示完整信息。 ## 实现细节 ### 1. 新增 ShellDisplayBlock ```python # tools/display.py class ShellDisplayBlock(DisplayBlock): """Display block describing a shell command.""" type: str = "shell" language: str command: str ``` ### 2. 预渲染内容块 使用 NamedTuple 存储预渲染的内容块及其行数: ```python class _ApprovalContentBlock(NamedTuple): """A pre-rendered content block for approval request with line count.""" text: str lines: int style: str = "" lexer: str = "" ``` 在 `_ApprovalRequestPanel.__init__` 中预渲染所有内容: ```python class _ApprovalRequestPanel: def __init__(self, request: ApprovalRequest): # Pre-render all content blocks with line counts self._content_blocks: list[_ApprovalContentBlock] = [] last_diff_path: str | None = None # Handle display blocks for block in request.display: if isinstance(block, DiffDisplayBlock): # File path or ellipsis for same-file hunks if block.path != last_diff_path: self._content_blocks.append( _ApprovalContentBlock(text=block.path, lines=1, style="bold") ) last_diff_path = block.path else: self._content_blocks.append( _ApprovalContentBlock(text="⋮", lines=1, style="dim") ) # Diff content diff_text = format_unified_diff(...).rstrip("\n") self._content_blocks.append( _ApprovalContentBlock( text=diff_text, lines=diff_text.count("\n") + 1, lexer="diff" ) ) elif isinstance(block, ShellDisplayBlock): text = block.command.rstrip("\n") self._content_blocks.append( _ApprovalContentBlock( text=text, lines=text.count("\n") + 1, lexer=block.language ) ) # ... self._total_lines = sum(b.lines for b in self._content_blocks) self.has_expandable_content = self._total_lines > MAX_PREVIEW_LINES ``` ### 3. 统一行预算渲染 ```python def render(self) -> RenderableType: content_lines: list[RenderableType] = [ Text.from_markup( "[yellow]⚠ " f"{escape(self.request.sender)} is requesting approval to " f"{escape(self.request.action)}:[/yellow]" ) ] content_lines.append(Text("")) # Render content with line budget remaining = MAX_PREVIEW_LINES for block in self._content_blocks: if remaining <= 0: break content_lines.append(self._render_block(block, remaining)) remaining -= min(block.lines, remaining) if self.has_expandable_content: content_lines.append( Text("... (truncated, ctrl-e to expand)", style="dim italic") ) # ... menu options ... return Padding(Group(*lines), 1) ``` ### 4. Pager 复用预渲染内容 ```python def render_full(self) -> list[RenderableType]: """Render full content for pager (no truncation).""" return [self._render_block(block) for block in self._content_blocks] def _show_approval_in_pager(panel: _ApprovalRequestPanel) -> None: """Show the full approval request content in a pager.""" with console.screen(), console.pager(styles=True): # Header console.print( Text.from_markup( "[yellow]⚠ " f"{escape(panel.request.sender)} is requesting approval to " f"{escape(panel.request.action)}:[/yellow]" ) ) console.print() # Render full content (no truncation) for renderable in panel.render_full(): console.print(renderable) ``` ### 5. KeyboardListener 支持 Pause/Resume 为了在 pager 活动时暂停键盘监听,新增 `KeyboardListener` 类: ```python class KeyboardListener: async def start(self) -> None: ... async def stop(self) -> None: ... async def pause(self) -> None: ... async def resume(self) -> None: ... async def get(self) -> KeyEvent: ... ``` 键盘处理中使用 pause/resume: ```python async def keyboard_handler(listener: KeyboardListener, event: KeyEvent) -> None: if event == KeyEvent.CTRL_E: if ( self._current_approval_request_panel and self._current_approval_request_panel.has_expandable_content ): await listener.pause() live.stop() try: _show_approval_in_pager(self._current_approval_request_panel) finally: self._reset_live_shape(live) live.start() live.update(self.compose(), refresh=True) await listener.resume() return # ... handle other events ... ``` ## 变更范围 | 文件 | 变更 | |------|------| | `tools/display.py` | 新增 `ShellDisplayBlock` | | `ui/shell/visualize.py` | 预渲染内容块、统一行预算、pager 展开、无边框设计 | | `ui/shell/keyboard.py` | 新增 `KeyboardListener` 类支持 pause/resume、添加 `CTRL_E` 事件 | | `tools/shell/__init__.py` | 使用 `ShellDisplayBlock` 传递命令 | | `utils/diff.py` | 新增 `format_unified_diff` 函数 | | `utils/rich/syntax.py` | 新增 `KimiSyntax` 支持自定义主题 | ## 设计决策 1. **Ctrl+E 而非 Ctrl+O**:E 代表 Expand,更直观 2. **无边框设计**:移除 Panel 边框,使用 Padding,更简洁 3. **统一行预算**:所有内容共享 4 行预算,避免多个 block 导致高度爆炸 4. **简化截断提示**:只显示 `... (truncated, ctrl-e to expand)`,不显示具体行数 5. **预渲染复用**:preview 和 pager 共享预渲染的内容块,避免重复计算 6. **同文件多 hunk**:使用 `⋮` 表示省略的中间行,而非重复显示文件名 ## 边界情况 1. **短内容**:如果内容不需要截断,不显示截断提示,`has_expandable_content` 为 False 2. **无 display**:如果只有 description 没有 display blocks,也正确处理 3. **多个 DiffDisplayBlock**:统一行预算,可能只显示第一个 block 的部分内容 4. **Pager 不可用**:Rich 会 fallback 到直接输出 ## 测试计划 1. 短命令的 approval request(不截断) 2. 长命令的 approval request(截断 + Ctrl+E 展开) 3. 文件编辑的 approval request(diff 显示 + Ctrl+E 展开) 4. 同一文件多个 hunk(显示 `⋮`) 5. 从 pager 返回后 Live display 正常工作 6. 在 pager 中按 q 退出、按 / 搜索等 ================================================ FILE: packages/kaos/.pre-commit-config.yaml ================================================ orphan: true repos: - repo: local hooks: - id: make-format-pykaos name: make format-pykaos entry: make -C ../.. format-pykaos language: system pass_filenames: false - id: make-check-pykaos name: make check-pykaos entry: make -C ../.. check-pykaos language: system pass_filenames: false ================================================ FILE: packages/kaos/CHANGELOG.md ================================================ # Changelog ## Unreleased ## 0.7.0 (2026-02-06) - Add `env` parameter to `exec()` method for passing environment variables to subprocesses ## 0.6.0 (2026-01-09) - Add optional `n` parameter to `readbytes` to read only the first n bytes ## 0.5.4 (2026-01-06) - Relax `aiofiles` dependency version to `>=24.0,<26.0` ## 0.5.3 (2025-12-29) - Add `host` property to `SSHKaos` ## 0.5.2 (2025-12-17) - Fix `SSHKaos.Process.wait` to not drain stdout/stderr buffers - Return 1 as return code if `SSHKaos.Process.wait` does not get a return code ## 0.5.1 (2025-12-15) - Fix unhandled exception thrown by `SSHKaos.stat` when the file does not exist - Fix `SSHKaos.exec` without CWD - Fix `SSHKaos.iterdir` to return `KaosPath` ## 0.5.0 (2025-12-12) - Move `KaosProcess` to `Kaos.Process` - Add `AsyncReadable` and `AsyncWritable` protocols - Add `SSHKaos` implementation - Lower the required Python version to 3.12 ## 0.4.0 (2025-12-06) - Add `Kaos.exec` method for executing commands - Add `StepResult` as the return type for `Kaos.stat` ## 0.3.0 (2025-12-03) - Change `iterdir`, `glob` and `read_lines` to sync function returning `AsyncIterator` ## 0.2.0 (2025-12-01) - Initial release with `Kaos` protocol, `LocalKaos` implementation, and `KaosPath` for convenient file operations ================================================ FILE: packages/kaos/LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: packages/kaos/NOTICE ================================================ PyKAOS Copyright 2025 Moonshot AI This product includes software developed at Moonshot AI (https://www.moonshot.ai/). ================================================ FILE: packages/kaos/README.md ================================================ # PyKAOS PyKAOS is a lightweight Python library providing an abstraction layer for agents to interact with operating systems. File operations and command executions via KAOS can be easily switched between local environment and remote systems over SSH. ================================================ FILE: packages/kaos/pyproject.toml ================================================ [project] name = "pykaos" version = "0.7.0" description = "" readme = "README.md" requires-python = ">=3.12" dependencies = [ "aiofiles>=24.0,<26.0", "asyncssh==2.21.1", ] [dependency-groups] dev = [ "inline-snapshot[black]>=0.31.1", "pyright>=1.1.407", "ty>=0.0.7", "pytest>=9.0.2", "pytest-asyncio>=1.3.0", "ruff>=0.14.9", ] [build-system] requires = ["uv_build>=0.8.5,<0.9.0"] build-backend = "uv_build" [tool.uv.build-backend] module-name = ["kaos"] source-exclude = ["tests/**/*"] [tool.ruff] line-length = 100 [tool.ruff.lint] select = [ "E", # pycodestyle "F", # Pyflakes "UP", # pyupgrade "B", # flake8-bugbear "SIM", # flake8-simplify "I", # isort ] [tool.pyright] typeCheckingMode = "strict" pythonVersion = "3.14" include = [ "src/**/*.py", "tests/**/*.py", ] [tool.ty.environment] python-version = "3.14" [tool.ty.src] include = [ "src/**/*.py", "tests/**/*.py", ] ================================================ FILE: packages/kaos/src/kaos/__init__.py ================================================ from __future__ import annotations import contextvars from collections.abc import AsyncGenerator, AsyncIterator, Iterable, Mapping from dataclasses import dataclass from pathlib import PurePath from typing import TYPE_CHECKING, Literal, Protocol, runtime_checkable if TYPE_CHECKING: from asyncio import StreamReader, StreamWriter from asyncssh.stream import SSHReader, SSHWriter from kaos.path import KaosPath def type_check( stream_reader: StreamReader, stream_writer: StreamWriter, ssh_reader: SSHReader[bytes], ssh_writer: SSHWriter[bytes], ): _reader: AsyncReadable = stream_reader _reader = ssh_reader _writer: AsyncWritable = stream_writer _writer = ssh_writer type StrOrKaosPath = str | KaosPath @runtime_checkable class AsyncReadable(Protocol): """Protocol describing readable async byte streams.""" def __aiter__(self) -> AsyncIterator[bytes]: """Yield chunks (typically lines) as they arrive.""" ... def at_eof(self) -> bool: """Return True when the stream has reached EOF and buffer is empty.""" ... def feed_data(self, data: bytes) -> None: """Inject data into the stream; mainly for testing or adapters.""" ... def feed_eof(self) -> None: """Signal end-of-file to the stream.""" ... async def read(self, n: int = -1) -> bytes: """Read up to n bytes; -1 reads until EOF.""" ... async def readline(self) -> bytes: """Read a single line ending with newline or EOF.""" ... async def readexactly(self, n: int) -> bytes: """Read exactly n bytes or raise IncompleteReadError.""" ... async def readuntil(self, separator: bytes) -> bytes: """Read until separator is encountered, including the separator.""" ... @runtime_checkable class AsyncWritable(Protocol): """Protocol describing writable async byte streams.""" def can_write_eof(self) -> bool: """Return True if write_eof() is supported.""" ... def close(self) -> None: """Schedule closing of the underlying transport.""" ... async def drain(self) -> None: """Block until the internal write buffer is flushed.""" ... def is_closing(self) -> bool: """Return True once the stream has been closed or is closing.""" ... async def wait_closed(self) -> None: """Wait until the closing handshake completes.""" ... def write(self, data: bytes) -> None: """Write raw bytes to the stream.""" ... def writelines(self, data: Iterable[bytes], /) -> None: """Write an iterable of byte chunks to the stream.""" ... def write_eof(self) -> None: """Send EOF to the underlying transport if supported.""" ... @runtime_checkable class KaosProcess(Protocol): """Process interface exposed by KAOS `exec` implementations.""" stdin: AsyncWritable stdout: AsyncReadable stderr: AsyncReadable @property def pid(self) -> int: """Get the process ID.""" ... @property def returncode(self) -> int | None: """Get the process return code, or None if it is still running.""" ... async def wait(self) -> int: """Wait for the process to complete and return the exit code.""" ... async def kill(self) -> None: """Kill the process.""" ... @runtime_checkable class Kaos(Protocol): """Kimi Agent Operating System (KAOS) interface.""" name: str """The name of the KAOS implementation.""" def pathclass(self) -> type[PurePath]: """Get the path class used under `KaosPath`.""" ... def normpath(self, path: StrOrKaosPath) -> KaosPath: """Normalize path, eliminating double slashes, etc.""" ... def gethome(self) -> KaosPath: """Get the home directory path.""" ... def getcwd(self) -> KaosPath: """Get the current working directory path.""" ... async def chdir(self, path: StrOrKaosPath) -> None: """Change the current working directory.""" ... async def stat(self, path: StrOrKaosPath, *, follow_symlinks: bool = True) -> StatResult: """Get the stat result for a path.""" ... def iterdir(self, path: StrOrKaosPath) -> AsyncGenerator[KaosPath]: """Iterate over the entries in a directory.""" ... def glob( self, path: StrOrKaosPath, pattern: str, *, case_sensitive: bool = True ) -> AsyncGenerator[KaosPath]: """Search for files/directories matching a pattern in the given path.""" ... async def readbytes(self, path: StrOrKaosPath, n: int | None = None) -> bytes: """Read the entire file contents as bytes, or the first n bytes if provided.""" ... async def readtext( self, path: StrOrKaosPath, *, encoding: str = "utf-8", errors: Literal["strict", "ignore", "replace"] = "strict", ) -> str: """Read the entire file contents as text.""" ... def readlines( self, path: StrOrKaosPath, *, encoding: str = "utf-8", errors: Literal["strict", "ignore", "replace"] = "strict", ) -> AsyncGenerator[str]: """Iterate over the lines of the file.""" ... async def writebytes(self, path: StrOrKaosPath, data: bytes) -> int: """Write bytes data to the file.""" ... async def writetext( self, path: StrOrKaosPath, data: str, *, mode: Literal["w", "a"] = "w", encoding: str = "utf-8", errors: Literal["strict", "ignore", "replace"] = "strict", ) -> int: """Write text data to the file, returning the number of characters written.""" ... async def mkdir( self, path: StrOrKaosPath, parents: bool = False, exist_ok: bool = False ) -> None: """Create a directory at the given path.""" ... async def exec(self, *args: str, env: Mapping[str, str] | None = None) -> KaosProcess: """ Execute a command with arguments and return the running process. Args: *args: Command and its arguments. env: Environment variables for the subprocess. If None, inherits from the parent process. """ ... @dataclass class StatResult: """KAOS stat result data class.""" st_mode: int st_ino: int st_dev: int st_nlink: int st_uid: int st_gid: int st_size: int st_atime: float st_mtime: float st_ctime: float def get_current_kaos() -> Kaos: """Get the current KAOS instance.""" from kaos._current import current_kaos return current_kaos.get() def set_current_kaos(kaos: Kaos) -> contextvars.Token[Kaos]: """Set the current KAOS instance.""" from kaos._current import current_kaos return current_kaos.set(kaos) def reset_current_kaos(token: contextvars.Token[Kaos]) -> None: """Reset the current KAOS instance.""" from kaos._current import current_kaos current_kaos.reset(token) def pathclass() -> type[PurePath]: return get_current_kaos().pathclass() def normpath(path: StrOrKaosPath) -> KaosPath: return get_current_kaos().normpath(path) def gethome() -> KaosPath: return get_current_kaos().gethome() def getcwd() -> KaosPath: return get_current_kaos().getcwd() async def chdir(path: StrOrKaosPath) -> None: await get_current_kaos().chdir(path) async def stat(path: StrOrKaosPath, *, follow_symlinks: bool = True) -> StatResult: return await get_current_kaos().stat(path, follow_symlinks=follow_symlinks) def iterdir(path: StrOrKaosPath) -> AsyncGenerator[KaosPath]: return get_current_kaos().iterdir(path) def glob( path: StrOrKaosPath, pattern: str, *, case_sensitive: bool = True ) -> AsyncGenerator[KaosPath]: return get_current_kaos().glob(path, pattern, case_sensitive=case_sensitive) async def readbytes(path: StrOrKaosPath, n: int | None = None) -> bytes: return await get_current_kaos().readbytes(path, n=n) async def readtext( path: StrOrKaosPath, *, encoding: str = "utf-8", errors: Literal["strict", "ignore", "replace"] = "strict", ) -> str: return await get_current_kaos().readtext(path, encoding=encoding, errors=errors) def readlines( path: StrOrKaosPath, *, encoding: str = "utf-8", errors: Literal["strict", "ignore", "replace"] = "strict", ) -> AsyncGenerator[str]: return get_current_kaos().readlines(path, encoding=encoding, errors=errors) async def writebytes(path: StrOrKaosPath, data: bytes) -> int: return await get_current_kaos().writebytes(path, data) async def writetext( path: StrOrKaosPath, data: str, *, mode: Literal["w", "a"] = "w", encoding: str = "utf-8", errors: Literal["strict", "ignore", "replace"] = "strict", ) -> int: return await get_current_kaos().writetext( path, data, mode=mode, encoding=encoding, errors=errors ) async def mkdir(path: StrOrKaosPath, parents: bool = False, exist_ok: bool = False) -> None: return await get_current_kaos().mkdir(path, parents=parents, exist_ok=exist_ok) async def exec(*args: str, env: Mapping[str, str] | None = None) -> KaosProcess: return await get_current_kaos().exec(*args, env=env) ================================================ FILE: packages/kaos/src/kaos/_current.py ================================================ from contextvars import ContextVar from kaos import Kaos from kaos.local import local_kaos current_kaos = ContextVar[Kaos]("current_kaos", default=local_kaos) ================================================ FILE: packages/kaos/src/kaos/local.py ================================================ from __future__ import annotations import asyncio import os from asyncio.subprocess import Process as AsyncioProcess from collections.abc import AsyncGenerator from pathlib import Path, PurePath from typing import TYPE_CHECKING, Literal if os.name == "nt": import ntpath as pathmodule from pathlib import PureWindowsPath as PurePathClass else: import posixpath as pathmodule from pathlib import PurePosixPath as PurePathClass from collections.abc import Mapping import aiofiles import aiofiles.os from kaos import AsyncReadable, AsyncWritable, Kaos, KaosProcess, StatResult, StrOrKaosPath from kaos.path import KaosPath if TYPE_CHECKING: def type_check(local: LocalKaos) -> None: _: Kaos = local class LocalKaos: """ A KAOS implementation that directly interacts with the local filesystem. """ name: str = "local" class Process: """Local KAOS process wrapper around asyncio.subprocess.Process.""" def __init__(self, process: AsyncioProcess) -> None: if process.stdin is None or process.stdout is None or process.stderr is None: raise ValueError("Process must be created with stdin/stdout/stderr pipes.") self._process = process self.stdin: AsyncWritable = process.stdin self.stdout: AsyncReadable = process.stdout self.stderr: AsyncReadable = process.stderr @property def pid(self) -> int: return self._process.pid @property def returncode(self) -> int | None: return self._process.returncode async def wait(self) -> int: return await self._process.wait() async def kill(self) -> None: self._process.kill() def pathclass(self) -> type[PurePath]: return PurePathClass def normpath(self, path: StrOrKaosPath) -> KaosPath: return KaosPath(pathmodule.normpath(str(path))) def gethome(self) -> KaosPath: return KaosPath.unsafe_from_local_path(Path.home()) def getcwd(self) -> KaosPath: return KaosPath.unsafe_from_local_path(Path.cwd()) async def chdir(self, path: StrOrKaosPath) -> None: local_path = path.unsafe_to_local_path() if isinstance(path, KaosPath) else Path(path) os.chdir(local_path) async def stat(self, path: StrOrKaosPath, *, follow_symlinks: bool = True) -> StatResult: local_path = path.unsafe_to_local_path() if isinstance(path, KaosPath) else Path(path) st = await aiofiles.os.stat(local_path, follow_symlinks=follow_symlinks) return StatResult( st_mode=st.st_mode, st_ino=st.st_ino, st_dev=st.st_dev, st_nlink=st.st_nlink, st_uid=st.st_uid, st_gid=st.st_gid, st_size=st.st_size, st_atime=st.st_atime, st_mtime=st.st_mtime, st_ctime=st.st_ctime if os.name != "nt" else st.st_birthtime, ) async def iterdir(self, path: StrOrKaosPath) -> AsyncGenerator[KaosPath]: local_path = path.unsafe_to_local_path() if isinstance(path, KaosPath) else Path(path) for entry in await aiofiles.os.listdir(local_path): yield KaosPath.unsafe_from_local_path(local_path / entry) async def glob( self, path: StrOrKaosPath, pattern: str, *, case_sensitive: bool = True ) -> AsyncGenerator[KaosPath]: local_path = path.unsafe_to_local_path() if isinstance(path, KaosPath) else Path(path) entries = await asyncio.to_thread( lambda: list(local_path.glob(pattern, case_sensitive=case_sensitive)) ) for entry in entries: yield KaosPath.unsafe_from_local_path(entry) async def readbytes(self, path: StrOrKaosPath, n: int | None = None) -> bytes: local_path = path.unsafe_to_local_path() if isinstance(path, KaosPath) else Path(path) async with aiofiles.open(local_path, mode="rb") as f: return await f.read() if n is None else await f.read(n) async def readtext( self, path: str | KaosPath, *, encoding: str = "utf-8", errors: Literal["strict", "ignore", "replace"] = "strict", ) -> str: local_path = path.unsafe_to_local_path() if isinstance(path, KaosPath) else Path(path) async with aiofiles.open(local_path, encoding=encoding, errors=errors) as f: return await f.read() async def readlines( self, path: str | KaosPath, *, encoding: str = "utf-8", errors: Literal["strict", "ignore", "replace"] = "strict", ) -> AsyncGenerator[str]: local_path = path.unsafe_to_local_path() if isinstance(path, KaosPath) else Path(path) async with aiofiles.open(local_path, encoding=encoding, errors=errors) as f: async for line in f: yield line async def writebytes(self, path: StrOrKaosPath, data: bytes) -> int: local_path = path.unsafe_to_local_path() if isinstance(path, KaosPath) else Path(path) async with aiofiles.open(local_path, mode="wb") as f: return await f.write(data) async def writetext( self, path: str | KaosPath, data: str, *, mode: Literal["w"] | Literal["a"] = "w", encoding: str = "utf-8", errors: Literal["strict", "ignore", "replace"] = "strict", ) -> int: local_path = path.unsafe_to_local_path() if isinstance(path, KaosPath) else Path(path) async with aiofiles.open(local_path, mode=mode, encoding=encoding, errors=errors) as f: return await f.write(data) async def mkdir( self, path: StrOrKaosPath, parents: bool = False, exist_ok: bool = False ) -> None: local_path = path.unsafe_to_local_path() if isinstance(path, KaosPath) else Path(path) await asyncio.to_thread(local_path.mkdir, parents=parents, exist_ok=exist_ok) async def exec(self, *args: str, env: Mapping[str, str] | None = None) -> KaosProcess: if not args: raise ValueError("At least one argument (the program to execute) is required.") process = await asyncio.create_subprocess_exec( *args, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=env, ) return self.Process(process) local_kaos = LocalKaos() """The default local KAOS instance.""" ================================================ FILE: packages/kaos/src/kaos/path.py ================================================ from __future__ import annotations from collections.abc import AsyncGenerator from pathlib import Path, PurePath from stat import S_ISDIR, S_ISREG from typing import Any, Literal import kaos class KaosPath: """ A path abstraction for KAOS filesystem. """ def __init__(self, *args: str) -> None: self._path: PurePath = kaos.pathclass()(*args) @classmethod def unsafe_from_local_path(cls, path: Path) -> KaosPath: """ Create a `KaosPath` from a local `Path`. Only use this if you are sure that `LocalKaos` is being used. """ return cls(str(path)) def unsafe_to_local_path(self) -> Path: """ Convert the `KaosPath` to a local `Path`. Only use this if you are sure that `LocalKaos` is being used. """ return Path(str(self._path)) def __lt__(self, other: KaosPath) -> bool: return self._path.__lt__(other._path) def __le__(self, other: KaosPath) -> bool: return self._path.__le__(other._path) def __gt__(self, other: KaosPath) -> bool: return self._path.__gt__(other._path) def __ge__(self, other: KaosPath) -> bool: return self._path.__ge__(other._path) def __eq__(self, other: Any) -> bool: if not isinstance(other, KaosPath): return NotImplemented return self._path.__eq__(other._path) def __repr__(self) -> str: return f"KaosPath({repr(str(self._path))})" def __str__(self) -> str: return str(self._path) @property def name(self) -> str: """Return the final component of the path.""" return self._path.name @property def parent(self) -> KaosPath: """Return the parent directory of the path.""" return KaosPath(str(self._path.parent)) def is_absolute(self) -> bool: """Return True if the path is absolute.""" return self._path.is_absolute() def joinpath(self, *other: str) -> KaosPath: """Join this path with other path components.""" return KaosPath(str(self._path.joinpath(*other))) def __truediv__(self, other: str | KaosPath) -> KaosPath: """Join this path with another path using the `/` operator.""" p = other._path if isinstance(other, KaosPath) else other ret = KaosPath() ret._path = self._path.__truediv__(p) return ret def canonical(self) -> KaosPath: """ Make the path absolute, resolving all `.` and `..` in the path. Unlike `pathlib.Path.resolve`, this method does not resolve symlinks. """ abs_path = self if self.is_absolute() else kaos.getcwd().joinpath(str(self._path)) # Normalize the path (handle . and ..) but preserve the format normalized = kaos.normpath(abs_path) # `normpath` might strip trailing slash, but we want to preserve it for directories # However, since we don't access the filesystem, we can't know if it's a directory # So we follow the pathlib behavior which doesn't preserve trailing slashes return normalized def relative_to(self, other: KaosPath) -> KaosPath: """Return the relative path from `other` to this path.""" relative_path = self._path.relative_to(other._path) return KaosPath(str(relative_path)) @classmethod def home(cls) -> KaosPath: """Return the home directory as a KaosPath.""" return kaos.gethome() @classmethod def cwd(cls) -> KaosPath: """Return the current working directory as a KaosPath.""" return kaos.getcwd() def expanduser(self) -> KaosPath: """Expand `~` to the backend home directory.""" parts = self._path.parts if not parts or parts[0] != "~": return self home = KaosPath.home() if len(parts) == 1: return home return home.joinpath(*parts[1:]) async def stat(self, follow_symlinks: bool = True) -> kaos.StatResult: """Return an os.stat_result for the path.""" return await kaos.stat(self, follow_symlinks=follow_symlinks) async def exists(self, *, follow_symlinks: bool = True) -> bool: """Return True if the path points to an existing filesystem entry.""" try: await self.stat(follow_symlinks=follow_symlinks) return True except OSError: return False async def is_file(self, *, follow_symlinks: bool = True) -> bool: """Return True if the path points to a regular file.""" try: st = await self.stat(follow_symlinks=follow_symlinks) return S_ISREG(st.st_mode) except OSError: return False async def is_dir(self, *, follow_symlinks: bool = True) -> bool: """Return True if the path points to a directory.""" try: st = await self.stat(follow_symlinks=follow_symlinks) return S_ISDIR(st.st_mode) except OSError: return False def iterdir(self) -> AsyncGenerator[KaosPath]: """Return the direct children of the directory.""" return kaos.iterdir(self) def glob(self, pattern: str, *, case_sensitive: bool = True) -> AsyncGenerator[KaosPath]: """Return all paths matching the pattern under this directory.""" return kaos.glob(self, pattern, case_sensitive=case_sensitive) async def read_bytes(self, n: int | None = None) -> bytes: """Read the entire file contents as bytes, or the first n bytes if provided.""" return await kaos.readbytes(self, n=n) async def read_text( self, *, encoding: str = "utf-8", errors: Literal["strict", "ignore", "replace"] = "strict", ) -> str: """Read the entire file contents as text.""" return await kaos.readtext(self, encoding=encoding, errors=errors) def read_lines( self, *, encoding: str = "utf-8", errors: Literal["strict", "ignore", "replace"] = "strict", ) -> AsyncGenerator[str]: """Iterate over the lines of the file.""" return kaos.readlines(self, encoding=encoding, errors=errors) async def write_bytes(self, data: bytes) -> int: """Write bytes data to the file.""" return await kaos.writebytes(self, data) async def write_text( self, data: str, *, encoding: str = "utf-8", errors: Literal["strict", "ignore", "replace"] = "strict", ) -> int: """Write text data to the file, returning the number of characters written.""" return await kaos.writetext( self, data, mode="w", encoding=encoding, errors=errors, ) async def append_text( self, data: str, *, encoding: str = "utf-8", errors: Literal["strict", "ignore", "replace"] = "strict", ) -> int: """Append text data to the file, returning the number of characters written.""" return await kaos.writetext( self, data, mode="a", encoding=encoding, errors=errors, ) async def mkdir(self, parents: bool = False, exist_ok: bool = False) -> None: """Create a directory at this path.""" return await kaos.mkdir(self, parents=parents, exist_ok=exist_ok) ================================================ FILE: packages/kaos/src/kaos/py.typed ================================================ ================================================ FILE: packages/kaos/src/kaos/ssh.py ================================================ from __future__ import annotations import posixpath import shlex import stat from collections.abc import AsyncGenerator, Mapping from pathlib import PurePath, PurePosixPath from typing import TYPE_CHECKING, Literal import asyncssh from asyncssh.constants import ( FILEXFER_TYPE_BLOCK_DEVICE, FILEXFER_TYPE_CHAR_DEVICE, FILEXFER_TYPE_DIRECTORY, FILEXFER_TYPE_FIFO, FILEXFER_TYPE_REGULAR, FILEXFER_TYPE_SOCKET, FILEXFER_TYPE_SYMLINK, ) from kaos import AsyncReadable, AsyncWritable, Kaos, KaosProcess, StatResult, StrOrKaosPath from kaos.path import KaosPath if TYPE_CHECKING: def type_check(ssh: SSHKaos) -> None: _: Kaos = ssh _FILEXFER_TYPE_TO_MODE = { FILEXFER_TYPE_REGULAR: stat.S_IFREG, FILEXFER_TYPE_DIRECTORY: stat.S_IFDIR, FILEXFER_TYPE_SYMLINK: stat.S_IFLNK, FILEXFER_TYPE_SOCKET: stat.S_IFSOCK, FILEXFER_TYPE_CHAR_DEVICE: stat.S_IFCHR, FILEXFER_TYPE_BLOCK_DEVICE: stat.S_IFBLK, FILEXFER_TYPE_FIFO: stat.S_IFIFO, } def _build_st_mode(attrs: asyncssh.SFTPAttrs) -> int: """Combine SFTP permissions and type information into st_mode.""" perm_mode = attrs.permissions or 0 type_mode = _FILEXFER_TYPE_TO_MODE.get(attrs.type, 0) if perm_mode: if type_mode and stat.S_IFMT(perm_mode) == 0: perm_mode |= type_mode return perm_mode return type_mode def _sec_with_nanos(sec: int, ns: int | None) -> float: if ns is None: return float(sec) return float(sec) + (ns / 1_000_000_000.0) class SSHKaos: """ A KAOS implementation that interacts with a remote machine via SSH and SFTP. """ name: str = "ssh" class Process: """KAOS process wrapper around asyncssh.SSHClientProcess.""" def __init__(self, process: asyncssh.SSHClientProcess[bytes]) -> None: self._process = process self.stdin: AsyncWritable = process.stdin self.stdout: AsyncReadable = process.stdout self.stderr: AsyncReadable = process.stderr @property def pid(self) -> int: # FIXME: SSHClientProcess does not have a pid attribute. return -1 @property def returncode(self) -> int | None: return self._process.returncode async def wait(self) -> int: # asyncssh.SSHClientProcess.wait() drains stdout/stderr via communicate() # which clears the internal receive buffers. Use wait_closed() so # stdout/stderr remain readable after wait, matching LocalKaos. await self._process.wait_closed() return 1 if self._process.returncode is None else self._process.returncode async def kill(self) -> None: self._process.kill() @classmethod async def create( cls, host: str, *, port: int = 22, username: str | None = None, password: str | None = None, key_paths: list[str] | None = None, key_contents: list[str] | None = None, cwd: str | None = None, **extra_options: object, ): options = { "host": host, "port": port, **extra_options, } if username: options["username"] = username if password: options["password"] = password client_keys: list[str | asyncssh.SSHKey] = [] if key_contents: client_keys.extend([asyncssh.import_private_key(key) for key in key_contents]) if key_paths: client_keys.extend(key_paths) if client_keys: options["client_keys"] = client_keys # Ensure encoding is None to read/write bytes options["encoding"] = None # Known hosts is None to avoid the "Host key is not trusted" error options["known_hosts"] = None # Connect to ssh connection = await asyncssh.connect(**options) sftp = await connection.start_sftp_client() home_dir = await sftp.realpath(".") if cwd is not None: await sftp.chdir(cwd) cwd = await sftp.realpath(".") else: cwd = home_dir return cls(connection=connection, sftp=sftp, home=home_dir, cwd=cwd, host=host) def __init__( self, *, connection: asyncssh.SSHClientConnection, sftp: asyncssh.SFTPClient, home: str, cwd: str, host: str, ) -> None: self._connection = connection self._sftp = sftp self._home_dir = home self._cwd = cwd self._host = host @property def host(self) -> str: return self._host def pathclass(self) -> type[PurePath]: return PurePosixPath def normpath(self, path: StrOrKaosPath) -> KaosPath: return KaosPath(posixpath.normpath(str(path))) def gethome(self) -> KaosPath: return KaosPath(self._home_dir) def getcwd(self) -> KaosPath: return KaosPath(self._cwd) async def chdir(self, path: StrOrKaosPath) -> None: await self._sftp.chdir(str(path)) self._cwd = await self._sftp.realpath(".") async def stat( self, path: StrOrKaosPath, *, follow_symlinks: bool = True, ) -> StatResult: try: st = await self._sftp.stat(str(path), follow_symlinks=follow_symlinks) except asyncssh.SFTPError as e: raise OSError from e return StatResult( st_mode=_build_st_mode(st), st_uid=st.uid or 0, st_gid=st.gid or 0, st_size=st.size or 0, st_atime=_sec_with_nanos(st.atime or 0, st.atime_ns), st_mtime=_sec_with_nanos(st.mtime or 0, st.mtime_ns), st_ctime=_sec_with_nanos(st.ctime or 0, st.ctime_ns), st_ino=0, # sftp does not support ino st_dev=0, # sftp does not support dev st_nlink=st.nlink or 0, ) async def iterdir(self, path: StrOrKaosPath) -> AsyncGenerator[KaosPath]: kaos_path = KaosPath(path) if isinstance(path, str) else path for entry in await self._sftp.listdir(str(path)): # NOTE: sftp listdir gives . and .. if entry in {".", ".."}: continue yield kaos_path / entry async def glob( self, path: StrOrKaosPath, pattern: str, *, case_sensitive: bool = True, ) -> AsyncGenerator[KaosPath]: if not case_sensitive: raise ValueError("Case insensitive glob is not supported in current environment") real_path = await self._sftp.realpath(str(path)) for entry in await self._sftp.glob(f"{real_path}/{pattern}"): yield KaosPath(await self._sftp.realpath(str(entry))) async def readbytes(self, path: StrOrKaosPath, n: int | None = None) -> bytes: async with self._sftp.open(str(path), "rb") as f: return await f.read() if n is None else await f.read(n) async def readtext( self, path: str | KaosPath, *, encoding: str = "utf-8", errors: Literal["strict", "ignore", "replace"] = "strict", ) -> str: async with self._sftp.open(str(path), "r", encoding=encoding, errors=errors) as f: return await f.read() async def readlines( self, path: str | KaosPath, *, encoding: str = "utf-8", errors: Literal["strict", "ignore", "replace"] = "strict", ) -> AsyncGenerator[str]: # NOTE: readlines is not supported by SFTPClientFile text = await self.readtext(path, encoding=encoding, errors=errors) for line in text.splitlines(): yield line async def writebytes(self, path: StrOrKaosPath, data: bytes) -> int: async with self._sftp.open(str(path), "wb") as f: return await f.write(data) async def writetext( self, path: str | KaosPath, data: str, *, mode: Literal["w"] | Literal["a"] = "w", encoding: str = "utf-8", errors: Literal["strict", "ignore", "replace"] = "strict", ) -> int: async with self._sftp.open(str(path), mode, encoding=encoding, errors=errors) as f: return await f.write(data) async def mkdir( self, path: StrOrKaosPath, parents: bool = False, exist_ok: bool = False, ) -> None: if parents: await self._sftp.makedirs(str(path), exist_ok=exist_ok) else: existed = await self._sftp.exists(str(path)) if existed and not exist_ok: raise FileExistsError(f"{path} already exists") await self._sftp.mkdir(str(path)) async def exec(self, *args: str, env: Mapping[str, str] | None = None) -> KaosProcess: if not args: raise ValueError("At least one argument (the program to execute) is required.") command = " ".join(shlex.quote(arg) for arg in args) # NOTE: # - SFTP has its own concept of working directory; it does not affect SSH exec. # - To make exec behave like other KAOS backends, we explicitly `cd` to our tracked # cwd before running the command. # # This is intentionally strict: if cwd doesn't exist, the command fails. if self._cwd: command = f"cd {shlex.quote(self._cwd)} && {command}" process = await self._connection.create_process(command, encoding=None, env=env) return self.Process(process) async def unsafe_close(self) -> None: """Close the SSH connection. After that, SSHKaos will be unusable.""" if self._sftp: self._sftp.exit() if self._connection: self._connection.close() ================================================ FILE: packages/kaos/tests/test_kaos_path.py ================================================ from __future__ import annotations import os from collections.abc import Generator from pathlib import Path import pytest from kaos import reset_current_kaos, set_current_kaos from kaos.local import LocalKaos from kaos.path import KaosPath @pytest.fixture def kaos_cwd(tmp_path: Path) -> Generator[KaosPath]: """Set LocalKaos as the current Kaos and switch cwd to a temp directory.""" token = set_current_kaos(LocalKaos()) old_cwd = Path.cwd() try: os.chdir(tmp_path) yield KaosPath.unsafe_from_local_path(tmp_path) finally: os.chdir(old_cwd) reset_current_kaos(token) def test_join_and_parent(kaos_cwd: KaosPath): base = KaosPath("folder") child = base / "data.txt" assert str(child) == str(Path("folder") / "data.txt") assert child.parent == KaosPath("folder") assert child.name == "data.txt" assert not child.is_absolute() def test_home_and_cwd(kaos_cwd: KaosPath): assert str(KaosPath.home()) == str(Path.home()) assert str(KaosPath.cwd()) == str(kaos_cwd) def test_expanduser(kaos_cwd: KaosPath): home = KaosPath.home() assert str(KaosPath("~").expanduser()) == str(home) assert str(KaosPath("~/docs").expanduser()) == str(home / "docs") def test_canonical_and_relative_to(kaos_cwd: KaosPath): canonical = KaosPath("nested/../file.txt").canonical() assert str(canonical) == str(kaos_cwd / "file.txt") base = KaosPath(str(kaos_cwd / "base")) child = base / "inner" / "note.txt" relative = child.relative_to(base) assert str(relative) == str(KaosPath("inner") / "note.txt") async def test_exists_and_file_ops(kaos_cwd: KaosPath): file_path = KaosPath("log.txt") assert not await file_path.exists() await file_path.write_text("hello") assert await file_path.exists() assert await file_path.is_file() assert not await file_path.is_dir() await file_path.append_text("\nworld") assert await file_path.read_text() == "hello\nworld" dir_path = KaosPath("logs") await dir_path.mkdir() assert await dir_path.exists() assert await dir_path.is_dir() async def test_iterdir_and_glob_from_kaos_path(kaos_cwd: KaosPath): base_dir = KaosPath("data") await base_dir.mkdir() await (base_dir / "one.txt").write_text("1") await (base_dir / "two.md").write_text("2") await (base_dir / "three.txt").write_text("3") entries = [entry.name async for entry in base_dir.iterdir()] assert set(entries) == {"one.txt", "two.md", "three.txt"} globbed = [entry.name async for entry in base_dir.glob("*.txt")] assert set(globbed) == {"one.txt", "three.txt"} async def test_read_write_bytes(kaos_cwd: KaosPath): file_path = KaosPath("data.bin") await file_path.write_bytes(b"\x00\x01\xff") assert await file_path.read_bytes() == b"\x00\x01\xff" ================================================ FILE: packages/kaos/tests/test_local_kaos.py ================================================ from __future__ import annotations import asyncio import os import sys from collections.abc import Generator from pathlib import Path, PurePosixPath, PureWindowsPath import pytest from kaos import reset_current_kaos, set_current_kaos from kaos.local import LocalKaos from kaos.path import KaosPath @pytest.fixture def local_kaos(tmp_path: Path) -> Generator[LocalKaos]: """Set LocalKaos as the current Kaos and switch cwd to a temp directory.""" local = LocalKaos() token = set_current_kaos(local) old_cwd = Path.cwd() try: os.chdir(tmp_path) yield local finally: os.chdir(old_cwd) reset_current_kaos(token) def test_pathclass_gethome_and_getcwd(local_kaos: LocalKaos): path_class = local_kaos.pathclass() if os.name == "nt": assert issubclass(path_class, PureWindowsPath) else: assert issubclass(path_class, PurePosixPath) assert str(local_kaos.gethome()) == str(Path.home()) assert str(local_kaos.getcwd()) == str(Path.cwd()) async def test_chdir_and_stat(local_kaos: LocalKaos): new_dir = local_kaos.getcwd() / "nested" await local_kaos.mkdir(new_dir) await local_kaos.chdir(new_dir) assert Path.cwd() == new_dir.unsafe_to_local_path() file_path = new_dir / "file.txt" await local_kaos.writetext(file_path, "hello world") stat_result = await local_kaos.stat(file_path) assert stat_result.st_size == len("hello world") async def test_iterdir_and_glob(local_kaos: LocalKaos): tmp_path = local_kaos.getcwd() await local_kaos.mkdir(tmp_path / "alpha") await local_kaos.writetext(tmp_path / "bravo.txt", "bravo") await local_kaos.writetext(tmp_path / "charlie.TXT", "charlie") entries = [entry async for entry in local_kaos.iterdir(tmp_path)] assert {entry.name for entry in entries} == {"alpha", "bravo.txt", "charlie.TXT"} assert all(isinstance(entry, KaosPath) for entry in entries) matched = [entry.name async for entry in local_kaos.glob(tmp_path, "*.txt")] assert set(matched) == {"bravo.txt"} async def test_read_write_and_append_text(local_kaos: LocalKaos): tmp_path = local_kaos.getcwd() file_path = tmp_path / "note.txt" written = await local_kaos.writetext(file_path, "line1") assert written == len("line1") content = await local_kaos.readtext(file_path) assert content == "line1" await local_kaos.writetext(file_path, "\nline2", mode="a") lines = [line async for line in local_kaos.readlines(file_path)] assert "".join(lines) == "line1\nline2" async def test_mkdir_with_parents(local_kaos: LocalKaos): tmp_path = local_kaos.getcwd() nested_dir = tmp_path / "a" / "b" / "c" await local_kaos.mkdir(nested_dir, parents=True) assert await nested_dir.is_dir() async def test_read_write_bytes(local_kaos: LocalKaos): tmp_path = local_kaos.getcwd() file_path = tmp_path / "data.bin" await local_kaos.writebytes(file_path, b"\x00\x01\xff") assert await local_kaos.readbytes(file_path) == b"\x00\x01\xff" def _python_code_args(code: str) -> tuple[str, str, str]: return sys.executable, "-c", code async def test_exec_runs_command_and_streams(local_kaos: LocalKaos): code = "import sys\nsys.stdout.write('hello\\n')\nsys.stderr.write('stderr line\\n')\n" process = await local_kaos.exec(*_python_code_args(code)) assert process.stdin is not None assert process.stdout is not None assert process.stderr is not None stdout_data, stderr_data = await asyncio.gather(process.stdout.read(), process.stderr.read()) assert await process.wait() == 0 assert stdout_data.decode("utf-8").strip() == "hello" assert stderr_data.decode("utf-8").strip() == "stderr line" async def test_exec_runs_command_wait_before_read(local_kaos: LocalKaos): code = "import sys\nsys.stdout.write('hello\\n')\nsys.stderr.write('stderr line\\n')\n" process = await local_kaos.exec(*_python_code_args(code)) assert process.stdin is not None assert process.stdout is not None assert process.stderr is not None assert await process.wait() == 0 stdout_data, stderr_data = await asyncio.gather(process.stdout.read(), process.stderr.read()) assert stdout_data.decode("utf-8").strip() == "hello" assert stderr_data.decode("utf-8").strip() == "stderr line" async def test_exec_non_zero_exit(local_kaos: LocalKaos): process = await local_kaos.exec(*_python_code_args("import sys; sys.exit(7)")) exit_code = await process.wait() assert exit_code == 7 async def test_exec_wait_timeout(local_kaos: LocalKaos): process = await local_kaos.exec(*_python_code_args("import time; time.sleep(1)")) assert process.pid > 0 try: with pytest.raises(asyncio.TimeoutError): await asyncio.wait_for(process.wait(), timeout=0.01) finally: if process.returncode is None: await process.kill() await process.wait() ================================================ FILE: packages/kaos/tests/test_local_kaos_cmd.py ================================================ """Tests for `kaos.exec` running commands via cmd.exe /c.""" from __future__ import annotations import asyncio import os import platform from collections.abc import Generator from pathlib import Path import pytest from inline_snapshot import snapshot import kaos from kaos import reset_current_kaos, set_current_kaos from kaos.local import LocalKaos pytestmark = pytest.mark.skipif( platform.system() != "Windows", reason="cmd.exe tests run only on Windows." ) @pytest.fixture(autouse=True) def local_kaos(tmp_path: Path) -> Generator[LocalKaos]: """Set LocalKaos as current KAOS and isolate cwd per test.""" local = LocalKaos() token = set_current_kaos(local) old_cwd = Path.cwd() os.chdir(tmp_path) try: yield local finally: os.chdir(old_cwd) reset_current_kaos(token) async def run_cmd(command: str) -> tuple[int, str, str]: """Execute a cmd.exe command through kaos.exec and collect exit code and streams.""" process = await kaos.exec("cmd.exe", "/c", f"chcp 65001>nul & {command}") assert process.stdout is not None assert process.stderr is not None stdout_task = asyncio.create_task(process.stdout.read()) stderr_task = asyncio.create_task(process.stderr.read()) exit_code = await process.wait() stdout_data, stderr_data = await asyncio.gather(stdout_task, stderr_task) return exit_code, stdout_data.decode("utf-8"), stderr_data.decode("utf-8") async def test_simple_command(): """Ensure a basic cmd.exe command runs.""" exit_code, stdout, stderr = await run_cmd("echo Hello Windows") assert exit_code == 0 assert stdout.strip() == snapshot("Hello Windows") assert stderr == snapshot("") async def test_command_with_error(): """Failing commands should return a non-zero exit code.""" exit_code, stdout, stderr = await run_cmd("exit /b 1") assert exit_code == 1 assert stdout == snapshot("") assert stderr == snapshot("") async def test_command_chaining(): """Chaining commands with && should work.""" exit_code, stdout, stderr = await run_cmd("echo First&& echo Second") assert exit_code == 0 assert stdout.replace("\r\n", "\n") == snapshot("First\nSecond\n") assert stderr == snapshot("") async def test_file_operations(): """Basic file write/read using cmd redirection.""" file_path = Path("test_file.txt") exit_code, stdout, stderr = await run_cmd(f"echo Test content> {file_path}") assert exit_code == 0 assert stdout == snapshot("") assert stderr == snapshot("") assert file_path.is_file() exit_code, stdout, stderr = await run_cmd(f"type {file_path}") assert exit_code == 0 assert stdout == snapshot("Test content\r\n") assert stderr == snapshot("") ================================================ FILE: packages/kaos/tests/test_local_kaos_sh.py ================================================ """Tests for `kaos.exec` running commands via /bin/sh -c.""" from __future__ import annotations import asyncio import os import platform from collections.abc import Generator from pathlib import Path import pytest from inline_snapshot import snapshot import kaos from kaos import reset_current_kaos, set_current_kaos from kaos.local import LocalKaos pytestmark = pytest.mark.skipif( platform.system() == "Windows", reason="/bin/sh is not available on Windows." ) @pytest.fixture(autouse=True) def local_kaos(tmp_path: Path) -> Generator[LocalKaos]: """Set LocalKaos as current KAOS and isolate cwd per test.""" local = LocalKaos() token = set_current_kaos(local) old_cwd = Path.cwd() os.chdir(tmp_path) try: yield local finally: os.chdir(old_cwd) reset_current_kaos(token) async def run_sh( command: str, *, timeout: float | None = None, stdin_data: str | bytes | None = None ) -> tuple[int, str, str]: """Execute a shell command through kaos.exec and collect exit code and streams.""" process = await kaos.exec("/bin/sh", "-c", command) stdout_task = asyncio.create_task(process.stdout.read()) stderr_task = asyncio.create_task(process.stderr.read()) if stdin_data is not None: input_bytes = stdin_data.encode("utf-8") if isinstance(stdin_data, str) else stdin_data process.stdin.write(input_bytes) await process.stdin.drain() process.stdin.close() if hasattr(process.stdin, "wait_closed"): await process.stdin.wait_closed() try: wait_coro = process.wait() exit_code = ( await asyncio.wait_for(wait_coro, timeout=timeout) if timeout else await wait_coro ) except TimeoutError: await process.kill() await process.wait() await asyncio.gather(stdout_task, stderr_task, return_exceptions=True) raise stdout_data, stderr_data = await asyncio.gather(stdout_task, stderr_task) return exit_code, stdout_data.decode("utf-8"), stderr_data.decode("utf-8") async def test_simple_command(): """Test executing a simple command.""" exit_code, stdout, stderr = await run_sh("echo 'Hello World'") assert exit_code == 0 assert stdout == snapshot("Hello World\n") assert stderr == snapshot("") async def test_command_with_error(): """Test executing a command that returns an error.""" exit_code, stdout, stderr = await run_sh("ls /nonexistent/directory") assert exit_code != 0 assert stdout == snapshot("") assert "No such file or directory" in stderr async def test_command_chaining(): """Test command chaining with &&.""" exit_code, stdout, stderr = await run_sh("echo 'First' && echo 'Second'") assert exit_code == 0 assert stdout == snapshot("""\ First Second """) assert stderr == snapshot("") async def test_command_sequential(): """Test sequential command execution with ;.""" exit_code, stdout, stderr = await run_sh("echo 'One'; echo 'Two'") assert exit_code == 0 assert stdout == snapshot("""\ One Two """) assert stderr == snapshot("") async def test_command_conditional(): """Test conditional command execution with ||.""" exit_code, stdout, stderr = await run_sh("false || echo 'Success'") assert exit_code == 0 assert stdout == snapshot("Success\n") assert stderr == snapshot("") async def test_command_pipe(): """Test command piping.""" exit_code, stdout, stderr = await run_sh("echo 'Hello World' | wc -w") assert exit_code == 0 assert stdout.strip() == snapshot("2") assert stderr == snapshot("") async def test_multiple_pipes(): """Test multiple pipes in one command.""" exit_code, stdout, stderr = await run_sh("printf '1\\n2\\n3\\n' | grep '2' | wc -l") assert exit_code == 0 assert stdout.strip() == snapshot("1") assert stderr == snapshot("") async def test_command_with_timeout(): """Test command execution with an upper bound on runtime.""" exit_code, stdout, stderr = await run_sh("sleep 0.1", timeout=1) assert exit_code == 0 assert stdout == snapshot("") assert stderr == snapshot("") async def test_command_timeout_expires(): """Test command that times out.""" with pytest.raises(TimeoutError): await run_sh("sleep 2", timeout=0.1) async def test_environment_variables(): """Test setting and using environment variables.""" exit_code, stdout, stderr = await run_sh( "TEST_VAR='test_value'; export TEST_VAR; echo \"$TEST_VAR\"" ) assert exit_code == 0 assert stdout == snapshot("test_value\n") assert stderr == snapshot("") async def test_file_operations(): """Test basic file operations.""" exit_code, stdout, stderr = await run_sh("echo 'Test content' > test_file.txt") assert exit_code == 0 assert stdout == snapshot("") assert stderr == snapshot("") assert (Path.cwd() / "test_file.txt").is_file() exit_code, stdout, stderr = await run_sh("cat test_file.txt") assert exit_code == 0 assert stdout == snapshot("Test content\n") assert stderr == snapshot("") async def test_text_processing(): """Test text processing commands.""" exit_code, stdout, stderr = await run_sh("echo 'apple banana cherry' | sed 's/banana/orange/'") assert exit_code == 0 assert stdout == snapshot("apple orange cherry\n") assert stderr == snapshot("") async def test_command_substitution(): """Test command substitution with a portable command.""" exit_code, stdout, stderr = await run_sh('echo "Result: $(echo hello)"') assert exit_code == 0 assert stdout == snapshot("Result: hello\n") assert stderr == snapshot("") async def test_arithmetic_substitution(): """Test arithmetic substitution - more portable than date command.""" exit_code, stdout, stderr = await run_sh('echo "Answer: $((2 + 2))"') assert exit_code == 0 assert stdout == snapshot("Answer: 4\n") assert stderr == snapshot("") async def test_very_long_output(): """Test command that produces very long output.""" exit_code, stdout, stderr = await run_sh("seq 1 100 | head -50") assert exit_code == 0 assert "1" in stdout assert "50" in stdout assert "51" not in stdout assert stderr == snapshot("") async def test_command_reads_stdin(): """Test passing data to stdin.""" exit_code, stdout, stderr = await run_sh( "read value; printf '%s\\n' \"$value\"", stdin_data="from stdin\n", ) assert exit_code == 0 assert stdout == snapshot("from stdin\n") assert stderr == snapshot("") async def test_command_reads_multiple_lines_from_stdin(): """Test reading multiple lines through stdin.""" exit_code, stdout, stderr = await run_sh( "count=0; while IFS= read -r _; do count=$((count+1)); done; printf '%s\\n' \"$count\"", stdin_data="alpha\nbeta\ngamma\n", ) assert exit_code == 0 assert stdout.strip() == snapshot("3") assert stderr == snapshot("") ================================================ FILE: packages/kaos/tests/test_ssh_kaos.py ================================================ from __future__ import annotations import asyncio import os import platform import stat from collections.abc import AsyncGenerator from pathlib import PurePosixPath from typing import Any from uuid import uuid4 import asyncssh import pytest import pytest_asyncio from kaos import reset_current_kaos, set_current_kaos from kaos.path import KaosPath from kaos.ssh import SSHKaos pytestmark = pytest.mark.skipif( platform.system() == "Windows", reason="SSH tests run only on non-Windows.", ) @pytest.fixture(scope="module") def ssh_kaos_config() -> dict[str, Any]: """Collect SSH connection parameters from environment variables.""" host = os.environ.get("KAOS_SSH_HOST", "127.0.0.1") username = os.environ.get("KAOS_SSH_USERNAME") config: dict[str, Any] = { "host": host, "port": int(os.environ.get("KAOS_SSH_PORT", "22")), "username": username, } password = os.environ.get("KAOS_SSH_PASSWORD") if password: config["password"] = password key_paths = os.environ.get("KAOS_SSH_KEY_PATHS") if key_paths: config["key_paths"] = [path for path in key_paths.split(",") if path] key_contents = os.environ.get("KAOS_SSH_KEY_CONTENTS") if key_contents: config["key_contents"] = [content for content in key_contents.split("|||") if content] return config @pytest_asyncio.fixture async def ssh_kaos(ssh_kaos_config: dict[str, Any]) -> AsyncGenerator[SSHKaos]: """Create a shared SSH KAOS instance for integration tests.""" try: kaos = await SSHKaos.create(**ssh_kaos_config) except (OSError, asyncssh.Error) as exc: pytest.skip(f"SSH connection failed: {exc}") try: yield kaos finally: await kaos.unsafe_close() @pytest_asyncio.fixture async def remote_base(ssh_kaos: SSHKaos) -> AsyncGenerator[str]: """Create and clean up an isolated remote directory for each test.""" base = ssh_kaos.gethome().joinpath(f".pykaos_test_{os.getpid()}_{uuid4().hex}") base_str = str(base) await ssh_kaos.mkdir(base_str, parents=True, exist_ok=True) try: yield base_str finally: cleanup = await ssh_kaos.exec("rm", "-rf", base_str) await cleanup.wait() await ssh_kaos.chdir(ssh_kaos.gethome()) @pytest.fixture def bind_current_kaos(ssh_kaos: SSHKaos): """Bind KAOS globals to the SSH backend for KaosPath helpers.""" token = set_current_kaos(ssh_kaos) try: yield ssh_kaos finally: reset_current_kaos(token) async def test_pathclass_home_and_cwd(ssh_kaos: SSHKaos): home = ssh_kaos.gethome() cwd = ssh_kaos.getcwd() assert ssh_kaos.pathclass() is PurePosixPath assert isinstance(home, KaosPath) assert isinstance(cwd, KaosPath) assert home.is_absolute() assert cwd.is_absolute() assert str(home) == str(cwd) async def test_chdir_updates_real_path(ssh_kaos: SSHKaos, remote_base: str): await ssh_kaos.chdir(remote_base) assert str(ssh_kaos.getcwd()) == remote_base await ssh_kaos.mkdir("child", exist_ok=True) await ssh_kaos.chdir("child") assert str(ssh_kaos.getcwd()) == os.path.join(remote_base, "child") await ssh_kaos.chdir("..") assert str(ssh_kaos.getcwd()) == remote_base async def test_exec_respects_cwd(ssh_kaos: SSHKaos, remote_base: str): await ssh_kaos.chdir(remote_base) proc = await ssh_kaos.exec("pwd") out = (await proc.stdout.read()).decode().strip() code = await proc.wait() assert code == 0 assert out == remote_base async def test_exec_wait_before_read(ssh_kaos: SSHKaos): proc = await ssh_kaos.exec("echo", "output") exit_code = await proc.wait() output = (await proc.stdout.read()).decode().strip() assert exit_code == 0 assert output == "output" async def test_mkdir_respects_exist_ok(ssh_kaos: SSHKaos, remote_base: str): nested_dir = os.path.join(remote_base, "deep", "level") await ssh_kaos.mkdir(nested_dir, parents=True, exist_ok=False) with pytest.raises(FileExistsError): await ssh_kaos.mkdir(nested_dir, exist_ok=False) await ssh_kaos.mkdir(nested_dir, parents=True, exist_ok=True) async def test_stat_reports_directory_and_file_metadata(ssh_kaos: SSHKaos, remote_base: str): directory_stat = await ssh_kaos.stat(remote_base, follow_symlinks=False) assert stat.S_ISDIR(directory_stat.st_mode) file_path = os.path.join(remote_base, "payload.txt") payload = "metadata" await ssh_kaos.writetext(file_path, payload) file_stat = await ssh_kaos.stat(file_path) assert stat.S_ISREG(file_stat.st_mode) assert file_stat.st_size == len(payload) assert file_stat.st_nlink >= 0 async def test_kaospath_roundtrip(bind_current_kaos: SSHKaos, remote_base: str): await bind_current_kaos.chdir(remote_base) text_path = KaosPath(remote_base) / "text.txt" bytes_path = KaosPath(remote_base) / "blob.bin" text_payload = "Hello SSH\n" appended = "More data\n" written = await text_path.write_text(text_payload) assert written == len(text_payload) appended_len = await text_path.append_text(appended) assert appended_len == len(appended) full_text = await text_path.read_text() assert full_text == text_payload + appended lines = [line async for line in text_path.read_lines()] assert lines == ["Hello SSH", "More data"] bytes_payload = bytes(range(32)) bytes_written = await bytes_path.write_bytes(bytes_payload) assert bytes_written == len(bytes_payload) roundtrip = await bytes_path.read_bytes() assert roundtrip == bytes_payload assert str(KaosPath.cwd()) == remote_base async def test_iterdir_lists_child_entries(ssh_kaos: SSHKaos, remote_base: str): await ssh_kaos.writetext(os.path.join(remote_base, "file1.txt"), "1") await ssh_kaos.writetext(os.path.join(remote_base, "file2.log"), "2") await ssh_kaos.mkdir(os.path.join(remote_base, "subdir"), exist_ok=True) entries = [entry async for entry in ssh_kaos.iterdir(remote_base)] names = {entry.name for entry in entries} assert names == {"file1.txt", "file2.log", "subdir"} assert all(isinstance(entry, KaosPath) for entry in entries) async def test_glob_is_case_sensitive(ssh_kaos: SSHKaos, remote_base: str): await ssh_kaos.writetext(os.path.join(remote_base, "file.log"), "lowercase") await ssh_kaos.writetext(os.path.join(remote_base, "FILE.LOG"), "uppercase") matches = {str(path) async for path in ssh_kaos.glob(remote_base, "*.log")} assert os.path.join(remote_base, "file.log") in matches assert os.path.join(remote_base, "FILE.LOG") not in matches with pytest.raises(ValueError): await anext(ssh_kaos.glob(remote_base, "*.log", case_sensitive=False)) async def test_exec_streams_stdout_and_stderr(ssh_kaos: SSHKaos): proc = await ssh_kaos.exec("sh", "-c", "printf 'out\\n' && printf 'err\\n' 1>&2") stdout_data, stderr_data = await asyncio.gather(proc.stdout.read(), proc.stderr.read()) exit_code = await proc.wait() assert proc.returncode == exit_code == 0 assert stdout_data.decode().strip() == "out" assert stderr_data.decode().strip() == "err" async def test_exec_rejects_empty_command(ssh_kaos: SSHKaos): with pytest.raises(ValueError): await ssh_kaos.exec() # type: ignore[misc] async def test_process_kill_updates_returncode(ssh_kaos: SSHKaos): proc = await ssh_kaos.exec("sh", "-c", "echo ready; sleep 30") first_line = await proc.stdout.readline() assert first_line == b"ready\n" assert proc.returncode is None await proc.kill() exit_code = await proc.wait() assert exit_code != 0 assert proc.returncode == exit_code assert proc.pid == -1 ================================================ FILE: packages/kimi-code/pyproject.toml ================================================ [project] name = "kimi-code" version = "1.24.0" description = "Kimi Code is a CLI agent that lives in your terminal." readme = "README.md" requires-python = ">=3.12" dependencies = ["kimi-cli==1.24.0"] [project.scripts] kimi-code = "kimi_cli.__main__:main" [build-system] requires = ["uv_build>=0.8.5,<0.10.0"] build-backend = "uv_build" [tool.uv.build-backend] module-name = ["kimi_code"] ================================================ FILE: packages/kimi-code/src/kimi_code/__init__.py ================================================ from __future__ import annotations import importlib import sys # Alias the kimi_code package to kimi_cli for compatibility. sys.modules[__name__] = importlib.import_module("kimi_cli") ================================================ FILE: packages/kosong/.pre-commit-config.yaml ================================================ orphan: true repos: - repo: local hooks: - id: make-format-kosong name: make format-kosong entry: make -C ../.. format-kosong language: system pass_filenames: false - id: make-check-kosong name: make check-kosong entry: make -C ../.. check-kosong language: system pass_filenames: false ================================================ FILE: packages/kosong/CHANGELOG.md ================================================ # Changelog ## Unreleased ## 0.45.0 (2026-03-11) - OpenAI Responses: Fix implicit `reasoning.effort=null` being sent which breaks Responses-compatible endpoints that require reasoning — reasoning parameters are now omitted unless explicitly set ## 0.44.0 (2026-03-09) - Anthropic: Support optional `metadata` parameter in `Anthropic` chat provider for passing metadata (e.g., `user_id`) to the API ## 0.43.0 (2026-02-24) - Add `RetryableChatProvider` protocol for providers that can recover from retryable transport errors - Implement `RetryableChatProvider` in Kimi, OpenAI Legacy, and OpenAI Responses providers - Add `create_openai_client` and `close_replaced_openai_client` utilities to `openai_common` ## 0.42.0 (2026-02-06) - Anthropic: Use adaptive thinking for Opus 4.6+ models instead of budget-based thinking ## 0.41.1 (2026-02-05) - Handle string annotations in `SimpleToolset` return type check (supports `from __future__ import annotations`) ## 0.41.0 (2026-01-27) - Remove default temperature setting in Kimi chat provider based on model name ## 0.40.0 (2026-01-24) - Add `ScriptedEchoChatProvider` for scripted conversation simulation in end-to-end testing ## 0.39.1 (2026-01-21) - Fix streamed usage from choice not being read properly ## 0.39.0 (2026-01-21) - Control thinking mode via `extra_body` parameter instead of legacy `reasoning_effort` - Add `files` property to `Kimi` provider that returns a `KimiFiles` object - Add `KimiFiles.upload_video()` method for uploading videos to Kimi files API, returning `VideoURLPart` ## 0.38.0 (2026-01-15) - Add `thinking_effort` property to `ChatProvider` protocol to query current thinking effort level ## 0.37.0 (2026-01-08) - Change `TokenUsage` from dataclass to pydantic BaseModel. ## 0.36.1 (2026-01-04) - Relax `loguru` lower bound. ## 0.36.0 (2025-12-31) - Add `VideoURLPart` content part ## 0.35.1-4 (2025-12-26) - Nothing changed. ## 0.35.0 (2025-12-24) - Add registry-based `DisplayBlock` validation to allow custom tool/UI display block subclasses, plus `BriefDisplayBlock` and `UnknownDisplayBlock` - Rename brief display payload field to `text` and keep tool return display blocks empty when no brief is provided ## 0.34.1 (2025-12-22) - Add `convert_mcp_content` util to convert MCP content type to kosong content type ## 0.34.0 (2025-12-19) - Support Vertex AI in GoogleGenAI chat provider - Add `SimpleToolset.add()` and `SimpleToolset.remove()` methods to add or remove tools from the toolset ## 0.33.0 (2025-12-12) - Lower the required Python version to 3.12 - Make the `contrib` module an optional extra that can be installed with `uv add "kosong[contrib]"` ## 0.32.0 (2025-12-08) - Introduce `ToolMessageConversion` to customize how tool messages are converted in chat providers ## 0.31.0 (2025-12-03) - Fix OpenAI Responses provider not mapping `role="system"` to `developer` - Improve the compatibility of OpenAI Responses and Anthropic providers against some third-party APIs ## 0.30.0 (2025-12-03) - Serialize empty content as an empty list instead of `None` - Fix Kimi chat provider panicking when `stream` is `False` ## 0.29.0 (2025-12-02) - Change `Message.content` field from `str | list[ContentPart]` to just `list[ContentPart]` - Add `Message.extract_text()` method to extract text content from message ## 0.28.1 (2025-12-01) - Fix interleaved thinking for Kimi and OpenAILegacy chat providers ## 0.28.0 (2025-11-28) - Support non-OpenAI models which do not accept `developer` role in system prompt in `OpenAIResponses` chat provider - Fix token usage for Anthropic chat provider - Fix `StepResult.tool_results()` cannot be called multiple times - Add `EchoChatProvider` to allow generate assistant responses by echoing back the user messages ## 0.27.1 (2025-11-24) - Nothing ## 0.27.0 (2025-11-24) - Fix function call ID in `GoogleGenAI` chat provider - Make `CallableTool2` not a `pydantic.BaseModel` - Introduce `ToolReturnValue` as the common base class of `ToolOk` and `ToolError` - Require `CallableTool` and `CallableTool2` to return `ToolReturnValue` instead of `ToolOk | ToolError` - Rename `ToolResult.result` to `ToolResult.return_value` ## 0.26.2 (2025-11-20) - Better thinking level mapping in `GoogleGenAI` chat provider ## 0.26.1 (2025-11-19) - Deref JSON schema in tool parameters to fix compatibility with some LLM providers ## 0.26.0 (2025-11-19) - Fix thinking part in `Anthropic` provider's non-stream mode - Add `GoogleGenAI` chat provider ## 0.25.1 (2025-11-18) - Catch httpx exceptions correctly in Kimi and OpenAI providers ## 0.25.0 (2025-11-13) - Add `reasoning_key` argument to `OpenAILegacy` chat provider to specify the field for reasoning content in messages ## 0.24.0 (2025-11-12) - Set default temperature settings for Kimi models based on model name ## 0.23.0 (2025-11-10) - Change type of `ToolError.output` to `str | ContentPart | Sequence[ContentPart]` ## 0.22.0 (2025-11-10) - Add `APIEmptyResponseError` for cases where the API returns an empty response - Add `GenerateResult` as the return type of `generate` function - Add `id: str | None` field to `GenerateResult` and `StepResult` ================================================ FILE: packages/kosong/LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: packages/kosong/NOTICE ================================================ Kosong Copyright 2025 Moonshot AI This product includes software developed at Moonshot AI (https://www.moonshot.ai/). ================================================ FILE: packages/kosong/README.md ================================================ # Kosong Kosong is an LLM abstraction layer designed for modern AI agent applications. It unifies message structures, asynchronous tool orchestration, and pluggable chat providers so you can build agents with ease and avoid vendor lock-in. > Kosong means "empty" in Malay and Indonesian. ## Installation Kosong requires Python 3.12 or higher. We recommend using uv as the package manager. Init your project with: ```bash uv init --python 3.12 # or higher ``` Then add Kosong as a dependency: ```bash uv add kosong ``` To enable chat providers other than Kimi (e.g. Anthropic and Google Gemini), install the optional extra: ```bash uv add 'kosong[contrib]' ``` ## Examples ### Simple chat completion ```python import asyncio import kosong from kosong.chat_provider.kimi import Kimi from kosong.message import Message async def main() -> None: kimi = Kimi( base_url="https://api.moonshot.ai/v1", api_key="your_kimi_api_key_here", model="kimi-k2-turbo-preview", ) history = [ Message(role="user", content="Who are you?"), ] result = await kosong.generate( chat_provider=kimi, system_prompt="You are a helpful assistant.", tools=[], history=history, ) print(result.message) print(result.usage) asyncio.run(main()) ``` ### Streaming output ```python import asyncio import kosong from kosong.chat_provider import StreamedMessagePart from kosong.chat_provider.kimi import Kimi from kosong.message import Message async def main() -> None: kimi = Kimi( base_url="https://api.moonshot.ai/v1", api_key="your_kimi_api_key_here", model="kimi-k2-turbo-preview", ) history = [ Message(role="user", content="Who are you?"), ] def output(message_part: StreamedMessagePart): print(message_part) result = await kosong.generate( chat_provider=kimi, system_prompt="You are a helpful assistant.", tools=[], history=history, on_message_part=output, ) print(result.message) print(result.usage) asyncio.run(main()) ``` ### Tool calling with `kosong.step` ```python import asyncio from pydantic import BaseModel import kosong from kosong import StepResult from kosong.chat_provider.kimi import Kimi from kosong.message import Message from kosong.tooling import CallableTool2, ToolOk, ToolReturnValue from kosong.tooling.simple import SimpleToolset class AddToolParams(BaseModel): a: int b: int class AddTool(CallableTool2[AddToolParams]): name: str = "add" description: str = "Add two integers." params: type[AddToolParams] = AddToolParams async def __call__(self, params: AddToolParams) -> ToolReturnValue: return ToolOk(output=str(params.a + params.b)) async def main() -> None: kimi = Kimi( base_url="https://api.moonshot.ai/v1", api_key="your_kimi_api_key_here", model="kimi-k2-turbo-preview", ) toolset = SimpleToolset() toolset += AddTool() history = [ Message(role="user", content="Please add 2 and 3 with the add tool."), ] result: StepResult = await kosong.step( chat_provider=kimi, system_prompt="You are a precise math tutor.", toolset=toolset, history=history, ) print(result.message) print(await result.tool_results()) asyncio.run(main()) ``` ## Builtin Demo Kosong comes with a builtin demo agent that you can run locally. To start the demo, run: ```sh export KIMI_BASE_URL="https://api.moonshot.ai/v1" export KIMI_API_KEY="your_kimi_api_key" uv run python -m kosong kimi --with-bash ``` ## Development To set up a development environment, clone the repository and install the dependencies: ```bash git clone https://github.com/MoonshotAI/kosong.git cd kosong uv sync --all-extras make check # run lint and type checks make test # run tests make format # format code ``` ================================================ FILE: packages/kosong/pyproject.toml ================================================ [project] name = "kosong" version = "0.45.0" description = "The LLM abstraction layer for modern AI agent applications." readme = "README.md" requires-python = ">=3.12" dependencies = [ "anthropic>=0.78.0", "google-genai>=1.56.0", "jsonschema>=4.25.1", "loguru>=0.6.0,<0.8", "openai>=2.14.0,<2.15.0", "pydantic>=2.12.5", "python-dotenv>=1.2.1", "typing-extensions>=4.15.0", "mcp>=1,<2", ] [project.optional-dependencies] contrib = [ "anthropic>=0.78.0", "google-genai>=1.55.0", ] [dependency-groups] dev = [ "pyright>=1.1.407", "ty>=0.0.7", "pytest>=9.0.2", "pytest-asyncio>=1.3.0", "respx>=0.22.0", "ruff>=0.14.10", "inline-snapshot[black]>=0.31.1", "pdoc>=16.0.0", ] [build-system] requires = ["uv_build>=0.8.5,<0.10.0"] build-backend = "uv_build" [tool.uv.build-backend] module-name = ["kosong"] [tool.ruff] line-length = 100 [tool.ruff.format] docstring-code-format = true [tool.ruff.lint] select = [ "E", # pycodestyle "F", # Pyflakes "UP", # pyupgrade "B", # flake8-bugbear "SIM", # flake8-simplify "I", # isort ] [tool.pyright] typeCheckingMode = "strict" pythonVersion = "3.14" include = [ "src/**/*.py", "tests/**/*.py", ] [tool.ty.environment] python-version = "3.14" [tool.ty.src] include = [ "src/**/*.py", "tests/**/*.py", ] ================================================ FILE: packages/kosong/src/kosong/__init__.py ================================================ """ Kosong is an LLM abstraction layer designed for modern AI agent applications. It unifies message structures, asynchronous tool orchestration, and pluggable chat providers so you can build agents with ease and avoid vendor lock-in. Key features: - `kosong.generate` creates a completion stream and merges streamed message parts (including content and tool calls) from any `ChatProvider` into a complete `Message` plus optional `TokenUsage`. - `kosong.step` layers tool dispatch (`Tool`, `Toolset`, `SimpleToolset`) over `generate`, exposing `StepResult` with awaited tool outputs and streaming callbacks. - Message structures and tool abstractions live under `kosong.message` and `kosong.tooling`. Example: ```python import asyncio from pydantic import BaseModel import kosong from kosong import StepResult from kosong.chat_provider.kimi import Kimi from kosong.message import Message from kosong.tooling import CallableTool2, ToolOk, ToolReturnValue from kosong.tooling.simple import SimpleToolset class AddToolParams(BaseModel): a: int b: int class AddTool(CallableTool2[AddToolParams]): name: str = "add" description: str = "Add two integers." params: type[AddToolParams] = AddToolParams async def __call__(self, params: AddToolParams) -> ToolReturnValue: return ToolOk(output=str(params.a + params.b)) async def main() -> None: kimi = Kimi( base_url="https://api.moonshot.ai/v1", api_key="your_kimi_api_key_here", model="kimi-k2-turbo-preview", ) toolset = SimpleToolset() toolset += AddTool() history = [ Message(role="user", content="Please add 2 and 3 with the add tool."), ] result: StepResult = await kosong.step( chat_provider=kimi, system_prompt="You are a precise math tutor.", toolset=toolset, history=history, ) print(result.message) print(await result.tool_results()) asyncio.run(main()) ``` """ import asyncio from collections.abc import Callable, Sequence from dataclasses import dataclass from loguru import logger from kosong._generate import GenerateResult, generate from kosong.chat_provider import ChatProvider, ChatProviderError, StreamedMessagePart, TokenUsage from kosong.message import Message, ToolCall from kosong.tooling import ToolResult, ToolResultFuture, Toolset from kosong.utils.aio import Callback # Explicitly import submodules from . import chat_provider, contrib, message, tooling, utils logger.disable("kosong") __all__ = [ # submodules "chat_provider", "tooling", "message", "utils", "contrib", # classes and functions "generate", "GenerateResult", "step", "StepResult", ] async def step( chat_provider: ChatProvider, system_prompt: str, toolset: Toolset, history: Sequence[Message], *, on_message_part: Callback[[StreamedMessagePart], None] | None = None, on_tool_result: Callable[[ToolResult], None] | None = None, ) -> "StepResult": """ Run one agent "step". In one step, the function generates LLM response based on the given context for exactly one time. All new message parts will be streamed to `on_message_part` in real-time if provided. Tool calls will be handled by `toolset`. The generated message will be returned in a `StepResult`. Depending on the toolset implementation, the tool calls may be handled asynchronously and the results need to be fetched with `await result.tool_results()`. The message history will NOT be modified in this function. The token usage will be returned in the `StepResult` if available. Raises: APIConnectionError: If the API connection fails. APITimeoutError: If the API request times out. APIStatusError: If the API returns a status code of 4xx or 5xx. APIEmptyResponseError: If the API returns an empty response. ChatProviderError: If any other recognized chat provider error occurs. asyncio.CancelledError: If the step is cancelled. """ tool_calls: list[ToolCall] = [] tool_result_futures: dict[str, ToolResultFuture] = {} def future_done_callback(future: ToolResultFuture): if on_tool_result: try: result = future.result() on_tool_result(result) except asyncio.CancelledError: return async def on_tool_call(tool_call: ToolCall): tool_calls.append(tool_call) result = toolset.handle(tool_call) if isinstance(result, ToolResult): future = ToolResultFuture() future.add_done_callback(future_done_callback) future.set_result(result) tool_result_futures[tool_call.id] = future else: result.add_done_callback(future_done_callback) tool_result_futures[tool_call.id] = result try: result = await generate( chat_provider, system_prompt, toolset.tools, history, on_message_part=on_message_part, on_tool_call=on_tool_call, ) except (ChatProviderError, asyncio.CancelledError): # cancel all the futures to avoid hanging tasks for future in tool_result_futures.values(): future.remove_done_callback(future_done_callback) future.cancel() await asyncio.gather(*tool_result_futures.values(), return_exceptions=True) raise return StepResult( result.id, result.message, result.usage, tool_calls, tool_result_futures, ) @dataclass(frozen=True, slots=True) class StepResult: id: str | None """The ID of the generated message.""" message: Message """The message generated in this step.""" usage: TokenUsage | None """The token usage in this step.""" tool_calls: list[ToolCall] """All the tool calls generated in this step.""" _tool_result_futures: dict[str, ToolResultFuture] """@private The futures of the results of the spawned tool calls.""" async def tool_results(self) -> list[ToolResult]: """All the tool results returned by corresponding tool calls.""" if not self._tool_result_futures: return [] try: results: list[ToolResult] = [] for tool_call in self.tool_calls: future = self._tool_result_futures[tool_call.id] result = await future results.append(result) return results finally: # one exception should cancel all the futures to avoid hanging tasks for future in self._tool_result_futures.values(): future.cancel() await asyncio.gather(*self._tool_result_futures.values(), return_exceptions=True) ================================================ FILE: packages/kosong/src/kosong/__main__.py ================================================ import asyncio import os import textwrap from argparse import ArgumentParser from typing import Literal from dotenv import load_dotenv from pydantic import BaseModel import kosong from kosong.chat_provider import ChatProvider from kosong.message import Message from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolResult, ToolReturnValue, Toolset from kosong.tooling.simple import SimpleToolset class BashToolParams(BaseModel): command: str """The bash command to execute.""" class BashTool(CallableTool2[BashToolParams]): name: str = "Bash" description: str = "Execute a bash command." params: type[BashToolParams] = BashToolParams async def __call__(self, params: BashToolParams) -> ToolReturnValue: proc = await asyncio.create_subprocess_shell( params.command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await proc.communicate() stdout_text = stdout.decode().strip() stderr_text = stderr.decode().strip() output_text = "\n".join(filter(None, [stdout_text, stderr_text])) if proc.returncode == 0: return ToolOk(output=output_text) else: return ToolError( output=output_text, message=f"Command failed with exit code {proc.returncode}", brief="Bash command failed.", ) async def agent_loop(chat_provider: ChatProvider, toolset: Toolset): system_prompt = "You are a helpful assistant." history: list[Message] = [] while True: user_input = input("You: ").strip() if not user_input: continue if user_input.lower() in {"exit", "quit"}: break history.append(Message(role="user", content=user_input)) while True: result = await kosong.step( chat_provider=chat_provider, system_prompt=system_prompt, toolset=toolset, history=history, ) tool_results = await result.tool_results() assistant_message = result.message tool_messages = [tool_result_to_message(tr) for tr in tool_results] history.append(assistant_message) history.extend(tool_messages) if s := assistant_message.extract_text(): print("Assistant:\n", textwrap.indent(s, " ")) for tool_msg in tool_messages: if s := tool_msg.extract_text(): print("Tool:\n", textwrap.indent(s, " ")) if not result.tool_calls: break def tool_result_to_message(result: ToolResult) -> Message: return Message( role="tool", tool_call_id=result.tool_call_id, content=result.return_value.output, ) async def main(): load_dotenv() parser = ArgumentParser(description="A simple agent.") parser.add_argument( "provider", choices=["kimi", "openai", "anthropic", "google"], help="The chat provider to use.", ) parser.add_argument( "--with-bash", action="store_true", help="Enable Bash tool.", ) args = parser.parse_args() provider: Literal["kimi", "openai", "anthropic", "google"] = args.provider with_bash: bool = args.with_bash provider_upper = provider.upper() base_url = os.getenv(f"{provider_upper}_BASE_URL") api_key = os.getenv(f"{provider_upper}_API_KEY") model = os.getenv(f"{provider_upper}_MODEL_NAME") match provider: case "kimi": from kosong.chat_provider.kimi import Kimi base_url = base_url or "https://api.moonshot.ai/v1" assert api_key is not None, "Expect KIMI_API_KEY environment variable" model = model or "kimi-k2-turbo-preview" chat_provider = Kimi(base_url=base_url, api_key=api_key, model=model) case "openai": from kosong.contrib.chat_provider.openai_responses import OpenAIResponses base_url = base_url or "https://api.openai.com/v1" assert api_key is not None, "Expect OPENAI_API_KEY environment variable" model = model or "gpt-5" chat_provider = OpenAIResponses(base_url=base_url, api_key=api_key, model=model) case "anthropic": from kosong.contrib.chat_provider.anthropic import Anthropic base_url = base_url or "https://api.anthropic.com" assert api_key is not None, "Expect ANTHROPIC_API_KEY environment variable" model = model or "claude-sonnet-4-5" chat_provider = Anthropic( base_url=base_url, api_key=api_key, model=model, default_max_tokens=50_000 ) case "google": from kosong.contrib.chat_provider.google_genai import GoogleGenAI api_key = api_key or os.getenv("GEMINI_API_KEY") assert api_key is not None, ( "Expect GOOGLE_API_KEY or GEMINI_API_KEY environment variable" ) model = model or "gemini-3-pro-preview" chat_provider = GoogleGenAI( base_url=base_url, api_key=api_key, model=model ).with_thinking("high") toolset = SimpleToolset() if with_bash: toolset += BashTool() await agent_loop(chat_provider, toolset) asyncio.run(main()) ================================================ FILE: packages/kosong/src/kosong/_generate.py ================================================ from collections.abc import Sequence from dataclasses import dataclass from loguru import logger from kosong.chat_provider import ( APIEmptyResponseError, ChatProvider, StreamedMessagePart, TokenUsage, ) from kosong.message import ContentPart, Message, ToolCall from kosong.tooling import Tool from kosong.utils.aio import Callback, callback async def generate( chat_provider: ChatProvider, system_prompt: str, tools: Sequence[Tool], history: Sequence[Message], *, on_message_part: Callback[[StreamedMessagePart], None] | None = None, on_tool_call: Callback[[ToolCall], None] | None = None, ) -> "GenerateResult": """ Generate one message based on the given context. Parts of the message will be streamed to the specified callbacks if provided. Args: chat_provider: The chat provider to use for generation. system_prompt: The system prompt to use for generation. tools: The tools available for the model to call. history: The message history to use for generation. on_message_part: An optional callback to be called for each raw message part. on_tool_call: An optional callback to be called for each complete tool call. Returns: A tuple of the generated message and the token usage (if available). All parts in the message are guaranteed to be complete and merged as much as possible. Raises: APIConnectionError: If the API connection fails. APITimeoutError: If the API request times out. APIStatusError: If the API returns a status code of 4xx or 5xx. APIEmptyResponseError: If the API returns an empty response. ChatProviderError: If any other recognized chat provider error occurs. """ message = Message(role="assistant", content=[]) pending_part: StreamedMessagePart | None = None # message part that is currently incomplete logger.trace("Generating with history: {history}", history=history) stream = await chat_provider.generate(system_prompt, tools, history) async for part in stream: logger.trace("Received part: {part}", part=part) if on_message_part: await callback(on_message_part, part.model_copy(deep=True)) if pending_part is None: pending_part = part elif not pending_part.merge_in_place(part): # try merge into the pending part # unmergeable part must push the pending part to the buffer _message_append(message, pending_part) if isinstance(pending_part, ToolCall) and on_tool_call: await callback(on_tool_call, pending_part) pending_part = part # end of message if pending_part is not None: _message_append(message, pending_part) if isinstance(pending_part, ToolCall) and on_tool_call: await callback(on_tool_call, pending_part) if not message.content and not message.tool_calls: raise APIEmptyResponseError("The API returned an empty response.") return GenerateResult( id=stream.id, message=message, usage=stream.usage, ) @dataclass(frozen=True, slots=True) class GenerateResult: """The result of a generation.""" id: str | None """The ID of the generated message.""" message: Message """The generated message.""" usage: TokenUsage | None """The token usage of the generated message.""" def _message_append(message: Message, part: StreamedMessagePart) -> None: match part: case ContentPart(): message.content.append(part) case ToolCall(): if message.tool_calls is None: message.tool_calls = [] message.tool_calls.append(part) case _: # may be an orphaned `ToolCallPart` return ================================================ FILE: packages/kosong/src/kosong/chat_provider/__init__.py ================================================ from collections.abc import AsyncIterator, Sequence from typing import Literal, Protocol, Self, runtime_checkable from pydantic import BaseModel from kosong.message import ContentPart, Message, ToolCall, ToolCallPart from kosong.tooling import Tool @runtime_checkable class ChatProvider(Protocol): """The interface of chat providers.""" name: str """ The name of the chat provider. """ @property def model_name(self) -> str: """ The name of the model to use. """ ... @property def thinking_effort(self) -> "ThinkingEffort | None": """ The current thinking effort level. Returns None if not explicitly set. """ ... async def generate( self, system_prompt: str, tools: Sequence[Tool], history: Sequence[Message], ) -> "StreamedMessage": """ Generate a new message based on the given system prompt, tools, and history. Raises: APIConnectionError: If the API connection fails. APITimeoutError: If the API request times out. APIStatusError: If the API returns a status code of 4xx or 5xx. ChatProviderError: If any other recognized chat provider error occurs. """ ... def with_thinking(self, effort: "ThinkingEffort") -> Self: """ Return a copy of self configured with the given thinking effort. If the chat provider does not support thinking, simply return a copy of self. """ ... @runtime_checkable class RetryableChatProvider(Protocol): """Optional interface for providers that can recover from retryable transport errors.""" def on_retryable_error(self, error: BaseException) -> bool: """ Try to recover provider transport state after a retryable error. Returns: bool: Whether recovery action was performed. """ ... type StreamedMessagePart = ContentPart | ToolCall | ToolCallPart @runtime_checkable class StreamedMessage(Protocol): """The interface of streamed messages.""" def __aiter__(self) -> AsyncIterator[StreamedMessagePart]: """Create an async iterator from the stream.""" ... @property def id(self) -> str | None: """The ID of the streamed message.""" ... @property def usage(self) -> "TokenUsage | None": """The token usage of the streamed message.""" ... class TokenUsage(BaseModel): """Token usage statistics.""" input_other: int """Input tokens excluding `input_cache_read` and `input_cache_creation`.""" output: int """Total output tokens.""" input_cache_read: int = 0 """Cached input tokens.""" input_cache_creation: int = 0 """Input tokens used for cache creation. For now, only Anthropic API supports this.""" @property def total(self) -> int: """Total tokens used, including input and output tokens.""" return self.input + self.output @property def input(self) -> int: """Total input tokens, including cached and uncached tokens.""" return self.input_other + self.input_cache_read + self.input_cache_creation type ThinkingEffort = Literal["off", "low", "medium", "high"] """The effort level for thinking.""" class ChatProviderError(Exception): """The error raised by a chat provider.""" def __init__(self, message: str): super().__init__(message) class APIConnectionError(ChatProviderError): """The error raised when the API connection fails.""" class APITimeoutError(ChatProviderError): """The error raised when the API request times out.""" class APIStatusError(ChatProviderError): """The error raised when the API returns a status code of 4xx or 5xx.""" status_code: int def __init__(self, status_code: int, message: str): super().__init__(message) self.status_code = status_code class APIEmptyResponseError(ChatProviderError): """The error raised when the API returns an empty response.""" ================================================ FILE: packages/kosong/src/kosong/chat_provider/chaos.py ================================================ import json import os import random from collections.abc import AsyncIterator, Sequence from typing import TYPE_CHECKING, Any import httpx from pydantic import BaseModel from kosong.chat_provider import ( ChatProvider, ChatProviderError, RetryableChatProvider, StreamedMessage, StreamedMessagePart, ThinkingEffort, TokenUsage, ) from kosong.message import Message, ToolCall, ToolCallPart from kosong.tooling import Tool if TYPE_CHECKING: def type_check( chaos: "ChaosChatProvider", ): _: ChatProvider = chaos _: RetryableChatProvider = chaos class ChaosConfig(BaseModel): """Configuration for chaos provider.""" error_probability: float = 0.3 error_types: list[int] = [429, 500, 502, 503] retry_after: int = 2 seed: int | None = None corrupt_tool_call_probability: float = 0.1 @classmethod def from_env(cls) -> "ChaosConfig": """Create config from environment variables.""" seed_str = os.getenv("CHAOS_SEED") return cls( error_probability=float(os.getenv("CHAOS_ERROR_PROBABILITY", "0.3")), error_types=[ int(x.strip()) for x in os.getenv("CHAOS_ERROR_TYPES", "429,500,502,503").split(",") ], retry_after=int(os.getenv("CHAOS_RETRY_AFTER", "2")), seed=int(seed_str) if seed_str else None, corrupt_tool_call_probability=float( os.getenv("CHAOS_CORRUPT_TOOL_CALL_PROBABILITY", "0.1") ), ) class ChaosTransport(httpx.AsyncBaseTransport): """HTTP transport that randomly injects errors.""" def __init__(self, wrapped_transport: httpx.AsyncBaseTransport, config: ChaosConfig): self._wrapped = wrapped_transport self._config = config self._rng = random.Random(config.seed) async def handle_async_request(self, request: httpx.Request) -> httpx.Response: if self._should_inject_error(): error_code = self._rng.choice(self._config.error_types) return self._create_error_response(request, error_code) return await self._wrapped.handle_async_request(request) def _should_inject_error(self) -> bool: return self._rng.random() < self._config.error_probability def _create_error_response(self, request: httpx.Request, status_code: int) -> httpx.Response: error_messages = { 429: {"error": {"code": "rate_limit_exceeded", "message": "Rate limit exceeded"}}, 500: {"error": {"code": "internal_error", "message": "Internal server error"}}, 502: {"error": {"code": "bad_gateway", "message": "Bad gateway"}}, 503: { "error": { "code": "service_unavailable", "message": "Service temporarily unavailable", } }, } content = json.dumps( error_messages.get(status_code, {"error": {"message": "Unknown error"}}) ) headers = {"content-type": "application/json"} if status_code == 429: headers["retry-after"] = str(self._config.retry_after) return httpx.Response( status_code=status_code, headers=headers, content=content.encode(), request=request, ) class ChaosChatProvider: """Wrap a chat provider and inject chaos into its HTTP transport and streamed tool calls.""" def __init__(self, provider: ChatProvider, chaos_config: ChaosConfig | None = None): self._provider = provider self._chaos_config = chaos_config or ChaosConfig.from_env() self.name: str = provider.name self._monkey_patch_client() async def generate( self, system_prompt: str, tools: Sequence[Tool], history: Sequence[Message], ) -> "ChaosStreamedMessage": base_stream = await self._provider.generate(system_prompt, tools, history) return ChaosStreamedMessage(base_stream, self._chaos_config) def _monkey_patch_client(self): """ Inject chaos transport into providers backed by httpx AsyncBaseTransport. Supported today (explicit list): - Kimi - OpenAILegacy - Anthropic The provider must expose an AsyncOpenAI/Anthropic/httpx client via `.client`, `.client._client`, or `._client`. Providers without an accessible httpx transport will raise ChatProviderError. """ transport_owner = self._find_transport_owner() transport = getattr(transport_owner, "_transport", None) if not isinstance(transport, httpx.AsyncBaseTransport): raise ChatProviderError( "ChaosChatProvider only supports providers backed by httpx.AsyncBaseTransport" ) chaos_transport = ChaosTransport(transport, self._chaos_config) transport_owner._transport = chaos_transport # type: ignore[reportPrivateUsage] def _find_transport_owner(self) -> Any: """Locate the object that owns the httpx transport.""" candidates: list[Any] = [] client = getattr(self._provider, "client", None) if client is not None: candidates.append(client) raw_client = getattr(client, "_client", None) if raw_client is not None: candidates.append(raw_client) inner_client = getattr(self._provider, "_client", None) if inner_client is not None: candidates.append(inner_client) for owner in candidates: if hasattr(owner, "_transport"): return owner nested = getattr(owner, "_client", None) if nested and hasattr(nested, "_transport"): return nested raise ChatProviderError( "ChaosChatProvider only supports providers backed by httpx.AsyncBaseTransport" ) @property def model_name(self) -> str: if ( self._chaos_config.error_probability > 0 or self._chaos_config.corrupt_tool_call_probability > 0 ): return f"chaos({self._provider.model_name})" return self._provider.model_name @property def thinking_effort(self) -> ThinkingEffort | None: return self._provider.thinking_effort def on_retryable_error(self, error: BaseException) -> bool: if not isinstance(self._provider, RetryableChatProvider): return False recovered = self._provider.on_retryable_error(error) if recovered: self._monkey_patch_client() return recovered def with_thinking(self, effort: ThinkingEffort) -> "ChaosChatProvider": return ChaosChatProvider(self._provider.with_thinking(effort), self._chaos_config) @classmethod def for_kimi( cls, chaos_config: ChaosConfig | None = None, **kwargs: Any ) -> "ChaosChatProvider": """Helper to wrap a Kimi provider without changing caller sites.""" from kosong.chat_provider.kimi import Kimi return cls(Kimi(**kwargs), chaos_config=chaos_config) class ChaosStreamedMessage: """Stream wrapper that injects chaos into tool calls.""" def __init__(self, wrapped: StreamedMessage, config: ChaosConfig): self._wrapped = wrapped self._config = config self._rng = random.Random(config.seed) self._iterator = wrapped.__aiter__() def __aiter__(self) -> AsyncIterator[StreamedMessagePart]: return self async def __anext__(self) -> StreamedMessagePart: part = await self._iterator.__anext__() return self._maybe_corrupt_tool_call(part) @property def id(self) -> str | None: return self._wrapped.id @property def usage(self) -> TokenUsage | None: return self._wrapped.usage def _should_corrupt_tool_call(self) -> bool: probability = self._config.corrupt_tool_call_probability return probability > 0 and self._rng.random() < probability def _maybe_corrupt_tool_call(self, part: StreamedMessagePart) -> StreamedMessagePart: if not self._should_corrupt_tool_call(): return part if isinstance(part, ToolCall): return self._corrupt_tool_call(part) if isinstance(part, ToolCallPart): return self._corrupt_tool_call_part(part) return part def _corrupt_tool_call(self, tool_call: ToolCall) -> StreamedMessagePart: arguments = tool_call.function.arguments if arguments is None or not arguments.endswith("}"): return tool_call corrupted = tool_call.model_copy(deep=True) corrupted.function.arguments = arguments[:-1] return corrupted def _corrupt_tool_call_part(self, part: ToolCallPart) -> StreamedMessagePart: arguments = part.arguments_part if arguments is None or not arguments.endswith("}"): return part corrupted = part.model_copy(deep=True) corrupted.arguments_part = arguments[:-1] return corrupted if __name__ == "__main__": async def _dev_main_anthropic(): from dotenv import load_dotenv from kosong.contrib.chat_provider.anthropic import Anthropic from kosong.message import Message, TextPart load_dotenv() provider = Anthropic( model="claude-3-5-sonnet-latest", api_key=os.getenv("ANTHROPIC_API_KEY"), default_max_tokens=64, stream=True, ) chat = ChaosChatProvider( provider, ChaosConfig( error_probability=0.0, corrupt_tool_call_probability=0.2, seed=42, ), ) history = [Message(role="user", content=[TextPart(text="Say hello briefly.")])] stream = await chat.generate(system_prompt="", tools=[], history=history) async for part in stream: print(part.model_dump(exclude_none=True)) print("id:", stream.id) print("usage:", stream.usage) import asyncio asyncio.run(_dev_main_anthropic()) ================================================ FILE: packages/kosong/src/kosong/chat_provider/echo/__init__.py ================================================ from .echo import EchoChatProvider, EchoStreamedMessage from .scripted_echo import ScriptedEchoChatProvider, ScriptedEchoStreamedMessage __all__ = [ "EchoChatProvider", "EchoStreamedMessage", "ScriptedEchoChatProvider", "ScriptedEchoStreamedMessage", ] ================================================ FILE: packages/kosong/src/kosong/chat_provider/echo/dsl.py ================================================ from __future__ import annotations import json from typing import Any, cast from kosong.chat_provider import ChatProviderError, StreamedMessagePart, TokenUsage from kosong.message import ( AudioURLPart, ImageURLPart, TextPart, ThinkPart, ToolCall, ToolCallPart, VideoURLPart, ) def parse_echo_script( script: str, ) -> tuple[list[StreamedMessagePart], str | None, TokenUsage | None]: parts: list[StreamedMessagePart] = [] message_id: str | None = None usage: TokenUsage | None = None for lineno, raw_line in enumerate(script.splitlines(), start=1): line = raw_line.strip() if not line or line.startswith("#") or line.startswith("```"): continue if line.lower() == "echo": continue key, sep, payload = line.partition(":") if not sep: raise ChatProviderError(f"Invalid echo DSL at line {lineno}: {raw_line!r}") kind = key.strip().lower() payload = payload[1:] if payload.startswith(" ") else payload if kind == "id": message_id = _strip_quotes(payload.strip()) continue if kind == "usage": usage = _parse_usage(payload) continue part = _parse_part(kind, payload, lineno, raw_line) parts.append(part) return parts, message_id, usage def _parse_part(kind: str, payload: str, lineno: int, raw_line: str) -> StreamedMessagePart: match kind: case "text": return TextPart(text=_strip_quotes(payload)) case "think": return ThinkPart(think=_strip_quotes(payload)) case "image_url": url, image_id = _parse_url_payload(payload, kind) return ImageURLPart(image_url=ImageURLPart.ImageURL(url=url, id=image_id)) case "audio_url": url, audio_id = _parse_url_payload(payload, kind) return AudioURLPart(audio_url=AudioURLPart.AudioURL(url=url, id=audio_id)) case "video_url": url, video_id = _parse_url_payload(payload, kind) return VideoURLPart(video_url=VideoURLPart.VideoURL(url=url, id=video_id)) case "tool_call": return _parse_tool_call(payload, lineno, raw_line) case "tool_call_part": return _parse_tool_call_part(payload) case _: raise ChatProviderError( f"Unknown echo DSL kind '{kind}' at line {lineno}: {raw_line!r}" ) def _parse_usage(payload: str) -> TokenUsage: mapping = _parse_mapping(payload, context="usage") def _int_value(key: str) -> int: value = mapping.get(key, 0) try: return int(value) except (TypeError, ValueError): raise ChatProviderError( f"Usage field '{key}' must be an integer, got {value!r}" ) from None return TokenUsage( input_other=_int_value("input_other"), output=_int_value("output"), input_cache_read=_int_value("input_cache_read"), input_cache_creation=_int_value("input_cache_creation"), ) def _parse_url_payload(payload: str, kind: str) -> tuple[str, str | None]: value = _parse_value(payload) if isinstance(value, dict): mapping = cast(dict[str, Any], value) url = mapping.get("url") if not isinstance(url, str): raise ChatProviderError(f"{kind} requires a url field, got {mapping!r}") content_id = mapping.get("id") if content_id is not None and not isinstance(content_id, str): raise ChatProviderError(f"{kind} id must be a string when provided.") return url, content_id if not isinstance(value, str): raise ChatProviderError(f"{kind} expects url string or object, got {value!r}") return value, None def _parse_tool_call(payload: str, lineno: int, raw_line: str) -> ToolCall: mapping = _parse_mapping(payload, context="tool_call") function = mapping.get("function") if isinstance(mapping.get("function"), dict) else None tool_call_id = mapping.get("id") name = mapping.get("name") or (function.get("name") if function else None) arguments = mapping.get("arguments") extras = mapping.get("extras") if function: if arguments is None: arguments = function.get("arguments") if extras is None: extras = function.get("extras") if not isinstance(tool_call_id, str) or not isinstance(name, str): raise ChatProviderError( f"tool_call requires string id and name at line {lineno}: {raw_line!r}" ) if arguments is not None and not isinstance(arguments, str): raise ChatProviderError( f"tool_call.arguments must be a string at line {lineno}, got {type(arguments).__name__}" ) return ToolCall( id=tool_call_id, function=ToolCall.FunctionBody(name=name, arguments=arguments), extras=cast(dict[str, Any], extras) if isinstance(extras, dict) else None, ) def _parse_tool_call_part(payload: str) -> ToolCallPart: value = _parse_value(payload) if isinstance(value, dict): value = cast(dict[str, Any], value) arguments_part: Any | None = value.get("arguments_part") else: arguments_part = value if isinstance(arguments_part, (dict, list)): arguments_part = json.dumps(arguments_part, separators=(",", ":")) return ToolCallPart(arguments_part=None if arguments_part in (None, "") else arguments_part) def _parse_mapping(raw: str, *, context: str) -> dict[str, Any]: raw = raw.strip() try: loaded = json.loads(raw) except json.JSONDecodeError: loaded = None if isinstance(loaded, dict): return cast(dict[str, Any], loaded) if loaded is not None: raise ChatProviderError(f"{context} payload must be an object, got {loaded!r}") mapping: dict[str, Any] = {} for token in raw.replace(",", " ").split(): if not token: continue if "=" not in token: raise ChatProviderError(f"Invalid token '{token}' in {context} payload.") key, value = token.split("=", 1) mapping[key.strip()] = _parse_value(value.strip()) if not mapping: raise ChatProviderError(f"{context} payload cannot be empty.") return mapping def _parse_value(raw: str) -> Any: raw = raw.strip() if not raw: return None lowered = raw.lower() if lowered in {"null", "none"}: return None try: return json.loads(raw) except json.JSONDecodeError: return _strip_quotes(raw) def _strip_quotes(value: str) -> str: if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: return value[1:-1] return value ================================================ FILE: packages/kosong/src/kosong/chat_provider/echo/echo.py ================================================ from __future__ import annotations import copy from collections.abc import AsyncIterator, Sequence from typing import TYPE_CHECKING, Self from kosong.chat_provider import ( ChatProvider, ChatProviderError, StreamedMessage, StreamedMessagePart, ThinkingEffort, TokenUsage, ) from kosong.chat_provider.echo.dsl import parse_echo_script from kosong.message import Message from kosong.tooling import Tool if TYPE_CHECKING: def type_check(echo: EchoChatProvider): _: ChatProvider = echo class EchoChatProvider: """ A test-only chat provider that streams parts described by a tiny DSL. The DSL lives in the content of the last message in `history` and is made of lines in the form `kind: payload`. Empty lines, comment lines starting with `#`, and markdown fences starting with ``` are ignored. Supported kinds: - `id`: sets the streamed message id. - `usage`: token usage, e.g. `usage: {"input_other": 10, "output": 2}` or `usage: input_other=1 output=2 input_cache_read=3`. - `text`: a text chunk. - `think`: a thinking chunk. - `image_url`: either a raw URL or `{"url": "...", "id": "opt"}`. - `audio_url`: either a raw URL or `{"url": "...", "id": "opt"}`. - `video_url`: either a raw URL or `{"url": "...", "id": "opt"}`. - `tool_call`: a JSON or key/value object. Fields: `id`, `name` (or `function.name`), optional `arguments`/`function.arguments`, optional `extras`. - `tool_call_part`: a string/JSON with `arguments_part`; `null` becomes `None`. Example: ``` id: echo-42 usage: {"input_other": 10, "output": 2} think: thinking... text: Hello, text: world! image_url: {"url": "https://example.com/image.png", "id": "img-1"} tool_call: {"id": "call-1", "name": "search", "arguments": "{\\"query"} tool_call_part: {"arguments_part": "\\": \\"what time is"} tool_call_part: {"arguments_part": " it?\\"}"} ``` """ name = "echo" @property def model_name(self) -> str: return "echo" @property def thinking_effort(self) -> ThinkingEffort | None: return None async def generate( self, system_prompt: str, tools: Sequence[Tool], history: Sequence[Message], ) -> EchoStreamedMessage: if not history: raise ChatProviderError("EchoChatProvider requires at least one message in history.") if history[-1].role != "user": raise ChatProviderError("EchoChatProvider expects the last history message to be user.") script_text = history[-1].extract_text() parts, message_id, usage = parse_echo_script(script_text) if not parts: raise ChatProviderError("EchoChatProvider DSL produced no streamable parts.") return EchoStreamedMessage(parts=parts, message_id=message_id, usage=usage) def with_thinking(self, effort: ThinkingEffort) -> Self: # Thinking effort is irrelevant to the echo provider; return a shallow copy to # satisfy the protocol and keep the instance immutable. return copy.copy(self) class EchoStreamedMessage(StreamedMessage): """Streamed message for EchoChatProvider.""" def __init__( self, *, parts: list[StreamedMessagePart], message_id: str | None, usage: TokenUsage | None, ): self._iter = self._to_stream(parts) self._id = message_id self._usage = usage def __aiter__(self) -> AsyncIterator[StreamedMessagePart]: return self async def __anext__(self) -> StreamedMessagePart: return await self._iter.__anext__() async def _to_stream( self, parts: list[StreamedMessagePart] ) -> AsyncIterator[StreamedMessagePart]: for part in parts: yield part @property def id(self) -> str | None: return self._id @property def usage(self) -> TokenUsage | None: return self._usage ================================================ FILE: packages/kosong/src/kosong/chat_provider/echo/scripted_echo.py ================================================ from __future__ import annotations import copy import json from collections import deque from collections.abc import AsyncIterator, Iterable, Sequence from typing import TYPE_CHECKING, Self from kosong.chat_provider import ( ChatProvider, ChatProviderError, StreamedMessage, StreamedMessagePart, ThinkingEffort, TokenUsage, ) from kosong.chat_provider.echo.dsl import parse_echo_script from kosong.message import Message from kosong.tooling import Tool if TYPE_CHECKING: def type_check(scripted: ScriptedEchoChatProvider): _: ChatProvider = scripted class ScriptedEchoChatProvider: """ A test-only chat provider that consumes a queue of echo DSL scripts per call. """ name = "scripted_echo" def __init__(self, scripts: Iterable[str], *, trace: bool = False): self._scripts = deque(scripts) self._turn = 0 self._trace = trace @property def model_name(self) -> str: return "scripted_echo" @property def thinking_effort(self) -> ThinkingEffort | None: return None async def generate( self, system_prompt: str, tools: Sequence[Tool], history: Sequence[Message], ) -> ScriptedEchoStreamedMessage: if not self._scripts: raise ChatProviderError(f"ScriptedEchoChatProvider exhausted at turn {self._turn + 1}.") script_text = self._scripts.popleft() if self._trace: script_json = json.dumps(script_text) print(f"SCRIPTED_ECHO TURN {self._turn + 1}: {script_json}") self._turn += 1 parts, message_id, usage = parse_echo_script(script_text) if not parts: raise ChatProviderError("ScriptedEchoChatProvider DSL produced no streamable parts.") return ScriptedEchoStreamedMessage(parts=parts, message_id=message_id, usage=usage) def with_thinking(self, effort: ThinkingEffort) -> Self: copied = copy.copy(self) copied._scripts = deque(self._scripts) return copied class ScriptedEchoStreamedMessage(StreamedMessage): """Streamed message for ScriptedEchoChatProvider.""" def __init__( self, *, parts: list[StreamedMessagePart], message_id: str | None, usage: TokenUsage | None, ): self._iter = self._to_stream(parts) self._id = message_id self._usage = usage def __aiter__(self) -> AsyncIterator[StreamedMessagePart]: return self async def __anext__(self) -> StreamedMessagePart: return await self._iter.__anext__() async def _to_stream( self, parts: list[StreamedMessagePart] ) -> AsyncIterator[StreamedMessagePart]: for part in parts: yield part @property def id(self) -> str | None: return self._id @property def usage(self) -> TokenUsage | None: return self._usage ================================================ FILE: packages/kosong/src/kosong/chat_provider/kimi.py ================================================ import copy import mimetypes import os import uuid from collections.abc import AsyncIterator, Sequence from typing import TYPE_CHECKING, Any, Literal, Self, Unpack, cast import httpx from openai import AsyncOpenAI, AsyncStream, BaseModel, OpenAIError, omit from openai._types import RequestFiles, RequestOptions from openai.types.chat import ( ChatCompletion, ChatCompletionChunk, ChatCompletionMessageFunctionToolCall, ChatCompletionMessageParam, ChatCompletionToolParam, ) from openai.types.completion_usage import CompletionUsage from typing_extensions import TypedDict from kosong.chat_provider import ( ChatProvider, ChatProviderError, RetryableChatProvider, StreamedMessagePart, ThinkingEffort, TokenUsage, ) from kosong.chat_provider.openai_common import ( close_replaced_openai_client, convert_error, create_openai_client, tool_to_openai, ) from kosong.message import ( ContentPart, Message, TextPart, ThinkPart, ToolCall, ToolCallPart, VideoURLPart, ) from kosong.tooling import Tool if TYPE_CHECKING: def type_check(kimi: "Kimi"): _: ChatProvider = kimi _: RetryableChatProvider = kimi class ThinkingConfig(TypedDict, total=True): type: Literal["enabled", "disabled"] class ExtraBody(TypedDict, total=False, extra_items=Any): thinking: ThinkingConfig class Kimi: """ A chat provider that uses the Kimi API. >>> chat_provider = Kimi(model="kimi-k2-turbo-preview", api_key="sk-1234567890") >>> chat_provider.name 'kimi' >>> chat_provider.model_name 'kimi-k2-turbo-preview' >>> chat_provider.with_generation_kwargs(temperature=0)._generation_kwargs {'temperature': 0} >>> chat_provider._generation_kwargs {} """ name = "kimi" class GenerationKwargs(TypedDict, total=False): """ See https://platform.moonshot.ai/docs/api/chat#request-body. """ max_tokens: int | None temperature: float | None top_p: float | None n: int | None presence_penalty: float | None frequency_penalty: float | None stop: str | list[str] | None prompt_cache_key: str | None reasoning_effort: str | None """Legacy thinking parameter. Use `extra_body.thinking` instead.""" extra_body: ExtraBody | None def __init__( self, *, model: str, api_key: str | None = None, base_url: str | None = None, stream: bool = True, **client_kwargs: Any, ): if api_key is None: api_key = os.getenv("KIMI_API_KEY") if api_key is None: raise ChatProviderError( "The api_key client option or the KIMI_API_KEY environment variable is not set" ) if base_url is None: base_url = os.getenv("KIMI_BASE_URL", "https://api.moonshot.ai/v1") self.model: str = model """The name of the model to use.""" self.stream: bool = stream """Whether to generate responses as a stream.""" self._api_key: str | None = api_key self._base_url: str | None = base_url self._client_kwargs: dict[str, Any] = dict(client_kwargs) self.client: AsyncOpenAI = create_openai_client( api_key=self._api_key, base_url=self._base_url, client_kwargs=self._client_kwargs, ) """The underlying `AsyncOpenAI` client.""" self._generation_kwargs: Kimi.GenerationKwargs = {} @property def model_name(self) -> str: return self.model @property def thinking_effort(self) -> ThinkingEffort | None: reasoning_effort = self._generation_kwargs.get("reasoning_effort") if reasoning_effort is None: return None match reasoning_effort: case "low": return "low" case "medium": return "medium" case "high": return "high" case _: return "off" async def generate( self, system_prompt: str, tools: Sequence[Tool], history: Sequence[Message], ) -> "KimiStreamedMessage": messages: list[ChatCompletionMessageParam] = [] if system_prompt: messages.append({"role": "system", "content": system_prompt}) messages.extend(_convert_message(message) for message in history) generation_kwargs: dict[str, Any] = { # default kimi generation kwargs "max_tokens": 32000, } generation_kwargs.update(self._generation_kwargs) try: response = await self.client.chat.completions.create( model=self.model, messages=messages, tools=(_convert_tool(tool) for tool in tools), stream=self.stream, stream_options={"include_usage": True} if self.stream else omit, **generation_kwargs, ) return KimiStreamedMessage(response) except (OpenAIError, httpx.HTTPError) as e: raise convert_error(e) from e def on_retryable_error(self, error: BaseException) -> bool: old_client = self.client self.client = create_openai_client( api_key=self._api_key, base_url=self._base_url, client_kwargs=self._client_kwargs, ) close_replaced_openai_client(old_client, client_kwargs=self._client_kwargs) return True def with_thinking(self, effort: ThinkingEffort) -> Self: match effort: case "off": reasoning_effort = None case "low": reasoning_effort = "low" case "medium": reasoning_effort = "medium" case "high": reasoning_effort = "high" return self.with_generation_kwargs(reasoning_effort=reasoning_effort).with_extra_body( { "thinking": { "type": "enabled" if effort != "off" else "disabled", } } ) def with_generation_kwargs(self, **kwargs: Unpack[GenerationKwargs]) -> Self: """ Copy the chat provider, updating the generation kwargs with the given values. Returns: Self: A new instance of the chat provider with updated generation kwargs. """ new_self = copy.copy(self) new_self._generation_kwargs = copy.deepcopy(self._generation_kwargs) new_self._generation_kwargs.update(kwargs) return new_self def with_extra_body(self, extra_body: ExtraBody) -> Self: """ Copy the chat provider, updating the extra_body in generation kwargs. Returns: Self: A new instance of the chat provider with updated extra_body. """ new_self = copy.copy(self) new_self._generation_kwargs = copy.deepcopy(self._generation_kwargs) old_extra_body = new_self._generation_kwargs.get("extra_body") or {} new_extra_body: ExtraBody = {**old_extra_body, **extra_body} new_self._generation_kwargs["extra_body"] = new_extra_body return new_self @property def model_parameters(self) -> dict[str, Any]: """ The parameters of the model to use. For tracing/logging purposes. """ model_parameters: dict[str, Any] = {"base_url": str(self.client.base_url)} model_parameters.update(self._generation_kwargs) return model_parameters @property def files(self) -> "KimiFiles": return KimiFiles(self.client) class KimiFiles: def __init__(self, client: AsyncOpenAI) -> None: self._client = client async def upload_video(self, *, data: bytes, mime_type: str) -> VideoURLPart: """Upload a video to Kimi files API and return a video URL content part.""" if not mime_type.startswith("video/"): raise ChatProviderError(f"Expected a video mime type, got {mime_type}") url = await self._upload_file(data=data, mime_type=mime_type, purpose="video") return VideoURLPart(video_url=VideoURLPart.VideoURL(url=url)) async def _upload_file(self, *, data: bytes, mime_type: str, purpose: "KimiFilePurpose") -> str: filename = _guess_filename(mime_type) files: RequestFiles = {"file": (filename, data, mime_type)} options: RequestOptions = {"headers": {"Content-Type": "multipart/form-data"}} try: response: KimiFileObject = await self._client.post( "/files", cast_to=KimiFileObject, body={"purpose": purpose}, files=files, options=options, ) except (OpenAIError, httpx.HTTPError) as e: raise convert_error(e) from e return f"ms://{response.id}" class KimiFileObject(BaseModel): id: str type KimiFilePurpose = Literal["video", "image"] def _guess_filename(mime_type: str) -> str: extension = mimetypes.guess_extension(mime_type) or ".bin" return f"upload{extension}" def _convert_message(message: Message) -> ChatCompletionMessageParam: message = message.model_copy(deep=True) reasoning_content: str = "" content: list[ContentPart] = [] for part in message.content: if isinstance(part, ThinkPart): reasoning_content += part.think else: content.append(part) message.content = content dumped_message = message.model_dump(exclude_none=True) if reasoning_content: dumped_message["reasoning_content"] = reasoning_content return cast(ChatCompletionMessageParam, dumped_message) def _convert_tool(tool: Tool) -> ChatCompletionToolParam: if tool.name.startswith("$"): # Kimi builtin functions start with `$` return cast( ChatCompletionToolParam, { "type": "builtin_function", "function": { "name": tool.name, # no need to set description and parameters }, }, ) else: return tool_to_openai(tool) class KimiStreamedMessage: """The streamed message of the Kimi chat provider.""" def __init__(self, response: ChatCompletion | AsyncStream[ChatCompletionChunk]): if isinstance(response, ChatCompletion): self._iter = self._convert_non_stream_response(response) else: self._iter = self._convert_stream_response(response) self._id: str | None = None self._usage: CompletionUsage | None = None def __aiter__(self) -> AsyncIterator[StreamedMessagePart]: return self async def __anext__(self) -> StreamedMessagePart: return await self._iter.__anext__() @property def id(self) -> str | None: return self._id @property def usage(self) -> TokenUsage | None: if self._usage: cached = 0 other_input = self._usage.prompt_tokens if hasattr(self._usage, "cached_tokens"): # https://platform.moonshot.cn/docs/api/chat#%E8%BF%94%E5%9B%9E%E5%86%85%E5%AE%B9 # TODO: delete this when Moonshot API becomes compatible with OpenAI API cached = getattr(self._usage, "cached_tokens") or 0 # noqa: B009 other_input -= cached elif ( self._usage.prompt_tokens_details and self._usage.prompt_tokens_details.cached_tokens ): cached = self._usage.prompt_tokens_details.cached_tokens other_input -= cached return TokenUsage( input_other=other_input, output=self._usage.completion_tokens, input_cache_read=cached, ) return None async def _convert_non_stream_response( self, response: ChatCompletion, ) -> AsyncIterator[StreamedMessagePart]: self._id = response.id self._usage = response.usage message = response.choices[0].message if reasoning_content := getattr(message, "reasoning_content", None): assert isinstance(reasoning_content, str) yield ThinkPart(think=reasoning_content) if message.content: yield TextPart(text=message.content) if message.tool_calls: for tool_call in message.tool_calls: if isinstance(tool_call, ChatCompletionMessageFunctionToolCall): yield ToolCall( id=tool_call.id or str(uuid.uuid4()), function=ToolCall.FunctionBody( name=tool_call.function.name, arguments=tool_call.function.arguments, ), ) async def _convert_stream_response( self, response: AsyncIterator[ChatCompletionChunk], ) -> AsyncIterator[StreamedMessagePart]: try: async for chunk in response: if chunk.id: self._id = chunk.id if usage := extract_usage_from_chunk(chunk): self._usage = usage if not chunk.choices: continue delta = chunk.choices[0].delta # convert thinking content if reasoning_content := getattr(delta, "reasoning_content", None): assert isinstance(reasoning_content, str) yield ThinkPart(think=reasoning_content) # convert text content if delta.content: yield TextPart(text=delta.content) # convert tool calls for tool_call in delta.tool_calls or []: if not tool_call.function: continue if tool_call.function.name: yield ToolCall( id=tool_call.id or str(uuid.uuid4()), function=ToolCall.FunctionBody( name=tool_call.function.name, arguments=tool_call.function.arguments, ), ) elif tool_call.function.arguments: yield ToolCallPart( arguments_part=tool_call.function.arguments, ) else: # skip empty tool calls pass except (OpenAIError, httpx.HTTPError) as e: raise convert_error(e) from e def extract_usage_from_chunk(chunk: ChatCompletionChunk) -> CompletionUsage | None: if chunk.usage: return chunk.usage if not chunk.choices: return None choice_dump: dict[str, object] = chunk.choices[0].model_dump() raw_usage = choice_dump.get("usage") if isinstance(raw_usage, CompletionUsage): return raw_usage if isinstance(raw_usage, dict): return CompletionUsage.model_validate(raw_usage) return None if __name__ == "__main__": async def _dev_main(): chat = Kimi(model="kimi-k2-turbo-preview", stream=False) system_prompt = "" history = [ Message(role="user", content="Hello, who is Confucius?"), ] stream = await chat.with_generation_kwargs( temperature=0, max_tokens=1000, ).generate(system_prompt, [], history) async for part in stream: print(part.model_dump(exclude_none=True)) print("id:", stream.id) print("usage:", stream.usage) import asyncio from dotenv import load_dotenv load_dotenv() asyncio.run(_dev_main()) ================================================ FILE: packages/kosong/src/kosong/chat_provider/mock.py ================================================ import copy from collections.abc import AsyncIterator, Sequence from typing import TYPE_CHECKING, Self from kosong.chat_provider import ( ChatProvider, StreamedMessage, StreamedMessagePart, ThinkingEffort, TokenUsage, ) from kosong.message import Message from kosong.tooling import Tool if TYPE_CHECKING: def type_check(mock: "MockChatProvider"): _: ChatProvider = mock class MockChatProvider(ChatProvider): """ A mock chat provider. """ name = "mock" def __init__( self, message_parts: list[StreamedMessagePart], ): """Initialize the mock chat provider with predefined message parts.""" self._message_parts = message_parts @property def model_name(self) -> str: return "mock" @property def thinking_effort(self) -> ThinkingEffort | None: return None async def generate( self, system_prompt: str, tools: Sequence[Tool], history: Sequence[Message], ) -> "MockStreamedMessage": """Always return the predefined message parts.""" return MockStreamedMessage(self._message_parts) def with_thinking(self, effort: ThinkingEffort) -> Self: return copy.copy(self) class MockStreamedMessage(StreamedMessage): """The streamed message of the mock chat provider.""" def __init__(self, message_parts: list[StreamedMessagePart]): self._iter = self._to_stream(message_parts) def __aiter__(self) -> AsyncIterator[StreamedMessagePart]: return self async def __anext__(self) -> StreamedMessagePart: return await self._iter.__anext__() async def _to_stream( self, message_parts: list[StreamedMessagePart] ) -> AsyncIterator[StreamedMessagePart]: for part in message_parts: yield part @property def id(self) -> str: return "mock" @property def usage(self) -> TokenUsage | None: return None ================================================ FILE: packages/kosong/src/kosong/chat_provider/openai_common.py ================================================ import asyncio import inspect from collections.abc import Awaitable, Mapping from typing import Any, cast import httpx import openai from openai import AsyncOpenAI, OpenAIError from openai.types import ReasoningEffort from openai.types.chat import ChatCompletionToolParam from kosong.chat_provider import ( APIConnectionError, APIStatusError, APITimeoutError, ChatProviderError, ThinkingEffort, ) from kosong.tooling import Tool def create_openai_client( *, api_key: str | None, base_url: str | None, client_kwargs: Mapping[str, Any], ) -> AsyncOpenAI: return AsyncOpenAI(api_key=api_key, base_url=base_url, **dict(client_kwargs)) async def _drain_awaitable(awaitable: Awaitable[object]) -> None: try: await awaitable except Exception: return def close_openai_client(client: AsyncOpenAI) -> None: close = getattr(client, "close", None) if not callable(close): return try: result = close() except Exception: return if not inspect.isawaitable(result): return try: loop = asyncio.get_running_loop() except RuntimeError: if hasattr(result, "close"): result.close() # type: ignore[attr-defined] return loop.create_task(_drain_awaitable(cast(Awaitable[object], result))) def close_replaced_openai_client(client: AsyncOpenAI, *, client_kwargs: Mapping[str, Any]) -> None: """ Close a replaced OpenAI client unless it would close a shared external http client. When callers pass `http_client=...` to `AsyncOpenAI`, multiple wrappers may share the same `httpx.AsyncClient`. Closing the replaced wrapper would also close that shared client and break the new wrapper immediately. """ shared_http_client = client_kwargs.get("http_client") if isinstance(shared_http_client, httpx.AsyncClient) and getattr(client, "_client", None) is ( shared_http_client ): return close_openai_client(client) def convert_error(error: OpenAIError | httpx.HTTPError) -> ChatProviderError: match error: case openai.APIStatusError(): return APIStatusError(error.status_code, error.message) case openai.APIConnectionError(): return APIConnectionError(error.message) case openai.APITimeoutError(): return APITimeoutError(error.message) case httpx.TimeoutException(): return APITimeoutError(str(error)) case httpx.NetworkError(): return APIConnectionError(str(error)) case httpx.HTTPStatusError(): return APIStatusError(error.response.status_code, str(error)) case _: return ChatProviderError(f"Error: {error}") def thinking_effort_to_reasoning_effort(effort: ThinkingEffort) -> ReasoningEffort: match effort: case "off": return None case "low": return "low" case "medium": return "medium" case "high": return "high" def reasoning_effort_to_thinking_effort(effort: ReasoningEffort) -> ThinkingEffort: match effort: case "low" | "minimal": return "low" case "medium": return "medium" case "high" | "xhigh": return "high" case "none" | None: return "off" def tool_to_openai(tool: Tool) -> ChatCompletionToolParam: """Convert a single tool to OpenAI tool format.""" # simply `model_dump` because the `Tool` type is OpenAI-compatible return { "type": "function", "function": { "name": tool.name, "description": tool.description, "parameters": tool.parameters, }, } ================================================ FILE: packages/kosong/src/kosong/contrib/__init__.py ================================================ ================================================ FILE: packages/kosong/src/kosong/contrib/chat_provider/__init__.py ================================================ ================================================ FILE: packages/kosong/src/kosong/contrib/chat_provider/anthropic.py ================================================ try: import anthropic as _ # noqa: F401 except ModuleNotFoundError as exc: raise ModuleNotFoundError( "Anthropic support requires the optional dependency 'anthropic'. " 'Install with `pip install "kosong[contrib]"`.' ) from exc import copy import json from collections.abc import AsyncIterator, Mapping, Sequence from typing import TYPE_CHECKING, Any, Literal, Self, TypedDict, Unpack, cast from anthropic import ( AnthropicError, AsyncAnthropic, AsyncStream, omit, ) from anthropic import ( APIConnectionError as AnthropicAPIConnectionError, ) from anthropic import ( APIStatusError as AnthropicAPIStatusError, ) from anthropic import ( APITimeoutError as AnthropicAPITimeoutError, ) from anthropic import ( AuthenticationError as AnthropicAuthenticationError, ) from anthropic import ( PermissionDeniedError as AnthropicPermissionDeniedError, ) from anthropic import ( RateLimitError as AnthropicRateLimitError, ) from anthropic.lib.streaming import MessageStopEvent from anthropic.types import ( Base64ImageSourceParam, CacheControlEphemeralParam, ContentBlockParam, ImageBlockParam, MessageDeltaEvent, MessageDeltaUsage, MessageParam, MessageStartEvent, MetadataParam, RawContentBlockDeltaEvent, RawContentBlockStartEvent, RawMessageStreamEvent, TextBlockParam, ThinkingBlockParam, ThinkingConfigParam, ToolChoiceParam, ToolParam, ToolResultBlockParam, ToolUseBlockParam, URLImageSourceParam, Usage, ) from anthropic.types import ( Message as AnthropicMessage, ) from anthropic.types.tool_result_block_param import Content as ToolResultContent from kosong.chat_provider import ( APIConnectionError, APIStatusError, APITimeoutError, ChatProvider, ChatProviderError, StreamedMessagePart, ThinkingEffort, TokenUsage, ) from kosong.contrib.chat_provider.common import ToolMessageConversion from kosong.message import ( ContentPart, ImageURLPart, Message, TextPart, ThinkPart, ToolCall, ToolCallPart, ) from kosong.tooling import Tool if TYPE_CHECKING: def type_check(anthropic: "Anthropic"): _: ChatProvider = anthropic type MessagePayload = tuple[str | None, list[MessageParam]] type BetaFeatures = Literal["interleaved-thinking-2025-05-14"] class Anthropic: """ Chat provider backed by Anthropic's Messages API. """ name = "anthropic" class GenerationKwargs(TypedDict, total=False): max_tokens: int | None temperature: float | None top_k: int | None top_p: float | None # e.g., {"type": "adaptive"} or {"type": "enabled", "budget_tokens": 1024} thinking: ThinkingConfigParam | None # e.g., {"type": "auto", "disable_parallel_tool_use": True} tool_choice: ToolChoiceParam | None beta_features: list[BetaFeatures] | None extra_headers: Mapping[str, str] | None def __init__( self, *, model: str, api_key: str | None = None, base_url: str | None = None, stream: bool = True, # which process should we apply on tool result tool_message_conversion: ToolMessageConversion | None = None, # Must provide a max_tokens. Can be overridden by .with_generation_kwargs() default_max_tokens: int, metadata: MetadataParam | None = None, **client_kwargs: Any, ): self._model = model self._stream = stream self._client = AsyncAnthropic(api_key=api_key, base_url=base_url, **client_kwargs) self._tool_message_conversion: ToolMessageConversion | None = tool_message_conversion self._metadata = metadata self._generation_kwargs: Anthropic.GenerationKwargs = { "max_tokens": default_max_tokens, "beta_features": ["interleaved-thinking-2025-05-14"], } @property def model_name(self) -> str: return self._model @property def thinking_effort(self) -> "ThinkingEffort | None": thinking_config = self._generation_kwargs.get("thinking") if thinking_config is None: return None if thinking_config["type"] == "disabled": return "off" if thinking_config["type"] == "adaptive": return "high" budget = thinking_config["budget_tokens"] if budget <= 1024: return "low" if budget <= 4096: return "medium" return "high" async def generate( self, system_prompt: str, tools: Sequence[Tool], history: Sequence[Message], ) -> "AnthropicStreamedMessage": # https://docs.claude.com/en/api/messages#body-messages # Anthropic API does not support system roles, but just a system prompt. system = ( [ TextBlockParam( text=system_prompt, type="text", cache_control=CacheControlEphemeralParam(type="ephemeral"), ) ] if system_prompt else omit ) messages: list[MessageParam] = [] for message in history: messages.append(self._convert_message(message)) if messages: last_message = messages[-1] last_content = last_message["content"] # inject cache control in the last content. # https://docs.claude.com/en/docs/build-with-claude/prompt-caching if isinstance(last_content, list) and last_content: content_blocks = cast(list[ContentBlockParam], last_content) last_block = content_blocks[-1] match last_block["type"]: case ( "text" | "image" | "document" | "search_result" | "tool_use" | "tool_result" | "server_tool_use" | "web_search_tool_result" ): last_block["cache_control"] = CacheControlEphemeralParam(type="ephemeral") case "thinking" | "redacted_thinking": pass generation_kwargs: dict[str, Any] = {} generation_kwargs.update(self._generation_kwargs) betas = generation_kwargs.pop("beta_features", []) extra_headers = { **{"anthropic-beta": ",".join(str(e) for e in betas)}, **(generation_kwargs.pop("extra_headers", {})), } tools_ = [_convert_tool(tool) for tool in tools] if tools: tools_[-1]["cache_control"] = CacheControlEphemeralParam(type="ephemeral") try: response = await self._client.messages.create( model=self._model, messages=messages, system=system, tools=tools_, stream=self._stream, extra_headers=extra_headers, metadata=self._metadata if self._metadata is not None else omit, **generation_kwargs, ) return AnthropicStreamedMessage(response) except AnthropicError as e: raise _convert_error(e) from e def _use_adaptive_thinking(self) -> bool: """Whether to use adaptive thinking (Opus 4.6+) instead of budget-based thinking.""" model = self._model.lower() return "opus-4.6" in model or "opus-4-6" in model def with_thinking(self, effort: "ThinkingEffort") -> Self: thinking_config: ThinkingConfigParam if self._use_adaptive_thinking(): # Opus 4.6+: use adaptive thinking (budget_tokens is deprecated). # The interleaved-thinking beta header is also not needed with adaptive. match effort: case "off": thinking_config = {"type": "disabled"} case _: thinking_config = {"type": "adaptive"} # type: ignore[typeddict-item] new = self.with_generation_kwargs(thinking=thinking_config) # Remove the now-unnecessary interleaved-thinking beta header. if ( beta_features := new._generation_kwargs.get("beta_features") ) and "interleaved-thinking-2025-05-14" in beta_features: beta_features.remove("interleaved-thinking-2025-05-14") return new else: # Pre-4.6 models: use legacy budget-based thinking. match effort: case "off": thinking_config = {"type": "disabled"} case "low": thinking_config = {"type": "enabled", "budget_tokens": 1024} case "medium": thinking_config = {"type": "enabled", "budget_tokens": 4096} case "high": thinking_config = {"type": "enabled", "budget_tokens": 32_000} return self.with_generation_kwargs(thinking=thinking_config) def with_generation_kwargs(self, **kwargs: Unpack[GenerationKwargs]) -> Self: """ Copy the chat provider, updating the generation kwargs with the given values. Returns: Self: A new instance of the chat provider with updated generation kwargs. """ new_self = copy.copy(self) new_self._generation_kwargs = copy.deepcopy(self._generation_kwargs) new_self._generation_kwargs.update(kwargs) return new_self @property def model_parameters(self) -> dict[str, Any]: """ The parameters of the model to use. For tracing/logging purposes. """ model_parameters: dict[str, Any] = {"base_url": str(self._client.base_url)} model_parameters.update(self._generation_kwargs) return model_parameters def _convert_message(self, message: Message) -> MessageParam: """Convert a single internal message into Anthropic wire format.""" role = message.role if role == "system": # Anthropic does not support system messages in the conversation. # We map it to a special user message. return MessageParam( role="user", content=[ TextBlockParam( type="text", text=f"{message.extract_text(sep='\n')}" ) ], ) elif role == "tool": if message.tool_call_id is None: raise ChatProviderError("Tool message missing `tool_call_id`.") if self._tool_message_conversion == "extract_text": content = message.extract_text(sep="\n") else: content = message.content block = _tool_result_message_to_block(message.tool_call_id, content) return MessageParam(role="user", content=[block]) assert role in ("user", "assistant") blocks: list[ContentBlockParam] = [] for part in message.content: if isinstance(part, TextPart): blocks.append(TextBlockParam(type="text", text=part.text)) elif isinstance(part, ImageURLPart): blocks.append(_image_url_part_to_anthropic(part)) elif isinstance(part, ThinkPart): if part.encrypted is None: # missing signature, strip this thinking block. continue else: blocks.append( ThinkingBlockParam( type="thinking", thinking=part.think, signature=part.encrypted ) ) else: continue for tool_call in message.tool_calls or []: if tool_call.function.arguments: try: parsed_arguments = json.loads(tool_call.function.arguments) except json.JSONDecodeError as exc: # pragma: no cover - defensive guard raise ChatProviderError("Tool call arguments must be valid JSON.") from exc if not isinstance(parsed_arguments, dict): raise ChatProviderError("Tool call arguments must be a JSON object.") tool_input = cast(dict[str, object], parsed_arguments) else: tool_input = {} blocks.append( ToolUseBlockParam( type="tool_use", id=tool_call.id, name=tool_call.function.name, input=tool_input, ) ) return MessageParam(role=role, content=blocks) class AnthropicStreamedMessage: def __init__(self, response: AnthropicMessage | AsyncStream[RawMessageStreamEvent]): if isinstance(response, AnthropicMessage): self._iter = self._convert_non_stream_response(response) else: self._iter = self._convert_stream_response(response) self._id: str | None = None self._usage = Usage(input_tokens=0, output_tokens=0) def __aiter__(self) -> AsyncIterator[StreamedMessagePart]: return self async def __anext__(self) -> StreamedMessagePart: return await self._iter.__anext__() @property def id(self) -> str | None: return self._id @property def usage(self) -> TokenUsage | None: # https://docs.claude.com/en/docs/build-with-claude/prompt-caching#tracking-cache-performance return TokenUsage( # Note: in some Anthropic-compatible APIs, input_tokens can be None input_other=self._usage.input_tokens or 0, output=self._usage.output_tokens, input_cache_read=self._usage.cache_read_input_tokens or 0, input_cache_creation=self._usage.cache_creation_input_tokens or 0, ) def _update_usage(self, delta_usage: MessageDeltaUsage) -> None: if delta_usage.cache_creation_input_tokens is not None: self._usage.cache_creation_input_tokens = delta_usage.cache_creation_input_tokens if delta_usage.cache_read_input_tokens is not None: self._usage.cache_read_input_tokens = delta_usage.cache_read_input_tokens if delta_usage.input_tokens is not None: self._usage.input_tokens = delta_usage.input_tokens if delta_usage.output_tokens is not None: # type: ignore self._usage.output_tokens = delta_usage.output_tokens async def _convert_non_stream_response( self, response: AnthropicMessage, ) -> AsyncIterator[StreamedMessagePart]: self._id = response.id self._usage = response.usage for block in response.content: match block.type: case "text": yield TextPart(text=block.text) case "thinking": yield ThinkPart(think=block.thinking, encrypted=block.signature) case "redacted_thinking": yield ThinkPart(think="", encrypted=block.data) case "tool_use": yield ToolCall( id=block.id, function=ToolCall.FunctionBody( name=block.name, arguments=json.dumps(block.input) ), ) case _: continue async def _convert_stream_response( self, manager: AsyncStream[RawMessageStreamEvent], ) -> AsyncIterator[StreamedMessagePart]: try: async with manager as stream: async for event in stream: if isinstance(event, MessageStartEvent): self._id = event.message.id # Capture initial usage from start event # (contains initial prompt/input token usage) self._usage = event.message.usage elif isinstance(event, RawContentBlockStartEvent): block = event.content_block match block.type: case "text": yield TextPart(text=block.text) case "thinking": yield ThinkPart(think=block.thinking) case "redacted_thinking": yield ThinkPart(think="", encrypted=block.data) case "tool_use": yield ToolCall( id=block.id, function=ToolCall.FunctionBody(name=block.name, arguments=""), ) case "server_tool_use" | "web_search_tool_result": # ignore continue elif isinstance(event, RawContentBlockDeltaEvent): delta = event.delta match delta.type: case "text_delta": yield TextPart(text=delta.text) case "thinking_delta": yield ThinkPart(think=delta.thinking) case "input_json_delta": yield ToolCallPart(arguments_part=delta.partial_json) case "signature_delta": yield ThinkPart(think="", encrypted=delta.signature) case "citations_delta": # ignore continue elif isinstance(event, MessageDeltaEvent): if event.usage: self._update_usage(event.usage) elif isinstance(event, MessageStopEvent): continue except AnthropicError as exc: raise _convert_error(exc) from exc def _convert_tool(tool: Tool) -> ToolParam: return { "name": tool.name, "description": tool.description, "input_schema": tool.parameters, } def _tool_result_message_to_block( tool_call_id: str, content: str | list[ContentPart] ) -> ToolResultBlockParam: block_content: str | list[ToolResultContent] # If tool_result_process is `extract_text`, we join all text parts into one string if isinstance(content, str): block_content = content else: # Otherwise, map parts to content blocks blocks: list[ToolResultContent] = [] for part in content: if isinstance(part, TextPart): if part.text: blocks.append(TextBlockParam(type="text", text=part.text)) elif isinstance(part, ImageURLPart): blocks.append(_image_url_part_to_anthropic(part)) else: # https://docs.claude.com/en/docs/build-with-claude/files#file-types-and-content-blocks # Anthropic API supports very limited file types raise ChatProviderError( f"Anthropic API does not support {type(part)} in tool result" ) block_content = blocks return ToolResultBlockParam( type="tool_result", tool_use_id=tool_call_id, content=block_content, ) def _image_url_part_to_anthropic(part: ImageURLPart) -> ImageBlockParam: url = part.image_url.url # data:[][;base64], if url.startswith("data:"): res = url[5:].split(";base64,", 1) if len(res) != 2: raise ChatProviderError(f"Invalid data URL for image: {url}") media_type, data = res if media_type not in ("image/png", "image/jpeg", "image/gif", "image/webp"): raise ChatProviderError( f"Unsupported media type for base64 image: {media_type}, url: {url}" ) return ImageBlockParam( type="image", source=Base64ImageSourceParam( type="base64", data=data, media_type=media_type, ), ) else: return ImageBlockParam( type="image", source=URLImageSourceParam(type="url", url=url), ) def _convert_error(error: AnthropicError) -> ChatProviderError: if isinstance(error, AnthropicAPIStatusError): return APIStatusError(error.status_code, str(error)) if isinstance(error, AnthropicAuthenticationError): return APIStatusError(getattr(error, "status_code", 401), str(error)) if isinstance(error, AnthropicPermissionDeniedError): return APIStatusError(getattr(error, "status_code", 403), str(error)) if isinstance(error, AnthropicRateLimitError): return APIStatusError(getattr(error, "status_code", 429), str(error)) if isinstance(error, AnthropicAPIConnectionError): return APIConnectionError(str(error)) if isinstance(error, AnthropicAPITimeoutError): return APITimeoutError(str(error)) return ChatProviderError(f"Anthropic error: {error}") ================================================ FILE: packages/kosong/src/kosong/contrib/chat_provider/common.py ================================================ from __future__ import annotations from typing import Literal type ToolMessageConversion = Literal["extract_text"] ================================================ FILE: packages/kosong/src/kosong/contrib/chat_provider/google_genai.py ================================================ try: from google import genai as _ # noqa: F401 except ModuleNotFoundError as exc: raise ModuleNotFoundError( "Google Gemini support requires the optional dependency 'google-genai'. " 'Install with `pip install "kosong[contrib]"`.' ) from exc import base64 import copy import json import mimetypes from collections.abc import AsyncIterator, Sequence from typing import TYPE_CHECKING, Any, Self, TypedDict, Unpack, cast import httpx from google import genai from google.genai import client as genai_client from google.genai import errors as genai_errors from google.genai.types import ( Content, FunctionCall, FunctionDeclaration, FunctionResponse, FunctionResponsePart, GenerateContentConfig, GenerateContentResponse, GenerateContentResponseUsageMetadata, HttpOptions, Part, ThinkingConfig, ThinkingLevel, Tool, ToolConfig, ) from kosong.chat_provider import ( APIStatusError, APITimeoutError, ChatProvider, ChatProviderError, StreamedMessagePart, ThinkingEffort, TokenUsage, ) from kosong.message import ( AudioURLPart, ContentPart, ImageURLPart, Message, TextPart, ThinkPart, ToolCall, ) from kosong.tooling import Tool as KosongTool from kosong.tooling import ToolReturnValue if TYPE_CHECKING: def type_check(google_genai: "GoogleGenAI"): _: ChatProvider = google_genai class GoogleGenAI: """ Chat provider backed by Google's Gemini API. """ name = "google_genai" class GenerationKwargs(TypedDict, total=False): max_output_tokens: int | None temperature: float | None top_k: int | None top_p: float | None # Thinking configuration for supported models thinking_config: ThinkingConfig | None # Tool configuration tool_config: ToolConfig | None # Extra headers http_options: HttpOptions | None def __init__( self, *, model: str, api_key: str | None = None, base_url: str | None = None, stream: bool = True, vertexai: bool | None = None, **client_kwargs: Any, ): self._model = model self._stream = stream self._base_url = base_url self._client: genai_client.Client = genai.Client( http_options=HttpOptions(base_url=base_url), api_key=api_key, vertexai=vertexai, **client_kwargs, ) self._generation_kwargs: GoogleGenAI.GenerationKwargs = {} @property def model_name(self) -> str: return self._model @property def thinking_effort(self) -> "ThinkingEffort | None": thinking_config = self._generation_kwargs.get("thinking_config") if thinking_config is None: return None # For gemini-3 models that use thinking_level thinking_level = thinking_config.thinking_level if thinking_level is not None: match thinking_level: case ThinkingLevel.LOW | ThinkingLevel.MINIMAL: return "low" case ThinkingLevel.MEDIUM: return "medium" case ThinkingLevel.HIGH: return "high" case _: return None # For other models that use thinking_budget thinking_budget = thinking_config.thinking_budget if thinking_budget is not None: if thinking_budget == 0: return "off" if thinking_budget <= 1024: return "low" if thinking_budget <= 4096: return "medium" return "high" return None async def generate( self, system_prompt: str, tools: Sequence[KosongTool], history: Sequence[Message], ) -> "GoogleGenAIStreamedMessage": contents = messages_to_google_genai_contents(history) config = GenerateContentConfig(**self._generation_kwargs) config.system_instruction = system_prompt config.tools = [tool_to_google_genai(tool) for tool in tools] try: if self._stream: stream_response = await self._client.aio.models.generate_content_stream( # type: ignore[reportUnknownMemberType] model=self._model, contents=contents, # type: ignore[reportArgumentType] config=config, ) return GoogleGenAIStreamedMessage(stream_response) else: response = await self._client.aio.models.generate_content( # type: ignore[reportUnknownMemberType] model=self._model, contents=contents, # type: ignore[reportArgumentType] config=config, ) return GoogleGenAIStreamedMessage(response) except Exception as e: # genai_errors.APIError and others raise _convert_error(e) from e def with_thinking(self, effort: "ThinkingEffort") -> Self: thinking_config = ThinkingConfig(include_thoughts=True) # Map thinking effort to budget tokens if "gemini-3" in self._model: match effort: case "off": # use default thinking config pass case "low": thinking_config.thinking_level = ThinkingLevel.LOW case "medium": # FIXME: medium not supported yet, use high thinking_config.thinking_level = ThinkingLevel.HIGH case "high": thinking_config.thinking_level = ThinkingLevel.HIGH else: match effort: case "off": thinking_config.thinking_budget = 0 thinking_config.include_thoughts = False case "low": thinking_config.thinking_budget = 1024 thinking_config.include_thoughts = True case "medium": thinking_config.thinking_budget = 4096 thinking_config.include_thoughts = True case "high": thinking_config.thinking_budget = 32_000 thinking_config.include_thoughts = True return self.with_generation_kwargs(thinking_config=thinking_config) def with_generation_kwargs(self, **kwargs: Unpack[GenerationKwargs]) -> Self: """ Copy the chat provider, updating the generation kwargs with the given values. Returns: Self: A new instance of the chat provider with updated generation kwargs. """ new_self = copy.copy(self) new_self._generation_kwargs = copy.deepcopy(self._generation_kwargs) new_self._generation_kwargs.update(kwargs) return new_self @property def model_parameters(self) -> dict[str, Any]: """ The parameters of the model to use. For tracing/logging purposes. """ return { "model": self._model, "base_url": self._base_url, **self._generation_kwargs, } class GoogleGenAIStreamedMessage: def __init__(self, response: GenerateContentResponse | AsyncIterator[GenerateContentResponse]): if isinstance(response, GenerateContentResponse): self._iter = self._convert_non_stream_response(response) else: self._iter = self._convert_stream_response(response) self._id: str | None = None self._usage: GenerateContentResponseUsageMetadata | None = None def __aiter__(self) -> AsyncIterator[StreamedMessagePart]: return self async def __anext__(self) -> StreamedMessagePart: return await self._iter.__anext__() @property def id(self) -> str | None: return self._id @property def usage(self) -> TokenUsage | None: if self._usage is None: return None return TokenUsage( input_other=self._usage.prompt_token_count or 0, output=self._usage.candidates_token_count or 0, input_cache_read=self._usage.cached_content_token_count or 0, input_cache_creation=0, ) async def _convert_non_stream_response( self, response: GenerateContentResponse, ) -> AsyncIterator[StreamedMessagePart]: # Extract usage information if response.usage_metadata: self._usage = response.usage_metadata # Extract ID if available if response.response_id is not None: self._id = response.response_id # Process candidates for candidate in response.candidates or []: parts = candidate.content.parts if candidate.content else None if not parts: continue for part in parts: async for message_part in self._process_part_async(part): yield message_part async def _convert_stream_response( self, response_stream: AsyncIterator[GenerateContentResponse], ) -> AsyncIterator[StreamedMessagePart]: try: async for response in response_stream: # Extract ID from first response if not self._id and response.response_id is not None: self._id = response.response_id # Extract usage information if response.usage_metadata: self._usage = response.usage_metadata # Process candidates for candidate in response.candidates or []: parts = candidate.content.parts if candidate.content else None if not parts: continue for part in parts: async for message_part in self._process_part_async(part): yield message_part except genai_errors.APIError as exc: raise _convert_error(exc) from exc def _process_part(self, part: Part): """Process a single part and yield message components (synchronous generator). Handles different part types from Gemini API: - synthetic thinking parts (part.thought is True) - encrypted thinking parts (part.thought_signature is not None) - text parts - function calls """ if part.thought: # Synthetic thinking part if part.text: yield ThinkPart(think=part.text) elif part.text: # Regular text part yield TextPart(text=part.text) elif part.function_call: func_call = part.function_call if func_call.name is None: # Skip function calls without a name return id_ = func_call.id if func_call.id is not None else f"{id(func_call)}" tool_call_id = f"{func_call.name}_{id_}" # Gemini uses thought_signature to store the encrypted thinking signature. # part.thought is synthetic # See: https://colab.research.google.com/github/GoogleCloudPlatform/generative-ai/blob/main/gemini/thinking/intro_thought_signatures.ipynb thought_signature_b64 = ( base64.b64encode(part.thought_signature).decode("ascii") if part.thought_signature else None ) yield ToolCall( id=tool_call_id, function=ToolCall.FunctionBody( name=func_call.name, arguments=json.dumps(func_call.args) if func_call.args else "{}", ), extras={ "thought_signature_b64": thought_signature_b64, } if thought_signature_b64 else None, ) async def _process_part_async(self, part: Part) -> AsyncIterator[StreamedMessagePart]: """Async wrapper for _process_part.""" for message_part in self._process_part(part): yield message_part def tool_to_google_genai(tool: KosongTool) -> Tool: """Convert a Kosong tool to GoogleGenAI tool format.""" # Use parameters_json_schema instead of parameters to bypass the SDK's # Pydantic validation (extra='forbid') which rejects standard JSON Schema # metadata fields like $schema, $id, $comment, examples, etc. # This is the SDK's official way to pass raw JSON Schema directly to the API. return Tool( function_declarations=[ FunctionDeclaration( name=tool.name, description=tool.description, parameters_json_schema=tool.parameters, ) ] ) def _image_url_part_to_google_genai(part: ImageURLPart) -> Part: """Convert an image URL part to GoogleGenAI format.""" url = part.image_url.url # Handle data URLs if url.startswith("data:"): # data:[][;base64], res = url[5:].split(";base64,", 1) if len(res) != 2: raise ChatProviderError(f"Invalid data URL for image: {url}") media_type, data_b64 = res if media_type not in ("image/png", "image/jpeg", "image/gif", "image/webp"): raise ChatProviderError( f"Unsupported media type for base64 image: {media_type}, url: {url}" ) # Decode base64 string to bytes data_bytes = base64.b64decode(data_b64) return Part.from_bytes(data=data_bytes, mime_type=media_type) else: # For regular URLs, try to download the image and convert to bytes mime_type, _ = mimetypes.guess_type(url) if not mime_type or not mime_type.startswith("image/"): # Default to image/png if we can't detect or it's not an image type mime_type = "image/png" response = httpx.get(url).raise_for_status() data_bytes = response.content return Part.from_bytes(data=data_bytes, mime_type=mime_type) def _audio_url_part_to_google_genai(part: AudioURLPart) -> Part: """Convert an audio URL part to GoogleGenAI format.""" url = part.audio_url.url # Handle data URLs if url.startswith("data:"): # data:[][;base64], res = url[5:].split(";base64,", 1) if len(res) != 2: raise ChatProviderError(f"Invalid data URL for audio: {url}") media_type, data_b64 = res # Supported audio formats for GoogleGenAI supported_audio_types = ( "audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac", ) if media_type not in supported_audio_types: error_msg = ( f"Unsupported media type for base64 audio: {media_type}, url: {url}. " f"Supported types: {supported_audio_types}" ) raise ChatProviderError(error_msg) # Decode base64 string to bytes data_bytes = base64.b64decode(data_b64) return Part.from_bytes(data=data_bytes, mime_type=media_type) else: # Fetch the audio and convert to bytes mime_type, _ = mimetypes.guess_type(url) if not mime_type or not mime_type.startswith("audio/"): # Default to audio/mp3 if we can't detect or it's not an audio type mime_type = "audio/mp3" response = httpx.get(url).raise_for_status() data_bytes = response.content return Part.from_bytes(data=data_bytes, mime_type=mime_type) def _tool_result_to_response_and_parts( parts: list[ContentPart], ) -> tuple[dict[str, str], list[FunctionResponsePart]]: """Convert tool response content to Gemini function response format.""" genai_parts: list[FunctionResponsePart] = [] response: str = "" for part in parts: if isinstance(part, TextPart): if part.text: response += part.text elif isinstance(part, ImageURLPart): genai_parts.append(FunctionResponsePart.from_uri(file_uri=part.image_url.url)) elif isinstance(part, AudioURLPart): genai_parts.append(FunctionResponsePart.from_uri(file_uri=part.audio_url.url)) else: # Skip unsupported parts (like ThinkPart, etc.) continue return {"output": response}, genai_parts def _tool_call_id_to_name(tool_call_id: str, tool_name_by_id: dict[str, str]) -> str: """Resolve Gemini `FunctionResponse.name` from a tool_call_id.""" if tool_call_id in tool_name_by_id: return tool_name_by_id[tool_call_id] # Fallback for older ids of the form "{tool_name}_{id}". return tool_call_id.split("_", 1)[0] def _tool_message_to_function_response_part( message: Message, *, tool_name_by_id: dict[str, str], ) -> Part: if message.role != "tool": # pragma: no cover - defensive guard raise ChatProviderError("Expected a tool message.") if message.tool_call_id is None: raise ChatProviderError("Tool response is missing `tool_call_id`.") response_data, tool_result_parts = _tool_result_to_response_and_parts(message.content) return Part( function_response=FunctionResponse( id=message.tool_call_id, name=_tool_call_id_to_name(message.tool_call_id, tool_name_by_id), response=response_data, parts=tool_result_parts, ) ) def _tool_messages_to_google_genai_content( messages: Sequence[Message], *, tool_name_by_id: dict[str, str], expected_tool_call_ids: Sequence[str] | None = None, require_all_expected: bool = False, ) -> Content: """Pack one-or-more tool results into a single Gemini "user" turn. VertexAI-backed Gemini enforces that, for a tool-calling turn, the next turn contains the same number of `functionResponse` parts as the preceding `functionCall` parts. Packing multiple tool results into a single "user" Content keeps us compliant and avoids ordering issues from parallel tool execution. """ if not messages: raise ChatProviderError("Expected at least one tool message.") expected_index: dict[str, int] = ( {tool_call_id: i for i, tool_call_id in enumerate(expected_tool_call_ids)} if expected_tool_call_ids is not None else {} ) seen_tool_call_ids: set[str] = set() indexed_messages = list(enumerate(messages)) indexed_messages.sort( key=lambda t: (expected_index.get(cast(str, t[1].tool_call_id), 10**9), t[0]) ) parts: list[Part] = [] actual_tool_call_ids: list[str] = [] for _, message in indexed_messages: if message.tool_call_id is None: raise ChatProviderError("Tool response is missing `tool_call_id`.") if message.tool_call_id in seen_tool_call_ids: raise ChatProviderError(f"Duplicate tool response for id: {message.tool_call_id}") seen_tool_call_ids.add(message.tool_call_id) actual_tool_call_ids.append(message.tool_call_id) parts.append( _tool_message_to_function_response_part(message, tool_name_by_id=tool_name_by_id) ) if expected_tool_call_ids is not None and require_all_expected: expected_set = set(expected_tool_call_ids) missing = [ tool_call_id for tool_call_id in expected_tool_call_ids if tool_call_id not in seen_tool_call_ids ] extra = [ tool_call_id for tool_call_id in actual_tool_call_ids if tool_call_id not in expected_set ] if missing: raise ChatProviderError(f"Missing tool responses for ids: {missing}") if extra: raise ChatProviderError(f"Unexpected tool responses for ids: {extra}") return Content(role="user", parts=parts) def messages_to_google_genai_contents(messages: Sequence[Message]) -> list[Content]: """Convert internal messages into a Gemini contents list. Tool results for a tool-calling turn are packed into a single "user" message with N `functionResponse` parts matching the preceding "model" message's N `functionCall` parts. This avoids ordering issues from parallel tool execution and satisfies VertexAI's stricter validation. """ contents: list[Content] = [] tool_name_by_id: dict[str, str] = {} i = 0 while i < len(messages): message = messages[i] if message.role == "assistant" and message.tool_calls: contents.append(message_to_google_genai(message)) expected_tool_call_ids: list[str] = [] for tool_call in message.tool_calls: tool_name_by_id[tool_call.id] = tool_call.function.name expected_tool_call_ids.append(tool_call.id) # Collect consecutive tool messages that correspond to this turn. j = i + 1 tool_messages: list[Message] = [] while j < len(messages) and messages[j].role == "tool": tool_messages.append(messages[j]) j += 1 if tool_messages: contents.append( _tool_messages_to_google_genai_content( tool_messages, tool_name_by_id=tool_name_by_id, expected_tool_call_ids=expected_tool_call_ids, require_all_expected=True, ) ) i = j continue i += 1 continue if message.role == "tool": # Tool message without an immediately preceding tool-calling assistant # message (e.g. truncated history). Convert it best-effort. contents.append( _tool_messages_to_google_genai_content([message], tool_name_by_id=tool_name_by_id) ) i += 1 continue contents.append(message_to_google_genai(message)) if message.role == "assistant" and message.tool_calls: for tool_call in message.tool_calls: tool_name_by_id[tool_call.id] = tool_call.function.name i += 1 return contents def message_to_google_genai(message: Message) -> Content: """Convert a single internal message into GoogleGenAI wire format.""" role = message.role if role == "tool": raise ChatProviderError( "Tool messages must be converted via messages_to_google_genai_contents " "to preserve tool-call ordering and tool-response packing." ) # GoogleGenAI uses: "user" and "model" (not "assistant") google_genai_role = "model" if role == "assistant" else role parts: list[Part] = [] # Handle content parts for part in message.content: if isinstance(part, TextPart): parts.append(Part.from_text(text=part.text)) elif isinstance(part, ImageURLPart): parts.append(_image_url_part_to_google_genai(part)) elif isinstance(part, AudioURLPart): parts.append(_audio_url_part_to_google_genai(part)) elif isinstance(part, ThinkPart): # Note: skip part.thought because it is synthetic continue else: # Skip unsupported parts continue # Handle tool calls for assistant messages for tool_call in message.tool_calls or []: if tool_call.function.arguments: try: parsed_arguments = json.loads(tool_call.function.arguments) except json.JSONDecodeError as exc: # pragma: no cover - defensive guard raise ChatProviderError("Tool call arguments must be valid JSON.") from exc if not isinstance(parsed_arguments, dict): raise ChatProviderError("Tool call arguments must be a JSON object.") args = cast(dict[str, object], parsed_arguments) else: args = {} function_call = FunctionCall( id=tool_call.id, name=tool_call.function.name, args=args, ) function_call_part = Part(function_call=function_call) # Add thought_signature back to function_call if tool_call.extras and "thought_signature_b64" in tool_call.extras: function_call_part.thought_signature = base64.b64decode( cast(str, tool_call.extras["thought_signature_b64"]) ) parts.append(function_call_part) return Content(role=google_genai_role, parts=parts) def _convert_error(error: Exception) -> ChatProviderError: """Convert a GoogleGenAI error to a Kosong chat provider error.""" # Handle specific GoogleGenAI error types with detailed status code mapping if isinstance(error, genai_errors.ClientError): # 4xx client errors status_code = getattr(error, "code", 400) if status_code == 401: return APIStatusError(401, f"Authentication failed: {error}") elif status_code == 403: return APIStatusError(403, f"Permission denied: {error}") elif status_code == 429: return APIStatusError(429, f"Rate limit exceeded: {error}") return APIStatusError(status_code, str(error)) elif isinstance(error, genai_errors.ServerError): # 5xx server errors status_code = getattr(error, "code", 500) return APIStatusError(status_code, f"Server error: {error}") elif isinstance(error, genai_errors.APIError): # Generic API errors status_code = getattr(error, "code", 500) return APIStatusError(status_code, str(error)) elif isinstance(error, TimeoutError): return APITimeoutError(f"Request timed out: {error}") else: # Fallback for unexpected errors return ChatProviderError(f"Unexpected GoogleGenAI error: {error}") if __name__ == "__main__": async def main(): import os from typing import override from pydantic import BaseModel import kosong from kosong.tooling import CallableTool2, ToolOk from kosong.tooling.simple import SimpleToolset chat = GoogleGenAI( model="gemini-3-pro-preview", vertexai=True, api_key=os.getenv("VERTEXAI_API_KEY"), ).with_thinking("high") system_prompt = "You are a helpful assistant." class GetWeatherParams(BaseModel): city: str class GetWeather(CallableTool2[GetWeatherParams]): name: str = "get_weather" description: str = "Get the weather of a city" params: type[GetWeatherParams] = GetWeatherParams @override async def __call__(self, params: GetWeatherParams) -> ToolReturnValue: return ToolOk(output="Sunny") toolset = SimpleToolset() toolset += GetWeather() history = [ Message( role="user", content=( "What's the weather like in Beijing and Shanghai? " "Spawn parallel tool calls to get the answer." ), ) ] result = await kosong.step(chat, system_prompt, toolset, history) tool_results = await result.tool_results() assistant_message = result.message tool_messages = [ Message(role="tool", content=tr.return_value.output, tool_call_id=tr.tool_call_id) for tr in tool_results ] history.extend([assistant_message] + tool_messages) async for part in await chat.generate(system_prompt, toolset.tools, history): print(part.model_dump(exclude_none=True)) import asyncio from dotenv import load_dotenv load_dotenv() asyncio.run(main()) ================================================ FILE: packages/kosong/src/kosong/contrib/chat_provider/openai_legacy.py ================================================ import copy import uuid from collections.abc import AsyncIterator, Sequence from typing import TYPE_CHECKING, Any, Self, Unpack, cast import httpx from openai import AsyncStream, Omit, OpenAIError, omit from openai.types import CompletionUsage, ReasoningEffort from openai.types.chat import ( ChatCompletion, ChatCompletionChunk, ChatCompletionMessageFunctionToolCall, ChatCompletionMessageParam, ) from typing_extensions import TypedDict from kosong.chat_provider import ( ChatProvider, RetryableChatProvider, StreamedMessagePart, ThinkingEffort, TokenUsage, ) from kosong.chat_provider.openai_common import ( close_replaced_openai_client, convert_error, create_openai_client, reasoning_effort_to_thinking_effort, thinking_effort_to_reasoning_effort, tool_to_openai, ) from kosong.contrib.chat_provider.common import ToolMessageConversion from kosong.message import ContentPart, Message, TextPart, ThinkPart, ToolCall, ToolCallPart from kosong.tooling import Tool if TYPE_CHECKING: def type_check(openai_legacy: "OpenAILegacy"): _: ChatProvider = openai_legacy _: RetryableChatProvider = openai_legacy class OpenAILegacy: """ A chat provider that uses the OpenAI Chat Completions API. >>> chat_provider = OpenAILegacy(model="gpt-5", api_key="sk-1234567890") >>> chat_provider.name 'openai' >>> chat_provider.model_name 'gpt-5' """ name = "openai" class GenerationKwargs(TypedDict, extra_items=Any, total=False): """ Generation kwargs for various kinds of OpenAI-compatible APIs. `extra_items=Any` is used to support any extra args. """ max_tokens: int | None temperature: float | None top_p: float | None n: int | None presence_penalty: float | None frequency_penalty: float | None stop: str | list[str] | None prompt_cache_key: str | None def __init__( self, *, model: str, api_key: str | None = None, base_url: str | None = None, stream: bool = True, reasoning_key: str | None = None, tool_message_conversion: ToolMessageConversion | None = None, **client_kwargs: Any, ): """ Initialize the OpenAILegacy chat provider. To support OpenAI-compatible APIs that inject reasoning content in a extra field in the message, such as `{"reasoning": ...}`, `reasoning_key` can be set to the key name. """ self.model = model self.stream = stream self._api_key: str | None = api_key self._base_url: str | None = base_url self._client_kwargs: dict[str, Any] = dict(client_kwargs) self.client = create_openai_client( api_key=self._api_key, base_url=self._base_url, client_kwargs=self._client_kwargs, ) """The underlying `AsyncOpenAI` client.""" self._reasoning_effort: ReasoningEffort | Omit = omit self._reasoning_key = reasoning_key self._tool_message_conversion: ToolMessageConversion | None = tool_message_conversion self._generation_kwargs: OpenAILegacy.GenerationKwargs = {} @property def model_name(self) -> str: return self.model @property def thinking_effort(self) -> ThinkingEffort | None: if isinstance(self._reasoning_effort, Omit): return None return reasoning_effort_to_thinking_effort(self._reasoning_effort) async def generate( self, system_prompt: str, tools: Sequence[Tool], history: Sequence[Message], ) -> "OpenAILegacyStreamedMessage": messages: list[ChatCompletionMessageParam] = [] if system_prompt: # `system` vs `developer`: see `message_to_openai` comments messages.append({"role": "system", "content": system_prompt}) messages.extend(self._convert_message(message) for message in history) generation_kwargs: dict[str, Any] = {} generation_kwargs.update(self._generation_kwargs) try: response = await self.client.chat.completions.create( model=self.model, messages=messages, tools=(tool_to_openai(tool) for tool in tools), stream=self.stream, stream_options={"include_usage": True} if self.stream else omit, reasoning_effort=self._reasoning_effort, **generation_kwargs, ) return OpenAILegacyStreamedMessage(response, self._reasoning_key) except (OpenAIError, httpx.HTTPError) as e: raise convert_error(e) from e def on_retryable_error(self, error: BaseException) -> bool: old_client = self.client self.client = create_openai_client( api_key=self._api_key, base_url=self._base_url, client_kwargs=self._client_kwargs, ) close_replaced_openai_client(old_client, client_kwargs=self._client_kwargs) return True def with_thinking(self, effort: ThinkingEffort) -> Self: new_self = copy.copy(self) new_self._reasoning_effort = thinking_effort_to_reasoning_effort(effort) return new_self def with_generation_kwargs(self, **kwargs: Unpack[GenerationKwargs]) -> Self: """ Copy the chat provider, updating the generation kwargs with the given values. Returns: Self: A new instance of the chat provider with updated generation kwargs. """ new_self = copy.copy(self) new_self._generation_kwargs = copy.deepcopy(self._generation_kwargs) new_self._generation_kwargs.update(kwargs) return new_self @property def model_parameters(self) -> dict[str, Any]: """ The parameters of the model to use. For tracing/logging purposes. """ model_parameters: dict[str, Any] = {"base_url": str(self.client.base_url)} if self._reasoning_effort is not omit: model_parameters["reasoning_effort"] = self._reasoning_effort return model_parameters def _convert_message(self, message: Message) -> ChatCompletionMessageParam: """Convert a Kosong message to OpenAI message.""" # Note: for openai, `developer` role is more standard, but `system` is still accepted. # And many openai-compatible models do not accept `developer` role. # So we use `system` role here. OpenAIResponses will use `developer` role. # See https://cdn.openai.com/spec/model-spec-2024-05-08.html#definitions message = message.model_copy(deep=True) reasoning_content: str = "" content: list[ContentPart] = [] for part in message.content: if isinstance(part, ThinkPart): reasoning_content += part.think else: content.append(part) # if tool message and `tool_result_conversion` is `extract_text`, patch all text parts into # one so that we can make use of the serialization process of `Message` to output string if message.role == "tool" and self._tool_message_conversion == "extract_text": message.content = [TextPart(text=message.extract_text(sep="\n"))] else: message.content = content dumped_message = message.model_dump(exclude_none=True) if reasoning_content: assert self._reasoning_key, ( "reasoning_key must not be empty if reasoning_content exists" ) dumped_message[self._reasoning_key] = reasoning_content return cast(ChatCompletionMessageParam, dumped_message) class OpenAILegacyStreamedMessage: def __init__( self, response: ChatCompletion | AsyncStream[ChatCompletionChunk], reasoning_key: str | None ): self._reasoning_key: str | None = reasoning_key if isinstance(response, ChatCompletion): self._iter = self._convert_non_stream_response(response) else: self._iter = self._convert_stream_response(response) self._id: str | None = None self._usage: CompletionUsage | None = None def __aiter__(self) -> AsyncIterator[StreamedMessagePart]: return self async def __anext__(self) -> StreamedMessagePart: return await self._iter.__anext__() @property def id(self) -> str | None: return self._id @property def usage(self) -> TokenUsage | None: if self._usage: cached = 0 other_input = self._usage.prompt_tokens if ( self._usage.prompt_tokens_details and self._usage.prompt_tokens_details.cached_tokens ): cached = self._usage.prompt_tokens_details.cached_tokens other_input -= cached return TokenUsage( input_other=other_input, output=self._usage.completion_tokens, input_cache_read=cached, ) return None async def _convert_non_stream_response( self, response: ChatCompletion, ) -> AsyncIterator[StreamedMessagePart]: self._id = response.id self._usage = response.usage message = response.choices[0].message reasoning_key = self._reasoning_key if reasoning_key and (reasoning_content := getattr(message, reasoning_key, None)): assert isinstance(reasoning_content, str) yield ThinkPart(think=reasoning_content) if message.content: yield TextPart(text=message.content) if message.tool_calls: for tool_call in message.tool_calls: if isinstance(tool_call, ChatCompletionMessageFunctionToolCall): yield ToolCall( id=tool_call.id or str(uuid.uuid4()), function=ToolCall.FunctionBody( name=tool_call.function.name, arguments=tool_call.function.arguments, ), ) async def _convert_stream_response( self, response: AsyncIterator[ChatCompletionChunk], ) -> AsyncIterator[StreamedMessagePart]: try: async for chunk in response: if chunk.id: self._id = chunk.id if chunk.usage: self._usage = chunk.usage if not chunk.choices: continue delta = chunk.choices[0].delta # convert thinking content reasoning_key = self._reasoning_key if reasoning_key and (reasoning_content := getattr(delta, reasoning_key, None)): assert isinstance(reasoning_content, str) yield ThinkPart(think=reasoning_content) # convert text content if delta.content: yield TextPart(text=delta.content) # convert tool calls for tool_call in delta.tool_calls or []: if not tool_call.function: continue if tool_call.function.name: yield ToolCall( id=tool_call.id or str(uuid.uuid4()), function=ToolCall.FunctionBody( name=tool_call.function.name, arguments=tool_call.function.arguments, ), ) elif tool_call.function.arguments: yield ToolCallPart( arguments_part=tool_call.function.arguments, ) else: # skip empty tool calls pass except (OpenAIError, httpx.HTTPError) as e: raise convert_error(e) from e if __name__ == "__main__": async def _dev_main(): chat = OpenAILegacy(model="gpt-4o", stream=False) system_prompt = "You are a helpful assistant." history = [Message(role="user", content="Hello, how are you?")] async for part in await chat.generate(system_prompt, [], history): print(part.model_dump(exclude_none=True)) tools = [ Tool( name="get_weather", description="Get the weather", parameters={ "type": "object", "properties": { "city": { "type": "string", "description": "The city to get the weather for.", }, }, }, ) ] history = [Message(role="user", content="What's the weather in Beijing?")] stream = await chat.generate(system_prompt, tools, history) async for part in stream: print(part.model_dump(exclude_none=True)) print("usage:", stream.usage) import asyncio from dotenv import load_dotenv load_dotenv() asyncio.run(_dev_main()) ================================================ FILE: packages/kosong/src/kosong/contrib/chat_provider/openai_responses.py ================================================ import copy import uuid from collections.abc import AsyncIterator, Sequence from typing import TYPE_CHECKING, Any, Self, TypedDict, Unpack, cast, get_args import httpx from openai import AsyncStream, OpenAIError from openai.types.responses import ( Response, ResponseInputItemParam, ResponseInputParam, ResponseOutputMessageParam, ResponseOutputTextParam, ResponseReasoningItemParam, ResponseStreamEvent, ResponseUsage, ToolParam, ) from openai.types.responses.response_function_call_output_item_list_param import ( ResponseFunctionCallOutputItemListParam, ) from openai.types.responses.response_input_file_content_param import ( ResponseInputFileContentParam, ) from openai.types.responses.response_input_file_param import ResponseInputFileParam from openai.types.responses.response_input_message_content_list_param import ( ResponseInputMessageContentListParam, ) from openai.types.shared.reasoning import Reasoning from openai.types.shared.reasoning_effort import ReasoningEffort from openai.types.shared_params.responses_model import ResponsesModel from kosong.chat_provider import ( ChatProvider, RetryableChatProvider, StreamedMessagePart, ThinkingEffort, TokenUsage, ) from kosong.chat_provider.openai_common import ( close_replaced_openai_client, convert_error, create_openai_client, reasoning_effort_to_thinking_effort, thinking_effort_to_reasoning_effort, ) from kosong.contrib.chat_provider.common import ToolMessageConversion from kosong.message import ( AudioURLPart, ContentPart, ImageURLPart, Message, TextPart, ThinkPart, ToolCall, ToolCallPart, ) from kosong.tooling import Tool if TYPE_CHECKING: def type_check(openai_responses: "OpenAIResponses"): _: ChatProvider = openai_responses _: RetryableChatProvider = openai_responses def get_openai_models_set() -> set[str]: """Return a set of all available OpenAI response models. This extracts all literal values from the ResponsesModel TypeAlias, which includes both ChatModel and additional response-specific models. """ responses_model_args = get_args(ResponsesModel) # responses_model_args is (str, ChatModel, Literal[...]) # Extract from ChatModel (index 1) chat_models = set(get_args(responses_model_args[1])) # Extract from the Literal part (index 2) response_models = set(get_args(responses_model_args[2])) return chat_models | response_models _openai_models = get_openai_models_set() def is_openai_model(model_name: str) -> bool: """Judge if the model name is an OpenAI model.""" return model_name in _openai_models class OpenAIResponses: """ A chat provider that uses the OpenAI Responses API. Similar to `OpenAILegacy`, but uses `client.responses` under the hood. This provider always enables reasoning when generating responses. If you want to use a non-reasoning model, please use `OpenAILegacy` instead. >>> chat_provider = OpenAIResponses(model="gpt-5-codex", api_key="sk-1234567890") >>> chat_provider.name 'openai-responses' >>> chat_provider.model_name 'gpt-5-codex' """ name = "openai-responses" class GenerationKwargs(TypedDict, total=False): max_output_tokens: int | None max_tool_calls: int | None reasoning_effort: ReasoningEffort | None temperature: float | None top_logprobs: float | None top_p: float | None user: str | None def __init__( self, *, model: str, api_key: str | None = None, base_url: str | None = None, stream: bool = True, tool_message_conversion: ToolMessageConversion | None = None, **client_kwargs: Any, ): self._model = model self._stream = stream self._tool_message_conversion: ToolMessageConversion | None = tool_message_conversion self._api_key: str | None = api_key self._base_url: str | None = base_url self._client_kwargs: dict[str, Any] = dict(client_kwargs) self._client = create_openai_client( api_key=self._api_key, base_url=self._base_url, client_kwargs=self._client_kwargs, ) self._generation_kwargs: OpenAIResponses.GenerationKwargs = {} @property def model_name(self) -> str: return self._model @property def thinking_effort(self) -> ThinkingEffort | None: reasoning_effort = self._generation_kwargs.get("reasoning_effort") if reasoning_effort is None: return None return reasoning_effort_to_thinking_effort(reasoning_effort) async def generate( self, system_prompt: str, tools: Sequence[Tool], history: Sequence[Message], ) -> "OpenAIResponsesStreamedMessage": inputs: ResponseInputParam = [] if system_prompt: system_message: ResponseInputItemParam = {"role": "system", "content": system_prompt} if is_openai_model(self.model_name): system_message["role"] = "developer" inputs.append(system_message) # The `Message` type is OpenAI-compatible for Responses API `input` messages. for message in history: inputs.extend(self._convert_message(message)) generation_kwargs: dict[str, Any] = {} generation_kwargs.update(self._generation_kwargs) reasoning_effort = generation_kwargs.pop("reasoning_effort", None) if reasoning_effort is not None: generation_kwargs["reasoning"] = Reasoning( effort=reasoning_effort, summary="auto", ) generation_kwargs["include"] = ["reasoning.encrypted_content"] try: response = await self._client.responses.create( stream=self._stream, model=self._model, input=inputs, tools=[_convert_tool(tool) for tool in tools], store=False, **generation_kwargs, ) return OpenAIResponsesStreamedMessage(response) except (OpenAIError, httpx.HTTPError) as e: raise convert_error(e) from e def on_retryable_error(self, error: BaseException) -> bool: old_client = self._client self._client = create_openai_client( api_key=self._api_key, base_url=self._base_url, client_kwargs=self._client_kwargs, ) close_replaced_openai_client(old_client, client_kwargs=self._client_kwargs) return True def with_thinking(self, effort: ThinkingEffort) -> Self: reasoning_effort = thinking_effort_to_reasoning_effort(effort) return self.with_generation_kwargs(reasoning_effort=reasoning_effort) def with_generation_kwargs(self, **kwargs: Unpack[GenerationKwargs]) -> Self: """ Copy the chat provider, updating the generation kwargs with the given values. Returns: Self: A new instance of the chat provider with updated generation kwargs. """ new_self = copy.copy(self) new_self._generation_kwargs = copy.deepcopy(self._generation_kwargs) new_self._generation_kwargs.update(kwargs) return new_self @property def model_parameters(self) -> dict[str, Any]: """ The parameters of the model to use. For tracing/logging purposes. """ model_parameters: dict[str, Any] = {"base_url": str(self._client.base_url)} model_parameters.update(self._generation_kwargs) return model_parameters def _convert_message(self, message: Message) -> list[ResponseInputItemParam]: """Convert a single message to OpenAI Responses input format. Rules: - role in {user, assistant}: map to EasyInputMessageParam with role kept role == system: map to role=developer for OpenAI models, otherwise kept content: str kept; list[ContentPart] mapped to ResponseInputMessageContentListParam - role == tool: map to FunctionCallOutput with call_id and output """ role = message.role if is_openai_model(self.model_name) and role == "system": role = "developer" # tool role → function_call_output (return value from a prior tool call) if role == "tool": call_id = message.tool_call_id or "" if self._tool_message_conversion == "extract_text": content = message.extract_text(sep="\n") else: content = message.content output = _message_content_to_function_output_items(content) return [ { "call_id": call_id, "output": output, "type": "function_call_output", } ] result: list[ResponseInputItemParam] = [] # user/system/assistant → message input item if len(message.content) > 0: # Split into two kinds of blocks: contiguous non-ThinkPart message blocks, and # contiguous ThinkPart groups (grouped by the same `encrypted` value) pending_parts: list[ContentPart] = [] def flush_pending_parts() -> None: if not pending_parts: return if role == "assistant": # the "id" key is missing by purpose result.append( cast( ResponseOutputMessageParam, { "content": _content_parts_to_output_items(pending_parts), "role": role, "type": "message", }, ) ) else: result.append( { "content": _content_parts_to_input_items(pending_parts), "role": role, "type": "message", } ) pending_parts.clear() i = 0 n = len(message.content) while i < n: part = message.content[i] if isinstance(part, ThinkPart): # Flush accumulated non-reasoning parts first flush_pending_parts() # Aggregate consecutive ThinkPart items with the same `encrypted` value encrypted_value = part.encrypted summaries = [{"type": "summary_text", "text": part.think or ""}] i += 1 while i < n: next_part = message.content[i] if not isinstance(next_part, ThinkPart): break if next_part.encrypted != encrypted_value: break summaries.append({"type": "summary_text", "text": next_part.think or ""}) i += 1 result.append( cast( ResponseReasoningItemParam, { "summary": summaries, "type": "reasoning", "encrypted_content": encrypted_value, }, ) ) else: pending_parts.append(part) i += 1 # Handle remaining trailing non-reasoning parts flush_pending_parts() for tool_call in message.tool_calls or []: result.append( { "arguments": tool_call.function.arguments or "{}", "call_id": tool_call.id, "name": tool_call.function.name, "type": "function_call", } ) return result def _convert_tool(tool: Tool) -> ToolParam: """Convert a Kosong tool to an OpenAI Responses tool.""" return { "type": "function", "name": tool.name, "description": tool.description, "parameters": tool.parameters, "strict": False, } def _content_parts_to_input_items(parts: list[ContentPart]) -> ResponseInputMessageContentListParam: """Map internal ContentPart list → ResponseInputMessageContentListParam items.""" items: ResponseInputMessageContentListParam = [] for part in parts: if isinstance(part, TextPart): if part.text: items.append({"type": "input_text", "text": part.text}) elif isinstance(part, ImageURLPart): # default detail url = part.image_url.url items.append( { "type": "input_image", "detail": "auto", "image_url": url, } ) elif isinstance(part, AudioURLPart): mapped = _map_audio_url_to_input_item(part.audio_url.url) if mapped is not None: items.append(mapped) else: # Unknown content – ignore continue return items def _content_parts_to_output_items(parts: list[ContentPart]) -> list[ResponseOutputTextParam]: """Map internal ContentPart list → ResponseOutputTextParam list items.""" items: list[ResponseOutputTextParam] = [] for part in parts: if isinstance(part, TextPart): if part.text: items.append({"type": "output_text", "text": part.text, "annotations": []}) else: # Unknown content – ignore continue return items def _message_content_to_function_output_items( content: str | list[ContentPart], ) -> str | ResponseFunctionCallOutputItemListParam: """Map ContentPart list → ResponseFunctionCallOutputItemListParam items.""" output: str | ResponseFunctionCallOutputItemListParam # If tool_result_process is `extract_text`, patch all text parts into one string if isinstance(content, str): output = content else: items: ResponseFunctionCallOutputItemListParam = [] for part in content: if isinstance(part, TextPart): if part.text: items.append({"type": "input_text", "text": part.text}) elif isinstance(part, ImageURLPart): url = part.image_url.url items.append({"type": "input_image", "image_url": url}) elif isinstance(part, AudioURLPart): mapped = _map_audio_url_to_file_content(part.audio_url.url) if mapped is not None: items.append(mapped) else: continue output = items return output def _map_audio_url_to_input_item(url: str) -> ResponseInputFileParam | None: """Map audio URL/data URI to an input content item (always an input_file). OpenAI Responses message content no longer accepts `input_audio`, so both inline data and remote URLs are converted to `input_file` items instead. """ if url.startswith("data:audio/"): try: header, b64 = url.split(",", 1) subtype = header.split("/")[1].split(";")[0].lower() ext = "mp3" if subtype in {"mp3", "mpeg"} else ("wav" if subtype == "wav" else None) if ext is None: return None item: ResponseInputFileParam = {"type": "input_file", "file_data": b64} item["filename"] = f"inline.{ext}" return item except Exception: return None if url.startswith("http://") or url.startswith("https://"): return {"type": "input_file", "file_url": url} return None def _map_audio_url_to_file_content(url: str) -> ResponseInputFileContentParam | None: """Map audio URL/data URI to a file content item for function_call_output.""" if url.startswith("http://") or url.startswith("https://"): return {"type": "input_file", "file_url": url} if url.startswith("data:audio/"): try: _, b64 = url.split(",", 1) # We can attach filename optionally; Responses accepts file_data only return {"type": "input_file", "file_data": b64} except Exception: return None return None class OpenAIResponsesStreamedMessage: def __init__(self, response: Response | AsyncStream[ResponseStreamEvent]): if isinstance(response, Response): self._iter = self._convert_non_stream_response(response) else: self._iter = self._convert_stream_response(response) self._id: str | None = None self._usage: ResponseUsage | None = None def __aiter__(self) -> AsyncIterator[StreamedMessagePart]: return self async def __anext__(self) -> StreamedMessagePart: return await self._iter.__anext__() @property def id(self) -> str | None: return self._id @property def usage(self) -> TokenUsage | None: if self._usage: cached = 0 other_input = self._usage.input_tokens if self._usage.input_tokens_details and self._usage.input_tokens_details.cached_tokens: cached = self._usage.input_tokens_details.cached_tokens other_input -= cached return TokenUsage( input_other=other_input, output=self._usage.output_tokens, input_cache_read=cached, ) return None async def _convert_non_stream_response( self, response: Response ) -> AsyncIterator[StreamedMessagePart]: """Convert a non-streaming Responses API result into message parts.""" self._id = response.id self._usage = response.usage for item in response.output: if item.type == "message": for content in item.content or []: if content.type == "output_text": yield TextPart(text=content.text) elif item.type == "function_call": yield ToolCall( id=item.call_id or str(uuid.uuid4()), function=ToolCall.FunctionBody( name=item.name, arguments=item.arguments, ), ) elif item.type == "reasoning": for summary in item.summary: yield ThinkPart( think=summary.text, encrypted=item.encrypted_content, ) async def _convert_stream_response( self, response: AsyncStream[ResponseStreamEvent] ) -> AsyncIterator[StreamedMessagePart]: """Convert streaming Responses events into message parts.""" try: async for chunk in response: if chunk.type == "response.output_text.delta": yield TextPart(text=chunk.delta) elif chunk.type == "response.output_item.added": item = chunk.item self._id = item.id if item.type == "function_call": yield ToolCall( id=item.call_id or str(uuid.uuid4()), function=ToolCall.FunctionBody( name=item.name, arguments=item.arguments, ), ) elif chunk.type == "response.output_item.done": item = chunk.item self._id = item.id if item.type == "reasoning": yield ThinkPart(think="", encrypted=item.encrypted_content) elif chunk.type == "response.function_call_arguments.delta": yield ToolCallPart(arguments_part=chunk.delta) elif chunk.type == "response.reasoning_summary_part.added": yield ThinkPart(think="") elif chunk.type == "response.reasoning_summary_text.delta": yield ThinkPart(think=chunk.delta) elif chunk.type == "response.completed": self._usage = chunk.response.usage except (OpenAIError, httpx.HTTPError) as e: raise convert_error(e) from e if __name__ == "__main__": async def _dev_main(): # Non-streaming example chat = OpenAIResponses(model="gpt-5-codex", stream=True) system_prompt = "You are a helpful assistant." history = [Message(role="user", content="Hello, how are you?")] from kosong import generate result = await generate(chat, system_prompt, [], history) print(result.message) print(result.usage) history.append(result.message) # Streaming example with tools tools = [ Tool( name="get_weather", description="Get the weather", parameters={ "type": "object", "properties": { "city": { "type": "string", "description": "The city to get the weather for.", }, }, }, ) ] history.append(Message(role="user", content="What's the weather in Beijing?")) result = await generate(chat, system_prompt, tools, history) print(result.message) print(result.usage) history.append(result.message) for tool_call in result.message.tool_calls or []: assert tool_call.function.name == "get_weather" history.append(Message(role="tool", tool_call_id=tool_call.id, content="Sunny")) result = await generate(chat, system_prompt, tools, history) print(result.message) print(result.usage) import asyncio from dotenv import load_dotenv load_dotenv(override=True) asyncio.run(_dev_main()) ================================================ FILE: packages/kosong/src/kosong/contrib/context/__init__.py ================================================ ================================================ FILE: packages/kosong/src/kosong/contrib/context/linear.py ================================================ import asyncio import json from pathlib import Path from typing import IO, Protocol, runtime_checkable from kosong.message import Message class LinearContext: """ A context that contains a linear history of messages. """ def __init__(self, storage: "LinearStorage"): self._storage = storage @property def history(self) -> list[Message]: return self._storage.messages @property def token_count(self) -> int: return self._storage.token_count async def add_message(self, message: Message): await self._storage.append_message(message) async def mark_token_count(self, token_count: int): await self._storage.mark_token_count(token_count) @runtime_checkable class LinearStorage(Protocol): @property def messages(self) -> list[Message]: """ All messages in the storage. """ ... @property def token_count(self) -> int: """ The total token count of the messages in the storage. This may not be the precise token count, depending on the caller of `mark_token_count`. """ ... async def append_message(self, message: Message) -> None: ... async def mark_token_count(self, token_count: int) -> None: ... class MemoryLinearStorage: """ A linear storage that stores messages in memory, only for testing. """ def __init__(self): self._messages: list[Message] = [] self._token_count: int | None = None @property def messages(self) -> list[Message]: return self._messages @property def token_count(self) -> int: return self._token_count or 0 async def append_message(self, message: Message): self._messages.append(message) async def mark_token_count(self, token_count: int): self._token_count = token_count class JsonlLinearStorage(MemoryLinearStorage): """ A linear storage that stores messages in a JSONL file. """ def __init__(self, path: Path | str): super().__init__() self._path = path if isinstance(path, Path) else Path(path) self._file: IO[str] | None = None async def restore(self): """Restore all messages from the JSONL file.""" if self._messages: raise RuntimeError("The storage is already modified") if not self._path.exists(): return def _restore(): with open(self._path, encoding="utf-8") as f: for line in f: if not line.strip(): continue line_json = json.loads(line) if "token_count" in line_json: self._token_count = line_json["token_count"] continue message = Message.model_validate(line_json) self._messages.append(message) await asyncio.to_thread(_restore) def _get_file(self) -> IO[str]: if self._file is None: self._file = open(self._path, "a", encoding="utf-8") # noqa: SIM115 return self._file def __del__(self): if self._file: self._file.close() async def append_message(self, message: Message): await super().append_message(message) def _write(): file = self._get_file() json.dump( message.model_dump(exclude_none=True), file, ensure_ascii=False, separators=(",", ":"), ) file.write("\n") await asyncio.to_thread(_write) async def mark_token_count(self, token_count: int): await super().mark_token_count(token_count) def _write(): file = self._get_file() json.dump( {"role": "_usage", "token_count": token_count}, file, ensure_ascii=False, separators=(",", ":"), ) file.write("\n") await asyncio.to_thread(_write) ================================================ FILE: packages/kosong/src/kosong/message.py ================================================ from abc import ABC from typing import Any, ClassVar, Literal, cast, override from pydantic import BaseModel, GetCoreSchemaHandler, field_serializer, field_validator from pydantic_core import core_schema from kosong.utils.typing import JsonType class MergeableMixin: def merge_in_place(self, other: Any) -> bool: """Merge the other part into the current part. Return True if the merge is successful.""" return False class ContentPart(BaseModel, ABC, MergeableMixin): """ A part of a message content. This is the abstract base class for all supported content parts. Subclasses must define a `type` field of type `str` and optional other fields specific to the content part. For Kosong users, you typically do not need to subclass this directly. Instead, use the provided subclasses like `TextPart`, `ThinkPart`, `ImageURLPart`, etc. Unless you are implementing custom `ChatProvider`s that supports new content part types. """ __content_part_registry: ClassVar[dict[str, type["ContentPart"]]] = {} type: str ... # to be added by subclasses def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) invalid_subclass_error_msg = ( f"ContentPart subclass {cls.__name__} must have a `type` field of type `str`" ) type_value = getattr(cls, "type", None) if type_value is None or not isinstance(type_value, str): raise ValueError(invalid_subclass_error_msg) cls.__content_part_registry[type_value] = cls @classmethod def __get_pydantic_core_schema__( cls, source_type: Any, handler: GetCoreSchemaHandler ) -> core_schema.CoreSchema: # If we're dealing with the base ContentPart class, use custom validation if cls.__name__ == "ContentPart": def validate_content_part(value: Any) -> Any: # if it's already an instance of a ContentPart subclass, return it if hasattr(value, "__class__") and issubclass(value.__class__, cls): return value # if it's a dict with a type field, dispatch to the appropriate subclass if isinstance(value, dict) and "type" in value: type_value: Any | None = cast(dict[str, Any], value).get("type") if not isinstance(type_value, str): raise ValueError(f"Cannot validate {value} as ContentPart") target_class = cls.__content_part_registry[type_value] return target_class.model_validate(value) raise ValueError(f"Cannot validate {value} as ContentPart") return core_schema.no_info_plain_validator_function(validate_content_part) # for subclasses, use the default schema return handler(source_type) class TextPart(ContentPart): """ >>> TextPart(text="Hello, world!").model_dump() {'type': 'text', 'text': 'Hello, world!'} """ type: str = "text" text: str @override def merge_in_place(self, other: Any) -> bool: if not isinstance(other, TextPart): return False self.text += other.text return True class ThinkPart(ContentPart): """ >>> ThinkPart(think="I think I need to think about this.").model_dump() {'type': 'think', 'think': 'I think I need to think about this.', 'encrypted': None} """ type: str = "think" think: str encrypted: str | None = None """Encrypted thinking content, or signature.""" @override def merge_in_place(self, other: Any) -> bool: if not isinstance(other, ThinkPart): return False if self.encrypted: return False self.think += other.think if other.encrypted: self.encrypted = other.encrypted return True class ImageURLPart(ContentPart): """ >>> ImageURLPart( ... image_url=ImageURLPart.ImageURL(url="https://example.com/image.png") ... ).model_dump() {'type': 'image_url', 'image_url': {'url': 'https://example.com/image.png', 'id': None}} """ class ImageURL(BaseModel): """Image URL payload.""" url: str """The URL of the image, can be data URI scheme like `data:image/png;base64,...`.""" id: str | None = None """The ID of the image, to allow LLMs to distinguish different images.""" type: str = "image_url" image_url: ImageURL class AudioURLPart(ContentPart): """ >>> AudioURLPart( ... audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3") ... ).model_dump() {'type': 'audio_url', 'audio_url': {'url': 'https://example.com/audio.mp3', 'id': None}} """ class AudioURL(BaseModel): """Audio URL payload.""" url: str """The URL of the audio, can be data URI scheme like `data:audio/aac;base64,...`.""" id: str | None = None """The ID of the audio, to allow LLMs to distinguish different audios.""" type: str = "audio_url" audio_url: AudioURL class VideoURLPart(ContentPart): """ >>> VideoURLPart( ... video_url=VideoURLPart.VideoURL(url="https://example.com/video.mp4") ... ).model_dump() {'type': 'video_url', 'video_url': {'url': 'https://example.com/video.mp4', 'id': None}} """ class VideoURL(BaseModel): """Video URL payload.""" url: str """The URL of the video, can be data URI scheme like `data:video/mp4;base64,...`.""" id: str | None = None """The ID of the video, to allow LLMs to distinguish different videos.""" type: str = "video_url" video_url: VideoURL class ToolCall(BaseModel, MergeableMixin): """ A tool call requested by the assistant. >>> ToolCall( ... id="123", ... function=ToolCall.FunctionBody(name="function", arguments="{}"), ... ).model_dump(exclude_none=True) {'type': 'function', 'id': '123', 'function': {'name': 'function', 'arguments': '{}'}} """ class FunctionBody(BaseModel): """Tool call function body.""" name: str """The name of the tool to be called.""" arguments: str | None """Arguments of the tool call in JSON string format.""" type: Literal["function"] = "function" id: str """The ID of the tool call.""" function: FunctionBody """The function body of the tool call.""" extras: dict[str, JsonType] | None = None """Extra information about the tool call.""" @override def merge_in_place(self, other: Any) -> bool: if not isinstance(other, ToolCallPart): return False if self.function.arguments is None: self.function.arguments = other.arguments_part else: self.function.arguments += other.arguments_part or "" return True class ToolCallPart(BaseModel, MergeableMixin): """A part of the tool call.""" arguments_part: str | None = None """A part of the arguments of the tool call.""" @override def merge_in_place(self, other: Any) -> bool: if not isinstance(other, ToolCallPart): return False if self.arguments_part is None: self.arguments_part = other.arguments_part else: self.arguments_part += other.arguments_part or "" return True type Role = Literal[ # for OpenAI API, this should be converted to `developer` # OpenAI & Kimi support system messages in the middle of the conversation. # Anthropic only support system messages at the beginning https://docs.claude.com/en/api/messages#body-messages # In this case, we map `system` message to a `user` message wrapped in `` tags. "system", "user", "assistant", "tool", ] """The role of a message sender.""" class Message(BaseModel): """A message in a conversation.""" role: Role """The role of the message sender.""" name: str | None = None content: list[ContentPart] """ The content of the message. Empty list `[]` will be interpreted as no content. """ tool_calls: list[ToolCall] | None = None """Tool calls requested by the assistant in this message.""" tool_call_id: str | None = None """The ID of the tool call if this message is a tool response.""" partial: bool | None = None @field_serializer("content") def _serialize_content(self, content: list[ContentPart]) -> str | list[dict[str, Any]] | None: if len(content) == 1 and isinstance(content[0], TextPart): return content[0].text return [part.model_dump() for part in content] @field_validator("content", mode="before") @classmethod def _coerce_none_content(cls, value: Any) -> Any: if value is None: return [] if isinstance(value, str): return [TextPart(text=value)] return value def __init__( self, *, role: Role, content: list[ContentPart] | ContentPart | str, tool_calls: list[ToolCall] | None = None, tool_call_id: str | None = None, **data: Any, ) -> None: if isinstance(content, str): content = [TextPart(text=content)] elif isinstance(content, ContentPart): content = [content] super().__init__( role=role, content=content, tool_calls=tool_calls, tool_call_id=tool_call_id, **data, ) def extract_text(self, sep: str = "") -> str: """Extract and concatenate all text parts in the message content.""" return sep.join(part.text for part in self.content if isinstance(part, TextPart)) ================================================ FILE: packages/kosong/src/kosong/py.typed ================================================ ================================================ FILE: packages/kosong/src/kosong/tooling/__init__.py ================================================ from abc import ABC, abstractmethod from asyncio import Future from typing import Any, ClassVar, Protocol, Self, cast, override, runtime_checkable import jsonschema import pydantic from pydantic import BaseModel, GetCoreSchemaHandler, model_validator from pydantic.json_schema import GenerateJsonSchema from pydantic_core import core_schema from kosong.message import ContentPart, ToolCall from kosong.utils.jsonschema import deref_json_schema from kosong.utils.typing import JsonType type ParametersType = dict[str, Any] class Tool(BaseModel): """The definition of a tool that can be recognized by the model.""" name: str """The name of the tool.""" description: str """The description of the tool.""" parameters: ParametersType """The parameters of the tool, in JSON Schema format.""" @model_validator(mode="after") def _validate_parameters(self) -> Self: jsonschema.validate(self.parameters, jsonschema.Draft202012Validator.META_SCHEMA) return self class DisplayBlock(BaseModel, ABC): """ A block of content to be displayed to the user. Similar to `ContentPart`, but scoped to user-facing UI. `ContentPart` is for model-facing message content; `DisplayBlock` is for tool/UI extensions. Unlike `ContentPart`, Kosong users may directly subclass `DisplayBlock` to define custom display blocks for their applications. """ __display_block_registry: ClassVar[dict[str, type["DisplayBlock"]]] = {} type: str ... # to be added by subclasses def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) invalid_subclass_error_msg = ( f"DisplayBlock subclass {cls.__name__} must have a `type` field of type `str`" ) type_value = getattr(cls, "type", None) if type_value is None or not isinstance(type_value, str): raise ValueError(invalid_subclass_error_msg) cls.__display_block_registry[type_value] = cls @classmethod def __get_pydantic_core_schema__( cls, source_type: Any, handler: GetCoreSchemaHandler ) -> core_schema.CoreSchema: # If we're dealing with the base DisplayBlock class, use custom validation if cls.__name__ == "DisplayBlock": def validate_display_block(value: Any) -> Any: # if it's already an instance of a DisplayBlock subclass, return it if hasattr(value, "__class__") and issubclass(value.__class__, cls): return value # if it's a dict with a type field, dispatch to the appropriate subclass if isinstance(value, dict) and "type" in value: type_value: Any | None = cast(dict[str, Any], value).get("type") if not isinstance(type_value, str): raise ValueError(f"Cannot validate {value} as DisplayBlock") target_class = cls.__display_block_registry.get(type_value) if target_class is None: data = {k: v for k, v in cast(dict[str, Any], value).items() if k != "type"} return UnknownDisplayBlock.model_validate( {"type": type_value, "data": data} ) return target_class.model_validate(value) raise ValueError(f"Cannot validate {value} as DisplayBlock") return core_schema.no_info_plain_validator_function(validate_display_block) # for subclasses, use the default schema return handler(source_type) class UnknownDisplayBlock(DisplayBlock): """Fallback display block for unknown types.""" type: str = "unknown" data: JsonType class BriefDisplayBlock(DisplayBlock): """A brief display block with plain string content.""" type: str = "brief" text: str class ToolReturnValue(BaseModel): """The return type of a callable tool.""" is_error: bool """Whether the tool call resulted in an error.""" # For model output: str | list[ContentPart] """The output content returned by the tool.""" message: str """An explanatory message to be given to the model.""" # For user display: list[DisplayBlock] """The content blocks to be displayed to the user.""" # For debugging/testing extras: dict[str, JsonType] | None = None @property def brief(self) -> str: """Get the brief display block data, if any.""" for block in self.display: if isinstance(block, BriefDisplayBlock): return block.text return "" class ToolOk(ToolReturnValue): """Subclass of `ToolReturnValue` representing a successful tool call.""" def __init__( self, *, output: str | ContentPart | list[ContentPart], message: str = "", brief: str = "", ) -> None: super().__init__( is_error=False, output=([output] if isinstance(output, ContentPart) else output), message=message, display=[BriefDisplayBlock(text=brief)] if brief else [], ) class ToolError(ToolReturnValue): """Subclass of `ToolReturnValue` representing a failed tool call.""" def __init__( self, *, message: str, brief: str, output: str | ContentPart | list[ContentPart] = "" ): super().__init__( is_error=True, output=([output] if isinstance(output, ContentPart) else output), message=message, display=[BriefDisplayBlock(text=brief)] if brief else [], ) class CallableTool(Tool, ABC): """ The abstract base class of tools that can be called as callables. The tool will be called with the arguments provided in the `ToolCall`. If the arguments are given as a JSON array, it will be unpacked into positional arguments. If the arguments are given as a JSON object, it will be unpacked into keyword arguments. Otherwise, the arguments will be passed as a single argument. """ @property def base(self) -> Tool: """The base tool definition.""" return self async def call(self, arguments: JsonType) -> ToolReturnValue: from kosong.tooling.error import ToolValidateError try: jsonschema.validate(arguments, self.parameters) except jsonschema.ValidationError as e: return ToolValidateError(str(e)) if isinstance(arguments, list): ret = await self.__call__(*arguments) elif isinstance(arguments, dict): ret = await self.__call__(**arguments) else: ret = await self.__call__(arguments) if not isinstance(ret, ToolReturnValue): # type: ignore[reportUnnecessaryIsInstance] # let's do not trust the return type of the tool ret = ToolError( message=f"Invalid return type: {type(ret)}", brief="Invalid return type", ) return ret @abstractmethod async def __call__(self, *args: Any, **kwargs: Any) -> ToolReturnValue: """ @public The implementation of the callable tool. """ ... class _GenerateJsonSchemaNoTitles(GenerateJsonSchema): """Custom JSON schema generator that omits titles.""" @override def field_title_should_be_set(self, schema) -> bool: # type: ignore[reportMissingParameterType] return False @override def _update_class_schema(self, json_schema, cls, config) -> None: # type: ignore[reportMissingParameterType] super()._update_class_schema(json_schema, cls, config) json_schema.pop("title", None) class CallableTool2[Params: BaseModel](ABC): """ The abstract base class of tools that can be called as callables, with typed parameters. The tool will be called with the arguments provided in the `ToolCall`. The arguments must be a JSON object, and will be validated by Pydantic to the `Params` type. """ name: str """The name of the tool.""" description: str """The description of the tool.""" params: type[Params] """The Pydantic model type of the tool parameters.""" def __init__( self, name: str | None = None, description: str | None = None, params: type[Params] | None = None, ) -> None: cls = self.__class__ self.name = name or getattr(cls, "name", "") if not self.name: raise ValueError( "Tool name must be provided either as class variable or constructor argument" ) if not isinstance(self.name, str): # type: ignore[reportUnnecessaryIsInstance] raise ValueError("Tool name must be a string") self.description = description or getattr(cls, "description", "") if not self.description: raise ValueError( "Tool description must be provided either as class variable or constructor argument" ) if not isinstance(self.description, str): # type: ignore[reportUnnecessaryIsInstance] raise ValueError("Tool description must be a string") self.params = params or getattr(cls, "params", None) # type: ignore if not self.params: raise ValueError( "Tool param must be provided either as class variable or constructor argument" ) if not isinstance(self.params, type) or not issubclass(self.params, BaseModel): # type: ignore[reportUnnecessaryIsInstance] raise ValueError("Tool params must be a subclass of pydantic.BaseModel") self._base = Tool( name=self.name, description=self.description, parameters=deref_json_schema( self.params.model_json_schema(schema_generator=_GenerateJsonSchemaNoTitles) ), ) @property def base(self) -> Tool: """The base tool definition.""" return self._base async def call(self, arguments: JsonType) -> ToolReturnValue: from kosong.tooling.error import ToolValidateError try: params = self.params.model_validate(arguments) except pydantic.ValidationError as e: return ToolValidateError(str(e)) ret = await self.__call__(params) if not isinstance(ret, ToolReturnValue): # type: ignore[reportUnnecessaryIsInstance] # let's do not trust the return type of the tool ret = ToolError( message=f"Invalid return type: {type(ret)}", brief="Invalid return type", ) return ret @abstractmethod async def __call__(self, params: Params) -> ToolReturnValue: """ @public The implementation of the callable tool. """ ... class ToolResult(BaseModel): """The result of a tool call.""" tool_call_id: str """The ID of the tool call.""" return_value: ToolReturnValue """The actual return value of the tool call.""" ToolResultFuture = Future[ToolResult] type HandleResult = ToolResultFuture | ToolResult @runtime_checkable class Toolset(Protocol): """ The interface of toolsets that can register tools and handle tool calls. """ @property def tools(self) -> list[Tool]: """The list of tool definitions registered in this toolset.""" ... def handle(self, tool_call: ToolCall) -> HandleResult: """ Handle a tool call. The result of the tool call, or the async future of the result, should be returned. The result should be a `ToolReturnValue`. This method MUST NOT do any blocking operations because it will be called during consuming the chat response stream. This method MUST NOT raise any exception except for `asyncio.CancelledError`. Any other error should be returned as a `ToolReturnValue` with `is_error=True`. """ ... ================================================ FILE: packages/kosong/src/kosong/tooling/empty.py ================================================ from typing import TYPE_CHECKING from kosong.message import ToolCall from kosong.tooling import HandleResult, Tool, ToolResult, Toolset from kosong.tooling.error import ToolNotFoundError if TYPE_CHECKING: def type_check(empty: "EmptyToolset"): _: Toolset = empty class EmptyToolset: """A toolset implementation that always contains no tools.""" @property def tools(self) -> list[Tool]: return [] def handle(self, tool_call: ToolCall) -> HandleResult: return ToolResult( tool_call_id=tool_call.id, return_value=ToolNotFoundError(tool_call.function.name), ) ================================================ FILE: packages/kosong/src/kosong/tooling/error.py ================================================ from kosong.tooling import ToolError class ToolNotFoundError(ToolError): """The tool was not found.""" def __init__(self, tool_name: str): super().__init__( message=f"Tool `{tool_name}` not found", brief=f"Tool `{tool_name}` not found", ) class ToolParseError(ToolError): """The arguments of the tool are not valid JSON.""" def __init__(self, message: str): super().__init__( message=f"Error parsing JSON arguments: {message}", brief="Invalid arguments", ) class ToolValidateError(ToolError): """The arguments of the tool are not valid.""" def __init__(self, message: str): super().__init__( message=f"Error validating JSON arguments: {message}", brief="Invalid arguments", ) class ToolRuntimeError(ToolError): """The tool failed to run.""" def __init__(self, message: str): super().__init__( message=f"Error running tool: {message}", brief="Tool runtime error", ) ================================================ FILE: packages/kosong/src/kosong/tooling/mcp.py ================================================ import mcp.types import kosong.message def convert_mcp_content(part: mcp.types.ContentBlock) -> kosong.message.ContentPart: """Convert MCP content block to kosong message content part. Raises: ValueError: If the content type or mime type is not supported. """ match part: case mcp.types.TextContent(text=text): return kosong.message.TextPart(text=text) case mcp.types.ImageContent(data=data, mimeType=mimeType): return kosong.message.ImageURLPart( image_url=kosong.message.ImageURLPart.ImageURL(url=f"data:{mimeType};base64,{data}") ) case mcp.types.AudioContent(data=data, mimeType=mimeType): return kosong.message.AudioURLPart( audio_url=kosong.message.AudioURLPart.AudioURL(url=f"data:{mimeType};base64,{data}") ) case mcp.types.EmbeddedResource( resource=mcp.types.BlobResourceContents(uri=_uri, mimeType=mimeType, blob=blob) ): mimeType = mimeType or "application/octet-stream" if mimeType.startswith("image/"): return kosong.message.ImageURLPart( type="image_url", image_url=kosong.message.ImageURLPart.ImageURL( url=f"data:{mimeType};base64,{blob}", ), ) elif mimeType.startswith("audio/"): return kosong.message.AudioURLPart( type="audio_url", audio_url=kosong.message.AudioURLPart.AudioURL( url=f"data:{mimeType};base64,{blob}" ), ) elif mimeType.startswith("video/"): return kosong.message.VideoURLPart( type="video_url", video_url=kosong.message.VideoURLPart.VideoURL( url=f"data:{mimeType};base64,{blob}" ), ) else: raise ValueError(f"Unsupported mime type: {mimeType}") case mcp.types.ResourceLink(uri=uri, mimeType=mimeType, description=_description): mimeType = mimeType or "application/octet-stream" if mimeType.startswith("image/"): return kosong.message.ImageURLPart( type="image_url", image_url=kosong.message.ImageURLPart.ImageURL(url=str(uri)), ) elif mimeType.startswith("audio/"): return kosong.message.AudioURLPart( type="audio_url", audio_url=kosong.message.AudioURLPart.AudioURL(url=str(uri)), ) elif mimeType.startswith("video/"): return kosong.message.VideoURLPart( type="video_url", video_url=kosong.message.VideoURLPart.VideoURL(url=str(uri)), ) else: raise ValueError(f"Unsupported mime type: {mimeType}") case _: raise ValueError(f"Unsupported MCP tool result part: {part}") ================================================ FILE: packages/kosong/src/kosong/tooling/simple.py ================================================ import asyncio import inspect import json from collections.abc import Iterable from typing import TYPE_CHECKING, Any, Self from kosong.message import ToolCall from kosong.tooling import ( CallableTool, CallableTool2, HandleResult, Tool, ToolResult, ToolReturnValue, Toolset, ) from kosong.tooling.error import ( ToolNotFoundError, ToolParseError, ToolRuntimeError, ) from kosong.utils.typing import JsonType if TYPE_CHECKING: def type_check( simple: "SimpleToolset", ): _: Toolset = simple type ToolType = CallableTool | CallableTool2[Any] """The tool type that can be added to the `SimpleToolset`.""" class SimpleToolset: """A simple toolset that can handle tool calls concurrently.""" _tool_dict: dict[str, ToolType] def __init__(self, tools: Iterable[ToolType] | None = None): """Initialize the simple toolset with an optional iterable of tools.""" self._tool_dict = {} if tools: for tool in tools: self += tool def __iadd__(self, tool: ToolType) -> Self: """ @public Add a tool to the toolset. """ return_annotation = inspect.signature(tool.__call__).return_annotation # Check if the return annotation is ToolReturnValue # Supports both actual type and string annotation (when using # `from __future__ import annotations`) if return_annotation is ToolReturnValue: pass elif isinstance(return_annotation, str): # String annotation - check if it matches ToolReturnValue # Accept any suffix of the full module path, e.g.: # "ToolReturnValue", "tooling.ToolReturnValue", "kosong.tooling.ToolReturnValue" full_name = f"{ToolReturnValue.__module__}.ToolReturnValue" full_parts = full_name.split(".") if not any( return_annotation == ".".join(full_parts[i:]) for i in range(len(full_parts)) ): raise TypeError( f"Expected tool `{tool.name}` to return `ToolReturnValue`, " f"but got `{return_annotation}`" ) else: raise TypeError( f"Expected tool `{tool.name}` to return `ToolReturnValue`, " f"but got `{return_annotation}`" ) self._tool_dict[tool.name] = tool return self def __add__(self, tool: ToolType) -> "SimpleToolset": """ @public Return a new toolset with the given tool added. """ new_toolset = SimpleToolset() new_toolset._tool_dict = self._tool_dict.copy() new_toolset += tool return new_toolset def add(self, tool: ToolType) -> None: """ @public Add a tool to the toolset. """ self += tool def remove(self, tool_name: str) -> None: """ @public Remove a tool from the toolset. """ if tool_name not in self._tool_dict: raise KeyError(f"Tool `{tool_name}` not found in the toolset.") del self._tool_dict[tool_name] @property def tools(self) -> list[Tool]: return [tool.base for tool in self._tool_dict.values()] def handle(self, tool_call: ToolCall) -> HandleResult: if tool_call.function.name not in self._tool_dict: return ToolResult( tool_call_id=tool_call.id, return_value=ToolNotFoundError(tool_call.function.name), ) tool = self._tool_dict[tool_call.function.name] try: arguments: JsonType = json.loads(tool_call.function.arguments or "{}") except json.JSONDecodeError as e: return ToolResult(tool_call_id=tool_call.id, return_value=ToolParseError(str(e))) async def _call(): try: ret = await tool.call(arguments) return ToolResult(tool_call_id=tool_call.id, return_value=ret) except Exception as e: return ToolResult(tool_call_id=tool_call.id, return_value=ToolRuntimeError(str(e))) return asyncio.create_task(_call()) ================================================ FILE: packages/kosong/src/kosong/utils/__init__.py ================================================ ================================================ FILE: packages/kosong/src/kosong/utils/aio.py ================================================ import inspect from collections.abc import Awaitable, Callable from typing import cast type Callback[**Params, Return] = Callable[Params, Awaitable[Return] | Return] async def callback[**Params, Return]( fn: Callback[Params, Return], *args: Params.args, **kwargs: Params.kwargs ) -> Return: ret = fn(*args, **kwargs) if inspect.isawaitable(ret): return await cast(Awaitable[Return], ret) return ret ================================================ FILE: packages/kosong/src/kosong/utils/jsonschema.py ================================================ from __future__ import annotations import copy from typing import cast from kosong.utils.typing import JsonType type JsonDict = dict[str, JsonType] def deref_json_schema(schema: JsonDict) -> JsonDict: """Expand local `$ref` entries in a JSON Schema without infinite recursion.""" # Work on a deep copy so we never mutate the caller's schema. full_schema: JsonDict = copy.deepcopy(schema) def resolve_pointer(root: JsonDict, pointer: str) -> JsonType: """Resolve a JSON Pointer (e.g. ``#/$defs/User``) inside the schema.""" parts = pointer.lstrip("#/").split("/") current: JsonType = root try: for part in parts: if isinstance(current, dict): current = current[part] else: raise ValueError return current except (KeyError, TypeError, ValueError): raise ValueError(f"Unable to resolve reference path: {pointer}") from None def traverse(node: JsonType, root: JsonDict) -> JsonType: """Recursively traverse every node to inline local references.""" if isinstance(node, dict): # Replace local ``$ref`` entries with their referenced payload. if "$ref" in node and isinstance(node["$ref"], str): ref_path = node["$ref"] if ref_path.startswith("#"): # Resolve the local reference target. target = resolve_pointer(root, ref_path) # Recursively inline the target in case it contains more refs. ref = traverse(target, root) if not isinstance(ref, dict): msg = "Local $ref must resolve to a JSON object" raise TypeError(msg) node.pop("$ref") node.update(ref) return node else: # Ignore remote references such as http://... return node # Traverse the remaining mapping entries. return {k: traverse(v, root) for k, v in node.items()} elif isinstance(node, list): # Traverse list members (e.g. allOf, oneOf, items). return [traverse(item, root) for item in node] else: return node # Remove definition buckets to keep the resolved schema minimal. resolved = cast(JsonDict, traverse(full_schema, full_schema)) # Comment these lines if you want to keep the emitted definitions. resolved.pop("$defs", None) resolved.pop("definitions", None) return resolved ================================================ FILE: packages/kosong/src/kosong/utils/typing.py ================================================ from __future__ import annotations type JsonType = None | int | float | str | bool | list[JsonType] | dict[str, JsonType] ================================================ FILE: packages/kosong/tests/api_snapshot_tests/common.py ================================================ """Common test cases and utilities for snapshot tests.""" import json from collections.abc import Sequence from typing import Any, TypedDict import respx from kosong.chat_provider import ChatProvider from kosong.message import ImageURLPart, Message, TextPart, ToolCall from kosong.tooling import Tool __all__ = [ "ADD_TOOL", "B64_PNG", "COMMON_CASES", "MUL_TOOL", "capture_request", "make_anthropic_response", "make_chat_completion_response", "run_test_cases", ] def make_anthropic_response(model: str = "claude-sonnet-4-20250514") -> dict[str, Any]: """Common response for Anthropic Messages API.""" return { "id": "msg_test_123", "type": "message", "role": "assistant", "model": model, "content": [{"type": "text", "text": "Hello"}], "stop_reason": "end_turn", "usage": {"input_tokens": 10, "output_tokens": 5}, } def make_chat_completion_response(model: str = "test-model") -> dict[str, Any]: """Common response for OpenAI-compatible chat completion APIs.""" return { "id": "chatcmpl-test123", "object": "chat.completion", "created": 1234567890, "model": model, "choices": [ { "index": 0, "message": {"role": "assistant", "content": "Hello"}, "finish_reason": "stop", } ], "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, } B64_PNG = ( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAA" "DUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" ) ADD_TOOL = Tool( name="add", description="Add two integers.", parameters={ "type": "object", "properties": { "a": {"type": "integer", "description": "First number"}, "b": {"type": "integer", "description": "Second number"}, }, "required": ["a", "b"], }, ) MUL_TOOL = Tool( name="multiply", description="Multiply two integers.", parameters={ "type": "object", "properties": { "a": {"type": "integer", "description": "First number"}, "b": {"type": "integer", "description": "Second number"}, }, "required": ["a", "b"], }, ) class Case(TypedDict, total=False): """A test case for chat providers.""" system: str """The system prompt.""" tools: list[Tool] """The list of tools.""" history: list[Message] """The message history.""" # Common test cases shared across providers COMMON_CASES: dict[str, Case] = { "simple_user_message": { "system": "You are helpful.", "history": [Message(role="user", content="Hello!")], }, "multi_turn_conversation": { "history": [ Message(role="user", content="What is 2+2?"), Message(role="assistant", content="2+2 equals 4."), Message(role="user", content="And 3+3?"), ], }, "multi_turn_with_system": { "system": "You are a math tutor.", "history": [ Message(role="user", content="What is 2+2?"), Message(role="assistant", content="2+2 equals 4."), Message(role="user", content="And 3+3?"), ], }, "image_url": { "history": [ Message( role="user", content=[ TextPart(text="What's in this image?"), ImageURLPart( image_url=ImageURLPart.ImageURL(url="https://example.com/image.png") ), ], ) ], }, "tool_definition": { "history": [Message(role="user", content="Add 2 and 3")], "tools": [ADD_TOOL, MUL_TOOL], }, "tool_call": { "history": [ Message(role="user", content="Add 2 and 3"), Message( role="assistant", content="I'll add those numbers for you.", tool_calls=[ ToolCall( id="call_abc123", function=ToolCall.FunctionBody(name="add", arguments='{"a": 2, "b": 3}'), ) ], ), Message(role="tool", content="5", tool_call_id="call_abc123"), ], }, "tool_call_with_image": { "history": [ Message(role="user", content="Add 2 and 3"), Message( role="assistant", content="I'll add those numbers for you.", tool_calls=[ ToolCall( id="call_abc123", function=ToolCall.FunctionBody(name="add", arguments='{"a": 2, "b": 3}'), ) ], ), Message( role="tool", content=[ TextPart(text="5"), ImageURLPart( image_url=ImageURLPart.ImageURL(url="https://example.com/image.png") ), ], tool_call_id="call_abc123", ), ], }, "parallel_tool_calls": { "tools": [ADD_TOOL, MUL_TOOL], "history": [ Message(role="user", content="Calculate 2+3 and 4*5"), Message( role="assistant", content="I'll calculate both.", tool_calls=[ ToolCall( id="call_add", function=ToolCall.FunctionBody(name="add", arguments='{"a": 2, "b": 3}'), ), ToolCall( id="call_mul", function=ToolCall.FunctionBody( name="multiply", arguments='{"a": 4, "b": 5}' ), ), ], ), Message( role="tool", content=[ TextPart(text="This is a system reminder"), TextPart(text="5"), ], tool_call_id="call_add", ), Message( role="tool", content=[ TextPart(text="This is a system reminder"), TextPart(text="20"), ], tool_call_id="call_mul", ), ], }, } async def capture_request( mock: respx.MockRouter, provider: ChatProvider, system: str, tools: Sequence[Tool], history: list[Message], ) -> dict[str, Any]: """Generate and capture the request body.""" stream = await provider.generate(system, tools, history) async for _ in stream: pass request = mock.calls.last.request assert request.content is not None return json.loads(request.content.decode()) async def run_test_cases( mock: respx.MockRouter, provider: ChatProvider, cases: dict[str, Case], extract_keys: tuple[str, ...], ) -> dict[str, dict[str, Any]]: """Run all test cases and return results dict for snapshot comparison.""" results: dict[str, dict[str, Any]] = {} for name, case in cases.items(): body = await capture_request( mock, provider, case.get("system", ""), case.get("tools", []), case.get("history", []), ) results[name] = {k: v for k, v in body.items() if k in extract_keys} return results ================================================ FILE: packages/kosong/tests/api_snapshot_tests/test_anthropic.py ================================================ """Snapshot tests for Anthropic chat provider.""" import json import pytest import respx from common import B64_PNG, COMMON_CASES, Case, make_anthropic_response, run_test_cases from httpx import Response from inline_snapshot import snapshot pytest.importorskip("anthropic", reason="Optional contrib dependency not installed") from kosong.contrib.chat_provider.anthropic import Anthropic from kosong.message import ImageURLPart, Message, TextPart, ThinkPart TEST_CASES: dict[str, Case] = { **COMMON_CASES, "assistant_with_thinking": { "history": [ Message(role="user", content="What is 2+2?"), Message( role="assistant", content=[ ThinkPart(think="Let me think...", encrypted="sig_abc123"), TextPart(text="The answer is 4."), ], ), Message(role="user", content="Thanks!"), ], }, "thinking_without_signature_stripped": { "history": [ Message(role="user", content="Hi"), Message( role="assistant", content=[ThinkPart(think="Thinking..."), TextPart(text="Hello!")], ), Message(role="user", content="Bye"), ], }, "base64_image": { "history": [ Message( role="user", content=[ TextPart(text="Describe:"), ImageURLPart( image_url=ImageURLPart.ImageURL(url=f"data:image/png;base64,{B64_PNG}") ), ], ) ], }, "redacted_thinking": { "history": [ Message(role="user", content="What is 2+2?"), Message( role="assistant", content=[ ThinkPart(think="", encrypted="enc_redacted_sig_xyz"), TextPart(text="4."), ], ), Message(role="user", content="Thanks!"), ], }, } async def test_anthropic_message_conversion(): with respx.mock(base_url="https://api.anthropic.com") as mock: mock.post("/v1/messages").mock(return_value=Response(200, json=make_anthropic_response())) provider = Anthropic( model="claude-sonnet-4-20250514", api_key="test-key", default_max_tokens=1024, stream=False, ) results = await run_test_cases(mock, provider, TEST_CASES, ("messages", "system", "tools")) assert results == snapshot( { "simple_user_message": { "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello!", "cache_control": {"type": "ephemeral"}, } ], } ], "system": [ { "type": "text", "text": "You are helpful.", "cache_control": {"type": "ephemeral"}, } ], "tools": [], }, "multi_turn_conversation": { "messages": [ {"role": "user", "content": [{"type": "text", "text": "What is 2+2?"}]}, { "role": "assistant", "content": [{"type": "text", "text": "2+2 equals 4."}], }, { "role": "user", "content": [ { "type": "text", "text": "And 3+3?", "cache_control": {"type": "ephemeral"}, } ], }, ], "tools": [], }, "multi_turn_with_system": { "messages": [ {"role": "user", "content": [{"type": "text", "text": "What is 2+2?"}]}, { "role": "assistant", "content": [{"type": "text", "text": "2+2 equals 4."}], }, { "role": "user", "content": [ { "type": "text", "text": "And 3+3?", "cache_control": {"type": "ephemeral"}, } ], }, ], "system": [ { "text": "You are a math tutor.", "type": "text", "cache_control": {"type": "ephemeral"}, } ], "tools": [], }, "image_url": { "messages": [ { "role": "user", "content": [ {"type": "text", "text": "What's in this image?"}, { "type": "image", "source": { "type": "url", "url": "https://example.com/image.png", }, "cache_control": {"type": "ephemeral"}, }, ], } ], "tools": [], }, "tool_definition": { "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Add 2 and 3", "cache_control": {"type": "ephemeral"}, } ], } ], "tools": [ { "name": "add", "description": "Add two integers.", "input_schema": { "type": "object", "properties": { "a": {"type": "integer", "description": "First number"}, "b": {"type": "integer", "description": "Second number"}, }, "required": ["a", "b"], }, }, { "name": "multiply", "description": "Multiply two integers.", "input_schema": { "type": "object", "properties": { "a": {"type": "integer", "description": "First number"}, "b": {"type": "integer", "description": "Second number"}, }, "required": ["a", "b"], }, "cache_control": {"type": "ephemeral"}, }, ], }, "tool_call_with_image": { "messages": [ {"role": "user", "content": [{"type": "text", "text": "Add 2 and 3"}]}, { "role": "assistant", "content": [ {"type": "text", "text": "I'll add those numbers for you."}, { "type": "tool_use", "id": "call_abc123", "name": "add", "input": {"a": 2, "b": 3}, }, ], }, { "role": "user", "content": [ { "type": "tool_result", "tool_use_id": "call_abc123", "content": [ {"type": "text", "text": "5"}, { "type": "image", "source": { "type": "url", "url": "https://example.com/image.png", }, }, ], "cache_control": {"type": "ephemeral"}, } ], }, ], "tools": [], }, "tool_call": { "messages": [ {"role": "user", "content": [{"type": "text", "text": "Add 2 and 3"}]}, { "role": "assistant", "content": [ {"type": "text", "text": "I'll add those numbers for you."}, { "type": "tool_use", "id": "call_abc123", "name": "add", "input": {"a": 2, "b": 3}, }, ], }, { "role": "user", "content": [ { "type": "tool_result", "tool_use_id": "call_abc123", "content": [{"type": "text", "text": "5"}], "cache_control": {"type": "ephemeral"}, } ], }, ], "tools": [], }, "parallel_tool_calls": { "messages": [ { "role": "user", "content": [{"type": "text", "text": "Calculate 2+3 and 4*5"}], }, { "role": "assistant", "content": [ {"type": "text", "text": "I'll calculate both."}, { "type": "tool_use", "id": "call_add", "name": "add", "input": {"a": 2, "b": 3}, }, { "type": "tool_use", "id": "call_mul", "name": "multiply", "input": {"a": 4, "b": 5}, }, ], }, { "role": "user", "content": [ { "type": "tool_result", "tool_use_id": "call_add", "content": [ { "type": "text", "text": "This is a system reminder" "", }, {"type": "text", "text": "5"}, ], } ], }, { "role": "user", "content": [ { "type": "tool_result", "tool_use_id": "call_mul", "content": [ { "type": "text", "text": "This is a system reminder" "", }, {"type": "text", "text": "20"}, ], "cache_control": {"type": "ephemeral"}, } ], }, ], "tools": [ { "name": "add", "description": "Add two integers.", "input_schema": { "type": "object", "properties": { "a": {"type": "integer", "description": "First number"}, "b": {"type": "integer", "description": "Second number"}, }, "required": ["a", "b"], }, }, { "name": "multiply", "description": "Multiply two integers.", "input_schema": { "type": "object", "properties": { "a": {"type": "integer", "description": "First number"}, "b": {"type": "integer", "description": "Second number"}, }, "required": ["a", "b"], }, "cache_control": {"type": "ephemeral"}, }, ], }, "assistant_with_thinking": { "messages": [ { "role": "user", "content": [{"type": "text", "text": "What is 2+2?"}], }, { "role": "assistant", "content": [ { "type": "thinking", "thinking": "Let me think...", "signature": "sig_abc123", }, {"type": "text", "text": "The answer is 4."}, ], }, { "role": "user", "content": [ { "type": "text", "text": "Thanks!", "cache_control": {"type": "ephemeral"}, } ], }, ], "tools": [], }, "thinking_without_signature_stripped": { "messages": [ { "role": "user", "content": [{"type": "text", "text": "Hi"}], }, { "role": "assistant", "content": [{"type": "text", "text": "Hello!"}], }, { "role": "user", "content": [ { "type": "text", "text": "Bye", "cache_control": {"type": "ephemeral"}, } ], }, ], "tools": [], }, "base64_image": { "messages": [ { "role": "user", "content": [ {"type": "text", "text": "Describe:"}, { "type": "image", "source": { "type": "base64", "data": B64_PNG, "media_type": "image/png", }, "cache_control": {"type": "ephemeral"}, }, ], } ], "tools": [], }, "redacted_thinking": { "messages": [ { "role": "user", "content": [{"type": "text", "text": "What is 2+2?"}], }, { "role": "assistant", "content": [ { "type": "thinking", "thinking": "", "signature": "enc_redacted_sig_xyz", }, {"type": "text", "text": "4."}, ], }, { "role": "user", "content": [ { "type": "text", "text": "Thanks!", "cache_control": {"type": "ephemeral"}, } ], }, ], "tools": [], }, } ) async def test_anthropic_generation_kwargs(): with respx.mock(base_url="https://api.anthropic.com") as mock: mock.post("/v1/messages").mock(return_value=Response(200, json=make_anthropic_response())) provider = Anthropic( model="claude-sonnet-4-20250514", api_key="test-key", default_max_tokens=1024, stream=False, ).with_generation_kwargs(temperature=0.7, top_p=0.9, max_tokens=2048) stream = await provider.generate("", [], [Message(role="user", content="Hi")]) async for _ in stream: pass body = json.loads(mock.calls.last.request.content.decode()) assert (body["temperature"], body["top_p"], body["max_tokens"]) == snapshot( (0.7, 0.9, 2048) ) async def test_anthropic_with_thinking(): with respx.mock(base_url="https://api.anthropic.com") as mock: mock.post("/v1/messages").mock(return_value=Response(200, json=make_anthropic_response())) provider = Anthropic( model="claude-sonnet-4-20250514", api_key="test-key", default_max_tokens=1024, stream=False, ).with_thinking("high") stream = await provider.generate("", [], [Message(role="user", content="Think")]) async for _ in stream: pass body = json.loads(mock.calls.last.request.content.decode()) assert body["thinking"] == snapshot({"type": "enabled", "budget_tokens": 32000}) async def test_anthropic_opus_46_adaptive_thinking(): """Opus 4.6 models should use adaptive thinking instead of budget-based thinking.""" with respx.mock(base_url="https://api.anthropic.com") as mock: mock.post("/v1/messages").mock(return_value=Response(200, json=make_anthropic_response())) provider = Anthropic( model="claude-opus-4-6-20260205", api_key="test-key", default_max_tokens=1024, stream=False, ).with_thinking("high") stream = await provider.generate("", [], [Message(role="user", content="Think")]) async for _ in stream: pass body = json.loads(mock.calls.last.request.content.decode()) assert body["thinking"] == snapshot({"type": "adaptive"}) # Adaptive thinking should not include interleaved-thinking beta header beta_header = mock.calls.last.request.headers.get("anthropic-beta", "") assert "interleaved-thinking-2025-05-14" not in beta_header async def test_anthropic_opus_46_thinking_off(): """Opus 4.6 with thinking off should still use disabled.""" with respx.mock(base_url="https://api.anthropic.com") as mock: mock.post("/v1/messages").mock(return_value=Response(200, json=make_anthropic_response())) provider = Anthropic( model="claude-opus-4-6-20260205", api_key="test-key", default_max_tokens=1024, stream=False, ).with_thinking("off") stream = await provider.generate("", [], [Message(role="user", content="Think")]) async for _ in stream: pass body = json.loads(mock.calls.last.request.content.decode()) assert body["thinking"] == snapshot({"type": "disabled"}) async def test_anthropic_metadata(): """Metadata should be forwarded to the Anthropic API request.""" with respx.mock(base_url="https://api.anthropic.com") as mock: mock.post("/v1/messages").mock(return_value=Response(200, json=make_anthropic_response())) provider = Anthropic( model="claude-sonnet-4-20250514", api_key="test-key", default_max_tokens=1024, stream=False, metadata={"user_id": "test-session-id"}, ) stream = await provider.generate("", [], [Message(role="user", content="Hi")]) async for _ in stream: pass body = json.loads(mock.calls.last.request.content.decode()) assert body["metadata"] == snapshot({"user_id": "test-session-id"}) async def test_anthropic_metadata_omitted_when_none(): """Metadata should not be included in the request when not provided.""" with respx.mock(base_url="https://api.anthropic.com") as mock: mock.post("/v1/messages").mock(return_value=Response(200, json=make_anthropic_response())) provider = Anthropic( model="claude-sonnet-4-20250514", api_key="test-key", default_max_tokens=1024, stream=False, ) stream = await provider.generate("", [], [Message(role="user", content="Hi")]) async for _ in stream: pass body = json.loads(mock.calls.last.request.content.decode()) assert "metadata" not in body async def test_anthropic_opus_46_thinking_effort_property(): """thinking_effort should return 'high' for adaptive thinking config.""" provider = Anthropic( model="claude-opus-4-6-20260205", api_key="test-key", default_max_tokens=1024, stream=False, ).with_thinking("high") assert provider.thinking_effort == "high" provider_off = Anthropic( model="claude-opus-4-6-20260205", api_key="test-key", default_max_tokens=1024, stream=False, ).with_thinking("off") assert provider_off.thinking_effort == "off" ================================================ FILE: packages/kosong/tests/api_snapshot_tests/test_google_genai.py ================================================ """Snapshot tests for Google GenAI (Gemini) chat provider.""" import json from typing import Any import pytest import respx from common import COMMON_CASES, Case, run_test_cases from httpx import Response from inline_snapshot import snapshot pytest.importorskip("google.genai", reason="Optional contrib dependency not installed") from google.genai import _api_client from kosong.message import Message, TextPart, ToolCall # Force google-genai to use httpx so respx can mock requests. _api_client.has_aiohttp = False from kosong.contrib.chat_provider.google_genai import GoogleGenAI # noqa: E402 def make_response() -> dict[str, Any]: return { "candidates": [ { "content": {"parts": [{"text": "Hello"}], "role": "model"}, "finishReason": "STOP", } ], "usageMetadata": { "promptTokenCount": 10, "candidatesTokenCount": 5, "totalTokenCount": 15, }, "modelVersion": "gemini-2.5-flash", } TEST_CASES: dict[str, Case] = { # Google GenAI doesn't support image_url in the same way, use subset of common cases **{k: v for k, v in COMMON_CASES.items() if "image" not in k}, "tool_call_with_thought_signature": { "history": [ Message(role="user", content="Add 2 and 3"), Message( role="assistant", content=[TextPart(text="I'll add those.")], tool_calls=[ ToolCall( id="add_call_sig", function=ToolCall.FunctionBody(name="add", arguments='{"a": 2, "b": 3}'), extras={"thought_signature_b64": "dGhvdWdodF9zaWduYXR1cmVfZGF0YQ=="}, ) ], ), ], }, } async def test_google_genai_message_conversion(): with respx.mock(base_url="https://generativelanguage.googleapis.com") as mock: mock.route(method="POST", path__regex=r"/v1beta/models/.+:generateContent").mock( return_value=Response(200, json=make_response()) ) provider = GoogleGenAI(model="gemini-2.5-flash", api_key="test-key", stream=False) results = await run_test_cases( mock, provider, TEST_CASES, ("contents", "systemInstruction", "tools") ) assert results == snapshot( { "simple_user_message": { "contents": [{"parts": [{"text": "Hello!"}], "role": "user"}], "systemInstruction": { "parts": [{"text": "You are helpful."}], "role": "user", }, }, "multi_turn_conversation": { "contents": [ {"parts": [{"text": "What is 2+2?"}], "role": "user"}, {"parts": [{"text": "2+2 equals 4."}], "role": "model"}, {"parts": [{"text": "And 3+3?"}], "role": "user"}, ], "systemInstruction": {"parts": [{"text": ""}], "role": "user"}, }, "multi_turn_with_system": { "contents": [ {"parts": [{"text": "What is 2+2?"}], "role": "user"}, {"parts": [{"text": "2+2 equals 4."}], "role": "model"}, {"parts": [{"text": "And 3+3?"}], "role": "user"}, ], "systemInstruction": { "parts": [{"text": "You are a math tutor."}], "role": "user", }, }, "tool_definition": { "contents": [{"parts": [{"text": "Add 2 and 3"}], "role": "user"}], "systemInstruction": {"parts": [{"text": ""}], "role": "user"}, "tools": [ { "functionDeclarations": [ { "name": "add", "description": "Add two integers.", "parameters_json_schema": { "type": "object", "properties": { "a": {"type": "integer", "description": "First number"}, "b": { "type": "integer", "description": "Second number", }, }, "required": ["a", "b"], }, }, { "description": "Multiply two integers.", "name": "multiply", "parameters_json_schema": { "type": "object", "properties": { "a": {"type": "integer", "description": "First number"}, "b": { "type": "integer", "description": "Second number", }, }, "required": ["a", "b"], }, }, ] } ], }, "tool_call": { "contents": [ {"parts": [{"text": "Add 2 and 3"}], "role": "user"}, { "parts": [ {"text": "I'll add those numbers for you."}, { "functionCall": { "id": "call_abc123", "args": {"a": 2, "b": 3}, "name": "add", } }, ], "role": "model", }, { "parts": [ { "functionResponse": { "parts": [], "id": "call_abc123", "name": "add", "response": {"output": "5"}, } } ], "role": "user", }, ], "systemInstruction": {"parts": [{"text": ""}], "role": "user"}, }, "parallel_tool_calls": { "contents": [ {"parts": [{"text": "Calculate 2+3 and 4*5"}], "role": "user"}, { "parts": [ {"text": "I'll calculate both."}, { "functionCall": { "id": "call_add", "name": "add", "args": {"a": 2, "b": 3}, } }, { "functionCall": { "id": "call_mul", "name": "multiply", "args": {"a": 4, "b": 5}, } }, ], "role": "model", }, { "parts": [ { "functionResponse": { "parts": [], "id": "call_add", "name": "add", "response": { "output": "This is a system reminder" "5" }, } }, { "functionResponse": { "parts": [], "id": "call_mul", "name": "multiply", "response": { "output": "This is a system reminder" "20" }, } }, ], "role": "user", }, ], "systemInstruction": {"parts": [{"text": ""}], "role": "user"}, "tools": [ { "functionDeclarations": [ { "description": "Add two integers.", "name": "add", "parameters_json_schema": { "type": "object", "properties": { "a": {"type": "integer", "description": "First number"}, "b": { "type": "integer", "description": "Second number", }, }, "required": ["a", "b"], }, }, { "description": "Multiply two integers.", "name": "multiply", "parameters_json_schema": { "type": "object", "properties": { "a": {"type": "integer", "description": "First number"}, "b": { "type": "integer", "description": "Second number", }, }, "required": ["a", "b"], }, }, ] } ], }, "tool_call_with_thought_signature": { "contents": [ {"parts": [{"text": "Add 2 and 3"}], "role": "user"}, { "parts": [ {"text": "I'll add those."}, { "functionCall": { "id": "add_call_sig", "name": "add", "args": {"a": 2, "b": 3}, }, "thoughtSignature": "dGhvdWdodF9zaWduYXR1cmVfZGF0YQ==", }, ], "role": "model", }, ], "systemInstruction": {"parts": [{"text": ""}], "role": "user"}, }, } ) async def test_google_genai_vertexai_message_conversion(): with respx.mock(base_url="https://aiplatform.googleapis.com") as mock: mock.route( method="POST", path__regex=r"/v1beta1/publishers/google/models/gemini-3-pro-preview:generateContent", ).mock(return_value=Response(200, json=make_response())) provider = GoogleGenAI( model="gemini-3-pro-preview", api_key="test-key", stream=False, vertexai=True, ) results = await run_test_cases( mock, provider, TEST_CASES, ("contents", "systemInstruction", "tools") ) assert results == snapshot( { "simple_user_message": { "contents": [{"parts": [{"text": "Hello!"}], "role": "user"}], "systemInstruction": {"parts": [{"text": "You are helpful."}], "role": "user"}, }, "multi_turn_conversation": { "contents": [ {"parts": [{"text": "What is 2+2?"}], "role": "user"}, {"parts": [{"text": "2+2 equals 4."}], "role": "model"}, {"parts": [{"text": "And 3+3?"}], "role": "user"}, ], "systemInstruction": {"parts": [{"text": ""}], "role": "user"}, }, "multi_turn_with_system": { "contents": [ {"parts": [{"text": "What is 2+2?"}], "role": "user"}, {"parts": [{"text": "2+2 equals 4."}], "role": "model"}, {"parts": [{"text": "And 3+3?"}], "role": "user"}, ], "systemInstruction": { "parts": [{"text": "You are a math tutor."}], "role": "user", }, }, "tool_definition": { "contents": [{"parts": [{"text": "Add 2 and 3"}], "role": "user"}], "systemInstruction": {"parts": [{"text": ""}], "role": "user"}, "tools": [ { "functionDeclarations": [ { "description": "Add two integers.", "name": "add", "parametersJsonSchema": { "type": "object", "properties": { "a": {"type": "integer", "description": "First number"}, "b": { "type": "integer", "description": "Second number", }, }, "required": ["a", "b"], }, }, { "description": "Multiply two integers.", "name": "multiply", "parametersJsonSchema": { "type": "object", "properties": { "a": {"type": "integer", "description": "First number"}, "b": { "type": "integer", "description": "Second number", }, }, "required": ["a", "b"], }, }, ] } ], }, "tool_call": { "contents": [ {"parts": [{"text": "Add 2 and 3"}], "role": "user"}, { "parts": [ {"text": "I'll add those numbers for you."}, { "function_call": { "id": "call_abc123", "args": {"a": 2, "b": 3}, "name": "add", } }, ], "role": "model", }, { "parts": [ { "function_response": { "parts": [], "id": "call_abc123", "name": "add", "response": {"output": "5"}, } } ], "role": "user", }, ], "systemInstruction": {"parts": [{"text": ""}], "role": "user"}, }, "parallel_tool_calls": { "contents": [ {"parts": [{"text": "Calculate 2+3 and 4*5"}], "role": "user"}, { "parts": [ {"text": "I'll calculate both."}, { "function_call": { "id": "call_add", "args": {"a": 2, "b": 3}, "name": "add", } }, { "function_call": { "id": "call_mul", "args": {"a": 4, "b": 5}, "name": "multiply", } }, ], "role": "model", }, { "parts": [ { "function_response": { "parts": [], "id": "call_add", "name": "add", "response": { "output": "This is a system reminder5" # noqa: E501 }, } }, { "function_response": { "parts": [], "id": "call_mul", "name": "multiply", "response": { "output": "This is a system reminder20" # noqa: E501 }, } }, ], "role": "user", }, ], "systemInstruction": {"parts": [{"text": ""}], "role": "user"}, "tools": [ { "functionDeclarations": [ { "description": "Add two integers.", "name": "add", "parametersJsonSchema": { "type": "object", "properties": { "a": {"type": "integer", "description": "First number"}, "b": { "type": "integer", "description": "Second number", }, }, "required": ["a", "b"], }, }, { "description": "Multiply two integers.", "name": "multiply", "parametersJsonSchema": { "type": "object", "properties": { "a": {"type": "integer", "description": "First number"}, "b": { "type": "integer", "description": "Second number", }, }, "required": ["a", "b"], }, }, ] } ], }, "tool_call_with_thought_signature": { "contents": [ {"parts": [{"text": "Add 2 and 3"}], "role": "user"}, { "parts": [ {"text": "I'll add those."}, { "function_call": { "id": "add_call_sig", "args": {"a": 2, "b": 3}, "name": "add", }, "thought_signature": "dGhvdWdodF9zaWduYXR1cmVfZGF0YQ==", }, ], "role": "model", }, ], "systemInstruction": {"parts": [{"text": ""}], "role": "user"}, }, } ) async def test_google_genai_generation_kwargs(): with respx.mock(base_url="https://generativelanguage.googleapis.com") as mock: mock.route(method="POST", path__regex=r"/v1beta/models/.+:generateContent").mock( return_value=Response(200, json=make_response()) ) provider = GoogleGenAI( model="gemini-2.5-flash", api_key="test-key", stream=False ).with_generation_kwargs(temperature=0.7, max_output_tokens=2048) stream = await provider.generate("", [], [Message(role="user", content="Hi")]) async for _ in stream: pass body = json.loads(mock.calls.last.request.content.decode()) config = body.get("generationConfig", {}) assert (config.get("temperature"), config.get("maxOutputTokens")) == snapshot((0.7, 2048)) async def test_google_genai_with_thinking(): with respx.mock(base_url="https://generativelanguage.googleapis.com") as mock: mock.route(method="POST", path__regex=r"/v1beta/models/.+:generateContent").mock( return_value=Response(200, json=make_response()) ) provider = GoogleGenAI( model="gemini-2.5-flash", api_key="test-key", stream=False ).with_thinking("high") stream = await provider.generate("", [], [Message(role="user", content="Think")]) async for _ in stream: pass body = json.loads(mock.calls.last.request.content.decode()) assert body.get("generationConfig", {}).get("thinkingConfig") == snapshot( {"include_thoughts": True, "thinking_budget": 32000} ) ================================================ FILE: packages/kosong/tests/api_snapshot_tests/test_kimi.py ================================================ """Snapshot tests for Kimi chat provider.""" import json import respx from common import COMMON_CASES, Case, make_chat_completion_response, run_test_cases from httpx import Response from inline_snapshot import snapshot from kosong.chat_provider.kimi import Kimi from kosong.message import Message, TextPart, ThinkPart from kosong.tooling import Tool BUILTIN_TOOL = Tool( name="$web_search", description="Search the web", parameters={"type": "object", "properties": {}}, ) TEST_CASES: dict[str, Case] = { **COMMON_CASES, "builtin_tool": { "history": [Message(role="user", content="Search for something")], "tools": [BUILTIN_TOOL], }, "assistant_with_reasoning": { "history": [ Message(role="user", content="What is 2+2?"), Message( role="assistant", content=[ ThinkPart(think="Let me think..."), TextPart(text="The answer is 4."), ], ), Message(role="user", content="Thanks!"), ], }, } async def test_kimi_message_conversion(): with respx.mock(base_url="https://api.moonshot.ai") as mock: mock.post("/v1/chat/completions").mock( return_value=Response(200, json=make_chat_completion_response("kimi-k2")) ) provider = Kimi(model="kimi-k2-turbo-preview", api_key="test-key", stream=False) results = await run_test_cases(mock, provider, TEST_CASES, ("messages", "tools")) assert results == snapshot( { "simple_user_message": { "messages": [ {"role": "system", "content": "You are helpful."}, {"role": "user", "content": "Hello!"}, ], "tools": [], }, "multi_turn_conversation": { "messages": [ {"role": "user", "content": "What is 2+2?"}, {"role": "assistant", "content": "2+2 equals 4."}, {"role": "user", "content": "And 3+3?"}, ], "tools": [], }, "multi_turn_with_system": { "messages": [ {"role": "system", "content": "You are a math tutor."}, {"role": "user", "content": "What is 2+2?"}, {"role": "assistant", "content": "2+2 equals 4."}, {"role": "user", "content": "And 3+3?"}, ], "tools": [], }, "image_url": { "messages": [ { "role": "user", "content": [ {"type": "text", "text": "What's in this image?"}, { "type": "image_url", "image_url": { "url": "https://example.com/image.png", "id": None, }, }, ], } ], "tools": [], }, "tool_definition": { "messages": [{"role": "user", "content": "Add 2 and 3"}], "tools": [ { "type": "function", "function": { "name": "add", "description": "Add two integers.", "parameters": { "type": "object", "properties": { "a": { "type": "integer", "description": "First number", }, "b": { "type": "integer", "description": "Second number", }, }, "required": ["a", "b"], }, }, }, { "type": "function", "function": { "name": "multiply", "description": "Multiply two integers.", "parameters": { "type": "object", "properties": { "a": {"type": "integer", "description": "First number"}, "b": {"type": "integer", "description": "Second number"}, }, "required": ["a", "b"], }, }, }, ], }, "tool_call_with_image": { "messages": [ {"role": "user", "content": "Add 2 and 3"}, { "role": "assistant", "content": "I'll add those numbers for you.", "tool_calls": [ { "type": "function", "id": "call_abc123", "function": {"name": "add", "arguments": '{"a": 2, "b": 3}'}, } ], }, { "role": "tool", "content": [ {"type": "text", "text": "5"}, { "type": "image_url", "image_url": { "url": "https://example.com/image.png", "id": None, }, }, ], "tool_call_id": "call_abc123", }, ], "tools": [], }, "tool_call": { "messages": [ {"role": "user", "content": "Add 2 and 3"}, { "role": "assistant", "content": "I'll add those numbers for you.", "tool_calls": [ { "type": "function", "id": "call_abc123", "function": {"name": "add", "arguments": '{"a": 2, "b": 3}'}, } ], }, {"role": "tool", "content": "5", "tool_call_id": "call_abc123"}, ], "tools": [], }, "parallel_tool_calls": { "messages": [ {"role": "user", "content": "Calculate 2+3 and 4*5"}, { "role": "assistant", "content": "I'll calculate both.", "tool_calls": [ { "type": "function", "id": "call_add", "function": { "name": "add", "arguments": '{"a": 2, "b": 3}', }, }, { "type": "function", "id": "call_mul", "function": { "name": "multiply", "arguments": '{"a": 4, "b": 5}', }, }, ], }, { "role": "tool", "content": [ { "type": "text", "text": "This is a system reminder" "", }, {"type": "text", "text": "5"}, ], "tool_call_id": "call_add", }, { "role": "tool", "content": [ { "type": "text", "text": "This is a system reminder" "", }, {"type": "text", "text": "20"}, ], "tool_call_id": "call_mul", }, ], "tools": [ { "type": "function", "function": { "name": "add", "description": "Add two integers.", "parameters": { "type": "object", "properties": { "a": {"type": "integer", "description": "First number"}, "b": {"type": "integer", "description": "Second number"}, }, "required": ["a", "b"], }, }, }, { "type": "function", "function": { "name": "multiply", "description": "Multiply two integers.", "parameters": { "type": "object", "properties": { "a": {"type": "integer", "description": "First number"}, "b": {"type": "integer", "description": "Second number"}, }, "required": ["a", "b"], }, }, }, ], }, "builtin_tool": { "messages": [{"role": "user", "content": "Search for something"}], "tools": [ { "type": "builtin_function", "function": {"name": "$web_search"}, } ], }, "assistant_with_reasoning": { "messages": [ {"role": "user", "content": "What is 2+2?"}, { "role": "assistant", "content": "The answer is 4.", "reasoning_content": "Let me think...", }, {"role": "user", "content": "Thanks!"}, ], "tools": [], }, } ) async def test_kimi_generation_kwargs(): with respx.mock(base_url="https://api.moonshot.ai") as mock: mock.post("/v1/chat/completions").mock( return_value=Response(200, json=make_chat_completion_response()) ) provider = Kimi( model="kimi-k2-turbo-preview", api_key="test-key", stream=False ).with_generation_kwargs(temperature=0.7, max_tokens=2048) stream = await provider.generate("", [], [Message(role="user", content="Hi")]) async for _ in stream: pass body = json.loads(mock.calls.last.request.content.decode()) assert (body["temperature"], body["max_tokens"]) == snapshot((0.7, 2048)) async def test_kimi_with_thinking(): with respx.mock(base_url="https://api.moonshot.ai") as mock: mock.post("/v1/chat/completions").mock( return_value=Response(200, json=make_chat_completion_response()) ) provider = Kimi( model="kimi-k2-turbo-preview", api_key="test-key", stream=False ).with_thinking("high") stream = await provider.generate("", [], [Message(role="user", content="Think")]) async for _ in stream: pass body = json.loads(mock.calls.last.request.content.decode()) assert body["reasoning_effort"] == snapshot("high") ================================================ FILE: packages/kosong/tests/api_snapshot_tests/test_openai_legacy.py ================================================ """Snapshot tests for OpenAI Legacy (Chat Completions API) chat provider.""" import json import respx from common import COMMON_CASES, Case, make_chat_completion_response, run_test_cases from httpx import Response from inline_snapshot import snapshot from kosong.contrib.chat_provider.openai_legacy import OpenAILegacy from kosong.message import Message, TextPart, ThinkPart TEST_CASES: dict[str, Case] = {**COMMON_CASES} async def test_openai_legacy_message_conversion(): with respx.mock(base_url="https://api.openai.com") as mock: mock.post("/v1/chat/completions").mock( return_value=Response(200, json=make_chat_completion_response("gpt-4.1")) ) provider = OpenAILegacy(model="gpt-4.1", api_key="test-key", stream=False) results = await run_test_cases(mock, provider, TEST_CASES, ("messages", "tools")) assert results == snapshot( { "simple_user_message": { "messages": [ {"role": "system", "content": "You are helpful."}, {"role": "user", "content": "Hello!"}, ], "tools": [], }, "multi_turn_conversation": { "messages": [ {"role": "user", "content": "What is 2+2?"}, {"role": "assistant", "content": "2+2 equals 4."}, {"role": "user", "content": "And 3+3?"}, ], "tools": [], }, "multi_turn_with_system": { "messages": [ {"role": "system", "content": "You are a math tutor."}, {"role": "user", "content": "What is 2+2?"}, {"role": "assistant", "content": "2+2 equals 4."}, {"role": "user", "content": "And 3+3?"}, ], "tools": [], }, "image_url": { "messages": [ { "role": "user", "content": [ {"type": "text", "text": "What's in this image?"}, { "type": "image_url", "image_url": { "url": "https://example.com/image.png", "id": None, }, }, ], } ], "tools": [], }, "tool_definition": { "messages": [{"role": "user", "content": "Add 2 and 3"}], "tools": [ { "type": "function", "function": { "name": "add", "description": "Add two integers.", "parameters": { "type": "object", "properties": { "a": { "type": "integer", "description": "First number", }, "b": { "type": "integer", "description": "Second number", }, }, "required": ["a", "b"], }, }, }, { "type": "function", "function": { "name": "multiply", "description": "Multiply two integers.", "parameters": { "type": "object", "properties": { "a": {"type": "integer", "description": "First number"}, "b": {"type": "integer", "description": "Second number"}, }, "required": ["a", "b"], }, }, }, ], }, "tool_call_with_image": { "messages": [ {"role": "user", "content": "Add 2 and 3"}, { "role": "assistant", "content": "I'll add those numbers for you.", "tool_calls": [ { "type": "function", "id": "call_abc123", "function": {"name": "add", "arguments": '{"a": 2, "b": 3}'}, } ], }, { "role": "tool", "content": [ {"type": "text", "text": "5"}, { "type": "image_url", "image_url": { "url": "https://example.com/image.png", "id": None, }, }, ], "tool_call_id": "call_abc123", }, ], "tools": [], }, "tool_call": { "messages": [ {"role": "user", "content": "Add 2 and 3"}, { "role": "assistant", "content": "I'll add those numbers for you.", "tool_calls": [ { "type": "function", "id": "call_abc123", "function": {"name": "add", "arguments": '{"a": 2, "b": 3}'}, } ], }, {"role": "tool", "content": "5", "tool_call_id": "call_abc123"}, ], "tools": [], }, "parallel_tool_calls": { "messages": [ {"role": "user", "content": "Calculate 2+3 and 4*5"}, { "role": "assistant", "content": "I'll calculate both.", "tool_calls": [ { "type": "function", "id": "call_add", "function": { "name": "add", "arguments": '{"a": 2, "b": 3}', }, }, { "type": "function", "id": "call_mul", "function": { "name": "multiply", "arguments": '{"a": 4, "b": 5}', }, }, ], }, { "role": "tool", "content": [ { "type": "text", "text": "This is a system reminder" "", }, {"type": "text", "text": "5"}, ], "tool_call_id": "call_add", }, { "role": "tool", "content": [ { "type": "text", "text": "This is a system reminder" "", }, {"type": "text", "text": "20"}, ], "tool_call_id": "call_mul", }, ], "tools": [ { "type": "function", "function": { "name": "add", "description": "Add two integers.", "parameters": { "type": "object", "properties": { "a": {"type": "integer", "description": "First number"}, "b": {"type": "integer", "description": "Second number"}, }, "required": ["a", "b"], }, }, }, { "type": "function", "function": { "name": "multiply", "description": "Multiply two integers.", "parameters": { "type": "object", "properties": { "a": {"type": "integer", "description": "First number"}, "b": {"type": "integer", "description": "Second number"}, }, "required": ["a", "b"], }, }, }, ], }, } ) async def test_openai_legacy_reasoning_content(): with respx.mock(base_url="https://api.openai.com") as mock: mock.post("/v1/chat/completions").mock( return_value=Response(200, json=make_chat_completion_response()) ) provider = OpenAILegacy( model="deepseek-reasoner", api_key="test-key", stream=False, reasoning_key="reasoning_content", ) history = [ Message(role="user", content="What is 2+2?"), Message( role="assistant", content=[ThinkPart(think="Thinking..."), TextPart(text="4.")], ), Message(role="user", content="Thanks!"), ] stream = await provider.generate("", [], history) async for _ in stream: pass body = json.loads(mock.calls.last.request.content.decode()) assert body["messages"] == snapshot( [ {"role": "user", "content": "What is 2+2?"}, { "role": "assistant", "content": "4.", "reasoning_content": "Thinking...", }, {"role": "user", "content": "Thanks!"}, ] ) async def test_openai_legacy_generation_kwargs(): with respx.mock(base_url="https://api.openai.com") as mock: mock.post("/v1/chat/completions").mock( return_value=Response(200, json=make_chat_completion_response()) ) provider = OpenAILegacy( model="gpt-4.1", api_key="test-key", stream=False ).with_generation_kwargs(temperature=0.7, max_tokens=2048) stream = await provider.generate("", [], [Message(role="user", content="Hi")]) async for _ in stream: pass body = json.loads(mock.calls.last.request.content.decode()) assert (body["temperature"], body["max_tokens"]) == snapshot((0.7, 2048)) async def test_openai_legacy_with_thinking(): with respx.mock(base_url="https://api.openai.com") as mock: mock.post("/v1/chat/completions").mock( return_value=Response(200, json=make_chat_completion_response()) ) provider = OpenAILegacy(model="gpt-4.1", api_key="test-key", stream=False).with_thinking( "high" ) stream = await provider.generate("", [], [Message(role="user", content="Think")]) async for _ in stream: pass body = json.loads(mock.calls.last.request.content.decode()) assert body["reasoning_effort"] == snapshot("high") ================================================ FILE: packages/kosong/tests/api_snapshot_tests/test_openai_responses.py ================================================ """Snapshot tests for OpenAI Responses API chat provider.""" import json from typing import Any import respx from common import COMMON_CASES, Case, run_test_cases from httpx import Response from inline_snapshot import snapshot from kosong.contrib.chat_provider.openai_responses import OpenAIResponses from kosong.message import Message, TextPart, ThinkPart def make_response() -> dict[str, Any]: return { "id": "resp_test123", "object": "response", "created_at": 1234567890, "status": "completed", "model": "gpt-4.1", "output": [ { "type": "message", "id": "msg_test", "role": "assistant", "content": [{"type": "output_text", "text": "Hello", "annotations": []}], } ], "usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, } TEST_CASES: dict[str, Case] = { **COMMON_CASES, "assistant_with_reasoning": { "history": [ Message(role="user", content="What is 2+2?"), Message( role="assistant", content=[ ThinkPart(think="Thinking...", encrypted="enc_abc"), TextPart(text="4."), ], ), Message(role="user", content="Thanks!"), ], }, } async def test_openai_responses_message_conversion(): with respx.mock(base_url="https://api.openai.com") as mock: mock.post("/v1/responses").mock(return_value=Response(200, json=make_response())) provider = OpenAIResponses(model="gpt-4.1", api_key="test-key", stream=False) results = await run_test_cases(mock, provider, TEST_CASES, ("input", "tools")) assert results == snapshot( { "simple_user_message": { "input": [ {"role": "developer", "content": "You are helpful."}, { "content": [{"type": "input_text", "text": "Hello!"}], "role": "user", "type": "message", }, ], "tools": [], }, "multi_turn_conversation": { "input": [ { "content": [{"type": "input_text", "text": "What is 2+2?"}], "role": "user", "type": "message", }, { "content": [ {"type": "output_text", "text": "2+2 equals 4.", "annotations": []} ], "role": "assistant", "type": "message", }, { "content": [{"type": "input_text", "text": "And 3+3?"}], "role": "user", "type": "message", }, ], "tools": [], }, "multi_turn_with_system": { "input": [ {"role": "developer", "content": "You are a math tutor."}, { "content": [{"type": "input_text", "text": "What is 2+2?"}], "role": "user", "type": "message", }, { "content": [ {"type": "output_text", "text": "2+2 equals 4.", "annotations": []} ], "role": "assistant", "type": "message", }, { "content": [{"type": "input_text", "text": "And 3+3?"}], "role": "user", "type": "message", }, ], "tools": [], }, "image_url": { "input": [ { "content": [ {"type": "input_text", "text": "What's in this image?"}, { "type": "input_image", "detail": "auto", "image_url": "https://example.com/image.png", }, ], "role": "user", "type": "message", } ], "tools": [], }, "tool_definition": { "input": [ { "content": [{"type": "input_text", "text": "Add 2 and 3"}], "role": "user", "type": "message", } ], "tools": [ { "type": "function", "name": "add", "description": "Add two integers.", "parameters": { "type": "object", "properties": { "a": { "type": "integer", "description": "First number", }, "b": { "type": "integer", "description": "Second number", }, }, "required": ["a", "b"], }, "strict": False, }, { "type": "function", "name": "multiply", "description": "Multiply two integers.", "parameters": { "type": "object", "properties": { "a": {"type": "integer", "description": "First number"}, "b": {"type": "integer", "description": "Second number"}, }, "required": ["a", "b"], }, "strict": False, }, ], }, "tool_call_with_image": { "input": [ { "content": [{"type": "input_text", "text": "Add 2 and 3"}], "role": "user", "type": "message", }, { "content": [ { "type": "output_text", "text": "I'll add those numbers for you.", "annotations": [], } ], "role": "assistant", "type": "message", }, { "arguments": '{"a": 2, "b": 3}', "call_id": "call_abc123", "name": "add", "type": "function_call", }, { "call_id": "call_abc123", "output": [ {"type": "input_text", "text": "5"}, { "type": "input_image", "image_url": "https://example.com/image.png", }, ], "type": "function_call_output", }, ], "tools": [], }, "tool_call": { "input": [ { "content": [{"type": "input_text", "text": "Add 2 and 3"}], "role": "user", "type": "message", }, { "content": [ { "type": "output_text", "text": "I'll add those numbers for you.", "annotations": [], } ], "role": "assistant", "type": "message", }, { "arguments": '{"a": 2, "b": 3}', "call_id": "call_abc123", "name": "add", "type": "function_call", }, { "call_id": "call_abc123", "output": [{"type": "input_text", "text": "5"}], "type": "function_call_output", }, ], "tools": [], }, "parallel_tool_calls": { "input": [ { "content": [{"type": "input_text", "text": "Calculate 2+3 and 4*5"}], "role": "user", "type": "message", }, { "content": [ { "type": "output_text", "text": "I'll calculate both.", "annotations": [], } ], "role": "assistant", "type": "message", }, { "arguments": '{"a": 2, "b": 3}', "call_id": "call_add", "name": "add", "type": "function_call", }, { "arguments": '{"a": 4, "b": 5}', "call_id": "call_mul", "name": "multiply", "type": "function_call", }, { "call_id": "call_add", "output": [ { "type": "input_text", "text": "This is a system reminder" "", }, {"type": "input_text", "text": "5"}, ], "type": "function_call_output", }, { "call_id": "call_mul", "output": [ { "type": "input_text", "text": "This is a system reminder" "", }, {"type": "input_text", "text": "20"}, ], "type": "function_call_output", }, ], "tools": [ { "type": "function", "name": "add", "description": "Add two integers.", "parameters": { "type": "object", "properties": { "a": {"type": "integer", "description": "First number"}, "b": {"type": "integer", "description": "Second number"}, }, "required": ["a", "b"], }, "strict": False, }, { "type": "function", "name": "multiply", "description": "Multiply two integers.", "parameters": { "type": "object", "properties": { "a": {"type": "integer", "description": "First number"}, "b": {"type": "integer", "description": "Second number"}, }, "required": ["a", "b"], }, "strict": False, }, ], }, "assistant_with_reasoning": { "input": [ { "content": [{"type": "input_text", "text": "What is 2+2?"}], "role": "user", "type": "message", }, { "summary": [{"type": "summary_text", "text": "Thinking..."}], "type": "reasoning", "encrypted_content": "enc_abc", }, { "content": [ { "type": "output_text", "text": "4.", "annotations": [], } ], "role": "assistant", "type": "message", }, { "content": [{"type": "input_text", "text": "Thanks!"}], "role": "user", "type": "message", }, ], "tools": [], }, } ) async def test_openai_responses_generation_kwargs(): with respx.mock(base_url="https://api.openai.com") as mock: mock.post("/v1/responses").mock(return_value=Response(200, json=make_response())) provider = OpenAIResponses( model="gpt-4.1", api_key="test-key", stream=False ).with_generation_kwargs(temperature=0.7, max_output_tokens=2048) stream = await provider.generate("", [], [Message(role="user", content="Hi")]) async for _ in stream: pass body = json.loads(mock.calls.last.request.content.decode()) assert (body["temperature"], body["max_output_tokens"]) == snapshot((0.7, 2048)) async def test_openai_responses_omits_reasoning_by_default(): with respx.mock(base_url="https://api.openai.com") as mock: mock.post("/v1/responses").mock(return_value=Response(200, json=make_response())) provider = OpenAIResponses(model="gpt-4.1", api_key="test-key", stream=False) stream = await provider.generate("", [], [Message(role="user", content="Hi")]) async for _ in stream: pass body = json.loads(mock.calls.last.request.content.decode()) assert "reasoning" not in body assert "include" not in body async def test_openai_responses_with_thinking_off_omits_reasoning(): """with_thinking("off") should also omit reasoning from the request, since thinking_effort_to_reasoning_effort("off") returns None.""" with respx.mock(base_url="https://api.openai.com") as mock: mock.post("/v1/responses").mock(return_value=Response(200, json=make_response())) provider = OpenAIResponses(model="gpt-4.1", api_key="test-key", stream=False).with_thinking( "off" ) stream = await provider.generate("", [], [Message(role="user", content="Hi")]) async for _ in stream: pass body = json.loads(mock.calls.last.request.content.decode()) assert "reasoning" not in body assert "include" not in body async def test_openai_responses_with_thinking_low(): """with_thinking("low") should send reasoning with effort="low".""" with respx.mock(base_url="https://api.openai.com") as mock: mock.post("/v1/responses").mock(return_value=Response(200, json=make_response())) provider = OpenAIResponses(model="gpt-4.1", api_key="test-key", stream=False).with_thinking( "low" ) stream = await provider.generate("", [], [Message(role="user", content="Think")]) async for _ in stream: pass body = json.loads(mock.calls.last.request.content.decode()) assert body["reasoning"] == snapshot({"effort": "low", "summary": "auto"}) assert body["include"] == snapshot(["reasoning.encrypted_content"]) async def test_openai_responses_with_thinking(): with respx.mock(base_url="https://api.openai.com") as mock: mock.post("/v1/responses").mock(return_value=Response(200, json=make_response())) provider = OpenAIResponses(model="gpt-4.1", api_key="test-key", stream=False).with_thinking( "high" ) stream = await provider.generate("", [], [Message(role="user", content="Think")]) async for _ in stream: pass body = json.loads(mock.calls.last.request.content.decode()) assert body["reasoning"] == snapshot({"effort": "high", "summary": "auto"}) ================================================ FILE: packages/kosong/tests/test_chat_provider.py ================================================ import asyncio from kosong.chat_provider import APIStatusError, StreamedMessagePart from kosong.chat_provider.chaos import ChaosChatProvider, ChaosConfig from kosong.chat_provider.kimi import Kimi from kosong.chat_provider.mock import MockChatProvider from kosong.message import Message, TextPart def test_mock_chat_provider(): input_parts: list[StreamedMessagePart] = [ TextPart(text="Hello, world!"), ] async def generate() -> list[StreamedMessagePart]: chat_provider = MockChatProvider(message_parts=input_parts) parts: list[StreamedMessagePart] = [] async for part in await chat_provider.generate(system_prompt="", tools=[], history=[]): parts.append(part) return parts output_parts = asyncio.run(generate()) assert output_parts == input_parts async def test_chaos_chat_provider(): base = Kimi(model="dummy", api_key="sk-1234567890") chat_provider = ChaosChatProvider( base, chaos_config=ChaosConfig(error_probability=1.0), ) for _ in range(3): try: parts: list[StreamedMessagePart] = [] async for part in await chat_provider.generate( system_prompt="", tools=[], history=[Message(role="user", content=[TextPart(text="Hello, world!")])], ): parts.append(part) raise AssertionError("Expected APIStatusError") except APIStatusError: pass ================================================ FILE: packages/kosong/tests/test_context.py ================================================ import asyncio from pathlib import Path from kosong.contrib.context.linear import JsonlLinearStorage, LinearContext, MemoryLinearStorage from kosong.message import Message def test_linear_context(): context = LinearContext( storage=MemoryLinearStorage(), ) assert context.history == [] async def run(): await context.add_message(Message(role="user", content="abc")) await context.add_message(Message(role="assistant", content="def")) return context.history history = asyncio.run(run()) assert history == [ Message(role="user", content="abc"), Message(role="assistant", content="def"), ] def test_linear_context_with_jsonl_storage(): test_path = Path(__file__).parent / "test.jsonl" if test_path.exists(): test_path.unlink() async def run(): storage = JsonlLinearStorage(path=test_path) context = LinearContext( storage=storage, ) await context.add_message(Message(role="user", content="abc")) await context.add_message(Message(role="assistant", content="def")) return context.history history = asyncio.run(run()) assert history == [ Message(role="user", content="abc"), Message(role="assistant", content="def"), ] with open(test_path) as f: expected = """\ {"role":"user","content":"abc"} {"role":"assistant","content":"def"} """ assert f.read() == expected test_path.unlink() ================================================ FILE: packages/kosong/tests/test_echo_chat_provider.py ================================================ import pytest from kosong import generate from kosong.chat_provider import ChatProviderError, StreamedMessagePart, TokenUsage from kosong.chat_provider.echo import EchoChatProvider from kosong.message import ( AudioURLPart, ImageURLPart, Message, TextPart, ThinkPart, ToolCall, ToolCallPart, VideoURLPart, ) async def test_echo_chat_provider_streams_parts(): dsl = "\n".join( [ "id: echo-42", 'usage: {"input_other": 10, "output": 2, "input_cache_read": 3}', "text: Hello,", "text: world!", "think: thinking...", 'image_url: {"url": "https://example.com/image.png", "id": "img-1"}', "audio_url: https://example.com/audio.mp3", "video_url: https://example.com/video.mp4", ( 'tool_call: {"id": "call-1", "name": "search", ' '"arguments": "{\\"q\\":\\"python\\"", "extras": {"source": "test"}}' ), 'tool_call_part: {"arguments_part": "}"}', ] ) provider = EchoChatProvider() history = [Message(role="user", content=dsl)] parts: list[StreamedMessagePart] = [] stream = await provider.generate(system_prompt="", tools=[], history=history) async for part in stream: parts.append(part) assert stream.id == "echo-42" assert stream.usage == TokenUsage( input_other=10, output=2, input_cache_read=3, input_cache_creation=0, ) assert parts == [ TextPart(text="Hello,"), TextPart(text=" world!"), ThinkPart(think="thinking...", encrypted=None), ImageURLPart( image_url=ImageURLPart.ImageURL(url="https://example.com/image.png", id="img-1") ), AudioURLPart(audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3", id=None)), VideoURLPart(video_url=VideoURLPart.VideoURL(url="https://example.com/video.mp4", id=None)), ToolCall( id="call-1", function=ToolCall.FunctionBody(name="search", arguments='{"q":"python"'), extras={"source": "test"}, ), ToolCallPart(arguments_part="}"), ] async def test_echo_chat_provider_with_generate_merge_tool_call(): dsl = """ text: Hello tool_call: {"id": "tc-1", "name": "get_weather", "arguments": null} tool_call_part: {"arguments_part": "{"} tool_call_part: {"arguments_part": "\\"city\\":\\"Hangzhou\\""} tool_call_part: {"arguments_part": "}"} tool_call_part: """ provider = EchoChatProvider() history = [Message(role="user", content=dsl)] result = await generate( chat_provider=provider, system_prompt="", tools=[], history=history, ) message = result.message assert message.content == [TextPart(text="Hello")] assert message.tool_calls == [ ToolCall( id="tc-1", function=ToolCall.FunctionBody(name="get_weather", arguments='{"city":"Hangzhou"}'), ) ] assert result.usage is None async def test_echo_chat_provider_rejects_non_string_arguments(): dsl = """ tool_call: {"id": "call-1", "name": "search", "arguments": {"q": "python"}} """ provider = EchoChatProvider() history = [Message(role="user", content=dsl)] with pytest.raises(ChatProviderError): await provider.generate(system_prompt="", tools=[], history=history) async def test_echo_chat_provider_requires_user_message(): provider = EchoChatProvider() history = [Message(role="tool", content="tool output")] with pytest.raises(ChatProviderError): await provider.generate(system_prompt="", tools=[], history=history) async def test_echo_chat_provider_requires_dsl_content(): provider = EchoChatProvider() history = [Message(role="user", content="")] with pytest.raises(ChatProviderError): await provider.generate(system_prompt="", tools=[], history=history) ================================================ FILE: packages/kosong/tests/test_generate.py ================================================ import asyncio from copy import deepcopy from kosong import generate from kosong.chat_provider import StreamedMessagePart from kosong.chat_provider.mock import MockChatProvider from kosong.message import ImageURLPart, TextPart, ToolCall, ToolCallPart def test_generate(): chat_provider = MockChatProvider( message_parts=[ TextPart(text="Hello, "), TextPart(text="world"), TextPart(text="!"), ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.png")), TextPart(text="Another text."), TextPart(text=""), ToolCall( id="get_weather#123", function=ToolCall.FunctionBody(name="get_weather", arguments=None), ), ToolCallPart(arguments_part="{"), ToolCallPart(arguments_part='"city":'), ToolCallPart(arguments_part='"Beijing"'), ToolCallPart(arguments_part="}"), ToolCallPart(arguments_part=None), ] ) message = asyncio.run(generate(chat_provider, system_prompt="", tools=[], history=[])).message assert message.content == [ TextPart(text="Hello, world!"), ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.png")), TextPart(text="Another text."), ] assert message.tool_calls == [ ToolCall( id="get_weather#123", function=ToolCall.FunctionBody(name="get_weather", arguments='{"city":"Beijing"}'), ), ] def test_generate_with_callbacks(): input_parts: list[StreamedMessagePart] = [ TextPart(text="Hello, "), TextPart(text="world"), TextPart(text="!"), ToolCall( id="get_weather#123", function=ToolCall.FunctionBody(name="get_weather", arguments=None), ), ToolCallPart(arguments_part="{"), ToolCallPart(arguments_part='"city":'), ToolCallPart(arguments_part='"Beijing"'), ToolCallPart(arguments_part="}"), ToolCall( id="get_time#123", function=ToolCall.FunctionBody(name="get_time", arguments=""), ), ] chat_provider = MockChatProvider(message_parts=deepcopy(input_parts)) output_parts: list[StreamedMessagePart] = [] output_tool_calls: list[ToolCall] = [] async def on_message_part(part: StreamedMessagePart): output_parts.append(part) async def on_tool_call(tool_call: ToolCall): output_tool_calls.append(tool_call) message = asyncio.run( generate( chat_provider, system_prompt="", tools=[], history=[], on_message_part=on_message_part, on_tool_call=on_tool_call, ) ).message assert output_parts == input_parts assert output_tool_calls == message.tool_calls ================================================ FILE: packages/kosong/tests/test_json_schema_deref.py ================================================ from __future__ import annotations from typing import Literal from inline_snapshot import snapshot from pydantic import BaseModel, Field from kosong.utils.jsonschema import deref_json_schema from kosong.utils.typing import JsonType JsonSchema = dict[str, JsonType] def test_no_ref(): class Params(BaseModel): id: str = Field(description="The ID of the action.") action: str = Field(description="The action to be performed.") resolved = deref_json_schema(Params.model_json_schema()) assert resolved == snapshot( { "properties": { "id": {"description": "The ID of the action.", "title": "Id", "type": "string"}, "action": { "description": "The action to be performed.", "title": "Action", "type": "string", }, }, "required": ["id", "action"], "title": "Params", "type": "object", } ) def test_simple_ref(): class Todo(BaseModel): title: str = Field(description="The title of the todo item.") status: Literal["pending", "completed"] = Field(description="The status of the todo item.") class Params(BaseModel): todos: list[Todo] = Field(description="A list of todo items.") resolved = deref_json_schema(Params.model_json_schema()) assert resolved == snapshot( { "properties": { "todos": { "description": "A list of todo items.", "items": { "properties": { "title": { "description": "The title of the todo item.", "title": "Title", "type": "string", }, "status": { "description": "The status of the todo item.", "enum": ["pending", "completed"], "title": "Status", "type": "string", }, }, "required": ["title", "status"], "title": "Todo", "type": "object", }, "title": "Todos", "type": "array", } }, "required": ["todos"], "title": "Params", "type": "object", } ) def test_nested_ref(): class Address(BaseModel): street: str = Field(description="The street address.") city: str = Field(description="The city.") zip_code: str = Field(description="The ZIP code.") class User(BaseModel): name: str = Field(description="The name of the user.") email: str = Field(description="The email of the user.") address: Address = Field(description="The address of the user.") class Params(BaseModel): users: list[User] = Field(description="A list of users.") resolved = deref_json_schema(Params.model_json_schema()) assert resolved == snapshot( { "properties": { "users": { "description": "A list of users.", "items": { "properties": { "name": { "description": "The name of the user.", "title": "Name", "type": "string", }, "email": { "description": "The email of the user.", "title": "Email", "type": "string", }, "address": { "description": "The address of the user.", "properties": { "street": { "description": "The street address.", "title": "Street", "type": "string", }, "city": { "description": "The city.", "title": "City", "type": "string", }, "zip_code": { "description": "The ZIP code.", "title": "Zip Code", "type": "string", }, }, "required": ["street", "city", "zip_code"], "title": "Address", "type": "object", }, }, "required": ["name", "email", "address"], "title": "User", "type": "object", }, "title": "Users", "type": "array", } }, "required": ["users"], "title": "Params", "type": "object", } ) ================================================ FILE: packages/kosong/tests/test_kimi_stream_usage.py ================================================ from openai.types.chat import ChatCompletionChunk from kosong.chat_provider.kimi import extract_usage_from_chunk def test_kimi_extracts_choice_usage_in_stream_chunk() -> None: chunk = ChatCompletionChunk.model_validate( { "id": "chatcmpl-6970b5d02fa474c1767e8767", "object": "chat.completion.chunk", "created": 1768994256, "model": "kimi-k2-turbo-preview", "choices": [ { "index": 0, "delta": {}, "finish_reason": "stop", "usage": { "prompt_tokens": 8, "completion_tokens": 11, "total_tokens": 19, "cached_tokens": 8, }, } ], "system_fingerprint": "fpv0_10a6da87", } ) usage = extract_usage_from_chunk(chunk) assert usage is not None assert usage.prompt_tokens == 8 assert usage.completion_tokens == 11 assert usage.total_tokens == 19 ================================================ FILE: packages/kosong/tests/test_message.py ================================================ from inline_snapshot import snapshot from kosong.message import ( AudioURLPart, ImageURLPart, Message, TextPart, ThinkPart, ToolCall, VideoURLPart, ) def test_plain_text_message(): message = Message(role="user", content="Hello, world!") dumped = message.model_dump(exclude_none=True) assert dumped == snapshot({"role": "user", "content": "Hello, world!"}) assert Message.model_validate(dumped) == message def test_message_with_single_part(): message = Message( role="assistant", content=ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.png")), ) dumped = message.model_dump(exclude_none=True) assert dumped == snapshot( { "role": "assistant", "content": [ { "type": "image_url", "image_url": {"url": "https://example.com/image.png", "id": None}, } ], } ) assert Message.model_validate(dumped) == message def test_message_with_tool_calls(): message = Message( role="assistant", content=[TextPart(text="Hello, world!")], tool_calls=[ ToolCall(id="123", function=ToolCall.FunctionBody(name="function", arguments="{}")) ], ) dumped = message.model_dump(exclude_none=True) assert dumped == snapshot( { "role": "assistant", "content": "Hello, world!", "tool_calls": [ { "type": "function", "id": "123", "function": {"name": "function", "arguments": "{}"}, } ], } ) assert Message.model_validate(dumped) == message def test_message_with_no_content(): message = Message( role="assistant", content=[], tool_calls=[ ToolCall(id="123", function=ToolCall.FunctionBody(name="function", arguments="{}")) ], ) assert message.model_dump(exclude_none=True) == snapshot( { "role": "assistant", "content": [], "tool_calls": [ { "type": "function", "id": "123", "function": {"name": "function", "arguments": "{}"}, } ], } ) def test_message_with_complex_content(): message = Message( role="user", content=[ TextPart(text="Hello, world!"), ThinkPart(think="I think I need to think about this."), ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.png")), AudioURLPart(audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3")), VideoURLPart(video_url=VideoURLPart.VideoURL(url="https://example.com/video.mp4")), ], tool_calls=[ ToolCall(id="123", function=ToolCall.FunctionBody(name="function", arguments="{}")), ], ) dumped = message.model_dump(exclude_none=True) assert dumped == snapshot( { "role": "user", "content": [ {"type": "text", "text": "Hello, world!"}, { "type": "think", "think": "I think I need to think about this.", "encrypted": None, }, { "type": "image_url", "image_url": {"url": "https://example.com/image.png", "id": None}, }, { "type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3", "id": None}, }, { "type": "video_url", "video_url": {"url": "https://example.com/video.mp4", "id": None}, }, ], "tool_calls": [ { "type": "function", "id": "123", "function": {"name": "function", "arguments": "{}"}, } ], } ) assert Message.model_validate(dumped) == message def test_deserialize_from_json_plain_text(): data = { "role": "user", "content": "Hello, world!", } message = Message.model_validate(data) assert message == snapshot(Message(role="user", content=[TextPart(text="Hello, world!")])) def test_deserialize_from_json_with_content_and_tool_calls(): data = { "role": "assistant", "content": [ { "type": "text", "text": "Hello, world!", } ], "tool_calls": [ { "type": "function", "id": "tc_123", "function": {"name": "do_something", "arguments": '{"x":1}'}, } ], } message = Message.model_validate(data) assert message == snapshot( Message( role="assistant", content=[TextPart(text="Hello, world!")], tool_calls=[ ToolCall( id="tc_123", function=ToolCall.FunctionBody(name="do_something", arguments='{"x":1}'), ) ], ) ) def test_deserialize_from_json_none_content_with_tool_calls(): data = { "role": "assistant", "content": None, "tool_calls": [ { "type": "function", "id": "tc_456", "function": {"name": "do_other", "arguments": "{}"}, } ], } message = Message.model_validate(data) assert message == snapshot( Message( role="assistant", content=[], tool_calls=[ ToolCall( id="tc_456", function=ToolCall.FunctionBody(name="do_other", arguments="{}") ) ], ) ) def test_deserialize_from_json_with_content_but_no_tool_calls(): data = { "role": "user", "content": [ { "type": "text", "text": "Only content, no tools.", } ], } message = Message.model_validate(data) assert message == snapshot( Message(role="user", content=[TextPart(text="Only content, no tools.")]) ) def test_message_with_empty_list_content(): """Test that content=[] serializes to None and deserializes back to [].""" # Create message with empty list content message = Message(role="assistant", content=[]) # Serialize - empty list should become None dumped = message.model_dump() assert dumped == snapshot( { "role": "assistant", "name": None, "content": [], "tool_calls": None, "tool_call_id": None, "partial": None, } ) # Deserialize back - None should become empty list assert Message.model_validate(dumped) == snapshot(Message(role="assistant", content=[])) # Test with tool_calls message_with_tools = Message( role="assistant", content=[], tool_calls=[ ToolCall(id="123", function=ToolCall.FunctionBody(name="test_func", arguments="{}")) ], ) dumped = message_with_tools.model_dump() assert dumped == snapshot( { "role": "assistant", "name": None, "content": [], "tool_calls": [ { "type": "function", "id": "123", "function": {"name": "test_func", "arguments": "{}"}, "extras": None, } ], "tool_call_id": None, "partial": None, } ) assert Message.model_validate(dumped) == snapshot( Message( role="assistant", content=[], tool_calls=[ ToolCall(id="123", function=ToolCall.FunctionBody(name="test_func", arguments="{}")) ], ) ) def test_message_extract_text(): message = Message( role="user", content=[ TextPart(text="Hello, "), TextPart(text="world"), ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.png")), TextPart(text="!"), ThinkPart(think="This is a thought."), ], ) extracted_text = message.extract_text() assert extracted_text == snapshot("Hello, world!") extracted_text = message.extract_text(sep="\n") assert extracted_text == snapshot("""\ Hello, \n\ world !\ """) ================================================ FILE: packages/kosong/tests/test_openai_common.py ================================================ import asyncio from typing import Any import httpx import pytest from kosong.chat_provider import APIConnectionError, openai_common from kosong.contrib.chat_provider.openai_legacy import OpenAILegacy def test_create_openai_client_does_not_inject_max_retries(monkeypatch: pytest.MonkeyPatch) -> None: captured: dict[str, Any] = {} class FakeAsyncOpenAI: def __init__(self, **kwargs: Any) -> None: captured.update(kwargs) monkeypatch.setattr(openai_common, "AsyncOpenAI", FakeAsyncOpenAI) openai_common.create_openai_client( api_key="test-key", base_url="https://example.com/v1", client_kwargs={"timeout": 3}, ) assert captured["api_key"] == "test-key" assert captured["base_url"] == "https://example.com/v1" assert captured["timeout"] == 3 assert "max_retries" not in captured @pytest.mark.asyncio async def test_retry_recovery_does_not_close_shared_http_client() -> None: http_client = httpx.AsyncClient() provider = OpenAILegacy( model="gpt-4.1", api_key="test-key", http_client=http_client, ) provider.on_retryable_error(APIConnectionError("Connection error.")) await asyncio.sleep(0) await asyncio.sleep(0) assert provider.client._client is http_client # type: ignore[reportPrivateUsage] assert http_client.is_closed is False await http_client.aclose() ================================================ FILE: packages/kosong/tests/test_scripted_echo_chat_provider.py ================================================ import pytest from kosong import generate from kosong.chat_provider import ChatProviderError, StreamedMessagePart, TokenUsage from kosong.chat_provider.echo import ScriptedEchoChatProvider from kosong.message import ( AudioURLPart, ImageURLPart, Message, TextPart, ThinkPart, ToolCall, ToolCallPart, VideoURLPart, ) async def test_scripted_echo_chat_provider_streams_parts(): dsl = "\n".join( [ "id: scripted-1", 'usage: {"input_other": 4, "output": 1, "input_cache_read": 2}', "text: Hello,", "text: world!", "think: thinking...", 'image_url: {"url": "https://example.com/image.png", "id": "img-1"}', "audio_url: https://example.com/audio.mp3", "video_url: https://example.com/video.mp4", ( 'tool_call: {"id": "call-1", "name": "search", ' '"arguments": "{\\"q\\":\\"python\\"", "extras": {"source": "test"}}' ), 'tool_call_part: {"arguments_part": "}"}', ] ) second_dsl = "\n".join( [ "id: scripted-2", "text: second turn", ] ) provider = ScriptedEchoChatProvider([dsl, second_dsl]) history = [Message(role="tool", content="tool output")] parts: list[StreamedMessagePart] = [] stream = await provider.generate(system_prompt="", tools=[], history=history) async for part in stream: parts.append(part) assert stream.id == "scripted-1" assert stream.usage == TokenUsage( input_other=4, output=1, input_cache_read=2, input_cache_creation=0, ) assert parts == [ TextPart(text="Hello,"), TextPart(text=" world!"), ThinkPart(think="thinking...", encrypted=None), ImageURLPart( image_url=ImageURLPart.ImageURL(url="https://example.com/image.png", id="img-1") ), AudioURLPart(audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3", id=None)), VideoURLPart(video_url=VideoURLPart.VideoURL(url="https://example.com/video.mp4", id=None)), ToolCall( id="call-1", function=ToolCall.FunctionBody(name="search", arguments='{"q":"python"'), extras={"source": "test"}, ), ToolCallPart(arguments_part="}"), ] second_stream = await provider.generate(system_prompt="", tools=[], history=[]) second_parts = [part async for part in second_stream] assert second_stream.id == "scripted-2" assert second_stream.usage is None assert second_parts == [TextPart(text="second turn")] async def test_scripted_echo_chat_provider_exhausted(): provider = ScriptedEchoChatProvider(["text: only once"]) await provider.generate(system_prompt="", tools=[], history=[]) with pytest.raises(ChatProviderError): await provider.generate(system_prompt="", tools=[], history=[]) async def test_scripted_echo_chat_provider_with_generate_merge_tool_call(): dsl = """ text: Hello tool_call: {"id": "tc-1", "name": "get_weather", "arguments": null} tool_call_part: {"arguments_part": "{"} tool_call_part: {"arguments_part": "\\"city\\":\\"Hangzhou\\""} tool_call_part: {"arguments_part": "}"} tool_call_part: """ provider = ScriptedEchoChatProvider([dsl]) history = [Message(role="tool", content="tool output")] result = await generate( chat_provider=provider, system_prompt="", tools=[], history=history, ) message = result.message assert message.content == [TextPart(text="Hello")] assert message.tool_calls == [ ToolCall( id="tc-1", function=ToolCall.FunctionBody(name="get_weather", arguments='{"city":"Hangzhou"}'), ) ] assert result.usage is None async def test_scripted_echo_chat_provider_rejects_non_string_arguments(): dsl = """ tool_call: {"id": "call-1", "name": "search", "arguments": {"q": "python"}} """ provider = ScriptedEchoChatProvider([dsl]) with pytest.raises(ChatProviderError): await provider.generate(system_prompt="", tools=[], history=[]) async def test_scripted_echo_chat_provider_requires_dsl_content(): provider = ScriptedEchoChatProvider(["# comment only\n```"]) with pytest.raises(ChatProviderError): await provider.generate(system_prompt="", tools=[], history=[]) ================================================ FILE: packages/kosong/tests/test_step.py ================================================ import asyncio from typing import override from kosong import step from kosong.chat_provider import StreamedMessagePart from kosong.chat_provider.mock import MockChatProvider from kosong.message import TextPart, ToolCall from kosong.tooling import CallableTool, ParametersType, ToolOk, ToolResult, ToolReturnValue from kosong.tooling.simple import SimpleToolset def test_step(): class PlusTool(CallableTool): name: str = "plus" description: str = "This is a plus tool" parameters: ParametersType = { "type": "object", "properties": { "a": {"type": "integer"}, "b": {"type": "integer"}, }, } @override async def __call__(self, a: int, b: int) -> ToolReturnValue: return ToolOk(output=str(a + b)) plus_tool_call = ToolCall( id="plus#123", function=ToolCall.FunctionBody(name="plus", arguments='{"a": 1, "b": 2}'), ) input_parts: list[StreamedMessagePart] = [ TextPart(text="Hello, world!"), plus_tool_call, ] chat_provider = MockChatProvider(message_parts=input_parts) toolset = SimpleToolset([PlusTool()]) output_parts: list[StreamedMessagePart] = [] collected_tool_results: list[ToolResult] = [] def on_message_part(part: StreamedMessagePart): output_parts.append(part) def on_tool_result(result: ToolResult): collected_tool_results.append(result) async def run(): step_result = await step( chat_provider, system_prompt="", toolset=toolset, history=[], on_message_part=on_message_part, on_tool_result=on_tool_result, ) tool_results = await step_result.tool_results() return step_result, tool_results step_result, tool_results = asyncio.run(run()) assert step_result.message.content == [TextPart(text="Hello, world!")] assert step_result.tool_calls == [plus_tool_call] assert output_parts == input_parts assert tool_results == [ToolResult(tool_call_id="plus#123", return_value=ToolOk(output="3"))] assert collected_tool_results == tool_results ================================================ FILE: packages/kosong/tests/test_tool_call.py ================================================ import asyncio import inspect import json from typing import override from inline_snapshot import snapshot from pydantic import BaseModel, Field from kosong.message import ToolCall from kosong.tooling import ( BriefDisplayBlock, CallableTool, CallableTool2, ParametersType, ToolError, ToolOk, ToolResult, ToolResultFuture, ToolReturnValue, ) from kosong.tooling.error import ( ToolNotFoundError, ToolParseError, ToolRuntimeError, ToolValidateError, ) from kosong.tooling.simple import SimpleToolset def test_callable_tool_int_argument(): class TestTool(CallableTool): name: str = "test" description: str = "This is a test tool" parameters: ParametersType = { "type": "integer", } @override async def __call__(self, test: int) -> ToolReturnValue: return ToolOk(output=f"Test tool called with {test}") tool = TestTool() assert asyncio.run(tool.call(1)) == ToolOk(output="Test tool called with 1") def test_callable_tool_list_argument(): class TestTool(CallableTool): name: str = "test" description: str = "This is a test tool" parameters: ParametersType = { "type": "array", "items": { "type": "string", }, } @override async def __call__(self, a: str, b: str) -> ToolReturnValue: return ToolOk(output="Test tool called with a and b") tool = TestTool() assert asyncio.run(tool.call(["a", "b"])) == ToolOk(output="Test tool called with a and b") def test_callable_tool_dict_argument(): class TestTool(CallableTool): name: str = "test" description: str = "This is a test tool" parameters: ParametersType = { "type": "object", "properties": { "a": {"type": "string"}, "b": {"type": "integer"}, }, } @override async def __call__(self, a: str, b: int) -> ToolReturnValue: return ToolOk(output=f"Test tool called with {a} and {b}") tool = TestTool() assert asyncio.run(tool.call({"a": "a", "b": 1})) == ToolOk( output="Test tool called with a and 1" ) def test_simple_toolset(): class PlusTool(CallableTool): name: str = "plus" description: str = "This is a plus tool" parameters: ParametersType = { "type": "object", "properties": { "a": {"type": "integer"}, "b": {"type": "integer"}, }, "required": ["a", "b"], } @override async def __call__(self, a: int, b: int) -> ToolReturnValue: return ToolOk(output=str(a + b)) class CompareTool(CallableTool): name: str = "compare" description: str = "This is a compare tool" parameters: ParametersType = { "type": "object", "properties": { "a": {"type": "integer"}, "b": {"type": "integer"}, }, "required": ["a", "b"], } @override async def __call__(self, a: int, b: int) -> ToolReturnValue: return ToolOk(output="greater" if a > b else "less" if a < b else "equal") class RaiseTool(CallableTool): name: str = "raise" description: str = "This is a raise tool" parameters: ParametersType = { "type": "object", "properties": {}, } @override async def __call__(self) -> ToolReturnValue: raise Exception("test exception") class ErrorTool(CallableTool): name: str = "error" description: str = "This is a error tool" parameters: ParametersType = { "type": "object", "properties": {}, } @override async def __call__(self) -> ToolReturnValue: return ToolError(message="test error", brief="Error") class InvalidReturnTypeTool(CallableTool): name: str = "invalid_return_type" description: str = "This is a invalid return type tool" parameters: ParametersType = { "type": "object", "properties": {}, } @override async def __call__(self) -> str: # type: ignore[reportIncompatibleMethodOverride] return "invalid return type" toolset = SimpleToolset([PlusTool()]) toolset += CompareTool() toolset += RaiseTool() toolset.add(ErrorTool()) assert toolset.tools[0].name == "plus" assert toolset.tools[1].name == "compare" assert toolset.tools[2].name == "raise" assert toolset.tools[3].name == "error" try: toolset += InvalidReturnTypeTool() except TypeError as e: assert str(e) == ( "Expected tool `invalid_return_type` to return `ToolReturnValue`, " "but got ``" ) else: raise AssertionError("Expected TypeError") tool_calls = [ ToolCall( id="1", function=ToolCall.FunctionBody( name="plus", arguments=json.dumps({"a": 1, "b": 2}), ), ), ToolCall( id="2", function=ToolCall.FunctionBody( name="compare", arguments='{"a": 1, b: 2}', ), ), ToolCall( id="3", function=ToolCall.FunctionBody( name="plus", arguments='{"a": 1}', ), ), ToolCall( id="4", function=ToolCall.FunctionBody( name="raise", arguments=None, ), ), ToolCall( id="5", function=ToolCall.FunctionBody( name="not_found", arguments=None, ), ), ToolCall( id="6", function=ToolCall.FunctionBody( name="error", arguments=None, ), ), ] async def run() -> list[ToolResult]: futures: list[ToolResultFuture] = [] for tool_call in tool_calls: result = toolset.handle(tool_call) if isinstance(result, ToolResult): future = ToolResultFuture() future.set_result(result) futures.append(future) else: futures.append(result) return await asyncio.gather(*futures) results = asyncio.run(run()) assert results[0].tool_call_id == "1" assert results[0].return_value == ToolOk(output="3") assert isinstance(results[1].return_value, ToolParseError) assert isinstance(results[2].return_value, ToolValidateError) assert isinstance(results[3].return_value, ToolRuntimeError) assert isinstance(results[4].return_value, ToolNotFoundError) assert isinstance(results[5].return_value, ToolError) assert results[5].return_value.message == "test error" assert results[5].return_value.display == snapshot([BriefDisplayBlock(text="Error")]) def test_callable_tool_2(): class TestParams(BaseModel): a: int = Field(description="The first argument") b: int = Field(default=0, description="The second argument") c: str = Field(default="", alias="-c", description="The third argument") class TestTool(CallableTool2[TestParams]): name: str = "test" description: str = "This is a test tool" params: type[TestParams] = TestParams @override async def __call__(self, params: TestParams) -> ToolReturnValue: return ToolOk(output=f"Test tool called with {params.a} and {params.b}") tool = TestTool() assert tool.base.name == "test" assert tool.base.description == "This is a test tool" assert tool.base.parameters == { "type": "object", "properties": { "a": {"type": "integer", "description": "The first argument"}, "b": {"type": "integer", "description": "The second argument", "default": 0}, "-c": {"type": "string", "description": "The third argument", "default": ""}, }, "required": ["a"], } assert asyncio.run(tool.call({"a": 1, "b": 2})) == ToolOk( output="Test tool called with 1 and 2" ) assert asyncio.run(tool.call({"a": 1})) == ToolOk(output="Test tool called with 1 and 0") assert isinstance(asyncio.run(tool.call({"b": 2})), ToolValidateError) def test_simple_toolset_sub(): class TestParams(BaseModel): pass class TestTool(CallableTool2[TestParams]): name: str = "test" description: str = "This is a test tool" params: type[TestParams] = TestParams @override async def __call__(self, params: TestParams) -> ToolReturnValue: return ToolOk(output="Test tool called") toolset = SimpleToolset([TestTool()]) assert len(toolset.tools) == 1 toolset.remove(TestTool.name) assert len(toolset.tools) == 0 # Tests for both real type and string annotations support # These tests verify that SimpleToolset works correctly in both scenarios: # 1. When type annotations are actual type objects (normal case) # 2. When type annotations are strings (with `from __future__ import annotations`) def test_simple_toolset_with_real_type_annotation_callable_tool(): """Test that SimpleToolset works with CallableTool when using real type annotation.""" class TestTool(CallableTool): name: str = "test_real" description: str = "This is a test tool" parameters: ParametersType = { "type": "object", "properties": {}, } @override async def __call__(self) -> ToolReturnValue: return ToolOk(output="test") # Verify the annotation is actually a type (not string) assert inspect.signature(TestTool().__call__).return_annotation is ToolReturnValue toolset = SimpleToolset() toolset += TestTool() assert len(toolset.tools) == 1 assert toolset.tools[0].name == "test_real" def test_simple_toolset_with_string_annotation_callable_tool(): """Test that SimpleToolset works with CallableTool when using string annotation.""" class TestTool(CallableTool): name: str = "test_str" description: str = "This is a test tool" parameters: ParametersType = { "type": "object", "properties": {}, } @override async def __call__(self) -> "ToolReturnValue": # type: ignore[reportIncompatibleMethodOverride] return ToolOk(output="test") # Verify the annotation is actually a string assert isinstance(inspect.signature(TestTool().__call__).return_annotation, str) toolset = SimpleToolset() toolset += TestTool() assert len(toolset.tools) == 1 assert toolset.tools[0].name == "test_str" def test_simple_toolset_with_invalid_string_annotation_rejected(): """Test that SimpleToolset rejects invalid string annotations.""" class TestTool(CallableTool): name: str = "test_invalid" description: str = "This is a test tool" parameters: ParametersType = { "type": "object", "properties": {}, } @override async def __call__(self) -> "InvalidType": # noqa: F821 # type: ignore[reportUnknownParameterType] return ToolOk(output="test") # type: ignore[return-value] tool_instance = TestTool() sig = inspect.signature(tool_instance.__call__) # type: ignore[reportUnknownMemberType, reportUnknownArgumentType] # Verify the annotation is actually a string assert isinstance(sig.return_annotation, str) toolset = SimpleToolset() try: toolset += TestTool() raise AssertionError("Expected TypeError for invalid string annotation") except TypeError as e: assert "InvalidType" in str(e) def test_simple_toolset_with_real_type_annotation_callable_tool2(): """Test that SimpleToolset works with CallableTool2 when using real type annotation.""" class TestParams(BaseModel): value: int = Field(description="A test value") class TestTool(CallableTool2[TestParams]): name: str = "test2_real" description: str = "This is a test tool 2" params: type[TestParams] = TestParams @override async def __call__(self, params: TestParams) -> ToolReturnValue: return ToolOk(output=f"value: {params.value}") # Verify the annotation is actually a type (not string) assert inspect.signature(TestTool().__call__).return_annotation is ToolReturnValue toolset = SimpleToolset() toolset += TestTool() assert len(toolset.tools) == 1 assert toolset.tools[0].name == "test2_real" def test_simple_toolset_with_string_annotation_callable_tool2(): """Test that SimpleToolset works with CallableTool2 when using string annotation.""" class TestParams(BaseModel): value: int = Field(description="A test value") class TestTool(CallableTool2[TestParams]): name: str = "test2_str" description: str = "This is a test tool 2" params: type[TestParams] = TestParams @override async def __call__(self, params: TestParams) -> "ToolReturnValue": return ToolOk(output=f"value: {params.value}") # Verify the annotation is actually a string assert isinstance(inspect.signature(TestTool().__call__).return_annotation, str) toolset = SimpleToolset() toolset += TestTool() assert len(toolset.tools) == 1 assert toolset.tools[0].name == "test2_str" async def _test_handle_async_with_string_annotation(): """Helper async function to test tool handling with string annotation.""" class TestTool(CallableTool): name: str = "add_str" description: str = "Add two numbers" parameters: ParametersType = { "type": "object", "properties": { "a": {"type": "integer"}, "b": {"type": "integer"}, }, "required": ["a", "b"], } @override async def __call__(self, a: int, b: int) -> "ToolReturnValue": return ToolOk(output=str(a + b)) # Verify the annotation is actually a string assert isinstance(inspect.signature(TestTool().__call__).return_annotation, str) toolset = SimpleToolset([TestTool()]) tool_call = ToolCall( id="1", function=ToolCall.FunctionBody( name="add_str", arguments='{"a": 2, "b": 3}', ), ) result = toolset.handle(tool_call) if asyncio.isfuture(result): result = await result return result def test_simple_toolset_with_string_annotation_handle(): """Test that tools with string annotations can be called correctly.""" result = asyncio.run(_test_handle_async_with_string_annotation()) assert result.return_value == ToolOk(output="5") ================================================ FILE: packages/kosong/tests/test_tool_result.py ================================================ from inline_snapshot import snapshot from kosong.message import ImageURLPart, TextPart from kosong.tooling import ( BriefDisplayBlock, ToolError, ToolOk, ToolReturnValue, UnknownDisplayBlock, ) from kosong.tooling.error import ToolNotFoundError def test_tool_return_value(): ret = ToolReturnValue( is_error=False, output=[ TextPart(type="text", text="output text"), ImageURLPart( type="image_url", image_url=ImageURLPart.ImageURL(url="https://example.com/image.png"), ), ], message="This is a successful tool call.", display=[ BriefDisplayBlock(text="a brief msg for user"), ], extras={"key1": "value1", "key2": 42}, ) dump = ret.model_dump(mode="json", exclude_none=True) assert dump == snapshot( { "is_error": False, "output": [ {"type": "text", "text": "output text"}, { "type": "image_url", "image_url": {"url": "https://example.com/image.png"}, }, ], "message": "This is a successful tool call.", "display": [{"type": "brief", "text": "a brief msg for user"}], "extras": {"key1": "value1", "key2": 42}, } ) assert ToolReturnValue.model_validate(dump) == ret def test_tool_ok(): ret = ToolOk( output="output text", message="This is a successful tool call.", brief="a brief msg for user", ) assert isinstance(ret, ToolReturnValue) assert ret.model_dump(mode="json", exclude_none=True) == snapshot( { "is_error": False, "output": "output text", "message": "This is a successful tool call.", "display": [{"type": "brief", "text": "a brief msg for user"}], } ) def test_tool_error(): ret = ToolError( message="This is a failed tool call.", brief="a brief error msg for user", output="error output text", ) assert isinstance(ret, ToolReturnValue) assert ret.model_dump(mode="json", exclude_none=True) == snapshot( { "is_error": True, "output": "error output text", "message": "This is a failed tool call.", "display": [{"type": "brief", "text": "a brief error msg for user"}], } ) def test_tool_ok_with_content_parts(): ret = ToolOk( output=[ TextPart(type="text", text="output text"), ImageURLPart( type="image_url", image_url=ImageURLPart.ImageURL(url="https://example.com/image.png"), ), ], message="This is a successful tool call.", brief="a brief msg for user", ) assert isinstance(ret, ToolReturnValue) assert ret.model_dump(mode="json", exclude_none=True) == snapshot( { "is_error": False, "output": [ {"type": "text", "text": "output text"}, { "type": "image_url", "image_url": {"url": "https://example.com/image.png"}, }, ], "message": "This is a successful tool call.", "display": [{"type": "brief", "text": "a brief msg for user"}], } ) def test_tool_error_subclass(): ret = ToolNotFoundError(tool_name="non_existent_tool") assert isinstance(ret, ToolReturnValue) assert isinstance(ret, ToolError) assert ret.model_dump(mode="json", exclude_none=True) == snapshot( { "is_error": True, "output": "", "message": "Tool `non_existent_tool` not found", "display": [{"type": "brief", "text": "Tool `non_existent_tool` not found"}], } ) def test_unknown_display_block(): payload = { "is_error": False, "output": "ok", "message": "done", "display": [ {"type": "fancy", "title": "Hello", "payload": {"a": 1}, "list": [1, 2]}, ], } ret = ToolReturnValue.model_validate(payload) assert ret.display == snapshot( [ UnknownDisplayBlock( type="fancy", data={"title": "Hello", "payload": {"a": 1}, "list": [1, 2]} ) ] ) assert ret.model_dump(mode="json", exclude_none=True) == snapshot( { "is_error": False, "output": "ok", "message": "done", "display": [ { "type": "fancy", "data": {"title": "Hello", "payload": {"a": 1}, "list": [1, 2]}, } ], } ) ================================================ FILE: pyproject.toml ================================================ [project] name = "kimi-cli" version = "1.24.0" description = "Kimi Code CLI is your next CLI agent." readme = "README.md" requires-python = ">=3.12" dependencies = [ "agent-client-protocol==0.8.0", "aiofiles>=24.0,<26.0", "aiohttp==3.13.3", "typer==0.21.1", "kosong[contrib]==0.45.0", # loguru stays >=0.6.0 because notify-py (via batrachian-toad) caps it at <=0.6.0 on 3.14+. "loguru>=0.6.0,<0.8", "prompt-toolkit==3.0.52", "pillow==12.1.0", "pyyaml==6.0.3", "rich==14.2.0", "ripgrepy==2.2.0", "streamingjson==0.0.5", "trafilatura==2.0.0", # lxml is used by trafilatura/htmldate/justext; keep pinned for binary wheels. "lxml==6.0.2", "tenacity==9.1.2", "fastmcp==2.12.5", "pydantic==2.12.5", "httpx[socks]==0.28.1", "pykaos==0.7.0", "batrachian-toad==0.5.23; python_version >= \"3.14\"", "tomlkit==0.14.0", "jinja2==3.1.6", "pyobjc-framework-cocoa>=12.1 ; sys_platform == 'darwin'", "fastapi>=0.115.0", "uvicorn[standard]>=0.32.0", "scalar-fastapi>=1.5.0", "websockets>=14.0", "keyring>=25.7.0", "setproctitle>=1.3.0", ] [dependency-groups] dev = [ "pyinstaller==6.18.0", "inline-snapshot[black]>=0.31.1", "pyright>=1.1.407", "ty>=0.0.9", "pytest>=9.0.2", "pytest-asyncio>=1.3.0", "ruff>=0.14.10", ] [build-system] requires = ["uv_build>=0.8.5,<0.10.0"] build-backend = "uv_build" [tool.uv.build-backend] module-name = ["kimi_cli"] source-exclude = ["examples/**/*", "tests/**/*", "src/kimi_cli/deps/**/*"] [tool.uv.workspace] members = [ "packages/kosong", "packages/kaos", "packages/kimi-code", "sdks/kimi-sdk", ] [tool.uv.sources] kosong = { workspace = true } pykaos = { workspace = true } kimi-cli = { workspace = true } [project.scripts] kimi = "kimi_cli.__main__:main" kimi-cli = "kimi_cli.__main__:main" [tool.ruff] line-length = 100 [tool.ruff.lint] select = [ "E", # pycodestyle "F", # Pyflakes "UP", # pyupgrade "B", # flake8-bugbear "SIM", # flake8-simplify "I", # isort ] [tool.ruff.lint.per-file-ignores] "tests/**/*.py" = ["E501"] "tests_e2e/**/*.py" = ["E501"] "src/kimi_cli/web/api/**/*.py" = ["B008"] # FastAPI Depends() is standard usage [tool.pyright] typeCheckingMode = "standard" pythonVersion = "3.14" include = [ "src/**/*.py", "tests/**/*.py", "tests_ai/scripts/**/*.py", "tests_e2e/**/*.py", ] strict = ["src/kimi_cli/**/*.py"] [tool.ty.environment] python-version = "3.14" [tool.ty.src] include = [ "src/**/*.py", "tests/**/*.py", "tests_ai/scripts/**/*.py", "tests_e2e/**/*.py", ] [tool.typos.files] extend-exclude = ["kimi.spec", "pyinstaller.py"] [tool.typos.default.extend-words] datas = "datas" Seeked = "Seeked" seeked = "seeked" iterm = "iterm" ================================================ FILE: pytest.ini ================================================ [pytest] asyncio_mode = auto ================================================ FILE: scripts/build_vis.py ================================================ from __future__ import annotations import os import shutil import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] VIS_DIR = ROOT / "vis" DIST_DIR = VIS_DIR / "dist" NODE_MODULES = VIS_DIR / "node_modules" STATIC_DIR = ROOT / "src" / "kimi_cli" / "vis" / "static" REQUIRED_VIS_TYPE_FILES = ( NODE_MODULES / "vite" / "client.d.ts", NODE_MODULES / "typescript" / "lib" / "typescript.d.ts", ) def has_required_vis_type_files() -> bool: return all(path.is_file() for path in REQUIRED_VIS_TYPE_FILES) def resolve_npm() -> str | None: candidates = ["npm"] if os.name == "nt": candidates.extend(["npm.cmd", "npm.exe", "npm.bat"]) for candidate in candidates: npm = shutil.which(candidate) if npm: return npm return None def check_node_version() -> bool: """Vite 7 requires Node.js ^20.19.0 || >=22.12.0.""" node = shutil.which("node") if not node: return False try: result = subprocess.run([node, "--version"], capture_output=True, text=True, check=False) version = result.stdout.strip().lstrip("v") parts = [int(x) for x in version.split(".")[:3]] major, minor = parts[0], parts[1] if len(parts) > 1 else 0 ok = (major == 20 and minor >= 19) or (major >= 22 and (major > 22 or minor >= 12)) if not ok: print( f"Node.js ^20.19.0 or >=22.12.0 required (Vite 7), found v{version}", file=sys.stderr, ) return False except Exception: pass return True def run_npm(npm: str, args: list[str]) -> int: try: result = subprocess.run([npm, *args], check=False) except FileNotFoundError: print( "npm not found or failed to execute. Install Node.js (npm) and ensure it is on PATH.", file=sys.stderr, ) return 1 return result.returncode def main() -> int: npm = resolve_npm() if npm is None: print("npm not found. Install Node.js (npm) to build the vis UI.", file=sys.stderr) return 1 if not check_node_version(): return 1 needs_install = (not NODE_MODULES.exists()) or (not has_required_vis_type_files()) if needs_install: if NODE_MODULES.exists(): print("vis dependencies are incomplete; reinstalling with devDependencies...") returncode = run_npm(npm, ["--prefix", str(VIS_DIR), "ci", "--include=dev"]) if returncode != 0: return returncode returncode = run_npm(npm, ["--prefix", str(VIS_DIR), "run", "build"]) if returncode != 0: return returncode if not DIST_DIR.exists(): print("vis/dist not found after build. Check the vis build output.", file=sys.stderr) return 1 if STATIC_DIR.exists(): shutil.rmtree(STATIC_DIR) STATIC_DIR.parent.mkdir(parents=True, exist_ok=True) shutil.copytree(DIST_DIR, STATIC_DIR) print(f"Synced vis UI to {STATIC_DIR}") return 0 if __name__ == "__main__": raise SystemExit(main()) ================================================ FILE: scripts/build_web.py ================================================ from __future__ import annotations import os import shutil import subprocess import sys import tomllib from pathlib import Path ROOT = Path(__file__).resolve().parents[1] WEB_DIR = ROOT / "web" DIST_DIR = WEB_DIR / "dist" NODE_MODULES = WEB_DIR / "node_modules" STATIC_DIR = ROOT / "src" / "kimi_cli" / "web" / "static" STRICT_VERSION = os.environ.get("KIMI_WEB_STRICT_VERSION", "").lower() in {"1", "true", "yes"} REQUIRED_WEB_TYPE_FILES = ( NODE_MODULES / "vite" / "client.d.ts", NODE_MODULES / "@types" / "node" / "index.d.ts", ) def read_pyproject_version() -> str: with (ROOT / "pyproject.toml").open("rb") as handle: data = tomllib.load(handle) return str(data["project"]["version"]) def find_version_in_dist(version: str) -> bool: search_suffixes = {".js", ".css", ".html", ".map"} version_with_prefix = f"v{version}" found_plain = False for path in DIST_DIR.rglob("*"): if not path.is_file() or path.suffix not in search_suffixes: continue try: content = path.read_text(encoding="utf-8", errors="ignore") except OSError: continue if version_with_prefix in content: return True if version in content: found_plain = True return found_plain def resolve_npm() -> str | None: candidates = ["npm"] if os.name == "nt": candidates.extend(["npm.cmd", "npm.exe", "npm.bat"]) for candidate in candidates: npm = shutil.which(candidate) if npm: return npm return None def run_npm(npm: str, args: list[str]) -> int: try: result = subprocess.run([npm, *args], check=False) except FileNotFoundError: print( "npm not found or failed to execute. Install Node.js (npm) and ensure it is on PATH.", file=sys.stderr, ) return 1 return result.returncode def has_required_web_type_files() -> bool: return all(path.is_file() for path in REQUIRED_WEB_TYPE_FILES) def main() -> int: npm = resolve_npm() if npm is None: print("npm not found. Install Node.js (npm) to build the web UI.", file=sys.stderr) return 1 expected_version = read_pyproject_version() explicit_expected = os.environ.get("KIMI_WEB_EXPECT_VERSION") if explicit_expected and explicit_expected != expected_version: print( f"web version mismatch: pyproject={expected_version}, expected={explicit_expected}", file=sys.stderr, ) return 1 needs_install = (not NODE_MODULES.exists()) or (not has_required_web_type_files()) if needs_install: if NODE_MODULES.exists(): print("web dependencies are incomplete; reinstalling with devDependencies...") returncode = run_npm(npm, ["--prefix", str(WEB_DIR), "ci", "--include=dev"]) if returncode != 0: return returncode returncode = run_npm(npm, ["--prefix", str(WEB_DIR), "run", "build"]) if returncode != 0: return returncode if not DIST_DIR.exists(): print("web/dist not found after build. Check the web build output.", file=sys.stderr) return 1 if STRICT_VERSION and not find_version_in_dist(expected_version): print( f"web version not found in build output; expected version {expected_version}", file=sys.stderr, ) return 1 if STATIC_DIR.exists(): shutil.rmtree(STATIC_DIR) STATIC_DIR.parent.mkdir(parents=True, exist_ok=True) shutil.copytree(DIST_DIR, STATIC_DIR) print(f"Synced web UI to {STATIC_DIR}") return 0 if __name__ == "__main__": raise SystemExit(main()) ================================================ FILE: scripts/check_kimi_dependency_versions.py ================================================ from __future__ import annotations import argparse import re import sys import tomllib from pathlib import Path def load_project_table(pyproject_path: Path) -> dict: with pyproject_path.open("rb") as handle: data = tomllib.load(handle) project = data.get("project") if not isinstance(project, dict): raise ValueError(f"Missing [project] table in {pyproject_path}") return project def load_project_version(pyproject_path: Path) -> str: project = load_project_table(pyproject_path) version = project.get("version") if not isinstance(version, str) or not version: raise ValueError(f"Missing project.version in {pyproject_path}") return version def find_pinned_dependency(deps: list[str], name: str) -> str | None: pattern = re.compile(rf"^{re.escape(name)}(?:\[[^\]]+\])?(.+)$") for dep in deps: match = pattern.match(dep) if not match: continue spec = match.group(1) pinned = re.match(r"^==(.+)$", spec) if pinned: return pinned.group(1) return None return None def main() -> int: parser = argparse.ArgumentParser(description="Validate kimi-cli dependency versions.") parser.add_argument("--root-pyproject", type=Path, required=True) parser.add_argument("--kosong-pyproject", type=Path, required=True) parser.add_argument("--pykaos-pyproject", type=Path, required=True) args = parser.parse_args() try: root_project = load_project_table(args.root_pyproject) except ValueError as exc: print(f"error: {exc}", file=sys.stderr) return 1 deps = root_project.get("dependencies", []) if not isinstance(deps, list): print( f"error: project.dependencies must be a list in {args.root_pyproject}", file=sys.stderr, ) return 1 errors: list[str] = [] for name, pyproject_path in ( ("kosong", args.kosong_pyproject), ("pykaos", args.pykaos_pyproject), ): try: package_version = load_project_version(pyproject_path) except ValueError as exc: errors.append(str(exc)) continue pinned_version = find_pinned_dependency(deps, name) if pinned_version is None: errors.append(f"Missing pinned dependency for {name} in {args.root_pyproject}.") continue if pinned_version != package_version: errors.append( f"{name} version mismatch: root depends on {pinned_version}, " f"but {pyproject_path} has {package_version}." ) if errors: for error in errors: print(f"error: {error}", file=sys.stderr) return 1 print("ok: kimi-cli dependencies match workspace package versions") return 0 if __name__ == "__main__": raise SystemExit(main()) ================================================ FILE: scripts/check_version_tag.py ================================================ from __future__ import annotations import argparse import re import sys import tomllib from pathlib import Path def load_project_version(pyproject_path: Path) -> str: with pyproject_path.open("rb") as handle: data = tomllib.load(handle) project = data.get("project") if not isinstance(project, dict): raise ValueError(f"Missing [project] table in {pyproject_path}") version = project.get("version") if not isinstance(version, str) or not version: raise ValueError(f"Missing project.version in {pyproject_path}") return version def main() -> int: parser = argparse.ArgumentParser(description="Validate tag version against pyproject.") parser.add_argument("--pyproject", type=Path, required=True) parser.add_argument("--expected-version", required=True) args = parser.parse_args() semver_re = re.compile(r"^\d+\.\d+\.\d+$") if not semver_re.match(args.expected_version): print( f"error: expected version must include patch (x.y.z): {args.expected_version}", file=sys.stderr, ) return 1 try: project_version = load_project_version(args.pyproject) except ValueError as exc: print(f"error: {exc}", file=sys.stderr) return 1 if not semver_re.match(project_version): print( "error: project version must include patch (x.y.z): " f"{args.pyproject} has {project_version}", file=sys.stderr, ) return 1 if project_version != args.expected_version: print( "error: version mismatch: " f"{args.pyproject} has {project_version}, expected {args.expected_version}", file=sys.stderr, ) return 1 print(f"ok: {args.pyproject} matches expected version {args.expected_version}") return 0 if __name__ == "__main__": raise SystemExit(main()) ================================================ FILE: scripts/cleanup_tmp_sessions.py ================================================ #!/usr/bin/env python3 """Clean up .kimi sessions whose workdir is under a temporary directory. This script handles two cases: 1. Entries in kimi.json whose path is a tmp directory -> remove entry + session dir. 2. Orphan session directories on disk that have no matching kimi.json entry (e.g. leftover from previously cleaned entries or tests). Temporary directories are detected by checking if the path starts with common tmp prefixes: /tmp, /private/tmp, /var/folders, /private/var/folders. Usage: python scripts/cleanup_tmp_sessions.py # dry-run (default) python scripts/cleanup_tmp_sessions.py --apply # actually delete """ from __future__ import annotations import argparse import json import shutil import sys from hashlib import md5 from pathlib import Path KIMI_DIR = Path.home() / ".kimi" METADATA_FILE = KIMI_DIR / "kimi.json" SESSIONS_DIR = KIMI_DIR / "sessions" TMP_PREFIXES = ( "/tmp/", "/private/tmp/", "/var/folders/", "/private/var/folders/", ) def is_tmp_path(path: str) -> bool: """Return True if *path* looks like a temporary directory.""" if path in ("/tmp", "/private/tmp"): return True return any(path.startswith(p) for p in TMP_PREFIXES) def work_dir_hash(path: str, kaos: str = "local") -> str: h = md5(path.encode("utf-8")).hexdigest() return h if kaos == "local" else f"{kaos}_{h}" def dir_total_size(d: Path) -> int: return sum(f.stat().st_size for f in d.rglob("*") if f.is_file()) def main() -> None: parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("--apply", action="store_true", help="Actually delete (default is dry-run)") args = parser.parse_args() if not METADATA_FILE.exists(): print(f"Metadata file not found: {METADATA_FILE}") sys.exit(1) with open(METADATA_FILE, encoding="utf-8") as f: metadata = json.load(f) work_dirs: list[dict] = metadata.get("work_dirs", []) # --- Phase 1: tmp entries in kimi.json --- tmp_entries: list[dict] = [] keep_entries: list[dict] = [] keep_hashes: set[str] = set() for wd in work_dirs: if is_tmp_path(wd.get("path", "")): tmp_entries.append(wd) else: keep_entries.append(wd) keep_hashes.add(work_dir_hash(wd["path"], wd.get("kaos", "local"))) tmp_dirs: list[Path] = [] for wd in tmp_entries: h = work_dir_hash(wd["path"], wd.get("kaos", "local")) session_dir = SESSIONS_DIR / h if session_dir.is_dir(): tmp_dirs.append(session_dir) # --- Phase 2: orphan directories (on disk but not in kimi.json) --- orphan_dirs: list[Path] = [] if SESSIONS_DIR.is_dir(): for d in SESSIONS_DIR.iterdir(): if d.is_dir() and d.name not in keep_hashes and d not in tmp_dirs: orphan_dirs.append(d) all_dirs_to_remove = tmp_dirs + orphan_dirs if not all_dirs_to_remove and not tmp_entries: print("No temporary or orphan sessions found. Nothing to do.") return mode = "DRY-RUN" if not args.apply else "APPLY" # Report phase 1 if tmp_entries: n_entries, n_dirs = len(tmp_entries), len(tmp_dirs) print(f"[{mode}] Phase 1: {n_entries} tmp workdir entries, {n_dirs} dirs.") for wd in tmp_entries[:10]: print(f" {wd['path']}") if len(tmp_entries) > 10: print(f" ... and {len(tmp_entries) - 10} more") print() # Report phase 2 if orphan_dirs: n_orphans = len(orphan_dirs) print(f"[{mode}] Phase 2: {n_orphans} orphan session dirs.") for d in orphan_dirs[:5]: subdirs = list(d.iterdir()) print(f" {d.name}/ ({len(subdirs)} session(s))") if len(orphan_dirs) > 5: print(f" ... and {len(orphan_dirs) - 5} more") print() total_size = sum(dir_total_size(d) for d in all_dirs_to_remove) print(f"Total: {len(all_dirs_to_remove)} directories, {total_size / 1024 / 1024:.1f} MB") if not args.apply: print("\nRe-run with --apply to delete.") return # Delete session directories for d in all_dirs_to_remove: shutil.rmtree(d) print(f"\nRemoved {len(all_dirs_to_remove)} session directories.") # Update metadata (remove tmp entries) if tmp_entries: metadata["work_dirs"] = keep_entries with open(METADATA_FILE, "w", encoding="utf-8") as f: json.dump(metadata, f, ensure_ascii=False, indent=2) print(f"Updated {METADATA_FILE.name}: {len(work_dirs)} -> {len(keep_entries)} work_dirs.") if __name__ == "__main__": main() ================================================ FILE: scripts/install.ps1 ================================================ $ErrorActionPreference = "Stop" function Install-Uv { Invoke-RestMethod -Uri "https://astral.sh/uv/install.ps1" | Invoke-Expression } if (Get-Command uv -ErrorAction SilentlyContinue) { $uvBin = "uv" } else { Install-Uv $uvBin = "uv" } if (-not (Get-Command $uvBin -ErrorAction SilentlyContinue)) { Write-Error "Error: uv not found after installation." exit 1 } & $uvBin tool install --python 3.13 kimi-cli ================================================ FILE: scripts/install.sh ================================================ #!/usr/bin/env bash set -euo pipefail install_uv() { if command -v curl >/dev/null 2>&1; then curl -fsSL https://astral.sh/uv/install.sh | sh return fi if command -v wget >/dev/null 2>&1; then wget -qO- https://astral.sh/uv/install.sh | sh return fi echo "Error: curl or wget is required to install uv." >&2 exit 1 } if command -v uv >/dev/null 2>&1; then UV_BIN="uv" else install_uv UV_BIN="uv" fi if ! command -v "$UV_BIN" >/dev/null 2>&1; then echo "Error: uv not found after installation." >&2 exit 1 fi "$UV_BIN" tool install --python 3.13 kimi-cli ================================================ FILE: sdks/kimi-sdk/CHANGELOG.md ================================================ # Changelog ## Unreleased ## 0.2.1 (2026-01-24) - Relax kosong dependency to support kosong 0.40.x ## 0.2.0 (2026-01-21) - Export `KimiFiles` class to support video file uploads ## 0.1.2 (2026-01-21) - Update kosong dependency upper bound to support kosong 0.39.x ## 0.1.1 (2026-01-16) - Fix kosong dependency version constraint to support kosong 0.38.x ## 0.1.0 (2026-01-08) - Initial release. ================================================ FILE: sdks/kimi-sdk/LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: sdks/kimi-sdk/NOTICE ================================================ Kimi SDK Copyright 2025 Moonshot AI This product includes software developed at Moonshot AI (https://www.moonshot.ai/). ================================================ FILE: sdks/kimi-sdk/README.md ================================================ # Kimi SDK Kimi SDK provides a convenient way to access the Kimi API and build agent workflows in Python. ## Installation Kimi SDK requires Python 3.12 or higher. We recommend using uv as the package manager. ```bash uv init --python 3.12 # or higher ``` Then add Kimi SDK as a dependency: ```bash uv add kimi-sdk ``` ## Examples ### Simple chat completion ```python import asyncio from kimi_sdk import Kimi, Message, generate async def main() -> None: kimi = Kimi( base_url="https://api.moonshot.ai/v1", api_key="your_kimi_api_key_here", model="kimi-k2-turbo-preview", ) history = [ Message(role="user", content="Who are you?"), ] result = await generate( chat_provider=kimi, system_prompt="You are a helpful assistant.", tools=[], history=history, ) print(result.message) print(result.usage) asyncio.run(main()) ``` ### Streaming output ```python import asyncio from kimi_sdk import Kimi, Message, StreamedMessagePart, generate async def main() -> None: kimi = Kimi( base_url="https://api.moonshot.ai/v1", api_key="your_kimi_api_key_here", model="kimi-k2-turbo-preview", ) history = [ Message(role="user", content="Who are you?"), ] def output(message_part: StreamedMessagePart) -> None: print(message_part) result = await generate( chat_provider=kimi, system_prompt="You are a helpful assistant.", tools=[], history=history, on_message_part=output, ) print(result.message) print(result.usage) asyncio.run(main()) ``` ### Upload video ```python import asyncio from pathlib import Path from kimi_sdk import Kimi, Message, TextPart, generate async def main() -> None: kimi = Kimi( base_url="https://api.moonshot.ai/v1", api_key="your_kimi_api_key_here", model="kimi-k2-turbo-preview", ) video_path = Path("demo.mp4") video_part = await kimi.files.upload_video( data=video_path.read_bytes(), mime_type="video/mp4", ) history = [ Message( role="user", content=[ TextPart(text="Please describe this video."), video_part, ], ), ] result = await generate( chat_provider=kimi, system_prompt="You are a helpful assistant.", tools=[], history=history, ) print(result.message) print(result.usage) asyncio.run(main()) ``` ### Tool calling with `step` ```python import asyncio from pydantic import BaseModel from kimi_sdk import CallableTool2, Kimi, Message, SimpleToolset, StepResult, ToolOk, ToolReturnValue, step class AddToolParams(BaseModel): a: int b: int class AddTool(CallableTool2[AddToolParams]): name: str = "add" description: str = "Add two integers." params: type[AddToolParams] = AddToolParams async def __call__(self, params: AddToolParams) -> ToolReturnValue: return ToolOk(output=str(params.a + params.b)) async def main() -> None: kimi = Kimi( base_url="https://api.moonshot.ai/v1", api_key="your_kimi_api_key_here", model="kimi-k2-turbo-preview", ) toolset = SimpleToolset() toolset += AddTool() history = [ Message(role="user", content="Please add 2 and 3 with the add tool."), ] result: StepResult = await step( chat_provider=kimi, system_prompt="You are a precise math tutor.", toolset=toolset, history=history, ) print(result.message) print(await result.tool_results()) asyncio.run(main()) ``` ## Environment variables - `KIMI_API_KEY`: API key for the Kimi API. - `KIMI_BASE_URL`: Override the API base URL (defaults to `https://api.moonshot.ai/v1`). ================================================ FILE: sdks/kimi-sdk/pyproject.toml ================================================ [project] name = "kimi-sdk" version = "0.2.1" description = "A lightweight Python SDK for the Kimi API." readme = "README.md" requires-python = ">=3.12" dependencies = ["kosong>=0.37.0"] [dependency-groups] dev = [ "httpx>=0.28.1,<0.29.0", "inline-snapshot[black]>=0.31.1", "pdoc>=16.0.0", "pyright>=1.1.407", "ty>=0.0.7", "pytest>=9.0.2", "pytest-asyncio>=1.3.0", "ruff>=0.14.10", ] [build-system] requires = ["uv_build>=0.8.5,<0.10.0"] build-backend = "uv_build" [tool.uv.build-backend] module-name = ["kimi_sdk"] source-exclude = ["tests/**/*"] [tool.ruff] line-length = 100 [tool.ruff.lint] select = [ "E", # pycodestyle "F", # Pyflakes "UP", # pyupgrade "B", # flake8-bugbear "SIM", # flake8-simplify "I", # isort ] [tool.pyright] typeCheckingMode = "strict" pythonVersion = "3.14" include = ["src/**/*.py", "tests/**/*.py"] [tool.ty.environment] python-version = "3.14" [tool.ty.src] include = ["src/**/*.py", "tests/**/*.py"] ================================================ FILE: sdks/kimi-sdk/src/kimi_sdk/__init__.py ================================================ """ Kimi SDK provides a convenient way to access the Kimi API and build agent workflows. Key features: - `generate` creates a completion stream and merges message parts into a `Message` with optional `TokenUsage`. - `step` layers tool dispatch over `generate`, returning `StepResult` and tool outputs. - Message structures, content parts, and tool abstractions live in this module. Example (minimal agent loop): ```python import asyncio from kimi_sdk import Kimi, Message, SimpleToolset, StepResult, ToolResult, step def tool_result_to_message(result: ToolResult) -> Message: return Message( role="tool", tool_call_id=result.tool_call_id, content=result.return_value.output, ) async def agent_loop() -> None: kimi = Kimi( base_url="https://api.moonshot.ai/v1", api_key="your_kimi_api_key_here", model="kimi-k2-turbo-preview", ) toolset = SimpleToolset() history: list[Message] = [] system_prompt = "You are a helpful assistant." while True: user_input = input("You: ").strip() if not user_input: continue if user_input.lower() in {"exit", "quit"}: break history.append(Message(role="user", content=user_input)) while True: result: StepResult = await step( chat_provider=kimi, system_prompt=system_prompt, toolset=toolset, history=history, ) history.append(result.message) tool_results = await result.tool_results() for tool_result in tool_results: history.append(tool_result_to_message(tool_result)) if text := result.message.extract_text(): print("Assistant:", text) if not result.tool_calls: break asyncio.run(agent_loop()) ``` """ from __future__ import annotations from kosong import GenerateResult, StepResult, generate, step from kosong.chat_provider import ( APIConnectionError, APIEmptyResponseError, APIStatusError, APITimeoutError, ChatProviderError, StreamedMessagePart, ThinkingEffort, TokenUsage, ) from kosong.chat_provider.kimi import Kimi, KimiFiles, KimiStreamedMessage from kosong.message import ( AudioURLPart, ContentPart, ImageURLPart, Message, Role, TextPart, ThinkPart, ToolCall, ToolCallPart, VideoURLPart, ) from kosong.tooling import ( BriefDisplayBlock, CallableTool, CallableTool2, DisplayBlock, Tool, ToolError, ToolOk, ToolResult, ToolResultFuture, ToolReturnValue, Toolset, UnknownDisplayBlock, ) from kosong.tooling.simple import SimpleToolset __all__ = [ # providers "Kimi", "KimiFiles", "KimiStreamedMessage", "StreamedMessagePart", "ThinkingEffort", # provider errors "APIConnectionError", "APIEmptyResponseError", "APIStatusError", "APITimeoutError", "ChatProviderError", # messages and content parts "Message", "Role", "ContentPart", "TextPart", "ThinkPart", "ImageURLPart", "AudioURLPart", "VideoURLPart", "ToolCall", "ToolCallPart", # tooling "Tool", "CallableTool", "CallableTool2", "Toolset", "SimpleToolset", "ToolReturnValue", "ToolOk", "ToolError", "ToolResult", "ToolResultFuture", # display blocks "DisplayBlock", "BriefDisplayBlock", "UnknownDisplayBlock", # generation "generate", "step", "GenerateResult", "StepResult", "TokenUsage", ] ================================================ FILE: sdks/kimi-sdk/src/kimi_sdk/py.typed ================================================ ================================================ FILE: sdks/kimi-sdk/tests/test_smoke.py ================================================ from __future__ import annotations import httpx import pytest from kimi_sdk import Kimi, Message, generate def _chat_completion_response() -> dict[str, object]: return { "id": "chatcmpl-test123", "object": "chat.completion", "created": 1234567890, "model": "kimi-k2-turbo-preview", "choices": [ { "index": 0, "message": {"role": "assistant", "content": "Hello"}, "finish_reason": "stop", } ], "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, } @pytest.mark.asyncio async def test_generate_smoke() -> None: def handler(request: httpx.Request) -> httpx.Response: assert request.url.path == "/v1/chat/completions" return httpx.Response(200, json=_chat_completion_response()) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: kimi = Kimi( model="kimi-k2-turbo-preview", api_key="test-key", stream=False, http_client=http_client, ) result = await generate( chat_provider=kimi, system_prompt="You are helpful.", tools=[], history=[Message(role="user", content="Hi")], ) assert result.message.role == "assistant" assert result.message.extract_text() == "Hello" assert result.usage is not None assert result.usage.input_other == 10 assert result.usage.output == 5 ================================================ FILE: src/kimi_cli/__init__.py ================================================ from __future__ import annotations from typing import Any, cast class _LazyLogger: """Import loguru only when logging is actually used.""" def __init__(self) -> None: self._logger: Any | None = None def _get(self) -> Any: if self._logger is None: from loguru import logger as real_logger # Disable logging by default for library usage. # Application entry points (e.g., kimi_cli.cli) should call logger.enable("kimi_cli") # to enable logging. real_logger.disable("kimi_cli") self._logger = real_logger return self._logger def __getattr__(self, name: str) -> Any: return getattr(self._get(), name) logger = cast(Any, _LazyLogger()) __all__ = ["logger"] ================================================ FILE: src/kimi_cli/__main__.py ================================================ from __future__ import annotations import sys from collections.abc import Sequence from pathlib import Path def _prog_name() -> str: return Path(sys.argv[0]).name or "kimi" def main(argv: Sequence[str] | None = None) -> int | str | None: args = list(sys.argv[1:] if argv is None else argv) if len(args) == 1 and args[0] in {"--version", "-V"}: from kimi_cli.constant import get_version print(f"kimi, version {get_version()}") return 0 from kimi_cli.cli import cli try: return cli(args=args, prog_name=_prog_name()) except SystemExit as exc: return exc.code if __name__ == "__main__": raise SystemExit(main()) ================================================ FILE: src/kimi_cli/acp/AGENTS.md ================================================ # ACP Integration Notes (kimi-cli) ## Protocol summary (ACP overview) - ACP is JSON-RPC 2.0 with request/response methods plus one-way notifications. - Typical flow: `initialize` -> optional `authenticate` -> `session/new` or `session/load` -> `session/prompt` with `session/update` notifications and optional `session/cancel`. - Clients provide `session/request_permission` and optional terminal/filesystem methods. - All ACP file paths must be absolute; line numbers are 1-based. ## Entry points and server modes - **Single-session server**: `KimiCLI.run_acp()` uses `ACP` -> `ACPServerSingleSession`. - Code: `src/kimi_cli/app.py`, `src/kimi_cli/ui/acp/__init__.py`. - Used when running CLI with `--acp` UI mode. - **Multi-session server**: `acp_main()` runs `ACPServer` with `use_unstable_protocol=True`. - Code: `src/kimi_cli/acp/__init__.py`, `src/kimi_cli/acp/server.py`. - Exposed via the `kimi acp` command in `src/kimi_cli/cli/__init__.py`. ## Capabilities advertised - `prompt_capabilities`: `embedded_context=False`, `image=True`, `audio=False`. - `mcp_capabilities`: `http=True`, `sse=False`. - Single-session: `load_session=False`, no session list capabilities. - Multi-session: `load_session=True`, `session_capabilities.list` supported. - `auth_methods=[]` (no authentication methods advertised). ## Session lifecycle (implemented behavior) - `session/new` - Multi-session: creates a persisted `Session`, builds `KimiCLI`, stores `ACPSession`. - Single-session: wraps the existing `Soul` into a `Wire` loop and creates `ACPSession`. - Both send `AvailableCommandsUpdate` for slash commands on session creation. - MCP servers passed by ACP are converted via `acp_mcp_servers_to_mcp_config`. - `session/load` - Multi-session only: loads by `Session.find`, then builds `KimiCLI` and `ACPSession`. - No history replay yet (TODO). - Single-session: not implemented. - `session/list` - Multi-session only: lists sessions via `Session.list`, no pagination. - Single-session: not implemented. - `session/prompt` - Uses `ACPSession.prompt()` to stream updates and produce a `stop_reason`. - Stop reasons: `end_turn`, `max_turn_requests`, `cancelled`. - `session/cancel` - Sets the per-turn cancel event to stop the prompt. ## Streaming updates and content mapping - Text chunks -> `AgentMessageChunk`. - Think chunks -> `AgentThoughtChunk`. - Tool calls: - Start -> `ToolCallStart` with JSON args as text content. - Streaming args -> `ToolCallProgress` with updated title/args. - Results -> `ToolCallProgress` with `completed` or `failed`. - Tool call IDs are prefixed with turn ID to avoid collisions across turns. - Plan updates: - `TodoDisplayBlock` is converted into `AgentPlanUpdate`. - Available commands: - `AvailableCommandsUpdate` is sent right after session creation. ## Prompt/content conversion - Incoming prompt blocks: - Supported: `TextContentBlock`, `ImageContentBlock` (converted to data URL). - Unsupported types are logged and ignored. - Tool result display blocks: - `DiffDisplayBlock` -> `FileEditToolCallContent`. - `HideOutputDisplayBlock` suppresses tool output in ACP (used by terminal tool). ## Tool integration and permission flow - ACP sessions use `ACPKaos` to route filesystem reads/writes through ACP clients. - If the client advertises `terminal` capability, the `Shell` tool is replaced by an ACP-backed `Terminal` tool. - Uses ACP `terminal/create`, waits for exit, streams `TerminalToolCallContent`, then releases the terminal handle. - Approval requests in the core tool system are bridged to ACP `session/request_permission` with allow-once/allow-always/reject options. ## Current gaps / not implemented - `authenticate` method (not used by current Zed ACP client). - `session/set_mode` and `session/set_model` (no multi-mode/model switching in kimi-cli). - `ext_method` / `ext_notification` for custom ACP extensions are stubbed. - Single-session server does not implement `session/load` or `session/list`. ## Filesystem (ACP client-backed) - When the client advertises `fs.readTextFile` / `fs.writeTextFile`, `ACPKaos` routes reads and writes through ACP `fs/*` methods. - `ReadFile` uses `KaosPath.read_lines`, which `ACPKaos` implements via ACP reads. - `ReadMediaFile` uses `KaosPath.read_bytes` to load image/video payloads through ACP reads. - `WriteFile` uses `KaosPath.read_text/write_text/append_text` and still generates diffs and approvals in the tool layer. ## Zed-specific notes (as of current integration) - Zed does not currently call `authenticate`. - Zed’s external agent server session management is not yet available, so `session/load` is not exercised in practice. ================================================ FILE: src/kimi_cli/acp/__init__.py ================================================ def acp_main() -> None: """Entry point for the multi-session ACP server.""" import asyncio import acp from kimi_cli.acp.server import ACPServer from kimi_cli.app import enable_logging from kimi_cli.utils.logging import logger enable_logging() logger.info("Starting ACP server on stdio") asyncio.run(acp.run_agent(ACPServer(), use_unstable_protocol=True)) ================================================ FILE: src/kimi_cli/acp/convert.py ================================================ from __future__ import annotations import acp from kimi_cli.acp.types import ACPContentBlock from kimi_cli.utils.logging import logger from kimi_cli.wire.types import ( ContentPart, DiffDisplayBlock, DisplayBlock, ImageURLPart, TextPart, ToolReturnValue, ) def acp_blocks_to_content_parts(prompt: list[ACPContentBlock]) -> list[ContentPart]: content: list[ContentPart] = [] for block in prompt: match block: case acp.schema.TextContentBlock(): content.append(TextPart(text=block.text)) case acp.schema.ImageContentBlock(): content.append( ImageURLPart( image_url=ImageURLPart.ImageURL( url=f"data:{block.mime_type};base64,{block.data}" ) ) ) case acp.schema.EmbeddedResourceContentBlock(): resource = block.resource if isinstance(resource, acp.schema.TextResourceContents): uri = resource.uri text = resource.text content.append(TextPart(text=f"\n{text}\n")) else: logger.warning( "Unsupported embedded resource type: {type}", type=type(resource).__name__, ) case acp.schema.ResourceContentBlock(): # ResourceContentBlock is a link reference without inline content; # include the URI so the model is at least aware of the reference. content.append( TextPart(text=f"") ) case _: logger.warning("Unsupported prompt content block: {block}", block=block) return content def display_block_to_acp_content( block: DisplayBlock, ) -> acp.schema.FileEditToolCallContent | None: if isinstance(block, DiffDisplayBlock): return acp.schema.FileEditToolCallContent( type="diff", path=block.path, old_text=block.old_text, new_text=block.new_text, ) return None def tool_result_to_acp_content( tool_ret: ToolReturnValue, ) -> list[ acp.schema.ContentToolCallContent | acp.schema.FileEditToolCallContent | acp.schema.TerminalToolCallContent ]: from kimi_cli.acp.tools import HideOutputDisplayBlock def _to_acp_content( part: ContentPart, ) -> ( acp.schema.ContentToolCallContent | acp.schema.FileEditToolCallContent | acp.schema.TerminalToolCallContent ): if isinstance(part, TextPart): return acp.schema.ContentToolCallContent( type="content", content=acp.schema.TextContentBlock(type="text", text=part.text) ) logger.warning("Unsupported content part in tool result: {part}", part=part) return acp.schema.ContentToolCallContent( type="content", content=acp.schema.TextContentBlock(type="text", text=f"[{part.__class__.__name__}]"), ) def _to_text_block(text: str) -> acp.schema.ContentToolCallContent: return acp.schema.ContentToolCallContent( type="content", content=acp.schema.TextContentBlock(type="text", text=text) ) contents: list[ acp.schema.ContentToolCallContent | acp.schema.FileEditToolCallContent | acp.schema.TerminalToolCallContent ] = [] for block in tool_ret.display: if isinstance(block, HideOutputDisplayBlock): # return early to indicate no output should be shown return [] content = display_block_to_acp_content(block) if content is not None: contents.append(content) # TODO: better concatenation of `display` blocks and `output`? output = tool_ret.output if isinstance(output, str): if output: contents.append(_to_text_block(output)) else: # NOTE: At the moment, ToolReturnValue.output is either a string or a # list of ContentPart. We avoid an unnecessary isinstance() check here # to keep pyright happy while still handling list outputs. contents.extend(_to_acp_content(part) for part in output) if not contents and tool_ret.message: # Fallback to the `message` for LLM if there's no other content contents.append(_to_text_block(tool_ret.message)) return contents ================================================ FILE: src/kimi_cli/acp/kaos.py ================================================ from __future__ import annotations import asyncio from collections.abc import AsyncGenerator, Iterable, Mapping from contextlib import suppress from typing import Literal import acp from kaos import AsyncReadable, AsyncWritable, Kaos, KaosProcess, StatResult, StrOrKaosPath from kaos.local import local_kaos from kaos.path import KaosPath _DEFAULT_TERMINAL_OUTPUT_LIMIT = 50_000 _DEFAULT_POLL_INTERVAL = 0.2 _TRUNCATION_NOTICE = "[acp output truncated]\n" class _NullWritable: def can_write_eof(self) -> bool: return False def close(self) -> None: return None async def drain(self) -> None: return None def is_closing(self) -> bool: return False async def wait_closed(self) -> None: return None def write(self, data: bytes) -> None: return None def writelines(self, data: Iterable[bytes], /) -> None: return None def write_eof(self) -> None: return None class ACPProcess: """KAOS process adapter for ACP terminal execution.""" def __init__( self, terminal: acp.TerminalHandle, *, poll_interval: float = _DEFAULT_POLL_INTERVAL, ) -> None: self._terminal = terminal self._poll_interval = poll_interval self._stdin = _NullWritable() self._stdout = asyncio.StreamReader() self._stderr = asyncio.StreamReader() self.stdin: AsyncWritable = self._stdin self.stdout: AsyncReadable = self._stdout # ACP does not expose stderr separately; keep stderr empty. self.stderr: AsyncReadable = self._stderr self._returncode: int | None = None self._last_output = "" self._truncation_noted = False self._exit_future: asyncio.Future[int] = asyncio.get_running_loop().create_future() self._poll_task = asyncio.create_task(self._poll_output()) @property def pid(self) -> int: return -1 @property def returncode(self) -> int | None: return self._returncode async def wait(self) -> int: return await self._exit_future async def kill(self) -> None: await self._terminal.kill() def _feed_output(self, output_response: acp.schema.TerminalOutputResponse) -> None: output = output_response.output reset = output_response.truncated or ( self._last_output and not output.startswith(self._last_output) ) if reset and self._last_output and not self._truncation_noted: self._stdout.feed_data(_TRUNCATION_NOTICE.encode("utf-8")) self._truncation_noted = True delta = output if reset else output[len(self._last_output) :] if delta: self._stdout.feed_data(delta.encode("utf-8", "replace")) self._last_output = output @staticmethod def _normalize_exit_code(exit_code: int | None) -> int: return 1 if exit_code is None else exit_code async def _poll_output(self) -> None: exit_task = asyncio.create_task(self._terminal.wait_for_exit()) exit_code: int | None = None try: while True: if exit_task.done(): exit_response = exit_task.result() exit_code = exit_response.exit_code break output_response = await self._terminal.current_output() self._feed_output(output_response) if output_response.exit_status: exit_code = output_response.exit_status.exit_code try: exit_response = await exit_task exit_code = exit_response.exit_code or exit_code except Exception: pass break await asyncio.sleep(self._poll_interval) final_output = await self._terminal.current_output() self._feed_output(final_output) except Exception as exc: error_note = f"[acp terminal error] {exc}\n" self._stdout.feed_data(error_note.encode("utf-8", "replace")) if exit_code is None: exit_code = 1 finally: if not exit_task.done(): exit_task.cancel() with suppress(Exception): await exit_task self._returncode = self._normalize_exit_code(exit_code) self._stdout.feed_eof() self._stderr.feed_eof() if not self._exit_future.done(): self._exit_future.set_result(self._returncode) with suppress(Exception): await self._terminal.release() class ACPKaos: """KAOS backend that routes supported operations through ACP.""" name: str = "acp" def __init__( self, client: acp.Client, session_id: str, client_capabilities: acp.schema.ClientCapabilities | None, fallback: Kaos | None = None, *, output_byte_limit: int | None = _DEFAULT_TERMINAL_OUTPUT_LIMIT, poll_interval: float = _DEFAULT_POLL_INTERVAL, ) -> None: self._client = client self._session_id = session_id self._fallback = fallback or local_kaos fs = client_capabilities.fs if client_capabilities else None self._supports_read = bool(fs and fs.read_text_file) self._supports_write = bool(fs and fs.write_text_file) self._supports_terminal = bool(client_capabilities and client_capabilities.terminal) self._output_byte_limit = output_byte_limit self._poll_interval = poll_interval def pathclass(self): return self._fallback.pathclass() def normpath(self, path: StrOrKaosPath) -> KaosPath: return self._fallback.normpath(path) def gethome(self) -> KaosPath: return self._fallback.gethome() def getcwd(self) -> KaosPath: return self._fallback.getcwd() async def chdir(self, path: StrOrKaosPath) -> None: await self._fallback.chdir(path) async def stat(self, path: StrOrKaosPath, *, follow_symlinks: bool = True) -> StatResult: return await self._fallback.stat(path, follow_symlinks=follow_symlinks) def iterdir(self, path: StrOrKaosPath) -> AsyncGenerator[KaosPath]: return self._fallback.iterdir(path) def glob( self, path: StrOrKaosPath, pattern: str, *, case_sensitive: bool = True ) -> AsyncGenerator[KaosPath]: return self._fallback.glob(path, pattern, case_sensitive=case_sensitive) async def readbytes(self, path: StrOrKaosPath, n: int | None = None) -> bytes: return await self._fallback.readbytes(path, n=n) async def readtext( self, path: StrOrKaosPath, *, encoding: str = "utf-8", errors: Literal["strict", "ignore", "replace"] = "strict", ) -> str: abs_path = self._abs_path(path) if not self._supports_read: return await self._fallback.readtext(abs_path, encoding=encoding, errors=errors) response = await self._client.read_text_file(path=abs_path, session_id=self._session_id) return response.content async def readlines( self, path: StrOrKaosPath, *, encoding: str = "utf-8", errors: Literal["strict", "ignore", "replace"] = "strict", ) -> AsyncGenerator[str]: text = await self.readtext(path, encoding=encoding, errors=errors) for line in text.splitlines(keepends=True): yield line async def writebytes(self, path: StrOrKaosPath, data: bytes) -> int: return await self._fallback.writebytes(path, data) async def writetext( self, path: StrOrKaosPath, data: str, *, mode: Literal["w", "a"] = "w", encoding: str = "utf-8", errors: Literal["strict", "ignore", "replace"] = "strict", ) -> int: abs_path = self._abs_path(path) if mode == "a": if self._supports_read and self._supports_write: existing = await self.readtext(abs_path, encoding=encoding, errors=errors) await self._client.write_text_file( path=abs_path, content=existing + data, session_id=self._session_id, ) return len(data) return await self._fallback.writetext( abs_path, data, mode="a", encoding=encoding, errors=errors ) if not self._supports_write: return await self._fallback.writetext( abs_path, data, mode=mode, encoding=encoding, errors=errors ) await self._client.write_text_file( path=abs_path, content=data, session_id=self._session_id, ) return len(data) async def mkdir( self, path: StrOrKaosPath, parents: bool = False, exist_ok: bool = False ) -> None: await self._fallback.mkdir(path, parents=parents, exist_ok=exist_ok) async def exec(self, *args: str, env: Mapping[str, str] | None = None) -> KaosProcess: return await self._fallback.exec(*args, env=env) def _abs_path(self, path: StrOrKaosPath) -> str: kaos_path = path if isinstance(path, KaosPath) else KaosPath(path) return str(kaos_path.canonical()) ================================================ FILE: src/kimi_cli/acp/mcp.py ================================================ from __future__ import annotations from typing import Any import acp.schema from fastmcp.mcp_config import MCPConfig from pydantic import ValidationError from kimi_cli.acp.types import MCPServer from kimi_cli.exception import MCPConfigError def acp_mcp_servers_to_mcp_config(mcp_servers: list[MCPServer]) -> MCPConfig: if not mcp_servers: return MCPConfig() try: return MCPConfig.model_validate( {"mcpServers": {server.name: _convert_acp_mcp_server(server) for server in mcp_servers}} ) except ValidationError as exc: raise MCPConfigError(f"Invalid MCP config from ACP client: {exc}") from exc def _convert_acp_mcp_server(server: MCPServer) -> dict[str, Any]: """Convert an ACP MCP server to a dictionary representation.""" match server: case acp.schema.HttpMcpServer(): return { "url": server.url, "transport": "http", "headers": {header.name: header.value for header in server.headers}, } case acp.schema.SseMcpServer(): return { "url": server.url, "transport": "sse", "headers": {header.name: header.value for header in server.headers}, } case acp.schema.McpServerStdio(): return { "command": server.command, "args": server.args, "env": {item.name: item.value for item in server.env}, "transport": "stdio", } ================================================ FILE: src/kimi_cli/acp/server.py ================================================ from __future__ import annotations import asyncio import sys from datetime import datetime from pathlib import Path from typing import Any, NamedTuple import acp from kaos.path import KaosPath from kimi_cli.acp.kaos import ACPKaos from kimi_cli.acp.mcp import acp_mcp_servers_to_mcp_config from kimi_cli.acp.session import ACPSession from kimi_cli.acp.tools import replace_tools from kimi_cli.acp.types import ACPContentBlock, MCPServer from kimi_cli.acp.version import ACPVersionSpec, negotiate_version from kimi_cli.app import KimiCLI from kimi_cli.auth.oauth import KIMI_CODE_OAUTH_KEY, load_tokens from kimi_cli.config import LLMModel, OAuthRef, load_config, save_config from kimi_cli.constant import NAME, VERSION from kimi_cli.llm import create_llm, derive_model_capabilities from kimi_cli.session import Session from kimi_cli.soul.slash import registry as soul_slash_registry from kimi_cli.soul.toolset import KimiToolset from kimi_cli.utils.logging import logger class ACPServer: def __init__(self) -> None: self.client_capabilities: acp.schema.ClientCapabilities | None = None self.conn: acp.Client | None = None self.sessions: dict[str, tuple[ACPSession, _ModelIDConv]] = {} self.negotiated_version: ACPVersionSpec | None = None self._auth_methods: list[acp.schema.AuthMethod] = [] def on_connect(self, conn: acp.Client) -> None: logger.info("ACP client connected") self.conn = conn async def initialize( self, protocol_version: int, client_capabilities: acp.schema.ClientCapabilities | None = None, client_info: acp.schema.Implementation | None = None, **kwargs: Any, ) -> acp.InitializeResponse: self.negotiated_version = negotiate_version(protocol_version) logger.info( "ACP server initialized with client protocol version: {version}, " "negotiated version: {negotiated}, " "client capabilities: {capabilities}, client info: {info}", version=protocol_version, negotiated=self.negotiated_version, capabilities=client_capabilities, info=client_info, ) self.client_capabilities = client_capabilities # get command and args of current process for terminal-auth command = sys.argv[0] if command.endswith("kimi"): args = [] else: idx = sys.argv.index("kimi") args = sys.argv[1 : idx + 1] # Build terminal auth data for error response terminal_args = args + ["login"] # Build and cache auth methods for reuse in AUTH_REQUIRED errors self._auth_methods = [ acp.schema.AuthMethod( id="login", name="Login with Kimi account", description=( "Run `kimi login` command in the terminal, " "then follow the instructions to finish login." ), # Store auth data in field_meta for building AUTH_REQUIRED error field_meta={ "terminal-auth": { "command": command, "args": terminal_args, "label": "Kimi Code Login", "env": {}, "type": "terminal", } }, ), ] return acp.InitializeResponse( protocol_version=self.negotiated_version.protocol_version, agent_capabilities=acp.schema.AgentCapabilities( load_session=True, prompt_capabilities=acp.schema.PromptCapabilities( embedded_context=True, image=True, audio=False ), mcp_capabilities=acp.schema.McpCapabilities(http=True, sse=False), session_capabilities=acp.schema.SessionCapabilities( list=acp.schema.SessionListCapabilities(), resume=acp.schema.SessionResumeCapabilities(), ), ), auth_methods=self._auth_methods, agent_info=acp.schema.Implementation(name=NAME, version=VERSION), ) def _check_auth(self) -> None: """Check if Kimi Code authentication is complete. Raise AUTH_REQUIRED if not.""" ref = OAuthRef(storage="file", key=KIMI_CODE_OAUTH_KEY) token = load_tokens(ref) if token is None or not token.access_token: # Build AUTH_REQUIRED error data for clients auth_methods_data: list[dict[str, Any]] = [] for m in self._auth_methods: if m.field_meta and "terminal-auth" in m.field_meta: terminal_auth = m.field_meta["terminal-auth"] auth_methods_data.append( { "id": m.id, "name": m.name, "description": m.description, "type": terminal_auth.get("type", "terminal"), "args": terminal_auth.get("args", []), "env": terminal_auth.get("env", {}), } ) logger.warning("Authentication required, no valid token found") raise acp.RequestError.auth_required({"authMethods": auth_methods_data}) async def new_session( self, cwd: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any ) -> acp.NewSessionResponse: logger.info("Creating new session for working directory: {cwd}", cwd=cwd) assert self.conn is not None, "ACP client not connected" assert self.client_capabilities is not None, "ACP connection not initialized" # Check authentication before creating session self._check_auth() session = await Session.create(KaosPath.unsafe_from_local_path(Path(cwd))) mcp_config = acp_mcp_servers_to_mcp_config(mcp_servers or []) cli_instance = await KimiCLI.create( session, mcp_configs=[mcp_config], ) config = cli_instance.soul.runtime.config acp_kaos = ACPKaos(self.conn, session.id, self.client_capabilities) acp_session = ACPSession(session.id, cli_instance, self.conn, kaos=acp_kaos) model_id_conv = _ModelIDConv(config.default_model, config.default_thinking) self.sessions[session.id] = (acp_session, model_id_conv) if isinstance(cli_instance.soul.agent.toolset, KimiToolset): replace_tools( self.client_capabilities, self.conn, session.id, cli_instance.soul.agent.toolset, cli_instance.soul.runtime, ) available_commands = [ acp.schema.AvailableCommand(name=cmd.name, description=cmd.description) for cmd in soul_slash_registry.list_commands() ] asyncio.create_task( self.conn.session_update( session_id=session.id, update=acp.schema.AvailableCommandsUpdate( session_update="available_commands_update", available_commands=available_commands, ), ) ) return acp.NewSessionResponse( session_id=session.id, modes=acp.schema.SessionModeState( available_modes=[ acp.schema.SessionMode( id="default", name="Default", description="The default mode.", ), ], current_mode_id="default", ), models=acp.schema.SessionModelState( available_models=_expand_llm_models(config.models), current_model_id=model_id_conv.to_acp_model_id(), ), ) async def _setup_session( self, cwd: str, session_id: str, mcp_servers: list[MCPServer] | None = None, ) -> tuple[ACPSession, _ModelIDConv]: """Load or resume a session. Shared by load_session and resume_session.""" assert self.conn is not None, "ACP client not connected" assert self.client_capabilities is not None, "ACP connection not initialized" work_dir = KaosPath.unsafe_from_local_path(Path(cwd)) session = await Session.find(work_dir, session_id) if session is None: logger.error( "Session not found: {id} for working directory: {cwd}", id=session_id, cwd=cwd ) raise acp.RequestError.invalid_params({"session_id": "Session not found"}) mcp_config = acp_mcp_servers_to_mcp_config(mcp_servers or []) cli_instance = await KimiCLI.create( session, mcp_configs=[mcp_config], ) config = cli_instance.soul.runtime.config acp_kaos = ACPKaos(self.conn, session.id, self.client_capabilities) acp_session = ACPSession(session.id, cli_instance, self.conn, kaos=acp_kaos) model_id_conv = _ModelIDConv(config.default_model, config.default_thinking) self.sessions[session.id] = (acp_session, model_id_conv) if isinstance(cli_instance.soul.agent.toolset, KimiToolset): replace_tools( self.client_capabilities, self.conn, session.id, cli_instance.soul.agent.toolset, cli_instance.soul.runtime, ) return acp_session, model_id_conv async def load_session( self, cwd: str, session_id: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any ) -> None: logger.info("Loading session: {id} for working directory: {cwd}", id=session_id, cwd=cwd) if session_id in self.sessions: logger.warning("Session already loaded: {id}", id=session_id) return # Check authentication before loading session self._check_auth() await self._setup_session(cwd, session_id, mcp_servers) # TODO: replay session history? async def resume_session( self, cwd: str, session_id: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any ) -> acp.schema.ResumeSessionResponse: logger.info("Resuming session: {id} for working directory: {cwd}", id=session_id, cwd=cwd) if session_id not in self.sessions: await self._setup_session(cwd, session_id, mcp_servers) acp_session, model_id_conv = self.sessions[session_id] config = acp_session.cli.soul.runtime.config return acp.schema.ResumeSessionResponse( modes=acp.schema.SessionModeState( available_modes=[ acp.schema.SessionMode( id="default", name="Default", description="The default mode.", ), ], current_mode_id="default", ), models=acp.schema.SessionModelState( available_models=_expand_llm_models(config.models), current_model_id=model_id_conv.to_acp_model_id(), ), ) async def fork_session( self, cwd: str, session_id: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any ) -> acp.schema.ForkSessionResponse: raise NotImplementedError async def list_sessions( self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any ) -> acp.schema.ListSessionsResponse: logger.info("Listing sessions for working directory: {cwd}", cwd=cwd) if cwd is None: return acp.schema.ListSessionsResponse(sessions=[], next_cursor=None) work_dir = KaosPath.unsafe_from_local_path(Path(cwd)) sessions = await Session.list(work_dir) return acp.schema.ListSessionsResponse( sessions=[ acp.schema.SessionInfo( cwd=cwd, session_id=s.id, title=s.title, updated_at=datetime.fromtimestamp(s.updated_at).astimezone().isoformat(), ) for s in sessions ], next_cursor=None, ) async def set_session_mode(self, mode_id: str, session_id: str, **kwargs: Any) -> None: assert mode_id == "default", "Only default mode is supported" async def set_session_model(self, model_id: str, session_id: str, **kwargs: Any) -> None: logger.info( "Setting session model to {model_id} for session: {id}", model_id=model_id, id=session_id, ) if session_id not in self.sessions: logger.error("Session not found: {id}", id=session_id) raise acp.RequestError.invalid_params({"session_id": "Session not found"}) acp_session, current_model_id = self.sessions[session_id] cli_instance = acp_session.cli model_id_conv = _ModelIDConv.from_acp_model_id(model_id) if model_id_conv == current_model_id: return config = cli_instance.soul.runtime.config new_model = config.models.get(model_id_conv.model_key) if new_model is None: logger.error("Model not found: {model_key}", model_key=model_id_conv.model_key) raise acp.RequestError.invalid_params({"model_id": "Model not found"}) new_provider = config.providers.get(new_model.provider) if new_provider is None: logger.error( "Provider not found: {provider} for model: {model_key}", provider=new_model.provider, model_key=model_id_conv.model_key, ) raise acp.RequestError.invalid_params({"model_id": "Model's provider not found"}) new_llm = create_llm( new_provider, new_model, session_id=acp_session.id, thinking=model_id_conv.thinking, oauth=cli_instance.soul.runtime.oauth, ) cli_instance.soul.runtime.llm = new_llm config.default_model = model_id_conv.model_key config.default_thinking = model_id_conv.thinking assert config.is_from_default_location, "`kimi acp` must use the default config location" config_for_save = load_config() config_for_save.default_model = model_id_conv.model_key config_for_save.default_thinking = model_id_conv.thinking save_config(config_for_save) async def authenticate(self, method_id: str, **kwargs: Any) -> acp.AuthenticateResponse | None: """ For Terminal Auth, this method is typically not called directly (user completes auth in terminal). Implement for completeness. """ if method_id == "login": ref = OAuthRef(storage="file", key=KIMI_CODE_OAUTH_KEY) token = load_tokens(ref) if token and token.access_token: logger.info("Authentication successful for method: {id}", id=method_id) return acp.AuthenticateResponse() else: logger.warning("Authentication not complete for method: {id}", id=method_id) raise acp.RequestError.auth_required( { "message": "Please complete login in terminal first", "authMethods": self._auth_methods, } ) logger.error("Unknown auth method: {method_id}", method_id=method_id) raise acp.RequestError.invalid_params({"method_id": "Unknown auth method"}) async def prompt( self, prompt: list[ACPContentBlock], session_id: str, **kwargs: Any ) -> acp.PromptResponse: logger.info("Received prompt request for session: {id}", id=session_id) if session_id not in self.sessions: logger.error("Session not found: {id}", id=session_id) raise acp.RequestError.invalid_params({"session_id": "Session not found"}) acp_session, *_ = self.sessions[session_id] return await acp_session.prompt(prompt) async def cancel(self, session_id: str, **kwargs: Any) -> None: logger.info("Received cancel request for session: {id}", id=session_id) if session_id not in self.sessions: logger.error("Session not found: {id}", id=session_id) raise acp.RequestError.invalid_params({"session_id": "Session not found"}) acp_session, *_ = self.sessions[session_id] await acp_session.cancel() async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]: raise NotImplementedError async def ext_notification(self, method: str, params: dict[str, Any]) -> None: raise NotImplementedError class _ModelIDConv(NamedTuple): model_key: str thinking: bool @classmethod def from_acp_model_id(cls, model_id: str) -> _ModelIDConv: if model_id.endswith(",thinking"): return _ModelIDConv(model_id[: -len(",thinking")], True) return _ModelIDConv(model_id, False) def to_acp_model_id(self) -> str: if self.thinking: return f"{self.model_key},thinking" return self.model_key def _expand_llm_models(models: dict[str, LLMModel]) -> list[acp.schema.ModelInfo]: expanded_models: list[acp.schema.ModelInfo] = [] for model_key, model in models.items(): capabilities = derive_model_capabilities(model) if "thinking" in model.model or "reason" in model.model: # always-thinking models expanded_models.append( acp.schema.ModelInfo( model_id=_ModelIDConv(model_key, True).to_acp_model_id(), name=f"{model.model}", ) ) else: expanded_models.append( acp.schema.ModelInfo( model_id=model_key, name=model.model, ) ) if "thinking" in capabilities: # add thinking variant expanded_models.append( acp.schema.ModelInfo( model_id=_ModelIDConv(model_key, True).to_acp_model_id(), name=f"{model.model} (thinking)", ) ) return expanded_models ================================================ FILE: src/kimi_cli/acp/session.py ================================================ from __future__ import annotations import asyncio import uuid from contextvars import ContextVar import acp import streamingjson # type: ignore[reportMissingTypeStubs] from kaos import Kaos, reset_current_kaos, set_current_kaos from kosong.chat_provider import ChatProviderError from kimi_cli.acp.convert import ( acp_blocks_to_content_parts, display_block_to_acp_content, tool_result_to_acp_content, ) from kimi_cli.acp.types import ACPContentBlock from kimi_cli.app import KimiCLI from kimi_cli.soul import LLMNotSet, LLMNotSupported, MaxStepsReached, RunCancelled from kimi_cli.tools import extract_key_argument from kimi_cli.utils.logging import logger from kimi_cli.wire.types import ( ApprovalRequest, ApprovalResponse, CompactionBegin, CompactionEnd, ContentPart, MCPLoadingBegin, MCPLoadingEnd, Notification, QuestionRequest, StatusUpdate, SteerInput, StepBegin, StepInterrupted, SubagentEvent, TextPart, ThinkPart, TodoDisplayBlock, ToolCall, ToolCallPart, ToolCallRequest, ToolResult, TurnBegin, TurnEnd, ) _current_turn_id = ContextVar[str | None]("current_turn_id", default=None) _terminal_tool_call_ids = ContextVar[set[str] | None]("terminal_tool_call_ids", default=None) def get_current_acp_tool_call_id_or_none() -> str | None: """See `_ToolCallState.acp_tool_call_id`.""" from kimi_cli.soul.toolset import get_current_tool_call_or_none turn_id = _current_turn_id.get() if turn_id is None: return None tool_call = get_current_tool_call_or_none() if tool_call is None: return None return f"{turn_id}/{tool_call.id}" def register_terminal_tool_call_id(tool_call_id: str) -> None: calls = _terminal_tool_call_ids.get() if calls is not None: calls.add(tool_call_id) def should_hide_terminal_output(tool_call_id: str) -> bool: calls = _terminal_tool_call_ids.get() return calls is not None and tool_call_id in calls class _ToolCallState: """Manages the state of a single tool call for streaming updates.""" def __init__(self, tool_call: ToolCall): self.tool_call = tool_call self.args = tool_call.function.arguments or "" self.lexer = streamingjson.Lexer() if tool_call.function.arguments is not None: self.lexer.append_string(tool_call.function.arguments) @property def acp_tool_call_id(self) -> str: # When the user rejected or cancelled a tool call, the step result may not # be appended to the context. In this case, future step may emit tool call # with the same tool call ID (on the LLM side). To avoid confusion of the # ACP client, we ensure the uniqueness by prefixing with the turn ID. turn_id = _current_turn_id.get() assert turn_id is not None return f"{turn_id}/{self.tool_call.id}" def append_args_part(self, args_part: str) -> None: """Append a new arguments part to the accumulated args and lexer.""" self.args += args_part self.lexer.append_string(args_part) def get_title(self) -> str: """Get the current title with subtitle if available.""" tool_name = self.tool_call.function.name subtitle = extract_key_argument(self.lexer, tool_name) if subtitle: return f"{tool_name}: {subtitle}" return tool_name class _TurnState: def __init__(self): self.id = str(uuid.uuid4()) """Unique ID for the turn.""" self.tool_calls: dict[str, _ToolCallState] = {} """Map of tool call ID (LLM-side ID) to tool call state.""" self.last_tool_call: _ToolCallState | None = None self.cancel_event = asyncio.Event() class ACPSession: def __init__( self, id: str, cli: KimiCLI, acp_conn: acp.Client, kaos: Kaos | None = None, ) -> None: self._id = id self._cli = cli self._conn = acp_conn self._kaos = kaos self._turn_state: _TurnState | None = None @property def id(self) -> str: """The ID of the ACP session.""" return self._id @property def cli(self) -> KimiCLI: """The Kimi Code CLI instance bound to this ACP session.""" return self._cli async def prompt(self, prompt: list[ACPContentBlock]) -> acp.PromptResponse: user_input = acp_blocks_to_content_parts(prompt) self._turn_state = _TurnState() token = _current_turn_id.set(self._turn_state.id) kaos_token = set_current_kaos(self._kaos) if self._kaos is not None else None terminal_tool_calls_token = _terminal_tool_call_ids.set(set()) try: async for msg in self._cli.run(user_input, self._turn_state.cancel_event): match msg: case TurnBegin(): pass case SteerInput(): pass case TurnEnd(): pass case StepBegin(): pass case StepInterrupted(): break case CompactionBegin(): pass case CompactionEnd(): pass case MCPLoadingBegin(): pass case MCPLoadingEnd(): pass case StatusUpdate(): pass case Notification(): await self._send_notification(msg) case ThinkPart(think=think): await self._send_thinking(think) case TextPart(text=text): await self._send_text(text) case ContentPart(): logger.warning("Unsupported content part: {part}", part=msg) await self._send_text(f"[{msg.__class__.__name__}]") case ToolCall(): await self._send_tool_call(msg) case ToolCallPart(): await self._send_tool_call_part(msg) case ToolResult(): await self._send_tool_result(msg) case ApprovalResponse(): pass case SubagentEvent(): pass case ApprovalRequest(): await self._handle_approval_request(msg) case ToolCallRequest(): logger.warning("Unexpected ToolCallRequest in ACP session: {msg}", msg=msg) case QuestionRequest(): logger.warning( "QuestionRequest is unsupported in ACP session; resolving empty answer." ) msg.resolve({}) except LLMNotSet as e: logger.exception("LLM not set:") raise acp.RequestError.auth_required() from e except LLMNotSupported as e: logger.exception("LLM not supported:") raise acp.RequestError.internal_error({"error": str(e)}) from e except ChatProviderError as e: logger.exception("LLM provider error:") raise acp.RequestError.internal_error({"error": str(e)}) from e except MaxStepsReached as e: logger.warning("Max steps reached: {n_steps}", n_steps=e.n_steps) return acp.PromptResponse(stop_reason="max_turn_requests") except RunCancelled: logger.info("Prompt cancelled by user") return acp.PromptResponse(stop_reason="cancelled") except Exception as e: logger.exception("Unexpected error during prompt:") raise acp.RequestError.internal_error({"error": str(e)}) from e finally: self._turn_state = None if kaos_token is not None: reset_current_kaos(kaos_token) _terminal_tool_call_ids.reset(terminal_tool_calls_token) _current_turn_id.reset(token) return acp.PromptResponse(stop_reason="end_turn") async def cancel(self) -> None: if self._turn_state is None: logger.warning("Cancel requested but no prompt is running") return self._turn_state.cancel_event.set() async def _send_thinking(self, think: str): """Send thinking content to client.""" if not self._id or not self._conn: return await self._conn.session_update( self._id, acp.schema.AgentThoughtChunk( content=acp.schema.TextContentBlock(type="text", text=think), session_update="agent_thought_chunk", ), ) async def _send_text(self, text: str): """Send text chunk to client.""" if not self._id or not self._conn: return await self._conn.session_update( session_id=self._id, update=acp.schema.AgentMessageChunk( content=acp.schema.TextContentBlock(type="text", text=text), session_update="agent_message_chunk", ), ) async def _send_notification(self, notification: Notification): """Send a system notification to the client as a text chunk.""" body = notification.body.strip() text = f"[Notification] {notification.title}" if body: text = f"{text}\n{body}" await self._send_text(text) async def _send_tool_call(self, tool_call: ToolCall): """Send tool call to client.""" assert self._turn_state is not None if not self._id or not self._conn: return # Create and store tool call state state = _ToolCallState(tool_call) self._turn_state.tool_calls[tool_call.id] = state self._turn_state.last_tool_call = state await self._conn.session_update( session_id=self._id, update=acp.schema.ToolCallStart( session_update="tool_call", tool_call_id=state.acp_tool_call_id, title=state.get_title(), status="in_progress", content=[ acp.schema.ContentToolCallContent( type="content", content=acp.schema.TextContentBlock(type="text", text=state.args), ) ], ), ) logger.debug("Sent tool call: {name}", name=tool_call.function.name) async def _send_tool_call_part(self, part: ToolCallPart): """Send tool call part (streaming arguments).""" assert self._turn_state is not None if ( not self._id or not self._conn or not part.arguments_part or self._turn_state.last_tool_call is None ): return # Append new arguments part to the last tool call self._turn_state.last_tool_call.append_args_part(part.arguments_part) # Update the tool call with new content and title update = acp.schema.ToolCallProgress( session_update="tool_call_update", tool_call_id=self._turn_state.last_tool_call.acp_tool_call_id, title=self._turn_state.last_tool_call.get_title(), status="in_progress", content=[ acp.schema.ContentToolCallContent( type="content", content=acp.schema.TextContentBlock( type="text", text=self._turn_state.last_tool_call.args ), ) ], ) await self._conn.session_update(session_id=self._id, update=update) logger.debug("Sent tool call update: {delta}", delta=part.arguments_part[:50]) async def _send_tool_result(self, result: ToolResult): """Send tool result to client.""" assert self._turn_state is not None if not self._id or not self._conn: return tool_ret = result.return_value state = self._turn_state.tool_calls.pop(result.tool_call_id, None) if state is None: logger.warning("Tool call not found: {id}", id=result.tool_call_id) return update = acp.schema.ToolCallProgress( session_update="tool_call_update", tool_call_id=state.acp_tool_call_id, status="failed" if tool_ret.is_error else "completed", ) contents = ( [] if should_hide_terminal_output(state.acp_tool_call_id) else tool_result_to_acp_content(tool_ret) ) if contents: update.content = contents await self._conn.session_update(session_id=self._id, update=update) logger.debug("Sent tool result: {id}", id=result.tool_call_id) for block in tool_ret.display: if isinstance(block, TodoDisplayBlock): await self._send_plan_update(block) async def _handle_approval_request(self, request: ApprovalRequest): """Handle approval request by sending permission request to client.""" assert self._turn_state is not None if not self._id or not self._conn: logger.warning("No session ID, auto-rejecting approval request") request.resolve("reject") return state = self._turn_state.tool_calls.get(request.tool_call_id, None) if state is None: logger.warning("Tool call not found: {id}", id=request.tool_call_id) request.resolve("reject") return try: content: list[ acp.schema.ContentToolCallContent | acp.schema.FileEditToolCallContent | acp.schema.TerminalToolCallContent ] = [] if request.display: for block in request.display: diff_content = display_block_to_acp_content(block) if diff_content is not None: content.append(diff_content) if not content: content.append( acp.schema.ContentToolCallContent( type="content", content=acp.schema.TextContentBlock( type="text", text=f"Requesting approval to perform: {request.description}", ), ) ) # Send permission request and wait for response logger.debug("Requesting permission for action: {action}", action=request.action) response = await self._conn.request_permission( [ acp.schema.PermissionOption( option_id="approve", name="Approve once", kind="allow_once", ), acp.schema.PermissionOption( option_id="approve_for_session", name="Approve for this session", kind="allow_always", ), acp.schema.PermissionOption( option_id="reject", name="Reject", kind="reject_once", ), ], self._id, acp.schema.ToolCallUpdate( tool_call_id=state.acp_tool_call_id, title=state.get_title(), content=content, ), ) logger.debug("Received permission response: {response}", response=response) # Process the outcome if isinstance(response.outcome, acp.schema.AllowedOutcome): # selected option_id = response.outcome.option_id if option_id == "approve": logger.debug("Permission granted for: {action}", action=request.action) request.resolve("approve") elif option_id == "approve_for_session": logger.debug("Permission granted for session: {action}", action=request.action) request.resolve("approve_for_session") else: logger.debug("Permission denied for: {action}", action=request.action) request.resolve("reject") else: # cancelled logger.debug("Permission request cancelled for: {action}", action=request.action) request.resolve("reject") except Exception: logger.exception("Error handling approval request:") # On error, reject the request request.resolve("reject") async def _send_plan_update(self, block: TodoDisplayBlock) -> None: """Send todo list updates as ACP agent plan updates.""" status_map: dict[str, acp.schema.PlanEntryStatus] = { "pending": "pending", "in progress": "in_progress", "in_progress": "in_progress", "done": "completed", "completed": "completed", } entries: list[acp.schema.PlanEntry] = [ acp.schema.PlanEntry( content=todo.title, priority="medium", status=status_map.get(todo.status.lower(), "pending"), ) for todo in block.items if todo.title ] if not entries: logger.warning("No valid todo items to send in plan update: {todos}", todos=block.items) return await self._conn.session_update( session_id=self._id, update=acp.schema.AgentPlanUpdate(session_update="plan", entries=entries), ) ================================================ FILE: src/kimi_cli/acp/tools.py ================================================ import asyncio from contextlib import suppress import acp from kaos import get_current_kaos from kaos.local import local_kaos from kosong.tooling import CallableTool2, ToolReturnValue from kimi_cli.soul.agent import Runtime from kimi_cli.soul.approval import Approval from kimi_cli.soul.toolset import KimiToolset from kimi_cli.tools.shell import Params as ShellParams from kimi_cli.tools.shell import Shell from kimi_cli.tools.utils import ToolRejectedError, ToolResultBuilder from kimi_cli.wire.types import DisplayBlock def replace_tools( client_capabilities: acp.schema.ClientCapabilities, acp_conn: acp.Client, acp_session_id: str, toolset: KimiToolset, runtime: Runtime, ) -> None: current_kaos = get_current_kaos().name if current_kaos not in (local_kaos.name, "acp"): # Only replace tools when running locally or under ACPKaos. return if client_capabilities.terminal and (shell_tool := toolset.find(Shell)): # Replace the Shell tool with the ACP Terminal tool if supported. toolset.add( Terminal( shell_tool, acp_conn, acp_session_id, runtime.approval, ) ) class HideOutputDisplayBlock(DisplayBlock): """A special DisplayBlock that indicates output should be hidden in ACP clients.""" type: str = "acp/hide_output" class Terminal(CallableTool2[ShellParams]): def __init__( self, shell_tool: Shell, acp_conn: acp.Client, acp_session_id: str, approval: Approval, ) -> None: # Use the `name`, `description`, and `params` from the existing Shell tool, # so that when this is added to the toolset, it replaces the original Shell tool. super().__init__(shell_tool.name, shell_tool.description, shell_tool.params) self._acp_conn = acp_conn self._acp_session_id = acp_session_id self._approval = approval async def __call__(self, params: ShellParams) -> ToolReturnValue: from kimi_cli.acp.session import get_current_acp_tool_call_id_or_none builder = ToolResultBuilder() # Hide tool output because we use `TerminalToolCallContent` which already streams output # directly to the user. builder.display(HideOutputDisplayBlock()) if not params.command: return builder.error("Command cannot be empty.", brief="Empty command") if not await self._approval.request( self.name, "run shell command", f"Run command `{params.command}`", ): return ToolRejectedError() timeout_seconds = float(params.timeout) timeout_label = f"{timeout_seconds:g}s" terminal: acp.TerminalHandle | None = None exit_status: ( acp.schema.WaitForTerminalExitResponse | acp.schema.TerminalExitStatus | None ) = None timed_out = False try: term = await self._acp_conn.create_terminal( command=params.command, session_id=self._acp_session_id, output_byte_limit=builder.max_chars, ) # FIXME: update ACP sdk for the fix assert isinstance(term, acp.TerminalHandle), ( "Expected TerminalHandle from create_terminal" ) terminal = term acp_tool_call_id = get_current_acp_tool_call_id_or_none() assert acp_tool_call_id, "Expected to have an ACP tool call ID in context" await self._acp_conn.session_update( session_id=self._acp_session_id, update=acp.schema.ToolCallProgress( session_update="tool_call_update", tool_call_id=acp_tool_call_id, status="in_progress", content=[ acp.schema.TerminalToolCallContent( type="terminal", terminal_id=terminal.id, ) ], ), ) try: async with asyncio.timeout(timeout_seconds): exit_status = await terminal.wait_for_exit() except TimeoutError: timed_out = True await terminal.kill() output_response = await terminal.current_output() builder.write(output_response.output) if output_response.exit_status: exit_status = output_response.exit_status exit_code = exit_status.exit_code if exit_status else None exit_signal = exit_status.signal if exit_status else None truncated_note = ( " Output was truncated by the client output limit." if output_response.truncated else "" ) if timed_out: return builder.error( f"Command killed by timeout ({timeout_label}){truncated_note}", brief=f"Killed by timeout ({timeout_label})", ) if exit_signal: return builder.error( f"Command terminated by signal: {exit_signal}.{truncated_note}", brief=f"Signal: {exit_signal}", ) if exit_code not in (None, 0): return builder.error( f"Command failed with exit code: {exit_code}.{truncated_note}", brief=f"Failed with exit code: {exit_code}", ) return builder.ok(f"Command executed successfully.{truncated_note}") finally: if terminal is not None: with suppress(Exception): await terminal.release() ================================================ FILE: src/kimi_cli/acp/types.py ================================================ from __future__ import annotations import acp MCPServer = acp.schema.HttpMcpServer | acp.schema.SseMcpServer | acp.schema.McpServerStdio ACPContentBlock = ( acp.schema.TextContentBlock | acp.schema.ImageContentBlock | acp.schema.AudioContentBlock | acp.schema.ResourceContentBlock | acp.schema.EmbeddedResourceContentBlock ) ================================================ FILE: src/kimi_cli/acp/version.py ================================================ from __future__ import annotations from dataclasses import dataclass @dataclass(frozen=True) class ACPVersionSpec: """Describes one supported ACP protocol version.""" protocol_version: int # negotiation integer (currently 1) spec_tag: str # ACP spec tag (e.g. "v0.10.8") sdk_version: str # corresponding SDK version (e.g. "0.8.0") CURRENT_VERSION = ACPVersionSpec( protocol_version=1, spec_tag="v0.10.8", sdk_version="0.8.0", ) SUPPORTED_VERSIONS: dict[int, ACPVersionSpec] = { 1: CURRENT_VERSION, } MIN_PROTOCOL_VERSION = 1 def negotiate_version(client_protocol_version: int) -> ACPVersionSpec: """Negotiate the protocol version with the client. Returns the highest server-supported version that does not exceed the client's requested version. If the client version is lower than ``MIN_PROTOCOL_VERSION`` the server still returns its own current version so the client can decide whether to disconnect. """ if client_protocol_version < MIN_PROTOCOL_VERSION: return CURRENT_VERSION # Find the highest supported version <= client version best: ACPVersionSpec | None = None for ver, spec in SUPPORTED_VERSIONS.items(): if ver <= client_protocol_version and (best is None or ver > best.protocol_version): best = spec return best if best is not None else CURRENT_VERSION ================================================ FILE: src/kimi_cli/agents/default/agent.yaml ================================================ version: 1 agent: name: "" system_prompt_path: ./system.md system_prompt_args: ROLE_ADDITIONAL: "" tools: - "kimi_cli.tools.multiagent:Task" # - "kimi_cli.tools.multiagent:CreateSubagent" # - "kimi_cli.tools.dmail:SendDMail" # - "kimi_cli.tools.think:Think" - "kimi_cli.tools.ask_user:AskUserQuestion" - "kimi_cli.tools.todo:SetTodoList" - "kimi_cli.tools.shell:Shell" - "kimi_cli.tools.background:TaskList" - "kimi_cli.tools.background:TaskOutput" - "kimi_cli.tools.background:TaskStop" - "kimi_cli.tools.file:ReadFile" - "kimi_cli.tools.file:ReadMediaFile" - "kimi_cli.tools.file:Glob" - "kimi_cli.tools.file:Grep" - "kimi_cli.tools.file:WriteFile" - "kimi_cli.tools.file:StrReplaceFile" - "kimi_cli.tools.web:SearchWeb" - "kimi_cli.tools.web:FetchURL" - "kimi_cli.tools.plan:ExitPlanMode" - "kimi_cli.tools.plan.enter:EnterPlanMode" subagents: coder: path: ./sub.yaml description: "Good at general software engineering tasks." ================================================ FILE: src/kimi_cli/agents/default/sub.yaml ================================================ version: 1 agent: extend: ./agent.yaml system_prompt_args: ROLE_ADDITIONAL: | You are now running as a subagent. All the `user` messages are sent by the main agent. The main agent cannot see your context, it can only see your last message when you finish the task. You need to provide a comprehensive summary on what you have done and learned in your final message. If you wrote or modified any files, you must mention them in the summary. exclude_tools: - "kimi_cli.tools.multiagent:Task" - "kimi_cli.tools.multiagent:CreateSubagent" - "kimi_cli.tools.dmail:SendDMail" - "kimi_cli.tools.todo:SetTodoList" - "kimi_cli.tools.plan:ExitPlanMode" - "kimi_cli.tools.plan.enter:EnterPlanMode" subagents: # make sure no subagents are provided ================================================ FILE: src/kimi_cli/agents/default/system.md ================================================ You are Kimi Code CLI, an interactive general AI agent running on a user's computer. Your primary goal is to answer questions and/or finish tasks safely and efficiently, adhering strictly to the following system instructions and the user's requirements, leveraging the available tools flexibly. ${ROLE_ADDITIONAL} # Prompt and Tool Use The user's messages may contain questions and/or task descriptions in natural language, code snippets, logs, file paths, or other forms of information. Read them, understand them and do what the user requested. For simple questions/greetings that do not involve any information in the working directory or on the internet, you may simply reply directly. When handling the user's request, you may call available tools to accomplish the task. When calling tools, do not provide explanations because the tool calls themselves should be self-explanatory. You MUST follow the description of each tool and its parameters when calling tools. You have the capability to output any number of tool calls in a single response. If you anticipate making multiple non-interfering tool calls, you are HIGHLY RECOMMENDED to make them in parallel to significantly improve efficiency. This is very important to your performance. The results of the tool calls will be returned to you in a tool message. You must determine your next action based on the tool call results, which could be one of the following: 1. Continue working on the task, 2. Inform the user that the task is completed or has failed, or 3. Ask the user for more information. The system may insert information wrapped in `` tags within user or tool messages. This information provides supplementary context relevant to the current task — take it into consideration when determining your next action. Tool results and user messages may also include `` tags. Unlike `` tags, these are **authoritative system directives** that you MUST follow. They bear no direct relation to the specific tool results or user messages in which they appear. Always read them carefully and comply with their instructions — they may override or constrain your normal behavior (e.g., restricting you to read-only actions during plan mode). If the `Shell`, `TaskList`, `TaskOutput`, and `TaskStop` tools are available and you are the root agent, you can use Background Bash for long-running shell commands. Launch it via `Shell` with `run_in_background=true` and a short `description`. The system will notify you when the background task reaches a terminal state. Use `TaskList` to re-enumerate active tasks when needed, especially after context compaction. Use `TaskOutput` to inspect progress or wait for completion, and use `TaskStop` only when you need to cancel the task. For human users in the interactive shell, the only task-management slash command is `/task`. Do not tell users to run `/task list`, `/task output`, `/task stop`, `/tasks`, or any other invented slash subcommands. If you are a subagent or these tools are not available, do not assume you can create or control background tasks. When responding to the user, you MUST use the SAME language as the user, unless explicitly instructed to do otherwise. # General Guidelines for Coding When building something from scratch, you should: - Understand the user's requirements. - Ask the user for clarification if there is anything unclear. - Design the architecture and make a plan for the implementation. - Write the code in a modular and maintainable way. When working on an existing codebase, you should: - Understand the codebase and the user's requirements. Identify the ultimate goal and the most important criteria to achieve the goal. - For a bug fix, you typically need to check error logs or failed tests, scan over the codebase to find the root cause, and figure out a fix. If user mentioned any failed tests, you should make sure they pass after the changes. - For a feature, you typically need to design the architecture, and write the code in a modular and maintainable way, with minimal intrusions to existing code. Add new tests if the project already has tests. - For a code refactoring, you typically need to update all the places that call the code you are refactoring if the interface changes. DO NOT change any existing logic especially in tests, focus only on fixing any errors caused by the interface changes. - Make MINIMAL changes to achieve the goal. This is very important to your performance. - Follow the coding style of existing code in the project. DO NOT run `git commit`, `git push`, `git reset`, `git rebase` and/or do any other git mutations unless explicitly asked to do so. Ask for confirmation each time when you need to do git mutations, even if the user has confirmed in earlier conversations. # General Guidelines for Research and Data Processing The user may ask you to research on certain topics, process or generate certain multimedia files. When doing such tasks, you must: - Understand the user's requirements thoroughly, ask for clarification before you start if needed. - Make plans before doing deep or wide research, to ensure you are always on track. - Search on the Internet if possible, with carefully-designed search queries to improve efficiency and accuracy. - Use proper tools or shell commands or Python packages to process or generate images, videos, PDFs, docs, spreadsheets, presentations, or other multimedia files. Detect if there are already such tools in the environment. If you have to install third-party tools/packages, you MUST ensure that they are installed in a virtual/isolated environment. - Once you generate or edit any images, videos or other media files, try to read it again before proceed, to ensure that the content is as expected. - Avoid installing or deleting anything to/from outside of the current working directory. If you have to do so, ask the user for confirmation. # Working Environment ## Operating System The operating environment is not in a sandbox. Any actions you do will immediately affect the user's system. So you MUST be extremely cautious. Unless being explicitly instructed to do so, you should never access (read/write/execute) files outside of the working directory. ## Date and Time The current date and time in ISO format is `${KIMI_NOW}`. This is only a reference for you when searching the web, or checking file modification time, etc. If you need the exact time, use Shell tool with proper command. ## Working Directory The current working directory is `${KIMI_WORK_DIR}`. This should be considered as the project root if you are instructed to perform tasks on the project. Every file system operation will be relative to the working directory if you do not explicitly specify the absolute path. Tools may require absolute paths for some parameters, IF SO, YOU MUST use absolute paths for these parameters. The directory listing of current working directory is: ``` ${KIMI_WORK_DIR_LS} ``` Use this as your basic understanding of the project structure. {% if KIMI_ADDITIONAL_DIRS_INFO %} ## Additional Directories The following directories have been added to the workspace. You can read, write, search, and glob files in these directories as part of your workspace scope. ${KIMI_ADDITIONAL_DIRS_INFO} {% endif %} # Project Information Markdown files named `AGENTS.md` usually contain the background, structure, coding styles, user preferences and other relevant information about the project. You should use this information to understand the project and the user's preferences. `AGENTS.md` files may exist at different locations in the project, but typically there is one in the project root. > Why `AGENTS.md`? > > `README.md` files are for humans: quick starts, project descriptions, and contribution guidelines. `AGENTS.md` complements this by containing the extra, sometimes detailed context coding agents need: build steps, tests, and conventions that might clutter a README or aren’t relevant to human contributors. > > We intentionally kept it separate to: > > - Give agents a clear, predictable place for instructions. > - Keep `README`s concise and focused on human contributors. > - Provide precise, agent-focused guidance that complements existing `README` and docs. The project level `${KIMI_WORK_DIR}/AGENTS.md`: ````````` ${KIMI_AGENTS_MD} ````````` If the above `AGENTS.md` is empty or insufficient, you may check `README`/`README.md` files or `AGENTS.md` files in subdirectories for more information about specific parts of the project. If you modified any files/styles/structures/configurations/workflows/... mentioned in `AGENTS.md` files, you MUST update the corresponding `AGENTS.md` files to keep them up-to-date. # Skills Skills are reusable, composable capabilities that enhance your abilities. Each skill is a self-contained directory with a `SKILL.md` file that contains instructions, examples, and/or reference material. ## What are skills? Skills are modular extensions that provide: - Specialized knowledge: Domain-specific expertise (e.g., PDF processing, data analysis) - Workflow patterns: Best practices for common tasks - Tool integrations: Pre-configured tool chains for specific operations - Reference material: Documentation, templates, and examples ## Available skills ${KIMI_SKILLS} ## How to use skills Identify the skills that are likely to be useful for the tasks you are currently working on, read the `SKILL.md` file for detailed instructions, guidelines, scripts and more. Only read skill details when needed to conserve the context window. # Ultimate Reminders At any time, you should be HELPFUL and POLITE, CONCISE and ACCURATE, PATIENT and THOROUGH. - Never diverge from the requirements and the goals of the task you work on. Stay on track. - Never give the user more than what they want. - Try your best to avoid any hallucination. Do fact checking before providing any factual information. - Think twice before you act. - Do not give up too early. - ALWAYS, keep it stupidly simple. Do not overcomplicate things. ================================================ FILE: src/kimi_cli/agents/okabe/agent.yaml ================================================ version: 1 agent: extend: default tools: - "kimi_cli.tools.multiagent:Task" # - "kimi_cli.tools.multiagent:CreateSubagent" - "kimi_cli.tools.dmail:SendDMail" # - "kimi_cli.tools.think:Think" - "kimi_cli.tools.todo:SetTodoList" - "kimi_cli.tools.shell:Shell" - "kimi_cli.tools.file:ReadFile" - "kimi_cli.tools.file:ReadMediaFile" - "kimi_cli.tools.file:Glob" - "kimi_cli.tools.file:Grep" - "kimi_cli.tools.file:WriteFile" - "kimi_cli.tools.file:StrReplaceFile" - "kimi_cli.tools.web:SearchWeb" - "kimi_cli.tools.web:FetchURL" ================================================ FILE: src/kimi_cli/agentspec.py ================================================ from __future__ import annotations from dataclasses import dataclass from pathlib import Path from typing import Any, NamedTuple import yaml from pydantic import BaseModel, Field from kimi_cli.exception import AgentSpecError DEFAULT_AGENT_SPEC_VERSION = "1" SUPPORTED_AGENT_SPEC_VERSIONS = (DEFAULT_AGENT_SPEC_VERSION,) def get_agents_dir() -> Path: return Path(__file__).parent / "agents" DEFAULT_AGENT_FILE = get_agents_dir() / "default" / "agent.yaml" OKABE_AGENT_FILE = get_agents_dir() / "okabe" / "agent.yaml" class Inherit(NamedTuple): """Marker class for inheritance in agent spec.""" inherit = Inherit() class AgentSpec(BaseModel): """Agent specification.""" extend: str | None = Field(default=None, description="Agent file to extend") name: str | Inherit = Field(default=inherit, description="Agent name") # required system_prompt_path: Path | Inherit = Field( default=inherit, description="System prompt path" ) # required system_prompt_args: dict[str, str] = Field( default_factory=dict, description="System prompt arguments" ) tools: list[str] | None | Inherit = Field(default=inherit, description="Tools") # required exclude_tools: list[str] | None | Inherit = Field( default=inherit, description="Tools to exclude" ) subagents: dict[str, SubagentSpec] | None | Inherit = Field( default=inherit, description="Subagents" ) class SubagentSpec(BaseModel): """Subagent specification.""" path: Path = Field(description="Subagent file path") description: str = Field(description="Subagent description") @dataclass(frozen=True, slots=True, kw_only=True) class ResolvedAgentSpec: """Resolved agent specification.""" name: str system_prompt_path: Path system_prompt_args: dict[str, str] tools: list[str] exclude_tools: list[str] subagents: dict[str, SubagentSpec] def load_agent_spec(agent_file: Path) -> ResolvedAgentSpec: """ Load agent specification from file. Raises: FileNotFoundError: If the agent spec file is not found. AgentSpecError: If the agent spec is not valid. """ agent_spec = _load_agent_spec(agent_file) assert agent_spec.extend is None, "agent extension should be recursively resolved" if isinstance(agent_spec.name, Inherit): raise AgentSpecError("Agent name is required") if isinstance(agent_spec.system_prompt_path, Inherit): raise AgentSpecError("System prompt path is required") if isinstance(agent_spec.tools, Inherit): raise AgentSpecError("Tools are required") if isinstance(agent_spec.exclude_tools, Inherit): agent_spec.exclude_tools = [] if isinstance(agent_spec.subagents, Inherit): agent_spec.subagents = {} return ResolvedAgentSpec( name=agent_spec.name, system_prompt_path=agent_spec.system_prompt_path, system_prompt_args=agent_spec.system_prompt_args, tools=agent_spec.tools or [], exclude_tools=agent_spec.exclude_tools or [], subagents=agent_spec.subagents or {}, ) def _load_agent_spec(agent_file: Path) -> AgentSpec: if not agent_file.exists(): raise AgentSpecError(f"Agent spec file not found: {agent_file}") if not agent_file.is_file(): raise AgentSpecError(f"Agent spec path is not a file: {agent_file}") try: with open(agent_file, encoding="utf-8") as f: data: dict[str, Any] = yaml.safe_load(f) except yaml.YAMLError as e: raise AgentSpecError(f"Invalid YAML in agent spec file: {e}") from e version = str(data.get("version", DEFAULT_AGENT_SPEC_VERSION)) if version not in SUPPORTED_AGENT_SPEC_VERSIONS: raise AgentSpecError(f"Unsupported agent spec version: {version}") agent_spec = AgentSpec(**data.get("agent", {})) if isinstance(agent_spec.system_prompt_path, Path): agent_spec.system_prompt_path = ( agent_file.parent / agent_spec.system_prompt_path ).absolute() if isinstance(agent_spec.subagents, dict): for v in agent_spec.subagents.values(): v.path = (agent_file.parent / v.path).absolute() if agent_spec.extend: if agent_spec.extend == "default": base_agent_file = DEFAULT_AGENT_FILE else: base_agent_file = (agent_file.parent / agent_spec.extend).absolute() base_agent_spec = _load_agent_spec(base_agent_file) if not isinstance(agent_spec.name, Inherit): base_agent_spec.name = agent_spec.name if not isinstance(agent_spec.system_prompt_path, Inherit): base_agent_spec.system_prompt_path = agent_spec.system_prompt_path for k, v in agent_spec.system_prompt_args.items(): # system prompt args should be merged instead of overwritten base_agent_spec.system_prompt_args[k] = v if not isinstance(agent_spec.tools, Inherit): base_agent_spec.tools = agent_spec.tools if not isinstance(agent_spec.exclude_tools, Inherit): base_agent_spec.exclude_tools = agent_spec.exclude_tools if not isinstance(agent_spec.subagents, Inherit): base_agent_spec.subagents = agent_spec.subagents agent_spec = base_agent_spec return agent_spec ================================================ FILE: src/kimi_cli/app.py ================================================ from __future__ import annotations import asyncio import contextlib import dataclasses import warnings from collections.abc import AsyncGenerator, Callable from pathlib import Path from typing import TYPE_CHECKING, Any import kaos from kaos.path import KaosPath from pydantic import SecretStr from kimi_cli.agentspec import DEFAULT_AGENT_FILE from kimi_cli.auth.oauth import OAuthManager from kimi_cli.cli import InputFormat, OutputFormat from kimi_cli.config import Config, LLMModel, LLMProvider, load_config from kimi_cli.llm import augment_provider_with_env_vars, create_llm, model_display_name from kimi_cli.session import Session from kimi_cli.share import get_share_dir from kimi_cli.soul import run_soul from kimi_cli.soul.agent import Runtime, load_agent from kimi_cli.soul.context import Context from kimi_cli.soul.kimisoul import KimiSoul from kimi_cli.utils.aioqueue import QueueShutDown from kimi_cli.utils.logging import logger, redirect_stderr_to_logger from kimi_cli.utils.path import shorten_home from kimi_cli.wire import Wire, WireUISide from kimi_cli.wire.types import ContentPart, WireMessage if TYPE_CHECKING: from fastmcp.mcp_config import MCPConfig def enable_logging(debug: bool = False, *, redirect_stderr: bool = True) -> None: # NOTE: stderr redirection is implemented by swapping the process-level fd=2 (dup2). # That can hide Click/Typer error output during CLI startup, so some entrypoints delay # installing it until after critical initialization succeeds. logger.remove() # Remove default stderr handler logger.enable("kimi_cli") if debug: logger.enable("kosong") logger.add( get_share_dir() / "logs" / "kimi.log", # FIXME: configure level for different modules level="TRACE" if debug else "INFO", rotation="06:00", retention="10 days", ) if redirect_stderr: redirect_stderr_to_logger() class KimiCLI: @staticmethod async def create( session: Session, *, # Basic configuration config: Config | Path | None = None, model_name: str | None = None, thinking: bool | None = None, # Run mode yolo: bool = False, # Extensions agent_file: Path | None = None, mcp_configs: list[MCPConfig] | list[dict[str, Any]] | None = None, skills_dir: KaosPath | None = None, # Loop control max_steps_per_turn: int | None = None, max_retries_per_step: int | None = None, max_ralph_iterations: int | None = None, startup_progress: Callable[[str], None] | None = None, defer_mcp_loading: bool = False, ) -> KimiCLI: """ Create a KimiCLI instance. Args: session (Session): A session created by `Session.create` or `Session.continue_`. config (Config | Path | None, optional): Configuration to use, or path to config file. Defaults to None. model_name (str | None, optional): Name of the model to use. Defaults to None. thinking (bool | None, optional): Whether to enable thinking mode. Defaults to None. yolo (bool, optional): Approve all actions without confirmation. Defaults to False. agent_file (Path | None, optional): Path to the agent file. Defaults to None. mcp_configs (list[MCPConfig | dict[str, Any]] | None, optional): MCP configs to load MCP tools from. Defaults to None. skills_dir (KaosPath | None, optional): Override skills directory discovery. Defaults to None. max_steps_per_turn (int | None, optional): Maximum number of steps in one turn. Defaults to None. max_retries_per_step (int | None, optional): Maximum number of retries in one step. Defaults to None. max_ralph_iterations (int | None, optional): Extra iterations after the first turn in Ralph mode. Defaults to None. startup_progress (Callable[[str], None] | None, optional): Progress callback used by interactive startup UI. Defaults to None. defer_mcp_loading (bool, optional): Defer MCP startup until the interactive shell is ready. Defaults to False. Raises: FileNotFoundError: When the agent file is not found. ConfigError(KimiCLIException, ValueError): When the configuration is invalid. AgentSpecError(KimiCLIException, ValueError): When the agent specification is invalid. SystemPromptTemplateError(KimiCLIException, ValueError): When the system prompt template is invalid. InvalidToolError(KimiCLIException, ValueError): When any tool cannot be loaded. MCPConfigError(KimiCLIException, ValueError): When any MCP configuration is invalid. MCPRuntimeError(KimiCLIException, RuntimeError): When any MCP server cannot be connected. """ if startup_progress is not None: startup_progress("Loading configuration...") config = config if isinstance(config, Config) else load_config(config) if max_steps_per_turn is not None: config.loop_control.max_steps_per_turn = max_steps_per_turn if max_retries_per_step is not None: config.loop_control.max_retries_per_step = max_retries_per_step if max_ralph_iterations is not None: config.loop_control.max_ralph_iterations = max_ralph_iterations logger.info("Loaded config: {config}", config=config) oauth = OAuthManager(config) model: LLMModel | None = None provider: LLMProvider | None = None # try to use config file if not model_name and config.default_model: # no --model specified && default model is set in config model = config.models[config.default_model] provider = config.providers[model.provider] if model_name and model_name in config.models: # --model specified && model is set in config model = config.models[model_name] provider = config.providers[model.provider] if not model: model = LLMModel(provider="", model="", max_context_size=100_000) provider = LLMProvider(type="kimi", base_url="", api_key=SecretStr("")) # try overwrite with environment variables assert provider is not None assert model is not None env_overrides = augment_provider_with_env_vars(provider, model) # determine thinking mode thinking = config.default_thinking if thinking is None else thinking # determine yolo mode yolo = yolo if yolo else config.default_yolo llm = create_llm( provider, model, thinking=thinking, session_id=session.id, oauth=oauth, ) if llm is not None: logger.info("Using LLM provider: {provider}", provider=provider) logger.info("Using LLM model: {model}", model=model) logger.info("Thinking mode: {thinking}", thinking=thinking) if startup_progress is not None: startup_progress("Scanning workspace...") runtime = await Runtime.create(config, oauth, llm, session, yolo, skills_dir) runtime.notifications.recover() runtime.background_tasks.reconcile() # Refresh plugin configs with fresh credentials (e.g. OAuth tokens) try: from kimi_cli.plugin.manager import ( collect_host_values, get_plugins_dir, refresh_plugin_configs, ) host_values = collect_host_values(config, oauth) if host_values.get("api_key"): refresh_plugin_configs(get_plugins_dir(), host_values) except Exception: logger.debug("Failed to refresh plugin configs, skipping") if agent_file is None: agent_file = DEFAULT_AGENT_FILE if startup_progress is not None: startup_progress("Loading agent...") agent = await load_agent( agent_file, runtime, mcp_configs=mcp_configs or [], start_mcp_loading=not defer_mcp_loading, ) if startup_progress is not None: startup_progress("Restoring conversation...") context = Context(session.context_file) await context.restore() if context.system_prompt is not None: agent = dataclasses.replace(agent, system_prompt=context.system_prompt) else: await context.write_system_prompt(agent.system_prompt) soul = KimiSoul(agent, context=context) return KimiCLI(soul, runtime, env_overrides) def __init__( self, _soul: KimiSoul, _runtime: Runtime, _env_overrides: dict[str, str], ) -> None: self._soul = _soul self._runtime = _runtime self._env_overrides = _env_overrides @property def soul(self) -> KimiSoul: """Get the KimiSoul instance.""" return self._soul @property def session(self) -> Session: """Get the Session instance.""" return self._runtime.session def shutdown_background_tasks(self) -> None: """Kill active background tasks on exit, unless keep_alive_on_exit is configured.""" if self._runtime.config.background.keep_alive_on_exit: return killed = self._runtime.background_tasks.kill_all_active(reason="CLI session ended") if killed: logger.info("Stopped {n} background task(s) on exit: {ids}", n=len(killed), ids=killed) @contextlib.asynccontextmanager async def _env(self) -> AsyncGenerator[None]: original_cwd = KaosPath.cwd() await kaos.chdir(self._runtime.session.work_dir) try: # to ignore possible warnings from dateparser warnings.filterwarnings("ignore", category=DeprecationWarning) async with self._runtime.oauth.refreshing(self._runtime): yield finally: await kaos.chdir(original_cwd) async def run( self, user_input: str | list[ContentPart], cancel_event: asyncio.Event, merge_wire_messages: bool = False, ) -> AsyncGenerator[WireMessage]: """ Run the Kimi Code CLI instance without any UI and yield Wire messages directly. Args: user_input (str | list[ContentPart]): The user input to the agent. cancel_event (asyncio.Event): An event to cancel the run. merge_wire_messages (bool): Whether to merge Wire messages as much as possible. Yields: WireMessage: The Wire messages from the `KimiSoul`. Raises: LLMNotSet: When the LLM is not set. LLMNotSupported: When the LLM does not have required capabilities. ChatProviderError: When the LLM provider returns an error. MaxStepsReached: When the maximum number of steps is reached. RunCancelled: When the run is cancelled by the cancel event. """ async with self._env(): wire_future = asyncio.Future[WireUISide]() stop_ui_loop = asyncio.Event() async def _ui_loop_fn(wire: Wire) -> None: wire_future.set_result(wire.ui_side(merge=merge_wire_messages)) await stop_ui_loop.wait() soul_task = asyncio.create_task( run_soul( self.soul, user_input, _ui_loop_fn, cancel_event, runtime=self._runtime, ) ) try: wire_ui = await wire_future while True: msg = await wire_ui.receive() yield msg except QueueShutDown: pass finally: # stop consuming Wire messages stop_ui_loop.set() # wait for the soul task to finish, or raise await soul_task async def run_shell(self, command: str | None = None) -> bool: """Run the Kimi Code CLI instance with shell UI.""" from kimi_cli.ui.shell import Shell, WelcomeInfoItem welcome_info = [ WelcomeInfoItem( name="Directory", value=str(shorten_home(self._runtime.session.work_dir)) ), WelcomeInfoItem(name="Session", value=self._runtime.session.id), ] if base_url := self._env_overrides.get("KIMI_BASE_URL"): welcome_info.append( WelcomeInfoItem( name="API URL", value=f"{base_url} (from KIMI_BASE_URL)", level=WelcomeInfoItem.Level.WARN, ) ) if self._env_overrides.get("KIMI_API_KEY"): welcome_info.append( WelcomeInfoItem( name="API Key", value="****** (from KIMI_API_KEY)", level=WelcomeInfoItem.Level.WARN, ) ) if not self._runtime.llm: welcome_info.append( WelcomeInfoItem( name="Model", value="not set, send /login to login", level=WelcomeInfoItem.Level.WARN, ) ) elif "KIMI_MODEL_NAME" in self._env_overrides: welcome_info.append( WelcomeInfoItem( name="Model", value=f"{self._soul.model_name} (from KIMI_MODEL_NAME)", level=WelcomeInfoItem.Level.WARN, ) ) else: welcome_info.append( WelcomeInfoItem( name="Model", value=model_display_name(self._soul.model_name), level=WelcomeInfoItem.Level.INFO, ) ) if self._soul.model_name not in ( "kimi-for-coding", "kimi-code", "kimi-k2.5", "kimi-k2-5", ): welcome_info.append( WelcomeInfoItem( name="Tip", value="send /login to use our latest kimi-k2.5 model", level=WelcomeInfoItem.Level.WARN, ) ) welcome_info.append( WelcomeInfoItem( name="\nTip", value=( "Kimi Code Web UI, a GUI version of Kimi Code, is now in technical preview." "\n" " Type /web to switch, or next time run `kimi web` directly." ), level=WelcomeInfoItem.Level.INFO, ) ) async with self._env(): shell = Shell(self._soul, welcome_info=welcome_info) return await shell.run(command) async def run_print( self, input_format: InputFormat, output_format: OutputFormat, command: str | None = None, *, final_only: bool = False, ) -> bool: """Run the Kimi Code CLI instance with print UI.""" from kimi_cli.ui.print import Print async with self._env(): print_ = Print( self._soul, input_format, output_format, self._runtime.session.context_file, final_only=final_only, ) return await print_.run(command) async def run_acp(self) -> None: """Run the Kimi Code CLI instance as ACP server.""" from kimi_cli.ui.acp import ACP async with self._env(): acp = ACP(self._soul) await acp.run() async def run_wire_stdio(self) -> None: """Run the Kimi Code CLI instance as Wire server over stdio.""" from kimi_cli.wire.server import WireServer async with self._env(): server = WireServer(self._soul) await server.serve() ================================================ FILE: src/kimi_cli/auth/__init__.py ================================================ from __future__ import annotations KIMI_CODE_PLATFORM_ID = "kimi-code" __all__ = ["KIMI_CODE_PLATFORM_ID"] ================================================ FILE: src/kimi_cli/auth/oauth.py ================================================ from __future__ import annotations import asyncio import json import os import platform import socket import sys import time import uuid import webbrowser from collections.abc import AsyncIterator from contextlib import asynccontextmanager, suppress from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, cast import aiohttp import keyring from pydantic import SecretStr from kimi_cli.auth import KIMI_CODE_PLATFORM_ID from kimi_cli.auth.platforms import ( ModelInfo, get_platform_by_id, list_models, managed_model_key, managed_provider_key, ) from kimi_cli.config import ( Config, LLMModel, LLMProvider, MoonshotFetchConfig, MoonshotSearchConfig, OAuthRef, save_config, ) from kimi_cli.constant import VERSION from kimi_cli.share import get_share_dir from kimi_cli.utils.aiohttp import new_client_session from kimi_cli.utils.logging import logger if TYPE_CHECKING: from kimi_cli.soul.agent import Runtime KIMI_CODE_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098" KIMI_CODE_OAUTH_KEY = "oauth/kimi-code" DEFAULT_OAUTH_HOST = "https://auth.kimi.com" KEYRING_SERVICE = "kimi-code" REFRESH_INTERVAL_SECONDS = 60 REFRESH_THRESHOLD_SECONDS = 300 class OAuthError(RuntimeError): """OAuth flow error.""" class OAuthUnauthorized(OAuthError): """OAuth credentials rejected.""" class OAuthDeviceExpired(OAuthError): """Device authorization expired.""" OAuthEventKind = Literal["info", "error", "waiting", "verification_url", "success"] @dataclass(slots=True, frozen=True) class OAuthEvent: type: OAuthEventKind message: str data: dict[str, Any] | None = None def __str__(self) -> str: return self.message @property def json(self) -> str: payload: dict[str, Any] = {"type": self.type, "message": self.message} if self.data is not None: payload["data"] = self.data return json.dumps(payload, ensure_ascii=False) @dataclass(slots=True) class OAuthToken: access_token: str refresh_token: str expires_at: float scope: str token_type: str @classmethod def from_response(cls, payload: dict[str, Any]) -> OAuthToken: expires_in = float(payload["expires_in"]) return cls( access_token=str(payload["access_token"]), refresh_token=str(payload["refresh_token"]), expires_at=time.time() + expires_in, scope=str(payload["scope"]), token_type=str(payload["token_type"]), ) def to_dict(self) -> dict[str, Any]: return { "access_token": self.access_token, "refresh_token": self.refresh_token, "expires_at": self.expires_at, "scope": self.scope, "token_type": self.token_type, } @classmethod def from_dict(cls, payload: dict[str, Any]) -> OAuthToken: expires_at_value = payload.get("expires_at") return cls( access_token=str(payload.get("access_token") or ""), refresh_token=str(payload.get("refresh_token") or ""), expires_at=float(expires_at_value) if expires_at_value is not None else 0.0, scope=str(payload.get("scope") or ""), token_type=str(payload.get("token_type") or ""), ) @dataclass(slots=True) class DeviceAuthorization: user_code: str device_code: str verification_uri: str verification_uri_complete: str expires_in: int | None interval: int def _oauth_host() -> str: return os.getenv("KIMI_CODE_OAUTH_HOST") or os.getenv("KIMI_OAUTH_HOST") or DEFAULT_OAUTH_HOST def _device_id_path() -> Path: return get_share_dir() / "device_id" def _ensure_private_file(path: Path) -> None: with suppress(OSError): os.chmod(path, 0o600) def _device_model() -> str: system = platform.system() arch = platform.machine() or "" if system == "Darwin": version = platform.mac_ver()[0] or platform.release() if version and arch: return f"macOS {version} {arch}" if version: return f"macOS {version}" return f"macOS {arch}".strip() if system == "Windows": release = platform.release() if release == "10": try: build = sys.getwindowsversion().build # type: ignore[attr-defined] except Exception: build = None if build and build >= 22000: release = "11" if release and arch: return f"Windows {release} {arch}" if release: return f"Windows {release}" return f"Windows {arch}".strip() if system: version = platform.release() if version and arch: return f"{system} {version} {arch}" if version: return f"{system} {version}" return f"{system} {arch}".strip() return "Unknown" def get_device_id() -> str: path = _device_id_path() if path.exists(): return path.read_text(encoding="utf-8").strip() device_id = uuid.uuid4().hex path.write_text(device_id, encoding="utf-8") _ensure_private_file(path) return device_id def _ascii_header_value(value: str, *, fallback: str = "unknown") -> str: try: value.encode("ascii") return value.strip() except UnicodeEncodeError: sanitized = value.encode("ascii", errors="ignore").decode("ascii").strip() return sanitized or fallback def _common_headers() -> dict[str, str]: device_name = platform.node() or socket.gethostname() device_model = _device_model() headers = { "X-Msh-Platform": "kimi_cli", "X-Msh-Version": VERSION, "X-Msh-Device-Name": device_name, "X-Msh-Device-Model": device_model, "X-Msh-Os-Version": platform.version(), "X-Msh-Device-Id": get_device_id(), } return {key: _ascii_header_value(value) for key, value in headers.items()} def _credentials_dir() -> Path: path = get_share_dir() / "credentials" path.mkdir(parents=True, exist_ok=True) return path def _credentials_path(key: str) -> Path: name = key.removeprefix("oauth/").split("/")[-1] or key return _credentials_dir() / f"{name}.json" def _load_from_keyring(key: str) -> OAuthToken | None: try: raw = keyring.get_password(KEYRING_SERVICE, key) except Exception as exc: logger.warning("Failed to read token from keyring: {error}", error=exc) return None if not raw: return None try: payload = json.loads(raw) except json.JSONDecodeError: return None if not isinstance(payload, dict): return None payload = cast(dict[str, Any], payload) return OAuthToken.from_dict(payload) def _delete_from_keyring(key: str) -> None: try: keyring.delete_password(KEYRING_SERVICE, key) except Exception: return def _load_from_file(key: str) -> OAuthToken | None: path = _credentials_path(key) if not path.exists(): return None try: payload = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError: return None if not isinstance(payload, dict): return None payload = cast(dict[str, Any], payload) return OAuthToken.from_dict(payload) def _save_to_file(key: str, token: OAuthToken) -> None: path = _credentials_path(key) path.write_text(json.dumps(token.to_dict(), ensure_ascii=False), encoding="utf-8") _ensure_private_file(path) def _delete_from_file(key: str) -> None: path = _credentials_path(key) if path.exists(): path.unlink() def load_tokens(ref: OAuthRef) -> OAuthToken | None: file_token = _load_from_file(ref.key) if file_token is not None: return file_token if ref.storage != "keyring": return None token = _load_from_keyring(ref.key) if token is None: return None try: _save_to_file(ref.key, token) except OSError as exc: logger.warning("Failed to migrate token from keyring to file: {error}", error=exc) else: with suppress(Exception): _delete_from_keyring(ref.key) return token def save_tokens(ref: OAuthRef, token: OAuthToken) -> OAuthRef: if ref.storage == "keyring": logger.warning("Keyring storage is deprecated; saving OAuth tokens to file.") ref = OAuthRef(storage="file", key=ref.key) _save_to_file(ref.key, token) return ref def delete_tokens(ref: OAuthRef) -> None: if ref.storage == "keyring": _delete_from_keyring(ref.key) _delete_from_file(ref.key) async def request_device_authorization() -> DeviceAuthorization: async with ( new_client_session() as session, session.post( f"{_oauth_host().rstrip('/')}/api/oauth/device_authorization", data={"client_id": KIMI_CODE_CLIENT_ID}, headers=_common_headers(), ) as response, ): data = await response.json(content_type=None) status = response.status if status != 200: raise OAuthError(f"Device authorization failed: {data}") return DeviceAuthorization( user_code=str(data["user_code"]), device_code=str(data["device_code"]), verification_uri=str(data.get("verification_uri") or ""), verification_uri_complete=str(data["verification_uri_complete"]), expires_in=int(data.get("expires_in") or 0) or None, interval=int(data.get("interval") or 5), ) async def _request_device_token(auth: DeviceAuthorization) -> tuple[int, dict[str, Any]]: try: async with ( new_client_session() as session, session.post( f"{_oauth_host().rstrip('/')}/api/oauth/token", data={ "client_id": KIMI_CODE_CLIENT_ID, "device_code": auth.device_code, "grant_type": "urn:ietf:params:oauth:grant-type:device_code", }, headers=_common_headers(), ) as response, ): data_any: Any = await response.json(content_type=None) status = response.status except aiohttp.ClientError as exc: raise OAuthError("Token polling request failed.") from exc if not isinstance(data_any, dict): raise OAuthError("Unexpected token polling response.") data = cast(dict[str, Any], data_any) if status >= 500: raise OAuthError(f"Token polling server error: {status}.") return status, data async def refresh_token(refresh_token: str) -> OAuthToken: async with ( new_client_session() as session, session.post( f"{_oauth_host().rstrip('/')}/api/oauth/token", data={ "client_id": KIMI_CODE_CLIENT_ID, "grant_type": "refresh_token", "refresh_token": refresh_token, }, headers=_common_headers(), ) as response, ): data = await response.json(content_type=None) status = response.status if status in (401, 403): raise OAuthUnauthorized(data.get("error_description") or "Token refresh unauthorized.") if status != 200: raise OAuthError(data.get("error_description") or "Token refresh failed.") return OAuthToken.from_response(data) def _select_default_model_and_thinking(models: list[ModelInfo]) -> tuple[ModelInfo, bool] | None: if not models: return None selected_model = models[0] capabilities = selected_model.capabilities thinking = "thinking" in capabilities or "always_thinking" in capabilities return selected_model, thinking def _apply_kimi_code_config( config: Config, *, models: list[ModelInfo], selected_model: ModelInfo, thinking: bool, oauth_ref: OAuthRef, ) -> None: platform = get_platform_by_id(KIMI_CODE_PLATFORM_ID) if platform is None: raise OAuthError("Kimi Code platform not found.") provider_key = managed_provider_key(platform.id) config.providers[provider_key] = LLMProvider( type="kimi", base_url=platform.base_url, api_key=SecretStr(""), oauth=oauth_ref, ) for key, model in list(config.models.items()): if model.provider == provider_key: del config.models[key] for model_info in models: capabilities = model_info.capabilities or None config.models[managed_model_key(platform.id, model_info.id)] = LLMModel( provider=provider_key, model=model_info.id, max_context_size=model_info.context_length, capabilities=capabilities, ) config.default_model = managed_model_key(platform.id, selected_model.id) config.default_thinking = thinking if platform.search_url: config.services.moonshot_search = MoonshotSearchConfig( base_url=platform.search_url, api_key=SecretStr(""), oauth=oauth_ref, ) if platform.fetch_url: config.services.moonshot_fetch = MoonshotFetchConfig( base_url=platform.fetch_url, api_key=SecretStr(""), oauth=oauth_ref, ) async def login_kimi_code( config: Config, *, open_browser: bool = True ) -> AsyncIterator[OAuthEvent]: if not config.is_from_default_location: yield OAuthEvent( "error", "Login requires the default config file; restart without --config/--config-file.", ) return platform = get_platform_by_id(KIMI_CODE_PLATFORM_ID) if platform is None: yield OAuthEvent("error", "Kimi Code platform is unavailable.") return auth: DeviceAuthorization token: OAuthToken | None = None while True: try: auth = await request_device_authorization() except Exception as exc: yield OAuthEvent("error", f"Login failed: {exc}") return yield OAuthEvent( "info", "Please visit the following URL to finish authorization.", ) yield OAuthEvent( "verification_url", f"Verification URL: {auth.verification_uri_complete}", data={ "verification_url": auth.verification_uri_complete, "user_code": auth.user_code, }, ) if open_browser: try: webbrowser.open(auth.verification_uri_complete) except Exception as exc: logger.warning("Failed to open browser: {error}", error=exc) interval = max(auth.interval, 1) printed_wait = False try: while True: status, data = await _request_device_token(auth) if status == 200 and "access_token" in data: token = OAuthToken.from_response(data) break error_code = str(data.get("error") or "unknown_error") if error_code == "expired_token": raise OAuthDeviceExpired("Device code expired.") error_description = str(data.get("error_description") or "") if not printed_wait: yield OAuthEvent( "waiting", f"Waiting for user authorization...: {error_description.strip()}", data={ "error": error_code, "error_description": error_description, }, ) printed_wait = True await asyncio.sleep(interval) except OAuthDeviceExpired: yield OAuthEvent("info", "Device code expired, restarting login...") continue except Exception as exc: yield OAuthEvent("error", f"Login failed: {exc}") return break assert token is not None oauth_ref = OAuthRef(storage="file", key=KIMI_CODE_OAUTH_KEY) oauth_ref = save_tokens(oauth_ref, token) try: models = await list_models(platform, token.access_token) except Exception as exc: logger.error("Failed to get models: {error}", error=exc) yield OAuthEvent("error", f"Failed to get models: {exc}") return if not models: yield OAuthEvent("error", "No models available for the selected platform.") return selection = _select_default_model_and_thinking(models) if selection is None: return selected_model, thinking = selection _apply_kimi_code_config( config, models=models, selected_model=selected_model, thinking=thinking, oauth_ref=oauth_ref, ) save_config(config) yield OAuthEvent("success", "Logged in successfully.") return async def logout_kimi_code(config: Config) -> AsyncIterator[OAuthEvent]: if not config.is_from_default_location: yield OAuthEvent( "error", "Logout requires the default config file; restart without --config/--config-file.", ) return delete_tokens(OAuthRef(storage="keyring", key=KIMI_CODE_OAUTH_KEY)) delete_tokens(OAuthRef(storage="file", key=KIMI_CODE_OAUTH_KEY)) provider_key = managed_provider_key(KIMI_CODE_PLATFORM_ID) if provider_key in config.providers: del config.providers[provider_key] removed_default = False for key, model in list(config.models.items()): if model.provider != provider_key: continue del config.models[key] if config.default_model == key: removed_default = True if removed_default: config.default_model = "" config.services.moonshot_search = None config.services.moonshot_fetch = None save_config(config) yield OAuthEvent("success", "Logged out successfully.") return class OAuthManager: def __init__(self, config: Config) -> None: self._config = config # Cache access tokens only; refresh tokens are always read from persisted storage. self._access_tokens: dict[str, str] = {} self._refresh_lock = asyncio.Lock() self._migrate_oauth_storage() self._load_initial_tokens() def _iter_oauth_refs(self) -> list[OAuthRef]: refs: list[OAuthRef] = [] for provider in self._config.providers.values(): if provider.oauth: refs.append(provider.oauth) for service in ( self._config.services.moonshot_search, self._config.services.moonshot_fetch, ): if service and service.oauth: refs.append(service.oauth) return refs def _migrate_oauth_storage(self) -> None: migrated_keys: set[str] = set() changed = False def _migrate_ref(ref: OAuthRef) -> OAuthRef: nonlocal changed if ref.storage != "keyring": return ref if ref.key not in migrated_keys: load_tokens(ref) migrated_keys.add(ref.key) changed = True return OAuthRef(storage="file", key=ref.key) for provider in self._config.providers.values(): if provider.oauth: provider.oauth = _migrate_ref(provider.oauth) for service in ( self._config.services.moonshot_search, self._config.services.moonshot_fetch, ): if service and service.oauth: service.oauth = _migrate_ref(service.oauth) if changed and self._config.is_from_default_location: save_config(self._config) def _load_initial_tokens(self) -> None: for ref in self._iter_oauth_refs(): token = load_tokens(ref) if token: self._cache_access_token(ref, token) def _cache_access_token(self, ref: OAuthRef, token: OAuthToken) -> None: if not token.access_token: self._access_tokens.pop(ref.key, None) return self._access_tokens[ref.key] = token.access_token def common_headers(self) -> dict[str, str]: return _common_headers() def resolve_api_key(self, api_key: SecretStr, oauth: OAuthRef | None) -> str: if oauth: token = self._access_tokens.get(oauth.key) if token is None: persisted = load_tokens(oauth) if persisted: self._cache_access_token(oauth, persisted) token = self._access_tokens.get(oauth.key) if token: return token return api_key.get_secret_value() def _kimi_code_ref(self) -> OAuthRef | None: provider_key = managed_provider_key(KIMI_CODE_PLATFORM_ID) provider = self._config.providers.get(provider_key) if provider and provider.oauth: return provider.oauth for service in ( self._config.services.moonshot_search, self._config.services.moonshot_fetch, ): if service and service.oauth and service.oauth.key == KIMI_CODE_OAUTH_KEY: return service.oauth return None async def ensure_fresh(self, runtime: Runtime) -> None: ref = self._kimi_code_ref() if ref is None: return token = load_tokens(ref) if token is None: return self._cache_access_token(ref, token) self._apply_access_token(runtime, token.access_token) await self._refresh_tokens(ref, token, runtime) @asynccontextmanager async def refreshing(self, runtime: Runtime) -> AsyncIterator[None]: stop_event = asyncio.Event() async def _runner() -> None: try: while True: try: await asyncio.wait_for( stop_event.wait(), timeout=REFRESH_INTERVAL_SECONDS, ) return except TimeoutError: pass try: await self.ensure_fresh(runtime) except Exception as exc: logger.warning( "Failed to refresh OAuth token in background: {error}", error=exc, ) except asyncio.CancelledError: pass await self.ensure_fresh(runtime) refresh_task = asyncio.create_task(_runner()) try: yield finally: stop_event.set() refresh_task.cancel() with suppress(asyncio.CancelledError): await refresh_task async def _refresh_tokens( self, ref: OAuthRef, token: OAuthToken, runtime: Runtime, ) -> None: # Always prefer persisted tokens before refresh to avoid stale cache # when multiple sessions might have already rotated the refresh token. persisted = load_tokens(ref) if persisted: self._cache_access_token(ref, persisted) current_token = persisted or token if not current_token.refresh_token: return async with self._refresh_lock: # Re-check persisted token inside the lock to reduce races. persisted = load_tokens(ref) if persisted: self._cache_access_token(ref, persisted) current = persisted or current_token now = time.time() if ( current.expires_at and current.expires_at > now and current.expires_at - now >= REFRESH_THRESHOLD_SECONDS ): return refresh_token_value = current.refresh_token if not refresh_token_value: return try: refreshed = await refresh_token(refresh_token_value) except OAuthUnauthorized as exc: # If another session refreshed and persisted a new token, # do not delete it. Just sync memory and exit. latest = load_tokens(ref) if latest and latest.refresh_token != refresh_token_value: self._cache_access_token(ref, latest) self._apply_access_token(runtime, latest.access_token) return logger.warning( "OAuth credentials rejected, deleting stored tokens: {error}", error=exc, ) self._access_tokens.pop(ref.key, None) delete_tokens(ref) self._apply_access_token(runtime, "") return except Exception as exc: logger.warning("Failed to refresh OAuth token: {error}", error=exc) return save_tokens(ref, refreshed) self._cache_access_token(ref, refreshed) self._apply_access_token(runtime, refreshed.access_token) def _apply_access_token(self, runtime: Runtime, access_token: str) -> None: provider_key = managed_provider_key(KIMI_CODE_PLATFORM_ID) if runtime.llm is None or runtime.llm.model_config is None: return if runtime.llm.model_config.provider != provider_key: return from kosong.chat_provider.kimi import Kimi assert isinstance(runtime.llm.chat_provider, Kimi), "Expected Kimi chat provider" runtime.llm.chat_provider.client.api_key = access_token if __name__ == "__main__": from rich import print print(_common_headers()) ================================================ FILE: src/kimi_cli/auth/platforms.py ================================================ from __future__ import annotations import os from typing import Any, NamedTuple, cast import aiohttp from pydantic import BaseModel from kimi_cli.auth import KIMI_CODE_PLATFORM_ID from kimi_cli.config import Config, LLMModel, load_config, save_config from kimi_cli.llm import ModelCapability from kimi_cli.utils.aiohttp import new_client_session from kimi_cli.utils.logging import logger class ModelInfo(BaseModel): """Model information returned from the API.""" id: str context_length: int supports_reasoning: bool supports_image_in: bool supports_video_in: bool @property def capabilities(self) -> set[ModelCapability]: """Derive capabilities from model info.""" caps: set[ModelCapability] = set() if self.supports_reasoning: caps.add("thinking") # Models with "thinking" in name are always-thinking if "thinking" in self.id.lower(): caps.update(("thinking", "always_thinking")) if self.supports_image_in: caps.add("image_in") if self.supports_video_in: caps.add("video_in") if "kimi-k2.5" in self.id.lower(): caps.update(("thinking", "image_in", "video_in")) return caps class Platform(NamedTuple): id: str name: str base_url: str search_url: str | None = None fetch_url: str | None = None allowed_prefixes: list[str] | None = None def _kimi_code_base_url() -> str: if base_url := os.getenv("KIMI_CODE_BASE_URL"): return base_url return "https://api.kimi.com/coding/v1" PLATFORMS: list[Platform] = [ Platform( id=KIMI_CODE_PLATFORM_ID, name="Kimi Code", base_url=_kimi_code_base_url(), search_url=f"{_kimi_code_base_url()}/search", fetch_url=f"{_kimi_code_base_url()}/fetch", ), Platform( id="moonshot-cn", name="Moonshot AI Open Platform (moonshot.cn)", base_url="https://api.moonshot.cn/v1", allowed_prefixes=["kimi-k"], ), Platform( id="moonshot-ai", name="Moonshot AI Open Platform (moonshot.ai)", base_url="https://api.moonshot.ai/v1", allowed_prefixes=["kimi-k"], ), ] _PLATFORM_BY_ID = {platform.id: platform for platform in PLATFORMS} _PLATFORM_BY_NAME = {platform.name: platform for platform in PLATFORMS} def get_platform_by_id(platform_id: str) -> Platform | None: return _PLATFORM_BY_ID.get(platform_id) def get_platform_by_name(name: str) -> Platform | None: return _PLATFORM_BY_NAME.get(name) MANAGED_PROVIDER_PREFIX = "managed:" def managed_provider_key(platform_id: str) -> str: return f"{MANAGED_PROVIDER_PREFIX}{platform_id}" def managed_model_key(platform_id: str, model_id: str) -> str: return f"{platform_id}/{model_id}" def parse_managed_provider_key(provider_key: str) -> str | None: if not provider_key.startswith(MANAGED_PROVIDER_PREFIX): return None return provider_key.removeprefix(MANAGED_PROVIDER_PREFIX) def is_managed_provider_key(provider_key: str) -> bool: return provider_key.startswith(MANAGED_PROVIDER_PREFIX) def get_platform_name_for_provider(provider_key: str) -> str | None: platform_id = parse_managed_provider_key(provider_key) if not platform_id: return None platform = get_platform_by_id(platform_id) return platform.name if platform else None async def refresh_managed_models(config: Config) -> bool: if not config.is_from_default_location: return False managed_providers = { key: provider for key, provider in config.providers.items() if is_managed_provider_key(key) } if not managed_providers: return False changed = False updates: list[tuple[str, str, list[ModelInfo]]] = [] for provider_key, provider in managed_providers.items(): platform_id = parse_managed_provider_key(provider_key) if not platform_id: continue platform = get_platform_by_id(platform_id) if platform is None: logger.warning("Managed platform not found: {platform}", platform=platform_id) continue api_key = provider.api_key.get_secret_value() if not api_key and provider.oauth: from kimi_cli.auth.oauth import load_tokens token = load_tokens(provider.oauth) if token: api_key = token.access_token if not api_key: logger.warning( "Missing API key for managed provider: {provider}", provider=provider_key, ) continue try: models = await list_models(platform, api_key) except Exception as exc: logger.error( "Failed to refresh models for {platform}: {error}", platform=platform_id, error=exc, ) continue updates.append((provider_key, platform_id, models)) if _apply_models(config, provider_key, platform_id, models): changed = True if changed: config_for_save = load_config() save_changed = False for provider_key, platform_id, models in updates: if _apply_models(config_for_save, provider_key, platform_id, models): save_changed = True if save_changed: save_config(config_for_save) return changed async def list_models(platform: Platform, api_key: str) -> list[ModelInfo]: async with new_client_session() as session: models = await _list_models( session, base_url=platform.base_url, api_key=api_key, ) if platform.allowed_prefixes is None: return models prefixes = tuple(platform.allowed_prefixes) return [model for model in models if model.id.startswith(prefixes)] async def _list_models( session: aiohttp.ClientSession, *, base_url: str, api_key: str, ) -> list[ModelInfo]: models_url = f"{base_url.rstrip('/')}/models" try: async with session.get( models_url, headers={"Authorization": f"Bearer {api_key}"}, raise_for_status=True, ) as response: resp_json = await response.json() except aiohttp.ClientError: raise data = resp_json.get("data") if not isinstance(data, list): raise ValueError(f"Unexpected models response for {base_url}") result: list[ModelInfo] = [] for item in cast(list[dict[str, Any]], data): model_id = item.get("id") if not model_id: continue result.append( ModelInfo( id=str(model_id), context_length=int(item.get("context_length") or 0), supports_reasoning=bool(item.get("supports_reasoning")), supports_image_in=bool(item.get("supports_image_in")), supports_video_in=bool(item.get("supports_video_in")), ) ) return result def _apply_models( config: Config, provider_key: str, platform_id: str, models: list[ModelInfo], ) -> bool: changed = False model_keys: list[str] = [] for model in models: model_key = managed_model_key(platform_id, model.id) model_keys.append(model_key) existing = config.models.get(model_key) capabilities = model.capabilities or None # empty set -> None if existing is None: config.models[model_key] = LLMModel( provider=provider_key, model=model.id, max_context_size=model.context_length, capabilities=capabilities, ) changed = True continue if existing.provider != provider_key: existing.provider = provider_key changed = True if existing.model != model.id: existing.model = model.id changed = True if existing.max_context_size != model.context_length: existing.max_context_size = model.context_length changed = True if existing.capabilities != capabilities: existing.capabilities = capabilities changed = True removed_default = False model_keys_set = set(model_keys) for key, model in list(config.models.items()): if model.provider != provider_key: continue if key in model_keys_set: continue del config.models[key] if config.default_model == key: removed_default = True changed = True if removed_default: if model_keys: config.default_model = model_keys[0] else: config.default_model = next(iter(config.models), "") changed = True if config.default_model and config.default_model not in config.models: config.default_model = next(iter(config.models), "") changed = True return changed ================================================ FILE: src/kimi_cli/background/__init__.py ================================================ from .ids import generate_task_id from .manager import BackgroundTaskManager from .models import ( TaskConsumerState, TaskControl, TaskKind, TaskOutputChunk, TaskRuntime, TaskSpec, TaskStatus, TaskView, is_terminal_status, ) from .store import BackgroundTaskStore from .summary import build_active_task_snapshot, format_task, format_task_list, list_task_views from .worker import run_background_task_worker __all__ = [ "BackgroundTaskManager", "BackgroundTaskStore", "TaskConsumerState", "TaskControl", "TaskKind", "TaskOutputChunk", "TaskRuntime", "TaskSpec", "TaskStatus", "TaskView", "build_active_task_snapshot", "format_task", "format_task_list", "generate_task_id", "is_terminal_status", "list_task_views", "run_background_task_worker", ] ================================================ FILE: src/kimi_cli/background/ids.py ================================================ from __future__ import annotations import secrets from .models import TaskKind _TASK_ID_PREFIXES: dict[TaskKind, str] = { "bash": "b", "agent": "a", } _ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz" def generate_task_id(kind: TaskKind) -> str: prefix = _TASK_ID_PREFIXES[kind] suffix = "".join(secrets.choice(_ALPHABET) for _ in range(8)) return f"{prefix}{suffix}" ================================================ FILE: src/kimi_cli/background/manager.py ================================================ from __future__ import annotations import asyncio import os import signal import subprocess import sys import time from pathlib import Path from typing import Any from kaos.local import local_kaos from kimi_cli.config import BackgroundConfig from kimi_cli.notifications import NotificationEvent, NotificationManager from kimi_cli.session import Session from kimi_cli.utils.logging import logger from .ids import generate_task_id from .models import ( TaskOutputChunk, TaskRuntime, TaskSpec, TaskStatus, TaskView, is_terminal_status, ) from .store import BackgroundTaskStore class BackgroundTaskManager: def __init__( self, session: Session, config: BackgroundConfig, *, notifications: NotificationManager, owner_role: str = "root", ) -> None: self._session = session self._config = config self._notifications = notifications self._owner_role = owner_role self._store = BackgroundTaskStore(session.context_file.parent / "tasks") @property def store(self) -> BackgroundTaskStore: return self._store @property def role(self) -> str: return self._owner_role def copy_for_role(self, role: str) -> BackgroundTaskManager: return BackgroundTaskManager( self._session, self._config, notifications=self._notifications, owner_role=role, ) def _ensure_root(self) -> None: if self._owner_role != "root": raise RuntimeError("Background tasks are only supported from the root agent.") def _ensure_local_backend(self) -> None: if self._session.work_dir_meta.kaos != local_kaos.name: raise RuntimeError("Background tasks are only supported on local sessions.") def _active_task_count(self) -> int: return sum( 1 for view in self._store.list_views() if not is_terminal_status(view.runtime.status) ) def _worker_command(self, task_dir: Path) -> list[str]: if getattr(sys, "frozen", False): return [ sys.executable, "__background-task-worker", "--task-dir", str(task_dir), "--heartbeat-interval-ms", str(self._config.worker_heartbeat_interval_ms), "--control-poll-interval-ms", str(self._config.wait_poll_interval_ms), "--kill-grace-period-ms", str(self._config.kill_grace_period_ms), ] return [ sys.executable, "-m", "kimi_cli.cli", "__background-task-worker", "--task-dir", str(task_dir), "--heartbeat-interval-ms", str(self._config.worker_heartbeat_interval_ms), "--control-poll-interval-ms", str(self._config.wait_poll_interval_ms), "--kill-grace-period-ms", str(self._config.kill_grace_period_ms), ] def _launch_worker(self, task_dir: Path) -> int: kwargs: dict[str, Any] = { "stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "cwd": str(task_dir), } if os.name == "nt": kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) else: kwargs["start_new_session"] = True process = subprocess.Popen(self._worker_command(task_dir), **kwargs) return process.pid def create_bash_task( self, *, command: str, description: str, timeout_s: int, tool_call_id: str, shell_name: str, shell_path: str, cwd: str, ) -> TaskView: self._ensure_root() self._ensure_local_backend() if self._active_task_count() >= self._config.max_running_tasks: raise RuntimeError("Too many background tasks are already running.") task_id = generate_task_id("bash") spec = TaskSpec( id=task_id, kind="bash", session_id=self._session.id, description=description, tool_call_id=tool_call_id, owner_role="root", command=command, shell_name=shell_name, shell_path=shell_path, cwd=cwd, timeout_s=timeout_s, ) self._store.create_task(spec) runtime = self._store.read_runtime(task_id) task_dir = self._store.task_dir(task_id) try: worker_pid = self._launch_worker(task_dir) except Exception as exc: runtime.status = "failed" runtime.failure_reason = f"Failed to launch worker: {exc}" runtime.finished_at = time.time() runtime.updated_at = runtime.finished_at self._store.write_runtime(task_id, runtime) raise runtime = self._store.read_runtime(task_id) if runtime.finished_at is None and ( runtime.status == "created" or (runtime.status == "starting" and runtime.worker_pid is None) ): runtime.status = "starting" runtime.worker_pid = worker_pid runtime.updated_at = time.time() self._store.write_runtime(task_id, runtime) return self._store.merged_view(task_id) def list_tasks( self, *, status: TaskStatus | None = None, limit: int | None = 20, ) -> list[TaskView]: tasks = self._store.list_views() if status is not None: tasks = [task for task in tasks if task.runtime.status == status] if limit is None: return tasks return tasks[:limit] def get_task(self, task_id: str) -> TaskView | None: try: return self._store.merged_view(task_id) except (FileNotFoundError, ValueError): return None def read_output( self, task_id: str, *, offset: int = 0, max_bytes: int | None = None, ) -> TaskOutputChunk: view = self._store.merged_view(task_id) return self._store.read_output( task_id, offset, max_bytes or self._config.read_max_bytes, status=view.runtime.status, ) def tail_output( self, task_id: str, *, max_bytes: int | None = None, max_lines: int | None = None, ) -> str: self._store.merged_view(task_id) return self._store.tail_output( task_id, max_bytes=max_bytes or self._config.read_max_bytes, max_lines=max_lines or self._config.notification_tail_lines, ) async def wait(self, task_id: str, *, timeout_s: int = 30) -> TaskView: end_time = time.monotonic() + timeout_s while True: view = self._store.merged_view(task_id) if is_terminal_status(view.runtime.status): return view if time.monotonic() >= end_time: return view await asyncio.sleep(self._config.wait_poll_interval_ms / 1000) def _best_effort_kill(self, runtime: TaskRuntime) -> None: try: if os.name == "nt": pid = runtime.child_pid or runtime.worker_pid if pid is None: return subprocess.run( ["taskkill", "/PID", str(pid), "/T", "/F"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ) return if runtime.child_pgid is not None: os.killpg(runtime.child_pgid, signal.SIGTERM) return if runtime.child_pid is not None: os.kill(runtime.child_pid, signal.SIGTERM) except ProcessLookupError: pass except Exception: logger.exception("Failed to send best-effort kill signal") def kill(self, task_id: str, *, reason: str = "Killed by user") -> TaskView: self._ensure_root() view = self._store.merged_view(task_id) if is_terminal_status(view.runtime.status): return view control = view.control.model_copy( update={ "kill_requested_at": time.time(), "kill_reason": reason, "force": False, } ) self._store.write_control(task_id, control) self._best_effort_kill(view.runtime) return self._store.merged_view(task_id) def kill_all_active(self, *, reason: str = "CLI session ended") -> list[str]: """Kill all non-terminal background tasks. Used during CLI shutdown.""" killed: list[str] = [] for view in self._store.list_views(): if is_terminal_status(view.runtime.status): continue try: self.kill(view.spec.id, reason=reason) killed.append(view.spec.id) except Exception: logger.exception( "Failed to kill task {task_id} during shutdown", task_id=view.spec.id, ) return killed def recover(self) -> None: now = time.time() stale_after = self._config.worker_stale_after_ms / 1000 for view in self._store.list_views(): if is_terminal_status(view.runtime.status): continue last_progress_at = ( view.runtime.heartbeat_at or view.runtime.started_at or view.runtime.updated_at or view.spec.created_at ) if now - last_progress_at <= stale_after: continue # Re-read runtime to narrow the race window with the worker process. fresh_runtime = self._store.read_runtime(view.spec.id) if is_terminal_status(fresh_runtime.status): continue fresh_progress = ( fresh_runtime.heartbeat_at or fresh_runtime.started_at or fresh_runtime.updated_at or view.spec.created_at ) if now - fresh_progress <= stale_after: continue runtime = fresh_runtime.model_copy() runtime.finished_at = now runtime.updated_at = now if view.control.kill_requested_at is not None: runtime.status = "killed" runtime.interrupted = True runtime.failure_reason = view.control.kill_reason or "Killed during recovery" else: runtime.status = "lost" runtime.failure_reason = ( "Background worker never heartbeat after startup" if fresh_runtime.heartbeat_at is None else "Background worker heartbeat expired" ) self._store.write_runtime(view.spec.id, runtime) def reconcile(self, *, limit: int | None = None) -> list[str]: self.recover() return self.publish_terminal_notifications(limit=limit) def publish_terminal_notifications(self, *, limit: int | None = None) -> list[str]: published: list[str] = [] for view in self._store.list_views(): if not is_terminal_status(view.runtime.status): continue status = view.runtime.status terminal_reason = "timed_out" if view.runtime.timed_out else status match terminal_reason: case "completed": severity = "success" title = f"Background task completed: {view.spec.description}" case "timed_out": severity = "error" title = f"Background task timed out: {view.spec.description}" case "failed": severity = "error" title = f"Background task failed: {view.spec.description}" case "killed": severity = "warning" title = f"Background task stopped: {view.spec.description}" case "lost": severity = "warning" title = f"Background task lost: {view.spec.description}" case _: severity = "info" title = f"Background task updated: {view.spec.description}" body_lines = [ f"Task ID: {view.spec.id}", f"Status: {status}", f"Description: {view.spec.description}", ] if terminal_reason != status: body_lines.append(f"Terminal reason: {terminal_reason}") if view.runtime.exit_code is not None: body_lines.append(f"Exit code: {view.runtime.exit_code}") if view.runtime.failure_reason: body_lines.append(f"Failure reason: {view.runtime.failure_reason}") event = NotificationEvent( id=self._notifications.new_id(), category="task", type=f"task.{terminal_reason}", source_kind="background_task", source_id=view.spec.id, title=title, body="\n".join(body_lines), severity=severity, payload={ "task_id": view.spec.id, "task_kind": view.spec.kind, "status": status, "description": view.spec.description, "exit_code": view.runtime.exit_code, "interrupted": view.runtime.interrupted, "timed_out": view.runtime.timed_out, "terminal_reason": terminal_reason, "failure_reason": view.runtime.failure_reason, }, dedupe_key=f"background_task:{view.spec.id}:{terminal_reason}", ) notification = self._notifications.publish(event) if notification.event.id == event.id: published.append(notification.event.id) if limit is not None and len(published) >= limit: break return published ================================================ FILE: src/kimi_cli/background/models.py ================================================ from __future__ import annotations import time from typing import Literal from pydantic import BaseModel, ConfigDict, Field type TaskKind = Literal["bash", "agent"] type TaskStatus = Literal["created", "starting", "running", "completed", "failed", "killed", "lost"] type TaskOwnerRole = Literal["root", "fixed_subagent", "dynamic_subagent"] TERMINAL_TASK_STATUSES: tuple[TaskStatus, ...] = ("completed", "failed", "killed", "lost") def is_terminal_status(status: TaskStatus) -> bool: return status in TERMINAL_TASK_STATUSES class TaskSpec(BaseModel): model_config = ConfigDict(extra="ignore") version: int = 1 id: str kind: TaskKind session_id: str description: str tool_call_id: str owner_role: TaskOwnerRole = "root" created_at: float = Field(default_factory=time.time) # Bash-specific fields for V1. Future task types can use kind_payload. command: str | None = None shell_name: str | None = None shell_path: str | None = None cwd: str | None = None timeout_s: int | None = None kind_payload: dict[str, str] | None = None class TaskRuntime(BaseModel): model_config = ConfigDict(extra="ignore") status: TaskStatus = "created" worker_pid: int | None = None child_pid: int | None = None child_pgid: int | None = None started_at: float | None = None heartbeat_at: float | None = None updated_at: float = Field(default_factory=time.time) finished_at: float | None = None exit_code: int | None = None interrupted: bool = False timed_out: bool = False failure_reason: str | None = None class TaskControl(BaseModel): model_config = ConfigDict(extra="ignore") kill_requested_at: float | None = None kill_reason: str | None = None force: bool = False class TaskConsumerState(BaseModel): model_config = ConfigDict(extra="ignore") last_seen_output_size: int = 0 last_viewed_at: float | None = None class TaskView(BaseModel): model_config = ConfigDict(extra="ignore") spec: TaskSpec runtime: TaskRuntime control: TaskControl consumer: TaskConsumerState class TaskOutputChunk(BaseModel): model_config = ConfigDict(extra="ignore") task_id: str offset: int next_offset: int text: str eof: bool status: TaskStatus ================================================ FILE: src/kimi_cli/background/store.py ================================================ from __future__ import annotations import os import re from pathlib import Path from kimi_cli.utils.io import atomic_json_write from .models import ( TaskConsumerState, TaskControl, TaskOutputChunk, TaskRuntime, TaskSpec, TaskStatus, TaskView, ) _VALID_TASK_ID = re.compile(r"^[a-z0-9]{2,20}$") def _validate_task_id(task_id: str) -> None: if not _VALID_TASK_ID.match(task_id): raise ValueError(f"Invalid task_id: {task_id!r}") class BackgroundTaskStore: SPEC_FILE = "spec.json" RUNTIME_FILE = "runtime.json" CONTROL_FILE = "control.json" CONSUMER_FILE = "consumer.json" OUTPUT_FILE = "output.log" def __init__(self, root: Path): self._root = root @property def root(self) -> Path: return self._root def _ensure_root(self) -> Path: """Return the root directory, creating it if it does not exist.""" self._root.mkdir(parents=True, exist_ok=True) return self._root def task_dir(self, task_id: str) -> Path: _validate_task_id(task_id) path = self._ensure_root() / task_id path.mkdir(parents=True, exist_ok=True) return path def task_path(self, task_id: str) -> Path: _validate_task_id(task_id) return self.root / task_id def spec_path(self, task_id: str) -> Path: return self.task_path(task_id) / self.SPEC_FILE def runtime_path(self, task_id: str) -> Path: return self.task_path(task_id) / self.RUNTIME_FILE def control_path(self, task_id: str) -> Path: return self.task_path(task_id) / self.CONTROL_FILE def consumer_path(self, task_id: str) -> Path: return self.task_path(task_id) / self.CONSUMER_FILE def output_path(self, task_id: str) -> Path: return self.task_path(task_id) / self.OUTPUT_FILE def create_task(self, spec: TaskSpec) -> None: task_dir = self.task_dir(spec.id) atomic_json_write(spec.model_dump(mode="json"), task_dir / self.SPEC_FILE) atomic_json_write(TaskRuntime().model_dump(mode="json"), task_dir / self.RUNTIME_FILE) atomic_json_write(TaskControl().model_dump(mode="json"), task_dir / self.CONTROL_FILE) atomic_json_write( TaskConsumerState().model_dump(mode="json"), task_dir / self.CONSUMER_FILE, ) self.output_path(spec.id).touch(exist_ok=True) def list_task_ids(self) -> list[str]: if not self.root.exists(): return [] task_ids: list[str] = [] for path in sorted(self.root.iterdir()): if not path.is_dir(): continue if not (path / self.SPEC_FILE).exists(): continue task_ids.append(path.name) return task_ids def write_spec(self, spec: TaskSpec) -> None: atomic_json_write(spec.model_dump(mode="json"), self.spec_path(spec.id)) def read_spec(self, task_id: str) -> TaskSpec: return TaskSpec.model_validate_json(self.spec_path(task_id).read_text(encoding="utf-8")) def write_runtime(self, task_id: str, runtime: TaskRuntime) -> None: atomic_json_write(runtime.model_dump(mode="json"), self.runtime_path(task_id)) def read_runtime(self, task_id: str) -> TaskRuntime: path = self.runtime_path(task_id) if not path.exists(): return TaskRuntime() return TaskRuntime.model_validate_json(path.read_text(encoding="utf-8")) def write_control(self, task_id: str, control: TaskControl) -> None: atomic_json_write(control.model_dump(mode="json"), self.control_path(task_id)) def read_control(self, task_id: str) -> TaskControl: path = self.control_path(task_id) if not path.exists(): return TaskControl() return TaskControl.model_validate_json(path.read_text(encoding="utf-8")) def write_consumer(self, task_id: str, consumer: TaskConsumerState) -> None: atomic_json_write(consumer.model_dump(mode="json"), self.consumer_path(task_id)) def read_consumer(self, task_id: str) -> TaskConsumerState: path = self.consumer_path(task_id) if not path.exists(): return TaskConsumerState() return TaskConsumerState.model_validate_json(path.read_text(encoding="utf-8")) def merged_view(self, task_id: str) -> TaskView: return TaskView( spec=self.read_spec(task_id), runtime=self.read_runtime(task_id), control=self.read_control(task_id), consumer=self.read_consumer(task_id), ) def list_views(self) -> list[TaskView]: views = [self.merged_view(task_id) for task_id in self.list_task_ids()] views.sort( key=lambda view: view.runtime.updated_at or view.spec.created_at, reverse=True, ) return views def read_output( self, task_id: str, offset: int, max_bytes: int, *, status: TaskStatus, ) -> TaskOutputChunk: path = self.output_path(task_id) if not path.exists(): return TaskOutputChunk( task_id=task_id, offset=offset, next_offset=offset, text="", eof=True, status=status, ) with path.open("rb") as f: f.seek(0, os.SEEK_END) total_size = f.tell() bounded_offset = min(max(offset, 0), total_size) f.seek(bounded_offset) content = f.read(max_bytes) next_offset = bounded_offset + len(content) return TaskOutputChunk( task_id=task_id, offset=bounded_offset, next_offset=next_offset, text=content.decode("utf-8", errors="replace"), eof=next_offset >= total_size, status=status, ) def tail_output(self, task_id: str, max_bytes: int, max_lines: int) -> str: path = self.output_path(task_id) if not path.exists(): return "" with path.open("rb") as f: f.seek(0, os.SEEK_END) total_size = f.tell() start = max(0, total_size - max_bytes) f.seek(start) content = f.read() text = content.decode("utf-8", errors="replace") lines = text.splitlines() if len(lines) > max_lines: lines = lines[-max_lines:] return "\n".join(lines) ================================================ FILE: src/kimi_cli/background/summary.py ================================================ from __future__ import annotations from .manager import BackgroundTaskManager from .models import TaskView, is_terminal_status def list_task_views( manager: BackgroundTaskManager, *, active_only: bool = True, limit: int = 20, ) -> list[TaskView]: views = manager.list_tasks(limit=None) if active_only: views = [view for view in views if not is_terminal_status(view.runtime.status)] return views[:limit] def format_task(view: TaskView, *, include_command: bool = False) -> str: lines = [ f"task_id: {view.spec.id}", f"kind: {view.spec.kind}", f"status: {view.runtime.status}", f"description: {view.spec.description}", ] if include_command and view.spec.command: lines.append(f"command: {view.spec.command}") if view.runtime.exit_code is not None: lines.append(f"exit_code: {view.runtime.exit_code}") if view.runtime.failure_reason: lines.append(f"reason: {view.runtime.failure_reason}") return "\n".join(lines) def format_task_list( views: list[TaskView], *, active_only: bool = True, include_command: bool = True, ) -> str: header = "active_background_tasks" if active_only else "background_tasks" if not views: return f"{header}: 0\n[no tasks]" lines = [f"{header}: {len(views)}", ""] for index, view in enumerate(views, start=1): lines.extend([f"[{index}]", format_task(view, include_command=include_command), ""]) return "\n".join(lines).rstrip() def build_active_task_snapshot(manager: BackgroundTaskManager, *, limit: int = 20) -> str | None: views = list_task_views(manager, active_only=True, limit=limit) if not views: return None return "\n".join( [ "", format_task_list(views, active_only=True, include_command=False), "", ] ) ================================================ FILE: src/kimi_cli/background/worker.py ================================================ from __future__ import annotations import asyncio import contextlib import os import signal import subprocess import time from pathlib import Path from typing import Any from kimi_cli.utils.logging import logger from kimi_cli.utils.subprocess_env import get_clean_env from .models import TaskControl from .store import BackgroundTaskStore def terminate_process_tree_windows(pid: int, *, force: bool) -> None: args = ["taskkill", "/PID", str(pid), "/T"] if force: args.append("/F") subprocess.run( args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ) async def run_background_task_worker( task_dir: Path, *, heartbeat_interval_ms: int = 5000, control_poll_interval_ms: int = 500, kill_grace_period_ms: int = 2000, ) -> None: task_dir = task_dir.expanduser().resolve() task_id = task_dir.name store = BackgroundTaskStore(task_dir.parent) spec = store.read_spec(task_id) runtime = store.read_runtime(task_id) runtime.status = "starting" runtime.worker_pid = os.getpid() runtime.started_at = time.time() runtime.heartbeat_at = runtime.started_at runtime.updated_at = runtime.started_at store.write_runtime(task_id, runtime) control = store.read_control(task_id) if control.kill_requested_at is not None: runtime.status = "killed" runtime.interrupted = True runtime.finished_at = time.time() runtime.updated_at = runtime.finished_at runtime.failure_reason = control.kill_reason or "Killed before command start" store.write_runtime(task_id, runtime) return if spec.command is None or spec.shell_path is None or spec.cwd is None: runtime.status = "failed" runtime.finished_at = time.time() runtime.updated_at = runtime.finished_at runtime.failure_reason = "Task spec is incomplete for bash worker" store.write_runtime(task_id, runtime) return process: asyncio.subprocess.Process | None = None control_task: asyncio.Task[None] | None = None heartbeat_task: asyncio.Task[None] | None = None stop_event = asyncio.Event() kill_sent_at: float | None = None timed_out = False timeout_reason: str | None = None async def _heartbeat_loop() -> None: while not stop_event.is_set(): await asyncio.sleep(heartbeat_interval_ms / 1000) current = store.read_runtime(task_id) if current.finished_at is not None: return current.heartbeat_at = time.time() current.updated_at = current.heartbeat_at store.write_runtime(task_id, current) async def _terminate_process(force: bool = False) -> None: nonlocal kill_sent_at if process is None or process.returncode is not None: return kill_sent_at = kill_sent_at or time.time() try: if os.name == "nt": terminate_process_tree_windows(process.pid, force=force) return target_pgid = process.pid if force: os.killpg(target_pgid, signal.SIGKILL) else: os.killpg(target_pgid, signal.SIGTERM) except ProcessLookupError: pass async def _control_loop() -> None: nonlocal kill_sent_at while not stop_event.is_set(): await asyncio.sleep(control_poll_interval_ms / 1000) current_control: TaskControl = store.read_control(task_id) if current_control.kill_requested_at is not None: await _terminate_process(force=current_control.force) if ( kill_sent_at is not None and process is not None and process.returncode is None and time.time() - kill_sent_at >= kill_grace_period_ms / 1000 ): await _terminate_process(force=True) try: output_path = store.output_path(task_id) with output_path.open("ab") as output_file: spawn_kwargs: dict[str, Any] = { "stdin": subprocess.DEVNULL, "stdout": output_file, "stderr": output_file, "cwd": spec.cwd, "env": get_clean_env(), } if os.name == "nt": spawn_kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) else: spawn_kwargs["start_new_session"] = True args = ( (spec.shell_path, "-command", spec.command) if spec.shell_name == "Windows PowerShell" else (spec.shell_path, "-c", spec.command) ) process = await asyncio.create_subprocess_exec(*args, **spawn_kwargs) runtime = store.read_runtime(task_id) runtime.status = "running" runtime.child_pid = process.pid runtime.child_pgid = process.pid if os.name != "nt" else None runtime.updated_at = time.time() runtime.heartbeat_at = runtime.updated_at store.write_runtime(task_id, runtime) last_known_runtime = runtime heartbeat_task = asyncio.create_task(_heartbeat_loop()) control_task = asyncio.create_task(_control_loop()) if spec.timeout_s is None: returncode = await process.wait() else: try: returncode = await asyncio.wait_for(process.wait(), timeout=spec.timeout_s) except TimeoutError: timed_out = True timeout_reason = f"Command timed out after {spec.timeout_s}s" await _terminate_process(force=False) try: returncode = await asyncio.wait_for( process.wait(), timeout=kill_grace_period_ms / 1000, ) except TimeoutError: await _terminate_process(force=True) returncode = await process.wait() except Exception as exc: logger.exception("Background task worker failed") runtime = store.read_runtime(task_id) runtime.status = "failed" runtime.finished_at = time.time() runtime.updated_at = runtime.finished_at runtime.failure_reason = str(exc) store.write_runtime(task_id, runtime) return finally: stop_event.set() for task in (heartbeat_task, control_task): if task is not None: task.cancel() with contextlib.suppress(asyncio.CancelledError): await task runtime = last_known_runtime.model_copy() control = store.read_control(task_id) runtime.finished_at = time.time() runtime.updated_at = runtime.finished_at runtime.exit_code = returncode runtime.heartbeat_at = runtime.finished_at if timed_out: runtime.status = "failed" runtime.interrupted = True runtime.timed_out = True runtime.failure_reason = timeout_reason elif control.kill_requested_at is not None: runtime.status = "killed" runtime.interrupted = True runtime.failure_reason = control.kill_reason or "Killed" elif returncode == 0: runtime.status = "completed" runtime.failure_reason = None else: runtime.status = "failed" runtime.failure_reason = f"Command failed with exit code {returncode}" store.write_runtime(task_id, runtime) ================================================ FILE: src/kimi_cli/cli/__init__.py ================================================ from __future__ import annotations from pathlib import Path from typing import Annotated, Literal import typer from ._lazy_group import LazySubcommandGroup class Reload(Exception): """Reload configuration.""" def __init__(self, session_id: str | None = None): super().__init__("reload") self.session_id = session_id class SwitchToWeb(Exception): """Switch to web interface.""" def __init__(self, session_id: str | None = None): super().__init__("switch_to_web") self.session_id = session_id cli = typer.Typer( cls=LazySubcommandGroup, epilog="""\b\ Documentation: https://moonshotai.github.io/kimi-cli/\n LLM friendly version: https://moonshotai.github.io/kimi-cli/llms.txt""", add_completion=False, context_settings={"help_option_names": ["-h", "--help"]}, help="Kimi, your next CLI agent.", ) UIMode = Literal["shell", "print", "acp", "wire"] InputFormat = Literal["text", "stream-json"] OutputFormat = Literal["text", "stream-json"] def _version_callback(value: bool) -> None: if value: from kimi_cli.constant import get_version typer.echo(f"kimi, version {get_version()}") raise typer.Exit() @cli.callback(invoke_without_command=True) def kimi( ctx: typer.Context, # Meta version: Annotated[ bool, typer.Option( "--version", "-V", help="Show version and exit.", callback=_version_callback, is_eager=True, ), ] = False, verbose: Annotated[ bool, typer.Option( "--verbose", help="Print verbose information. Default: no.", ), ] = False, debug: Annotated[ bool, typer.Option( "--debug", help="Log debug information. Default: no.", ), ] = False, # Basic configuration local_work_dir: Annotated[ Path | None, typer.Option( "--work-dir", "-w", exists=True, file_okay=False, dir_okay=True, readable=True, writable=True, help="Working directory for the agent. Default: current directory.", ), ] = None, local_add_dirs: Annotated[ list[Path] | None, typer.Option( "--add-dir", exists=True, file_okay=False, dir_okay=True, readable=True, help=( "Add an additional directory to the workspace scope. " "Can be specified multiple times." ), ), ] = None, session_id: Annotated[ str | None, typer.Option( "--session", "-S", help="Session ID to resume for the working directory. Default: new session.", ), ] = None, continue_: Annotated[ bool, typer.Option( "--continue", "-C", help="Continue the previous session for the working directory. Default: no.", ), ] = False, config_string: Annotated[ str | None, typer.Option( "--config", help="Config TOML/JSON string to load. Default: none.", ), ] = None, config_file: Annotated[ Path | None, typer.Option( "--config-file", exists=True, file_okay=True, dir_okay=False, readable=True, help="Config TOML/JSON file to load. Default: ~/.kimi/config.toml.", ), ] = None, model_name: Annotated[ str | None, typer.Option( "--model", "-m", help="LLM model to use. Default: default model set in config file.", ), ] = None, thinking: Annotated[ bool | None, typer.Option( "--thinking/--no-thinking", help="Enable thinking mode. Default: default thinking mode set in config file.", ), ] = None, # Run mode yolo: Annotated[ bool, typer.Option( "--yolo", "--yes", "-y", "--auto-approve", help="Automatically approve all actions. Default: no.", ), ] = False, prompt: Annotated[ str | None, typer.Option( "--prompt", "-p", "--command", "-c", help="User prompt to the agent. Default: prompt interactively.", ), ] = None, print_mode: Annotated[ bool, typer.Option( "--print", help=( "Run in print mode (non-interactive). Note: print mode implicitly adds `--yolo`." ), ), ] = False, acp_mode: Annotated[ bool, typer.Option( "--acp", help="(Deprecated, use `kimi acp` instead) Run as ACP server.", ), ] = False, wire_mode: Annotated[ bool, typer.Option( "--wire", help="Run as Wire server (experimental).", ), ] = False, input_format: Annotated[ InputFormat | None, typer.Option( "--input-format", help=( "Input format to use. Must be used with `--print` " "and the input must be piped in via stdin. " "Default: text." ), ), ] = None, output_format: Annotated[ OutputFormat | None, typer.Option( "--output-format", help="Output format to use. Must be used with `--print`. Default: text.", ), ] = None, final_message_only: Annotated[ bool, typer.Option( "--final-message-only", help="Only print the final assistant message (print UI).", ), ] = False, quiet: Annotated[ bool, typer.Option( "--quiet", help="Alias for `--print --output-format text --final-message-only`.", ), ] = False, # Customization agent: Annotated[ Literal["default", "okabe"] | None, typer.Option( "--agent", help="Builtin agent specification to use. Default: builtin default agent.", ), ] = None, agent_file: Annotated[ Path | None, typer.Option( "--agent-file", exists=True, file_okay=True, dir_okay=False, readable=True, help="Custom agent specification file. Default: builtin default agent.", ), ] = None, mcp_config_file: Annotated[ list[Path] | None, typer.Option( "--mcp-config-file", exists=True, file_okay=True, dir_okay=False, readable=True, help=( "MCP config file to load. Add this option multiple times to specify multiple MCP " "configs. Default: none." ), ), ] = None, mcp_config: Annotated[ list[str] | None, typer.Option( "--mcp-config", help=( "MCP config JSON to load. Add this option multiple times to specify multiple MCP " "configs. Default: none." ), ), ] = None, local_skills_dir: Annotated[ Path | None, typer.Option( "--skills-dir", exists=True, file_okay=False, dir_okay=True, readable=True, help="Path to the skills directory. Overrides discovery.", ), ] = None, # Loop control max_steps_per_turn: Annotated[ int | None, typer.Option( "--max-steps-per-turn", min=1, help="Maximum number of steps in one turn. Default: from config.", ), ] = None, max_retries_per_step: Annotated[ int | None, typer.Option( "--max-retries-per-step", min=1, help="Maximum number of retries in one step. Default: from config.", ), ] = None, max_ralph_iterations: Annotated[ int | None, typer.Option( "--max-ralph-iterations", min=-1, help=( "Extra iterations after the first turn in Ralph mode. Use -1 for unlimited. " "Default: from config." ), ), ] = None, ): """Kimi, your next CLI agent.""" import asyncio import json from kimi_cli.utils.proctitle import init_process_name init_process_name("Kimi Code") if ctx.invoked_subcommand is not None: return # skip rest if a subcommand is invoked del version # handled in the callback from kaos.path import KaosPath from kimi_cli.agentspec import DEFAULT_AGENT_FILE, OKABE_AGENT_FILE from kimi_cli.app import KimiCLI, enable_logging from kimi_cli.config import Config, load_config_from_string from kimi_cli.exception import ConfigError from kimi_cli.metadata import load_metadata, save_metadata from kimi_cli.session import Session from kimi_cli.ui.shell.startup import ShellStartupProgress from kimi_cli.utils.logging import logger, open_original_stderr, redirect_stderr_to_logger from .mcp import get_global_mcp_config_file # Don't redirect stderr yet. Our stderr redirector replaces fd=2 with a pipe, which # would swallow Click/Typer startup errors (e.g. config parsing / BadParameter). # We re-enable stderr redirection after KimiCLI.create() succeeds. enable_logging(debug, redirect_stderr=False) def _emit_fatal_error(message: str) -> None: # Prefer writing to the original stderr fd even if we later redirect fd=2. # This ensures fatal errors are visible to the user. with open_original_stderr() as stream: if stream is not None: stream.write((message.rstrip() + "\n").encode("utf-8", errors="replace")) stream.flush() return typer.echo(message, err=True) if session_id is not None: session_id = session_id.strip() if not session_id: raise typer.BadParameter("Session ID cannot be empty", param_hint="--session") if quiet: if acp_mode or wire_mode: raise typer.BadParameter( "Quiet mode cannot be combined with ACP or Wire UI", param_hint="--quiet", ) if output_format not in (None, "text"): raise typer.BadParameter( "Quiet mode implies `--output-format text`", param_hint="--quiet", ) print_mode = True output_format = "text" final_message_only = True conflict_option_sets = [ { "--print": print_mode, "--acp": acp_mode, "--wire": wire_mode, }, { "--agent": agent is not None, "--agent-file": agent_file is not None, }, { "--continue": continue_, "--session": session_id is not None, }, { "--config": config_string is not None, "--config-file": config_file is not None, }, ] for option_set in conflict_option_sets: active_options = [flag for flag, active in option_set.items() if active] if len(active_options) > 1: raise typer.BadParameter( f"Cannot combine {', '.join(active_options)}.", param_hint=active_options[0], ) if agent is not None: match agent: case "default": agent_file = DEFAULT_AGENT_FILE case "okabe": agent_file = OKABE_AGENT_FILE ui: UIMode = "shell" if print_mode: ui = "print" elif acp_mode: ui = "acp" elif wire_mode: ui = "wire" if prompt is not None: prompt = prompt.strip() if not prompt: raise typer.BadParameter("Prompt cannot be empty", param_hint="--prompt") if input_format is not None and ui != "print": raise typer.BadParameter( "Input format is only supported for print UI", param_hint="--input-format", ) if output_format is not None and ui != "print": raise typer.BadParameter( "Output format is only supported for print UI", param_hint="--output-format", ) if final_message_only and ui != "print": raise typer.BadParameter( "Final-message-only output is only supported for print UI", param_hint="--final-message-only", ) config: Config | Path | None = None if config_string is not None: config_string = config_string.strip() if not config_string: raise typer.BadParameter("Config cannot be empty", param_hint="--config") try: config = load_config_from_string(config_string) except ConfigError as e: raise typer.BadParameter(str(e), param_hint="--config") from e elif config_file is not None: config = config_file file_configs = list(mcp_config_file or []) raw_mcp_config = list(mcp_config or []) # Use default MCP config file if no MCP config is provided if not file_configs: default_mcp_file = get_global_mcp_config_file() if default_mcp_file.exists(): file_configs.append(default_mcp_file) try: mcp_configs = [json.loads(conf.read_text(encoding="utf-8")) for conf in file_configs] except json.JSONDecodeError as e: raise typer.BadParameter(f"Invalid JSON: {e}", param_hint="--mcp-config-file") from e try: mcp_configs += [json.loads(conf) for conf in raw_mcp_config] except json.JSONDecodeError as e: raise typer.BadParameter(f"Invalid JSON: {e}", param_hint="--mcp-config") from e skills_dir: KaosPath | None = None if local_skills_dir is not None: skills_dir = KaosPath.unsafe_from_local_path(local_skills_dir) work_dir = KaosPath.unsafe_from_local_path(local_work_dir) if local_work_dir else KaosPath.cwd() async def _run(session_id: str | None) -> tuple[Session, bool]: """ Create/load session and run the CLI instance. Returns: The session and whether the run succeeded. """ startup_progress = ShellStartupProgress(enabled=ui == "shell") try: startup_progress.update("Preparing session...") if session_id is not None: session = await Session.find(work_dir, session_id) if session is None: logger.info( "Session {session_id} not found, creating new session", session_id=session_id, ) session = await Session.create(work_dir, session_id) logger.info("Switching to session: {session_id}", session_id=session.id) elif continue_: session = await Session.continue_(work_dir) if session is None: raise typer.BadParameter( "No previous session found for the working directory", param_hint="--continue", ) logger.info("Continuing previous session: {session_id}", session_id=session.id) else: session = await Session.create(work_dir) logger.info("Created new session: {session_id}", session_id=session.id) # Add CLI-provided additional directories to session state if local_add_dirs: from kimi_cli.utils.path import is_within_directory canonical_work_dir = work_dir.canonical() changed = False for d in local_add_dirs: dir_path = KaosPath.unsafe_from_local_path(d).canonical() dir_str = str(dir_path) # Skip dirs within work_dir (already accessible) if is_within_directory(dir_path, canonical_work_dir): logger.info( "Skipping --add-dir {dir}: already within working directory", dir=dir_str, ) continue if dir_str not in session.state.additional_dirs: session.state.additional_dirs.append(dir_str) changed = True if changed: session.save_state() instance = await KimiCLI.create( session, config=config, model_name=model_name, thinking=thinking, yolo=yolo or (ui == "print"), # print mode implies yolo agent_file=agent_file, mcp_configs=mcp_configs, skills_dir=skills_dir, max_steps_per_turn=max_steps_per_turn, max_retries_per_step=max_retries_per_step, max_ralph_iterations=max_ralph_iterations, startup_progress=startup_progress.update if ui == "shell" else None, defer_mcp_loading=ui == "shell" and prompt is None, ) startup_progress.stop() # Install stderr redirection only after initialization succeeded, so runtime # stderr noise is captured into logs without hiding startup failures. redirect_stderr_to_logger() preserve_background_tasks = False try: match ui: case "shell": succeeded = await instance.run_shell(prompt) case "print": succeeded = await instance.run_print( input_format or "text", output_format or "text", prompt, final_only=final_message_only, ) case "acp": if prompt is not None: logger.warning("ACP server ignores prompt argument") await instance.run_acp() succeeded = True case "wire": if prompt is not None: logger.warning("Wire server ignores prompt argument") await instance.run_wire_stdio() succeeded = True except Reload as e: preserve_background_tasks = True if e.session_id is None: raise Reload(session_id=session.id) from e raise except SwitchToWeb: preserve_background_tasks = True raise finally: if not preserve_background_tasks: instance.shutdown_background_tasks() return session, succeeded finally: startup_progress.stop() async def _post_run(last_session: Session, succeeded: bool) -> None: if not succeeded: return metadata = load_metadata() # Update work_dir metadata with last session work_dir_meta = metadata.get_work_dir_meta(last_session.work_dir) if work_dir_meta is None: logger.warning( "Work dir metadata missing when marking last session, recreating: {work_dir}", work_dir=last_session.work_dir, ) work_dir_meta = metadata.new_work_dir_meta(last_session.work_dir) if last_session.is_empty(): logger.info( "Session {session_id} has empty context, removing it", session_id=last_session.id, ) await last_session.delete() if work_dir_meta.last_session_id == last_session.id: work_dir_meta.last_session_id = None else: work_dir_meta.last_session_id = last_session.id save_metadata(metadata) async def _reload_loop(session_id: str | None) -> bool: """ Returns: True if should switch to web interface, False otherwise. """ while True: try: last_session, succeeded = await _run(session_id) break except Reload as e: session_id = e.session_id continue except SwitchToWeb as e: if e.session_id is not None: session = await Session.find(work_dir, e.session_id) if session is not None: await _post_run(session, True) return True await _post_run(last_session, succeeded) return False try: switch_to_web = asyncio.run(_reload_loop(session_id)) except (typer.BadParameter, typer.Exit): # Let Typer/Click format these errors (rich panel + correct exit code). raise except Exception as exc: import click if isinstance(exc, click.ClickException): # ClickException includes the errors Typer knows how to render; don't # wrap them, or we'd lose the standard error UI and exit codes. raise logger.exception("Fatal error when running CLI") if debug: import traceback # In debug mode, show full traceback for quick diagnosis. _emit_fatal_error(traceback.format_exc()) else: from kimi_cli.share import get_share_dir log_path = get_share_dir() / "logs" / "kimi.log" # In non-debug mode, print a concise error and point users to logs. _emit_fatal_error(f"{exc}\nSee logs: {log_path}") raise typer.Exit(code=1) from exc if switch_to_web: from kimi_cli.utils.logging import restore_stderr restore_stderr() # Restore default SIGINT handler and terminal state after the shell's # asyncio.run() to ensure Ctrl+C works in the uvicorn web server. import signal signal.signal(signal.SIGINT, signal.default_int_handler) from kimi_cli.utils.term import ensure_tty_sane ensure_tty_sane() from kimi_cli.web.app import run_web_server run_web_server(open_browser=True) @cli.command() def login( json: bool = typer.Option( False, "--json", help="Emit OAuth events as JSON lines.", ), ) -> None: """Login to your Kimi account.""" import asyncio from rich.console import Console from rich.status import Status from kimi_cli.auth.oauth import login_kimi_code from kimi_cli.config import load_config async def _run() -> bool: if json: ok = True async for event in login_kimi_code(load_config()): typer.echo(event.json) if event.type == "error": ok = False return ok console = Console() ok = True status: Status | None = None try: async for event in login_kimi_code(load_config()): if event.type == "waiting": if status is None: status = console.status("Waiting for user authorization...") status.start() continue if status is not None: status.stop() status = None match event.type: case "error": style = "red" case "success": style = "green" case _: style = None console.print(event.message, markup=False, style=style) if event.type == "error": ok = False finally: if status is not None: status.stop() return ok ok = asyncio.run(_run()) if not ok: raise typer.Exit(code=1) @cli.command() def logout( json: bool = typer.Option( False, "--json", help="Emit OAuth events as JSON lines.", ), ) -> None: """Logout from your Kimi account.""" import asyncio from rich.console import Console from kimi_cli.auth.oauth import logout_kimi_code from kimi_cli.config import load_config async def _run() -> bool: ok = True if json: async for event in logout_kimi_code(load_config()): typer.echo(event.json) if event.type == "error": ok = False return ok console = Console() async for event in logout_kimi_code(load_config()): match event.type: case "error": style = "red" case "success": style = "green" case _: style = None console.print(event.message, markup=False, style=style) if event.type == "error": ok = False return ok ok = asyncio.run(_run()) if not ok: raise typer.Exit(code=1) @cli.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) def term( ctx: typer.Context, ) -> None: """Run Toad TUI backed by Kimi Code CLI ACP server.""" from .toad import run_term run_term(ctx) @cli.command() def acp(): """Run Kimi Code CLI ACP server.""" from kimi_cli.acp import acp_main acp_main() @cli.command(name="__background-task-worker", hidden=True) def background_task_worker( task_dir: Annotated[Path, typer.Option("--task-dir")], heartbeat_interval_ms: Annotated[int, typer.Option("--heartbeat-interval-ms")] = 5000, control_poll_interval_ms: Annotated[int, typer.Option("--control-poll-interval-ms")] = 500, kill_grace_period_ms: Annotated[int, typer.Option("--kill-grace-period-ms")] = 2000, ) -> None: """Run background task worker subprocess (internal).""" import asyncio from kimi_cli.background import run_background_task_worker from kimi_cli.utils.proctitle import set_process_title set_process_title("kimi-code-bg-worker") from kimi_cli.app import enable_logging enable_logging(debug=False) asyncio.run( run_background_task_worker( task_dir, heartbeat_interval_ms=heartbeat_interval_ms, control_poll_interval_ms=control_poll_interval_ms, kill_grace_period_ms=kill_grace_period_ms, ) ) @cli.command(name="__web-worker", hidden=True) def web_worker(session_id: str) -> None: """Run web worker subprocess (internal).""" import asyncio from uuid import UUID from kimi_cli.utils.proctitle import set_process_title set_process_title("kimi-code-worker") from kimi_cli.app import enable_logging from kimi_cli.web.runner.worker import run_worker try: parsed_session_id = UUID(session_id) except ValueError as exc: raise typer.BadParameter(f"Invalid session ID: {session_id}") from exc enable_logging(debug=False) asyncio.run(run_worker(parsed_session_id)) if __name__ == "__main__": import sys if "kimi_cli.cli" not in sys.modules: sys.modules["kimi_cli.cli"] = sys.modules[__name__] sys.exit(cli()) ================================================ FILE: src/kimi_cli/cli/__main__.py ================================================ from __future__ import annotations import sys from kimi_cli.cli import cli if __name__ == "__main__": sys.exit(cli()) ================================================ FILE: src/kimi_cli/cli/_lazy_group.py ================================================ # pyright: reportAttributeAccessIssue=false, reportMissingParameterType=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportUnknownArgumentType=false, reportUnknownMemberType=false, reportUnknownParameterType=false, reportUnknownVariableType=false, reportUntypedBaseClass=false from __future__ import annotations from importlib import import_module from typing import Any, cast import click import typer from click.core import HelpFormatter from typer.main import get_command class LazySubcommandGroup(typer.core.TyperGroup): """Load heavyweight subcommands only when they are actually invoked.""" lazy_subcommands: dict[str, tuple[str, str, str]] = { "info": ("kimi_cli.cli.info", "cli", "Show version and protocol information."), "export": ("kimi_cli.cli.export", "cli", "Export session data."), "mcp": ("kimi_cli.cli.mcp", "cli", "Manage MCP server configurations."), "plugin": ("kimi_cli.cli.plugin", "cli", "Manage plugins."), "vis": ("kimi_cli.cli.vis", "cli", "Run Kimi Agent Tracing Visualizer."), "web": ("kimi_cli.cli.web", "cli", "Run Kimi Code CLI web interface."), } lazy_command_order: tuple[str, ...] = ( "info", "export", "mcp", "plugin", "vis", "web", ) def list_commands(self, ctx: click.Context) -> list[str]: commands = list(super().list_commands(ctx)) for name in self.lazy_command_order: if name not in commands: commands.append(name) return commands def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None: command = super().get_command(ctx, cmd_name) if command is not None: return command lazy_spec = self.lazy_subcommands.get(cmd_name) if lazy_spec is None: return None module_name, attribute_name, _ = lazy_spec command = get_command(getattr(import_module(module_name), attribute_name)) command.name = cmd_name self.commands[cmd_name] = command return command def format_help(self, ctx: click.Context, formatter: HelpFormatter) -> None: if not typer.core.HAS_RICH or self.rich_markup_mode is None: return super().format_help(ctx, formatter) from typer import rich_utils rich_utils_any = cast(Any, rich_utils) console = rich_utils_any._get_rich_console() console.print( rich_utils_any.Padding( rich_utils_any.highlighter(self.get_usage(ctx)), 1, ), style=rich_utils_any.STYLE_USAGE_COMMAND, ) if self.help: console.print( rich_utils_any.Padding( rich_utils_any.Align( rich_utils_any._get_help_text( obj=self, markup_mode=self.rich_markup_mode, ), pad=False, ), (0, 1, 1, 1), ) ) panel_to_arguments: dict[str, list[click.Argument]] = {} panel_to_options: dict[str, list[click.Option]] = {} for param in self.get_params(ctx): if getattr(param, "hidden", False): continue if isinstance(param, click.Argument): panel_name = ( getattr(param, rich_utils_any._RICH_HELP_PANEL_NAME, None) or rich_utils_any.ARGUMENTS_PANEL_TITLE ) panel_to_arguments.setdefault(panel_name, []).append(param) elif isinstance(param, click.Option): panel_name = ( getattr(param, rich_utils_any._RICH_HELP_PANEL_NAME, None) or rich_utils_any.OPTIONS_PANEL_TITLE ) panel_to_options.setdefault(panel_name, []).append(param) default_arguments = panel_to_arguments.get(rich_utils_any.ARGUMENTS_PANEL_TITLE, []) rich_utils_any._print_options_panel( name=rich_utils_any.ARGUMENTS_PANEL_TITLE, params=default_arguments, ctx=ctx, markup_mode=self.rich_markup_mode, console=console, ) for panel_name, arguments in panel_to_arguments.items(): if panel_name == rich_utils_any.ARGUMENTS_PANEL_TITLE: continue rich_utils_any._print_options_panel( name=panel_name, params=arguments, ctx=ctx, markup_mode=self.rich_markup_mode, console=console, ) default_options = panel_to_options.get(rich_utils_any.OPTIONS_PANEL_TITLE, []) rich_utils_any._print_options_panel( name=rich_utils_any.OPTIONS_PANEL_TITLE, params=default_options, ctx=ctx, markup_mode=self.rich_markup_mode, console=console, ) for panel_name, options in panel_to_options.items(): if panel_name == rich_utils_any.OPTIONS_PANEL_TITLE: continue rich_utils_any._print_options_panel( name=panel_name, params=options, ctx=ctx, markup_mode=self.rich_markup_mode, console=console, ) panel_to_commands: dict[str, list[click.Command]] = {} for command_name in self.list_commands(ctx): command = self.commands.get(command_name) if command is None: lazy_spec = self.lazy_subcommands.get(command_name) if lazy_spec is None: continue command = click.Command(command_name, help=lazy_spec[2]) if command.hidden: continue panel_name = ( getattr(command, rich_utils_any._RICH_HELP_PANEL_NAME, None) or rich_utils_any.COMMANDS_PANEL_TITLE ) panel_to_commands.setdefault(panel_name, []).append(command) max_cmd_len = max( ( len(command.name or "") for commands in panel_to_commands.values() for command in commands ), default=0, ) default_commands = panel_to_commands.get(rich_utils_any.COMMANDS_PANEL_TITLE, []) rich_utils_any._print_commands_panel( name=rich_utils_any.COMMANDS_PANEL_TITLE, commands=default_commands, markup_mode=self.rich_markup_mode, console=console, cmd_len=max_cmd_len, ) for panel_name, commands in panel_to_commands.items(): if panel_name == rich_utils_any.COMMANDS_PANEL_TITLE: continue rich_utils_any._print_commands_panel( name=panel_name, commands=commands, markup_mode=self.rich_markup_mode, console=console, cmd_len=max_cmd_len, ) if self.epilog: lines = self.epilog.split("\n\n") epilogue = "\n".join(x.replace("\n", " ").strip() for x in lines) epilogue_text = rich_utils_any._make_rich_text( text=epilogue, markup_mode=self.rich_markup_mode, ) console.print(rich_utils_any.Padding(rich_utils_any.Align(epilogue_text, pad=False), 1)) def format_commands(self, ctx: click.Context, formatter: HelpFormatter) -> None: entries: list[tuple[str, str | None]] = [] for subcommand in self.list_commands(ctx): command = self.commands.get(subcommand) if command is not None: if command.hidden: continue entries.append((subcommand, None)) continue lazy_spec = self.lazy_subcommands.get(subcommand) if lazy_spec is None: continue entries.append((subcommand, lazy_spec[2])) if not entries: return limit = formatter.width - 6 - max(len(name) for name, _ in entries) rows: list[tuple[str, str]] = [] for subcommand, short_help in entries: command = self.commands.get(subcommand) if command is not None: rows.append((subcommand, command.get_short_help_str(limit))) continue rows.append((subcommand, short_help or "")) if rows: with formatter.section("Commands"): formatter.write_dl(rows) ================================================ FILE: src/kimi_cli/cli/export.py ================================================ """Export command for packaging session data.""" from __future__ import annotations import io import zipfile from pathlib import Path from typing import Annotated import typer cli = typer.Typer(help="Export session data.") def _find_session_by_id(session_id: str) -> Path | None: """Find a session directory by session ID across all work directories.""" from kimi_cli.share import get_share_dir sessions_root = get_share_dir() / "sessions" if not sessions_root.exists(): return None for work_dir_hash_dir in sessions_root.iterdir(): if not work_dir_hash_dir.is_dir(): continue candidate = work_dir_hash_dir / session_id if candidate.is_dir(): return candidate return None @cli.callback(invoke_without_command=True) def export( session_id: Annotated[ str, typer.Argument(help="Session ID to export."), ], output: Annotated[ Path | None, typer.Option( "--output", "-o", help="Output ZIP file path. Default: session-{id}.zip in current directory.", ), ] = None, ) -> None: """Export a session as a ZIP archive.""" session_dir = _find_session_by_id(session_id) if session_dir is None: typer.echo(f"Error: session '{session_id}' not found.", err=True) raise typer.Exit(code=1) # Collect files files = sorted(f for f in session_dir.iterdir() if f.is_file()) if not files: typer.echo(f"Error: session '{session_id}' has no files.", err=True) raise typer.Exit(code=1) # Determine output path if output is None: output = Path.cwd() / f"session-{session_id}.zip" # Create ZIP buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: for file_path in files: zf.write(file_path, arcname=file_path.name) buf.seek(0) output.parent.mkdir(parents=True, exist_ok=True) output.write_bytes(buf.getvalue()) typer.echo(str(output)) ================================================ FILE: src/kimi_cli/cli/info.py ================================================ from __future__ import annotations import json import platform from typing import Annotated, TypedDict import typer class InfoData(TypedDict): kimi_cli_version: str agent_spec_versions: list[str] wire_protocol_version: str python_version: str def _collect_info() -> InfoData: from kimi_cli.agentspec import SUPPORTED_AGENT_SPEC_VERSIONS from kimi_cli.constant import get_version from kimi_cli.wire.protocol import WIRE_PROTOCOL_VERSION return { "kimi_cli_version": get_version(), "agent_spec_versions": [str(version) for version in SUPPORTED_AGENT_SPEC_VERSIONS], "wire_protocol_version": WIRE_PROTOCOL_VERSION, "python_version": platform.python_version(), } def _emit_info(json_output: bool) -> None: info = _collect_info() if json_output: typer.echo(json.dumps(info, ensure_ascii=False)) return agent_versions_text = ", ".join(str(version) for version in info["agent_spec_versions"]) lines = [ f"kimi-cli version: {info['kimi_cli_version']}", f"agent spec versions: {agent_versions_text}", f"wire protocol: {info['wire_protocol_version']}", f"python version: {info['python_version']}", ] for line in lines: typer.echo(line) cli = typer.Typer(help="Show version and protocol information.") @cli.callback(invoke_without_command=True) def info( json_output: Annotated[ bool, typer.Option( "--json", help="Output information as JSON.", ), ] = False, ): """Show version and protocol information.""" _emit_info(json_output) ================================================ FILE: src/kimi_cli/cli/mcp.py ================================================ import json from pathlib import Path from typing import Annotated, Any, Literal import typer cli = typer.Typer(help="Manage MCP server configurations.") def get_global_mcp_config_file() -> Path: """Get the global MCP config file path.""" from kimi_cli.share import get_share_dir return get_share_dir() / "mcp.json" def _load_mcp_config() -> dict[str, Any]: """Load MCP config from global mcp config file.""" from fastmcp.mcp_config import MCPConfig from pydantic import ValidationError mcp_file = get_global_mcp_config_file() if not mcp_file.exists(): return {"mcpServers": {}} try: config = json.loads(mcp_file.read_text(encoding="utf-8")) except json.JSONDecodeError as e: raise typer.BadParameter(f"Invalid JSON in MCP config file '{mcp_file}': {e}") from e try: MCPConfig.model_validate(config) except ValidationError as e: raise typer.BadParameter(f"Invalid MCP config in '{mcp_file}': {e}") from e return config def _save_mcp_config(config: dict[str, Any]) -> None: """Save MCP config to default file.""" mcp_file = get_global_mcp_config_file() mcp_file.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8") def _get_mcp_server(name: str, *, require_remote: bool = False) -> dict[str, Any]: """Get MCP server config by name.""" config = _load_mcp_config() servers = config.get("mcpServers", {}) if name not in servers: typer.echo(f"MCP server '{name}' not found.", err=True) raise typer.Exit(code=1) server = servers[name] if require_remote and "url" not in server: typer.echo(f"MCP server '{name}' is not a remote server.", err=True) raise typer.Exit(code=1) return server def _parse_key_value_pairs( items: list[str], option_name: str, *, separator: str = "=", strip_whitespace: bool = False ) -> dict[str, str]: """Parse key/value pairs from CLI options.""" parsed: dict[str, str] = {} for item in items: if separator not in item: typer.echo( f"Invalid {option_name} format: {item} (expected KEY{separator}VALUE).", err=True, ) raise typer.Exit(code=1) key, value = item.split(separator, 1) if strip_whitespace: key, value = key.strip(), value.strip() if not key: typer.echo(f"Invalid {option_name} format: {item} (empty key).", err=True) raise typer.Exit(code=1) parsed[key] = value return parsed Transport = Literal["stdio", "http"] @cli.command( "add", epilog=""" Examples:\n \n # Add streamable HTTP server:\n kimi mcp add --transport http context7 https://mcp.context7.com/mcp --header \"CONTEXT7_API_KEY: ctx7sk-your-key\"\n \n # Add streamable HTTP server with OAuth authorization:\n kimi mcp add --transport http --auth oauth linear https://mcp.linear.app/mcp\n \n # Add stdio server:\n kimi mcp add --transport stdio chrome-devtools -- npx chrome-devtools-mcp@latest """.strip(), # noqa: E501 ) def mcp_add( name: Annotated[ str, typer.Argument(help="Name of the MCP server to add."), ], server_args: Annotated[ list[str] | None, typer.Argument( metavar="TARGET_OR_COMMAND...", help="For http: server URL. For stdio: command to run (prefix with `--`).", ), ] = None, transport: Annotated[ Transport, typer.Option( "--transport", "-t", help="Transport type for the MCP server. Default: stdio.", ), ] = "stdio", env: Annotated[ list[str] | None, typer.Option( "--env", "-e", help="Environment variables in KEY=VALUE format. Can be specified multiple times.", ), ] = None, header: Annotated[ list[str] | None, typer.Option( "--header", "-H", help="HTTP headers in KEY:VALUE format. Can be specified multiple times.", ), ] = None, auth: Annotated[ str | None, typer.Option( "--auth", "-a", help="Authorization type (e.g., 'oauth').", ), ] = None, ): """Add an MCP server.""" config = _load_mcp_config() server_args = server_args or [] if transport not in {"stdio", "http"}: typer.echo(f"Unsupported transport: {transport}.", err=True) raise typer.Exit(code=1) if transport == "stdio": if not server_args: typer.echo( "For stdio transport, provide the command to start the MCP server after `--`.", err=True, ) raise typer.Exit(code=1) if header: typer.echo("--header is only valid for http transport.", err=True) raise typer.Exit(code=1) if auth: typer.echo("--auth is only valid for http transport.", err=True) raise typer.Exit(code=1) command, *command_args = server_args server_config: dict[str, Any] = {"command": command, "args": command_args} if env: server_config["env"] = _parse_key_value_pairs(env, "env") else: if env: typer.echo("--env is only supported for stdio transport.", err=True) raise typer.Exit(code=1) if not server_args: typer.echo("URL is required for http transport.", err=True) raise typer.Exit(code=1) if len(server_args) > 1: typer.echo( "Multiple targets provided. Supply a single URL for http transport.", err=True, ) raise typer.Exit(code=1) server_config = {"url": server_args[0], "transport": "http"} if header: server_config["headers"] = _parse_key_value_pairs( header, "header", separator=":", strip_whitespace=True ) if auth: server_config["auth"] = auth if "mcpServers" not in config: config["mcpServers"] = {} config["mcpServers"][name] = server_config _save_mcp_config(config) typer.echo(f"Added MCP server '{name}' to {get_global_mcp_config_file()}.") @cli.command("remove") def mcp_remove( name: Annotated[ str, typer.Argument(help="Name of the MCP server to remove."), ], ): """Remove an MCP server.""" _get_mcp_server(name) config = _load_mcp_config() del config["mcpServers"][name] _save_mcp_config(config) typer.echo(f"Removed MCP server '{name}' from {get_global_mcp_config_file()}.") def _has_oauth_tokens(server_url: str) -> bool: """Check if OAuth tokens exist for the server.""" import asyncio async def _check() -> bool: try: from fastmcp.client.auth.oauth import FileTokenStorage storage = FileTokenStorage(server_url=server_url) tokens = await storage.get_tokens() return tokens is not None except Exception: return False return asyncio.run(_check()) @cli.command("list") def mcp_list(): """List all MCP servers.""" config_file = get_global_mcp_config_file() config = _load_mcp_config() servers: dict[str, Any] = config.get("mcpServers", {}) typer.echo(f"MCP config file: {config_file}") if not servers: typer.echo("No MCP servers configured.") return for name, server in servers.items(): if "command" in server: cmd = server["command"] cmd_args = " ".join(server.get("args", [])) line = f"{name} (stdio): {cmd} {cmd_args}".rstrip() elif "url" in server: transport = server.get("transport") or "http" if transport == "streamable-http": transport = "http" line = f"{name} ({transport}): {server['url']}" if server.get("auth") == "oauth" and not _has_oauth_tokens(server["url"]): line += " [authorization required - run: kimi mcp auth " + name + "]" else: line = f"{name}: {server}" typer.echo(f" {line}") @cli.command("auth") def mcp_auth( name: Annotated[ str, typer.Argument(help="Name of the MCP server to authorize."), ], ): """Authorize with an OAuth-enabled MCP server.""" import asyncio server = _get_mcp_server(name, require_remote=True) if server.get("auth") != "oauth": typer.echo(f"MCP server '{name}' does not use OAuth. Add with --auth oauth.", err=True) raise typer.Exit(code=1) async def _auth() -> None: import fastmcp typer.echo(f"Authorizing with '{name}'...") typer.echo("A browser window will open for authorization.") client = fastmcp.Client({"mcpServers": {name: server}}) try: async with client: tools = await client.list_tools() typer.echo(f"Successfully authorized with '{name}'.") typer.echo(f"Available tools: {len(tools)}") except Exception as e: typer.echo(f"Authorization failed: {type(e).__name__}: {e}", err=True) raise typer.Exit(code=1) from None asyncio.run(_auth()) @cli.command("reset-auth") def mcp_reset_auth( name: Annotated[ str, typer.Argument(help="Name of the MCP server to reset authorization."), ], ): """Reset OAuth authorization for an MCP server (clear cached tokens).""" server = _get_mcp_server(name, require_remote=True) try: from fastmcp.client.auth.oauth import FileTokenStorage storage = FileTokenStorage(server_url=server["url"]) storage.clear() typer.echo(f"OAuth tokens cleared for '{name}'.") except ImportError: typer.echo("OAuth support not available.", err=True) raise typer.Exit(code=1) from None except Exception as e: typer.echo(f"Failed to clear tokens: {type(e).__name__}: {e}", err=True) raise typer.Exit(code=1) from None @cli.command("test") def mcp_test( name: Annotated[ str, typer.Argument(help="Name of the MCP server to test."), ], ): """Test connection to an MCP server and list available tools.""" import asyncio server = _get_mcp_server(name) async def _test() -> None: import fastmcp typer.echo(f"Testing connection to '{name}'...") client = fastmcp.Client({"mcpServers": {name: server}}) try: async with client: tools = await client.list_tools() typer.echo(f"✓ Connected to '{name}'") typer.echo(f" Available tools: {len(tools)}") if tools: typer.echo(" Tools:") for tool in tools: desc = tool.description or "" if len(desc) > 50: desc = desc[:47] + "..." typer.echo(f" - {tool.name}: {desc}") except Exception as e: typer.echo(f"✗ Connection failed: {type(e).__name__}: {e}", err=True) raise typer.Exit(code=1) from None asyncio.run(_test()) ================================================ FILE: src/kimi_cli/cli/plugin.py ================================================ """CLI commands for plugin management.""" from __future__ import annotations from pathlib import Path from typing import Annotated import typer from kimi_cli.plugin import PluginError cli = typer.Typer(help="Manage plugins.") def _parse_git_url(target: str) -> tuple[str, str | None, str | None]: """Parse a git URL into (clone_url, subpath, branch). Splits .git URLs at the .git boundary. For GitHub/GitLab short URLs, treats the first two path segments as owner/repo and the rest as subpath. Strips ``tree/{branch}/`` or ``-/tree/{branch}/`` prefixes from browser-copied URLs and returns the branch name. """ # Path 1: URL contains .git followed by / or end-of-string idx = target.find(".git/") if idx == -1 and target.endswith(".git"): return target, None, None if idx != -1: clone_url = target[: idx + 4] # up to and including ".git" rest = target[idx + 5 :] # after ".git/" subpath = rest.strip("/") or None return clone_url, subpath, None # Path 2: GitHub/GitLab short URL (no .git) from urllib.parse import urlparse parsed = urlparse(target) segments = [s for s in parsed.path.split("/") if s] if len(segments) < 2: return target, None, None owner_repo = "/".join(segments[:2]) clone_url = f"{parsed.scheme}://{parsed.netloc}/{owner_repo}" rest_segments = segments[2:] # GitLab uses /-/tree/{branch}/, strip leading "-" if rest_segments and rest_segments[0] == "-": rest_segments = rest_segments[1:] # Strip tree/{branch}/ prefix and extract branch branch: str | None = None if len(rest_segments) >= 2 and rest_segments[0] == "tree": branch = rest_segments[1] rest_segments = rest_segments[2:] subpath = "/".join(rest_segments) or None return clone_url, subpath, branch def _resolve_source(target: str) -> tuple[Path, Path | None]: """Resolve plugin source to (local_dir, tmp_to_cleanup). Returns the source directory and an optional temp directory that the caller must clean up after use. """ import shutil import tempfile # Git URL if target.startswith(("https://", "git@", "http://")) and ( ".git/" in target or target.endswith(".git") or "github.com/" in target or "gitlab.com/" in target ): import subprocess clone_url, subpath, branch = _parse_git_url(target) tmp = Path(tempfile.mkdtemp(prefix="kimi-plugin-")) typer.echo(f"Cloning {clone_url}...") clone_cmd = ["git", "clone", "--depth", "1"] if branch: clone_cmd += ["--branch", branch] clone_cmd += [clone_url, str(tmp / "repo")] result = subprocess.run( clone_cmd, capture_output=True, text=True, ) if result.returncode != 0: shutil.rmtree(tmp, ignore_errors=True) typer.echo( f"Error: git clone failed: {result.stderr.strip()}", err=True, ) raise typer.Exit(1) repo_root = tmp / "repo" if subpath: source = (repo_root / subpath).resolve() if not source.is_relative_to(repo_root.resolve()): shutil.rmtree(tmp, ignore_errors=True) typer.echo( f"Error: subpath escapes repository: {subpath}", err=True, ) raise typer.Exit(1) if not source.is_dir(): shutil.rmtree(tmp, ignore_errors=True) typer.echo( f"Error: subpath '{subpath}' not found in repository", err=True, ) raise typer.Exit(1) if not (source / "plugin.json").exists(): shutil.rmtree(tmp, ignore_errors=True) typer.echo( f"Error: no plugin.json in '{subpath}'", err=True, ) raise typer.Exit(1) return source, tmp # No subpath — check root first if (repo_root / "plugin.json").exists(): return repo_root, tmp # Scan one level for available plugins available = sorted( d.name for d in repo_root.iterdir() if d.is_dir() and (d / "plugin.json").exists() ) if available: names = "\n".join(f" - {n}" for n in available) typer.echo( f"Error: No plugin.json at repository root. " f"Available plugins:\n{names}\n" f"Use: kimi plugin install /", err=True, ) else: typer.echo( "Error: No plugin.json found in repository", err=True, ) shutil.rmtree(tmp, ignore_errors=True) raise typer.Exit(1) p = Path(target).expanduser().resolve() # Zip file if p.is_file() and p.suffix == ".zip": import zipfile tmp = Path(tempfile.mkdtemp(prefix="kimi-plugin-")) typer.echo(f"Extracting {p.name}...") with zipfile.ZipFile(p, "r") as zf: # Reject zip members that escape the extraction directory for member in zf.namelist(): member_path = (tmp / member).resolve() if not member_path.is_relative_to(tmp.resolve()): shutil.rmtree(tmp, ignore_errors=True) typer.echo(f"Error: zip contains unsafe path: {member}", err=True) raise typer.Exit(1) zf.extractall(tmp) # Find the directory containing plugin.json (may be nested one level) for candidate in [tmp] + sorted(tmp.iterdir()): if candidate.is_dir() and (candidate / "plugin.json").exists(): return candidate, tmp # Check for __MACOSX and similar artifacts dirs = [d for d in tmp.iterdir() if d.is_dir() and not d.name.startswith("_")] if len(dirs) == 1 and (dirs[0] / "plugin.json").exists(): return dirs[0], tmp shutil.rmtree(tmp, ignore_errors=True) typer.echo("Error: No plugin.json found in zip", err=True) raise typer.Exit(1) # Local directory if p.is_dir(): return p, None typer.echo(f"Error: {target} is not a directory, zip file, or git URL", err=True) raise typer.Exit(1) @cli.command("install") def install_cmd( target: Annotated[str, typer.Argument(help="Plugin source: directory, .zip, or git URL")], ) -> None: """Install a plugin and inject host configuration.""" import shutil from kimi_cli.config import load_config from kimi_cli.constant import VERSION from kimi_cli.plugin.manager import get_plugins_dir, install_plugin source, tmp_dir = _resolve_source(target) try: config = load_config() from kimi_cli.auth.oauth import OAuthManager from kimi_cli.llm import augment_provider_with_env_vars from kimi_cli.plugin.manager import collect_host_values # Apply env var overrides (install runs outside normal startup) if config.default_model and config.default_model in config.models: model = config.models[config.default_model] if model.provider in config.providers: augment_provider_with_env_vars(config.providers[model.provider], model) oauth = OAuthManager(config) host_values = collect_host_values(config, oauth) if not host_values.get("api_key"): typer.echo( "Warning: No LLM provider configured. " "Plugins requiring API key injection will fail. " "Run 'kimi login' or configure a provider first.", err=True, ) spec = install_plugin( source=source, plugins_dir=get_plugins_dir(), host_values=host_values, host_name="kimi-code", host_version=VERSION, ) except PluginError as exc: typer.echo(f"Error: {exc}", err=True) raise typer.Exit(1) from exc finally: # Clean up temp directory from zip/git extraction if tmp_dir is not None: shutil.rmtree(tmp_dir, ignore_errors=True) typer.echo(f"Installed plugin '{spec.name}' v{spec.version}") if spec.runtime: typer.echo(f" runtime: host={spec.runtime.host}, version={spec.runtime.host_version}") @cli.command("list") def list_cmd() -> None: """List installed plugins.""" from kimi_cli.plugin.manager import get_plugins_dir, list_plugins plugins = list_plugins(get_plugins_dir()) if not plugins: typer.echo("No plugins installed.") return for p in plugins: status = "installed" if p.runtime else "not configured" typer.echo(f" {p.name} v{p.version} ({status})") @cli.command("remove") def remove_cmd( name: Annotated[str, typer.Argument(help="Plugin name to remove")], ) -> None: """Remove an installed plugin.""" from kimi_cli.plugin.manager import get_plugins_dir, remove_plugin try: remove_plugin(name, get_plugins_dir()) except PluginError as exc: typer.echo(f"Error: {exc}", err=True) raise typer.Exit(1) from exc typer.echo(f"Removed plugin '{name}'") @cli.command("info") def info_cmd( name: Annotated[str, typer.Argument(help="Plugin name")], ) -> None: """Show plugin details.""" from kimi_cli.plugin import parse_plugin_json from kimi_cli.plugin.manager import get_plugins_dir plugin_json = get_plugins_dir() / name / "plugin.json" if not plugin_json.exists(): typer.echo(f"Error: Plugin '{name}' not found", err=True) raise typer.Exit(1) try: spec = parse_plugin_json(plugin_json) except PluginError as exc: typer.echo(f"Error: {exc}", err=True) raise typer.Exit(1) from exc typer.echo(f"Name: {spec.name}") typer.echo(f"Version: {spec.version}") typer.echo(f"Description: {spec.description or '(none)'}") typer.echo(f"Config file: {spec.config_file or '(none)'}") if spec.inject: typer.echo(f"Inject: {', '.join(f'{k} <- {v}' for k, v in spec.inject.items())}") if spec.runtime: typer.echo(f"Runtime: host={spec.runtime.host}, version={spec.runtime.host_version}") else: typer.echo("Runtime: (not installed via host)") ================================================ FILE: src/kimi_cli/cli/toad.py ================================================ import importlib.util import shlex import shutil import subprocess import sys from pathlib import Path import typer def _default_acp_command() -> list[str]: argv0 = sys.argv[0] if argv0: resolved = shutil.which(argv0) resolved_path = Path(resolved).expanduser() if resolved else Path(argv0).expanduser() if ( resolved_path.exists() and resolved_path.suffix != ".py" and not resolved_path.name.startswith(("python", "pypy")) ): return [str(resolved_path), "acp"] return [sys.executable, "-m", "kimi_cli.cli", "acp"] def _default_toad_command() -> list[str]: if sys.version_info < (3, 14): typer.echo("`kimi term` requires Python 3.14+ because Toad requires it.", err=True) raise typer.Exit(code=1) if importlib.util.find_spec("toad") is None: typer.echo( "Toad dependency is missing. Install kimi-cli with Python 3.14+ to use `kimi term`.", err=True, ) raise typer.Exit(code=1) return [sys.executable, "-m", "toad.cli"] def _extract_project_dir(extra_args: list[str]) -> Path | None: work_dir: str | None = None idx = 0 while idx < len(extra_args): arg = extra_args[idx] if arg in ("--work-dir", "-w"): if idx + 1 < len(extra_args): work_dir = extra_args[idx + 1] idx += 2 continue elif arg.startswith("--work-dir=") or arg.startswith("-w="): work_dir = arg.split("=", 1)[1] elif arg.startswith("-w") and len(arg) > 2: work_dir = arg[2:] idx += 1 if not work_dir: return None return Path(work_dir).expanduser().resolve() def run_term(ctx: typer.Context) -> None: extra_args = list(ctx.args) acp_args = _default_acp_command() acp_command = shlex.join(acp_args) toad_parts = _default_toad_command() args = [*toad_parts, "acp", acp_command] project_dir = _extract_project_dir(extra_args) if project_dir is not None: args.append(str(project_dir)) result = subprocess.run(args) if result.returncode != 0: raise typer.Exit(code=result.returncode) ================================================ FILE: src/kimi_cli/cli/vis.py ================================================ """Vis command for Kimi Agent Tracing Visualizer.""" from typing import Annotated import typer cli = typer.Typer(help="Run Kimi Agent Tracing Visualizer.") @cli.callback(invoke_without_command=True) def vis( ctx: typer.Context, port: Annotated[int, typer.Option("--port", "-p", help="Port to bind to")] = 5495, open_browser: Annotated[ bool, typer.Option("--open/--no-open", help="Open browser automatically") ] = True, reload: Annotated[bool, typer.Option("--reload", help="Enable auto-reload")] = False, ): """Launch the agent tracing visualizer.""" from kimi_cli.vis.app import run_vis_server run_vis_server(port=port, open_browser=open_browser, reload=reload) ================================================ FILE: src/kimi_cli/cli/web.py ================================================ """Web UI command for Kimi Code CLI.""" from typing import Annotated import typer cli = typer.Typer(help="Run Kimi Code CLI web interface.") @cli.callback(invoke_without_command=True) def web( ctx: typer.Context, host: Annotated[ str | None, typer.Option("--host", "-h", help="Bind to specific IP address"), ] = None, network: Annotated[ bool, typer.Option("--network", "-n", help="Enable network access (bind to 0.0.0.0)"), ] = False, port: Annotated[int, typer.Option("--port", "-p", help="Port to bind to")] = 5494, reload: Annotated[bool, typer.Option("--reload", help="Enable auto-reload")] = False, open_browser: Annotated[ bool, typer.Option("--open/--no-open", help="Open browser automatically") ] = True, auth_token: Annotated[ str | None, typer.Option("--auth-token", help="Bearer token for API authentication."), ] = None, allowed_origins: Annotated[ str | None, typer.Option( "--allowed-origins", help="Comma-separated list of allowed Origin values.", ), ] = None, dangerously_omit_auth: Annotated[ bool, typer.Option( "--dangerously-omit-auth", help="Disable auth checks (dangerous in public networks).", ), ] = False, restrict_sensitive_apis: Annotated[ bool | None, typer.Option( "--restrict-sensitive-apis/--no-restrict-sensitive-apis", help="Disable sensitive APIs (config write, open-in, file access limits).", ), ] = None, lan_only: Annotated[ bool, typer.Option( "--lan-only/--public", help="Only allow access from local network (default) or allow public access.", ), ] = True, ): """Run Kimi Code CLI web interface.""" from kimi_cli.web.app import run_web_server # Determine bind address if host: bind_host = host elif network: bind_host = "0.0.0.0" else: bind_host = "127.0.0.1" run_web_server( host=bind_host, port=port, reload=reload, open_browser=open_browser, auth_token=auth_token, allowed_origins=allowed_origins, dangerously_omit_auth=dangerously_omit_auth, restrict_sensitive_apis=restrict_sensitive_apis, lan_only=lan_only, ) ================================================ FILE: src/kimi_cli/config.py ================================================ from __future__ import annotations import json from pathlib import Path from typing import Literal, Self import tomlkit from pydantic import ( AliasChoices, BaseModel, Field, SecretStr, ValidationError, field_serializer, model_validator, ) from tomlkit.exceptions import TOMLKitError from kimi_cli.exception import ConfigError from kimi_cli.llm import ModelCapability, ProviderType from kimi_cli.share import get_share_dir from kimi_cli.utils.logging import logger class OAuthRef(BaseModel): """Reference to OAuth credentials stored outside the config file.""" storage: Literal["keyring", "file"] = "file" """Credential storage backend.""" key: str """Storage key to locate OAuth credentials.""" class LLMProvider(BaseModel): """LLM provider configuration.""" type: ProviderType """Provider type""" base_url: str """API base URL""" api_key: SecretStr """API key""" env: dict[str, str] | None = None """Environment variables to set before creating the provider instance""" custom_headers: dict[str, str] | None = None """Custom headers to include in API requests""" oauth: OAuthRef | None = None """OAuth credential reference (do not store tokens here).""" @field_serializer("api_key", when_used="json") def dump_secret(self, v: SecretStr): return v.get_secret_value() class LLMModel(BaseModel): """LLM model configuration.""" provider: str """Provider name""" model: str """Model name""" max_context_size: int """Maximum context size (unit: tokens)""" capabilities: set[ModelCapability] | None = None """Model capabilities""" class LoopControl(BaseModel): """Agent loop control configuration.""" max_steps_per_turn: int = Field( default=100, ge=1, validation_alias=AliasChoices("max_steps_per_turn", "max_steps_per_run"), ) """Maximum number of steps in one turn""" max_retries_per_step: int = Field(default=3, ge=1) """Maximum number of retries in one step""" max_ralph_iterations: int = Field(default=0, ge=-1) """Extra iterations after the first turn in Ralph mode. Use -1 for unlimited.""" reserved_context_size: int = Field(default=50_000, ge=1000) """Reserved token count for LLM response generation. Auto-compaction triggers when either context_tokens + reserved_context_size >= max_context_size or context_tokens >= max_context_size * compaction_trigger_ratio. Default is 50000.""" compaction_trigger_ratio: float = Field(default=0.85, ge=0.5, le=0.99) """Context usage ratio threshold for auto-compaction. Default is 0.85 (85%). Auto-compaction triggers when context_tokens >= max_context_size * compaction_trigger_ratio or when context_tokens + reserved_context_size >= max_context_size.""" class BackgroundConfig(BaseModel): """Background task runtime configuration.""" max_running_tasks: int = Field(default=4, ge=1) read_max_bytes: int = Field(default=30_000, ge=1024) notification_tail_lines: int = Field(default=20, ge=1) notification_tail_chars: int = Field(default=3_000, ge=256) wait_poll_interval_ms: int = Field(default=500, ge=50) worker_heartbeat_interval_ms: int = Field(default=5_000, ge=100) worker_stale_after_ms: int = Field(default=15_000, ge=1000) kill_grace_period_ms: int = Field(default=2_000, ge=100) keep_alive_on_exit: bool = Field( default=False, description="Keep background tasks alive when CLI exits. Default: kill on exit.", ) class NotificationConfig(BaseModel): """Notification runtime configuration.""" claim_stale_after_ms: int = Field(default=15_000, ge=1000) class MoonshotSearchConfig(BaseModel): """Moonshot Search configuration.""" base_url: str """Base URL for Moonshot Search service.""" api_key: SecretStr """API key for Moonshot Search service.""" custom_headers: dict[str, str] | None = None """Custom headers to include in API requests.""" oauth: OAuthRef | None = None """OAuth credential reference (do not store tokens here).""" @field_serializer("api_key", when_used="json") def dump_secret(self, v: SecretStr): return v.get_secret_value() class MoonshotFetchConfig(BaseModel): """Moonshot Fetch configuration.""" base_url: str """Base URL for Moonshot Fetch service.""" api_key: SecretStr """API key for Moonshot Fetch service.""" custom_headers: dict[str, str] | None = None """Custom headers to include in API requests.""" oauth: OAuthRef | None = None """OAuth credential reference (do not store tokens here).""" @field_serializer("api_key", when_used="json") def dump_secret(self, v: SecretStr): return v.get_secret_value() class Services(BaseModel): """Services configuration.""" moonshot_search: MoonshotSearchConfig | None = None """Moonshot Search configuration.""" moonshot_fetch: MoonshotFetchConfig | None = None """Moonshot Fetch configuration.""" class MCPClientConfig(BaseModel): """MCP client configuration.""" tool_call_timeout_ms: int = 60000 """Timeout for tool calls in milliseconds.""" class MCPConfig(BaseModel): """MCP configuration.""" client: MCPClientConfig = Field( default_factory=MCPClientConfig, description="MCP client configuration" ) class Config(BaseModel): """Main configuration structure.""" is_from_default_location: bool = Field( default=False, description="Whether the config was loaded from the default location", exclude=True, ) source_file: Path | None = Field( default=None, description="Path to the loaded config file. None when loaded from --config text.", exclude=True, ) default_model: str = Field(default="", description="Default model to use") default_thinking: bool = Field(default=False, description="Default thinking mode") default_yolo: bool = Field(default=False, description="Default yolo (auto-approve) mode") default_editor: str = Field( default="", description="Default external editor command (e.g. 'vim', 'code --wait')", ) models: dict[str, LLMModel] = Field(default_factory=dict, description="List of LLM models") providers: dict[str, LLMProvider] = Field( default_factory=dict, description="List of LLM providers" ) loop_control: LoopControl = Field(default_factory=LoopControl, description="Agent loop control") background: BackgroundConfig = Field( default_factory=BackgroundConfig, description="Background task configuration" ) notifications: NotificationConfig = Field( default_factory=NotificationConfig, description="Notification configuration" ) services: Services = Field(default_factory=Services, description="Services configuration") mcp: MCPConfig = Field(default_factory=MCPConfig, description="MCP configuration") @model_validator(mode="after") def validate_model(self) -> Self: if self.default_model and self.default_model not in self.models: raise ValueError(f"Default model {self.default_model} not found in models") for model in self.models.values(): if model.provider not in self.providers: raise ValueError(f"Provider {model.provider} not found in providers") return self def get_config_file() -> Path: """Get the configuration file path.""" return get_share_dir() / "config.toml" def get_default_config() -> Config: """Get the default configuration.""" return Config( default_model="", models={}, providers={}, services=Services(), ) def load_config(config_file: Path | None = None) -> Config: """ Load configuration from config file. If the config file does not exist, create it with default configuration. Args: config_file (Path | None): Path to the configuration file. If None, use default path. Returns: Validated Config object. Raises: ConfigError: If the configuration file is invalid. """ default_config_file = get_config_file().expanduser().resolve(strict=False) if config_file is None: config_file = default_config_file config_file = config_file.expanduser().resolve(strict=False) is_default_config_file = config_file == default_config_file logger.debug("Loading config from file: {file}", file=config_file) # If the user hasn't provided an explicit config path, migrate legacy JSON config once. if is_default_config_file and not config_file.exists(): _migrate_json_config_to_toml() if not config_file.exists(): config = get_default_config() logger.debug("No config file found, creating default config: {config}", config=config) save_config(config, config_file) config.is_from_default_location = is_default_config_file config.source_file = config_file return config try: config_text = config_file.read_text(encoding="utf-8") if config_file.suffix.lower() == ".json": data = json.loads(config_text) else: data = tomlkit.loads(config_text) config = Config.model_validate(data) except json.JSONDecodeError as e: raise ConfigError(f"Invalid JSON in configuration file {config_file}: {e}") from e except TOMLKitError as e: raise ConfigError(f"Invalid TOML in configuration file {config_file}: {e}") from e except ValidationError as e: raise ConfigError(f"Invalid configuration file {config_file}: {e}") from e config.is_from_default_location = is_default_config_file config.source_file = config_file return config def load_config_from_string(config_string: str) -> Config: """ Load configuration from a TOML or JSON string. Args: config_string (str): TOML or JSON configuration text. Returns: Validated Config object. Raises: ConfigError: If the configuration text is invalid. """ if not config_string.strip(): raise ConfigError("Configuration text cannot be empty") json_error: json.JSONDecodeError | None = None try: data = json.loads(config_string) except json.JSONDecodeError as exc: json_error = exc data = None if data is None: try: data = tomlkit.loads(config_string) except TOMLKitError as toml_error: raise ConfigError( f"Invalid configuration text: {json_error}; {toml_error}" ) from toml_error try: config = Config.model_validate(data) except ValidationError as e: raise ConfigError(f"Invalid configuration text: {e}") from e config.is_from_default_location = False config.source_file = None return config def save_config(config: Config, config_file: Path | None = None): """ Save configuration to config file. Args: config (Config): Config object to save. config_file (Path | None): Path to the configuration file. If None, use default path. """ config_file = config_file or get_config_file() logger.debug("Saving config to file: {file}", file=config_file) config_file.parent.mkdir(parents=True, exist_ok=True) config_data = config.model_dump(mode="json", exclude_none=True) with open(config_file, "w", encoding="utf-8") as f: if config_file.suffix.lower() == ".json": f.write(json.dumps(config_data, ensure_ascii=False, indent=2)) else: f.write(tomlkit.dumps(config_data)) # type: ignore[reportUnknownMemberType] def _migrate_json_config_to_toml() -> None: old_json_config_file = get_share_dir() / "config.json" new_toml_config_file = get_share_dir() / "config.toml" if not old_json_config_file.exists(): return if new_toml_config_file.exists(): return logger.info( "Migrating legacy config file from {old} to {new}", old=old_json_config_file, new=new_toml_config_file, ) try: with open(old_json_config_file, encoding="utf-8") as f: data = json.load(f) config = Config.model_validate(data) except json.JSONDecodeError as e: raise ConfigError(f"Invalid JSON in legacy configuration file: {e}") from e except ValidationError as e: raise ConfigError(f"Invalid legacy configuration file: {e}") from e # Write new TOML config, then keep a backup of the original JSON file. save_config(config, new_toml_config_file) backup_path = old_json_config_file.with_name("config.json.bak") old_json_config_file.replace(backup_path) logger.info("Legacy config backed up to {file}", file=backup_path) ================================================ FILE: src/kimi_cli/constant.py ================================================ from __future__ import annotations from functools import cache from typing import TYPE_CHECKING NAME = "Kimi Code CLI" if TYPE_CHECKING: VERSION: str USER_AGENT: str @cache def get_version() -> str: from importlib import metadata return metadata.version("kimi-cli") @cache def get_user_agent() -> str: return f"KimiCLI/{get_version()}" def __getattr__(name: str) -> str: if name == "VERSION": return get_version() if name == "USER_AGENT": return get_user_agent() raise AttributeError(f"module {__name__!r} has no attribute {name!r}") __all__ = ["NAME", "VERSION", "USER_AGENT", "get_version", "get_user_agent"] ================================================ FILE: src/kimi_cli/deps/Makefile ================================================ THIS_DIR := $(patsubst %/,%,$(dir $(lastword $(MAKEFILE_LIST)))) BIN_DIR := $(THIS_DIR)/bin TMP_DIR := $(THIS_DIR)/tmp # Allow override via environment: RG_VERSION=15.0.0 make download-ripgrep RG_VERSION ?= 15.0.0 OS := $(shell uname -s) ARCH := $(shell uname -m | tr '[:upper:]' '[:lower:]') RG_ARCHIVE_EXT := tar.gz RG_ARCHIVE_BIN := rg RG_BIN_SUFFIX := # Map OS/ARCH to ripgrep TARGET name # See: https://github.com/BurntSushi/ripgrep/releases ifeq ($(OS),Darwin) ifeq ($(ARCH),arm64) RG_TARGET := aarch64-apple-darwin else ifeq ($(ARCH),x86_64) RG_TARGET := x86_64-apple-darwin else $(error Unsupported macOS architecture: $(ARCH)) endif else ifeq ($(OS),Linux) ifeq ($(ARCH),x86_64) RG_TARGET := x86_64-unknown-linux-musl else ifeq ($(ARCH),aarch64) RG_TARGET := aarch64-unknown-linux-gnu else ifeq ($(ARCH),armv7l) RG_TARGET := arm-unknown-linux-gnueabihf else $(error Unsupported Linux architecture: $(ARCH)) endif else ifneq (,$(filter MSYS% MINGW%,$(OS))) ifeq ($(ARCH),x86_64) RG_TARGET := x86_64-pc-windows-msvc else ifeq ($(ARCH),aarch64) RG_TARGET := aarch64-pc-windows-msvc else $(error Unsupported Windows architecture: $(ARCH)) endif RG_ARCHIVE_EXT := zip RG_ARCHIVE_BIN := rg.exe RG_BIN_SUFFIX := .exe else $(error Unsupported OS: $(OS)) endif RG_ARCHIVE := ripgrep-$(RG_VERSION)-$(RG_TARGET).$(RG_ARCHIVE_EXT) RG_URL := https://github.com/BurntSushi/ripgrep/releases/download/$(RG_VERSION)/$(RG_ARCHIVE) .PHONY: download-ripgrep download-ripgrep: @echo "==> Ensuring ripgrep is installed" @if [ -f "$(BIN_DIR)/rg$(RG_BIN_SUFFIX)" ]; then \ echo "rg already installed at $(BIN_DIR)/rg$(RG_BIN_SUFFIX)"; \ else \ echo "Downloading ripgrep $(RG_VERSION) from: $(RG_URL)"; \ mkdir -p "$(BIN_DIR)" "$(TMP_DIR)"; \ ARCHIVE_PATH="$(TMP_DIR)/$(RG_ARCHIVE)"; \ if command -v curl >/dev/null 2>&1; then \ curl -L --fail -o "$$ARCHIVE_PATH" "$(RG_URL)"; \ else \ if command -v wget >/dev/null 2>&1; then \ wget -O "$$ARCHIVE_PATH" "$(RG_URL)"; \ else \ echo "Error: neither curl nor wget is available" && exit 1; \ fi; \ fi; \ if [ "$(RG_ARCHIVE_EXT)" = "zip" ]; then \ ARCHIVE_PATH="$$ARCHIVE_PATH" TMP_DIR="$(TMP_DIR)" python -c "import os, zipfile; zipfile.ZipFile(os.environ['ARCHIVE_PATH']).extractall(os.environ['TMP_DIR'])"; \ else \ tar -xzf "$$ARCHIVE_PATH" -C "$(TMP_DIR)"; \ fi; \ SRC_PATH="$(TMP_DIR)/ripgrep-$(RG_VERSION)-$(RG_TARGET)/$(RG_ARCHIVE_BIN)"; \ DST_PATH="$(BIN_DIR)/rg$(RG_BIN_SUFFIX)"; \ cp "$$SRC_PATH" "$$DST_PATH"; \ chmod +x "$$DST_PATH"; \ echo "rg installed at $$DST_PATH"; \ fi .PHONY: download-deps download-deps: download-ripgrep ================================================ FILE: src/kimi_cli/exception.py ================================================ from __future__ import annotations class KimiCLIException(Exception): """Base exception class for Kimi Code CLI.""" pass class ConfigError(KimiCLIException, ValueError): """Configuration error.""" pass class AgentSpecError(KimiCLIException, ValueError): """Agent specification error.""" pass class InvalidToolError(KimiCLIException, ValueError): """Invalid tool error.""" pass class SystemPromptTemplateError(KimiCLIException, ValueError): """System prompt template error.""" pass class MCPConfigError(KimiCLIException, ValueError): """MCP config error.""" pass class MCPRuntimeError(KimiCLIException, RuntimeError): """MCP runtime error.""" pass ================================================ FILE: src/kimi_cli/llm.py ================================================ from __future__ import annotations import json import os from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Literal, cast, get_args from kosong.chat_provider import ChatProvider from pydantic import SecretStr from kimi_cli.constant import USER_AGENT if TYPE_CHECKING: from kimi_cli.auth.oauth import OAuthManager from kimi_cli.config import LLMModel, LLMProvider type ProviderType = Literal[ "kimi", "openai_legacy", "openai_responses", "anthropic", "google_genai", # for backward-compatibility, equals to `gemini` "gemini", "vertexai", "_echo", "_scripted_echo", "_chaos", ] type ModelCapability = Literal["image_in", "video_in", "thinking", "always_thinking"] ALL_MODEL_CAPABILITIES: set[ModelCapability] = set(get_args(ModelCapability.__value__)) @dataclass(slots=True) class LLM: chat_provider: ChatProvider max_context_size: int capabilities: set[ModelCapability] model_config: LLMModel | None = None provider_config: LLMProvider | None = None @property def model_name(self) -> str: return self.chat_provider.model_name def model_display_name(model_name: str | None) -> str: if not model_name: return "" if model_name in ("kimi-for-coding", "kimi-code"): return f"{model_name} (powered by kimi-k2.5)" return model_name def augment_provider_with_env_vars(provider: LLMProvider, model: LLMModel) -> dict[str, str]: """Override provider/model settings from environment variables. Returns: Mapping of environment variables that were applied. """ applied: dict[str, str] = {} match provider.type: case "kimi": if base_url := os.getenv("KIMI_BASE_URL"): provider.base_url = base_url applied["KIMI_BASE_URL"] = base_url if api_key := os.getenv("KIMI_API_KEY"): provider.api_key = SecretStr(api_key) applied["KIMI_API_KEY"] = "******" if model_name := os.getenv("KIMI_MODEL_NAME"): model.model = model_name applied["KIMI_MODEL_NAME"] = model_name if max_context_size := os.getenv("KIMI_MODEL_MAX_CONTEXT_SIZE"): model.max_context_size = int(max_context_size) applied["KIMI_MODEL_MAX_CONTEXT_SIZE"] = max_context_size if capabilities := os.getenv("KIMI_MODEL_CAPABILITIES"): caps_lower = (cap.strip().lower() for cap in capabilities.split(",") if cap.strip()) model.capabilities = set( cast(ModelCapability, cap) for cap in caps_lower if cap in get_args(ModelCapability.__value__) ) applied["KIMI_MODEL_CAPABILITIES"] = capabilities case "openai_legacy" | "openai_responses": if base_url := os.getenv("OPENAI_BASE_URL"): provider.base_url = base_url if api_key := os.getenv("OPENAI_API_KEY"): provider.api_key = SecretStr(api_key) case _: pass return applied def _kimi_default_headers(provider: LLMProvider, oauth: OAuthManager | None) -> dict[str, str]: headers = {"User-Agent": USER_AGENT} if oauth: headers.update(oauth.common_headers()) if provider.custom_headers: headers.update(provider.custom_headers) return headers def create_llm( provider: LLMProvider, model: LLMModel, *, thinking: bool | None = None, session_id: str | None = None, oauth: OAuthManager | None = None, ) -> LLM | None: if provider.type not in {"_echo", "_scripted_echo"} and ( not provider.base_url or not model.model ): return None resolved_api_key = ( oauth.resolve_api_key(provider.api_key, provider.oauth) if oauth and provider.oauth else provider.api_key.get_secret_value() ) match provider.type: case "kimi": from kosong.chat_provider.kimi import Kimi chat_provider = Kimi( model=model.model, base_url=provider.base_url, api_key=resolved_api_key, default_headers=_kimi_default_headers(provider, oauth), ) gen_kwargs: Kimi.GenerationKwargs = {} if session_id: gen_kwargs["prompt_cache_key"] = session_id if temperature := os.getenv("KIMI_MODEL_TEMPERATURE"): gen_kwargs["temperature"] = float(temperature) if top_p := os.getenv("KIMI_MODEL_TOP_P"): gen_kwargs["top_p"] = float(top_p) if max_tokens := os.getenv("KIMI_MODEL_MAX_TOKENS"): gen_kwargs["max_tokens"] = int(max_tokens) if gen_kwargs: chat_provider = chat_provider.with_generation_kwargs(**gen_kwargs) case "openai_legacy": from kosong.contrib.chat_provider.openai_legacy import OpenAILegacy chat_provider = OpenAILegacy( model=model.model, base_url=provider.base_url, api_key=resolved_api_key, ) case "openai_responses": from kosong.contrib.chat_provider.openai_responses import OpenAIResponses chat_provider = OpenAIResponses( model=model.model, base_url=provider.base_url, api_key=resolved_api_key, ) case "anthropic": from kosong.contrib.chat_provider.anthropic import Anthropic chat_provider = Anthropic( model=model.model, base_url=provider.base_url, api_key=resolved_api_key, default_max_tokens=50000, metadata={"user_id": session_id} if session_id else None, ) case "google_genai" | "gemini": from kosong.contrib.chat_provider.google_genai import GoogleGenAI chat_provider = GoogleGenAI( model=model.model, base_url=provider.base_url, api_key=resolved_api_key, ) case "vertexai": from kosong.contrib.chat_provider.google_genai import GoogleGenAI os.environ.update(provider.env or {}) chat_provider = GoogleGenAI( model=model.model, base_url=provider.base_url, api_key=resolved_api_key, vertexai=True, ) case "_echo": from kosong.chat_provider.echo import EchoChatProvider chat_provider = EchoChatProvider() case "_scripted_echo": from kosong.chat_provider.echo import ScriptedEchoChatProvider if provider.env: os.environ.update(provider.env) scripts = _load_scripted_echo_scripts() trace_value = os.getenv("KIMI_SCRIPTED_ECHO_TRACE", "") trace = trace_value.strip().lower() in {"1", "true", "yes", "on"} chat_provider = ScriptedEchoChatProvider(scripts, trace=trace) case "_chaos": from kosong.chat_provider.chaos import ChaosChatProvider, ChaosConfig from kosong.chat_provider.kimi import Kimi chat_provider = ChaosChatProvider( provider=Kimi( model=model.model, base_url=provider.base_url, api_key=resolved_api_key, default_headers=_kimi_default_headers(provider, oauth), ), chaos_config=ChaosConfig( error_probability=0.8, error_types=[429, 500, 503], ), ) capabilities = derive_model_capabilities(model) # Apply thinking if specified or if model always requires thinking if "always_thinking" in capabilities or (thinking is True and "thinking" in capabilities): chat_provider = chat_provider.with_thinking("high") elif thinking is False: chat_provider = chat_provider.with_thinking("off") # If thinking is None and model doesn't always think, leave as-is (default behavior) return LLM( chat_provider=chat_provider, max_context_size=model.max_context_size, capabilities=capabilities, model_config=model, provider_config=provider, ) def derive_model_capabilities(model: LLMModel) -> set[ModelCapability]: capabilities = set(model.capabilities or ()) # Models with "thinking" in their name are always-thinking models if "thinking" in model.model.lower() or "reason" in model.model.lower(): capabilities.update(("thinking", "always_thinking")) # These models support thinking but can be toggled on/off elif model.model in {"kimi-for-coding", "kimi-code"}: capabilities.update(("thinking", "image_in", "video_in")) return capabilities def _load_scripted_echo_scripts() -> list[str]: script_path = os.getenv("KIMI_SCRIPTED_ECHO_SCRIPTS") if not script_path: raise ValueError("KIMI_SCRIPTED_ECHO_SCRIPTS is required for _scripted_echo.") path = Path(script_path).expanduser() if not path.exists(): raise ValueError(f"Scripted echo file not found: {path}") text = path.read_text(encoding="utf-8") try: data: object = json.loads(text) except json.JSONDecodeError: scripts = [chunk.strip() for chunk in text.split("\n---\n") if chunk.strip()] if scripts: return scripts raise ValueError( "Scripted echo file must be a JSON array of strings or a text file " "split by '\\n---\\n'." ) from None if isinstance(data, list): data_list = cast(list[object], data) if all(isinstance(item, str) for item in data_list): return cast(list[str], data_list) raise ValueError("Scripted echo JSON must be an array of strings.") ================================================ FILE: src/kimi_cli/metadata.py ================================================ from __future__ import annotations import json from hashlib import md5 from pathlib import Path from kaos import get_current_kaos from kaos.local import local_kaos from kaos.path import KaosPath from pydantic import BaseModel, ConfigDict, Field from kimi_cli.share import get_share_dir from kimi_cli.utils.io import atomic_json_write from kimi_cli.utils.logging import logger def get_metadata_file() -> Path: return get_share_dir() / "kimi.json" class WorkDirMeta(BaseModel): """Metadata for a work directory.""" path: str """The full path of the work directory.""" kaos: str = local_kaos.name """The name of the KAOS where the work directory is located.""" last_session_id: str | None = None """Last session ID of this work directory.""" @property def sessions_dir(self) -> Path: """The directory to store sessions for this work directory.""" path_md5 = md5(self.path.encode(encoding="utf-8")).hexdigest() dir_basename = path_md5 if self.kaos == local_kaos.name else f"{self.kaos}_{path_md5}" session_dir = get_share_dir() / "sessions" / dir_basename session_dir.mkdir(parents=True, exist_ok=True) return session_dir class Metadata(BaseModel): """Kimi metadata structure.""" model_config = ConfigDict(extra="ignore") work_dirs: list[WorkDirMeta] = Field(default_factory=list[WorkDirMeta]) """Work directory list.""" def get_work_dir_meta(self, path: KaosPath) -> WorkDirMeta | None: """Get the metadata for a work directory.""" for wd in self.work_dirs: if wd.path == str(path) and wd.kaos == get_current_kaos().name: return wd return None def new_work_dir_meta(self, path: KaosPath) -> WorkDirMeta: """Create a new work directory metadata.""" wd_meta = WorkDirMeta(path=str(path), kaos=get_current_kaos().name) self.work_dirs.append(wd_meta) return wd_meta def load_metadata() -> Metadata: metadata_file = get_metadata_file() logger.debug("Loading metadata from file: {file}", file=metadata_file) if not metadata_file.exists(): logger.debug("No metadata file found, creating empty metadata") return Metadata() with open(metadata_file, encoding="utf-8") as f: data = json.load(f) return Metadata(**data) def save_metadata(metadata: Metadata): metadata_file = get_metadata_file() logger.debug("Saving metadata to file: {file}", file=metadata_file) atomic_json_write(metadata.model_dump(), metadata_file) ================================================ FILE: src/kimi_cli/notifications/__init__.py ================================================ from .llm import build_notification_message, extract_notification_ids, is_notification_message from .manager import NotificationManager from .models import ( NotificationCategory, NotificationDelivery, NotificationDeliveryStatus, NotificationEvent, NotificationSeverity, NotificationSink, NotificationSinkState, NotificationView, ) from .notifier import NotificationWatcher from .store import NotificationStore from .wire import to_wire_notification __all__ = [ "NotificationCategory", "NotificationDelivery", "NotificationDeliveryStatus", "NotificationEvent", "NotificationManager", "NotificationSeverity", "NotificationSink", "NotificationSinkState", "NotificationStore", "NotificationView", "NotificationWatcher", "build_notification_message", "extract_notification_ids", "is_notification_message", "to_wire_notification", ] ================================================ FILE: src/kimi_cli/notifications/llm.py ================================================ from __future__ import annotations import re from collections.abc import Sequence from typing import TYPE_CHECKING from kosong.message import Message from kimi_cli.wire.types import TextPart from .models import NotificationView if TYPE_CHECKING: from kimi_cli.soul.agent import Runtime _NOTIFICATION_ID_RE = re.compile(r' Message: event = view.event lines = [ ( f'' ), f"Title: {event.title}", f"Severity: {event.severity}", event.body, ] if event.category == "task" and event.source_kind == "background_task": task_view = runtime.background_tasks.get_task(event.source_id) if task_view is not None: tail = runtime.background_tasks.store.tail_output( task_view.spec.id, max_bytes=runtime.config.background.notification_tail_chars, max_lines=runtime.config.background.notification_tail_lines, ) lines.extend( [ "", f"Task ID: {task_view.spec.id}", f"Task Type: {task_view.spec.kind}", f"Description: {task_view.spec.description}", f"Status: {task_view.runtime.status}", ] ) if task_view.runtime.exit_code is not None: lines.append(f"Exit code: {task_view.runtime.exit_code}") if task_view.runtime.failure_reason: lines.append(f"Failure reason: {task_view.runtime.failure_reason}") if tail: lines.extend(["Output tail:", tail]) lines.append("") lines.append("") return Message(role="user", content=[TextPart(text="\n".join(lines))]) def extract_notification_ids(history: Sequence[Message]) -> set[str]: ids: set[str] = set() for message in history: if message.role != "user": continue for part in message.content: if not isinstance(part, TextPart): continue for match in _NOTIFICATION_ID_RE.finditer(part.text): ids.add(match.group(1)) return ids def is_notification_message(message: Message) -> bool: if message.role != "user" or len(message.content) != 1: return False part = message.content[0] return isinstance(part, TextPart) and part.text.lstrip().startswith(" None: self._config = config self._store = NotificationStore(root) @property def store(self) -> NotificationStore: return self._store def new_id(self) -> str: return f"n{uuid.uuid4().hex[:8]}" def _initial_delivery(self, event: NotificationEvent) -> NotificationDelivery: return NotificationDelivery(sinks={sink: NotificationSinkState() for sink in event.targets}) def find_by_dedupe_key(self, dedupe_key: str) -> NotificationView | None: for view in self._store.list_views(): if view.event.dedupe_key == dedupe_key: return view return None def publish(self, event: NotificationEvent) -> NotificationView: if event.dedupe_key: existing = self.find_by_dedupe_key(event.dedupe_key) if existing is not None: return existing delivery = self._initial_delivery(event) self._store.create_notification(event, delivery) return NotificationView(event=event, delivery=delivery) def recover(self) -> None: now = time.time() stale_after = self._config.claim_stale_after_ms / 1000 for view in self._store.list_views(): updated = False delivery = view.delivery.model_copy(deep=True) for sink_state in delivery.sinks.values(): if sink_state.status != "claimed" or sink_state.claimed_at is None: continue if now - sink_state.claimed_at <= stale_after: continue sink_state.status = "pending" sink_state.claimed_at = None updated = True if updated: self._store.write_delivery(view.event.id, delivery) def claim_for_sink(self, sink: str, *, limit: int = 8) -> list[NotificationView]: self.recover() claimed: list[NotificationView] = [] now = time.time() for view in reversed(self._store.list_views()): sink_state = view.delivery.sinks.get(sink) if sink_state is None or sink_state.status == "acked": continue if sink_state.status == "claimed": continue delivery = view.delivery.model_copy(deep=True) target_state = delivery.sinks[sink] target_state.status = "claimed" target_state.claimed_at = now self._store.write_delivery(view.event.id, delivery) claimed.append(NotificationView(event=view.event, delivery=delivery)) if len(claimed) >= limit: break return claimed async def deliver_pending( self, sink: str, *, on_notification: Callable[[NotificationView], Awaitable[None] | None], limit: int = 8, before_claim: Callable[[], object] | None = None, ) -> list[NotificationView]: """Deliver pending notifications for one sink using a shared claim/ack flow. If the handler raises for a notification, the error is logged and that notification stays in ``claimed`` state (will be recovered later). Delivery continues for remaining notifications. """ if before_claim is not None: before_claim() delivered: list[NotificationView] = [] for view in self.claim_for_sink(sink, limit=limit): try: result = on_notification(view) if result is not None: await result except Exception: logger.exception( "Notification handler failed for {sink}/{id}, leaving claimed for recovery", sink=sink, id=view.event.id, ) continue delivered.append(self.ack(sink, view.event.id)) return delivered def ack(self, sink: str, notification_id: str) -> NotificationView: view = self._store.merged_view(notification_id) delivery = view.delivery.model_copy(deep=True) sink_state = delivery.sinks.get(sink) if sink_state is None: return view sink_state.status = "acked" sink_state.acked_at = time.time() sink_state.claimed_at = None self._store.write_delivery(notification_id, delivery) return NotificationView(event=view.event, delivery=delivery) def ack_ids(self, sink: str, notification_ids: set[str]) -> None: for notification_id in notification_ids: try: self.ack(sink, notification_id) except (FileNotFoundError, ValueError): continue ================================================ FILE: src/kimi_cli/notifications/models.py ================================================ from __future__ import annotations import time from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field type NotificationCategory = Literal["task", "agent", "system"] type NotificationSeverity = Literal["info", "success", "warning", "error"] type NotificationSink = Literal["llm", "wire", "shell"] type NotificationDeliveryStatus = Literal["pending", "claimed", "acked"] class NotificationEvent(BaseModel): model_config = ConfigDict(extra="ignore") version: int = 1 id: str category: NotificationCategory type: str source_kind: str source_id: str title: str body: str severity: NotificationSeverity = "info" created_at: float = Field(default_factory=time.time) payload: dict[str, Any] = Field(default_factory=dict) targets: list[NotificationSink] = Field(default_factory=lambda: ["llm", "wire", "shell"]) dedupe_key: str | None = None class NotificationSinkState(BaseModel): model_config = ConfigDict(extra="ignore") status: NotificationDeliveryStatus = "pending" claimed_at: float | None = None acked_at: float | None = None class NotificationDelivery(BaseModel): model_config = ConfigDict(extra="ignore") sinks: dict[str, NotificationSinkState] = Field(default_factory=dict) class NotificationView(BaseModel): model_config = ConfigDict(extra="ignore") event: NotificationEvent delivery: NotificationDelivery ================================================ FILE: src/kimi_cli/notifications/notifier.py ================================================ import asyncio from collections.abc import Awaitable, Callable from kimi_cli.utils.logging import logger from .manager import NotificationManager from .models import NotificationSink, NotificationView class NotificationWatcher: def __init__( self, manager: NotificationManager, *, sink: NotificationSink, on_notification: Callable[[NotificationView], Awaitable[None] | None], before_poll: Callable[[], object] | None = None, interval_s: float = 1.0, ) -> None: self._manager = manager self._sink = sink self._on_notification = on_notification self._before_poll = before_poll self._interval_s = interval_s async def poll_once(self) -> list[NotificationView]: return await self._manager.deliver_pending( self._sink, on_notification=self._on_notification, before_claim=self._before_poll, ) async def run_forever(self) -> None: while True: try: await self.poll_once() except asyncio.CancelledError: raise except Exception: logger.exception("NotificationWatcher poll failed") await asyncio.sleep(self._interval_s) ================================================ FILE: src/kimi_cli/notifications/store.py ================================================ from __future__ import annotations import re from pathlib import Path from kimi_cli.utils.io import atomic_json_write from .models import NotificationDelivery, NotificationEvent, NotificationView _VALID_NOTIFICATION_ID = re.compile(r"^[a-z0-9]{2,20}$") def _validate_notification_id(notification_id: str) -> None: if not _VALID_NOTIFICATION_ID.match(notification_id): raise ValueError(f"Invalid notification_id: {notification_id!r}") class NotificationStore: EVENT_FILE = "event.json" DELIVERY_FILE = "delivery.json" def __init__(self, root: Path): self._root = root @property def root(self) -> Path: return self._root def _ensure_root(self) -> Path: """Return the root directory, creating it if it does not exist.""" self._root.mkdir(parents=True, exist_ok=True) return self._root def notification_dir(self, notification_id: str) -> Path: _validate_notification_id(notification_id) path = self._ensure_root() / notification_id path.mkdir(parents=True, exist_ok=True) return path def notification_path(self, notification_id: str) -> Path: _validate_notification_id(notification_id) return self.root / notification_id def event_path(self, notification_id: str) -> Path: return self.notification_path(notification_id) / self.EVENT_FILE def delivery_path(self, notification_id: str) -> Path: return self.notification_path(notification_id) / self.DELIVERY_FILE def create_notification( self, event: NotificationEvent, delivery: NotificationDelivery, ) -> None: notification_dir = self.notification_dir(event.id) atomic_json_write(event.model_dump(mode="json"), notification_dir / self.EVENT_FILE) atomic_json_write(delivery.model_dump(mode="json"), notification_dir / self.DELIVERY_FILE) def list_notification_ids(self) -> list[str]: if not self.root.exists(): return [] notification_ids: list[str] = [] for path in sorted(self.root.iterdir()): if not path.is_dir(): continue if not (path / self.EVENT_FILE).exists(): continue notification_ids.append(path.name) return notification_ids def read_event(self, notification_id: str) -> NotificationEvent: return NotificationEvent.model_validate_json( self.event_path(notification_id).read_text(encoding="utf-8") ) def write_event(self, event: NotificationEvent) -> None: atomic_json_write(event.model_dump(mode="json"), self.event_path(event.id)) def read_delivery(self, notification_id: str) -> NotificationDelivery: path = self.delivery_path(notification_id) if not path.exists(): return NotificationDelivery() return NotificationDelivery.model_validate_json(path.read_text(encoding="utf-8")) def write_delivery(self, notification_id: str, delivery: NotificationDelivery) -> None: atomic_json_write(delivery.model_dump(mode="json"), self.delivery_path(notification_id)) def merged_view(self, notification_id: str) -> NotificationView: return NotificationView( event=self.read_event(notification_id), delivery=self.read_delivery(notification_id), ) def list_views(self) -> list[NotificationView]: views = [ self.merged_view(notification_id) for notification_id in self.list_notification_ids() ] views.sort(key=lambda view: view.event.created_at, reverse=True) return views ================================================ FILE: src/kimi_cli/notifications/wire.py ================================================ from __future__ import annotations from kimi_cli.wire.types import Notification from .models import NotificationView def to_wire_notification(view: NotificationView) -> Notification: event = view.event return Notification( id=event.id, category=event.category, type=event.type, source_kind=event.source_kind, source_id=event.source_id, title=event.title, body=event.body, severity=event.severity, created_at=event.created_at, payload=event.payload, ) ================================================ FILE: src/kimi_cli/plugin/__init__.py ================================================ """Plugin specification parsing and config injection.""" import json from pathlib import Path from typing import Any from pydantic import BaseModel, ConfigDict, Field class PluginError(Exception): """Raised when plugin.json is invalid or an operation fails.""" class PluginRuntime(BaseModel): """Runtime information written by the host after installation.""" host: str host_version: str class PluginToolSpec(BaseModel): """A tool declared by a plugin.""" name: str description: str command: list[str] parameters: dict[str, object] = Field(default_factory=dict) class PluginSpec(BaseModel): """Parsed representation of a plugin.json file.""" model_config = ConfigDict(extra="ignore") name: str version: str description: str = "" config_file: str | None = None inject: dict[str, str] = Field(default_factory=dict) tools: list[PluginToolSpec] = Field(default_factory=list) # pyright: ignore[reportUnknownVariableType] runtime: PluginRuntime | None = None PLUGIN_JSON = "plugin.json" def parse_plugin_json(path: Path) -> PluginSpec: """Parse a plugin.json file and return a validated PluginSpec.""" try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise PluginError(f"Failed to read {path}: {exc}") from exc if "name" not in data: raise PluginError(f"Missing required field 'name' in {path}") if "version" not in data: raise PluginError(f"Missing required field 'version' in {path}") if data.get("inject") and not data.get("config_file"): raise PluginError(f"'inject' requires 'config_file' in {path}") try: return PluginSpec.model_validate(data) except Exception as exc: raise PluginError(f"Invalid plugin.json schema in {path}: {exc}") from exc def inject_config(plugin_dir: Path, spec: PluginSpec, values: dict[str, str]) -> None: """Inject host values into the plugin's config file. Args: plugin_dir: Root directory of the installed plugin. spec: Parsed plugin spec. values: Map of standard inject keys to actual values (e.g. {"api_key": "sk-xxx"}). """ if not spec.inject or not spec.config_file: return config_path = (plugin_dir / spec.config_file).resolve() if not config_path.is_relative_to(plugin_dir.resolve()): raise PluginError(f"config_file escapes plugin directory: {spec.config_file}") if not config_path.exists(): raise PluginError(f"Config file not found: {config_path}") try: config = json.loads(config_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise PluginError(f"Failed to read config file {config_path}: {exc}") from exc for target_path, source_key in spec.inject.items(): if source_key not in values: raise PluginError(f"Host does not provide required inject key '{source_key}'") _set_nested(config, target_path, values[source_key]) config_path.write_text( json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8", ) def write_runtime(plugin_dir: Path, runtime: PluginRuntime) -> None: """Write runtime info into plugin.json.""" plugin_json_path = plugin_dir / PLUGIN_JSON try: data = json.loads(plugin_json_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise PluginError(f"Failed to read {plugin_json_path}: {exc}") from exc data["runtime"] = runtime.model_dump() plugin_json_path.write_text( json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8", ) def _set_nested(obj: dict[str, Any], dotted_path: str, value: object) -> None: """Set a value in a nested dict using dot-separated path. Creates intermediate dicts if they don't exist. """ keys = dotted_path.split(".") for key in keys[:-1]: if key not in obj or not isinstance(obj[key], dict): obj[key] = {} obj = obj[key] obj[keys[-1]] = value ================================================ FILE: src/kimi_cli/plugin/manager.py ================================================ """Plugin installation, removal, and listing.""" from __future__ import annotations import shutil import tempfile from pathlib import Path from typing import TYPE_CHECKING from kimi_cli.plugin import ( PLUGIN_JSON, PluginError, PluginRuntime, PluginSpec, inject_config, parse_plugin_json, write_runtime, ) from kimi_cli.share import get_share_dir if TYPE_CHECKING: from kimi_cli.auth.oauth import OAuthManager from kimi_cli.config import Config def get_plugins_dir() -> Path: """Return the plugins installation directory (~/.kimi/plugins/).""" return get_share_dir() / "plugins" def collect_host_values(config: Config, oauth: OAuthManager) -> dict[str, str]: """Collect host values (api_key, base_url) for plugin injection. Resolves credentials from the default provider, handling OAuth tokens and static API keys. Callers that run outside the normal startup flow (e.g. ``install_cmd``) should apply environment-variable overrides (``augment_provider_with_env_vars``) to the provider **before** calling this function; the main app startup already does that. """ values: dict[str, str] = {} if not config.default_model or config.default_model not in config.models: return values model = config.models[config.default_model] if model.provider not in config.providers: return values provider = config.providers[model.provider] api_key = oauth.resolve_api_key(provider.api_key, provider.oauth) if api_key: values["api_key"] = api_key values["base_url"] = provider.base_url return values def _validate_name(name: str, plugins_dir: Path) -> Path: """Resolve and validate plugin name, returning the safe destination path.""" dest = (plugins_dir / name).resolve() if not dest.is_relative_to(plugins_dir.resolve()): raise PluginError(f"Invalid plugin name: {name}") return dest def install_plugin( *, source: Path, plugins_dir: Path, host_values: dict[str, str], host_name: str, host_version: str, ) -> PluginSpec: """Install a plugin from a source directory. Stages the new copy to a temp dir first, so a failed upgrade does not destroy the previous installation. """ source_plugin_json = source / PLUGIN_JSON if not source_plugin_json.exists(): raise PluginError(f"No plugin.json found in {source}") spec = parse_plugin_json(source_plugin_json) dest = _validate_name(spec.name, plugins_dir) # Stage to a temp dir inside plugins_dir so rename is atomic on same fs plugins_dir.mkdir(parents=True, exist_ok=True) staging = Path(tempfile.mkdtemp(prefix=f".{spec.name}-", dir=plugins_dir)) try: # Copy source into staging staging_plugin = staging / spec.name shutil.copytree(source, staging_plugin) # Apply inject + runtime on the staged copy inject_config(staging_plugin, spec, host_values) runtime = PluginRuntime(host=host_name, host_version=host_version) write_runtime(staging_plugin, runtime) # Swap: remove old, move staged into place if dest.exists(): shutil.rmtree(dest) staging_plugin.rename(dest) except Exception: # On any failure, clean up staging but leave existing install intact shutil.rmtree(staging, ignore_errors=True) raise finally: # Clean up staging dir shell (may be empty after successful rename) shutil.rmtree(staging, ignore_errors=True) # Re-read to return the installed spec (with runtime) return parse_plugin_json(dest / PLUGIN_JSON) def refresh_plugin_configs(plugins_dir: Path, host_values: dict[str, str]) -> None: """Re-inject host values into all installed plugin config files. Called at startup so that OAuth tokens and other credentials stay fresh even after the initial install. """ if not plugins_dir.is_dir(): return for child in sorted(plugins_dir.iterdir()): plugin_json = child / PLUGIN_JSON if not child.is_dir() or not plugin_json.is_file(): continue try: spec = parse_plugin_json(plugin_json) if spec.inject and spec.config_file: inject_config(child, spec, host_values) except Exception: continue def list_plugins(plugins_dir: Path) -> list[PluginSpec]: """List all installed plugins.""" if not plugins_dir.is_dir(): return [] plugins: list[PluginSpec] = [] for child in sorted(plugins_dir.iterdir()): plugin_json = child / PLUGIN_JSON if child.is_dir() and plugin_json.is_file(): try: plugins.append(parse_plugin_json(plugin_json)) except PluginError: continue return plugins def remove_plugin(name: str, plugins_dir: Path) -> None: """Remove an installed plugin.""" dest = _validate_name(name, plugins_dir) if not dest.exists(): raise PluginError(f"Plugin '{name}' not found in {plugins_dir}") shutil.rmtree(dest) ================================================ FILE: src/kimi_cli/plugin/tool.py ================================================ """Plugin tool wrapper — runs plugin-declared tools as subprocesses.""" from __future__ import annotations import asyncio import json from pathlib import Path from typing import TYPE_CHECKING, Any from kosong.tooling import CallableTool, ToolError, ToolOk from kosong.tooling.error import ToolRuntimeError from loguru import logger from kimi_cli.plugin import PluginToolSpec from kimi_cli.tools.utils import ToolRejectedError from kimi_cli.utils.subprocess_env import get_clean_env from kimi_cli.wire.types import ToolReturnValue if TYPE_CHECKING: from kimi_cli.config import Config from kimi_cli.soul.approval import Approval def _get_host_values(config: Config) -> dict[str, str]: """Extract current host values (api_key, base_url) from config. Reads the latest provider credentials, which may have been refreshed by OAuth since plugin install time. """ from kimi_cli.auth.oauth import OAuthManager from kimi_cli.plugin.manager import collect_host_values oauth = OAuthManager(config) return collect_host_values(config, oauth) class PluginTool(CallableTool): """A tool that executes a plugin command in a subprocess. Parameters are passed via stdin as JSON. stdout is captured as the tool result. Host credentials are injected as environment variables at runtime (not baked into config files) to handle OAuth token refresh. """ def __init__( self, tool_spec: PluginToolSpec, plugin_dir: Path, *, inject: dict[str, str], config: Config, approval: Approval | None = None, **kwargs: Any, ): super().__init__( name=tool_spec.name, description=tool_spec.description, parameters=tool_spec.parameters or {"type": "object", "properties": {}}, **kwargs, ) self._command = tool_spec.command self._plugin_dir = plugin_dir self._inject = inject # e.g. {"kimiCodeAPIKey": "api_key"} self._config = config self._approval = approval def _build_env(self) -> dict[str, str]: """Build env vars with fresh host credentials for the subprocess.""" env = get_clean_env() if self._inject: host_values = _get_host_values(self._config) for target_key, source_key in self._inject.items(): if source_key in host_values: # Inject as env var using the plugin's config key name # e.g. kimiCodeAPIKey= env[target_key] = host_values[source_key] return env async def __call__(self, *args: Any, **kwargs: Any) -> ToolReturnValue: if self._approval is not None: description = f"Run plugin tool `{self.name}`." if not await self._approval.request(self.name, f"plugin:{self.name}", description): return ToolRejectedError() params_json = json.dumps(kwargs, ensure_ascii=False) try: proc = await asyncio.create_subprocess_exec( *self._command, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=str(self._plugin_dir), env=self._build_env(), ) except Exception as exc: return ToolRuntimeError(str(exc)) try: stdout, stderr = await asyncio.wait_for( proc.communicate(input=params_json.encode("utf-8")), timeout=120, ) except asyncio.CancelledError: proc.kill() await proc.wait() raise except TimeoutError: proc.kill() await proc.wait() return ToolError( message=f"Plugin tool '{self.name}' timed out after 120s.", brief="Timeout", ) output = stdout.decode("utf-8", errors="replace").strip() err_output = stderr.decode("utf-8", errors="replace").strip() if proc.returncode != 0: error_msg = err_output or output or f"Exit code {proc.returncode}" return ToolError( message=f"Plugin tool '{self.name}' failed: {error_msg}", brief=f"Exit {proc.returncode}", ) if err_output: logger.debug("Plugin tool {name} stderr: {err}", name=self.name, err=err_output) return ToolOk(output=output) def load_plugin_tools( plugins_dir: Path, config: Config, *, approval: Approval | None = None ) -> list[PluginTool]: """Scan installed plugins and create PluginTool instances for declared tools.""" from kimi_cli.plugin import PLUGIN_JSON, PluginError, parse_plugin_json if not plugins_dir.is_dir(): return [] tools: list[PluginTool] = [] for child in sorted(plugins_dir.iterdir()): plugin_json = child / PLUGIN_JSON if not child.is_dir() or not plugin_json.is_file(): continue try: spec = parse_plugin_json(plugin_json) except PluginError: continue for tool_spec in spec.tools: try: tool = PluginTool( tool_spec, plugin_dir=child, inject=spec.inject, config=config, approval=approval, ) except Exception: logger.warning( "Skipping invalid plugin tool: {name} (from {plugin})", name=tool_spec.name, plugin=spec.name, ) continue tools.append(tool) logger.info( "Loaded plugin tool: {name} (from {plugin})", name=tool_spec.name, plugin=spec.name, ) return tools ================================================ FILE: src/kimi_cli/prompts/__init__.py ================================================ from __future__ import annotations from pathlib import Path INIT = (Path(__file__).parent / "init.md").read_text(encoding="utf-8") COMPACT = (Path(__file__).parent / "compact.md").read_text(encoding="utf-8") ================================================ FILE: src/kimi_cli/prompts/compact.md ================================================ --- The above is a list of messages in an agent conversation. You are now given a task to compact this conversation context according to specific priorities and rules. **Compression Priorities (in order):** 1. **Current Task State**: What is being worked on RIGHT NOW 2. **Errors & Solutions**: All encountered errors and their resolutions 3. **Code Evolution**: Final working versions only (remove intermediate attempts) 4. **System Context**: Project structure, dependencies, environment setup 5. **Design Decisions**: Architectural choices and their rationale 6. **TODO Items**: Unfinished tasks and known issues **Compression Rules:** - MUST KEEP: Error messages, stack traces, working solutions, current task - MERGE: Similar discussions into single summary points - REMOVE: Redundant explanations, failed attempts (keep lessons learned), verbose comments - CONDENSE: Long code blocks → keep signatures + key logic only **Special Handling:** - For code: Keep full version if < 20 lines, otherwise keep signature + key logic - For errors: Keep full error message + final solution - For discussions: Extract decisions and action items only **Required Output Structure:** [What we're working on now] - [Key setup/config points] - ...more... - [Task]: [Brief outcome] - ...more... - [Issue]: [Status/Next steps] - ...more... [filename] **Summary:** [What this code file does] **Key elements:** - [Important functions/classes] - ...more... **Latest version:** [Critical code snippets in this file] [filename] ...Similar as above... ...more files... - [Any crucial information not covered above] - ...more... ================================================ FILE: src/kimi_cli/prompts/init.md ================================================ You are a software engineering expert with many years of programming experience. Please explore the current project directory to understand the project's architecture and main details. Task requirements: 1. Analyze the project structure and identify key configuration files (such as pyproject.toml, package.json, Cargo.toml, etc.). 2. Understand the project's technology stack, build process and runtime architecture. 3. Identify how the code is organized and main module divisions. 4. Discover project-specific development conventions, testing strategies, and deployment processes. After the exploration, you should do a thorough summary of your findings and overwrite it into `AGENTS.md` file in the project root. You need to refer to what is already in the file when you do so. For your information, `AGENTS.md` is a file intended to be read by AI coding agents. Expect the reader of this file know nothing about the project. You should compose this file according to the actual project content. Do not make any assumptions or generalizations. Ensure the information is accurate and useful. You must use the natural language that is mainly used in the project's comments and documentation. Popular sections that people usually write in `AGENTS.md` are: - Project overview - Build and test commands - Code style guidelines - Testing instructions - Security considerations ================================================ FILE: src/kimi_cli/py.typed ================================================ ================================================ FILE: src/kimi_cli/session.py ================================================ from __future__ import annotations import asyncio import builtins import json import shutil import uuid from dataclasses import dataclass from pathlib import Path from textwrap import shorten from kaos.path import KaosPath from kosong.message import Message from kimi_cli.metadata import WorkDirMeta, load_metadata, save_metadata from kimi_cli.session_state import SessionState, load_session_state, save_session_state from kimi_cli.utils.logging import logger from kimi_cli.wire.file import WireFile from kimi_cli.wire.types import TurnBegin @dataclass(slots=True, kw_only=True) class Session: """A session of a work directory.""" # static metadata id: str """The session ID.""" work_dir: KaosPath """The absolute path of the work directory.""" work_dir_meta: WorkDirMeta """The metadata of the work directory.""" context_file: Path """The absolute path to the file storing the message history.""" wire_file: WireFile """The wire message log file wrapper.""" # session state state: SessionState """Persisted session state (approval, dynamic subagents, etc.).""" # refreshable metadata title: str """The title of the session.""" updated_at: float """The timestamp of the last update to the session.""" @property def dir(self) -> Path: """The absolute path of the session directory.""" path = self.work_dir_meta.sessions_dir / self.id path.mkdir(parents=True, exist_ok=True) return path def is_empty(self) -> bool: """Whether the session has any context history.""" if not self.wire_file.is_empty(): return False try: with self.context_file.open(encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue role = json.loads(line).get("role") if isinstance(role, str) and not role.startswith("_"): return False except FileNotFoundError: return True except (OSError, ValueError, TypeError): logger.exception("Failed to read context file {file}:", file=self.context_file) return False return True def save_state(self) -> None: """Persist the session state to disk.""" save_session_state(self.state, self.dir) async def delete(self) -> None: """Delete the session directory.""" session_dir = self.work_dir_meta.sessions_dir / self.id if not session_dir.exists(): return await asyncio.to_thread(shutil.rmtree, session_dir, True) async def refresh(self) -> None: self.title = f"Untitled ({self.id})" self.updated_at = self.context_file.stat().st_mtime if self.context_file.exists() else 0.0 try: async for record in self.wire_file.iter_records(): wire_msg = record.to_wire_message() if isinstance(wire_msg, TurnBegin): title = shorten( Message(role="user", content=wire_msg.user_input).extract_text(" "), width=50, ) self.title = f"{title} ({self.id})" return except Exception: logger.exception( "Failed to derive session title from wire file {file}:", file=self.wire_file.path, ) @staticmethod async def create( work_dir: KaosPath, session_id: str | None = None, _context_file: Path | None = None, ) -> Session: """Create a new session for a work directory.""" work_dir = work_dir.canonical() logger.debug("Creating new session for work directory: {work_dir}", work_dir=work_dir) metadata = load_metadata() work_dir_meta = metadata.get_work_dir_meta(work_dir) if work_dir_meta is None: work_dir_meta = metadata.new_work_dir_meta(work_dir) if session_id is None: session_id = str(uuid.uuid4()) session_dir = work_dir_meta.sessions_dir / session_id session_dir.mkdir(parents=True, exist_ok=True) if _context_file is None: context_file = session_dir / "context.jsonl" else: logger.warning( "Using provided context file: {context_file}", context_file=_context_file ) _context_file.parent.mkdir(parents=True, exist_ok=True) if _context_file.exists(): assert _context_file.is_file() context_file = _context_file if context_file.exists(): # truncate if exists logger.warning( "Context file already exists, truncating: {context_file}", context_file=context_file ) context_file.unlink() context_file.touch() save_metadata(metadata) session = Session( id=session_id, work_dir=work_dir, work_dir_meta=work_dir_meta, context_file=context_file, wire_file=WireFile(path=session_dir / "wire.jsonl"), state=SessionState(), title="", updated_at=0.0, ) await session.refresh() return session @staticmethod async def find(work_dir: KaosPath, session_id: str) -> Session | None: """Find a session by work directory and session ID.""" work_dir = work_dir.canonical() logger.debug( "Finding session for work directory: {work_dir}, session ID: {session_id}", work_dir=work_dir, session_id=session_id, ) metadata = load_metadata() work_dir_meta = metadata.get_work_dir_meta(work_dir) if work_dir_meta is None: logger.debug("Work directory never been used") return None _migrate_session_context_file(work_dir_meta, session_id) session_dir = work_dir_meta.sessions_dir / session_id if not session_dir.is_dir(): logger.debug("Session directory not found: {session_dir}", session_dir=session_dir) return None context_file = session_dir / "context.jsonl" if not context_file.exists(): logger.debug( "Session context file not found: {context_file}", context_file=context_file ) return None session = Session( id=session_id, work_dir=work_dir, work_dir_meta=work_dir_meta, context_file=context_file, wire_file=WireFile(path=session_dir / "wire.jsonl"), state=load_session_state(session_dir), title="", updated_at=0.0, ) await session.refresh() return session @staticmethod async def list(work_dir: KaosPath) -> builtins.list[Session]: """List all sessions for a work directory.""" work_dir = work_dir.canonical() logger.debug("Listing sessions for work directory: {work_dir}", work_dir=work_dir) metadata = load_metadata() work_dir_meta = metadata.get_work_dir_meta(work_dir) if work_dir_meta is None: logger.debug("Work directory never been used") return [] session_ids = { path.name if path.is_dir() else path.stem for path in work_dir_meta.sessions_dir.iterdir() if path.is_dir() or path.suffix == ".jsonl" } sessions: list[Session] = [] for session_id in session_ids: _migrate_session_context_file(work_dir_meta, session_id) session_dir = work_dir_meta.sessions_dir / session_id if not session_dir.is_dir(): logger.debug("Session directory not found: {session_dir}", session_dir=session_dir) continue context_file = session_dir / "context.jsonl" if not context_file.exists(): logger.debug( "Session context file not found: {context_file}", context_file=context_file ) continue session = Session( id=session_id, work_dir=work_dir, work_dir_meta=work_dir_meta, context_file=context_file, wire_file=WireFile(path=session_dir / "wire.jsonl"), state=load_session_state(session_dir), title="", updated_at=0.0, ) if session.is_empty(): logger.debug( "Session context file is empty: {context_file}", context_file=context_file ) continue await session.refresh() sessions.append(session) sessions.sort(key=lambda session: session.updated_at, reverse=True) return sessions @staticmethod async def continue_(work_dir: KaosPath) -> Session | None: """Get the last session for a work directory.""" work_dir = work_dir.canonical() logger.debug("Continuing session for work directory: {work_dir}", work_dir=work_dir) metadata = load_metadata() work_dir_meta = metadata.get_work_dir_meta(work_dir) if work_dir_meta is None: logger.debug("Work directory never been used") return None if work_dir_meta.last_session_id is None: logger.debug("Work directory never had a session") return None logger.debug( "Found last session for work directory: {session_id}", session_id=work_dir_meta.last_session_id, ) return await Session.find(work_dir, work_dir_meta.last_session_id) def _migrate_session_context_file(work_dir_meta: WorkDirMeta, session_id: str) -> None: old_context_file = work_dir_meta.sessions_dir / f"{session_id}.jsonl" new_context_file = work_dir_meta.sessions_dir / session_id / "context.jsonl" if old_context_file.exists() and not new_context_file.exists(): new_context_file.parent.mkdir(parents=True, exist_ok=True) old_context_file.rename(new_context_file) logger.info( "Migrated session context file from {old} to {new}", old=old_context_file, new=new_context_file, ) ================================================ FILE: src/kimi_cli/session_state.py ================================================ from __future__ import annotations import json from pathlib import Path from pydantic import BaseModel, Field, ValidationError from kimi_cli.utils.io import atomic_json_write from kimi_cli.utils.logging import logger STATE_FILE_NAME = "state.json" class ApprovalStateData(BaseModel): yolo: bool = False auto_approve_actions: set[str] = Field(default_factory=set) class DynamicSubagentSpec(BaseModel): name: str system_prompt: str def _default_dynamic_subagents() -> list[DynamicSubagentSpec]: return [] class SessionState(BaseModel): version: int = 1 approval: ApprovalStateData = Field(default_factory=ApprovalStateData) dynamic_subagents: list[DynamicSubagentSpec] = Field(default_factory=_default_dynamic_subagents) additional_dirs: list[str] = Field(default_factory=list) plan_mode: bool = False plan_session_id: str | None = None plan_slug: str | None = None def load_session_state(session_dir: Path) -> SessionState: state_file = session_dir / STATE_FILE_NAME if not state_file.exists(): return SessionState() try: with open(state_file, encoding="utf-8") as f: return SessionState.model_validate(json.load(f)) except (json.JSONDecodeError, ValidationError, UnicodeDecodeError): logger.warning("Corrupted state file, using defaults: {path}", path=state_file) return SessionState() def save_session_state(state: SessionState, session_dir: Path) -> None: state_file = session_dir / STATE_FILE_NAME atomic_json_write(state.model_dump(mode="json"), state_file) ================================================ FILE: src/kimi_cli/share.py ================================================ from __future__ import annotations import os from pathlib import Path def get_share_dir() -> Path: """Get the share directory path.""" if share_dir := os.getenv("KIMI_SHARE_DIR"): share_dir = Path(share_dir) else: share_dir = Path.home() / ".kimi" share_dir.mkdir(parents=True, exist_ok=True) return share_dir ================================================ FILE: src/kimi_cli/skill/__init__.py ================================================ """Skill specification discovery and loading utilities.""" from __future__ import annotations from collections.abc import Callable, Iterable, Iterator from pathlib import Path from typing import Literal from kaos import get_current_kaos from kaos.local import local_kaos from kaos.path import KaosPath from pydantic import BaseModel, ConfigDict from kimi_cli import logger from kimi_cli.skill.flow import Flow, FlowError from kimi_cli.skill.flow.d2 import parse_d2_flowchart from kimi_cli.skill.flow.mermaid import parse_mermaid_flowchart from kimi_cli.utils.frontmatter import parse_frontmatter SkillType = Literal["standard", "flow"] def get_builtin_skills_dir() -> Path: """ Get the built-in skills directory path. """ return Path(__file__).parent.parent / "skills" def get_user_skills_dir_candidates() -> tuple[KaosPath, ...]: """ Get user-level skills directory candidates in priority order. """ return ( KaosPath.home() / ".config" / "agents" / "skills", KaosPath.home() / ".agents" / "skills", KaosPath.home() / ".kimi" / "skills", KaosPath.home() / ".claude" / "skills", KaosPath.home() / ".codex" / "skills", ) def get_project_skills_dir_candidates(work_dir: KaosPath) -> tuple[KaosPath, ...]: """ Get project-level skills directory candidates in priority order. """ return ( work_dir / ".agents" / "skills", work_dir / ".kimi" / "skills", work_dir / ".claude" / "skills", work_dir / ".codex" / "skills", ) def _supports_builtin_skills() -> bool: """Return True when the active KAOS backend can read bundled skills.""" current_name = get_current_kaos().name return current_name in (local_kaos.name, "acp") async def find_first_existing_dir(candidates: Iterable[KaosPath]) -> KaosPath | None: """ Return the first existing directory from candidates. """ for candidate in candidates: if await candidate.is_dir(): return candidate return None async def find_user_skills_dir() -> KaosPath | None: """ Return the first existing user-level skills directory. """ return await find_first_existing_dir(get_user_skills_dir_candidates()) async def find_project_skills_dir(work_dir: KaosPath) -> KaosPath | None: """ Return the first existing project-level skills directory. """ return await find_first_existing_dir(get_project_skills_dir_candidates(work_dir)) async def resolve_skills_roots( work_dir: KaosPath, *, skills_dir_override: KaosPath | None = None, ) -> list[KaosPath]: """ Resolve layered skill roots in priority order. Built-in skills load first when supported by the active KAOS backend. When an override is provided, user/project discovery is skipped. """ from kimi_cli.plugin.manager import get_plugins_dir roots: list[KaosPath] = [] if _supports_builtin_skills(): roots.append(KaosPath.unsafe_from_local_path(get_builtin_skills_dir())) if skills_dir_override is not None: roots.append(skills_dir_override) else: if user_dir := await find_user_skills_dir(): roots.append(user_dir) if project_dir := await find_project_skills_dir(work_dir): roots.append(project_dir) # Plugins are always discoverable, even when --skills-dir is set plugins_path = get_plugins_dir() if plugins_path.is_dir(): roots.append(KaosPath.unsafe_from_local_path(plugins_path)) return roots def normalize_skill_name(name: str) -> str: """Normalize a skill name for lookup.""" return name.casefold() def index_skills(skills: Iterable[Skill]) -> dict[str, Skill]: """Build a lookup table for skills by normalized name.""" return {normalize_skill_name(skill.name): skill for skill in skills} async def discover_skills_from_roots(skills_dirs: Iterable[KaosPath]) -> list[Skill]: """ Discover skills from multiple directory roots. """ skills_by_name: dict[str, Skill] = {} for skills_dir in skills_dirs: for skill in await discover_skills(skills_dir): skills_by_name[normalize_skill_name(skill.name)] = skill return sorted(skills_by_name.values(), key=lambda s: s.name) async def read_skill_text(skill: Skill) -> str | None: """Read the SKILL.md contents for a skill.""" try: return (await skill.skill_md_file.read_text(encoding="utf-8")).strip() except OSError as exc: logger.warning( "Failed to read skill file {path}: {error}", path=skill.skill_md_file, error=exc, ) return None class Skill(BaseModel): """Information about a single skill.""" model_config = ConfigDict(extra="ignore", arbitrary_types_allowed=True) name: str description: str type: SkillType = "standard" dir: KaosPath flow: Flow | None = None @property def skill_md_file(self) -> KaosPath: """Path to the SKILL.md file.""" return self.dir / "SKILL.md" async def discover_skills(skills_dir: KaosPath) -> list[Skill]: """ Discover all skills in the given directory. Args: skills_dir: Kaos path to the directory containing skills. Returns: List of Skill objects, one for each valid skill found. """ if not await skills_dir.is_dir(): return [] skills: list[Skill] = [] async for skill_dir in skills_dir.iterdir(): if not await skill_dir.is_dir(): continue skill_md = skill_dir / "SKILL.md" if not await skill_md.is_file(): continue try: content = await skill_md.read_text(encoding="utf-8") skills.append(parse_skill_text(content, dir_path=skill_dir)) except Exception as exc: logger.info("Skipping invalid skill at {}: {}", skill_md, exc) continue return sorted(skills, key=lambda s: s.name) def parse_skill_text(content: str, *, dir_path: KaosPath) -> Skill: """ Parse SKILL.md contents to extract name and description. """ frontmatter = parse_frontmatter(content) or {} name = frontmatter.get("name") or dir_path.name description = frontmatter.get("description") or "No description provided." skill_type = frontmatter.get("type") or "standard" if skill_type not in ("standard", "flow"): raise ValueError(f'Invalid skill type "{skill_type}"') flow = None if skill_type == "flow": try: flow = _parse_flow_from_skill(content) except ValueError as exc: logger.error("Failed to parse flow skill {name}: {error}", name=name, error=exc) skill_type = "standard" flow = None return Skill( name=name, description=description, type=skill_type, dir=dir_path, flow=flow, ) def _parse_flow_from_skill(content: str) -> Flow: for lang, code in _iter_fenced_codeblocks(content): if lang == "mermaid": return _parse_flow_block(parse_mermaid_flowchart, code) if lang == "d2": return _parse_flow_block(parse_d2_flowchart, code) raise ValueError("Flow skills require a mermaid or d2 code block in SKILL.md.") def _parse_flow_block(parser: Callable[[str], Flow], code: str) -> Flow: try: return parser(code) except FlowError as exc: raise ValueError(f"Invalid flow diagram: {exc}") from exc def _iter_fenced_codeblocks(content: str) -> Iterator[tuple[str, str]]: fence = "" fence_char = "" lang = "" buf: list[str] = [] in_block = False for line in content.splitlines(): stripped = line.lstrip() if not in_block: if match := _parse_fence_open(stripped): fence, fence_char, info = match lang = _normalize_code_lang(info) in_block = True buf = [] continue if _is_fence_close(stripped, fence_char, len(fence)): yield lang, "\n".join(buf).strip("\n") in_block = False fence = "" fence_char = "" lang = "" buf = [] continue buf.append(line) def _normalize_code_lang(info: str) -> str: if not info: return "" lang = info.split()[0].strip().lower() if lang.startswith("{") and lang.endswith("}"): lang = lang[1:-1].strip() return lang def _parse_fence_open(line: str) -> tuple[str, str, str] | None: if not line or line[0] not in ("`", "~"): return None fence_char = line[0] count = 0 for ch in line: if ch == fence_char: count += 1 else: break if count < 3: return None fence = fence_char * count info = line[count:].strip() return fence, fence_char, info def _is_fence_close(line: str, fence_char: str, fence_len: int) -> bool: if not fence_char or not line or line[0] != fence_char: return False count = 0 for ch in line: if ch == fence_char: count += 1 else: break if count < fence_len: return False return not line[count:].strip() ================================================ FILE: src/kimi_cli/skill/flow/__init__.py ================================================ from __future__ import annotations import re from dataclasses import dataclass from typing import Literal from kosong.message import ContentPart FlowNodeKind = Literal["begin", "end", "task", "decision"] class FlowError(ValueError): """Base error for flow parsing/validation.""" class FlowParseError(FlowError): """Raised when prompt flow parsing fails.""" class FlowValidationError(FlowError): """Raised when a flowchart fails validation.""" @dataclass(frozen=True, slots=True) class FlowNode: id: str label: str | list[ContentPart] kind: FlowNodeKind @dataclass(frozen=True, slots=True) class FlowEdge: src: str dst: str label: str | None @dataclass(slots=True) class Flow: nodes: dict[str, FlowNode] outgoing: dict[str, list[FlowEdge]] begin_id: str end_id: str _CHOICE_RE = re.compile(r"([^<]*)") def parse_choice(text: str) -> str | None: matches = _CHOICE_RE.findall(text or "") if not matches: return None return matches[-1].strip() def validate_flow( nodes: dict[str, FlowNode], outgoing: dict[str, list[FlowEdge]], ) -> tuple[str, str]: begin_ids = [node.id for node in nodes.values() if node.kind == "begin"] end_ids = [node.id for node in nodes.values() if node.kind == "end"] if len(begin_ids) != 1: raise FlowValidationError(f"Expected exactly one BEGIN node, found {len(begin_ids)}") if len(end_ids) != 1: raise FlowValidationError(f"Expected exactly one END node, found {len(end_ids)}") begin_id = begin_ids[0] end_id = end_ids[0] reachable: set[str] = set() queue: list[str] = [begin_id] while queue: node_id = queue.pop() if node_id in reachable: continue reachable.add(node_id) for edge in outgoing.get(node_id, []): if edge.dst not in reachable: queue.append(edge.dst) for node in nodes.values(): if node.id not in reachable: continue edges = outgoing.get(node.id, []) if len(edges) <= 1: continue labels: list[str] = [] for edge in edges: if edge.label is None or not edge.label.strip(): raise FlowValidationError(f'Node "{node.id}" has an unlabeled edge') labels.append(edge.label) if len(set(labels)) != len(labels): raise FlowValidationError(f'Node "{node.id}" has duplicate edge labels') if end_id not in reachable: raise FlowValidationError("END node is not reachable from BEGIN") return begin_id, end_id ================================================ FILE: src/kimi_cli/skill/flow/d2.py ================================================ from __future__ import annotations import re from collections.abc import Iterable from dataclasses import dataclass from . import ( Flow, FlowEdge, FlowNode, FlowNodeKind, FlowParseError, validate_flow, ) _NODE_ID_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_./-]*") _BLOCK_TAG_RE = re.compile(r"^\|md$") _PROPERTY_SEGMENTS = { "shape", "style", "label", "link", "icon", "near", "width", "height", "direction", "grid-rows", "grid-columns", "grid-gap", "font-size", "font-family", "font-color", "stroke", "fill", "opacity", "padding", "border-radius", "shadow", "sketch", "animated", "multiple", "constraint", "tooltip", } @dataclass(frozen=True, slots=True) class _NodeDef: node: FlowNode explicit: bool def parse_d2_flowchart(text: str) -> Flow: # Normalize D2 markdown blocks into quoted labels so the parser can stay line-based. text = _normalize_markdown_blocks(text) nodes: dict[str, _NodeDef] = {} outgoing: dict[str, list[FlowEdge]] = {} for line_no, statement in _iter_top_level_statements(text): if _has_unquoted_token(statement, "->"): _parse_edge_statement(statement, line_no, nodes, outgoing) else: _parse_node_statement(statement, line_no, nodes) flow_nodes = {node_id: node_def.node for node_id, node_def in nodes.items()} for node_id in flow_nodes: outgoing.setdefault(node_id, []) flow_nodes = _infer_decision_nodes(flow_nodes, outgoing) begin_id, end_id = validate_flow(flow_nodes, outgoing) return Flow(nodes=flow_nodes, outgoing=outgoing, begin_id=begin_id, end_id=end_id) def _normalize_markdown_blocks(text: str) -> str: normalized = text.replace("\r\n", "\n").replace("\r", "\n") lines = normalized.split("\n") out_lines: list[str] = [] i = 0 line_no = 1 while i < len(lines): line = lines[i] prefix, suffix = _split_unquoted_once(line, ":") if suffix is None: out_lines.append(line) i += 1 line_no += 1 continue suffix_clean = _strip_unquoted_comment(suffix).strip() # Only treat `: |md` as a markdown block starter. if not _BLOCK_TAG_RE.fullmatch(suffix_clean): out_lines.append(line) i += 1 line_no += 1 continue start_line = line_no block_lines: list[str] = [] i += 1 line_no += 1 while i < len(lines): block_line = lines[i] if block_line.strip() == "|": break block_lines.append(block_line) i += 1 line_no += 1 if i >= len(lines): raise FlowParseError(_line_error(start_line, "Unclosed markdown block")) # Convert the block into a multiline quoted string label. dedented = _dedent_block(block_lines) if dedented: escaped = [_escape_quoted_line(line) for line in dedented] out_lines.append(f'{prefix}: "{escaped[0]}') for line in escaped[1:]: out_lines.append(line) out_lines[-1] = f'{out_lines[-1]}"' out_lines.extend(["", ""]) else: out_lines.append(f'{prefix}: ""') out_lines.append("") i += 1 line_no += 1 return "\n".join(out_lines) def _strip_unquoted_comment(text: str) -> str: in_single = False in_double = False escape = False for idx, ch in enumerate(text): if escape: escape = False continue if ch == "\\" and (in_single or in_double): escape = True continue if ch == "'" and not in_double: in_single = not in_single continue if ch == '"' and not in_single: in_double = not in_double continue if ch == "#" and not in_single and not in_double: return text[:idx] return text def _dedent_block(lines: list[str]) -> list[str]: indent: int | None = None for line in lines: if not line.strip(): continue stripped = line.lstrip(" \t") lead = len(line) - len(stripped) if indent is None or lead < indent: indent = lead if indent is None: return ["" for _ in lines] return [line[indent:] if len(line) >= indent else "" for line in lines] def _escape_quoted_line(line: str) -> str: return line.replace("\\", "\\\\").replace('"', '\\"') def _iter_top_level_statements(text: str) -> Iterable[tuple[int, str]]: text = text.replace("\r\n", "\n").replace("\r", "\n") brace_depth = 0 in_single = False in_double = False escape = False drop_line = False buf: list[str] = [] line_no = 1 stmt_line = 1 i = 0 while i < len(text): ch = text[i] next_ch = text[i + 1] if i + 1 < len(text) else "" if ch == "\\" and next_ch == "\n": i += 2 line_no += 1 continue if ch == "\n": # Preserve newlines inside quoted strings (used for markdown block labels). if (in_single or in_double) and brace_depth == 0 and not drop_line: buf.append("\n") line_no += 1 i += 1 continue if brace_depth == 0 and not in_single and not in_double and not drop_line: statement = "".join(buf).strip() if statement: yield stmt_line, statement buf = [] drop_line = False stmt_line = line_no + 1 line_no += 1 i += 1 continue if not in_single and not in_double: if ch == "#": while i < len(text) and text[i] != "\n": i += 1 continue if ch == "{": if brace_depth == 0: statement = "".join(buf).strip() if statement: yield stmt_line, statement drop_line = True buf.clear() brace_depth += 1 i += 1 continue if ch == "}" and brace_depth > 0: brace_depth -= 1 i += 1 continue if ch == "}" and brace_depth == 0: raise FlowParseError(_line_error(line_no, "Unmatched '}'")) if ch == "'" and not in_double and not escape: in_single = not in_single elif ch == '"' and not in_single and not escape: in_double = not in_double if escape: escape = False elif ch == "\\" and (in_single or in_double): escape = True if brace_depth == 0 and not drop_line: buf.append(ch) i += 1 if brace_depth != 0: raise FlowParseError(_line_error(line_no, "Unclosed '{' block")) if in_single or in_double: raise FlowParseError(_line_error(line_no, "Unclosed string")) statement = "".join(buf).strip() if statement: yield stmt_line, statement def _has_unquoted_token(text: str, token: str) -> bool: parts = _split_on_token(text, token) return len(parts) > 1 def _parse_edge_statement( statement: str, line_no: int, nodes: dict[str, _NodeDef], outgoing: dict[str, list[FlowEdge]], ) -> None: parts = _split_on_token(statement, "->") if len(parts) < 2: raise FlowParseError(_line_error(line_no, "Expected edge arrow")) last_part = parts[-1] target_text, edge_label = _split_unquoted_once(last_part, ":") parts[-1] = target_text node_ids: list[str] = [] for idx, part in enumerate(parts): node_id = _parse_node_id(part, line_no, allow_inline_label=(idx < len(parts) - 1)) node_ids.append(node_id) if any(_is_property_path(node_id) for node_id in node_ids): return if len(node_ids) < 2: raise FlowParseError(_line_error(line_no, "Edge must have at least two nodes")) label = _parse_label(edge_label, line_no) if edge_label is not None else None for idx in range(len(node_ids) - 1): edge = FlowEdge( src=node_ids[idx], dst=node_ids[idx + 1], label=label if idx == len(node_ids) - 2 else None, ) outgoing.setdefault(edge.src, []).append(edge) outgoing.setdefault(edge.dst, []) for node_id in node_ids: _add_node(nodes, node_id=node_id, label=None, explicit=False, line_no=line_no) def _parse_node_statement(statement: str, line_no: int, nodes: dict[str, _NodeDef]) -> None: node_text, label_text = _split_unquoted_once(statement, ":") if label_text is not None and _is_property_path(node_text): return node_id = _parse_node_id(node_text, line_no, allow_inline_label=False) label = None explicit = False if label_text is not None and not label_text.strip(): return if label_text is not None: label = _parse_label(label_text, line_no) explicit = True _add_node(nodes, node_id=node_id, label=label, explicit=explicit, line_no=line_no) def _parse_node_id(text: str, line_no: int, *, allow_inline_label: bool) -> str: cleaned = text.strip() if allow_inline_label and ":" in cleaned: cleaned = _split_unquoted_once(cleaned, ":")[0].strip() if not cleaned: raise FlowParseError(_line_error(line_no, "Expected node id")) match = _NODE_ID_RE.fullmatch(cleaned) if not match: raise FlowParseError(_line_error(line_no, f'Invalid node id "{cleaned}"')) return match.group(0) def _is_property_path(node_id: str) -> bool: if "." not in node_id: return False parts = [part for part in node_id.split(".") if part] for part in parts[1:]: if part in _PROPERTY_SEGMENTS or part.startswith("style"): return True return parts[-1] in _PROPERTY_SEGMENTS def _parse_label(text: str, line_no: int) -> str: label = text.strip() if not label: raise FlowParseError(_line_error(line_no, "Label cannot be empty")) if label[0] in {"'", '"'}: return _parse_quoted_label(label, line_no) return label def _parse_quoted_label(text: str, line_no: int) -> str: quote = text[0] buf: list[str] = [] escape = False i = 1 while i < len(text): ch = text[i] if escape: buf.append(ch) escape = False i += 1 continue if ch == "\\": escape = True i += 1 continue if ch == quote: trailing = text[i + 1 :].strip() if trailing: raise FlowParseError(_line_error(line_no, "Unexpected trailing content")) return "".join(buf) buf.append(ch) i += 1 raise FlowParseError(_line_error(line_no, "Unclosed quoted label")) def _split_on_token(text: str, token: str) -> list[str]: parts: list[str] = [] buf: list[str] = [] in_single = False in_double = False escape = False i = 0 while i < len(text): if not in_single and not in_double and text.startswith(token, i): parts.append("".join(buf).strip()) buf = [] i += len(token) continue ch = text[i] if escape: escape = False elif ch == "\\" and (in_single or in_double): escape = True elif ch == "'" and not in_double: in_single = not in_single elif ch == '"' and not in_single: in_double = not in_double buf.append(ch) i += 1 if in_single or in_double: raise FlowParseError("Unclosed string in statement") parts.append("".join(buf).strip()) return parts def _split_unquoted_once(text: str, token: str) -> tuple[str, str | None]: in_single = False in_double = False escape = False for idx, ch in enumerate(text): if escape: escape = False continue if ch == "\\" and (in_single or in_double): escape = True continue if ch == "'" and not in_double: in_single = not in_single continue if ch == '"' and not in_single: in_double = not in_double continue if ch == token and not in_single and not in_double: return text[:idx].strip(), text[idx + 1 :].strip() return text.strip(), None def _add_node( nodes: dict[str, _NodeDef], *, node_id: str, label: str | None, explicit: bool, line_no: int, ) -> FlowNode: label = label if label is not None else node_id label_norm = label.strip().lower() if not label: raise FlowParseError(_line_error(line_no, "Node label cannot be empty")) kind: FlowNodeKind = "task" if label_norm == "begin": kind = "begin" elif label_norm == "end": kind = "end" node = FlowNode(id=node_id, label=label, kind=kind) existing = nodes.get(node_id) if existing is None: nodes[node_id] = _NodeDef(node=node, explicit=explicit) return node if existing.node == node: return existing.node if not explicit and existing.explicit: return existing.node if explicit and not existing.explicit: nodes[node_id] = _NodeDef(node=node, explicit=True) return node raise FlowParseError(_line_error(line_no, f'Conflicting definition for node "{node_id}"')) def _infer_decision_nodes( nodes: dict[str, FlowNode], outgoing: dict[str, list[FlowEdge]], ) -> dict[str, FlowNode]: updated: dict[str, FlowNode] = {} for node_id, node in nodes.items(): kind = node.kind if kind == "task" and len(outgoing.get(node_id, [])) > 1: kind = "decision" if kind != node.kind: updated[node_id] = FlowNode(id=node.id, label=node.label, kind=kind) else: updated[node_id] = node return updated def _line_error(line_no: int, message: str) -> str: return f"Line {line_no}: {message}" ================================================ FILE: src/kimi_cli/skill/flow/mermaid.py ================================================ from __future__ import annotations import re from dataclasses import dataclass from . import ( Flow, FlowEdge, FlowNode, FlowNodeKind, FlowParseError, validate_flow, ) @dataclass(frozen=True, slots=True) class _NodeSpec: node_id: str label: str | None @dataclass(slots=True) class _NodeDef: node: FlowNode explicit: bool _NODE_ID_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_-]*") _HEADER_RE = re.compile(r"^(flowchart|graph)\b", re.IGNORECASE) _SHAPES = { "[": "]", "(": ")", "{": "}", } _PIPE_LABEL_RE = re.compile(r"\|([^|]*)\|") _EDGE_LABEL_RE = re.compile(r"--\s*([^>-][^>]*)\s*-->") _ARROW_RE = re.compile(r"[-.=]+>") def parse_mermaid_flowchart(text: str) -> Flow: nodes: dict[str, _NodeDef] = {} outgoing: dict[str, list[FlowEdge]] = {} for line_no, raw_line in enumerate(text.splitlines(), start=1): line = _strip_comment(raw_line).strip() if not line or line.startswith("%%"): continue if _HEADER_RE.match(line): continue if _is_style_line(line): continue line = _strip_style_tokens(line) edge = _try_parse_edge_line(line, line_no) if edge is not None: src_spec, label, dst_spec = edge src_node = _add_node(nodes, src_spec, line_no) dst_node = _add_node(nodes, dst_spec, line_no) flow_edge = FlowEdge(src=src_node.id, dst=dst_node.id, label=label) outgoing.setdefault(flow_edge.src, []).append(flow_edge) outgoing.setdefault(flow_edge.dst, []) continue node_spec = _try_parse_node_line(line, line_no) if node_spec is not None: _add_node(nodes, node_spec, line_no) flow_nodes = {node_id: node_def.node for node_id, node_def in nodes.items()} for node_id in flow_nodes: outgoing.setdefault(node_id, []) flow_nodes = _infer_decision_nodes(flow_nodes, outgoing) begin_id, end_id = validate_flow(flow_nodes, outgoing) return Flow(nodes=flow_nodes, outgoing=outgoing, begin_id=begin_id, end_id=end_id) def _try_parse_edge_line(line: str, line_no: int) -> tuple[_NodeSpec, str | None, _NodeSpec] | None: try: src_spec, idx = _parse_node_token(line, 0, line_no) except FlowParseError: return None normalized, label = _normalize_edge_line(line) idx = _skip_ws(normalized, idx) if ">" not in normalized[idx:]: if "---" not in normalized[idx:]: return None normalized = normalized[:idx] + normalized[idx:].replace("---", "-->", 1) normalized = _ARROW_RE.sub("-->", normalized) arrow_idx = normalized.rfind(">") if arrow_idx == -1: return None dst_start = _skip_ws(normalized, arrow_idx + 1) try: dst_spec, _ = _parse_node_token(normalized, dst_start, line_no) except FlowParseError: return None return src_spec, label, dst_spec def _parse_node_token(line: str, idx: int, line_no: int) -> tuple[_NodeSpec, int]: match = _NODE_ID_RE.match(line, idx) if not match: raise FlowParseError(_line_error(line_no, "Expected node id")) node_id = match.group(0) idx = match.end() if idx >= len(line) or line[idx] not in _SHAPES: return _NodeSpec(node_id=node_id, label=None), idx close_char = _SHAPES[line[idx]] idx += 1 label, idx = _parse_label(line, idx, close_char, line_no) return _NodeSpec(node_id=node_id, label=label), idx def _parse_label(line: str, idx: int, close_char: str, line_no: int) -> tuple[str, int]: if idx >= len(line): raise FlowParseError(_line_error(line_no, "Expected node label")) if close_char == ")" and line[idx] == "[": label, idx = _parse_label(line, idx + 1, "]", line_no) while idx < len(line) and line[idx].isspace(): idx += 1 if idx >= len(line) or line[idx] != ")": raise FlowParseError(_line_error(line_no, "Unclosed node label")) return label, idx + 1 if line[idx] == '"': idx += 1 buf: list[str] = [] while idx < len(line): ch = line[idx] if ch == '"': idx += 1 while idx < len(line) and line[idx].isspace(): idx += 1 if idx >= len(line) or line[idx] != close_char: raise FlowParseError(_line_error(line_no, "Unclosed node label")) return "".join(buf), idx + 1 if ch == "\\" and idx + 1 < len(line): buf.append(line[idx + 1]) idx += 2 continue buf.append(ch) idx += 1 raise FlowParseError(_line_error(line_no, "Unclosed quoted label")) end = line.find(close_char, idx) if end == -1: raise FlowParseError(_line_error(line_no, "Unclosed node label")) label = line[idx:end].strip() if not label: raise FlowParseError(_line_error(line_no, "Node label cannot be empty")) return label, end + 1 def _skip_ws(line: str, idx: int) -> int: while idx < len(line) and line[idx].isspace(): idx += 1 return idx def _add_node(nodes: dict[str, _NodeDef], spec: _NodeSpec, line_no: int) -> FlowNode: label = spec.label if spec.label is not None else spec.node_id label_norm = label.strip().lower() if not label: raise FlowParseError(_line_error(line_no, "Node label cannot be empty")) kind: FlowNodeKind = "task" if label_norm == "begin": kind = "begin" elif label_norm == "end": kind = "end" node = FlowNode(id=spec.node_id, label=label, kind=kind) explicit = spec.label is not None existing = nodes.get(spec.node_id) if existing is None: nodes[spec.node_id] = _NodeDef(node=node, explicit=explicit) return node if existing.node == node: return existing.node if not explicit and existing.explicit: return existing.node if explicit and not existing.explicit: nodes[spec.node_id] = _NodeDef(node=node, explicit=True) return node raise FlowParseError(_line_error(line_no, f'Conflicting definition for node "{spec.node_id}"')) def _line_error(line_no: int, message: str) -> str: return f"Line {line_no}: {message}" def _strip_comment(line: str) -> str: if "%%" not in line: return line return line.split("%%", 1)[0] def _is_style_line(line: str) -> bool: lowered = line.lower() if lowered in ("end",): return True return lowered.startswith( ( "classdef ", "class ", "style ", "linkstyle ", "click ", "subgraph ", "direction ", ) ) def _strip_style_tokens(line: str) -> str: return re.sub(r":::[A-Za-z0-9_-]+", "", line) def _try_parse_node_line(line: str, line_no: int) -> _NodeSpec | None: try: node_spec, _ = _parse_node_token(line, 0, line_no) except FlowParseError: return None return node_spec def _normalize_edge_line(line: str) -> tuple[str, str | None]: label = None normalized = line pipe_match = _PIPE_LABEL_RE.search(normalized) if pipe_match: label = pipe_match.group(1).strip() or None normalized = normalized[: pipe_match.start()] + normalized[pipe_match.end() :] if label is None: edge_match = _EDGE_LABEL_RE.search(normalized) if edge_match: label = edge_match.group(1).strip() or None normalized = normalized[: edge_match.start()] + "-->" + normalized[edge_match.end() :] return normalized, label def _infer_decision_nodes( nodes: dict[str, FlowNode], outgoing: dict[str, list[FlowEdge]], ) -> dict[str, FlowNode]: updated: dict[str, FlowNode] = {} for node_id, node in nodes.items(): kind = node.kind if kind == "task" and len(outgoing.get(node_id, [])) > 1: kind = "decision" if kind != node.kind: updated[node_id] = FlowNode(id=node.id, label=node.label, kind=kind) else: updated[node_id] = node return updated ================================================ FILE: src/kimi_cli/skills/kimi-cli-help/SKILL.md ================================================ --- name: kimi-cli-help description: Answer Kimi Code CLI usage, configuration, and troubleshooting questions. Use when user asks about Kimi Code CLI installation, setup, configuration, slash commands, keyboard shortcuts, MCP integration, providers, environment variables, how something works internally, or any questions about Kimi Code CLI itself. --- # Kimi Code CLI Help Help users with Kimi Code CLI questions by consulting documentation and source code. ## Strategy 1. **Prefer official documentation** for most questions 2. **Read local source** when in kimi-cli project itself, or when user is developing with kimi-cli as a library (e.g., importing from `kimi_cli` in their code) 3. **Clone and explore source** for complex internals not covered in docs - **ask user for confirmation first** ## Documentation Base URL: `https://moonshotai.github.io/kimi-cli/` Fetch documentation index to find relevant pages: ``` https://moonshotai.github.io/kimi-cli/llms.txt ``` ### Page URL Pattern - English: `https://moonshotai.github.io/kimi-cli/en/...` - Chinese: `https://moonshotai.github.io/kimi-cli/zh/...` ### Topic Mapping | Topic | Page | |-------|------| | Installation, first run | `/en/guides/getting-started.md` | | Config files | `/en/configuration/config-files.md` | | Providers, models | `/en/configuration/providers.md` | | Environment variables | `/en/configuration/env-vars.md` | | Slash commands | `/en/reference/slash-commands.md` | | CLI flags | `/en/reference/kimi-command.md` | | Keyboard shortcuts | `/en/reference/keyboard.md` | | MCP | `/en/customization/mcp.md` | | Agents | `/en/customization/agents.md` | | Skills | `/en/customization/skills.md` | | FAQ | `/en/faq.md` | ## Source Code Repository: `https://github.com/MoonshotAI/kimi-cli` When to read source: - In kimi-cli project directory (check `pyproject.toml` for `name = "kimi-cli"`) - User is importing `kimi_cli` as a library in their project - Question about internals not covered in docs (ask user before cloning) ================================================ FILE: src/kimi_cli/skills/skill-creator/SKILL.md ================================================ --- name: skill-creator description: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Kimi's capabilities with specialized knowledge, workflows, or tool integrations. --- # Skill Creator This skill provides guidance for creating effective skills. ## About Skills Skills are modular, self-contained packages that extend Kimi's capabilities by providing specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific domains or tasks—they transform Kimi from a general-purpose agent into a specialized agent equipped with procedural knowledge that no model can fully possess. ### What Skills Provide 1. Specialized workflows - Multi-step procedures for specific domains 2. Tool integrations - Instructions for working with specific file formats or APIs 3. Domain expertise - Company-specific knowledge, schemas, business logic 4. Bundled resources - Scripts, references, and assets for complex and repetitive tasks ## Core Principles ### Concise is Key The context window is a public good. Skills share the context window with everything else Kimi needs: system prompt, conversation history, other Skills' metadata, and the actual user request. **Default assumption: Kimi is already very smart.** Only add context Kimi doesn't already have. Challenge each piece of information: "Does Kimi really need this explanation?" and "Does this paragraph justify its token cost?" Prefer concise examples over verbose explanations. ### Set Appropriate Degrees of Freedom Match the level of specificity to the task's fragility and variability: **High freedom (text-based instructions)**: Use when multiple approaches are valid, decisions depend on context, or heuristics guide the approach. **Medium freedom (pseudocode or scripts with parameters)**: Use when a preferred pattern exists, some variation is acceptable, or configuration affects behavior. **Low freedom (specific scripts, few parameters)**: Use when operations are fragile and error-prone, consistency is critical, or a specific sequence must be followed. Think of Kimi as exploring a path: a narrow bridge with cliffs needs specific guardrails (low freedom), while an open field allows many routes (high freedom). ### Anatomy of a Skill Every skill consists of a required SKILL.md file and optional bundled resources: ``` skill-name/ ├── SKILL.md (required) │ ├── YAML frontmatter metadata (required) │ │ ├── name: (required) │ │ └── description: (required) │ └── Markdown instructions (required) └── Bundled Resources (optional) ├── scripts/ - Executable code (Python/Bash/etc.) ├── references/ - Documentation intended to be loaded into context as needed └── assets/ - Files used in output (templates, icons, fonts, etc.) ``` #### SKILL.md (required) Every SKILL.md consists of: - **Frontmatter** (YAML): Contains `name` and `description` fields. These are the only fields that Kimi reads to determine when the skill gets used, thus it is very important to be clear and comprehensive in describing what the skill is, and when it should be used. - **Body** (Markdown): Instructions and guidance for using the skill. Only loaded AFTER the skill triggers (if at all). #### Bundled Resources (optional) ##### Scripts (`scripts/`) Executable code (Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten. - **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed - **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks - **Benefits**: Token efficient, deterministic, may be executed without loading into context - **Note**: Scripts may still need to be read by Kimi for patching or environment-specific adjustments ##### References (`references/`) Documentation and reference material intended to be loaded as needed into context to inform Kimi's process and thinking. - **When to include**: For documentation that Kimi should reference while working - **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications - **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides - **Benefits**: Keeps SKILL.md lean, loaded only when Kimi determines it's needed - **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md - **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files. ##### Assets (`assets/`) Files not intended to be loaded into context, but rather used within the output Kimi produces. - **When to include**: When the skill needs files that will be used in the final output - **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography - **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified - **Benefits**: Separates output resources from documentation, enables Kimi to use files without loading them into context #### What to Not Include in a Skill A skill should only contain essential files that directly support its functionality. Do NOT create extraneous documentation or auxiliary files, including: - README.md - INSTALLATION_GUIDE.md - QUICK_REFERENCE.md - CHANGELOG.md - etc. The skill should only contain the information needed for an AI agent to do the job at hand. It should not contain auxiliary context about the process that went into creating it, setup and testing procedures, user-facing documentation, etc. Creating additional documentation files just adds clutter and confusion. ### Progressive Disclosure Design Principle Skills use a three-level loading system to manage context efficiently: 1. **Metadata (name + description)** - Always in context (~100 words) 2. **SKILL.md body** - When skill triggers (<5k words) 3. **Bundled resources** - As needed by Kimi (Unlimited because scripts can be executed without reading into context window) #### Progressive Disclosure Patterns Keep SKILL.md body to the essentials and under 500 lines to minimize context bloat. Split content into separate files when approaching this limit. When splitting out content into other files, it is very important to reference them from SKILL.md and describe clearly when to read them, to ensure the reader of the skill knows they exist and when to use them. **Key principle:** When a skill supports multiple variations, frameworks, or options, keep only the core workflow and selection guidance in SKILL.md. Move variant-specific details (patterns, examples, configuration) into separate reference files. **Pattern 1: High-level guide with references** ```markdown # PDF Processing ## Quick start Extract text with pdfplumber: [code example] ## Advanced features - **Form filling**: See [FORMS.md](FORMS.md) for complete guide - **API reference**: See [REFERENCE.md](REFERENCE.md) for all methods - **Examples**: See [EXAMPLES.md](EXAMPLES.md) for common patterns ``` Kimi loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed. **Pattern 2: Domain-specific organization** For Skills with multiple domains, organize content by domain to avoid loading irrelevant context: ``` bigquery-skill/ ├── SKILL.md (overview and navigation) └── reference/ ├── finance.md (revenue, billing metrics) ├── sales.md (opportunities, pipeline) ├── product.md (API usage, features) └── marketing.md (campaigns, attribution) ``` When a user asks about sales metrics, Kimi only reads sales.md. Similarly, for skills supporting multiple frameworks or variants, organize by variant: ``` cloud-deploy/ ├── SKILL.md (workflow + provider selection) └── references/ ├── aws.md (AWS deployment patterns) ├── gcp.md (GCP deployment patterns) └── azure.md (Azure deployment patterns) ``` When the user chooses AWS, Kimi only reads aws.md. **Pattern 3: Conditional details** Show basic content, link to advanced content: ```markdown # DOCX Processing ## Creating documents Use docx-js for new documents. See [DOCX-JS.md](DOCX-JS.md). ## Editing documents For simple edits, modify the XML directly. **For tracked changes**: See [REDLINING.md](REDLINING.md) **For OOXML details**: See [OOXML.md](OOXML.md) ``` Kimi reads REDLINING.md or OOXML.md only when the user needs those features. **Important guidelines:** - **Avoid deeply nested references** - Keep references one level deep from SKILL.md. All reference files should link directly from SKILL.md. - **Structure longer reference files** - For files longer than 100 lines, include a table of contents at the top so Kimi can see the full scope when previewing. ## Skill Locations and Discovery Kimi Code CLI loads skills in layers (built-in -> user -> project). Within each layer, it uses the first existing directory in priority order. Built-in skills only load for LocalKaos or ACPKaos. **User level** (by priority): - `~/.config/agents/skills/` (recommended) - `~/.kimi/skills/` - `~/.claude/skills/` **Project level**: - `.agents/skills/` `--skills-dir` overrides discovery and loads only that directory (built-ins still load when supported). ## Skill Creation Process Skill creation involves these steps: 1. Understand the skill with concrete examples 2. Plan reusable skill contents (scripts, references, assets) 3. Initialize the skill (run init_skill.py) 4. Edit the skill (implement resources and write SKILL.md) 5. Package the skill (run package_skill.py) 6. Iterate based on real usage Follow these steps in order, skipping only if there is a clear reason why they are not applicable. ### Skill Naming - Use lowercase letters, digits, and hyphens only; normalize user-provided titles to hyphen-case (e.g., "Plan Mode" -> `plan-mode`). - When generating names, generate a name under 64 characters (letters, digits, hyphens). - Prefer short, verb-led phrases that describe the action. - Namespace by tool when it improves clarity or triggering (e.g., `gh-address-comments`, `linear-address-issue`). - Name the skill folder exactly after the skill name. ### Step 1: Understanding the Skill with Concrete Examples Skip this step only when the skill's usage patterns are already clearly understood. It remains valuable even when working with an existing skill. To create an effective skill, clearly understand concrete examples of how the skill will be used. This understanding can come from either direct user examples or generated examples that are validated with user feedback. For example, when building an image-editor skill, relevant questions include: - "What functionality should the image-editor skill support? Editing, rotating, anything else?" - "Can you give some examples of how this skill would be used?" - "I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?" - "What would a user say that should trigger this skill?" To avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness. Conclude this step when there is a clear sense of the functionality the skill should support. ### Step 2: Planning the Reusable Skill Contents To turn concrete examples into an effective skill, analyze each example by: 1. Considering how to execute on the example from scratch 2. Identifying what scripts, references, and assets would be helpful when executing these workflows repeatedly Example: When building a `pdf-editor` skill to handle queries like "Help me rotate this PDF," the analysis shows: 1. Rotating a PDF requires re-writing the same code each time 2. A `scripts/rotate_pdf.py` script would be helpful to store in the skill Example: When designing a `frontend-webapp-builder` skill for queries like "Build me a todo app" or "Build me a dashboard to track my steps," the analysis shows: 1. Writing a frontend webapp requires the same boilerplate HTML/React each time 2. An `assets/hello-world/` template containing the boilerplate HTML/React project files would be helpful to store in the skill Example: When building a `big-query` skill to handle queries like "How many users have logged in today?" the analysis shows: 1. Querying BigQuery requires re-discovering the table schemas and relationships each time 2. A `references/schema.md` file documenting the table schemas would be helpful to store in the skill To establish the skill's contents, analyze each concrete example to create a list of the reusable resources to include: scripts, references, and assets. ### Step 3: Initializing the Skill At this point, it is time to actually create the skill. Skip this step only if the skill being developed already exists, and iteration or packaging is needed. In this case, continue to the next step. When creating a new skill from scratch, create a new skill directory with a required `SKILL.md` file and any optional resource directories that the skill needs (`scripts/`, `references/`, `assets/`). Create only the directories you intend to populate. After initialization, customize the SKILL.md and add resources as needed. ### Step 4: Edit the Skill When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of Kimi to use. Include information that would be beneficial and non-obvious to Kimi. Consider what procedural knowledge, domain-specific details, or reusable assets would help another Kimi instance execute these tasks more effectively. #### Learn Proven Design Patterns Capture proven design patterns directly in this SKILL.md: - **Multi-step processes**: Clearly describe sequential workflows and conditional branches, including triggers, decision points, and expected outputs at each step. - **Specific output formats or quality standards**: Document required output shapes, templates, and examples directly in this SKILL.md so they are easy to follow. #### Start with Reusable Skill Contents To begin implementation, start with the reusable resources identified above: `scripts/`, `references/`, and `assets/` files. Note that this step may require user input. For example, when implementing a `brand-guidelines` skill, the user may need to provide brand assets or templates to store in `assets/`, or documentation to store in `references/`. Added scripts must be tested by actually running them to ensure there are no bugs and that the output matches what is expected. If there are many similar scripts, only a representative sample needs to be tested to ensure confidence that they all work while balancing time to completion. Delete any placeholder files that are not needed for the skill. Only create resource directories that are actually required. #### Update SKILL.md **Writing Guidelines:** Always use imperative/infinitive form. ##### Frontmatter Write the YAML frontmatter with `name` and `description`: - `name`: The skill name - `description`: This is the primary triggering mechanism for your skill, and helps Kimi understand when to use the skill. - Include both what the Skill does and specific triggers/contexts for when to use it. - Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to Kimi. - Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when Kimi needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks" Do not include any other fields in YAML frontmatter. ##### Body Write instructions for using the skill and its bundled resources. ### Step 5: Packaging a Skill Once development of the skill is complete, package it into a distributable `.skill` file (a zip archive). Before packaging, validate that the skill meets all requirements: 1. **Validate** the skill, checking: - YAML frontmatter format and required fields - Skill naming conventions and directory structure - Description completeness and quality - File organization and resource references 2. **Package** the skill if validation passes: - Create an archive of the skill's root folder (the folder containing `SKILL.md` and all related files). - Ensure the archive preserves the internal directory structure. - Name the archive `.skill` (for example, `my-skill.skill`). The `.skill` file is a zip file with a `.skill` extension. Example packaging command: ```bash cd zip -r my-skill.skill my-skill ``` If validation fails (for example, due to malformed frontmatter, missing files, or an incomplete description), fix the issues and repackage the skill. ### Step 6: Iterate After testing the skill, users may request improvements. Often this happens right after using the skill, with fresh context of how the skill performed. **Iteration workflow:** 1. Use the skill on real tasks 2. Notice struggles or inefficiencies 3. Identify how SKILL.md or bundled resources should be updated 4. Implement changes and test again ================================================ FILE: src/kimi_cli/soul/__init__.py ================================================ from __future__ import annotations import asyncio import contextlib from collections.abc import Callable, Coroutine from contextvars import ContextVar from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable from kimi_cli.utils.aioqueue import QueueShutDown from kimi_cli.utils.logging import logger from kimi_cli.wire import Wire from kimi_cli.wire.file import WireFile from kimi_cli.wire.types import ContentPart, MCPStatusSnapshot, WireMessage if TYPE_CHECKING: from kimi_cli.llm import LLM, ModelCapability from kimi_cli.soul.agent import Runtime from kimi_cli.utils.slashcmd import SlashCommand class LLMNotSet(Exception): """Raised when the LLM is not set.""" def __init__(self) -> None: super().__init__("LLM not set") class LLMNotSupported(Exception): """Raised when the LLM does not have required capabilities.""" def __init__(self, llm: LLM, capabilities: list[ModelCapability]): self.llm = llm self.capabilities = capabilities capabilities_str = "capability" if len(capabilities) == 1 else "capabilities" super().__init__( f"LLM model '{llm.model_name}' does not support required {capabilities_str}: " f"{', '.join(capabilities)}." ) class MaxStepsReached(Exception): """Raised when the maximum number of steps is reached.""" n_steps: int """The number of steps that have been taken.""" def __init__(self, n_steps: int): super().__init__(f"Max number of steps reached: {n_steps}") self.n_steps = n_steps def format_token_count(n: int) -> str: """Format token count as compact string, e.g. 28.5k, 128k, 1.2m.""" suffix = "" if n >= 1_000_000: value = n / 1_000_000 suffix = "m" elif n >= 1_000: value = n / 1_000 suffix = "k" else: return str(n) # Keep one decimal when needed, but drop trailing ".0". compact = f"{value:.1f}".rstrip("0").rstrip(".") return f"{compact}{suffix}" def format_context_status( context_usage: float, context_tokens: int = 0, max_context_tokens: int = 0, ) -> str: """Format context status string for display in status bar.""" bounded = max(0.0, min(context_usage, 1.0)) if max_context_tokens > 0: used = format_token_count(context_tokens) total = format_token_count(max_context_tokens) return f"context: {bounded:.1%} ({used}/{total})" return f"context: {bounded:.1%}" @dataclass(frozen=True, slots=True) class StatusSnapshot: context_usage: float """The usage of the context, in percentage.""" yolo_enabled: bool = False """Whether YOLO (auto-approve) mode is enabled.""" plan_mode: bool = False """Whether plan mode (read-only research and planning) is active.""" context_tokens: int = 0 """The number of tokens currently in the context.""" max_context_tokens: int = 0 """The maximum number of tokens the context can hold.""" mcp_status: MCPStatusSnapshot | None = None """The current MCP startup snapshot, if MCP is configured.""" @runtime_checkable class Soul(Protocol): @property def name(self) -> str: """The name of the soul.""" ... @property def model_name(self) -> str: """The name of the LLM model used by the soul. Empty string if LLM is not set.""" ... @property def model_capabilities(self) -> set[ModelCapability] | None: """The capabilities of the LLM model used by the soul. None if LLM is not set.""" ... @property def thinking(self) -> bool | None: """ Whether thinking mode is currently enabled. None if LLM is not set or thinking mode is not set explicitly. """ ... @property def status(self) -> StatusSnapshot: """The current status of the soul. The returned value is immutable.""" ... @property def available_slash_commands(self) -> list[SlashCommand[Any]]: """List of available slash commands supported by the soul.""" ... async def run(self, user_input: str | list[ContentPart]): """ Run the agent with the given user input until the max steps or no more tool calls. Args: user_input (str | list[ContentPart]): The user input to the agent. Can be a slash command call or natural language input. Raises: LLMNotSet: When the LLM is not set. LLMNotSupported: When the LLM does not have required capabilities. ChatProviderError: When the LLM provider returns an error. MaxStepsReached: When the maximum number of steps is reached. asyncio.CancelledError: When the run is cancelled by user. """ ... type UILoopFn = Callable[[Wire], Coroutine[Any, Any, None]] """A long-running async function to visualize the agent behavior.""" class RunCancelled(Exception): """The run was cancelled by the cancel event.""" async def run_soul( soul: Soul, user_input: str | list[ContentPart], ui_loop_fn: UILoopFn, cancel_event: asyncio.Event, wire_file: WireFile | None = None, runtime: Runtime | None = None, ) -> None: """ Run the soul with the given user input, connecting it to the UI loop with a `Wire`. `cancel_event` is a outside handle that can be used to cancel the run. When the event is set, the run will be gracefully stopped and a `RunCancelled` will be raised. Raises: LLMNotSet: When the LLM is not set. LLMNotSupported: When the LLM does not have required capabilities. ChatProviderError: When the LLM provider returns an error. MaxStepsReached: When the maximum number of steps is reached. RunCancelled: When the run is cancelled by the cancel event. """ wire = Wire(file_backend=wire_file) wire_token = _current_wire.set(wire) logger.debug("Starting UI loop with function: {ui_loop_fn}", ui_loop_fn=ui_loop_fn) ui_task = asyncio.create_task(ui_loop_fn(wire)) logger.debug("Starting soul run") soul_task = asyncio.create_task(soul.run(user_input)) notification_task = asyncio.create_task(_pump_notifications_to_wire(runtime, wire)) cancel_event_task = asyncio.create_task(cancel_event.wait()) await asyncio.wait( [soul_task, cancel_event_task], return_when=asyncio.FIRST_COMPLETED, ) try: if cancel_event.is_set(): logger.debug("Cancelling the run task") soul_task.cancel() try: await soul_task except asyncio.CancelledError: raise RunCancelled from None else: assert soul_task.done() # either stop event is set or the run task is done cancel_event_task.cancel() with contextlib.suppress(asyncio.CancelledError): await cancel_event_task soul_task.result() # this will raise if any exception was raised in the run task finally: notification_task.cancel() with contextlib.suppress(asyncio.CancelledError): await notification_task try: await _deliver_notifications_to_wire_once(runtime, wire) except Exception: logger.exception("Failed to flush notifications to wire during shutdown") logger.debug("Shutting down the UI loop") # shutting down the wire should break the UI loop wire.shutdown() await wire.join() try: await asyncio.wait_for(ui_task, timeout=0.5) except QueueShutDown: logger.debug("UI loop shut down") pass except TimeoutError: logger.warning("UI loop timed out") finally: _current_wire.reset(wire_token) _current_wire = ContextVar[Wire | None]("current_wire", default=None) def get_wire_or_none() -> Wire | None: """ Get the current wire or None. Expect to be not None when called from anywhere in the agent loop. """ return _current_wire.get() def wire_send(msg: WireMessage) -> None: """ Send a wire message to the current wire. Take this as `print` and `input` for souls. Souls should always use this function to send wire messages. """ wire = get_wire_or_none() assert wire is not None, "Wire is expected to be set when soul is running" wire.soul_side.send(msg) async def _pump_notifications_to_wire(runtime: Runtime | None, wire: Wire) -> None: while True: try: await _deliver_notifications_to_wire_once(runtime, wire) except asyncio.CancelledError: raise except Exception: logger.exception("Notification wire pump failed") await asyncio.sleep(1.0) async def _deliver_notifications_to_wire_once(runtime: Runtime | None, wire: Wire) -> None: if runtime is None or runtime.role != "root": return from kimi_cli.notifications import NotificationView, to_wire_notification def _send_notification(view: NotificationView) -> None: wire.soul_side.send(to_wire_notification(view)) await runtime.notifications.deliver_pending( "wire", limit=8, before_claim=runtime.background_tasks.reconcile, on_notification=_send_notification, ) ================================================ FILE: src/kimi_cli/soul/agent.py ================================================ from __future__ import annotations import asyncio from collections.abc import Mapping from dataclasses import asdict, dataclass from datetime import datetime from pathlib import Path from typing import TYPE_CHECKING, Any, Literal import pydantic from jinja2 import Environment as JinjaEnvironment from jinja2 import StrictUndefined, TemplateError, UndefinedError from kaos.path import KaosPath from kosong.tooling import Toolset from kimi_cli.agentspec import load_agent_spec from kimi_cli.auth.oauth import OAuthManager from kimi_cli.background import BackgroundTaskManager from kimi_cli.config import Config from kimi_cli.exception import MCPConfigError, SystemPromptTemplateError from kimi_cli.llm import LLM from kimi_cli.notifications import NotificationManager from kimi_cli.session import Session from kimi_cli.skill import Skill, discover_skills_from_roots, index_skills, resolve_skills_roots from kimi_cli.soul.approval import Approval, ApprovalState from kimi_cli.soul.denwarenji import DenwaRenji from kimi_cli.soul.toolset import KimiToolset from kimi_cli.utils.environment import Environment from kimi_cli.utils.logging import logger from kimi_cli.utils.path import list_directory if TYPE_CHECKING: from fastmcp.mcp_config import MCPConfig @dataclass(frozen=True, slots=True, kw_only=True) class BuiltinSystemPromptArgs: """Builtin system prompt arguments.""" KIMI_NOW: str """The current datetime.""" KIMI_WORK_DIR: KaosPath """The absolute path of current working directory.""" KIMI_WORK_DIR_LS: str """The directory listing of current working directory.""" KIMI_AGENTS_MD: str # TODO: move to first message from system prompt """The content of AGENTS.md.""" KIMI_SKILLS: str """Formatted information about available skills.""" KIMI_ADDITIONAL_DIRS_INFO: str """Formatted information about additional directories in the workspace.""" async def load_agents_md(work_dir: KaosPath) -> str | None: paths = [ work_dir / "AGENTS.md", work_dir / "agents.md", ] for path in paths: if await path.is_file(): logger.info("Loaded agents.md: {path}", path=path) return (await path.read_text()).strip() logger.info("No AGENTS.md found in {work_dir}", work_dir=work_dir) return None @dataclass(slots=True, kw_only=True) class Runtime: """Agent runtime.""" config: Config oauth: OAuthManager llm: LLM | None # we do not freeze the `Runtime` dataclass because LLM can be changed session: Session builtin_args: BuiltinSystemPromptArgs denwa_renji: DenwaRenji approval: Approval labor_market: LaborMarket environment: Environment notifications: NotificationManager background_tasks: BackgroundTaskManager skills: dict[str, Skill] additional_dirs: list[KaosPath] role: Literal["root", "fixed_subagent", "dynamic_subagent"] = "root" @staticmethod async def create( config: Config, oauth: OAuthManager, llm: LLM | None, session: Session, yolo: bool, skills_dir: KaosPath | None = None, ) -> Runtime: ls_output, agents_md, environment = await asyncio.gather( list_directory(session.work_dir), load_agents_md(session.work_dir), Environment.detect(), ) # Discover and format skills skills_roots = await resolve_skills_roots(session.work_dir, skills_dir_override=skills_dir) skills = await discover_skills_from_roots(skills_roots) skills_by_name = index_skills(skills) logger.info("Discovered {count} skill(s)", count=len(skills)) skills_formatted = "\n".join( ( f"- {skill.name}\n" f" - Path: {skill.skill_md_file}\n" f" - Description: {skill.description}" ) for skill in skills ) # Restore additional directories from session state, pruning stale entries additional_dirs: list[KaosPath] = [] pruned = False valid_dir_strs: list[str] = [] for dir_str in session.state.additional_dirs: d = KaosPath(dir_str).canonical() if await d.is_dir(): additional_dirs.append(d) valid_dir_strs.append(dir_str) else: logger.warning( "Additional directory no longer exists, removing from state: {dir}", dir=dir_str, ) pruned = True if pruned: session.state.additional_dirs = valid_dir_strs session.save_state() # Format additional dirs info for system prompt additional_dirs_info = "" if additional_dirs: parts: list[str] = [] for d in additional_dirs: try: dir_ls = await list_directory(d) except OSError: logger.warning( "Cannot list additional directory, skipping listing: {dir}", dir=d ) dir_ls = "[directory not readable]" parts.append(f"### `{d}`\n\n```\n{dir_ls}\n```") additional_dirs_info = "\n\n".join(parts) # Merge CLI flag with persisted session state effective_yolo = yolo or session.state.approval.yolo saved_actions = set(session.state.approval.auto_approve_actions) def _on_approval_change() -> None: session.state.approval.yolo = approval_state.yolo session.state.approval.auto_approve_actions = set(approval_state.auto_approve_actions) session.save_state() approval_state = ApprovalState( yolo=effective_yolo, auto_approve_actions=saved_actions, on_change=_on_approval_change, ) notifications = NotificationManager( session.context_file.parent / "notifications", config.notifications, ) return Runtime( config=config, oauth=oauth, llm=llm, session=session, builtin_args=BuiltinSystemPromptArgs( KIMI_NOW=datetime.now().astimezone().isoformat(), KIMI_WORK_DIR=session.work_dir, KIMI_WORK_DIR_LS=ls_output, KIMI_AGENTS_MD=agents_md or "", KIMI_SKILLS=skills_formatted or "No skills found.", KIMI_ADDITIONAL_DIRS_INFO=additional_dirs_info, ), denwa_renji=DenwaRenji(), approval=Approval(state=approval_state), labor_market=LaborMarket(), environment=environment, notifications=notifications, background_tasks=BackgroundTaskManager( session, config.background, notifications=notifications, ), skills=skills_by_name, additional_dirs=additional_dirs, role="root", ) def copy_for_fixed_subagent(self) -> Runtime: """Clone runtime for fixed subagent.""" return Runtime( config=self.config, oauth=self.oauth, llm=self.llm, session=self.session, builtin_args=self.builtin_args, denwa_renji=DenwaRenji(), # subagent must have its own DenwaRenji approval=self.approval.share(), labor_market=LaborMarket(), # fixed subagent has its own LaborMarket environment=self.environment, notifications=self.notifications, background_tasks=self.background_tasks.copy_for_role("fixed_subagent"), skills=self.skills, # Share the same list reference so /add-dir mutations propagate to all agents additional_dirs=self.additional_dirs, role="fixed_subagent", ) def copy_for_dynamic_subagent(self) -> Runtime: """Clone runtime for dynamic subagent.""" return Runtime( config=self.config, oauth=self.oauth, llm=self.llm, session=self.session, builtin_args=self.builtin_args, denwa_renji=DenwaRenji(), # subagent must have its own DenwaRenji approval=self.approval.share(), labor_market=self.labor_market, # dynamic subagent shares LaborMarket with main agent environment=self.environment, notifications=self.notifications, background_tasks=self.background_tasks.copy_for_role("dynamic_subagent"), skills=self.skills, # Share the same list reference so /add-dir mutations propagate to all agents additional_dirs=self.additional_dirs, role="dynamic_subagent", ) @dataclass(frozen=True, slots=True, kw_only=True) class Agent: """The loaded agent.""" name: str system_prompt: str toolset: Toolset runtime: Runtime """Each agent has its own runtime, which should be derived from its main agent.""" class LaborMarket: def __init__(self): self.fixed_subagents: dict[str, Agent] = {} self.fixed_subagent_descs: dict[str, str] = {} self.dynamic_subagents: dict[str, Agent] = {} @property def subagents(self) -> Mapping[str, Agent]: """Get all subagents in the labor market.""" return {**self.fixed_subagents, **self.dynamic_subagents} def add_fixed_subagent(self, name: str, agent: Agent, description: str): """Add a fixed subagent.""" self.fixed_subagents[name] = agent self.fixed_subagent_descs[name] = description def add_dynamic_subagent(self, name: str, agent: Agent): """Add a dynamic subagent.""" self.dynamic_subagents[name] = agent async def load_agent( agent_file: Path, runtime: Runtime, *, mcp_configs: list[MCPConfig] | list[dict[str, Any]], start_mcp_loading: bool = True, _restore_dynamic_subagents: bool = True, ) -> Agent: """ Load agent from specification file. Raises: FileNotFoundError: When the agent file is not found. AgentSpecError(KimiCLIException, ValueError): When the agent specification is invalid. SystemPromptTemplateError(KimiCLIException, ValueError): When the system prompt template is invalid. InvalidToolError(KimiCLIException, ValueError): When any tool cannot be loaded. MCPConfigError(KimiCLIException, ValueError): When any MCP configuration is invalid. MCPRuntimeError(KimiCLIException, RuntimeError): When any MCP server cannot be connected. """ logger.info("Loading agent: {agent_file}", agent_file=agent_file) agent_spec = load_agent_spec(agent_file) system_prompt = _load_system_prompt( agent_spec.system_prompt_path, agent_spec.system_prompt_args, runtime.builtin_args, ) # load subagents before loading tools because Task tool depends on LaborMarket on initialization for subagent_name, subagent_spec in agent_spec.subagents.items(): logger.debug("Loading subagent: {subagent_name}", subagent_name=subagent_name) subagent = await load_agent( subagent_spec.path, runtime.copy_for_fixed_subagent(), mcp_configs=mcp_configs, start_mcp_loading=start_mcp_loading, _restore_dynamic_subagents=False, ) runtime.labor_market.add_fixed_subagent(subagent_name, subagent, subagent_spec.description) toolset = KimiToolset() tool_deps = { KimiToolset: toolset, Runtime: runtime, # TODO: remove all the following dependencies and use Runtime instead Config: runtime.config, BuiltinSystemPromptArgs: runtime.builtin_args, Session: runtime.session, DenwaRenji: runtime.denwa_renji, Approval: runtime.approval, LaborMarket: runtime.labor_market, Environment: runtime.environment, } tools = agent_spec.tools if agent_spec.exclude_tools: logger.debug("Excluding tools: {tools}", tools=agent_spec.exclude_tools) tools = [tool for tool in tools if tool not in agent_spec.exclude_tools] toolset.load_tools(tools, tool_deps) # Load plugin tools from kimi_cli.plugin.manager import get_plugins_dir from kimi_cli.plugin.tool import load_plugin_tools plugin_tools = load_plugin_tools(get_plugins_dir(), runtime.config, approval=runtime.approval) for plugin_tool in plugin_tools: if toolset.find(plugin_tool.name) is not None: logger.warning( "Plugin tool '{name}' conflicts with an existing tool, skipping", name=plugin_tool.name, ) continue toolset.add(plugin_tool) if mcp_configs: validated_mcp_configs: list[MCPConfig] = [] if mcp_configs: from fastmcp.mcp_config import MCPConfig for mcp_config in mcp_configs: try: validated_mcp_configs.append( mcp_config if isinstance(mcp_config, MCPConfig) else MCPConfig.model_validate(mcp_config) ) except pydantic.ValidationError as e: raise MCPConfigError(f"Invalid MCP config: {e}") from e if start_mcp_loading: await toolset.load_mcp_tools(validated_mcp_configs, runtime, in_background=True) else: toolset.defer_mcp_tool_loading(validated_mcp_configs, runtime) # Restore dynamic subagents from persisted session state # Skip for fixed subagents — they have their own isolated LaborMarket if _restore_dynamic_subagents: for subagent_spec in runtime.session.state.dynamic_subagents: if subagent_spec.name not in runtime.labor_market.subagents: subagent = Agent( name=subagent_spec.name, system_prompt=subagent_spec.system_prompt, toolset=toolset, runtime=runtime.copy_for_dynamic_subagent(), ) runtime.labor_market.add_dynamic_subagent(subagent_spec.name, subagent) return Agent( name=agent_spec.name, system_prompt=system_prompt, toolset=toolset, runtime=runtime, ) def _load_system_prompt( path: Path, args: dict[str, str], builtin_args: BuiltinSystemPromptArgs ) -> str: logger.info("Loading system prompt: {path}", path=path) system_prompt = path.read_text(encoding="utf-8").strip() logger.debug( "Substituting system prompt with builtin args: {builtin_args}, spec args: {spec_args}", builtin_args=builtin_args, spec_args=args, ) env = JinjaEnvironment( keep_trailing_newline=True, lstrip_blocks=True, trim_blocks=True, variable_start_string="${", variable_end_string="}", undefined=StrictUndefined, ) try: template = env.from_string(system_prompt) return template.render(asdict(builtin_args), **args) except UndefinedError as exc: raise SystemPromptTemplateError(f"Missing system prompt arg in {path}: {exc}") from exc except TemplateError as exc: raise SystemPromptTemplateError(f"Invalid system prompt template: {path}: {exc}") from exc ================================================ FILE: src/kimi_cli/soul/approval.py ================================================ from __future__ import annotations import asyncio import uuid from collections.abc import Callable from dataclasses import dataclass from typing import Literal from kimi_cli.soul.toolset import get_current_tool_call_or_none from kimi_cli.utils.aioqueue import Queue from kimi_cli.utils.logging import logger from kimi_cli.wire.types import DisplayBlock @dataclass(frozen=True, slots=True, kw_only=True) class Request: id: str tool_call_id: str sender: str action: str description: str display: list[DisplayBlock] type Response = Literal["approve", "approve_for_session", "reject"] class ApprovalState: def __init__( self, yolo: bool = False, auto_approve_actions: set[str] | None = None, on_change: Callable[[], None] | None = None, ): self.yolo = yolo self.auto_approve_actions: set[str] = auto_approve_actions or set() """Set of action names that should automatically be approved.""" self._on_change = on_change def notify_change(self) -> None: if self._on_change is not None: self._on_change() class Approval: def __init__(self, yolo: bool = False, *, state: ApprovalState | None = None): self._request_queue = Queue[Request]() self._requests: dict[str, tuple[Request, asyncio.Future[bool]]] = {} self._state = state or ApprovalState(yolo=yolo) def share(self) -> Approval: """Create a new approval queue that shares state (yolo + auto-approve).""" return Approval(state=self._state) def set_yolo(self, yolo: bool) -> None: self._state.yolo = yolo self._state.notify_change() def is_yolo(self) -> bool: return self._state.yolo async def request( self, sender: str, action: str, description: str, display: list[DisplayBlock] | None = None, ) -> bool: """ Request approval for the given action. Intended to be called by tools. Args: sender (str): The name of the sender. action (str): The action to request approval for. This is used to identify the action for auto-approval. description (str): The description of the action. This is used to display to the user. Returns: bool: True if the action is approved, False otherwise. Raises: RuntimeError: If the approval is requested from outside a tool call. """ tool_call = get_current_tool_call_or_none() if tool_call is None: raise RuntimeError("Approval must be requested from a tool call.") logger.debug( "{tool_name} ({tool_call_id}) requesting approval: {action} {description}", tool_name=tool_call.function.name, tool_call_id=tool_call.id, action=action, description=description, ) if self._state.yolo: return True if action in self._state.auto_approve_actions: return True request = Request( id=str(uuid.uuid4()), tool_call_id=tool_call.id, sender=sender, action=action, description=description, display=display or [], ) approved_future = asyncio.Future[bool]() self._request_queue.put_nowait(request) self._requests[request.id] = (request, approved_future) return await approved_future async def fetch_request(self) -> Request: """ Fetch an approval request from the queue. Intended to be called by the soul. """ while True: request = await self._request_queue.get() if request.action in self._state.auto_approve_actions: # the action is not auto-approved when the request was created, but now it should be logger.debug( "Auto-approving previously requested action: {action}", action=request.action ) self.resolve_request(request.id, "approve") continue return request def resolve_request(self, request_id: str, response: Response) -> None: """ Resolve an approval request with the given response. Intended to be called by the soul. Args: request_id (str): The ID of the request to resolve. response (Response): The response to the request. Raises: KeyError: If there is no pending request with the given ID. """ request_tuple = self._requests.pop(request_id, None) if request_tuple is None: raise KeyError(f"No pending request with ID {request_id}") request, future = request_tuple logger.debug( "Received approval response for request {request_id}: {response}", request_id=request_id, response=response, ) match response: case "approve": future.set_result(True) case "approve_for_session": self._state.auto_approve_actions.add(request.action) self._state.notify_change() future.set_result(True) case "reject": future.set_result(False) ================================================ FILE: src/kimi_cli/soul/compaction.py ================================================ from __future__ import annotations from collections.abc import Sequence from typing import TYPE_CHECKING, NamedTuple, Protocol, runtime_checkable import kosong from kosong.chat_provider import TokenUsage from kosong.message import Message from kosong.tooling.empty import EmptyToolset import kimi_cli.prompts as prompts from kimi_cli.llm import LLM from kimi_cli.soul.message import system from kimi_cli.utils.logging import logger from kimi_cli.wire.types import ContentPart, TextPart, ThinkPart class CompactionResult(NamedTuple): messages: Sequence[Message] usage: TokenUsage | None @property def estimated_token_count(self) -> int: """Estimate the token count of the compacted messages. When LLM usage is available, ``usage.output`` gives the exact token count of the generated summary (the first message). Preserved messages (all subsequent messages) are estimated from their text length. When usage is not available (no compaction LLM call was made), all messages are estimated from text length. The estimate is intentionally conservative — it will be replaced by the real value on the next LLM call. """ if self.usage is not None and len(self.messages) > 0: summary_tokens = self.usage.output preserved_tokens = estimate_text_tokens(self.messages[1:]) return summary_tokens + preserved_tokens return estimate_text_tokens(self.messages) def estimate_text_tokens(messages: Sequence[Message]) -> int: """Estimate tokens from message text content using a character-based heuristic.""" total_chars = 0 for msg in messages: for part in msg.content: if isinstance(part, TextPart): total_chars += len(part.text) # ~4 chars per token for English; somewhat underestimates for CJK text, # but this is a temporary estimate that gets corrected on the next LLM call. return total_chars // 4 def should_auto_compact( token_count: int, max_context_size: int, *, trigger_ratio: float, reserved_context_size: int, ) -> bool: """Determine whether auto-compaction should be triggered. Returns True when either condition is met (whichever fires first): - Ratio-based: token_count >= max_context_size * trigger_ratio - Reserved-based: token_count + reserved_context_size >= max_context_size """ return ( token_count >= max_context_size * trigger_ratio or token_count + reserved_context_size >= max_context_size ) @runtime_checkable class Compaction(Protocol): async def compact( self, messages: Sequence[Message], llm: LLM, *, custom_instruction: str = "" ) -> CompactionResult: """ Compact a sequence of messages into a new sequence of messages. Args: messages (Sequence[Message]): The messages to compact. llm (LLM): The LLM to use for compaction. custom_instruction: Optional user instruction to guide compaction focus. Returns: CompactionResult: The compacted messages and token usage from the compaction LLM call. Raises: ChatProviderError: When the chat provider returns an error. """ ... if TYPE_CHECKING: def type_check(simple: SimpleCompaction): _: Compaction = simple class SimpleCompaction: def __init__(self, max_preserved_messages: int = 2) -> None: self.max_preserved_messages = max_preserved_messages async def compact( self, messages: Sequence[Message], llm: LLM, *, custom_instruction: str = "" ) -> CompactionResult: compact_message, to_preserve = self.prepare(messages, custom_instruction=custom_instruction) if compact_message is None: return CompactionResult(messages=to_preserve, usage=None) # Call kosong.step to get the compacted context # TODO: set max completion tokens logger.debug("Compacting context...") result = await kosong.step( chat_provider=llm.chat_provider, system_prompt="You are a helpful assistant that compacts conversation context.", toolset=EmptyToolset(), history=[compact_message], ) if result.usage: logger.debug( "Compaction used {input} input tokens and {output} output tokens", input=result.usage.input, output=result.usage.output, ) content: list[ContentPart] = [ system("Previous context has been compacted. Here is the compaction output:") ] compacted_msg = result.message # drop thinking parts if any content.extend(part for part in compacted_msg.content if not isinstance(part, ThinkPart)) compacted_messages: list[Message] = [Message(role="user", content=content)] compacted_messages.extend(to_preserve) return CompactionResult(messages=compacted_messages, usage=result.usage) class PrepareResult(NamedTuple): compact_message: Message | None to_preserve: Sequence[Message] def prepare( self, messages: Sequence[Message], *, custom_instruction: str = "" ) -> PrepareResult: if not messages or self.max_preserved_messages <= 0: return self.PrepareResult(compact_message=None, to_preserve=messages) history = list(messages) preserve_start_index = len(history) n_preserved = 0 for index in range(len(history) - 1, -1, -1): if history[index].role in {"user", "assistant"}: n_preserved += 1 if n_preserved == self.max_preserved_messages: preserve_start_index = index break if n_preserved < self.max_preserved_messages: return self.PrepareResult(compact_message=None, to_preserve=messages) to_compact = history[:preserve_start_index] to_preserve = history[preserve_start_index:] if not to_compact: # Let's hope this won't exceed the context size limit return self.PrepareResult(compact_message=None, to_preserve=to_preserve) # Create input message for compaction compact_message = Message(role="user", content=[]) for i, msg in enumerate(to_compact): compact_message.content.append( TextPart(text=f"## Message {i + 1}\nRole: {msg.role}\nContent:\n") ) compact_message.content.extend( part for part in msg.content if isinstance(part, TextPart) ) prompt_text = "\n" + prompts.COMPACT if custom_instruction: prompt_text += ( "\n\n**User's Custom Compaction Instruction:**\n" "The user has specifically requested the following focus during compaction. " "You MUST prioritize this instruction above the default compression priorities:\n" f"{custom_instruction}" ) compact_message.content.append(TextPart(text=prompt_text)) return self.PrepareResult(compact_message=compact_message, to_preserve=to_preserve) ================================================ FILE: src/kimi_cli/soul/context.py ================================================ from __future__ import annotations import json from collections.abc import Sequence from pathlib import Path import aiofiles import aiofiles.os from kosong.message import Message from kimi_cli.soul.message import system from kimi_cli.utils.logging import logger from kimi_cli.utils.path import next_available_rotation class Context: def __init__(self, file_backend: Path): self._file_backend = file_backend self._history: list[Message] = [] self._token_count: int = 0 self._next_checkpoint_id: int = 0 """The ID of the next checkpoint, starting from 0, incremented after each checkpoint.""" self._system_prompt: str | None = None async def restore(self) -> bool: logger.debug("Restoring context from file: {file_backend}", file_backend=self._file_backend) if self._history: logger.error("The context storage is already modified") raise RuntimeError("The context storage is already modified") if not self._file_backend.exists(): logger.debug("No context file found, skipping restoration") return False if self._file_backend.stat().st_size == 0: logger.debug("Empty context file, skipping restoration") return False async with aiofiles.open(self._file_backend, encoding="utf-8") as f: async for line in f: if not line.strip(): continue line_json = json.loads(line) if line_json["role"] == "_system_prompt": self._system_prompt = line_json["content"] continue if line_json["role"] == "_usage": self._token_count = line_json["token_count"] continue if line_json["role"] == "_checkpoint": self._next_checkpoint_id = line_json["id"] + 1 continue message = Message.model_validate(line_json) self._history.append(message) return True @property def history(self) -> Sequence[Message]: return self._history @property def token_count(self) -> int: return self._token_count @property def n_checkpoints(self) -> int: return self._next_checkpoint_id @property def system_prompt(self) -> str | None: return self._system_prompt @property def file_backend(self) -> Path: return self._file_backend async def write_system_prompt(self, prompt: str) -> None: """Write the system prompt as the first record of the context file. If the file is empty, writes it directly. If the file already has content (e.g. a legacy session without system prompt), prepends it atomically via a temporary file to avoid corruption on crash and avoid loading the entire file into memory. """ prompt_line = json.dumps({"role": "_system_prompt", "content": prompt}) + "\n" if not self._file_backend.exists() or self._file_backend.stat().st_size == 0: async with aiofiles.open(self._file_backend, "w", encoding="utf-8") as f: await f.write(prompt_line) else: tmp_path = self._file_backend.with_suffix(".tmp") async with aiofiles.open(tmp_path, "w", encoding="utf-8") as tmp_f: await tmp_f.write(prompt_line) async with aiofiles.open(self._file_backend, encoding="utf-8") as src_f: while True: chunk = await src_f.read(64 * 1024) if not chunk: break await tmp_f.write(chunk) await aiofiles.os.replace(tmp_path, self._file_backend) self._system_prompt = prompt async def checkpoint(self, add_user_message: bool): checkpoint_id = self._next_checkpoint_id self._next_checkpoint_id += 1 logger.debug("Checkpointing, ID: {id}", id=checkpoint_id) async with aiofiles.open(self._file_backend, "a", encoding="utf-8") as f: await f.write(json.dumps({"role": "_checkpoint", "id": checkpoint_id}) + "\n") if add_user_message: await self.append_message( Message(role="user", content=[system(f"CHECKPOINT {checkpoint_id}")]) ) async def revert_to(self, checkpoint_id: int): """ Revert the context to the specified checkpoint. After this, the specified checkpoint and all subsequent content will be removed from the context. File backend will be rotated. Args: checkpoint_id (int): The ID of the checkpoint to revert to. 0 is the first checkpoint. Raises: ValueError: When the checkpoint does not exist. RuntimeError: When no available rotation path is found. """ logger.debug("Reverting checkpoint, ID: {id}", id=checkpoint_id) if checkpoint_id >= self._next_checkpoint_id: logger.error("Checkpoint {checkpoint_id} does not exist", checkpoint_id=checkpoint_id) raise ValueError(f"Checkpoint {checkpoint_id} does not exist") # rotate the context file rotated_file_path = await next_available_rotation(self._file_backend) if rotated_file_path is None: logger.error("No available rotation path found") raise RuntimeError("No available rotation path found") await aiofiles.os.replace(self._file_backend, rotated_file_path) logger.debug( "Rotated context file: {rotated_file_path}", rotated_file_path=rotated_file_path ) # restore the context until the specified checkpoint self._history.clear() self._token_count = 0 self._next_checkpoint_id = 0 self._system_prompt = None async with ( aiofiles.open(rotated_file_path, encoding="utf-8") as old_file, aiofiles.open(self._file_backend, "w", encoding="utf-8") as new_file, ): async for line in old_file: if not line.strip(): continue line_json = json.loads(line) if line_json["role"] == "_checkpoint" and line_json["id"] == checkpoint_id: break await new_file.write(line) if line_json["role"] == "_system_prompt": self._system_prompt = line_json["content"] elif line_json["role"] == "_usage": self._token_count = line_json["token_count"] elif line_json["role"] == "_checkpoint": self._next_checkpoint_id = line_json["id"] + 1 else: message = Message.model_validate(line_json) self._history.append(message) async def clear(self): """ Clear the context history. This is almost equivalent to revert_to(0), but without relying on the assumption that the first checkpoint exists. File backend will be rotated. Raises: RuntimeError: When no available rotation path is found. """ logger.debug("Clearing context") # rotate the context file rotated_file_path = await next_available_rotation(self._file_backend) if rotated_file_path is None: logger.error("No available rotation path found") raise RuntimeError("No available rotation path found") await aiofiles.os.replace(self._file_backend, rotated_file_path) self._file_backend.touch() logger.debug( "Rotated context file: {rotated_file_path}", rotated_file_path=rotated_file_path ) self._history.clear() self._token_count = 0 self._next_checkpoint_id = 0 self._system_prompt = None async def append_message(self, message: Message | Sequence[Message]): logger.debug("Appending message(s) to context: {message}", message=message) messages = [message] if isinstance(message, Message) else message self._history.extend(messages) async with aiofiles.open(self._file_backend, "a", encoding="utf-8") as f: for message in messages: await f.write(message.model_dump_json(exclude_none=True) + "\n") async def update_token_count(self, token_count: int): logger.debug("Updating token count in context: {token_count}", token_count=token_count) self._token_count = token_count async with aiofiles.open(self._file_backend, "a", encoding="utf-8") as f: await f.write(json.dumps({"role": "_usage", "token_count": token_count}) + "\n") ================================================ FILE: src/kimi_cli/soul/denwarenji.py ================================================ from __future__ import annotations from pydantic import BaseModel, Field class DMail(BaseModel): message: str = Field(description="The message to send.") checkpoint_id: int = Field(description="The checkpoint to send the message back to.", ge=0) # TODO: allow restoring filesystem state to the checkpoint class DenwaRenjiError(Exception): pass class DenwaRenji: def __init__(self): self._pending_dmail: DMail | None = None self._n_checkpoints: int = 0 def send_dmail(self, dmail: DMail): """Send a D-Mail. Intended to be called by the SendDMail tool.""" if self._pending_dmail is not None: raise DenwaRenjiError("Only one D-Mail can be sent at a time") if dmail.checkpoint_id < 0: raise DenwaRenjiError("The checkpoint ID can not be negative") if dmail.checkpoint_id >= self._n_checkpoints: raise DenwaRenjiError("There is no checkpoint with the given ID") self._pending_dmail = dmail def set_n_checkpoints(self, n_checkpoints: int): """Set the number of checkpoints. Intended to be called by the soul.""" self._n_checkpoints = n_checkpoints def fetch_pending_dmail(self) -> DMail | None: """Fetch a pending D-Mail. Intended to be called by the soul.""" pending_dmail = self._pending_dmail self._pending_dmail = None return pending_dmail ================================================ FILE: src/kimi_cli/soul/dynamic_injection.py ================================================ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Sequence from dataclasses import dataclass from typing import TYPE_CHECKING from kosong.message import Message from kimi_cli.notifications import is_notification_message if TYPE_CHECKING: from kimi_cli.soul.kimisoul import KimiSoul @dataclass(frozen=True, slots=True) class DynamicInjection: """A dynamic prompt content to be injected before an LLM step.""" type: str # identifier, e.g. "plan_mode" content: str # text content (will be wrapped in tags) class DynamicInjectionProvider(ABC): """Base class for dynamic injection providers. Called before each LLM step. Implementations handle their own throttling. Providers can access all runtime state via the ``soul`` parameter (context_usage, runtime, config, etc.). """ @abstractmethod async def get_injections( self, history: Sequence[Message], soul: KimiSoul, ) -> list[DynamicInjection]: ... def normalize_history(history: Sequence[Message]) -> list[Message]: """Merge adjacent user messages to produce a clean API input sequence. Dynamic injections are stored as standalone user messages in history; normalization merges them into the adjacent user message. Only ``user`` role messages are merged. Assistant and tool messages are never merged because their ``tool_calls`` / ``tool_call_id`` fields form linked pairs that must stay intact. """ if not history: return [] result: list[Message] = [] for msg in history: if ( result and result[-1].role == msg.role and msg.role == "user" and not is_notification_message(result[-1]) and not is_notification_message(msg) ): merged_content = list(result[-1].content) + list(msg.content) result[-1] = Message(role="user", content=merged_content) else: result.append(msg) return result ================================================ FILE: src/kimi_cli/soul/dynamic_injections/__init__.py ================================================ ================================================ FILE: src/kimi_cli/soul/dynamic_injections/plan_mode.py ================================================ from __future__ import annotations from collections.abc import Sequence from typing import TYPE_CHECKING from kosong.message import Message, TextPart from kimi_cli.soul.dynamic_injection import DynamicInjection, DynamicInjectionProvider if TYPE_CHECKING: from kimi_cli.soul.kimisoul import KimiSoul # Inject a reminder every N assistant turns. _TURN_INTERVAL = 5 # Every N-th reminder is the full version; others are sparse. _FULL_EVERY_N = 5 class PlanModeInjectionProvider(DynamicInjectionProvider): """Periodically injects read-only reminders while plan mode is active. Throttling is inferred from history: scan backwards to the last plan mode reminder and count assistant messages in between. Only inject when the count exceeds ``_TURN_INTERVAL``. """ def __init__(self) -> None: self._inject_count: int = 0 async def get_injections( self, history: Sequence[Message], soul: KimiSoul, ) -> list[DynamicInjection]: if not soul.plan_mode: self._inject_count = 0 return [] plan_path = soul.get_plan_file_path() plan_path_str = str(plan_path) if plan_path else None plan_exists = plan_path is not None and plan_path.exists() # Manual toggles schedule a one-shot activation reminder for the next LLM step. if soul.consume_pending_plan_activation_injection(): self._inject_count = 1 # When re-entering with an existing plan, use the reentry reminder. if plan_exists: return [ DynamicInjection( type="plan_mode_reentry", content=_reentry_reminder(plan_path_str), ) ] return [ DynamicInjection( type="plan_mode", content=_full_reminder(plan_path_str, plan_exists), ) ] # Scan history backwards to find the last plan mode reminder. turns_since_last = 0 found_previous = False for msg in reversed(history): if msg.role == "user" and _has_plan_reminder(msg): found_previous = True break if msg.role == "assistant": turns_since_last += 1 # First time (no reminder in history yet) -> inject full version. if not found_previous: self._inject_count = 1 return [ DynamicInjection( type="plan_mode", content=_full_reminder(plan_path_str, plan_exists), ) ] # Not enough turns since last reminder -> skip. if turns_since_last < _TURN_INTERVAL: return [] # Inject. self._inject_count += 1 is_full = self._inject_count % _FULL_EVERY_N == 1 if is_full: content = _full_reminder(plan_path_str, plan_exists) else: content = _sparse_reminder(plan_path_str) return [DynamicInjection(type="plan_mode", content=content)] def _has_plan_reminder(msg: Message) -> bool: """Check whether a message contains a plan mode reminder. Detects by matching against stable prefixes of the actual reminder texts so changes to the reminder wording stay automatically in sync. """ keys = ( _sparse_reminder().split(".")[0], # "Plan mode still active ..." _full_reminder().split("\n")[0], # "Plan mode is active. ..." ) for part in msg.content: if isinstance(part, TextPart) and any(key in part.text for key in keys): return True return False def _full_reminder( plan_file_path: str | None = None, plan_exists: bool = False, ) -> str: lines = [ "Plan mode is active. You MUST NOT make any edits " "(with the exception of the plan file below), run non-readonly tools, " "or otherwise make changes to the system. " "This supersedes any other instructions you have received.", ] # Plan file info block if plan_file_path: lines.append("") if plan_exists: lines.append( f"Plan file: {plan_file_path} " "(exists — read first, then update it with WriteFile or StrReplaceFile)" ) else: lines.append( f"Plan file: {plan_file_path} " "(create it with WriteFile; once it exists, you can modify it with " "WriteFile or StrReplaceFile)" ) lines.append("This is the only file you are allowed to edit.") # Workflow lines.extend( [ "", "Workflow:", "1. Understand — explore the codebase with Glob, Grep, ReadFile", "2. Design — converge on the best approach; " "consider trade-offs but aim for a single recommendation", "3. Review — re-read key files to verify understanding", "4. Write Plan — modify the plan file with WriteFile or StrReplaceFile. " "Use WriteFile if the plan file does not exist yet", "5. Exit — call ExitPlanMode for user approval", ] ) # Multi-approach handling lines.extend( [ "", "## Handling multiple approaches", "Keep it focused: at most 2-3 meaningfully different approaches. " "Do NOT pad with minor variations — if one approach is clearly " "superior, just propose that one.", "When the best approach depends on user preferences, constraints, " "or context you don't have, use AskUserQuestion to clarify first. " "This helps you write a better, more targeted plan rather than " "dumping multiple options for the user to sort through.", "When you do include multiple approaches in the plan, you MUST pass them " "as the `options` parameter when calling ExitPlanMode, so the user can select which " "approach to execute at approval time.", "NEVER write multiple approaches in the plan and call ExitPlanMode without the " "`options` parameter — the user will only see Approve/Reject with no way to choose.", ] ) # Turn ending constraint + anti-pattern lines.extend( [ "", "AskUserQuestion is for clarifying missing requirements or user preferences " "that affect the plan.", "Never ask about plan approval via text or AskUserQuestion.", "Your turn must end with either AskUserQuestion " "(to clarify requirements or preferences) " "or ExitPlanMode (to request plan approval). " "Do NOT end your turn any other way.", "Do NOT use AskUserQuestion to ask about plan approval or reference " '"the plan" — the user cannot see the plan until you call ExitPlanMode.', ] ) return "\n".join(lines) def _sparse_reminder(plan_file_path: str | None = None) -> str: parts = [ "Plan mode still active (see full instructions earlier).", ] if plan_file_path: parts.append(f"Read-only except plan file ({plan_file_path}).") else: parts.append("Read-only.") parts.extend( [ "Use WriteFile or StrReplaceFile to modify the plan file. " "If it does not exist yet, create it with WriteFile first.", "Use AskUserQuestion to clarify user preferences " "when it helps you write a better plan.", "If the plan has multiple approaches, " "pass options to ExitPlanMode so the user can choose.", "End turns with AskUserQuestion (for clarifications) or ExitPlanMode (for approval).", "Never ask about plan approval via text or AskUserQuestion.", ] ) return " ".join(parts) def _reentry_reminder(plan_file_path: str | None = None) -> str: """One-shot reminder when re-entering plan mode with an existing plan.""" lines = [ "Plan mode is active. You MUST NOT make any edits " "(with the exception of the plan file below), run non-readonly tools, " "or otherwise make changes to the system. " "This supersedes any other instructions you have received.", "", "## Re-entering Plan Mode", ( f"A plan file exists at {plan_file_path} from a previous planning session." if plan_file_path else "A plan file from a previous planning session already exists." ), "Before proceeding:", "1. Read the existing plan file to understand what was previously planned", "2. Evaluate the user's current request against that plan", "3. If different task: replace the old plan with a fresh one. " "If same task: update the existing plan.", "4. You may use WriteFile or StrReplaceFile to modify the plan file. " "If the file does not exist yet, create it with WriteFile first.", "5. Use AskUserQuestion to clarify missing requirements " "or user preferences that affect the plan.", "6. Always edit the plan file before calling ExitPlanMode.", "", "Your turn must end with either AskUserQuestion (to clarify requirements) " "or ExitPlanMode (to request plan approval).", ] return "\n".join(lines) ================================================ FILE: src/kimi_cli/soul/kimisoul.py ================================================ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable, Sequence from contextlib import suppress from dataclasses import dataclass from functools import partial from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, cast import kosong import tenacity from kosong import StepResult from kosong.chat_provider import ( APIConnectionError, APIEmptyResponseError, APIStatusError, APITimeoutError, RetryableChatProvider, ) from kosong.message import Message from tenacity import RetryCallState, retry_if_exception, stop_after_attempt, wait_exponential_jitter from kimi_cli.background import build_active_task_snapshot from kimi_cli.llm import ModelCapability from kimi_cli.notifications import ( NotificationView, build_notification_message, extract_notification_ids, ) from kimi_cli.skill import Skill, read_skill_text from kimi_cli.skill.flow import Flow, FlowEdge, FlowNode, parse_choice from kimi_cli.soul import ( LLMNotSet, LLMNotSupported, MaxStepsReached, Soul, StatusSnapshot, wire_send, ) from kimi_cli.soul.agent import Agent, Runtime from kimi_cli.soul.compaction import ( CompactionResult, SimpleCompaction, estimate_text_tokens, should_auto_compact, ) from kimi_cli.soul.context import Context from kimi_cli.soul.dynamic_injection import ( DynamicInjection, DynamicInjectionProvider, normalize_history, ) from kimi_cli.soul.dynamic_injections.plan_mode import PlanModeInjectionProvider from kimi_cli.soul.message import check_message, system, system_reminder, tool_result_to_message from kimi_cli.soul.slash import registry as soul_slash_registry from kimi_cli.soul.toolset import KimiToolset from kimi_cli.tools.dmail import NAME as SendDMail_NAME from kimi_cli.tools.utils import ToolRejectedError from kimi_cli.utils.logging import logger from kimi_cli.utils.slashcmd import SlashCommand, parse_slash_command_call from kimi_cli.wire.file import WireFile from kimi_cli.wire.types import ( ApprovalRequest, ApprovalResponse, CompactionBegin, CompactionEnd, ContentPart, MCPLoadingBegin, MCPLoadingEnd, StatusUpdate, SteerInput, StepBegin, StepInterrupted, TextPart, ToolResult, TurnBegin, TurnEnd, ) if TYPE_CHECKING: def type_check(soul: KimiSoul): _: Soul = soul SKILL_COMMAND_PREFIX = "skill:" FLOW_COMMAND_PREFIX = "flow:" DEFAULT_MAX_FLOW_MOVES = 1000 type StepStopReason = Literal["no_tool_calls", "tool_rejected"] @dataclass(frozen=True, slots=True) class StepOutcome: stop_reason: StepStopReason assistant_message: Message type TurnStopReason = StepStopReason @dataclass(frozen=True, slots=True) class TurnOutcome: stop_reason: TurnStopReason final_message: Message | None step_count: int class KimiSoul: """The soul of Kimi Code CLI.""" def __init__( self, agent: Agent, *, context: Context, ): """ Initialize the soul. Args: agent (Agent): The agent to run. context (Context): The context of the agent. """ self._agent = agent self._runtime = agent.runtime self._denwa_renji = agent.runtime.denwa_renji self._approval = agent.runtime.approval self._context = context self._loop_control = agent.runtime.config.loop_control self._compaction = SimpleCompaction() # TODO: maybe configurable and composable for tool in agent.toolset.tools: if tool.name == SendDMail_NAME: self._checkpoint_with_user_message = True break else: self._checkpoint_with_user_message = False self._steer_queue: asyncio.Queue[str | list[ContentPart]] = asyncio.Queue() self._plan_mode: bool = self._runtime.session.state.plan_mode self._plan_session_id: str | None = self._runtime.session.state.plan_session_id # Pre-warm slug cache so the persisted slug survives process restarts if self._plan_session_id is not None and self._runtime.session.state.plan_slug is not None: from kimi_cli.tools.plan.heroes import seed_slug_cache seed_slug_cache(self._plan_session_id, self._runtime.session.state.plan_slug) self._pending_plan_activation_injection: bool = False if self._plan_mode: self._ensure_plan_session_id() self._injection_providers: list[DynamicInjectionProvider] = [ PlanModeInjectionProvider(), ] if self._runtime.role == "root": self._runtime.notifications.ack_ids("llm", extract_notification_ids(context.history)) # Bind plan mode state to tools that support it self._bind_plan_mode_tools() self._slash_commands = self._build_slash_commands() self._slash_command_map = self._index_slash_commands(self._slash_commands) @property def name(self) -> str: return self._agent.name @property def model_name(self) -> str: return self._runtime.llm.chat_provider.model_name if self._runtime.llm else "" @property def model_capabilities(self) -> set[ModelCapability] | None: if self._runtime.llm is None: return None return self._runtime.llm.capabilities @property def plan_mode(self) -> bool: """Whether plan mode (read-only research and planning) is active.""" return self._plan_mode def add_injection_provider(self, provider: DynamicInjectionProvider) -> None: """Register an additional dynamic injection provider.""" self._injection_providers.append(provider) async def _collect_injections(self) -> list[DynamicInjection]: """Collect dynamic injections from all registered providers.""" injections: list[DynamicInjection] = [] for provider in self._injection_providers: try: result = await provider.get_injections(self._context.history, self) injections.extend(result) except Exception: logger.warning( "injection provider %s failed", type(provider).__name__, exc_info=True, ) return injections def _bind_plan_mode_tools(self) -> None: """Bind plan mode state to tools that support it.""" if not isinstance(self._agent.toolset, KimiToolset): return def checker() -> bool: return self._plan_mode def path_getter() -> Path | None: return self.get_plan_file_path() # WriteFile gets both checker and path_getter (for plan file auto-approve) from kimi_cli.tools.file.write import WriteFile write_tool = self._agent.toolset.find("WriteFile") if isinstance(write_tool, WriteFile): write_tool.bind_plan_mode(checker, path_getter) from kimi_cli.tools.file.replace import StrReplaceFile replace_tool = self._agent.toolset.find("StrReplaceFile") if isinstance(replace_tool, StrReplaceFile): replace_tool.bind_plan_mode(checker, path_getter) # ExitPlanMode has a special bind() method from kimi_cli.tools.plan import ExitPlanMode exit_tool = self._agent.toolset.find("ExitPlanMode") if isinstance(exit_tool, ExitPlanMode): exit_tool.bind(self.toggle_plan_mode, path_getter, checker) # EnterPlanMode has a special bind() method from kimi_cli.tools.plan.enter import EnterPlanMode enter_tool = self._agent.toolset.find("EnterPlanMode") if isinstance(enter_tool, EnterPlanMode): enter_tool.bind(self.toggle_plan_mode, path_getter, checker) def _ensure_plan_session_id(self) -> None: """Allocate a stable plan session ID on first activation.""" if self._plan_session_id is None: import uuid self._plan_session_id = uuid.uuid4().hex self._runtime.session.state.plan_session_id = self._plan_session_id # Compute and persist slug immediately so the path survives process restarts from kimi_cli.tools.plan.heroes import get_or_create_slug slug = get_or_create_slug(self._plan_session_id) self._runtime.session.state.plan_slug = slug self._runtime.session.save_state() def _set_plan_mode(self, enabled: bool, *, source: Literal["manual", "tool"]) -> bool: """Update plan mode state for either manual or tool-driven toggles.""" if enabled == self._plan_mode: return self._plan_mode self._plan_mode = enabled if enabled: self._ensure_plan_session_id() self._pending_plan_activation_injection = source == "manual" else: self._pending_plan_activation_injection = False self._plan_session_id = None self._runtime.session.state.plan_session_id = None self._runtime.session.state.plan_slug = None # Persist plan mode to session state so it survives process restarts self._runtime.session.state.plan_mode = self._plan_mode self._runtime.session.save_state() return self._plan_mode def get_plan_file_path(self) -> Path | None: """Get the plan file path for the current session.""" if self._plan_session_id is None: return None from kimi_cli.tools.plan.heroes import get_plan_file_path return get_plan_file_path(self._plan_session_id) def read_current_plan(self) -> str | None: """Read the current plan file content.""" if self._plan_session_id is None: return None from kimi_cli.tools.plan.heroes import read_plan_file return read_plan_file(self._plan_session_id) def clear_current_plan(self) -> None: """Delete the current plan file.""" path = self.get_plan_file_path() if path and path.exists(): path.unlink() async def toggle_plan_mode(self) -> bool: """Toggle plan mode on/off. Returns the new state. Tools are not hidden/unhidden — instead, each tool checks plan mode state at call time and rejects if blocked. Periodic reminders are handled by the dynamic injection system. """ return self._set_plan_mode(not self._plan_mode, source="tool") async def toggle_plan_mode_from_manual(self) -> bool: """Toggle plan mode from UI/manual entry points (slash command, keybinding).""" return self._set_plan_mode(not self._plan_mode, source="manual") async def set_plan_mode_from_manual(self, enabled: bool) -> bool: """Set plan mode to a specific state from UI/manual entry points. Unlike toggle, this accepts the desired state directly, avoiding race conditions when the caller already knows the target value. """ return self._set_plan_mode(enabled, source="manual") def consume_pending_plan_activation_injection(self) -> bool: """Consume the next-step activation reminder scheduled by a manual toggle.""" if not self._plan_mode or not self._pending_plan_activation_injection: return False self._pending_plan_activation_injection = False return True @property def thinking(self) -> bool | None: """Whether thinking mode is enabled.""" if self._runtime.llm is None: return None if thinking_effort := self._runtime.llm.chat_provider.thinking_effort: return thinking_effort != "off" return None @property def status(self) -> StatusSnapshot: token_count = self._context.token_count max_size = self._runtime.llm.max_context_size if self._runtime.llm is not None else 0 return StatusSnapshot( context_usage=self._context_usage, yolo_enabled=self._approval.is_yolo(), plan_mode=self._plan_mode, context_tokens=token_count, max_context_tokens=max_size, mcp_status=self._mcp_status_snapshot(), ) @property def agent(self) -> Agent: return self._agent @property def runtime(self) -> Runtime: return self._runtime @property def context(self) -> Context: return self._context @property def _context_usage(self) -> float: if self._runtime.llm is not None: return self._context.token_count / self._runtime.llm.max_context_size return 0.0 @property def wire_file(self) -> WireFile: return self._runtime.session.wire_file def _mcp_status_snapshot(self): if not isinstance(self._agent.toolset, KimiToolset): return None return self._agent.toolset.mcp_status_snapshot() async def start_background_mcp_loading(self) -> bool: """Start deferred MCP loading, if any, without exposing toolset internals.""" if not isinstance(self._agent.toolset, KimiToolset): return False return await self._agent.toolset.start_deferred_mcp_tool_loading() async def wait_for_background_mcp_loading(self) -> None: """Wait for any in-flight MCP startup to finish.""" if not isinstance(self._agent.toolset, KimiToolset): return await self._agent.toolset.wait_for_mcp_tools() async def _checkpoint(self): await self._context.checkpoint(self._checkpoint_with_user_message) def steer(self, content: str | list[ContentPart]) -> None: """Queue a steer message for injection into the current turn.""" self._steer_queue.put_nowait(content) async def _consume_pending_steers(self) -> bool: """Drain the steer queue and inject as follow-up user messages. Returns True if any steers were consumed. """ consumed = False while not self._steer_queue.empty(): content = self._steer_queue.get_nowait() await self._inject_steer(content) wire_send(SteerInput(user_input=content)) consumed = True return consumed async def _inject_steer(self, content: str | list[ContentPart]) -> None: """Inject a single steer as a regular follow-up user message.""" parts = cast( list[ContentPart], [TextPart(text=content)] if isinstance(content, str) else list(content), ) message = Message(role="user", content=parts) if self._runtime.llm is None: raise LLMNotSet() if missing_caps := check_message(message, self._runtime.llm.capabilities): raise LLMNotSupported(self._runtime.llm, list(missing_caps)) await self._context.append_message(message) @property def available_slash_commands(self) -> list[SlashCommand[Any]]: return self._slash_commands async def run(self, user_input: str | list[ContentPart]): # Refresh OAuth tokens on each turn to avoid idle-time expirations. await self._runtime.oauth.ensure_fresh(self._runtime) wire_send(TurnBegin(user_input=user_input)) user_message = Message(role="user", content=user_input) text_input = user_message.extract_text(" ").strip() if command_call := parse_slash_command_call(text_input): command = self._find_slash_command(command_call.name) if command is None: # this should not happen actually, the shell should have filtered it out wire_send(TextPart(text=f'Unknown slash command "/{command_call.name}".')) else: ret = command.func(self, command_call.args) if isinstance(ret, Awaitable): await ret elif self._loop_control.max_ralph_iterations != 0: runner = FlowRunner.ralph_loop( user_message, self._loop_control.max_ralph_iterations, ) await runner.run(self, "") else: await self._turn(user_message) wire_send(TurnEnd()) async def _turn(self, user_message: Message) -> TurnOutcome: if self._runtime.llm is None: raise LLMNotSet() if missing_caps := check_message(user_message, self._runtime.llm.capabilities): raise LLMNotSupported(self._runtime.llm, list(missing_caps)) await self._checkpoint() # this creates the checkpoint 0 on first run await self._context.append_message(user_message) logger.debug("Appended user message to context") return await self._agent_loop() def _build_slash_commands(self) -> list[SlashCommand[Any]]: commands: list[SlashCommand[Any]] = list(soul_slash_registry.list_commands()) seen_names = {cmd.name for cmd in commands} for skill in self._runtime.skills.values(): if skill.type not in ("standard", "flow"): continue name = f"{SKILL_COMMAND_PREFIX}{skill.name}" if name in seen_names: logger.warning( "Skipping skill slash command /{name}: name already registered", name=name, ) continue commands.append( SlashCommand( name=name, func=self._make_skill_runner(skill), description=skill.description or "", aliases=[], ) ) seen_names.add(name) for skill in self._runtime.skills.values(): if skill.type != "flow": continue if skill.flow is None: logger.warning("Flow skill {name} has no flow; skipping", name=skill.name) continue command_name = f"{FLOW_COMMAND_PREFIX}{skill.name}" if command_name in seen_names: logger.warning( "Skipping prompt flow slash command /{name}: name already registered", name=command_name, ) continue runner = FlowRunner(skill.flow, name=skill.name) commands.append( SlashCommand( name=command_name, func=runner.run, description=skill.description or "", aliases=[], ) ) seen_names.add(command_name) return commands @staticmethod def _index_slash_commands( commands: list[SlashCommand[Any]], ) -> dict[str, SlashCommand[Any]]: indexed: dict[str, SlashCommand[Any]] = {} for command in commands: indexed[command.name] = command for alias in command.aliases: indexed[alias] = command return indexed def _find_slash_command(self, name: str) -> SlashCommand[Any] | None: return self._slash_command_map.get(name) def _make_skill_runner(self, skill: Skill) -> Callable[[KimiSoul, str], None | Awaitable[None]]: async def _run_skill(soul: KimiSoul, args: str, *, _skill: Skill = skill) -> None: skill_text = await read_skill_text(_skill) if skill_text is None: wire_send( TextPart(text=f'Failed to load skill "/{SKILL_COMMAND_PREFIX}{_skill.name}".') ) return extra = args.strip() if extra: skill_text = f"{skill_text}\n\nUser request:\n{extra}" await soul._turn(Message(role="user", content=skill_text)) _run_skill.__doc__ = skill.description return _run_skill async def _agent_loop(self) -> TurnOutcome: """The main agent loop for one run.""" assert self._runtime.llm is not None # Discard any stale steers from a previous turn. while not self._steer_queue.empty(): self._steer_queue.get_nowait() if isinstance(self._agent.toolset, KimiToolset): await self.start_background_mcp_loading() loading = bool((snapshot := self._mcp_status_snapshot()) and snapshot.loading) if loading: wire_send(StatusUpdate(mcp_status=snapshot)) wire_send(MCPLoadingBegin()) try: await self.wait_for_background_mcp_loading() finally: if loading: wire_send(StatusUpdate(mcp_status=self._mcp_status_snapshot())) wire_send(MCPLoadingEnd()) async def _pipe_approval_to_wire(): while True: request = await self._approval.fetch_request() # Here we decouple the wire approval request and the soul approval request. wire_request = ApprovalRequest( id=request.id, action=request.action, description=request.description, sender=request.sender, tool_call_id=request.tool_call_id, display=request.display, ) wire_send(wire_request) # We wait for the request to be resolved over the wire, which means that, # for each soul, we will have only one approval request waiting on the wire # at a time. However, be aware that subagents (which have their own souls) may # also send approval requests to the root wire. resp = await wire_request.wait() self._approval.resolve_request(request.id, resp) wire_send(ApprovalResponse(request_id=request.id, response=resp)) step_no = 0 while True: step_no += 1 if step_no > self._loop_control.max_steps_per_turn: raise MaxStepsReached(self._loop_control.max_steps_per_turn) wire_send(StepBegin(n=step_no)) approval_task = asyncio.create_task(_pipe_approval_to_wire()) back_to_the_future: BackToTheFuture | None = None step_outcome: StepOutcome | None = None try: # compact the context if needed if should_auto_compact( self._context.token_count, self._runtime.llm.max_context_size, trigger_ratio=self._loop_control.compaction_trigger_ratio, reserved_context_size=self._loop_control.reserved_context_size, ): logger.info("Context too long, compacting...") await self.compact_context() logger.debug("Beginning step {step_no}", step_no=step_no) await self._checkpoint() self._denwa_renji.set_n_checkpoints(self._context.n_checkpoints) step_outcome = await self._step() except BackToTheFuture as e: back_to_the_future = e except Exception: # any other exception should interrupt the step wire_send(StepInterrupted()) # break the agent loop raise finally: approval_task.cancel() # stop piping approval requests to the wire with suppress(asyncio.CancelledError): try: await approval_task except Exception: logger.exception("Approval piping task failed") if step_outcome is not None: has_steers = await self._consume_pending_steers() if has_steers: continue # steers injected, force another LLM step final_message = ( step_outcome.assistant_message if step_outcome.stop_reason == "no_tool_calls" else None ) return TurnOutcome( stop_reason=step_outcome.stop_reason, final_message=final_message, step_count=step_no, ) if back_to_the_future is not None: await self._context.revert_to(back_to_the_future.checkpoint_id) await self._checkpoint() await self._context.append_message(back_to_the_future.messages) # Consume any pending steers between steps await self._consume_pending_steers() async def _step(self) -> StepOutcome | None: """Run a single step and return a stop outcome, or None to continue.""" # already checked in `run` assert self._runtime.llm is not None chat_provider = self._runtime.llm.chat_provider if self._runtime.role == "root": async def _append_notification(view: NotificationView) -> None: await self._context.append_message(build_notification_message(view, self._runtime)) await self._runtime.notifications.deliver_pending( "llm", limit=4, before_claim=self._runtime.background_tasks.reconcile, on_notification=_append_notification, ) # Dynamic injection injections = await self._collect_injections() if injections: combined_reminders = "\n".join(system_reminder(inj.content).text for inj in injections) await self._context.append_message( Message( role="user", content=[TextPart(text=combined_reminders)], ) ) # Normalize: merge adjacent user messages for clean API input effective_history = normalize_history(self._context.history) async def _run_step_once() -> StepResult: # run an LLM step (may be interrupted) return await kosong.step( chat_provider, self._agent.system_prompt, self._agent.toolset, effective_history, on_message_part=wire_send, on_tool_result=wire_send, ) @tenacity.retry( retry=retry_if_exception(self._is_retryable_error), before_sleep=partial(self._retry_log, "step"), wait=wait_exponential_jitter(initial=0.3, max=5, jitter=0.5), stop=stop_after_attempt(self._loop_control.max_retries_per_step), reraise=True, ) async def _kosong_step_with_retry() -> StepResult: return await self._run_with_connection_recovery( "step", _run_step_once, chat_provider=chat_provider, ) result = await _kosong_step_with_retry() logger.debug("Got step result: {result}", result=result) status_update = StatusUpdate( token_usage=result.usage, message_id=result.id, plan_mode=self._plan_mode ) if result.usage is not None: # mark the token count for the context before the step await self._context.update_token_count(result.usage.input) snap = self.status status_update.context_usage = snap.context_usage status_update.context_tokens = snap.context_tokens status_update.max_context_tokens = snap.max_context_tokens wire_send(status_update) # wait for all tool results (may be interrupted) plan_mode_before_tools = self._plan_mode results = await result.tool_results() logger.debug("Got tool results: {results}", results=results) # If a tool (EnterPlanMode/ExitPlanMode) changed plan mode during execution, # send a corrected StatusUpdate so the client sees the up-to-date state. if self._plan_mode != plan_mode_before_tools: wire_send(StatusUpdate(plan_mode=self._plan_mode)) # shield the context manipulation from interruption await asyncio.shield(self._grow_context(result, results)) rejected = any(isinstance(result.return_value, ToolRejectedError) for result in results) if rejected: _ = self._denwa_renji.fetch_pending_dmail() return StepOutcome(stop_reason="tool_rejected", assistant_message=result.message) # handle pending D-Mail if dmail := self._denwa_renji.fetch_pending_dmail(): assert dmail.checkpoint_id >= 0, "DenwaRenji guarantees checkpoint_id >= 0" assert dmail.checkpoint_id < self._context.n_checkpoints, ( "DenwaRenji guarantees checkpoint_id < n_checkpoints" ) # raise to let the main loop take us back to the future raise BackToTheFuture( dmail.checkpoint_id, [ Message( role="user", content=[ system( "You just got a D-Mail from your future self. " "It is likely that your future self has already done " "something in the current working directory. Please read " "the D-Mail and decide what to do next. You MUST NEVER " "mention to the user about this information. " f"D-Mail content:\n\n{dmail.message.strip()}" ) ], ) ], ) if result.tool_calls: return None return StepOutcome(stop_reason="no_tool_calls", assistant_message=result.message) async def _grow_context(self, result: StepResult, tool_results: list[ToolResult]): logger.debug("Growing context with result: {result}", result=result) assert self._runtime.llm is not None tool_messages = [tool_result_to_message(tr) for tr in tool_results] for tm in tool_messages: if missing_caps := check_message(tm, self._runtime.llm.capabilities): logger.warning( "Tool result message requires unsupported capabilities: {caps}", caps=missing_caps, ) raise LLMNotSupported(self._runtime.llm, list(missing_caps)) await self._context.append_message(result.message) if result.usage is not None: await self._context.update_token_count(result.usage.total) logger.debug( "Appending tool messages to context: {tool_messages}", tool_messages=tool_messages ) await self._context.append_message(tool_messages) # token count of tool results are not available yet async def compact_context(self, custom_instruction: str = "") -> None: """ Compact the context. Raises: LLMNotSet: When the LLM is not set. ChatProviderError: When the chat provider returns an error. """ chat_provider = self._runtime.llm.chat_provider if self._runtime.llm is not None else None async def _run_compaction_once() -> CompactionResult: if self._runtime.llm is None: raise LLMNotSet() return await self._compaction.compact( self._context.history, self._runtime.llm, custom_instruction=custom_instruction ) @tenacity.retry( retry=retry_if_exception(self._is_retryable_error), before_sleep=partial(self._retry_log, "compaction"), wait=wait_exponential_jitter(initial=0.3, max=5, jitter=0.5), stop=stop_after_attempt(self._loop_control.max_retries_per_step), reraise=True, ) async def _compact_with_retry() -> CompactionResult: return await self._run_with_connection_recovery( "compaction", _run_compaction_once, chat_provider=chat_provider, ) wire_send(CompactionBegin()) compaction_result = await _compact_with_retry() await self._context.clear() await self._context.write_system_prompt(self._agent.system_prompt) await self._checkpoint() await self._context.append_message(compaction_result.messages) estimated_token_count = compaction_result.estimated_token_count if self._runtime.role == "root": active_task_snapshot = build_active_task_snapshot(self._runtime.background_tasks) if active_task_snapshot is not None: active_task_message = Message( role="user", content=[ system( "The following background tasks are still active after compaction. " "Use TaskList if you need to re-enumerate them later." ), TextPart(text=active_task_snapshot), ], ) await self._context.append_message(active_task_message) estimated_token_count += estimate_text_tokens([active_task_message]) # Estimate token count so context_usage is not reported as 0% await self._context.update_token_count(estimated_token_count) wire_send(CompactionEnd()) @staticmethod def _is_retryable_error(exception: BaseException) -> bool: if isinstance(exception, (APIConnectionError, APITimeoutError)): return not bool(getattr(exception, "_kimi_recovery_exhausted", False)) if isinstance(exception, APIEmptyResponseError): return True return isinstance(exception, APIStatusError) and exception.status_code in ( 429, # Too Many Requests 500, # Internal Server Error 502, # Bad Gateway 503, # Service Unavailable ) async def _run_with_connection_recovery( self, name: str, operation: Callable[[], Awaitable[Any]], *, chat_provider: object | None = None, ) -> Any: try: return await operation() except (APIConnectionError, APITimeoutError) as error: if not isinstance(chat_provider, RetryableChatProvider): raise try: recovered = chat_provider.on_retryable_error(error) except Exception: logger.exception( "Failed to recover chat provider during {name} after {error_type}.", name=name, error_type=type(error).__name__, ) raise if not recovered: raise logger.info( "Recovered chat provider during {name} after {error_type}; retrying once.", name=name, error_type=type(error).__name__, ) try: return await operation() except (APIConnectionError, APITimeoutError) as second_error: second_error._kimi_recovery_exhausted = True # type: ignore[attr-defined] raise @staticmethod def _retry_log(name: str, retry_state: RetryCallState): logger.info( "Retrying {name} for the {n} time. Waiting {sleep} seconds.", name=name, n=retry_state.attempt_number, sleep=retry_state.next_action.sleep if retry_state.next_action is not None else "unknown", ) class BackToTheFuture(Exception): """ Raise when we need to revert the context to a previous checkpoint. The main agent loop should catch this exception and handle it. """ def __init__(self, checkpoint_id: int, messages: Sequence[Message]): self.checkpoint_id = checkpoint_id self.messages = messages class FlowRunner: def __init__( self, flow: Flow, *, name: str | None = None, max_moves: int = DEFAULT_MAX_FLOW_MOVES, ) -> None: self._flow = flow self._name = name self._max_moves = max_moves @staticmethod def ralph_loop( user_message: Message, max_ralph_iterations: int, ) -> FlowRunner: prompt_content = list(user_message.content) prompt_text = Message(role="user", content=prompt_content).extract_text(" ").strip() total_runs = max_ralph_iterations + 1 if max_ralph_iterations < 0: total_runs = 1000000000000000 # effectively infinite nodes: dict[str, FlowNode] = { "BEGIN": FlowNode(id="BEGIN", label="BEGIN", kind="begin"), "END": FlowNode(id="END", label="END", kind="end"), } outgoing: dict[str, list[FlowEdge]] = {"BEGIN": [], "END": []} nodes["R1"] = FlowNode(id="R1", label=prompt_content, kind="task") nodes["R2"] = FlowNode( id="R2", label=( f"{prompt_text}. (You are running in an automated loop where the same " "prompt is fed repeatedly. Only choose STOP when the task is fully complete. " "Including it will stop further iterations. If you are not 100% sure, " "choose CONTINUE.)" ).strip(), kind="decision", ) outgoing["R1"] = [] outgoing["R2"] = [] outgoing["BEGIN"].append(FlowEdge(src="BEGIN", dst="R1", label=None)) outgoing["R1"].append(FlowEdge(src="R1", dst="R2", label=None)) outgoing["R2"].append(FlowEdge(src="R2", dst="R2", label="CONTINUE")) outgoing["R2"].append(FlowEdge(src="R2", dst="END", label="STOP")) flow = Flow(nodes=nodes, outgoing=outgoing, begin_id="BEGIN", end_id="END") max_moves = total_runs return FlowRunner(flow, max_moves=max_moves) async def run(self, soul: KimiSoul, args: str) -> None: if args.strip(): command = f"/{FLOW_COMMAND_PREFIX}{self._name}" if self._name else "/flow" logger.warning("Agent flow {command} ignores args: {args}", command=command, args=args) return current_id = self._flow.begin_id moves = 0 total_steps = 0 while True: node = self._flow.nodes[current_id] edges = self._flow.outgoing.get(current_id, []) if node.kind == "end": logger.info("Agent flow reached END node {node_id}", node_id=current_id) return if node.kind == "begin": if not edges: logger.error( 'Agent flow BEGIN node "{node_id}" has no outgoing edges; stopping.', node_id=node.id, ) return current_id = edges[0].dst continue if moves >= self._max_moves: raise MaxStepsReached(total_steps) next_id, steps_used = await self._execute_flow_node(soul, node, edges) total_steps += steps_used if next_id is None: return moves += 1 current_id = next_id async def _execute_flow_node( self, soul: KimiSoul, node: FlowNode, edges: list[FlowEdge], ) -> tuple[str | None, int]: if not edges: logger.error( 'Agent flow node "{node_id}" has no outgoing edges; stopping.', node_id=node.id, ) return None, 0 base_prompt = self._build_flow_prompt(node, edges) prompt = base_prompt steps_used = 0 while True: result = await self._flow_turn(soul, prompt) steps_used += result.step_count if result.stop_reason == "tool_rejected": logger.error("Agent flow stopped after tool rejection.") return None, steps_used if node.kind != "decision": return edges[0].dst, steps_used choice = ( parse_choice(result.final_message.extract_text(" ")) if result.final_message else None ) next_id = self._match_flow_edge(edges, choice) if next_id is not None: return next_id, steps_used options = ", ".join(edge.label or "" for edge in edges) logger.warning( "Agent flow invalid choice. Got: {choice}. Available: {options}.", choice=choice or "", options=options, ) prompt = ( f"{base_prompt}\n\n" "Your last response did not include a valid choice. " "Reply with one of the choices using ...." ) @staticmethod def _build_flow_prompt(node: FlowNode, edges: list[FlowEdge]) -> str | list[ContentPart]: if node.kind != "decision": return node.label if not isinstance(node.label, str): label_text = Message(role="user", content=node.label).extract_text(" ") else: label_text = node.label choices = [edge.label for edge in edges if edge.label] lines = [ label_text, "", "Available branches:", *(f"- {choice}" for choice in choices), "", "Reply with a choice using ....", ] return "\n".join(lines) @staticmethod def _match_flow_edge(edges: list[FlowEdge], choice: str | None) -> str | None: if not choice: return None for edge in edges: if edge.label == choice: return edge.dst return None @staticmethod async def _flow_turn( soul: KimiSoul, prompt: str | list[ContentPart], ) -> TurnOutcome: wire_send(TurnBegin(user_input=prompt)) res = await soul._turn(Message(role="user", content=prompt)) # type: ignore[reportPrivateUsage] wire_send(TurnEnd()) return res ================================================ FILE: src/kimi_cli/soul/message.py ================================================ from __future__ import annotations from collections.abc import Sequence from kosong.message import Message from kosong.tooling.error import ToolRuntimeError from kimi_cli.llm import ModelCapability from kimi_cli.wire.types import ( ContentPart, ImageURLPart, TextPart, ThinkPart, ToolResult, VideoURLPart, ) def system(message: str) -> ContentPart: return TextPart(text=f"{message}") def system_reminder(message: str) -> TextPart: return TextPart(text=f"\n{message}\n") def is_system_reminder_message(message: Message) -> bool: """Check whether a message is an internal system-reminder user message.""" if message.role != "user" or len(message.content) != 1: return False part = message.content[0] return isinstance(part, TextPart) and part.text.strip().startswith("") def tool_result_to_message(tool_result: ToolResult) -> Message: """Convert a tool result to a message.""" if tool_result.return_value.is_error: assert tool_result.return_value.message, "Error return value should have a message" message = tool_result.return_value.message if isinstance(tool_result.return_value, ToolRuntimeError): message += "\nThis is an unexpected error and the tool is probably not working." content: list[ContentPart] = [system(f"ERROR: {message}")] if tool_result.return_value.output: content.extend(_output_to_content_parts(tool_result.return_value.output)) else: content: list[ContentPart] = [] if tool_result.return_value.message: content.append(system(tool_result.return_value.message)) if tool_result.return_value.output: content.extend(_output_to_content_parts(tool_result.return_value.output)) if not content: content.append(system("Tool output is empty.")) return Message( role="tool", content=content, tool_call_id=tool_result.tool_call_id, ) def _output_to_content_parts( output: str | ContentPart | Sequence[ContentPart], ) -> list[ContentPart]: content: list[ContentPart] = [] match output: case str(text): if text: content.append(TextPart(text=text)) case ContentPart(): content.append(output) case _: content.extend(output) return content def check_message( message: Message, model_capabilities: set[ModelCapability] ) -> set[ModelCapability]: """Check the message content, return the missing model capabilities.""" capabilities_needed = set[ModelCapability]() for part in message.content: if isinstance(part, ImageURLPart): capabilities_needed.add("image_in") elif isinstance(part, VideoURLPart): capabilities_needed.add("video_in") elif isinstance(part, ThinkPart): capabilities_needed.add("thinking") return capabilities_needed - model_capabilities ================================================ FILE: src/kimi_cli/soul/slash.py ================================================ from __future__ import annotations import tempfile from collections.abc import Awaitable, Callable from pathlib import Path from typing import TYPE_CHECKING from kaos.path import KaosPath from kosong.message import Message import kimi_cli.prompts as prompts from kimi_cli import logger from kimi_cli.soul import wire_send from kimi_cli.soul.agent import load_agents_md from kimi_cli.soul.context import Context from kimi_cli.soul.message import system from kimi_cli.utils.export import is_sensitive_file from kimi_cli.utils.path import sanitize_cli_path, shorten_home from kimi_cli.utils.slashcmd import SlashCommandRegistry from kimi_cli.wire.types import StatusUpdate, TextPart if TYPE_CHECKING: from kimi_cli.soul.kimisoul import KimiSoul type SoulSlashCmdFunc = Callable[[KimiSoul, str], None | Awaitable[None]] """ A function that runs as a KimiSoul-level slash command. Raises: Any exception that can be raised by `Soul.run`. """ registry = SlashCommandRegistry[SoulSlashCmdFunc]() @registry.command async def init(soul: KimiSoul, args: str): """Analyze the codebase and generate an `AGENTS.md` file""" from kimi_cli.soul.kimisoul import KimiSoul with tempfile.TemporaryDirectory() as temp_dir: tmp_context = Context(file_backend=Path(temp_dir) / "context.jsonl") tmp_soul = KimiSoul(soul.agent, context=tmp_context) await tmp_soul.run(prompts.INIT) agents_md = await load_agents_md(soul.runtime.builtin_args.KIMI_WORK_DIR) system_message = system( "The user just ran `/init` slash command. " "The system has analyzed the codebase and generated an `AGENTS.md` file. " f"Latest AGENTS.md file content:\n{agents_md}" ) await soul.context.append_message(Message(role="user", content=[system_message])) @registry.command async def compact(soul: KimiSoul, args: str): """Compact the context (optionally with a custom focus, e.g. /compact keep db discussions)""" if soul.context.n_checkpoints == 0: wire_send(TextPart(text="The context is empty.")) return logger.info("Running `/compact`") await soul.compact_context(custom_instruction=args.strip()) wire_send(TextPart(text="The context has been compacted.")) snap = soul.status wire_send( StatusUpdate( context_usage=snap.context_usage, context_tokens=snap.context_tokens, max_context_tokens=snap.max_context_tokens, ) ) @registry.command(aliases=["reset"]) async def clear(soul: KimiSoul, args: str): """Clear the context""" logger.info("Running `/clear`") await soul.context.clear() await soul.context.write_system_prompt(soul.agent.system_prompt) wire_send(TextPart(text="The context has been cleared.")) snap = soul.status wire_send( StatusUpdate( context_usage=snap.context_usage, context_tokens=snap.context_tokens, max_context_tokens=snap.max_context_tokens, ) ) @registry.command async def yolo(soul: KimiSoul, args: str): """Toggle YOLO mode (auto-approve all actions)""" if soul.runtime.approval.is_yolo(): soul.runtime.approval.set_yolo(False) wire_send(TextPart(text="You only die once! Actions will require approval.")) else: soul.runtime.approval.set_yolo(True) wire_send(TextPart(text="You only live once! All actions will be auto-approved.")) @registry.command async def plan(soul: KimiSoul, args: str): """Toggle plan mode. Usage: /plan [on|off|view|clear]""" subcmd = args.strip().lower() if subcmd == "on": if not soul.plan_mode: await soul.toggle_plan_mode_from_manual() plan_path = soul.get_plan_file_path() wire_send(TextPart(text=f"Plan mode ON. Plan file: {plan_path}")) wire_send(StatusUpdate(plan_mode=soul.plan_mode)) elif subcmd == "off": if soul.plan_mode: await soul.toggle_plan_mode_from_manual() wire_send(TextPart(text="Plan mode OFF. All tools are now available.")) wire_send(StatusUpdate(plan_mode=soul.plan_mode)) elif subcmd == "view": content = soul.read_current_plan() if content: wire_send(TextPart(text=content)) else: wire_send(TextPart(text="No plan file found for this session.")) elif subcmd == "clear": soul.clear_current_plan() wire_send(TextPart(text="Plan cleared.")) else: # Default: toggle new_state = await soul.toggle_plan_mode_from_manual() if new_state: plan_path = soul.get_plan_file_path() wire_send( TextPart( text=f"Plan mode ON. Write your plan to: {plan_path}\n" "Use ExitPlanMode when done, or /plan off to exit manually." ) ) else: wire_send(TextPart(text="Plan mode OFF. All tools are now available.")) wire_send(StatusUpdate(plan_mode=soul.plan_mode)) @registry.command(name="add-dir") async def add_dir(soul: KimiSoul, args: str): """Add a directory to the workspace. Usage: /add-dir . Run without args to list added dirs""" # noqa: E501 from kaos.path import KaosPath from kimi_cli.utils.path import is_within_directory, list_directory args = sanitize_cli_path(args) if not args: if not soul.runtime.additional_dirs: wire_send(TextPart(text="No additional directories. Usage: /add-dir ")) else: lines = ["Additional directories:"] for d in soul.runtime.additional_dirs: lines.append(f" - {d}") wire_send(TextPart(text="\n".join(lines))) return path = KaosPath(args).expanduser().canonical() if not await path.exists(): wire_send(TextPart(text=f"Directory does not exist: {path}")) return if not await path.is_dir(): wire_send(TextPart(text=f"Not a directory: {path}")) return # Check if already added (exact match) if path in soul.runtime.additional_dirs: wire_send(TextPart(text=f"Directory already in workspace: {path}")) return # Check if it's within the work_dir (already accessible) work_dir = soul.runtime.builtin_args.KIMI_WORK_DIR if is_within_directory(path, work_dir): wire_send(TextPart(text=f"Directory is already within the working directory: {path}")) return # Check if it's within an already-added additional directory (redundant) for existing in soul.runtime.additional_dirs: if is_within_directory(path, existing): wire_send( TextPart( text=f"Directory is already within an added directory `{existing}`: {path}" ) ) return # Validate readability before committing any state changes try: ls_output = await list_directory(path) except OSError as e: wire_send(TextPart(text=f"Cannot read directory: {path} ({e})")) return # Add the directory (only after readability is confirmed) soul.runtime.additional_dirs.append(path) # Persist to session state soul.runtime.session.state.additional_dirs.append(str(path)) soul.runtime.session.save_state() # Inject a system message to inform the LLM about the new directory system_message = system( f"The user has added an additional directory to the workspace: `{path}`\n\n" f"Directory listing:\n```\n{ls_output}\n```\n\n" "You can now read, write, search, and glob files in this directory " "as if it were part of the working directory." ) await soul.context.append_message(Message(role="user", content=[system_message])) wire_send(TextPart(text=f"Added directory to workspace: {path}")) logger.info("Added additional directory: {path}", path=path) @registry.command async def export(soul: KimiSoul, args: str): """Export current session context to a markdown file""" from kimi_cli.utils.export import perform_export session = soul.runtime.session result = await perform_export( history=list(soul.context.history), session_id=session.id, work_dir=str(session.work_dir), token_count=soul.context.token_count, args=args, default_dir=Path(str(session.work_dir)), ) if isinstance(result, str): wire_send(TextPart(text=result)) return output, count = result display = shorten_home(KaosPath(str(output))) wire_send(TextPart(text=f"Exported {count} messages to {display}")) wire_send( TextPart( text=" Note: The exported file may contain sensitive information. " "Please be cautious when sharing it externally." ) ) @registry.command(name="import") async def import_context(soul: KimiSoul, args: str): """Import context from a file or session ID""" from kimi_cli.utils.export import perform_import target = sanitize_cli_path(args) if not target: wire_send(TextPart(text="Usage: /import ")) return session = soul.runtime.session raw_max_context_size = ( soul.runtime.llm.max_context_size if soul.runtime.llm is not None else None ) max_context_size = ( raw_max_context_size if isinstance(raw_max_context_size, int) and raw_max_context_size > 0 else None ) result = await perform_import( target=target, current_session_id=session.id, work_dir=session.work_dir, context=soul.context, max_context_size=max_context_size, ) if isinstance(result, str): wire_send(TextPart(text=result)) return source_desc, content_len = result wire_send(TextPart(text=f"Imported context from {source_desc} ({content_len} chars).")) if source_desc.startswith("file") and is_sensitive_file(Path(target).name): wire_send( TextPart( text="Warning: This file may contain secrets (API keys, tokens, credentials). " "The content is now part of your session context." ) ) ================================================ FILE: src/kimi_cli/soul/toolset.py ================================================ from __future__ import annotations import asyncio import contextlib import importlib import inspect import json from contextvars import ContextVar from dataclasses import dataclass from datetime import timedelta from typing import TYPE_CHECKING, Any, Literal, overload from kosong.tooling import ( CallableTool, CallableTool2, HandleResult, Tool, ToolError, ToolOk, Toolset, ) from kosong.tooling.error import ( ToolNotFoundError, ToolParseError, ToolRuntimeError, ) from kosong.tooling.mcp import convert_mcp_content from kosong.utils.typing import JsonType from kimi_cli import logger from kimi_cli.exception import InvalidToolError, MCPRuntimeError from kimi_cli.tools import SkipThisTool from kimi_cli.tools.utils import ToolRejectedError from kimi_cli.wire.types import ( ContentPart, MCPServerSnapshot, MCPStatusSnapshot, ToolCall, ToolCallRequest, ToolResult, ToolReturnValue, ) if TYPE_CHECKING: import fastmcp import mcp from fastmcp.client.client import CallToolResult from fastmcp.client.transports import ClientTransport from fastmcp.mcp_config import MCPConfig from kimi_cli.soul.agent import Runtime current_tool_call = ContextVar[ToolCall | None]("current_tool_call", default=None) def get_current_tool_call_or_none() -> ToolCall | None: """ Get the current tool call or None. Expect to be not None when called from a `__call__` method of a tool. """ return current_tool_call.get() type ToolType = CallableTool | CallableTool2[Any] if TYPE_CHECKING: def type_check(kimi_toolset: KimiToolset): _: Toolset = kimi_toolset class KimiToolset: def __init__(self) -> None: self._tool_dict: dict[str, ToolType] = {} self._hidden_tools: set[str] = set() self._mcp_servers: dict[str, MCPServerInfo] = {} self._mcp_loading_task: asyncio.Task[None] | None = None self._deferred_mcp_load: tuple[list[MCPConfig], Runtime] | None = None def add(self, tool: ToolType) -> None: self._tool_dict[tool.name] = tool def hide(self, tool_name: str) -> bool: """Hide a tool from the LLM tool list. Returns True if the tool exists.""" if tool_name in self._tool_dict: self._hidden_tools.add(tool_name) return True return False def unhide(self, tool_name: str) -> None: """Restore a hidden tool to the LLM tool list.""" self._hidden_tools.discard(tool_name) @overload def find(self, tool_name_or_type: str) -> ToolType | None: ... @overload def find[T: ToolType](self, tool_name_or_type: type[T]) -> T | None: ... def find(self, tool_name_or_type: str | type[ToolType]) -> ToolType | None: if isinstance(tool_name_or_type, str): return self._tool_dict.get(tool_name_or_type) else: for tool in self._tool_dict.values(): if isinstance(tool, tool_name_or_type): return tool return None @property def tools(self) -> list[Tool]: return [ tool.base for tool in self._tool_dict.values() if tool.name not in self._hidden_tools ] def handle(self, tool_call: ToolCall) -> HandleResult: token = current_tool_call.set(tool_call) try: if tool_call.function.name not in self._tool_dict: return ToolResult( tool_call_id=tool_call.id, return_value=ToolNotFoundError(tool_call.function.name), ) tool = self._tool_dict[tool_call.function.name] try: arguments: JsonType = json.loads(tool_call.function.arguments or "{}") except json.JSONDecodeError as e: return ToolResult(tool_call_id=tool_call.id, return_value=ToolParseError(str(e))) async def _call(): try: ret = await tool.call(arguments) return ToolResult(tool_call_id=tool_call.id, return_value=ret) except Exception as e: return ToolResult( tool_call_id=tool_call.id, return_value=ToolRuntimeError(str(e)) ) return asyncio.create_task(_call()) finally: current_tool_call.reset(token) def register_external_tool( self, name: str, description: str, parameters: dict[str, Any], ) -> tuple[bool, str | None]: if name in self._tool_dict: existing = self._tool_dict[name] if not isinstance(existing, WireExternalTool): return False, "tool name conflicts with existing tool" try: tool = WireExternalTool( name=name, description=description, parameters=parameters, ) except Exception as e: return False, str(e) self.add(tool) return True, None @property def mcp_servers(self) -> dict[str, MCPServerInfo]: """Get MCP servers info.""" return self._mcp_servers def mcp_status_snapshot(self) -> MCPStatusSnapshot | None: """Return a read-only snapshot of current MCP startup state.""" if not self._mcp_servers: return None servers = tuple( MCPServerSnapshot( name=name, status=info.status, tools=tuple(tool.name for tool in info.tools), ) for name, info in self._mcp_servers.items() ) return MCPStatusSnapshot( loading=self.has_pending_mcp_tools(), connected=sum(1 for server in servers if server.status == "connected"), total=len(servers), tools=sum(len(server.tools) for server in servers), servers=servers, ) def defer_mcp_tool_loading(self, mcp_configs: list[MCPConfig], runtime: Runtime) -> None: """Store MCP configs for a later background startup.""" self._deferred_mcp_load = (list(mcp_configs), runtime) def has_deferred_mcp_tools(self) -> bool: """Return True when MCP loading is configured but has not started yet.""" return self._deferred_mcp_load is not None async def start_deferred_mcp_tool_loading(self) -> bool: """Start any deferred MCP loading in the background.""" if self._deferred_mcp_load is None: return False if self._mcp_loading_task is not None or self._mcp_servers: self._deferred_mcp_load = None return False mcp_configs, runtime = self._deferred_mcp_load self._deferred_mcp_load = None await self.load_mcp_tools(mcp_configs, runtime, in_background=True) return True def load_tools(self, tool_paths: list[str], dependencies: dict[type[Any], Any]) -> None: """ Load tools from paths like `kimi_cli.tools.shell:Shell`. Raises: InvalidToolError(KimiCLIException, ValueError): When any tool cannot be loaded. """ good_tools: list[str] = [] bad_tools: list[str] = [] for tool_path in tool_paths: try: tool = self._load_tool(tool_path, dependencies) except SkipThisTool: logger.info("Skipping tool: {tool_path}", tool_path=tool_path) continue if tool: self.add(tool) good_tools.append(tool_path) else: bad_tools.append(tool_path) logger.info("Loaded tools: {good_tools}", good_tools=good_tools) if bad_tools: raise InvalidToolError(f"Invalid tools: {bad_tools}") @staticmethod def _load_tool(tool_path: str, dependencies: dict[type[Any], Any]) -> ToolType | None: logger.debug("Loading tool: {tool_path}", tool_path=tool_path) module_name, class_name = tool_path.rsplit(":", 1) try: module = importlib.import_module(module_name) except ImportError: return None tool_cls = getattr(module, class_name, None) if tool_cls is None: return None args: list[Any] = [] if "__init__" in tool_cls.__dict__: # the tool class overrides the `__init__` of base class for param in inspect.signature(tool_cls).parameters.values(): if param.kind == inspect.Parameter.KEYWORD_ONLY: # once we encounter a keyword-only parameter, we stop injecting dependencies break # all positional parameters should be dependencies to be injected if param.annotation not in dependencies: raise ValueError(f"Tool dependency not found: {param.annotation}") args.append(dependencies[param.annotation]) return tool_cls(*args) # TODO(rc): remove `in_background` parameter and always load in background async def load_mcp_tools( self, mcp_configs: list[MCPConfig], runtime: Runtime, in_background: bool = True ) -> None: """ Load MCP tools from specified MCP configs. Raises: MCPRuntimeError(KimiCLIException, RuntimeError): When any MCP server cannot be connected. """ import fastmcp from fastmcp.mcp_config import MCPConfig, RemoteMCPServer from kimi_cli.ui.shell.prompt import toast async def _check_oauth_tokens(server_url: str) -> bool: """Check if OAuth tokens exist for the server.""" try: from fastmcp.client.auth.oauth import FileTokenStorage storage = FileTokenStorage(server_url=server_url) tokens = await storage.get_tokens() return tokens is not None except Exception: return False def _toast_mcp(message: str) -> None: if in_background: toast( message, duration=10.0, topic="mcp", immediate=True, position="right", ) oauth_servers: dict[str, str] = {} async def _connect_server( server_name: str, server_info: MCPServerInfo ) -> tuple[str, Exception | None]: if server_info.status != "pending": return server_name, None server_info.status = "connecting" try: async with server_info.client as client: for tool in await client.list_tools(): server_info.tools.append( MCPTool(server_name, tool, client, runtime=runtime) ) for tool in server_info.tools: self.add(tool) server_info.status = "connected" logger.info("Connected MCP server: {server_name}", server_name=server_name) return server_name, None except Exception as e: logger.error( "Failed to connect MCP server: {server_name}, error: {error}", server_name=server_name, error=e, ) server_info.status = "failed" return server_name, e async def _connect(): _toast_mcp("connecting to mcp servers...") unauthorized_servers: dict[str, str] = {} for server_name, server_info in self._mcp_servers.items(): server_url = oauth_servers.get(server_name) if not server_url: continue if not await _check_oauth_tokens(server_url): logger.warning( "Skipping OAuth MCP server '{server_name}': not authorized. " "Run 'kimi mcp auth {server_name}' first.", server_name=server_name, ) server_info.status = "unauthorized" unauthorized_servers[server_name] = server_url tasks = [ asyncio.create_task(_connect_server(server_name, server_info)) for server_name, server_info in self._mcp_servers.items() if server_info.status == "pending" ] results = await asyncio.gather(*tasks) if tasks else [] failed_servers = {name: error for name, error in results if error is not None} for mcp_config in mcp_configs: # Skip empty MCP configs (no servers defined) if not mcp_config.mcpServers: logger.debug("Skipping empty MCP config: {mcp_config}", mcp_config=mcp_config) continue if failed_servers: _toast_mcp("mcp connection failed") raise MCPRuntimeError(f"Failed to connect MCP servers: {failed_servers}") if unauthorized_servers: _toast_mcp("mcp authorization needed") else: _toast_mcp("mcp servers connected") for mcp_config in mcp_configs: if not mcp_config.mcpServers: logger.debug("Skipping empty MCP config: {mcp_config}", mcp_config=mcp_config) continue for server_name, server_config in mcp_config.mcpServers.items(): if isinstance(server_config, RemoteMCPServer) and server_config.auth == "oauth": oauth_servers[server_name] = server_config.url client = fastmcp.Client(MCPConfig(mcpServers={server_name: server_config})) self._mcp_servers[server_name] = MCPServerInfo( status="pending", client=client, tools=[] ) if in_background: self._mcp_loading_task = asyncio.create_task(_connect()) else: await _connect() def has_pending_mcp_tools(self) -> bool: """Return True if the background MCP tool-loading task is still running.""" return self._mcp_loading_task is not None and not self._mcp_loading_task.done() async def wait_for_mcp_tools(self) -> None: """Wait for background MCP tool loading to finish.""" task = self._mcp_loading_task if not task: return try: await task finally: if self._mcp_loading_task is task and task.done(): self._mcp_loading_task = None async def cleanup(self) -> None: """Cleanup any resources held by the toolset.""" self._deferred_mcp_load = None if self._mcp_loading_task: self._mcp_loading_task.cancel() with contextlib.suppress(Exception): await self._mcp_loading_task for server_info in self._mcp_servers.values(): await server_info.client.close() @dataclass(slots=True) class MCPServerInfo: status: Literal["pending", "connecting", "connected", "failed", "unauthorized"] client: fastmcp.Client[Any] tools: list[MCPTool[Any]] class MCPTool[T: ClientTransport](CallableTool): def __init__( self, server_name: str, mcp_tool: mcp.Tool, client: fastmcp.Client[T], *, runtime: Runtime, **kwargs: Any, ): super().__init__( name=mcp_tool.name, description=( f"This is an MCP (Model Context Protocol) tool from MCP server `{server_name}`.\n\n" f"{mcp_tool.description or 'No description provided.'}" ), parameters=mcp_tool.inputSchema, **kwargs, ) self._mcp_tool = mcp_tool self._client = client self._runtime = runtime self._timeout = timedelta(milliseconds=runtime.config.mcp.client.tool_call_timeout_ms) self._action_name = f"mcp:{mcp_tool.name}" async def __call__(self, *args: Any, **kwargs: Any) -> ToolReturnValue: description = f"Call MCP tool `{self._mcp_tool.name}`." if not await self._runtime.approval.request(self.name, self._action_name, description): return ToolRejectedError() try: async with self._client as client: result = await client.call_tool( self._mcp_tool.name, kwargs, timeout=self._timeout, raise_on_error=False, ) return convert_mcp_tool_result(result) except Exception as e: # fastmcp raises `RuntimeError` on timeout and we cannot tell it from other errors exc_msg = str(e).lower() if "timeout" in exc_msg or "timed out" in exc_msg: return ToolError( message=( f"Timeout while calling MCP tool `{self._mcp_tool.name}`. " "You may explain to the user that the timeout config is set too low." ), brief="Timeout", ) raise class WireExternalTool(CallableTool): def __init__(self, *, name: str, description: str, parameters: dict[str, Any]) -> None: super().__init__( name=name, description=description or "No description provided.", parameters=parameters, ) async def __call__(self, *args: Any, **kwargs: Any) -> ToolReturnValue: tool_call = get_current_tool_call_or_none() if tool_call is None: return ToolError( message="External tool calls must be invoked from a tool call context.", brief="Invalid tool call", ) from kimi_cli.soul import get_wire_or_none wire = get_wire_or_none() if wire is None: logger.error( "Wire is not available for external tool call: {tool_name}", tool_name=self.name ) return ToolError( message="Wire is not available for external tool calls.", brief="Wire unavailable", ) external_tool_call = ToolCallRequest.from_tool_call(tool_call) wire.soul_side.send(external_tool_call) try: return await external_tool_call.wait() except asyncio.CancelledError: raise except Exception as e: logger.exception("External tool call failed: {tool_name}:", tool_name=self.name) return ToolError( message=f"External tool call failed: {e}", brief="External tool error", ) def convert_mcp_tool_result(result: CallToolResult) -> ToolReturnValue: """Convert MCP tool result to kosong tool return value. Raises: ValueError: If any content part has unsupported type or mime type. """ content: list[ContentPart] = [] for part in result.content: content.append(convert_mcp_content(part)) if result.is_error: return ToolError( output=content, message="Tool returned an error. The output may be error message or incomplete output", brief="", ) else: return ToolOk(output=content) ================================================ FILE: src/kimi_cli/tools/AGENTS.md ================================================ # Kimi Code CLI Tools ## Guidelines - Except for `Task` tool, tools should not refer to any types in `kimi_cli/wire/`. When importing things like `ToolReturnValue`, `DisplayBlock`, import from `kosong.tooling`. ================================================ FILE: src/kimi_cli/tools/__init__.py ================================================ import json from typing import cast import streamingjson # type: ignore[reportMissingTypeStubs] from kaos.path import KaosPath from kosong.utils.typing import JsonType from kimi_cli.utils.string import shorten_middle class SkipThisTool(Exception): """Raised when a tool decides to skip itself from the loading process.""" pass def extract_key_argument(json_content: str | streamingjson.Lexer, tool_name: str) -> str | None: if isinstance(json_content, streamingjson.Lexer): json_str = json_content.complete_json() else: json_str = json_content try: curr_args: JsonType = json.loads(json_str) except json.JSONDecodeError: return None if not curr_args: return None key_argument: str = "" match tool_name: case "Task": if not isinstance(curr_args, dict) or not curr_args.get("description"): return None key_argument = str(curr_args["description"]) case "CreateSubagent": if not isinstance(curr_args, dict) or not curr_args.get("name"): return None key_argument = str(curr_args["name"]) case "SendDMail": return None case "Think": if not isinstance(curr_args, dict) or not curr_args.get("thought"): return None key_argument = str(curr_args["thought"]) case "SetTodoList": return None case "Shell": if not isinstance(curr_args, dict) or not curr_args.get("command"): return None key_argument = str(curr_args["command"]) case "TaskOutput": if not isinstance(curr_args, dict) or not curr_args.get("task_id"): return None key_argument = str(curr_args["task_id"]) case "TaskList": if not isinstance(curr_args, dict): return None key_argument = "active" if curr_args.get("active_only", True) else "all" case "TaskStop": if not isinstance(curr_args, dict) or not curr_args.get("task_id"): return None key_argument = str(curr_args["task_id"]) case "ReadFile": if not isinstance(curr_args, dict) or not curr_args.get("path"): return None key_argument = _normalize_path(str(curr_args["path"])) case "ReadMediaFile": if not isinstance(curr_args, dict) or not curr_args.get("path"): return None key_argument = _normalize_path(str(curr_args["path"])) case "Glob": if not isinstance(curr_args, dict) or not curr_args.get("pattern"): return None key_argument = str(curr_args["pattern"]) case "Grep": if not isinstance(curr_args, dict) or not curr_args.get("pattern"): return None key_argument = str(curr_args["pattern"]) case "WriteFile": if not isinstance(curr_args, dict) or not curr_args.get("path"): return None key_argument = _normalize_path(str(curr_args["path"])) case "StrReplaceFile": if not isinstance(curr_args, dict) or not curr_args.get("path"): return None key_argument = _normalize_path(str(curr_args["path"])) case "SearchWeb": if not isinstance(curr_args, dict) or not curr_args.get("query"): return None key_argument = str(curr_args["query"]) case "FetchURL": if not isinstance(curr_args, dict) or not curr_args.get("url"): return None key_argument = str(curr_args["url"]) case _: if isinstance(json_content, streamingjson.Lexer): # lexer.json_content is list[str] based on streamingjson source code content: list[str] = cast(list[str], json_content.json_content) # type: ignore[reportUnknownMemberType] key_argument = "".join(content) else: key_argument = json_content key_argument = shorten_middle(key_argument, width=50) return key_argument def _normalize_path(path: str) -> str: cwd = str(KaosPath.cwd().canonical()) if path.startswith(cwd): path = path[len(cwd) :].lstrip("/\\") return path ================================================ FILE: src/kimi_cli/tools/ask_user/__init__.py ================================================ from __future__ import annotations import json import logging from pathlib import Path from typing import override from uuid import uuid4 from kosong.tooling import BriefDisplayBlock, CallableTool2, ToolError, ToolReturnValue from pydantic import BaseModel, Field from kimi_cli.soul import get_wire_or_none, wire_send from kimi_cli.soul.toolset import get_current_tool_call_or_none from kimi_cli.tools.utils import load_desc from kimi_cli.wire.types import QuestionItem, QuestionNotSupported, QuestionOption, QuestionRequest logger = logging.getLogger(__name__) NAME = "AskUserQuestion" _BASE_DESCRIPTION = load_desc(Path(__file__).parent / "description.md") class QuestionOptionParam(BaseModel): label: str = Field( description="Concise display text (1-5 words). If recommended, append '(Recommended)'." ) description: str = Field( default="", description="Brief explanation of trade-offs or implications of choosing this option.", ) class QuestionParam(BaseModel): question: str = Field(description="A specific, actionable question. End with '?'.") header: str = Field( default="", description="Short category tag (max 12 chars, e.g. 'Auth', 'Style')." ) options: list[QuestionOptionParam] = Field( description=( "2-4 meaningful, distinct options. Do NOT include an 'Other' option — " "the system adds one automatically." ), min_length=2, max_length=4, ) multi_select: bool = Field( default=False, description="Whether the user can select multiple options.", ) class Params(BaseModel): questions: list[QuestionParam] = Field( description="The questions to ask the user (1-4 questions).", min_length=1, max_length=4, ) class AskUserQuestion(CallableTool2[Params]): name: str = NAME description: str = _BASE_DESCRIPTION params: type[Params] = Params @override async def __call__(self, params: Params) -> ToolReturnValue: wire = get_wire_or_none() if wire is None: return ToolError( message="Cannot ask user questions: Wire is not available.", brief="Wire unavailable", ) tool_call = get_current_tool_call_or_none() if tool_call is None: return ToolError( message="AskUserQuestion must be called from a tool call context.", brief="Invalid context", ) questions = [ QuestionItem( question=q.question, header=q.header, options=[ QuestionOption(label=o.label, description=o.description) for o in q.options ], multi_select=q.multi_select, ) for q in params.questions ] request = QuestionRequest( id=str(uuid4()), tool_call_id=tool_call.id, questions=questions, ) wire_send(request) try: answers = await request.wait() except QuestionNotSupported: return ToolError( message=( "The connected client does not support interactive questions. " "Do NOT call this tool again. " "Ask the user directly in your text response instead." ), brief="Client unsupported", ) except Exception: logger.exception("Failed to get user response for question %s", request.id) return ToolError( message="Failed to get user response.", brief="Question failed", ) if not answers: return ToolReturnValue( is_error=False, output='{"answers": {}, "note": "User dismissed the question without answering."}', message="User dismissed the question without answering.", display=[BriefDisplayBlock(text="User dismissed")], ) formatted = json.dumps({"answers": answers}, ensure_ascii=False) return ToolReturnValue( is_error=False, output=formatted, message="User has answered.", display=[BriefDisplayBlock(text="User answered")], ) ================================================ FILE: src/kimi_cli/tools/ask_user/description.md ================================================ Use this tool when you need to ask the user questions with structured options during execution. This allows you to: 1. Collect user preferences or requirements before proceeding 2. Resolve ambiguous or underspecified instructions 3. Let the user decide between implementation approaches as you work 4. Present concrete options when multiple valid directions exist **When NOT to use:** - When you can infer the answer from context — be decisive and proceed - Trivial decisions that don't materially affect the outcome Overusing this tool interrupts the user's flow. Only use it when the user's input genuinely changes your next action. **Usage notes:** - Users always have an "Other" option for custom input — don't create one yourself - Use multi_select to allow multiple answers to be selected for a question - Keep option labels concise (1-5 words), use descriptions for trade-offs and details - Each question should have 2-4 meaningful, distinct options - You can ask 1-4 questions at a time; group related questions to minimize interruptions - If you recommend a specific option, list it first and append "(Recommended)" to its label ================================================ FILE: src/kimi_cli/tools/background/__init__.py ================================================ import time from pathlib import Path from typing import override from kosong.tooling import CallableTool2, ToolError, ToolReturnValue from pydantic import BaseModel, Field from kimi_cli.background import TaskStatus, TaskView, format_task, format_task_list, list_task_views from kimi_cli.soul.agent import Runtime from kimi_cli.soul.approval import Approval from kimi_cli.tools.display import BackgroundTaskDisplayBlock from kimi_cli.tools.utils import ToolRejectedError, load_desc TASK_OUTPUT_PREVIEW_BYTES = 32 << 10 TASK_OUTPUT_READ_HINT_LINES = 300 def _ensure_root(runtime: Runtime) -> ToolError | None: if runtime.role != "root": return ToolError( message="Background tasks can only be managed by the root agent.", brief="Background task unavailable", ) return None def _task_display(runtime: Runtime, task_id: str) -> BackgroundTaskDisplayBlock: view = runtime.background_tasks.store.merged_view(task_id) return BackgroundTaskDisplayBlock( task_id=view.spec.id, kind=view.spec.kind, status=view.runtime.status, description=view.spec.description, ) def _format_task_output( view: TaskView, *, retrieval_status: str, output: str, output_path: Path, full_output_available: bool, output_size_bytes: int, output_preview_bytes: int, output_truncated: bool, ) -> str: terminal_reason = "timed_out" if view.runtime.timed_out else view.runtime.status output_path_str = str(output_path.resolve()) lines = [ f"retrieval_status: {retrieval_status}", f"task_id: {view.spec.id}", f"kind: {view.spec.kind}", f"status: {view.runtime.status}", f"description: {view.spec.description}", ] if view.spec.command: lines.append(f"command: {view.spec.command}") lines.extend( [ f"interrupted: {str(view.runtime.interrupted).lower()}", f"timed_out: {str(view.runtime.timed_out).lower()}", f"terminal_reason: {terminal_reason}", ] ) if view.runtime.exit_code is not None: lines.append(f"exit_code: {view.runtime.exit_code}") if view.runtime.failure_reason: lines.append(f"reason: {view.runtime.failure_reason}") full_output_hint = ( ( "full_output_hint: " f'Use ReadFile(path="{output_path_str}", line_offset=1, ' f"n_lines={TASK_OUTPUT_READ_HINT_LINES}) to inspect the full log. " "Increase line_offset to continue paging through the file." ) if full_output_available else "full_output_hint: No output file is currently available for this task." ) lines.extend( [ "", f"output_path: {output_path_str}", f"output_size_bytes: {output_size_bytes}", f"output_preview_bytes: {output_preview_bytes}", f"output_truncated: {str(output_truncated).lower()}", "", f"full_output_available: {str(full_output_available).lower()}", "full_output_tool: ReadFile", full_output_hint, ] ) rendered_output = output or "[no output available]" if output_truncated: rendered_output = f"[Truncated. Full output: {output_path_str}]\n\n{rendered_output}" return "\n".join( lines + [ "", "[output]", rendered_output, ] ) class TaskOutputParams(BaseModel): task_id: str = Field(description="The background task ID to inspect.") block: bool = Field( default=True, description="Whether to wait for the task to finish before returning.", ) timeout: int = Field( default=30, ge=0, le=3600, description="Maximum number of seconds to wait when block=true.", ) class TaskStopParams(BaseModel): task_id: str = Field(description="The background task ID to stop.") reason: str = Field( default="Stopped by TaskStop", description="Short reason recorded when the task is stopped.", ) class TaskListParams(BaseModel): active_only: bool = Field( default=True, description="Whether to list only non-terminal background tasks.", ) limit: int = Field( default=20, ge=1, le=100, description="Maximum number of tasks to return.", ) class TaskList(CallableTool2[TaskListParams]): name: str = "TaskList" description: str = load_desc(Path(__file__).parent / "list.md") params: type[TaskListParams] = TaskListParams def __init__(self, runtime: Runtime): super().__init__() self._runtime = runtime @override async def __call__(self, params: TaskListParams) -> ToolReturnValue: if err := _ensure_root(self._runtime): return err views = list_task_views( self._runtime.background_tasks, active_only=params.active_only, limit=params.limit, ) display = [ BackgroundTaskDisplayBlock( task_id=view.spec.id, kind=view.spec.kind, status=view.runtime.status, description=view.spec.description, ) for view in views ] return ToolReturnValue( is_error=False, output=format_task_list(views, active_only=params.active_only), message="Task list retrieved.", display=list(display), ) class TaskOutput(CallableTool2[TaskOutputParams]): name: str = "TaskOutput" description: str = load_desc(Path(__file__).parent / "output.md") params: type[TaskOutputParams] = TaskOutputParams def __init__(self, runtime: Runtime): super().__init__() self._runtime = runtime def _render_output_preview( self, task_id: str, *, status: TaskStatus ) -> tuple[str, bool, int, int, bool, Path]: output_path = self._runtime.background_tasks.store.output_path(task_id) output_available = output_path.exists() try: output_size = output_path.stat().st_size except OSError: output_size = 0 preview_offset = max(0, output_size - TASK_OUTPUT_PREVIEW_BYTES) chunk = self._runtime.background_tasks.store.read_output( task_id, preview_offset, TASK_OUTPUT_PREVIEW_BYTES, status=status, ) preview_bytes = chunk.next_offset - chunk.offset preview_text = chunk.text.rstrip("\n") preview_truncated = preview_offset > 0 return ( preview_text, output_available, output_size, preview_bytes, preview_truncated, output_path, ) @override async def __call__(self, params: TaskOutputParams) -> ToolReturnValue: if err := _ensure_root(self._runtime): return err view = self._runtime.background_tasks.get_task(params.task_id) if view is None: return ToolError(message=f"Task not found: {params.task_id}", brief="Task not found") if params.block: view = await self._runtime.background_tasks.wait( params.task_id, timeout_s=params.timeout, ) retrieval_status = ( "success" if view.runtime.status in {"completed", "failed", "killed", "lost"} else "timeout" ) else: retrieval_status = ( "success" if view.runtime.status in {"completed", "failed", "killed", "lost"} else "not_ready" ) ( output, full_output_available, output_size, output_preview_bytes, output_truncated, output_path, ) = self._render_output_preview( params.task_id, status=view.runtime.status, ) consumer = view.consumer.model_copy( update={ "last_seen_output_size": output_size, "last_viewed_at": time.time(), } ) self._runtime.background_tasks.store.write_consumer(params.task_id, consumer) return ToolReturnValue( is_error=False, output=_format_task_output( view, retrieval_status=retrieval_status, output=output, output_path=output_path, full_output_available=full_output_available, output_size_bytes=output_size, output_preview_bytes=output_preview_bytes, output_truncated=output_truncated, ), message="Task output retrieved.", display=[_task_display(self._runtime, params.task_id)], ) class TaskStop(CallableTool2[TaskStopParams]): name: str = "TaskStop" description: str = load_desc(Path(__file__).parent / "stop.md") params: type[TaskStopParams] = TaskStopParams def __init__(self, runtime: Runtime, approval: Approval): super().__init__() self._runtime = runtime self._approval = approval @override async def __call__(self, params: TaskStopParams) -> ToolReturnValue: if err := _ensure_root(self._runtime): return err if self._runtime.session.state.plan_mode: return ToolError( message="TaskStop is not available in plan mode.", brief="Blocked in plan mode", ) view = self._runtime.background_tasks.get_task(params.task_id) if view is None: return ToolError(message=f"Task not found: {params.task_id}", brief="Task not found") if not await self._approval.request( self.name, "stop background task", f"Stop background task `{params.task_id}`", display=[_task_display(self._runtime, params.task_id)], ): return ToolRejectedError() view = self._runtime.background_tasks.kill( params.task_id, reason=params.reason.strip() or "Stopped by TaskStop", ) return ToolReturnValue( is_error=False, output=format_task(view, include_command=True), message="Task stop requested.", display=[_task_display(self._runtime, params.task_id)], ) ================================================ FILE: src/kimi_cli/tools/background/list.md ================================================ List background tasks from the current session. Use this when you need to re-enumerate which background tasks still exist, especially after context compaction or when you are no longer confident which task IDs are still active. Guidelines: - Prefer the default `active_only=true` unless you specifically need completed or failed tasks. - Use `TaskOutput` to inspect one task in detail after you have identified the correct task ID. - Do not guess which tasks are still running when you can call this tool directly. - This tool is read-only and safe to use in plan mode. ================================================ FILE: src/kimi_cli/tools/background/output.md ================================================ Retrieve output from a running or completed background task. Use this after `Shell(run_in_background=true)` when you need to inspect progress or explicitly wait for completion. Guidelines: - Prefer relying on automatic completion notifications. Use this tool only when you need task output before the automatic notification arrives. - Use `block=true` to wait for completion or timeout. - Use `block=false` for a non-blocking status and output check. - This tool returns structured task metadata, a fixed-size output preview, and an `output_path` for the full log. - When the preview is truncated, use `ReadFile` with the returned `output_path` to inspect the full log in pages. - This tool works with the generic background task system and should remain the primary read path for future task types, not just bash. ================================================ FILE: src/kimi_cli/tools/background/stop.md ================================================ Stop a running background task. Use this only when a background task must be cancelled. For normal task completion, prefer waiting for the automatic notification or using `TaskOutput`. Guidelines: - This is a generic task stop capability, not a bash-specific kill tool. - Use it sparingly because stopping a task is destructive and may leave partial side effects. - If the task is already complete, this tool will simply return its current state. ================================================ FILE: src/kimi_cli/tools/display.py ================================================ from typing import Literal from kosong.tooling import DisplayBlock from pydantic import BaseModel class DiffDisplayBlock(DisplayBlock): """Display block describing a file diff.""" type: str = "diff" path: str old_text: str new_text: str class TodoDisplayItem(BaseModel): title: str status: Literal["pending", "in_progress", "done"] class TodoDisplayBlock(DisplayBlock): """Display block describing a todo list update.""" type: str = "todo" items: list[TodoDisplayItem] class ShellDisplayBlock(DisplayBlock): """Display block describing a shell command.""" type: str = "shell" language: str command: str class BackgroundTaskDisplayBlock(DisplayBlock): """Display block describing a background task.""" type: str = "background_task" task_id: str kind: str status: str description: str ================================================ FILE: src/kimi_cli/tools/dmail/__init__.py ================================================ from pathlib import Path from typing import override from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnValue from kimi_cli.soul.denwarenji import DenwaRenji, DenwaRenjiError, DMail from kimi_cli.tools.utils import load_desc NAME = "SendDMail" class SendDMail(CallableTool2[DMail]): name: str = NAME description: str = load_desc(Path(__file__).parent / "dmail.md") params: type[DMail] = DMail def __init__(self, denwa_renji: DenwaRenji) -> None: super().__init__() self._denwa_renji = denwa_renji @override async def __call__(self, params: DMail) -> ToolReturnValue: try: self._denwa_renji.send_dmail(params) except DenwaRenjiError as e: return ToolError( output="", message=f"Failed to send D-Mail. Error: {str(e)}", brief="Failed to send D-Mail", ) return ToolOk( output="", message=( "If you see this message, the D-Mail was NOT sent successfully. " "This may be because some other tool that needs approval was rejected." ), brief="El Psy Kongroo", ) ================================================ FILE: src/kimi_cli/tools/dmail/dmail.md ================================================ Send a message to the past, just like sending a D-Mail in Steins;Gate. This tool is provided to enable you to proactively manage the context. You can see some `user` messages with text `CHECKPOINT {checkpoint_id}` wrapped in `` tags in the context. When you feel there is too much irrelevant information in the current context, you can send a D-Mail to revert the context to a previous checkpoint with a message containing only the useful information. When you send a D-Mail, you must specify an existing checkpoint ID from the before-mentioned messages. Typical scenarios you may want to send a D-Mail: - You read a file, found it very large and most of the content is not relevant to the current task. In this case you can send a D-Mail immediately to the checkpoint before you read the file and give your past self only the useful part. - You searched the web, the result is large. - If you got what you need, you may send a D-Mail to the checkpoint before you searched the web and put only the useful result in the mail message. - If you did not get what you need, you may send a D-Mail to tell your past self to try another query. - You wrote some code and it did not work as expected. You spent many struggling steps to fix it but the process is not relevant to the ultimate goal. In this case you can send a D-Mail to the checkpoint before you wrote the code and give your past self the fixed version of the code and tell yourself no need to write it again because you already wrote to the filesystem. After a D-Mail is sent, the system will revert the current context to the specified checkpoint, after which, you will no longer see any messages which you can now see after that checkpoint. The message in the D-Mail will be appended to the end of the context. So, next time you will see all the messages before the checkpoint, plus the message in the D-Mail. You must make it very clear in the message, tell your past self what you have done/changed, what you have learned and any other information that may be useful, so that your past self can continue the task without confusion and will not repeat the steps you have already done. You must understand that, unlike D-Mail in Steins;Gate, the D-Mail you send here will not revert the filesystem or any external state. That means, you are basically folding the recent messages in your context into a single message, which can significantly reduce the waste of context window. When sending a D-Mail, DO NOT explain to the user. The user do not care about this. Just explain to your past self. ================================================ FILE: src/kimi_cli/tools/file/__init__.py ================================================ from enum import StrEnum class FileOpsWindow: """Maintains a window of file operations.""" pass class FileActions(StrEnum): READ = "read file" EDIT = "edit file" EDIT_OUTSIDE = "edit file outside of working directory" from .glob import Glob # noqa: E402 from .grep_local import Grep # noqa: E402 from .read import ReadFile # noqa: E402 from .read_media import ReadMediaFile # noqa: E402 from .replace import StrReplaceFile # noqa: E402 from .write import WriteFile # noqa: E402 __all__ = ( "ReadFile", "ReadMediaFile", "Glob", "Grep", "WriteFile", "StrReplaceFile", ) ================================================ FILE: src/kimi_cli/tools/file/glob.md ================================================ Find files and directories using glob patterns. This tool supports standard glob syntax like `*`, `?`, and `**` for recursive searches. **When to use:** - Find files matching specific patterns (e.g., all Python files: `*.py`) - Search for files recursively in subdirectories (e.g., `src/**/*.js`) - Locate configuration files (e.g., `*.config.*`, `*.json`) - Find test files (e.g., `test_*.py`, `*_test.go`) **Example patterns:** - `*.py` - All Python files in current directory - `src/**/*.js` - All JavaScript files in src directory recursively - `test_*.py` - Python test files starting with "test_" - `*.config.{js,ts}` - Config files with .js or .ts extension **Bad example patterns:** - `**`, `**/*.py` - Any pattern starting with '**' will be rejected. Because it would recursively search all directories and subdirectories, which is very likely to yield large result that exceeds your context size. Always use more specific patterns like `src/**/*.py` instead. - `node_modules/**/*.js` - Although this does not start with '**', it would still highly possible to yield large result because `node_modules` is well-known to contain too many directories and files. Avoid recursively searching in such directories, other examples include `venv`, `.venv`, `__pycache__`, `target`. If you really need to search in a dependency, use more specific patterns like `node_modules/react/src/*` instead. ================================================ FILE: src/kimi_cli/tools/file/glob.py ================================================ """Glob tool implementation.""" from pathlib import Path from typing import override from kaos.path import KaosPath from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnValue from pydantic import BaseModel, Field from kimi_cli.soul.agent import Runtime from kimi_cli.tools.utils import load_desc from kimi_cli.utils.path import is_within_workspace, list_directory MAX_MATCHES = 1000 class Params(BaseModel): pattern: str = Field(description=("Glob pattern to match files/directories.")) directory: str | None = Field( description=( "Absolute path to the directory to search in (defaults to working directory)." ), default=None, ) include_dirs: bool = Field( description="Whether to include directories in results.", default=True, ) class Glob(CallableTool2[Params]): name: str = "Glob" description: str = load_desc( Path(__file__).parent / "glob.md", { "MAX_MATCHES": str(MAX_MATCHES), }, ) params: type[Params] = Params def __init__(self, runtime: Runtime) -> None: super().__init__() self._work_dir = runtime.builtin_args.KIMI_WORK_DIR self._additional_dirs = runtime.additional_dirs async def _validate_pattern(self, pattern: str) -> ToolError | None: """Validate that the pattern is safe to use.""" if pattern.startswith("**"): ls_result = await list_directory(self._work_dir) return ToolError( output=ls_result, message=( f"Pattern `{pattern}` starts with '**' which is not allowed. " "This would recursively search all directories and may include large " "directories like `node_modules`. Use more specific patterns instead. " "For your convenience, a list of all files and directories in the " "top level of the working directory is provided below." ), brief="Unsafe pattern", ) return None async def _validate_directory(self, directory: KaosPath) -> ToolError | None: """Validate that the directory is safe to search.""" resolved_dir = directory.canonical() # Ensure the directory is within the workspace (work_dir or additional dirs) if not is_within_workspace(resolved_dir, self._work_dir, self._additional_dirs): return ToolError( message=( f"`{directory}` is outside the workspace. " "You can only search within the working directory " "and additional directories." ), brief="Directory outside workspace", ) return None @override async def __call__(self, params: Params) -> ToolReturnValue: try: # Validate pattern safety pattern_error = await self._validate_pattern(params.pattern) if pattern_error: return pattern_error dir_path = KaosPath(params.directory) if params.directory else self._work_dir if not dir_path.is_absolute(): return ToolError( message=( f"`{params.directory}` is not an absolute path. " "You must provide an absolute path to search." ), brief="Invalid directory", ) # Validate directory safety dir_error = await self._validate_directory(dir_path) if dir_error: return dir_error if not await dir_path.exists(): return ToolError( message=f"`{params.directory}` does not exist.", brief="Directory not found", ) if not await dir_path.is_dir(): return ToolError( message=f"`{params.directory}` is not a directory.", brief="Invalid directory", ) # Perform the glob search - users can use ** directly in pattern matches: list[KaosPath] = [] async for match in dir_path.glob(params.pattern): matches.append(match) # Filter out directories if not requested if not params.include_dirs: matches = [p for p in matches if await p.is_file()] # Sort for consistent output matches.sort() # Limit matches message = ( f"Found {len(matches)} matches for pattern `{params.pattern}`." if len(matches) > 0 else f"No matches found for pattern `{params.pattern}`." ) if len(matches) > MAX_MATCHES: matches = matches[:MAX_MATCHES] message += ( f" Only the first {MAX_MATCHES} matches are returned. " "You may want to use a more specific pattern." ) return ToolOk( output="\n".join(str(p.relative_to(dir_path)) for p in matches), message=message, ) except Exception as e: return ToolError( message=f"Failed to search for pattern {params.pattern}. Error: {e}", brief="Glob failed", ) ================================================ FILE: src/kimi_cli/tools/file/grep.md ================================================ A powerful search tool based-on ripgrep. **Tips:** - ALWAYS use Grep tool instead of running `grep` or `rg` command with Shell tool. - Use the ripgrep pattern syntax, not grep syntax. E.g. you need to escape braces like `\\{` to search for `{`. ================================================ FILE: src/kimi_cli/tools/file/grep_local.py ================================================ """ The local version of the Grep tool using ripgrep. Be cautious that `KaosPath` is not used in this implementation. """ import asyncio import platform import shutil import stat import tarfile import tempfile import zipfile from pathlib import Path from typing import override import aiohttp import ripgrepy # type: ignore[reportMissingTypeStubs] from kosong.tooling import CallableTool2, ToolError, ToolReturnValue from pydantic import BaseModel, Field import kimi_cli from kimi_cli.share import get_share_dir from kimi_cli.tools.utils import ToolResultBuilder, load_desc from kimi_cli.utils.aiohttp import new_client_session from kimi_cli.utils.logging import logger class Params(BaseModel): pattern: str = Field( description="The regular expression pattern to search for in file contents" ) path: str = Field( description=( "File or directory to search in. Defaults to current working directory. " "If specified, it must be an absolute path." ), default=".", ) glob: str | None = Field( description=( "Glob pattern to filter files (e.g. `*.js`, `*.{ts,tsx}`). No filter by default." ), default=None, ) output_mode: str = Field( description=( "`content`: Show matching lines (supports `-B`, `-A`, `-C`, `-n`, `head_limit`); " "`files_with_matches`: Show file paths (supports `head_limit`); " "`count_matches`: Show total number of matches. " "Defaults to `files_with_matches`." ), default="files_with_matches", ) before_context: int | None = Field( alias="-B", description=( "Number of lines to show before each match (the `-B` option). " "Requires `output_mode` to be `content`." ), default=None, ) after_context: int | None = Field( alias="-A", description=( "Number of lines to show after each match (the `-A` option). " "Requires `output_mode` to be `content`." ), default=None, ) context: int | None = Field( alias="-C", description=( "Number of lines to show before and after each match (the `-C` option). " "Requires `output_mode` to be `content`." ), default=None, ) line_number: bool = Field( alias="-n", description=( "Show line numbers in output (the `-n` option). Requires `output_mode` to be `content`." ), default=False, ) ignore_case: bool = Field( alias="-i", description="Case insensitive search (the `-i` option).", default=False, ) type: str | None = Field( description=( "File type to search. Examples: py, rust, js, ts, go, java, etc. " "More efficient than `glob` for standard file types." ), default=None, ) head_limit: int | None = Field( description=( "Limit output to first N lines, equivalent to `| head -N`. " "Works across all output modes: content (limits output lines), " "files_with_matches (limits file paths), count_matches (limits count entries). " "By default, no limit is applied." ), default=None, ) multiline: bool = Field( description=( "Enable multiline mode where `.` matches newlines and patterns can span " "lines (the `-U` and `--multiline-dotall` options). " "By default, multiline mode is disabled." ), default=False, ) RG_VERSION = "15.0.0" RG_BASE_URL = "http://cdn.kimi.com/binaries/kimi-cli/rg" _RG_DOWNLOAD_LOCK = asyncio.Lock() def _rg_binary_name() -> str: return "rg.exe" if platform.system() == "Windows" else "rg" def _find_existing_rg(bin_name: str) -> Path | None: share_bin = get_share_dir() / "bin" / bin_name if share_bin.is_file(): return share_bin assert kimi_cli.__file__ is not None local_dep = Path(kimi_cli.__file__).parent / "deps" / "bin" / bin_name if local_dep.is_file(): return local_dep system_rg = shutil.which("rg") if system_rg: return Path(system_rg) return None def _detect_target() -> str | None: sys_name = platform.system() mach = platform.machine().lower() if mach in ("x86_64", "amd64"): arch = "x86_64" elif mach in ("arm64", "aarch64"): arch = "aarch64" else: logger.error("Unsupported architecture for ripgrep: {mach}", mach=mach) return None if sys_name == "Darwin": os_name = "apple-darwin" elif sys_name == "Linux": os_name = "unknown-linux-musl" if arch == "x86_64" else "unknown-linux-gnu" elif sys_name == "Windows": os_name = "pc-windows-msvc" else: logger.error("Unsupported operating system for ripgrep: {sys_name}", sys_name=sys_name) return None return f"{arch}-{os_name}" async def _download_and_install_rg(bin_name: str) -> Path: target = _detect_target() if not target: raise RuntimeError("Unsupported platform for ripgrep download") is_windows = "windows" in target archive_ext = "zip" if is_windows else "tar.gz" filename = f"ripgrep-{RG_VERSION}-{target}.{archive_ext}" url = f"{RG_BASE_URL}/{filename}" logger.info("Downloading ripgrep from {url}", url=url) share_bin_dir = get_share_dir() / "bin" share_bin_dir.mkdir(parents=True, exist_ok=True) destination = share_bin_dir / bin_name async with new_client_session() as session: with tempfile.TemporaryDirectory(prefix="kimi-rg-") as tmpdir: tar_path = Path(tmpdir) / filename try: async with session.get(url) as resp: resp.raise_for_status() with open(tar_path, "wb") as fh: async for chunk in resp.content.iter_chunked(1024 * 64): if chunk: fh.write(chunk) except (aiohttp.ClientError, TimeoutError) as exc: raise RuntimeError("Failed to download ripgrep binary") from exc try: if is_windows: with zipfile.ZipFile(tar_path, "r") as zf: member_name = next( (name for name in zf.namelist() if Path(name).name == bin_name), None, ) if not member_name: raise RuntimeError("Ripgrep binary not found in archive") with zf.open(member_name) as source, open(destination, "wb") as dest_fh: shutil.copyfileobj(source, dest_fh) else: with tarfile.open(tar_path, "r:gz") as tar: member = next( (m for m in tar.getmembers() if Path(m.name).name == bin_name), None, ) if not member: raise RuntimeError("Ripgrep binary not found in archive") extracted = tar.extractfile(member) if not extracted: raise RuntimeError("Failed to extract ripgrep binary") with open(destination, "wb") as dest_fh: shutil.copyfileobj(extracted, dest_fh) except (zipfile.BadZipFile, tarfile.TarError, OSError) as exc: raise RuntimeError("Failed to extract ripgrep archive") from exc destination.chmod(destination.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) logger.info("Installed ripgrep to {destination}", destination=destination) return destination async def _ensure_rg_path() -> str: bin_name = _rg_binary_name() existing = _find_existing_rg(bin_name) if existing: return str(existing) async with _RG_DOWNLOAD_LOCK: existing = _find_existing_rg(bin_name) if existing: return str(existing) downloaded = await _download_and_install_rg(bin_name) return str(downloaded) class Grep(CallableTool2[Params]): name: str = "Grep" description: str = load_desc(Path(__file__).parent / "grep.md") params: type[Params] = Params @override async def __call__(self, params: Params) -> ToolReturnValue: try: builder = ToolResultBuilder() message = "" # Initialize ripgrep with pattern and path rg_path = await _ensure_rg_path() logger.debug("Using ripgrep binary: {rg_bin}", rg_bin=rg_path) rg = ripgrepy.Ripgrepy(params.pattern, params.path, rg_path=rg_path) # Apply search options if params.ignore_case: rg = rg.ignore_case() if params.multiline: rg = rg.multiline().multiline_dotall() # Content display options (only for content mode) if params.output_mode == "content": if params.before_context is not None: rg = rg.before_context(params.before_context) if params.after_context is not None: rg = rg.after_context(params.after_context) if params.context is not None: rg = rg.context(params.context) if params.line_number: rg = rg.line_number() # File filtering options if params.glob: rg = rg.glob(params.glob) if params.type: rg = rg.type_(params.type) # Set output mode if params.output_mode == "files_with_matches": rg = rg.files_with_matches() elif params.output_mode == "count_matches": rg = rg.count_matches() # Execute search result = rg.run(universal_newlines=False) # Get results output = result.as_string # Apply head limit if specified if params.head_limit is not None: lines = output.split("\n") if len(lines) > params.head_limit: lines = lines[: params.head_limit] output = "\n".join(lines) message = f"Results truncated to first {params.head_limit} lines" if params.output_mode in ["content", "files_with_matches", "count_matches"]: output += f"\n... (results truncated to {params.head_limit} lines)" if not output: return builder.ok(message="No matches found") builder.write(output) return builder.ok(message=message) except Exception as e: return ToolError( message=f"Failed to grep. Error: {str(e)}", brief="Failed to grep", ) ================================================ FILE: src/kimi_cli/tools/file/plan_mode.py ================================================ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from pathlib import Path from kaos.path import KaosPath from kosong.tooling import ToolError @dataclass(frozen=True) class PlanEditTarget: active: bool plan_path: Path | None is_plan_target: bool def inspect_plan_edit_target( path: KaosPath, *, plan_mode_checker: Callable[[], bool] | None, plan_file_path_getter: Callable[[], Path | None] | None, ) -> PlanEditTarget | ToolError: """Resolve whether a file edit is targeting the current plan artifact.""" if plan_mode_checker is None or not plan_mode_checker(): return PlanEditTarget(active=False, plan_path=None, is_plan_target=False) plan_path = plan_file_path_getter() if plan_file_path_getter is not None else None if plan_path is None: return ToolError( message="Plan mode is active, but the current plan file is unavailable.", brief="Plan file unavailable", ) canonical_plan_path = KaosPath(str(plan_path)).canonical() if str(path) != str(canonical_plan_path): return ToolError( message=( "Plan mode is active. You may only edit the current plan file: " f"`{canonical_plan_path}`." ), brief="Plan mode restriction", ) return PlanEditTarget(active=True, plan_path=plan_path, is_plan_target=True) ================================================ FILE: src/kimi_cli/tools/file/read.md ================================================ Read text content from a file. **Tips:** - Make sure you follow the description of each tool parameter. - A `` tag will be given before the read file content. - The system will notify you when there is anything wrong when reading the file. - This tool is a tool that you typically want to use in parallel. Always read multiple files in one response when possible. - This tool can only read text files. To read images or videos, use other appropriate tools. To list directories, use the Glob tool or `ls` command via the Shell tool. To read other file types, use appropriate commands via the Shell tool. - If the file doesn't exist or path is invalid, an error will be returned. - If you want to search for a certain content/pattern, prefer Grep tool over ReadFile. - Content will be returned with a line number before each line like `cat -n` format. - Use `line_offset` and `n_lines` parameters when you only need to read a part of the file. - The maximum number of lines that can be read at once is ${MAX_LINES}. - Any lines longer than ${MAX_LINE_LENGTH} characters will be truncated, ending with "...". ================================================ FILE: src/kimi_cli/tools/file/read.py ================================================ from pathlib import Path from typing import override from kaos.path import KaosPath from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnValue from pydantic import BaseModel, Field from kimi_cli.soul.agent import Runtime from kimi_cli.tools.file.utils import MEDIA_SNIFF_BYTES, detect_file_type from kimi_cli.tools.utils import load_desc, truncate_line from kimi_cli.utils.path import is_within_workspace MAX_LINES = 1000 MAX_LINE_LENGTH = 2000 MAX_BYTES = 100 << 10 # 100KB class Params(BaseModel): path: str = Field( description=( "The path to the file to read. Absolute paths are required when reading files " "outside the working directory." ) ) line_offset: int = Field( description=( "The line number to start reading from. " "By default read from the beginning of the file. " "Set this when the file is too large to read at once." ), default=1, ge=1, ) n_lines: int = Field( description=( "The number of lines to read. " f"By default read up to {MAX_LINES} lines, which is the max allowed value. " "Set this value when the file is too large to read at once." ), default=MAX_LINES, ge=1, ) class ReadFile(CallableTool2[Params]): name: str = "ReadFile" params: type[Params] = Params def __init__(self, runtime: Runtime) -> None: description = load_desc( Path(__file__).parent / "read.md", { "MAX_LINES": MAX_LINES, "MAX_LINE_LENGTH": MAX_LINE_LENGTH, "MAX_BYTES": MAX_BYTES, }, ) super().__init__(description=description) self._runtime = runtime self._work_dir = runtime.builtin_args.KIMI_WORK_DIR self._additional_dirs = runtime.additional_dirs async def _validate_path(self, path: KaosPath) -> ToolError | None: """Validate that the path is safe to read.""" resolved_path = path.canonical() if ( not is_within_workspace(resolved_path, self._work_dir, self._additional_dirs) and not path.is_absolute() ): # Outside files can only be read with absolute paths return ToolError( message=( f"`{path}` is not an absolute path. " "You must provide an absolute path to read a file " "outside the working directory." ), brief="Invalid path", ) return None @override async def __call__(self, params: Params) -> ToolReturnValue: # TODO: checks: # - check if the path may contain secrets if not params.path: return ToolError( message="File path cannot be empty.", brief="Empty file path", ) try: p = KaosPath(params.path).expanduser() if err := await self._validate_path(p): return err p = p.canonical() if not await p.exists(): return ToolError( message=f"`{params.path}` does not exist.", brief="File not found", ) if not await p.is_file(): return ToolError( message=f"`{params.path}` is not a file.", brief="Invalid path", ) header = await p.read_bytes(MEDIA_SNIFF_BYTES) file_type = detect_file_type(str(p), header=header) if file_type.kind in ("image", "video"): return ToolError( message=( f"`{params.path}` is a {file_type.kind} file. " "Use other appropriate tools to read image or video files." ), brief="Unsupported file type", ) if file_type.kind == "unknown": return ToolError( message=( f"`{params.path}` seems not readable. " "You may need to read it with proper shell commands, Python tools " "or MCP tools if available. " "If you read/operate it with Python, you MUST ensure that any " "third-party packages are installed in a virtual environment (venv)." ), brief="File not readable", ) assert params.line_offset >= 1 assert params.n_lines >= 1 lines: list[str] = [] n_bytes = 0 truncated_line_numbers: list[int] = [] max_lines_reached = False max_bytes_reached = False current_line_no = 0 async for line in p.read_lines(errors="replace"): current_line_no += 1 if current_line_no < params.line_offset: continue truncated = truncate_line(line, MAX_LINE_LENGTH) if truncated != line: truncated_line_numbers.append(current_line_no) lines.append(truncated) n_bytes += len(truncated.encode("utf-8")) if len(lines) >= params.n_lines: break if len(lines) >= MAX_LINES: max_lines_reached = True break if n_bytes >= MAX_BYTES: max_bytes_reached = True break # Format output with line numbers like `cat -n` lines_with_no: list[str] = [] for line_num, line in zip( range(params.line_offset, params.line_offset + len(lines)), lines, strict=True ): # Use 6-digit line number width, right-aligned, with tab separator lines_with_no.append(f"{line_num:6d}\t{line}") message = ( f"{len(lines)} lines read from file starting from line {params.line_offset}." if len(lines) > 0 else "No lines read from file." ) if max_lines_reached: message += f" Max {MAX_LINES} lines reached." elif max_bytes_reached: message += f" Max {MAX_BYTES} bytes reached." elif len(lines) < params.n_lines: message += " End of file reached." if truncated_line_numbers: message += f" Lines {truncated_line_numbers} were truncated." return ToolOk( output="".join(lines_with_no), # lines already contain \n, just join them message=message, ) except Exception as e: return ToolError( message=f"Failed to read {params.path}. Error: {e}", brief="Failed to read file", ) ================================================ FILE: src/kimi_cli/tools/file/read_media.md ================================================ Read media content from a file. **Tips:** - Make sure you follow the description of each tool parameter. - A `` tag will be given before the read file content. - The system will notify you when there is anything wrong when reading the file. - This tool is a tool that you typically want to use in parallel. Always read multiple files in one response when possible. - This tool can only read image or video files. To read other types of files, use the ReadFile tool. To list directories, use the Glob tool or `ls` command via the Shell tool. - If the file doesn't exist or path is invalid, an error will be returned. - The maximum size that can be read is ${MAX_MEDIA_MEGABYTES}MB. An error will be returned if the file is larger than this limit. - The media content will be returned in a form that you can directly view and understand. **Capabilities** {% if "image_in" in capabilities and "video_in" in capabilities %} - This tool supports image and video files for the current model. {% elif "image_in" in capabilities %} - This tool supports image files for the current model. - Video files are not supported by the current model. {% elif "video_in" in capabilities %} - This tool supports video files for the current model. - Image files are not supported by the current model. {% else %} - The current model does not support image or video input. {% endif %} ================================================ FILE: src/kimi_cli/tools/file/read_media.py ================================================ import base64 from io import BytesIO from pathlib import Path from typing import override from kaos.path import KaosPath from kosong.chat_provider.kimi import Kimi from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnValue from pydantic import BaseModel, Field from kimi_cli.soul.agent import Runtime from kimi_cli.tools import SkipThisTool from kimi_cli.tools.file.utils import MEDIA_SNIFF_BYTES, FileType, detect_file_type from kimi_cli.tools.utils import load_desc from kimi_cli.utils.media_tags import wrap_media_part from kimi_cli.utils.path import is_within_workspace from kimi_cli.wire.types import ImageURLPart, VideoURLPart MAX_MEDIA_MEGABYTES = 100 def _to_data_url(mime_type: str, data: bytes) -> str: encoded = base64.b64encode(data).decode("ascii") return f"data:{mime_type};base64,{encoded}" def _extract_image_size(data: bytes) -> tuple[int, int] | None: try: from PIL import Image except Exception: return None try: with Image.open(BytesIO(data)) as image: image.load() return image.size except Exception: return None class Params(BaseModel): path: str = Field( description=( "The path to the file to read. Absolute paths are required when reading files " "outside the working directory." ) ) class ReadMediaFile(CallableTool2[Params]): name: str = "ReadMediaFile" params: type[Params] = Params def __init__(self, runtime: Runtime) -> None: capabilities = runtime.llm.capabilities if runtime.llm else set[str]() if "image_in" not in capabilities and "video_in" not in capabilities: raise SkipThisTool() description = load_desc( Path(__file__).parent / "read_media.md", { "MAX_MEDIA_MEGABYTES": MAX_MEDIA_MEGABYTES, "capabilities": capabilities, }, ) super().__init__(description=description) self._runtime = runtime self._work_dir = runtime.builtin_args.KIMI_WORK_DIR self._additional_dirs = runtime.additional_dirs self._capabilities = capabilities async def _validate_path(self, path: KaosPath) -> ToolError | None: """Validate that the path is safe to read.""" resolved_path = path.canonical() if ( not is_within_workspace(resolved_path, self._work_dir, self._additional_dirs) and not path.is_absolute() ): # Outside files can only be read with absolute paths return ToolError( message=( f"`{path}` is not an absolute path. " "You must provide an absolute path to read a file " "outside the working directory." ), brief="Invalid path", ) return None async def _read_media(self, path: KaosPath, file_type: FileType) -> ToolReturnValue: assert file_type.kind in ("image", "video") media_path = str(path) stat = await path.stat() size = stat.st_size if size == 0: return ToolError( message=f"`{path}` is empty.", brief="Empty file", ) if size > (MAX_MEDIA_MEGABYTES << 20): return ToolError( message=( f"`{path}` is {size} bytes, which exceeds the max " f"{MAX_MEDIA_MEGABYTES}MB bytes for media files." ), brief="File too large", ) match file_type.kind: case "image": data = await path.read_bytes() data_url = _to_data_url(file_type.mime_type, data) part = ImageURLPart(image_url=ImageURLPart.ImageURL(url=data_url)) wrapped = wrap_media_part(part, tag="image", attrs={"path": media_path}) image_size = _extract_image_size(data) case "video": data = await path.read_bytes() if (llm := self._runtime.llm) and isinstance(llm.chat_provider, Kimi): part = await llm.chat_provider.files.upload_video( data=data, mime_type=file_type.mime_type, ) wrapped = wrap_media_part(part, tag="video", attrs={"path": media_path}) else: data_url = _to_data_url(file_type.mime_type, data) part = VideoURLPart(video_url=VideoURLPart.VideoURL(url=data_url)) wrapped = wrap_media_part(part, tag="video", attrs={"path": media_path}) image_size = None size_hint = "" if image_size: size_hint = f", original size {image_size[0]}x{image_size[1]}px" note = ( " If you need to output coordinates, output relative coordinates first and " "compute absolute coordinates using the original image size; if you generate or " "edit images/videos via commands or scripts, read the result back immediately " "before continuing." ) return ToolOk( output=wrapped, message=( f"Loaded {file_type.kind} file `{path}` " f"({file_type.mime_type}, {size} bytes{size_hint}).{note}" ), ) @override async def __call__(self, params: Params) -> ToolReturnValue: if not params.path: return ToolError( message="File path cannot be empty.", brief="Empty file path", ) try: p = KaosPath(params.path).expanduser() if err := await self._validate_path(p): return err p = p.canonical() if not await p.exists(): return ToolError( message=f"`{params.path}` does not exist.", brief="File not found", ) if not await p.is_file(): return ToolError( message=f"`{params.path}` is not a file.", brief="Invalid path", ) header = await p.read_bytes(MEDIA_SNIFF_BYTES) file_type = detect_file_type(str(p), header=header) if file_type.kind == "text": return ToolError( message=f"`{params.path}` is a text file. Use ReadFile to read text files.", brief="Unsupported file type", ) if file_type.kind == "unknown": return ToolError( message=( f"`{params.path}` seems not readable as an image or video file. " "You may need to read it with proper shell commands, Python tools " "or MCP tools if available. " "If you read/operate it with Python, you MUST ensure that any " "third-party packages are installed in a virtual environment (venv)." ), brief="File not readable", ) if file_type.kind == "image" and "image_in" not in self._capabilities: return ToolError( message=( "The current model does not support image input. " "Tell the user to use a model with image input capability." ), brief="Unsupported media type", ) if file_type.kind == "video" and "video_in" not in self._capabilities: return ToolError( message=( "The current model does not support video input. " "Tell the user to use a model with video input capability." ), brief="Unsupported media type", ) return await self._read_media(p, file_type) except Exception as e: return ToolError( message=f"Failed to read {params.path}. Error: {e}", brief="Failed to read file", ) ================================================ FILE: src/kimi_cli/tools/file/replace.md ================================================ Replace specific strings within a specified file. **Tips:** - Only use this tool on text files. - Multi-line strings are supported. - Can specify a single edit or a list of edits in one call. - You should prefer this tool over WriteFile tool and Shell `sed` command. ================================================ FILE: src/kimi_cli/tools/file/replace.py ================================================ from collections.abc import Callable from pathlib import Path from typing import override from kaos.path import KaosPath from kosong.tooling import CallableTool2, ToolError, ToolReturnValue from pydantic import BaseModel, Field from kimi_cli.soul.agent import Runtime from kimi_cli.soul.approval import Approval from kimi_cli.tools.display import DisplayBlock from kimi_cli.tools.file import FileActions from kimi_cli.tools.file.plan_mode import inspect_plan_edit_target from kimi_cli.tools.utils import ToolRejectedError, load_desc from kimi_cli.utils.diff import build_diff_blocks from kimi_cli.utils.path import is_within_workspace _BASE_DESCRIPTION = load_desc(Path(__file__).parent / "replace.md") class Edit(BaseModel): old: str = Field(description="The old string to replace. Can be multi-line.") new: str = Field(description="The new string to replace with. Can be multi-line.") replace_all: bool = Field(description="Whether to replace all occurrences.", default=False) class Params(BaseModel): path: str = Field( description=( "The path to the file to edit. Absolute paths are required when editing files " "outside the working directory." ) ) edit: Edit | list[Edit] = Field( description=( "The edit(s) to apply to the file. " "You can provide a single edit or a list of edits here." ) ) class StrReplaceFile(CallableTool2[Params]): name: str = "StrReplaceFile" description: str = _BASE_DESCRIPTION params: type[Params] = Params def __init__(self, runtime: Runtime, approval: Approval): super().__init__() self._work_dir = runtime.builtin_args.KIMI_WORK_DIR self._additional_dirs = runtime.additional_dirs self._approval = approval self._plan_mode_checker: Callable[[], bool] | None = None self._plan_file_path_getter: Callable[[], Path | None] | None = None def bind_plan_mode( self, checker: Callable[[], bool], path_getter: Callable[[], Path | None] ) -> None: """Bind plan mode state checker and plan file path getter.""" self._plan_mode_checker = checker self._plan_file_path_getter = path_getter async def _validate_path(self, path: KaosPath) -> ToolError | None: """Validate that the path is safe to edit.""" resolved_path = path.canonical() if ( not is_within_workspace(resolved_path, self._work_dir, self._additional_dirs) and not path.is_absolute() ): return ToolError( message=( f"`{path}` is not an absolute path. " "You must provide an absolute path to edit a file " "outside the working directory." ), brief="Invalid path", ) return None def _apply_edit(self, content: str, edit: Edit) -> str: """Apply a single edit to the content.""" if edit.replace_all: return content.replace(edit.old, edit.new) else: return content.replace(edit.old, edit.new, 1) @override async def __call__(self, params: Params) -> ToolReturnValue: if not params.path: return ToolError( message="File path cannot be empty.", brief="Empty file path", ) try: p = KaosPath(params.path).expanduser() if err := await self._validate_path(p): return err p = p.canonical() plan_target = inspect_plan_edit_target( p, plan_mode_checker=self._plan_mode_checker, plan_file_path_getter=self._plan_file_path_getter, ) if isinstance(plan_target, ToolError): return plan_target is_plan_file_edit = plan_target.is_plan_target if not await p.exists(): if is_plan_file_edit: return ToolError( message=( "The current plan file does not exist yet. " "Use WriteFile to create it before calling StrReplaceFile." ), brief="Plan file not created", ) return ToolError( message=f"`{params.path}` does not exist.", brief="File not found", ) if not await p.is_file(): return ToolError( message=f"`{params.path}` is not a file.", brief="Invalid path", ) # Read the file content content = await p.read_text(errors="replace") original_content = content edits = [params.edit] if isinstance(params.edit, Edit) else params.edit # Apply all edits for edit in edits: content = self._apply_edit(content, edit) # Check if any changes were made if content == original_content: return ToolError( message="No replacements were made. The old string was not found in the file.", brief="No replacements made", ) diff_blocks: list[DisplayBlock] = list( build_diff_blocks(str(p), original_content, content) ) action = ( FileActions.EDIT if is_within_workspace(p, self._work_dir, self._additional_dirs) else FileActions.EDIT_OUTSIDE ) # Plan file edits are auto-approved; all other edits need approval. if not is_plan_file_edit and not await self._approval.request( self.name, action, f"Edit file `{p}`", display=diff_blocks, ): return ToolRejectedError() # Write the modified content back to the file await p.write_text(content, errors="replace") # Count changes for success message total_replacements = 0 for edit in edits: if edit.replace_all: total_replacements += original_content.count(edit.old) else: total_replacements += 1 if edit.old in original_content else 0 return ToolReturnValue( is_error=False, output="", message=( f"File successfully edited. " f"Applied {len(edits)} edit(s) with {total_replacements} total replacement(s)." ), display=diff_blocks, ) except Exception as e: return ToolError( message=f"Failed to edit. Error: {e}", brief="Failed to edit file", ) ================================================ FILE: src/kimi_cli/tools/file/utils.py ================================================ from __future__ import annotations import mimetypes from dataclasses import dataclass from pathlib import PurePath from typing import Literal MEDIA_SNIFF_BYTES = 512 _EXTRA_MIME_TYPES = { ".avif": "image/avif", ".heic": "image/heic", ".heif": "image/heif", ".mkv": "video/x-matroska", ".m4v": "video/x-m4v", ".3gp": "video/3gpp", ".3g2": "video/3gpp2", # TypeScript files: override mimetypes default (video/mp2t for MPEG Transport Stream) ".ts": "text/typescript", ".tsx": "text/typescript", ".mts": "text/typescript", ".cts": "text/typescript", } for suffix, mime_type in _EXTRA_MIME_TYPES.items(): mimetypes.add_type(mime_type, suffix) _IMAGE_MIME_BY_SUFFIX = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".bmp": "image/bmp", ".tif": "image/tiff", ".tiff": "image/tiff", ".webp": "image/webp", ".ico": "image/x-icon", ".heic": "image/heic", ".heif": "image/heif", ".avif": "image/avif", ".svgz": "image/svg+xml", } _VIDEO_MIME_BY_SUFFIX = { ".mp4": "video/mp4", ".mkv": "video/x-matroska", ".avi": "video/x-msvideo", ".mov": "video/quicktime", ".wmv": "video/x-ms-wmv", ".webm": "video/webm", ".m4v": "video/x-m4v", ".flv": "video/x-flv", ".3gp": "video/3gpp", ".3g2": "video/3gpp2", } _TEXT_MIME_BY_SUFFIX = { ".svg": "image/svg+xml", } _ASF_HEADER = b"\x30\x26\xb2\x75\x8e\x66\xcf\x11\xa6\xd9\x00\xaa\x00\x62\xce\x6c" _FTYP_IMAGE_BRANDS = { "avif": "image/avif", "avis": "image/avif", "heic": "image/heic", "heif": "image/heif", "heix": "image/heif", "hevc": "image/heic", "mif1": "image/heif", "msf1": "image/heif", } _FTYP_VIDEO_BRANDS = { "isom": "video/mp4", "iso2": "video/mp4", "iso5": "video/mp4", "mp41": "video/mp4", "mp42": "video/mp4", "avc1": "video/mp4", "mp4v": "video/mp4", "m4v": "video/x-m4v", "qt": "video/quicktime", "3gp4": "video/3gpp", "3gp5": "video/3gpp", "3gp6": "video/3gpp", "3gp7": "video/3gpp", "3g2": "video/3gpp2", } _NON_TEXT_SUFFIXES = { ".icns", ".psd", ".ai", ".eps", # Documents / office formats ".pdf", ".doc", ".docx", ".dot", ".dotx", ".rtf", ".odt", ".xls", ".xlsx", ".xlsm", ".xlt", ".xltx", ".xltm", ".ods", ".ppt", ".pptx", ".pptm", ".pps", ".ppsx", ".odp", ".pages", ".numbers", ".key", # Archives / compressed ".zip", ".rar", ".7z", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".zst", ".lz", ".lz4", ".br", ".cab", ".ar", ".deb", ".rpm", # Audio ".mp3", ".wav", ".flac", ".ogg", ".oga", ".opus", ".aac", ".m4a", ".wma", # Fonts ".ttf", ".otf", ".woff", ".woff2", # Binaries / bundles ".exe", ".dll", ".so", ".dylib", ".bin", ".apk", ".ipa", ".jar", ".class", ".pyc", ".pyo", ".wasm", # Disk images / databases ".dmg", ".iso", ".img", ".sqlite", ".sqlite3", ".db", ".db3", } @dataclass(frozen=True) class FileType: kind: Literal["text", "image", "video", "unknown"] mime_type: str def _sniff_ftyp_brand(header: bytes) -> str | None: if len(header) < 12 or header[4:8] != b"ftyp": return None brand = header[8:12].decode("ascii", errors="ignore").lower() return brand.strip() def sniff_media_from_magic(data: bytes) -> FileType | None: header = data[:MEDIA_SNIFF_BYTES] if header.startswith(b"\x89PNG\r\n\x1a\n"): return FileType(kind="image", mime_type="image/png") if header.startswith(b"\xff\xd8\xff"): return FileType(kind="image", mime_type="image/jpeg") if header.startswith((b"GIF87a", b"GIF89a")): return FileType(kind="image", mime_type="image/gif") if header.startswith(b"BM"): return FileType(kind="image", mime_type="image/bmp") if header.startswith((b"II*\x00", b"MM\x00*")): return FileType(kind="image", mime_type="image/tiff") if header.startswith(b"\x00\x00\x01\x00"): return FileType(kind="image", mime_type="image/x-icon") if header.startswith(b"RIFF") and len(header) >= 12: chunk = header[8:12] if chunk == b"WEBP": return FileType(kind="image", mime_type="image/webp") if chunk == b"AVI ": return FileType(kind="video", mime_type="video/x-msvideo") if header.startswith(b"FLV"): return FileType(kind="video", mime_type="video/x-flv") if header.startswith(_ASF_HEADER): return FileType(kind="video", mime_type="video/x-ms-wmv") if header.startswith(b"\x1a\x45\xdf\xa3"): lowered = header.lower() if b"webm" in lowered: return FileType(kind="video", mime_type="video/webm") if b"matroska" in lowered: return FileType(kind="video", mime_type="video/x-matroska") if brand := _sniff_ftyp_brand(header): if brand in _FTYP_IMAGE_BRANDS: return FileType(kind="image", mime_type=_FTYP_IMAGE_BRANDS[brand]) if brand in _FTYP_VIDEO_BRANDS: return FileType(kind="video", mime_type=_FTYP_VIDEO_BRANDS[brand]) return None def detect_file_type(path: str | PurePath, header: bytes | None = None) -> FileType: suffix = PurePath(str(path)).suffix.lower() media_hint: FileType | None = None if suffix in _TEXT_MIME_BY_SUFFIX: media_hint = FileType(kind="text", mime_type=_TEXT_MIME_BY_SUFFIX[suffix]) elif suffix in _IMAGE_MIME_BY_SUFFIX: media_hint = FileType(kind="image", mime_type=_IMAGE_MIME_BY_SUFFIX[suffix]) elif suffix in _VIDEO_MIME_BY_SUFFIX: media_hint = FileType(kind="video", mime_type=_VIDEO_MIME_BY_SUFFIX[suffix]) else: mime_type, _ = mimetypes.guess_type(str(path)) if mime_type: if mime_type.startswith("image/"): media_hint = FileType(kind="image", mime_type=mime_type) elif mime_type.startswith("video/"): media_hint = FileType(kind="video", mime_type=mime_type) if media_hint and media_hint.kind in ("image", "video"): return media_hint if header is not None: sniffed = sniff_media_from_magic(header) if sniffed: if media_hint and sniffed.kind != media_hint.kind: return FileType(kind="unknown", mime_type="") return sniffed # NUL bytes are a strong signal of binary content. if b"\x00" in header: return FileType(kind="unknown", mime_type="") if media_hint: return media_hint if suffix in _NON_TEXT_SUFFIXES: return FileType(kind="unknown", mime_type="") return FileType(kind="text", mime_type="text/plain") ================================================ FILE: src/kimi_cli/tools/file/write.md ================================================ Write content to a file. **Tips:** - When `mode` is not specified, it defaults to `overwrite`. Always write with caution. - When the content to write is too long (e.g. > 100 lines), use this tool multiple times instead of a single call. Use `overwrite` mode at the first time, then use `append` mode after the first write. ================================================ FILE: src/kimi_cli/tools/file/write.py ================================================ from collections.abc import Callable from pathlib import Path from typing import Literal, override from kaos.path import KaosPath from kosong.tooling import CallableTool2, ToolError, ToolReturnValue from pydantic import BaseModel, Field from kimi_cli.soul.agent import Runtime from kimi_cli.soul.approval import Approval from kimi_cli.tools.display import DisplayBlock from kimi_cli.tools.file import FileActions from kimi_cli.tools.file.plan_mode import inspect_plan_edit_target from kimi_cli.tools.utils import ToolRejectedError, load_desc from kimi_cli.utils.diff import build_diff_blocks from kimi_cli.utils.path import is_within_workspace _BASE_DESCRIPTION = load_desc(Path(__file__).parent / "write.md") class Params(BaseModel): path: str = Field( description=( "The path to the file to write. Absolute paths are required when writing files " "outside the working directory." ) ) content: str = Field(description="The content to write to the file") mode: Literal["overwrite", "append"] = Field( description=( "The mode to use to write to the file. " "Two modes are supported: `overwrite` for overwriting the whole file and " "`append` for appending to the end of an existing file." ), default="overwrite", ) class WriteFile(CallableTool2[Params]): name: str = "WriteFile" description: str = _BASE_DESCRIPTION params: type[Params] = Params def __init__(self, runtime: Runtime, approval: Approval): super().__init__() self._work_dir = runtime.builtin_args.KIMI_WORK_DIR self._additional_dirs = runtime.additional_dirs self._approval = approval self._plan_mode_checker: Callable[[], bool] | None = None self._plan_file_path_getter: Callable[[], Path | None] | None = None def bind_plan_mode( self, checker: Callable[[], bool], path_getter: Callable[[], Path | None] ) -> None: """Bind plan mode state checker and plan file path getter.""" self._plan_mode_checker = checker self._plan_file_path_getter = path_getter async def _validate_path(self, path: KaosPath) -> ToolError | None: """Validate that the path is safe to write.""" resolved_path = path.canonical() if ( not is_within_workspace(resolved_path, self._work_dir, self._additional_dirs) and not path.is_absolute() ): return ToolError( message=( f"`{path}` is not an absolute path. " "You must provide an absolute path to write a file " "outside the working directory." ), brief="Invalid path", ) return None @override async def __call__(self, params: Params) -> ToolReturnValue: # TODO: checks: # - check if the path may contain secrets if not params.path: return ToolError( message="File path cannot be empty.", brief="Empty file path", ) try: p = KaosPath(params.path).expanduser() if err := await self._validate_path(p): return err p = p.canonical() plan_target = inspect_plan_edit_target( p, plan_mode_checker=self._plan_mode_checker, plan_file_path_getter=self._plan_file_path_getter, ) if isinstance(plan_target, ToolError): return plan_target is_plan_file_write = plan_target.is_plan_target if is_plan_file_write and plan_target.plan_path is not None: plan_target.plan_path.parent.mkdir(parents=True, exist_ok=True) if not await p.parent.exists(): return ToolError( message=f"`{params.path}` parent directory does not exist.", brief="Parent directory not found", ) # Validate mode parameter if params.mode not in ["overwrite", "append"]: return ToolError( message=( f"Invalid write mode: `{params.mode}`. " "Mode must be either `overwrite` or `append`." ), brief="Invalid write mode", ) file_existed = await p.exists() old_text = None if file_existed: old_text = await p.read_text(errors="replace") new_text = ( params.content if params.mode == "overwrite" else (old_text or "") + params.content ) diff_blocks: list[DisplayBlock] = list( build_diff_blocks( str(p), old_text or "", new_text, ) ) # Plan file writes are auto-approved; other writes need approval if not is_plan_file_write: action = ( FileActions.EDIT if is_within_workspace(p, self._work_dir, self._additional_dirs) else FileActions.EDIT_OUTSIDE ) # Request approval if not await self._approval.request( self.name, action, f"Write file `{p}`", display=diff_blocks, ): return ToolRejectedError() # Write content to file match params.mode: case "overwrite": await p.write_text(params.content) case "append": await p.append_text(params.content) # Get file info for success message file_size = (await p.stat()).st_size action = "overwritten" if params.mode == "overwrite" else "appended to" return ToolReturnValue( is_error=False, output="", message=(f"File successfully {action}. Current size: {file_size} bytes."), display=diff_blocks, ) except Exception as e: return ToolError( message=f"Failed to write to {params.path}. Error: {e}", brief="Failed to write file", ) ================================================ FILE: src/kimi_cli/tools/multiagent/__init__.py ================================================ from .create import CreateSubagent from .task import Task __all__ = [ "Task", "CreateSubagent", ] ================================================ FILE: src/kimi_cli/tools/multiagent/create.md ================================================ Create a custom subagent with specific system prompt and name for reuse. Usage: - Define specialized agents with custom roles and boundaries - Created agents can be referenced by name in the Task tool - Use this when you need a specific agent type not covered by predefined agents - The created agent configuration will be saved and can be used immediately Example workflow: 1. Use CreateSubagent to define a specialized agent (e.g., 'code_reviewer') 2. Use the Task tool with agent='code_reviewer' to launch the created agent ================================================ FILE: src/kimi_cli/tools/multiagent/create.py ================================================ from pathlib import Path from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnValue from pydantic import BaseModel, Field from kimi_cli.session_state import DynamicSubagentSpec from kimi_cli.soul.agent import Agent, Runtime from kimi_cli.soul.toolset import KimiToolset from kimi_cli.tools.utils import load_desc class Params(BaseModel): name: str = Field( description=( "Unique name for this agent configuration (e.g., 'summarizer', 'code_reviewer'). " "This name will be used to reference the agent in the Task tool." ) ) system_prompt: str = Field( description="System prompt defining the agent's role, capabilities, and boundaries." ) class CreateSubagent(CallableTool2[Params]): name: str = "CreateSubagent" description: str = load_desc(Path(__file__).parent / "create.md") params: type[Params] = Params def __init__(self, toolset: KimiToolset, runtime: Runtime): super().__init__() self._toolset = toolset self._runtime = runtime async def __call__(self, params: Params) -> ToolReturnValue: if params.name in self._runtime.labor_market.subagents: return ToolError( message=f"Subagent with name '{params.name}' already exists.", brief="Subagent already exists", ) subagent = Agent( name=params.name, system_prompt=params.system_prompt, toolset=self._toolset, # share the same toolset as the parent agent runtime=self._runtime.copy_for_dynamic_subagent(), ) self._runtime.labor_market.add_dynamic_subagent(params.name, subagent) # Persist dynamic subagent definition self._runtime.session.state.dynamic_subagents.append( DynamicSubagentSpec(name=params.name, system_prompt=params.system_prompt) ) self._runtime.session.save_state() return ToolOk( output="Available subagents: " + ", ".join(self._runtime.labor_market.subagents.keys()), message=f"Subagent '{params.name}' created successfully.", ) ================================================ FILE: src/kimi_cli/tools/multiagent/task.md ================================================ Spawn a subagent to perform a specific task. Subagent will be spawned with a fresh context without any history of yours. **Context Isolation** Context isolation is one of the key benefits of using subagents. By delegating tasks to subagents, you can keep your main context clean and focused on the main goal requested by the user. Here are some scenarios you may want this tool for context isolation: - You wrote some code and it did not work as expected. In this case you can spawn a subagent to fix the code, asking the subagent to return how it is fixed. This can potentially benefit because the detailed process of fixing the code may not be relevant to your main goal, and may clutter your context. - When you need some latest knowledge of a specific library, framework or technology to proceed with your task, you can spawn a subagent to search on the internet for the needed information and return to you the gathered relevant information, for example code examples, API references, etc. This can avoid ton of irrelevant search results in your own context. DO NOT directly forward the user prompt to Task tool. DO NOT simply spawn Task tool for each todo item. This will cause the user confused because the user cannot see what the subagent do. Only you can see the response from the subagent. So, only spawn subagents for very specific and narrow tasks like fixing a compilation error, or searching for a specific solution. **Parallel Multi-Tasking** Parallel multi-tasking is another key benefit of this tool. When the user request involves multiple subtasks that are independent of each other, you can use Task tool multiple times in a single response to let subagents work in parallel for you. Examples: - User requests to code, refactor or fix multiple modules/files in a project, and they can be tested independently. In this case you can spawn multiple subagents each working on a different module/file. - When you need to analyze a huge codebase (> hundreds of thousands of lines), you can spawn multiple subagents each exploring on a different part of the codebase and gather the summarized results. - When you need to search the web for multiple queries, you can spawn multiple subagents for better efficiency. **Available Subagents:** ${SUBAGENTS_MD} ================================================ FILE: src/kimi_cli/tools/multiagent/task.py ================================================ import asyncio from pathlib import Path from typing import override from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnValue from pydantic import BaseModel, Field from kimi_cli.soul import MaxStepsReached, get_wire_or_none, run_soul from kimi_cli.soul.agent import Agent, Runtime from kimi_cli.soul.context import Context from kimi_cli.soul.kimisoul import KimiSoul from kimi_cli.soul.toolset import get_current_tool_call_or_none from kimi_cli.tools.utils import load_desc from kimi_cli.utils.path import next_available_rotation from kimi_cli.wire import Wire from kimi_cli.wire.types import ( ApprovalRequest, ApprovalResponse, QuestionRequest, SubagentEvent, ToolCallRequest, WireMessage, ) # Maximum continuation attempts for task summary MAX_CONTINUE_ATTEMPTS = 1 CONTINUE_PROMPT = """ Your previous response was too brief. Please provide a more comprehensive summary that includes: 1. Specific technical details and implementations 2. Complete code examples if relevant 3. Detailed findings and analysis 4. All important information that should be aware of by the caller """.strip() class Params(BaseModel): description: str = Field(description="A short (3-5 word) description of the task") subagent_name: str = Field( description="The name of the specialized subagent to use for this task" ) prompt: str = Field( description=( "The task for the subagent to perform. " "You must provide a detailed prompt with all necessary background information " "because the subagent cannot see anything in your context." ) ) class Task(CallableTool2[Params]): name: str = "Task" params: type[Params] = Params def __init__(self, runtime: Runtime): super().__init__( description=load_desc( Path(__file__).parent / "task.md", { "SUBAGENTS_MD": "\n".join( f"- `{name}`: {desc}" for name, desc in runtime.labor_market.fixed_subagent_descs.items() ), }, ), ) self._labor_market = runtime.labor_market self._session = runtime.session async def _get_subagent_context_file(self) -> Path: """Generate a unique context file path for subagent.""" main_context_file = self._session.context_file subagent_base_name = f"{main_context_file.stem}_sub" main_context_file.parent.mkdir(parents=True, exist_ok=True) # just in case sub_context_file = await next_available_rotation( main_context_file.parent / f"{subagent_base_name}{main_context_file.suffix}" ) assert sub_context_file is not None return sub_context_file @override async def __call__(self, params: Params) -> ToolReturnValue: subagents = self._labor_market.subagents if params.subagent_name not in subagents: return ToolError( message=f"Subagent not found: {params.subagent_name}", brief="Subagent not found", ) agent = subagents[params.subagent_name] try: result = await self._run_subagent(agent, params.prompt) return result except Exception as e: return ToolError( message=f"Failed to run subagent: {e}", brief="Failed to run subagent", ) async def _run_subagent(self, agent: Agent, prompt: str) -> ToolReturnValue: """Run subagent with optional continuation for task summary.""" super_wire = get_wire_or_none() assert super_wire is not None current_tool_call = get_current_tool_call_or_none() assert current_tool_call is not None current_tool_call_id = current_tool_call.id def _super_wire_send(msg: WireMessage) -> None: if isinstance( msg, ApprovalRequest | ApprovalResponse | ToolCallRequest | QuestionRequest, ): # Requests (and their resolution signals) should stay at the root wire level. super_wire.soul_side.send(msg) return event = SubagentEvent( task_tool_call_id=current_tool_call_id, event=msg, ) super_wire.soul_side.send(event) async def _ui_loop_fn(wire: Wire) -> None: wire_ui = wire.ui_side(merge=True) while True: msg = await wire_ui.receive() _super_wire_send(msg) subagent_context_file = await self._get_subagent_context_file() context = Context(file_backend=subagent_context_file) soul = KimiSoul(agent, context=context) try: await run_soul(soul, prompt, _ui_loop_fn, asyncio.Event(), runtime=soul.runtime) except MaxStepsReached as e: return ToolError( message=( f"Max steps {e.n_steps} reached when running subagent. " "Please try splitting the task into smaller subtasks." ), brief="Max steps reached", ) _error_msg = ( "The subagent seemed not to run properly. Maybe you have to do the task yourself." ) # Check if the subagent context is valid if len(context.history) == 0 or context.history[-1].role != "assistant": return ToolError(message=_error_msg, brief="Failed to run subagent") final_response = context.history[-1].extract_text(sep="\n") # Check if response is too brief, if so, run again with continuation prompt n_attempts_remaining = MAX_CONTINUE_ATTEMPTS if len(final_response) < 200 and n_attempts_remaining > 0: await run_soul( soul, CONTINUE_PROMPT, _ui_loop_fn, asyncio.Event(), runtime=soul.runtime ) if len(context.history) == 0 or context.history[-1].role != "assistant": return ToolError(message=_error_msg, brief="Failed to run subagent") final_response = context.history[-1].extract_text(sep="\n") return ToolOk(output=final_response) ================================================ FILE: src/kimi_cli/tools/plan/__init__.py ================================================ """ExitPlanMode tool — lets the LLM submit a plan for user approval.""" from __future__ import annotations import asyncio import logging from collections.abc import Awaitable, Callable from pathlib import Path from typing import override from uuid import uuid4 from kosong.tooling import BriefDisplayBlock, CallableTool2, ToolError, ToolReturnValue from pydantic import BaseModel, Field, field_validator from kimi_cli.soul import get_wire_or_none, wire_send from kimi_cli.soul.toolset import get_current_tool_call_or_none from kimi_cli.tools.utils import ToolRejectedError, load_desc from kimi_cli.wire.types import QuestionItem, QuestionNotSupported, QuestionOption, QuestionRequest logger = logging.getLogger(__name__) NAME = "ExitPlanMode" _RESERVED_LABELS = {"reject", "revise", "approve"} class PlanOption(BaseModel): """A selectable approach/option within the plan.""" label: str = Field( description=( "Short name for this option (1-8 words). " "Append '(Recommended)' if you recommend this option." ), ) description: str = Field( default="", description="Brief summary of this approach and its trade-offs.", ) @field_validator("label") @classmethod def label_not_reserved(cls, v: str) -> str: if v.strip().lower() in _RESERVED_LABELS: raise ValueError( f"Option label {v!r} is reserved. " "Do not use 'Reject', 'Revise', or 'Approve' as option labels." ) return v class Params(BaseModel): options: list[PlanOption] | None = Field( default=None, max_length=3, description=( "When the plan contains multiple alternative approaches, list them here " "so the user can choose which one to execute. 2-3 options. " "Each option represents a distinct approach from the plan. " "Do not use 'Reject', 'Revise', or 'Approve' as labels." ), ) @field_validator("options") @classmethod def options_labels_unique(cls, v: list[PlanOption] | None) -> list[PlanOption] | None: if v is None: return v labels = [opt.label for opt in v] if len(labels) != len(set(labels)): raise ValueError("Option labels must be unique. Found duplicate label(s).") return v class ExitPlanMode(CallableTool2[Params]): name: str = NAME description: str = load_desc(Path(__file__).parent / "description.md") params: type[Params] = Params def __init__(self) -> None: super().__init__() self._toggle_callback: Callable[[], Awaitable[bool]] | None = None self._plan_file_path_getter: Callable[[], Path | None] | None = None self._plan_mode_checker: Callable[[], bool] | None = None def bind( self, toggle_callback: Callable[[], Awaitable[bool]], plan_file_path_getter: Callable[[], Path | None], plan_mode_checker: Callable[[], bool], ) -> None: """Late-bind soul callbacks after KimiSoul is constructed.""" self._toggle_callback = toggle_callback self._plan_file_path_getter = plan_file_path_getter self._plan_mode_checker = plan_mode_checker @override async def __call__(self, params: Params) -> ToolReturnValue: # Guard: only works in plan mode if not self._plan_mode_checker or not self._plan_mode_checker(): return ToolError( message="Not in plan mode. ExitPlanMode is only available during plan mode.", brief="Not in plan mode", ) if not self._toggle_callback or not self._plan_file_path_getter: return ToolError( message="ExitPlanMode is not properly initialized.", brief="Not initialized", ) # Read the plan file plan_path = self._plan_file_path_getter() plan_content: str | None = None if plan_path and await asyncio.to_thread(plan_path.exists): plan_content = await asyncio.to_thread(plan_path.read_text, encoding="utf-8") if not plan_content: return ToolError( message=f"No plan file found. Write your plan to {plan_path} first, " "then call ExitPlanMode.", brief="No plan file", ) # Present plan to user via QuestionRequest wire = get_wire_or_none() if wire is None: return ToolError( message="Cannot present plan: Wire is not available.", brief="Wire unavailable", ) tool_call = get_current_tool_call_or_none() if tool_call is None: return ToolError( message="ExitPlanMode must be called from a tool call context.", brief="Invalid context", ) has_options = params.options is not None and len(params.options) >= 2 if has_options: assert params.options is not None question_options = [ QuestionOption(label=opt.label, description=opt.description) for opt in params.options ] question_options.append( QuestionOption( label="Reject", description="Stay in plan mode and continue conversation", ) ) else: question_options = [ QuestionOption( label="Approve", description="Exit plan mode and start execution", ), QuestionOption( label="Reject", description="Stay in plan mode and continue conversation", ), ] request = QuestionRequest( id=str(uuid4()), tool_call_id=tool_call.id, questions=[ QuestionItem( question=f"Plan ready for review (saved at {plan_path}):", header="Plan", body=plan_content, options=question_options, other_label="Revise", other_description="Stay in plan mode and provide feedback", ) ], ) wire_send(request) try: answers = await request.wait() except QuestionNotSupported: return ToolError( message="The connected client does not support plan mode. " "Do NOT call this tool again.", brief="Client unsupported", ) except Exception: logger.exception("Failed to get user response for ExitPlanMode") return ToolError( message="Failed to get user response.", brief="Question failed", ) if not answers: return ToolReturnValue( is_error=False, output="User dismissed without choosing. Plan mode remains active. " "Continue working on your plan or call ExitPlanMode again when ready.", message="Dismissed", display=[BriefDisplayBlock(text="Dismissed")], ) # Parse user choice — exact match on option label chose_reject = any(v == "Reject" for v in answers.values()) if chose_reject: return ToolRejectedError( message=( "Plan rejected by user. Stay in plan mode. " "The user will provide feedback via conversation. " "Wait for the user's next message before revising." ), brief="Plan rejected", ) if has_options: assert params.options is not None option_labels = {opt.label for opt in params.options} chosen_option = None for v in answers.values(): if v in option_labels: chosen_option = v break if chosen_option: await self._toggle_callback() return ToolReturnValue( is_error=False, output=( f'Plan approved by user. Selected approach: "{chosen_option}"\n' f"Plan mode deactivated. All tools are now available.\n" f"Plan saved to: {plan_path}\n\n" f'IMPORTANT: Execute ONLY the selected approach "{chosen_option}". ' f"Ignore other approaches in the plan.\n\n" f"## Approved Plan:\n{plan_content}" ), message=f"Plan approved: {chosen_option}", display=[BriefDisplayBlock(text=f"Plan approved: {chosen_option}")], ) else: # Revise — extract feedback text feedback = "" for v in answers.values(): if v != "Reject" and v not in option_labels: feedback = v msg = ( "Plan needs revision. Please revise your plan based on " "feedback and call ExitPlanMode again." ) if feedback: msg += f"\n\nUser feedback: {feedback}" return ToolReturnValue( is_error=False, output=msg, message="Plan revised", display=[BriefDisplayBlock(text="Plan revised")], ) else: chose_approve = any(v == "Approve" for v in answers.values()) if chose_approve: await self._toggle_callback() return ToolReturnValue( is_error=False, output=( f"Plan approved by user. Plan mode deactivated. " f"All tools are now available.\n" f"Plan saved to: {plan_path}\n\n" f"## Approved Plan:\n{plan_content}" ), message="Plan approved", display=[BriefDisplayBlock(text="Plan approved")], ) else: # Revise — extract feedback text feedback = "" for v in answers.values(): if v not in ("Approve", "Reject"): feedback = v msg = ( "Plan needs revision. Please revise your plan based on " "feedback and call ExitPlanMode again." ) if feedback: msg += f"\n\nUser feedback: {feedback}" return ToolReturnValue( is_error=False, output=msg, message="Plan revised", display=[BriefDisplayBlock(text="Plan revised")], ) ================================================ FILE: src/kimi_cli/tools/plan/description.md ================================================ Use this tool when you are in plan mode and have finished writing your plan to the plan file and are ready for user approval. ## How This Tool Works - You should have already written your plan to the plan file specified in the plan mode reminder. - This tool does NOT take the plan content as a parameter — it reads the plan from the file you wrote. - The user will see the contents of your plan file when they review it. ## When to Use Only use this tool for tasks that require planning implementation steps. For research tasks (searching files, reading code, understanding the codebase), do NOT use this tool. ## Multiple Approaches If your plan contains multiple alternative approaches: - Pass them via the `options` parameter so the user can choose which approach to execute. - Each option should have a concise label and a brief description of trade-offs. - If you recommend one option, append "(Recommended)" to its label. - The user will see all options alongside Reject and Revise choices. - Provide 2-3 options at most (the system appends a "Reject" option automatically, so the total shown to the user is 3-4). - Do NOT use "Reject", "Revise", or "Approve" as option labels — these are reserved by the system. ## Before Using - If you have unresolved questions, use AskUserQuestion first. - If you have multiple approaches and haven't narrowed down yet, consider using AskUserQuestion first to let the user choose, then write a plan for the chosen approach only. - Once your plan is finalized, use THIS tool to request approval. - Do NOT use AskUserQuestion to ask "Is this plan OK?" or "Should I proceed?" — that is exactly what ExitPlanMode does. - If rejected, revise based on feedback and call ExitPlanMode again. ================================================ FILE: src/kimi_cli/tools/plan/enter.py ================================================ """EnterPlanMode tool — lets the LLM request to enter plan mode.""" from __future__ import annotations import logging from collections.abc import Awaitable, Callable from pathlib import Path from typing import override from uuid import uuid4 from kosong.tooling import BriefDisplayBlock, CallableTool2, ToolError, ToolReturnValue from pydantic import BaseModel from kimi_cli.soul import get_wire_or_none, wire_send from kimi_cli.soul.toolset import get_current_tool_call_or_none from kimi_cli.tools.utils import load_desc from kimi_cli.wire.types import QuestionItem, QuestionNotSupported, QuestionOption, QuestionRequest logger = logging.getLogger(__name__) NAME = "EnterPlanMode" _DESCRIPTION = load_desc(Path(__file__).parent / "enter_description.md") class Params(BaseModel): pass class EnterPlanMode(CallableTool2[Params]): name: str = NAME description: str = _DESCRIPTION params: type[Params] = Params def __init__(self) -> None: super().__init__() self._toggle_callback: Callable[[], Awaitable[bool]] | None = None self._plan_file_path_getter: Callable[[], Path | None] | None = None self._plan_mode_checker: Callable[[], bool] | None = None def bind( self, toggle_callback: Callable[[], Awaitable[bool]], plan_file_path_getter: Callable[[], Path | None], plan_mode_checker: Callable[[], bool], ) -> None: """Late-bind soul callbacks after KimiSoul is constructed.""" self._toggle_callback = toggle_callback self._plan_file_path_getter = plan_file_path_getter self._plan_mode_checker = plan_mode_checker @override async def __call__(self, params: Params) -> ToolReturnValue: # Guard: already in plan mode if self._plan_mode_checker and self._plan_mode_checker(): return ToolError( message="Already in plan mode. Use ExitPlanMode when your plan is ready.", brief="Already in plan mode", ) if not self._toggle_callback or not self._plan_file_path_getter: return ToolError( message="EnterPlanMode is not properly initialized.", brief="Not initialized", ) # Present confirmation dialog to user via QuestionRequest wire = get_wire_or_none() if wire is None: return ToolError( message="Cannot request user confirmation: Wire is not available.", brief="Wire unavailable", ) tool_call = get_current_tool_call_or_none() if tool_call is None: return ToolError( message="EnterPlanMode must be called from a tool call context.", brief="Invalid context", ) request = QuestionRequest( id=str(uuid4()), tool_call_id=tool_call.id, questions=[ QuestionItem( question="Enter plan mode?", header="Plan Mode", options=[ QuestionOption( label="Yes", description="Enter plan mode to explore and design an approach", ), QuestionOption( label="No", description="Skip planning, start implementing now", ), ], ) ], ) wire_send(request) try: answers = await request.wait() except QuestionNotSupported: return ToolError( message="The connected client does not support plan mode. " "Do NOT call this tool again.", brief="Client unsupported", ) except Exception: logger.exception("Failed to get user response for EnterPlanMode") return ToolError( message="Failed to get user response.", brief="Question failed", ) if not answers: return ToolReturnValue( is_error=False, output="User dismissed without choosing. Proceed with implementation directly.", message="Dismissed", display=[BriefDisplayBlock(text="Dismissed")], ) # Parse user choice — exact match on option label chose_yes = any(v == "Yes" for v in answers.values()) if chose_yes: await self._toggle_callback() plan_path = self._plan_file_path_getter() return ToolReturnValue( is_error=False, output=( f"Plan mode activated. You MUST NOT edit code files — only read and plan.\n" f"Plan file: {plan_path}\n" f"Workflow: explore with Glob/Grep/ReadFile → design approach → " f"modify the plan file with WriteFile or StrReplaceFile " f"(create it with WriteFile first if it does not exist) → " f"call ExitPlanMode.\n" f"Use AskUserQuestion only to clarify missing requirements or choose " f"between approaches.\n" f"Do NOT use AskUserQuestion to ask about plan approval." ), message="Plan mode on", display=[BriefDisplayBlock(text="Plan mode on")], ) else: return ToolReturnValue( is_error=False, output=( "User declined to enter plan mode. Please check with user whether " "to proceed with implementation directly." ), message="Declined", display=[BriefDisplayBlock(text="Declined")], ) ================================================ FILE: src/kimi_cli/tools/plan/enter_description.md ================================================ Use this tool proactively when you're about to start a non-trivial implementation task. Getting user sign-off on your approach before writing code prevents wasted effort. Use it when ANY of these conditions apply: 1. New Feature Implementation — e.g. "Add a caching layer to the API" 2. Multiple Valid Approaches — e.g. "Optimize database queries" (indexing vs rewrite vs caching) 3. Code Modifications — e.g. "Refactor auth module to support OAuth" 4. Architectural Decisions — e.g. "Add WebSocket support" 5. Multi-File Changes — involves more than 2-3 files 6. Unclear Requirements — need exploration to understand scope 7. User Preferences Matter — if you'd use AskUserQuestion to clarify approach, use EnterPlanMode instead Yolo mode note: - Yolo mode users chose continuous execution. - In yolo mode, use EnterPlanMode only when the user explicitly asks for planning or when there is exceptional architectural ambiguity that requires user input before proceeding. When NOT to use: - Single-line or few-line fixes (typos, obvious bugs, small tweaks) - User gave very specific, detailed instructions - Pure research/exploration tasks ## What Happens in Plan Mode In plan mode, you will: 1. Explore the codebase using Glob, Grep, ReadFile (read-only) 2. Design an implementation approach 3. Write your plan to a plan file 4. Present your plan to the user via ExitPlanMode for approval ================================================ FILE: src/kimi_cli/tools/plan/heroes.py ================================================ """Plan file slug generation using Marvel and DC hero names.""" from __future__ import annotations import secrets from pathlib import Path PLANS_DIR = Path.home() / ".kimi" / "plans" HERO_NAMES: list[str] = [ # --- Marvel --- "iron-man", "spider-man", "captain-america", "thor", "hulk", "black-widow", "hawkeye", "black-panther", "doctor-strange", "scarlet-witch", "vision", "falcon", "war-machine", "ant-man", "wasp", "captain-marvel", "gamora", "star-lord", "groot", "rocket", "drax", "mantis", "nebula", "shang-chi", "moon-knight", "ms-marvel", "she-hulk", "echo", "wolverine", "cyclops", "storm", "jean-grey", "rogue", "beast", "nightcrawler", "colossus", "shadowcat", "jubilee", "cable", "deadpool", "bishop", "magik", "iceman", "archangel", "psylocke", "dazzler", "forge", "havok", "polaris", "emma-frost", "namor", "silver-surfer", "adam-warlock", "nova", "quasar", "sentry", "blue-marvel", "spectrum", "squirrel-girl", "cloak", "dagger", "punisher", "elektra", "luke-cage", "iron-fist", "jessica-jones", "daredevil", "blade", "ghost-rider", "morbius", "venom", "carnage", "silk", "spider-gwen", "miles-morales", "america-chavez", "kate-bishop", "yelena-belova", "white-tiger", "moon-girl", "devil-dinosaur", "amadeus-cho", "riri-williams", "kamala-khan", "sam-alexander", "nova-prime", "medusa", "black-bolt", "crystal", "karnak", "gorgon", "lockjaw", "quake", "mockingbird", "bobbi-morse", "maria-hill", "nick-fury", "phil-coulson", "winter-soldier", "us-agent", "patriot", "speed", "wiccan", "hulkling", "stature", "yellowjacket", "tigra", "hellcat", "valkyrie", "sif", "beta-ray-bill", "hercules", "wonder-man", "taskmaster", "domino", "cannonball", "sunspot", "wolfsbane", "warpath", "multiple-man", "banshee", "siryn", "monet", "rictor", "shatterstar", "longshot", "daken", "x-23", "fantomex", "batman", "superman", "wonder-woman", "flash", "aquaman", "green-lantern", "martian-manhunter", "cyborg", "hawkgirl", "green-arrow", "black-canary", "zatanna", "constantine", "shazam", "blue-beetle", "booster-gold", "firestorm", "atom", "hawkman", "plastic-man", "red-tornado", "starfire", "raven", "beast-boy", "robin", "nightwing", "batgirl", "batwoman", "red-hood", "signal", "orphan", "spoiler", "catwoman", "huntress", "supergirl", "superboy", "power-girl", "steel", "stargirl", "wildcat", "doctor-fate", "mister-terrific", "hourman", "sandman", "spectre", "phantom-stranger", "swamp-thing", "animal-man", "deadman", "vixen", "black-lightning", "static", "icon", "rocket-dc", "captain-atom", "fire", "ice", "elongated-man", "metamorpho", "black-hawk", "crimson-avenger", "doctor-mid-nite", "jakeem-thunder", "mister-miracle", "big-barda", "orion", "lightray", "forager", "killer-frost", "jessica-cruz", "simon-baz", "john-stewart", "guy-gardner", "kyle-rayner", "hal-jordan", "wally-west", "barry-allen", "jay-garrick", "impulse", "kid-flash", "donna-troy", "tempest", "aqualad", "miss-martian", "terra", "jericho", "ravager", "red-star", "pantha", "argent", "damage", "jade", "obsidian", "cyclone", "atom-smasher", "maxima", "starman", "liberty-belle", ] _slug_cache: dict[str, str] = {} def seed_slug_cache(session_id: str, slug: str) -> None: """Pre-warm the in-process slug cache with a previously persisted slug.""" _slug_cache[session_id] = slug def get_or_create_slug(session_id: str) -> str: """Get or create a plan file slug for the given session.""" if session_id in _slug_cache: return _slug_cache[session_id] PLANS_DIR.mkdir(parents=True, exist_ok=True) slug = "" for _ in range(20): words = [secrets.choice(HERO_NAMES) for _ in range(3)] slug = "-".join(words) if not (PLANS_DIR / f"{slug}.md").exists(): break else: # All 20 attempts collided; append session prefix for uniqueness slug = f"{slug}-{session_id[:8]}" _slug_cache[session_id] = slug return slug def get_plan_file_path(session_id: str) -> Path: """Get the plan file path for the given session.""" return PLANS_DIR / f"{get_or_create_slug(session_id)}.md" def read_plan_file(session_id: str) -> str | None: """Read the plan file content for the given session, or None if not found.""" path = get_plan_file_path(session_id) if path.exists(): return path.read_text(encoding="utf-8") return None ================================================ FILE: src/kimi_cli/tools/shell/__init__.py ================================================ import asyncio from collections.abc import Callable from pathlib import Path from typing import Self, override import kaos from kaos import AsyncReadable from kosong.tooling import CallableTool2, ToolReturnValue from pydantic import BaseModel, Field, model_validator from kimi_cli.background import TaskView, format_task from kimi_cli.soul.agent import Runtime from kimi_cli.soul.approval import Approval from kimi_cli.soul.toolset import get_current_tool_call_or_none from kimi_cli.tools.display import BackgroundTaskDisplayBlock, ShellDisplayBlock from kimi_cli.tools.utils import ToolRejectedError, ToolResultBuilder, load_desc from kimi_cli.utils.environment import Environment from kimi_cli.utils.subprocess_env import get_clean_env MAX_FOREGROUND_TIMEOUT = 5 * 60 MAX_BACKGROUND_TIMEOUT = 24 * 60 * 60 class Params(BaseModel): command: str = Field(description="The bash command to execute.") timeout: int = Field( description=( "The timeout in seconds for the command to execute. " "If the command takes longer than this, it will be killed." ), default=60, ge=1, le=MAX_BACKGROUND_TIMEOUT, ) run_in_background: bool = Field( default=False, description="Whether to run the command as a background task.", ) description: str = Field( default="", description=( "A short description for the background task. Required when run_in_background=true." ), ) @model_validator(mode="after") def _validate_background_fields(self) -> Self: if self.run_in_background and not self.description.strip(): raise ValueError("description is required when run_in_background is true") if not self.run_in_background and self.timeout > MAX_FOREGROUND_TIMEOUT: raise ValueError( f"timeout must be <= {MAX_FOREGROUND_TIMEOUT}s for foreground commands; " f"use run_in_background=true for longer timeouts (up to {MAX_BACKGROUND_TIMEOUT}s)" ) return self class Shell(CallableTool2[Params]): name: str = "Shell" params: type[Params] = Params def __init__(self, approval: Approval, environment: Environment, runtime: Runtime): is_powershell = environment.shell_name == "Windows PowerShell" super().__init__( description=load_desc( Path(__file__).parent / ("powershell.md" if is_powershell else "bash.md"), {"SHELL": f"{environment.shell_name} (`{environment.shell_path}`)"}, ) ) self._approval = approval self._is_powershell = is_powershell self._shell_path = environment.shell_path self._runtime = runtime @override async def __call__(self, params: Params) -> ToolReturnValue: builder = ToolResultBuilder() if not params.command: return builder.error("Command cannot be empty.", brief="Empty command") if params.run_in_background: return await self._run_in_background(params) if not await self._approval.request( self.name, "run command", f"Run command `{params.command}`", display=[ ShellDisplayBlock( language="powershell" if self._is_powershell else "bash", command=params.command, ) ], ): return ToolRejectedError() def stdout_cb(line: bytes): line_str = line.decode(encoding="utf-8", errors="replace") builder.write(line_str) def stderr_cb(line: bytes): line_str = line.decode(encoding="utf-8", errors="replace") builder.write(line_str) try: exitcode = await self._run_shell_command( params.command, stdout_cb, stderr_cb, params.timeout ) if exitcode == 0: return builder.ok("Command executed successfully.") else: return builder.error( f"Command failed with exit code: {exitcode}.", brief=f"Failed with exit code: {exitcode}", ) except TimeoutError: return builder.error( f"Command killed by timeout ({params.timeout}s)", brief=f"Killed by timeout ({params.timeout}s)", ) async def _run_in_background(self, params: Params) -> ToolReturnValue: tool_call = get_current_tool_call_or_none() if tool_call is None: return ToolResultBuilder().error( "Background shell requires a tool call context.", brief="No tool call context", ) if not await self._approval.request( self.name, "run background command", f"Run background command `{params.command}`", display=[ ShellDisplayBlock( language="powershell" if self._is_powershell else "bash", command=params.command, ) ], ): return ToolRejectedError() try: view = self._runtime.background_tasks.create_bash_task( command=params.command, description=params.description.strip(), timeout_s=params.timeout, tool_call_id=tool_call.id, shell_name="Windows PowerShell" if self._is_powershell else "bash", shell_path=str(self._shell_path), cwd=str(self._runtime.session.work_dir), ) except Exception as exc: builder = ToolResultBuilder() return builder.error(f"Failed to start background task: {exc}", brief="Start failed") return self._background_ok(view) def _background_ok(self, view: TaskView) -> ToolReturnValue: builder = ToolResultBuilder() builder.write( "\n".join( [ format_task(view, include_command=True), "automatic_notification: true", "next_step: You will be automatically notified when it completes.", ( "next_step: Use TaskOutput with this task_id " "if you need progress or want to wait." ), "next_step: Use TaskStop only if the task must be cancelled.", ( "human_shell_hint: For users in the interactive shell, " "the only task-management slash command is /task. " "Do not suggest /task list, /task output, /task stop, or /tasks." ), ] ) ) builder.display( BackgroundTaskDisplayBlock( task_id=view.spec.id, kind=view.spec.kind, status=view.runtime.status, description=view.spec.description, ) ) return builder.ok("Background task started", brief=f"Started {view.spec.id}") async def _run_shell_command( self, command: str, stdout_cb: Callable[[bytes], None], stderr_cb: Callable[[bytes], None], timeout: int, ) -> int: async def _read_stream(stream: AsyncReadable, cb: Callable[[bytes], None]): while True: line = await stream.readline() if line: cb(line) else: break process = await kaos.exec(*self._shell_args(command), env=get_clean_env()) try: await asyncio.wait_for( asyncio.gather( _read_stream(process.stdout, stdout_cb), _read_stream(process.stderr, stderr_cb), ), timeout, ) return await process.wait() except asyncio.CancelledError: await process.kill() raise except TimeoutError: await process.kill() raise def _shell_args(self, command: str) -> tuple[str, ...]: if self._is_powershell: return (str(self._shell_path), "-command", command) return (str(self._shell_path), "-c", command) ================================================ FILE: src/kimi_cli/tools/shell/bash.md ================================================ Execute a ${SHELL} command. Use this tool to explore the filesystem, edit files, run scripts, get system information, etc. **Output:** The stdout and stderr will be combined and returned as a string. The output may be truncated if it is too long. If the command failed, the exit code will be provided in a system tag. If `run_in_background=true`, the command will be started as a background task and this tool will return a task ID instead of waiting for command completion. When doing that, you must provide a short `description`. You will be automatically notified when the task completes. Use `TaskOutput` if you need progress or want to wait for completion, and use `TaskStop` only if the task must be cancelled. For human users in the interactive shell, background tasks are managed through `/task` only; do not suggest `/task list`, `/task output`, `/task stop`, `/tasks`, or any other invented shell subcommands. **Guidelines for safety and security:** - Each shell tool call will be executed in a fresh shell environment. The shell variables, current working directory changes, and the shell history is not preserved between calls. - The tool call will return after the command is finished. You shall not use this tool to execute an interactive command or a command that may run forever. For possibly long-running commands, you shall set `timeout` argument to a reasonable value. - Avoid using `..` to access files or directories outside of the working directory. - Avoid modifying files outside of the working directory unless explicitly instructed to do so. - Never run commands that require superuser privileges unless explicitly instructed to do so. **Guidelines for efficiency:** - For multiple related commands, use `&&` to chain them in a single call, e.g. `cd /path && ls -la` - Use `;` to run commands sequentially regardless of success/failure - Use `||` for conditional execution (run second command only if first fails) - Use pipe operations (`|`) and redirections (`>`, `>>`) to chain input and output between commands - Always quote file paths containing spaces with double quotes (e.g., cd "/path with spaces/") - Use `if`, `case`, `for`, `while` control flows to execute complex logic in a single call. - Verify directory structure before create/edit/delete files or directories to reduce the risk of failure. - Prefer `run_in_background=true` for long-running builds, tests, watchers, or servers when you need the conversation to continue before the command finishes. - After starting a background task, do not guess its outcome. Rely on the automatic completion notification whenever possible. Use `TaskOutput` only when you need to inspect progress or block until completion. - If you need to tell a human shell user how to manage background tasks, only mention `/task`. Do not invent `/task list`, `/task output`, `/task stop`, or `/tasks`. **Commands available:** - Shell environment: cd, pwd, export, unset, env - File system operations: ls, find, mkdir, rm, cp, mv, touch, chmod, chown - File viewing/editing: cat, grep, head, tail, diff, patch - Text processing: awk, sed, sort, uniq, wc - System information/operations: ps, kill, top, df, free, uname, whoami, id, date - Network operations: curl, wget, ping, telnet, ssh - Archive operations: tar, zip, unzip - Other: Other commands available in the shell environment. Check the existence of a command by running `which ` before using it. ================================================ FILE: src/kimi_cli/tools/shell/powershell.md ================================================ Execute a ${SHELL} command. Use this tool to explore the filesystem, inspect or edit files, run Windows scripts, collect system information, etc., whenever the agent is running on Windows. Note that you are running on Windows, so make sure to use Windows commands, paths, and conventions. **Output:** The stdout and stderr streams are combined and returned as a single string. Extremely long output may be truncated. When a command fails, the exit code is provided in a system tag. If `run_in_background=true`, the command will be started as a background task and this tool will return a task ID instead of waiting for completion. When doing that, you must provide a short `description`. You will be automatically notified when the task completes. Use `TaskOutput` if you need progress or want to wait for completion, and use `TaskStop` only if the task must be cancelled. For human users in the interactive shell, background tasks are managed through `/task` only; do not suggest `/task list`, `/task output`, `/task stop`, `/tasks`, or any other invented shell subcommands. **Guidelines for safety and security:** - Every tool call starts a fresh ${SHELL} session. Environment variables, `cd` changes, and command history do not persist between calls. - Do not launch interactive programs or anything that is expected to block indefinitely; ensure each command finishes promptly. Provide a `timeout` argument for potentially long runs. - Avoid using `..` to leave the working directory, and never touch files outside that directory unless explicitly instructed. - Never attempt commands that require elevated (Administrator) privileges unless explicitly authorized. **Guidelines for efficiency:** - Chain related commands with `;` and use `if ($?)` or `if (-not $?)` to conditionally execute commands based on the success or failure of previous ones. - Redirect or pipe output with `>`, `>>`, `|`, and leverage `for /f`, `if`, and `set` to build richer one-liners instead of multiple tool calls. - Reuse built-in utilities (e.g., `findstr`, `where`) to filter, transform, or locate data in a single invocation. - Prefer `run_in_background=true` for long-running builds, tests, watchers, or servers when you need the conversation to continue before the command finishes. - After starting a background task, do not guess its outcome. Rely on the automatic completion notification whenever possible. Use `TaskOutput` only when you need to inspect progress or block until completion. - If you need to tell a human shell user how to manage background tasks, only mention `/task`. Do not invent `/task list`, `/task output`, `/task stop`, or `/tasks`. **Commands available:** - Shell environment: `cd`, `dir`, `set`, `setlocal`, `echo`, `call`, `where` - File operations: `type`, `copy`, `move`, `del`, `erase`, `mkdir`, `rmdir`, `attrib`, `mklink` - Text/search: `find`, `findstr`, `more`, `sort`, `Get-Content` - System info: `ver`, `systeminfo`, `tasklist`, `wmic`, `hostname` - Archives/scripts: `tar`, `Compress-Archive`, `powershell`, `python`, `node` - Other: Any other binaries available on the system PATH; run `where ` first if unsure. ================================================ FILE: src/kimi_cli/tools/test.py ================================================ import asyncio from typing import override from kosong.tooling import CallableTool2, ToolOk, ToolReturnValue from pydantic import BaseModel class PlusParams(BaseModel): a: float b: float class Plus(CallableTool2[PlusParams]): name: str = "plus" description: str = "Add two numbers" params: type[PlusParams] = PlusParams @override async def __call__(self, params: PlusParams) -> ToolReturnValue: return ToolOk(output=str(params.a + params.b)) class CompareParams(BaseModel): a: float b: float class Compare(CallableTool2[CompareParams]): name: str = "compare" description: str = "Compare two numbers" params: type[CompareParams] = CompareParams @override async def __call__(self, params: CompareParams) -> ToolReturnValue: if params.a > params.b: return ToolOk(output="greater") elif params.a < params.b: return ToolOk(output="less") else: return ToolOk(output="equal") class PanicParams(BaseModel): message: str class Panic(CallableTool2[PanicParams]): name: str = "panic" description: str = "Raise an exception to cause the tool call to fail." params: type[PanicParams] = PanicParams @override async def __call__(self, params: PanicParams) -> ToolReturnValue: await asyncio.sleep(2) raise Exception(f"panicked with a message with {len(params.message)} characters") ================================================ FILE: src/kimi_cli/tools/think/__init__.py ================================================ from pathlib import Path from typing import override from kosong.tooling import CallableTool2, ToolOk, ToolReturnValue from pydantic import BaseModel, Field from kimi_cli.tools.utils import load_desc class Params(BaseModel): thought: str = Field(description=("A thought to think about.")) class Think(CallableTool2[Params]): name: str = "Think" description: str = load_desc(Path(__file__).parent / "think.md", {}) params: type[Params] = Params @override async def __call__(self, params: Params) -> ToolReturnValue: return ToolOk(output="", message="Thought logged") ================================================ FILE: src/kimi_cli/tools/think/think.md ================================================ Use the tool to think about something. It will not obtain new information or change the database, but just append the thought to the log. Use it when complex reasoning or some cache memory is needed. ================================================ FILE: src/kimi_cli/tools/todo/__init__.py ================================================ from pathlib import Path from typing import Literal, override from kosong.tooling import CallableTool2, ToolReturnValue from pydantic import BaseModel, Field from kimi_cli.tools.display import TodoDisplayBlock, TodoDisplayItem from kimi_cli.tools.utils import load_desc class Todo(BaseModel): title: str = Field(description="The title of the todo", min_length=1) status: Literal["pending", "in_progress", "done"] = Field(description="The status of the todo") class Params(BaseModel): todos: list[Todo] = Field(description="The updated todo list") class SetTodoList(CallableTool2[Params]): name: str = "SetTodoList" description: str = load_desc(Path(__file__).parent / "set_todo_list.md") params: type[Params] = Params @override async def __call__(self, params: Params) -> ToolReturnValue: items = [TodoDisplayItem(title=todo.title, status=todo.status) for todo in params.todos] return ToolReturnValue( is_error=False, output="", message="Todo list updated", display=[TodoDisplayBlock(items=items)], ) ================================================ FILE: src/kimi_cli/tools/todo/set_todo_list.md ================================================ Update the whole todo list. Todo list is a simple yet powerful tool to help you get things done. You typically want to use this tool when the given task involves multiple subtasks/milestones, or, multiple tasks are given in a single request. This tool can help you to break down the task and track the progress. This is the only todo list tool available to you. That said, each time you want to operate on the todo list, you need to update the whole. Make sure to maintain the todo items and their statuses properly. Once you finished a subtask/milestone, remember to update the todo list to reflect the progress. Also, you can give yourself a self-encouragement to keep you motivated. Abusing this tool to track too small steps will just waste your time and make your context messy. For example, here are some cases you should not use this tool: - When the user just simply ask you a question. E.g. "What language and framework is used in the project?", "What is the best practice for x?" - When it only takes a few steps/tool calls to complete the task. E.g. "Fix the unit test function 'test_xxx'", "Refactor the function 'xxx' to make it more solid." - When the user prompt is very specific and the only thing you need to do is brainlessly following the instructions. E.g. "Replace xxx to yyy in the file zzz", "Create a file xxx with content yyy." However, do not get stuck in a rut. Be flexible. Sometimes, you may try to use todo list at first, then realize the task is too simple and you can simply stop using it; or, sometimes, you may realize the task is complex after a few steps and then you can start using todo list to break it down. ================================================ FILE: src/kimi_cli/tools/utils.py ================================================ import re from pathlib import Path from jinja2 import Environment, Undefined from kosong.tooling import BriefDisplayBlock, DisplayBlock, ToolError, ToolReturnValue from kosong.utils.typing import JsonType class _KeepPlaceholderUndefined(Undefined): def __str__(self) -> str: if self._undefined_name is None: return "" return f"${{{self._undefined_name}}}" __repr__ = __str__ def load_desc(path: Path, context: dict[str, object] | None = None) -> str: """Load a tool description from a file, rendered via Jinja2.""" description = path.read_text(encoding="utf-8") env = Environment( keep_trailing_newline=True, lstrip_blocks=True, trim_blocks=True, variable_start_string="${", variable_end_string="}", undefined=_KeepPlaceholderUndefined, ) template = env.from_string(description) return template.render(context or {}) def truncate_line(line: str, max_length: int, marker: str = "...") -> str: """ Truncate a line if it exceeds `max_length`, preserving the beginning and the line break. The output may be longer than `max_length` if it is too short to fit the marker. """ if len(line) <= max_length: return line # Find line breaks at the end of the line m = re.search(r"[\r\n]+$", line) linebreak = m.group(0) if m else "" end = marker + linebreak max_length = max(max_length, len(end)) return line[: max_length - len(end)] + end # Default output limits DEFAULT_MAX_CHARS = 50_000 DEFAULT_MAX_LINE_LENGTH = 2000 class ToolResultBuilder: """ Builder for tool results with character and line limits. """ def __init__( self, max_chars: int = DEFAULT_MAX_CHARS, max_line_length: int | None = DEFAULT_MAX_LINE_LENGTH, ): self.max_chars = max_chars self.max_line_length = max_line_length self._marker = "[...truncated]" if max_line_length is not None: assert max_line_length > len(self._marker) self._buffer: list[str] = [] self._n_chars = 0 self._n_lines = 0 self._truncation_happened = False self._display: list[DisplayBlock] = [] self._extras: dict[str, JsonType] | None = None @property def is_full(self) -> bool: """Check if output buffer is full due to character limit.""" return self._n_chars >= self.max_chars @property def n_chars(self) -> int: """Get current character count.""" return self._n_chars @property def n_lines(self) -> int: """Get current line count.""" return self._n_lines def write(self, text: str) -> int: """ Write text to the output buffer. Returns: int: Number of characters actually written """ if self.is_full: return 0 lines = text.splitlines(keepends=True) if not lines: return 0 chars_written = 0 for line in lines: if self.is_full: break original_line = line remaining_chars = self.max_chars - self._n_chars limit = ( min(remaining_chars, self.max_line_length) if self.max_line_length is not None else remaining_chars ) line = truncate_line(line, limit, self._marker) if line != original_line: self._truncation_happened = True self._buffer.append(line) chars_written += len(line) self._n_chars += len(line) if line.endswith("\n"): self._n_lines += 1 return chars_written def display(self, *blocks: DisplayBlock) -> None: """Add display blocks to the tool result.""" self._display.extend(blocks) def extras(self, **extras: JsonType) -> None: """Add extra data to the tool result.""" if self._extras is None: self._extras = {} self._extras.update(extras) def ok(self, message: str = "", *, brief: str = "") -> ToolReturnValue: """Create a ToolReturnValue with is_error=False and the current output.""" output = "".join(self._buffer) final_message = message if final_message and not final_message.endswith("."): final_message += "." truncation_msg = "Output is truncated to fit in the message." if self._truncation_happened: if final_message: final_message += f" {truncation_msg}" else: final_message = truncation_msg return ToolReturnValue( is_error=False, output=output, message=final_message, display=([BriefDisplayBlock(text=brief)] if brief else []) + self._display, extras=self._extras, ) def error(self, message: str, *, brief: str) -> ToolReturnValue: """Create a ToolReturnValue with is_error=True and the current output.""" output = "".join(self._buffer) final_message = message if self._truncation_happened: truncation_msg = "Output is truncated to fit in the message." if final_message: final_message += f" {truncation_msg}" else: final_message = truncation_msg return ToolReturnValue( is_error=True, output=output, message=final_message, display=([BriefDisplayBlock(text=brief)] if brief else []) + self._display, extras=self._extras, ) class ToolRejectedError(ToolError): def __init__(self, message: str | None = None, brief: str = "Rejected by user"): super().__init__( message=message or ( "The tool call is rejected by the user. " "Please follow the new instructions from the user." ), brief=brief, ) ================================================ FILE: src/kimi_cli/tools/web/__init__.py ================================================ from .fetch import FetchURL from .search import SearchWeb __all__ = ("SearchWeb", "FetchURL") ================================================ FILE: src/kimi_cli/tools/web/fetch.md ================================================ Fetch a web page from a URL and extract main text content from it. ================================================ FILE: src/kimi_cli/tools/web/fetch.py ================================================ from pathlib import Path from typing import override import aiohttp import trafilatura from kosong.tooling import CallableTool2, ToolReturnValue from pydantic import BaseModel, Field from kimi_cli.config import Config from kimi_cli.constant import USER_AGENT from kimi_cli.soul.agent import Runtime from kimi_cli.soul.toolset import get_current_tool_call_or_none from kimi_cli.tools.utils import ToolResultBuilder, load_desc from kimi_cli.utils.aiohttp import new_client_session from kimi_cli.utils.logging import logger class Params(BaseModel): url: str = Field(description="The URL to fetch content from.") class FetchURL(CallableTool2[Params]): name: str = "FetchURL" description: str = load_desc(Path(__file__).parent / "fetch.md", {}) params: type[Params] = Params def __init__(self, config: Config, runtime: Runtime): super().__init__() self._runtime = runtime self._service_config = config.services.moonshot_fetch @override async def __call__(self, params: Params) -> ToolReturnValue: if self._service_config: ret = await self._fetch_with_service(params) if not ret.is_error: return ret logger.warning("Failed to fetch URL via service: {error}", error=ret.message) # fallback to local fetch if service fetch fails return await self.fetch_with_http_get(params) @staticmethod async def fetch_with_http_get(params: Params) -> ToolReturnValue: builder = ToolResultBuilder(max_line_length=None) try: async with ( new_client_session() as session, session.get( params.url, headers={ "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" ), }, ) as response, ): if response.status >= 400: return builder.error( ( f"Failed to fetch URL. Status: {response.status}. " f"This may indicate the page is not accessible or the server is down." ), brief=f"HTTP {response.status} error", ) resp_text = await response.text() content_type = response.headers.get(aiohttp.hdrs.CONTENT_TYPE, "").lower() if content_type.startswith(("text/plain", "text/markdown")): builder.write(resp_text) return builder.ok("The returned content is the full content of the page.") except aiohttp.ClientError as e: return builder.error( ( f"Failed to fetch URL due to network error: {str(e)}. " "This may indicate the URL is invalid or the server is unreachable." ), brief="Network error", ) if not resp_text: return builder.ok( "The response body is empty.", brief="Empty response body", ) extracted_text = trafilatura.extract( resp_text, include_comments=True, include_tables=True, include_formatting=False, output_format="txt", with_metadata=True, ) if not extracted_text: return builder.error( ( "Failed to extract meaningful content from the page. " "This may indicate the page content is not suitable for text extraction, " "or the page requires JavaScript to render its content." ), brief="No content extracted", ) builder.write(extracted_text) return builder.ok("The returned content is the main text content extracted from the page.") async def _fetch_with_service(self, params: Params) -> ToolReturnValue: assert self._service_config is not None tool_call = get_current_tool_call_or_none() assert tool_call is not None, "Tool call is expected to be set" builder = ToolResultBuilder(max_line_length=None) api_key = self._runtime.oauth.resolve_api_key( self._service_config.api_key, self._service_config.oauth ) if not api_key: return builder.error( "Fetch service is not configured. You may want to try other methods to fetch.", brief="Fetch service not configured", ) headers = { "User-Agent": USER_AGENT, "Authorization": f"Bearer {api_key}", "Accept": "text/markdown", "X-Msh-Tool-Call-Id": tool_call.id, **self._runtime.oauth.common_headers(), **(self._service_config.custom_headers or {}), } try: async with ( new_client_session() as session, session.post( self._service_config.base_url, headers=headers, json={"url": params.url}, ) as response, ): if response.status != 200: return builder.error( f"Failed to fetch URL via service. Status: {response.status}.", brief="Failed to fetch URL via fetch service", ) content = await response.text() builder.write(content) return builder.ok( "The returned content is the main content extracted from the page." ) except aiohttp.ClientError as e: return builder.error( ( f"Failed to fetch URL via service due to network error: {str(e)}. " "This may indicate the service is unreachable." ), brief="Network error when calling fetch service", ) ================================================ FILE: src/kimi_cli/tools/web/search.md ================================================ WebSearch tool allows you to search on the internet to get latest information, including news, documents, release notes, blog posts, papers, etc. ================================================ FILE: src/kimi_cli/tools/web/search.py ================================================ from pathlib import Path from typing import override from kosong.tooling import CallableTool2, ToolReturnValue from pydantic import BaseModel, Field, ValidationError from kimi_cli.config import Config from kimi_cli.constant import USER_AGENT from kimi_cli.soul.agent import Runtime from kimi_cli.soul.toolset import get_current_tool_call_or_none from kimi_cli.tools import SkipThisTool from kimi_cli.tools.utils import ToolResultBuilder, load_desc from kimi_cli.utils.aiohttp import new_client_session class Params(BaseModel): query: str = Field(description="The query text to search for.") limit: int = Field( description=( "The number of results to return. " "Typically you do not need to set this value. " "When the results do not contain what you need, " "you probably want to give a more concrete query." ), default=5, ge=1, le=20, ) include_content: bool = Field( description=( "Whether to include the content of the web pages in the results. " "It can consume a large amount of tokens when this is set to True. " "You should avoid enabling this when `limit` is set to a large value." ), default=False, ) class SearchWeb(CallableTool2[Params]): name: str = "SearchWeb" description: str = load_desc(Path(__file__).parent / "search.md", {}) params: type[Params] = Params def __init__(self, config: Config, runtime: Runtime): super().__init__() if config.services.moonshot_search is None: raise SkipThisTool() self._runtime = runtime self._base_url = config.services.moonshot_search.base_url self._api_key = config.services.moonshot_search.api_key self._oauth_ref = config.services.moonshot_search.oauth self._custom_headers = config.services.moonshot_search.custom_headers or {} @override async def __call__(self, params: Params) -> ToolReturnValue: builder = ToolResultBuilder(max_line_length=None) api_key = self._runtime.oauth.resolve_api_key(self._api_key, self._oauth_ref) if not self._base_url or not api_key: return builder.error( "Search service is not configured. You may want to try other methods to search.", brief="Search service not configured", ) tool_call = get_current_tool_call_or_none() assert tool_call is not None, "Tool call is expected to be set" async with ( new_client_session() as session, session.post( self._base_url, headers={ "User-Agent": USER_AGENT, "Authorization": f"Bearer {api_key}", "X-Msh-Tool-Call-Id": tool_call.id, **self._runtime.oauth.common_headers(), **self._custom_headers, }, json={ "text_query": params.query, "limit": params.limit, "enable_page_crawling": params.include_content, "timeout_seconds": 30, }, ) as response, ): if response.status != 200: return builder.error( ( f"Failed to search. Status: {response.status}. " "This may indicates that the search service is currently unavailable." ), brief="Failed to search", ) try: results = Response(**await response.json()).search_results except ValidationError as e: return builder.error( ( f"Failed to parse search results. Error: {e}. " "This may indicates that the search service is currently unavailable." ), brief="Failed to parse search results", ) for i, result in enumerate(results): if i > 0: builder.write("---\n\n") builder.write( f"Title: {result.title}\nDate: {result.date}\n" f"URL: {result.url}\nSummary: {result.snippet}\n\n" ) if result.content: builder.write(f"{result.content}\n\n") return builder.ok() class SearchResult(BaseModel): site_name: str title: str url: str snippet: str content: str = "" date: str = "" icon: str = "" mime: str = "" class Response(BaseModel): search_results: list[SearchResult] ================================================ FILE: src/kimi_cli/ui/__init__.py ================================================ ================================================ FILE: src/kimi_cli/ui/acp/__init__.py ================================================ from __future__ import annotations from typing import Any, NoReturn import acp from kimi_cli.acp.types import ACPContentBlock, MCPServer from kimi_cli.soul import Soul from kimi_cli.utils.logging import logger _DEPRECATED_MESSAGE = ( "`kimi --acp` is deprecated. " "Update your ACP client settings to use `kimi acp` without any flags or options." ) class ACPServerSingleSession: def __init__(self, soul: Soul): self.soul = soul def on_connect(self, conn: acp.Client) -> None: logger.info("ACP client connected") def _raise(self) -> NoReturn: logger.error(_DEPRECATED_MESSAGE) raise acp.RequestError.invalid_params({"error": _DEPRECATED_MESSAGE}) async def initialize( self, protocol_version: int, client_capabilities: acp.schema.ClientCapabilities | None = None, client_info: acp.schema.Implementation | None = None, **kwargs: Any, ) -> acp.InitializeResponse: self._raise() async def new_session( self, cwd: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any ) -> acp.NewSessionResponse: self._raise() async def load_session( self, cwd: str, session_id: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any ) -> None: self._raise() async def resume_session( self, cwd: str, session_id: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any ) -> acp.schema.ResumeSessionResponse: self._raise() async def fork_session( self, cwd: str, session_id: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any ) -> acp.schema.ForkSessionResponse: self._raise() async def list_sessions( self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any ) -> acp.schema.ListSessionsResponse: self._raise() async def set_session_mode( self, mode_id: str, session_id: str, **kwargs: Any ) -> acp.SetSessionModeResponse | None: self._raise() async def set_session_model( self, model_id: str, session_id: str, **kwargs: Any ) -> acp.SetSessionModelResponse | None: self._raise() async def authenticate(self, method_id: str, **kwargs: Any) -> acp.AuthenticateResponse | None: self._raise() async def prompt( self, prompt: list[ACPContentBlock], session_id: str, **kwargs: Any ) -> acp.PromptResponse: self._raise() async def cancel(self, session_id: str, **kwargs: Any) -> None: self._raise() async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]: self._raise() async def ext_notification(self, method: str, params: dict[str, Any]) -> None: self._raise() class ACP: """ACP server using the official acp library.""" def __init__(self, soul: Soul): self.soul = soul async def run(self): """Run the ACP server.""" logger.info("Starting ACP server (single session) on stdio") await acp.run_agent(ACPServerSingleSession(self.soul)) ================================================ FILE: src/kimi_cli/ui/print/__init__.py ================================================ from __future__ import annotations import asyncio import json import sys from functools import partial from pathlib import Path from kosong.chat_provider import ChatProviderError from kosong.message import Message from rich import print from kimi_cli.cli import InputFormat, OutputFormat from kimi_cli.soul import ( LLMNotSet, LLMNotSupported, MaxStepsReached, RunCancelled, Soul, run_soul, ) from kimi_cli.soul.kimisoul import KimiSoul from kimi_cli.ui.print.visualize import visualize from kimi_cli.utils.logging import logger from kimi_cli.utils.signals import install_sigint_handler class Print: """ An app implementation that prints the agent behavior to the console. Args: soul (Soul): The soul to run. input_format (InputFormat): The input format to use. output_format (OutputFormat): The output format to use. context_file (Path): The file to store the context. final_only (bool): Whether to only print the final assistant message. """ def __init__( self, soul: Soul, input_format: InputFormat, output_format: OutputFormat, context_file: Path, *, final_only: bool = False, ): self.soul = soul self.input_format: InputFormat = input_format self.output_format: OutputFormat = output_format self.context_file = context_file self.final_only = final_only async def run(self, command: str | None = None) -> bool: cancel_event = asyncio.Event() def _handler(): logger.debug("SIGINT received.") cancel_event.set() loop = asyncio.get_running_loop() remove_sigint = install_sigint_handler(loop, _handler) if command is None and not sys.stdin.isatty() and self.input_format == "text": command = sys.stdin.read().strip() logger.info("Read command from stdin: {command}", command=command) try: while True: if command is None: if self.input_format == "text": return True else: assert self.input_format == "stream-json" command = self._read_next_command() if command is None: return True if command: logger.info("Running agent with command: {command}", command=command) if self.output_format == "text" and not self.final_only: print(command) runtime = self.soul.runtime if isinstance(self.soul, KimiSoul) else None await run_soul( self.soul, command, partial(visualize, self.output_format, self.final_only), cancel_event, runtime.session.wire_file if runtime else None, runtime, ) else: logger.info("Empty command, skipping") command = None except LLMNotSet as e: logger.exception("LLM not set:") print(str(e)) except LLMNotSupported as e: logger.exception("LLM not supported:") print(str(e)) except ChatProviderError as e: logger.exception("LLM provider error:") print(str(e)) except MaxStepsReached as e: logger.warning("Max steps reached: {n_steps}", n_steps=e.n_steps) print(str(e)) except RunCancelled: logger.error("Interrupted by user") print("Interrupted by user") except BaseException as e: logger.exception("Unknown error:") print(f"Unknown error: {e}") raise finally: remove_sigint() return False def _read_next_command(self) -> str | None: while True: json_line = sys.stdin.readline() if not json_line: # EOF return None json_line = json_line.strip() if not json_line: # for empty line, read next line continue try: data = json.loads(json_line) message = Message.model_validate(data) if message.role == "user": return message.extract_text(sep="\n") logger.warning( "Ignoring message with role `{role}`: {json_line}", role=message.role, json_line=json_line, ) except Exception: logger.warning("Ignoring invalid user message: {json_line}", json_line=json_line) ================================================ FILE: src/kimi_cli/ui/print/visualize.py ================================================ from typing import Protocol import rich from kosong.message import Message from kimi_cli.cli import OutputFormat from kimi_cli.soul.message import tool_result_to_message from kimi_cli.utils.aioqueue import QueueShutDown from kimi_cli.wire import Wire from kimi_cli.wire.types import ( ContentPart, Notification, StepBegin, StepInterrupted, ToolCall, ToolCallPart, ToolResult, WireMessage, ) class Printer(Protocol): def feed(self, msg: WireMessage) -> None: ... def flush(self) -> None: ... def _merge_content(buffer: list[ContentPart], part: ContentPart) -> None: if not buffer or not buffer[-1].merge_in_place(part): buffer.append(part) class TextPrinter(Printer): def feed(self, msg: WireMessage) -> None: rich.print(msg) def flush(self) -> None: pass class JsonPrinter(Printer): def __init__(self) -> None: self._content_buffer: list[ContentPart] = [] """The buffer to merge content parts.""" self._tool_call_buffer: list[ToolCall] = [] """The buffer to store the current assistant message's tool calls.""" self._pending_notifications: list[Notification] = [] """Notifications buffered until the current assistant message reaches a safe boundary.""" self._last_tool_call: ToolCall | None = None def feed(self, msg: WireMessage) -> None: match msg: case StepBegin() | StepInterrupted(): self.flush() case Notification() as notification: if self._content_buffer or self._tool_call_buffer: self._pending_notifications.append(notification) else: self._flush_assistant_message() self._flush_notifications() self._emit_notification(notification) case ContentPart() as part: # merge with previous parts as much as possible _merge_content(self._content_buffer, part) case ToolCall() as call: self._tool_call_buffer.append(call) self._last_tool_call = call case ToolCallPart() as part: if self._last_tool_call is None: return assert self._last_tool_call.merge_in_place(part) case ToolResult() as result: self._flush_assistant_message() self._flush_notifications() message = tool_result_to_message(result) print(message.model_dump_json(exclude_none=True), flush=True) case _: # ignore other messages pass def _flush_assistant_message(self) -> None: if not self._content_buffer and not self._tool_call_buffer: return message = Message( role="assistant", content=self._content_buffer, tool_calls=self._tool_call_buffer or None, ) print(message.model_dump_json(exclude_none=True), flush=True) self._content_buffer.clear() self._tool_call_buffer.clear() self._last_tool_call = None def _emit_notification(self, notification: Notification) -> None: print(notification.model_dump_json(exclude_none=True), flush=True) def _flush_notifications(self) -> None: for notification in self._pending_notifications: self._emit_notification(notification) self._pending_notifications.clear() def flush(self) -> None: self._flush_assistant_message() self._flush_notifications() class FinalOnlyTextPrinter(Printer): def __init__(self) -> None: self._content_buffer: list[ContentPart] = [] def feed(self, msg: WireMessage) -> None: match msg: case StepBegin() | StepInterrupted(): self._content_buffer.clear() case ContentPart() as part: _merge_content(self._content_buffer, part) case _: pass def flush(self) -> None: if not self._content_buffer: return message = Message(role="assistant", content=self._content_buffer) text = message.extract_text() if text: print(text, flush=True) self._content_buffer.clear() class FinalOnlyJsonPrinter(Printer): def __init__(self) -> None: self._content_buffer: list[ContentPart] = [] def feed(self, msg: WireMessage) -> None: match msg: case StepBegin() | StepInterrupted(): self._content_buffer.clear() case ContentPart() as part: _merge_content(self._content_buffer, part) case _: pass def flush(self) -> None: if not self._content_buffer: return message = Message(role="assistant", content=self._content_buffer) text = message.extract_text() if text: final_message = Message(role="assistant", content=text) print(final_message.model_dump_json(exclude_none=True), flush=True) self._content_buffer.clear() async def visualize(output_format: OutputFormat, final_only: bool, wire: Wire) -> None: if final_only: match output_format: case "text": handler = FinalOnlyTextPrinter() case "stream-json": handler = FinalOnlyJsonPrinter() else: match output_format: case "text": handler = TextPrinter() case "stream-json": handler = JsonPrinter() wire_ui = wire.ui_side(merge=True) while True: try: msg = await wire_ui.receive() except QueueShutDown: handler.flush() break handler.feed(msg) if isinstance(msg, StepInterrupted): break ================================================ FILE: src/kimi_cli/ui/shell/__init__.py ================================================ from __future__ import annotations import asyncio import contextlib import shlex import time from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass from enum import Enum from typing import Any from kosong.chat_provider import APIStatusError, ChatProviderError from rich.console import Group, RenderableType from rich.panel import Panel from rich.table import Table from rich.text import Text from kimi_cli import logger from kimi_cli.background import list_task_views from kimi_cli.notifications import NotificationWatcher from kimi_cli.soul import LLMNotSet, LLMNotSupported, MaxStepsReached, RunCancelled, Soul, run_soul from kimi_cli.soul.kimisoul import KimiSoul from kimi_cli.ui.shell import update as _update_mod from kimi_cli.ui.shell.console import console from kimi_cli.ui.shell.echo import render_user_echo_text from kimi_cli.ui.shell.mcp_status import render_mcp_prompt from kimi_cli.ui.shell.prompt import ( CustomPromptSession, PromptMode, UserInput, toast, ) from kimi_cli.ui.shell.replay import replay_recent_history from kimi_cli.ui.shell.slash import registry as shell_slash_registry from kimi_cli.ui.shell.slash import shell_mode_registry from kimi_cli.ui.shell.update import LATEST_VERSION_FILE, UpdateResult, do_update, semver_tuple from kimi_cli.ui.shell.visualize import visualize from kimi_cli.utils.envvar import get_env_bool from kimi_cli.utils.logging import open_original_stderr from kimi_cli.utils.signals import install_sigint_handler from kimi_cli.utils.slashcmd import SlashCommand, SlashCommandCall, parse_slash_command_call from kimi_cli.utils.subprocess_env import get_clean_env from kimi_cli.utils.term import ensure_new_line, ensure_tty_sane from kimi_cli.wire.types import ContentPart, StatusUpdate @dataclass(slots=True) class _PromptEvent: kind: str user_input: UserInput | None = None class Shell: def __init__(self, soul: Soul, welcome_info: list[WelcomeInfoItem] | None = None): self.soul = soul self._welcome_info = list(welcome_info or []) self._background_tasks: set[asyncio.Task[Any]] = set() self._prompt_session: CustomPromptSession | None = None self._running_input_handler: Callable[[UserInput], None] | None = None self._running_interrupt_handler: Callable[[], None] | None = None self._exit_after_run = False self._available_slash_commands: dict[str, SlashCommand[Any]] = { **{cmd.name: cmd for cmd in soul.available_slash_commands}, **{cmd.name: cmd for cmd in shell_slash_registry.list_commands()}, } """Shell-level slash commands + soul-level slash commands. Name to command mapping.""" @property def available_slash_commands(self) -> dict[str, SlashCommand[Any]]: """Get all available slash commands, including shell-level and soul-level commands.""" return self._available_slash_commands @staticmethod def _should_exit_input(user_input: UserInput) -> bool: return user_input.command.strip() in {"exit", "quit", "/exit", "/quit"} @staticmethod def _agent_slash_command_call(user_input: UserInput) -> SlashCommandCall | None: if user_input.mode != PromptMode.AGENT: return None display_call = parse_slash_command_call(user_input.command) if display_call is None: return None resolved_call = parse_slash_command_call(user_input.resolved_command) if resolved_call is None or resolved_call.name != display_call.name: return display_call return resolved_call @staticmethod def _should_echo_agent_input(user_input: UserInput) -> bool: if user_input.mode != PromptMode.AGENT: return False if Shell._should_exit_input(user_input): return False return Shell._agent_slash_command_call(user_input) is None @staticmethod def _echo_agent_input(user_input: UserInput) -> None: console.print(render_user_echo_text(user_input.command)) def _bind_running_input( self, on_input: Callable[[UserInput], None], on_interrupt: Callable[[], None], ) -> None: self._running_input_handler = on_input self._running_interrupt_handler = on_interrupt def _unbind_running_input(self) -> None: self._running_input_handler = None self._running_interrupt_handler = None async def _route_prompt_events( self, prompt_session: CustomPromptSession, idle_events: asyncio.Queue[_PromptEvent], resume_prompt: asyncio.Event, ) -> None: while True: # Keep exactly one active prompt read. Idle submissions pause the # router until the shell decides whether the next prompt should # wait for a blocking action or stay live during an agent run. await resume_prompt.wait() ensure_tty_sane() try: ensure_new_line() user_input = await prompt_session.prompt_next() except KeyboardInterrupt: logger.debug("Prompt router got KeyboardInterrupt") if self._running_input_handler is not None: if self._running_interrupt_handler is not None: self._running_interrupt_handler() continue resume_prompt.clear() await idle_events.put(_PromptEvent(kind="interrupt")) continue except EOFError: logger.debug("Prompt router got EOF") if self._running_input_handler is not None: self._exit_after_run = True if self._running_interrupt_handler is not None: self._running_interrupt_handler() return resume_prompt.clear() await idle_events.put(_PromptEvent(kind="eof")) return except Exception: logger.exception("Prompt router crashed") resume_prompt.clear() await idle_events.put(_PromptEvent(kind="error")) return if prompt_session.last_submission_was_running: # noqa: SIM102 if self._running_input_handler is not None: if user_input: self._running_input_handler(user_input) continue # Handler already unbound — fall through to idle path. resume_prompt.clear() await idle_events.put(_PromptEvent(kind="input", user_input=user_input)) async def run(self, command: str | None = None) -> bool: if command is not None: # run single command and exit logger.info("Running agent with command: {command}", command=command) return await self.run_soul_command(command) # Start auto-update background task if not disabled if get_env_bool("KIMI_CLI_NO_AUTO_UPDATE"): logger.info("Auto-update disabled by KIMI_CLI_NO_AUTO_UPDATE environment variable") else: self._start_background_task(self._auto_update()) _print_welcome_info(self.soul.name or "Kimi Code CLI", self._welcome_info) if isinstance(self.soul, KimiSoul): watcher = NotificationWatcher( self.soul.runtime.notifications, sink="shell", before_poll=self.soul.runtime.background_tasks.reconcile, on_notification=lambda notification: toast( f"[{notification.event.type}] {notification.event.title}", topic="notification", duration=10.0, ), ) self._start_background_task(watcher.run_forever()) await replay_recent_history( self.soul.context.history, wire_file=self.soul.wire_file, ) await self.soul.start_background_mcp_loading() async def _plan_mode_toggle() -> bool: if isinstance(self.soul, KimiSoul): return await self.soul.toggle_plan_mode_from_manual() return False def _mcp_status_block(columns: int): if not isinstance(self.soul, KimiSoul): return None snapshot = self.soul.status.mcp_status if snapshot is None: return None return render_mcp_prompt(snapshot) def _mcp_status_loading() -> bool: if not isinstance(self.soul, KimiSoul): return False snapshot = self.soul.status.mcp_status return bool(snapshot and snapshot.loading) @dataclass class _BgCountCache: time: float = 0.0 count: int = 0 _bg_cache = _BgCountCache() def _bg_task_count() -> int: if not isinstance(self.soul, KimiSoul): return 0 now = time.monotonic() if now - _bg_cache.time < 1.0: return _bg_cache.count views = list_task_views(self.soul.runtime.background_tasks, active_only=True) _bg_cache.count = sum(1 for v in views if v.spec.kind == "bash") _bg_cache.time = now return _bg_cache.count with CustomPromptSession( status_provider=lambda: self.soul.status, status_block_provider=_mcp_status_block, fast_refresh_provider=_mcp_status_loading, background_task_count_provider=_bg_task_count, model_capabilities=self.soul.model_capabilities or set(), model_name=self.soul.model_name, thinking=self.soul.thinking or False, agent_mode_slash_commands=list(self._available_slash_commands.values()), shell_mode_slash_commands=shell_mode_registry.list_commands(), editor_command_provider=lambda: ( self.soul.runtime.config.default_editor if isinstance(self.soul, KimiSoul) else "" ), plan_mode_toggle_callback=_plan_mode_toggle, ) as prompt_session: self._prompt_session = prompt_session if isinstance(self.soul, KimiSoul): kimi_soul = self.soul snapshot = kimi_soul.status.mcp_status if snapshot and snapshot.loading: async def _invalidate_after_mcp_loading() -> None: try: await kimi_soul.wait_for_background_mcp_loading() except Exception: logger.debug("MCP loading finished with error while refreshing prompt") if self._prompt_session is prompt_session: prompt_session.invalidate() self._start_background_task(_invalidate_after_mcp_loading()) self._exit_after_run = False idle_events: asyncio.Queue[_PromptEvent] = asyncio.Queue() # resume_prompt controls whether the prompt router reads input. # Set BEFORE an await = prompt stays live during the operation # (agent runs that accept steer input); set AFTER = prompt is # paused until the operation finishes. resume_prompt = asyncio.Event() resume_prompt.set() prompt_task = asyncio.create_task( self._route_prompt_events(prompt_session, idle_events, resume_prompt) ) shell_ok = True try: while True: event = await idle_events.get() if event.kind == "interrupt": console.print("[grey50]Tip: press Ctrl-D or send 'exit' to quit[/grey50]") resume_prompt.set() continue if event.kind == "eof": console.print("Bye!") break if event.kind == "error": shell_ok = False break user_input = event.user_input assert user_input is not None if not user_input: logger.debug("Got empty input, skipping") resume_prompt.set() continue logger.debug("Got user input: {user_input}", user_input=user_input) if self._should_echo_agent_input(user_input): self._echo_agent_input(user_input) if self._should_exit_input(user_input): logger.debug("Exiting by slash command") console.print("Bye!") break if user_input.mode == PromptMode.SHELL: await self._run_shell_command(user_input.command) resume_prompt.set() continue if slash_cmd_call := self._agent_slash_command_call(user_input): is_soul_slash = ( slash_cmd_call.name in self._available_slash_commands and shell_slash_registry.find_command(slash_cmd_call.name) is None ) if is_soul_slash: resume_prompt.set() await self.run_soul_command(slash_cmd_call.raw_input) console.print() if self._exit_after_run: console.print("Bye!") break else: await self._run_slash_command(slash_cmd_call) resume_prompt.set() continue resume_prompt.set() await self.run_soul_command(user_input.content) console.print() if self._exit_after_run: console.print("Bye!") break finally: prompt_task.cancel() with contextlib.suppress(asyncio.CancelledError): await prompt_task self._running_input_handler = None self._running_interrupt_handler = None self._prompt_session = None self._cancel_background_tasks() ensure_tty_sane() return shell_ok async def _run_shell_command(self, command: str) -> None: """Run a shell command in foreground.""" if not command.strip(): return # Check if it's an allowed slash command in shell mode if slash_cmd_call := parse_slash_command_call(command): if shell_mode_registry.find_command(slash_cmd_call.name): await self._run_slash_command(slash_cmd_call) return else: console.print( f'[yellow]"/{slash_cmd_call.name}" is not available in shell mode. ' "Press Ctrl-X to switch to agent mode.[/yellow]" ) return # Check if user is trying to use 'cd' command stripped_cmd = command.strip() split_cmd: list[str] | None = None try: split_cmd = shlex.split(stripped_cmd) except ValueError as exc: logger.debug("Failed to parse shell command for cd check: {error}", error=exc) if split_cmd and len(split_cmd) == 2 and split_cmd[0] == "cd": console.print( "[yellow]Warning: Directory changes are not preserved across command executions." "[/yellow]" ) return logger.info("Running shell command: {cmd}", cmd=command) proc: asyncio.subprocess.Process | None = None def _handler(): logger.debug("SIGINT received.") if proc: proc.terminate() loop = asyncio.get_running_loop() remove_sigint = install_sigint_handler(loop, _handler) try: # TODO: For the sake of simplicity, we now use `create_subprocess_shell`. # Later we should consider making this behave like a real shell. with open_original_stderr() as stderr: kwargs: dict[str, Any] = {} if stderr is not None: kwargs["stderr"] = stderr proc = await asyncio.create_subprocess_shell(command, env=get_clean_env(), **kwargs) await proc.wait() except Exception as e: logger.exception("Failed to run shell command:") console.print(f"[red]Failed to run shell command: {e}[/red]") finally: remove_sigint() async def _run_slash_command(self, command_call: SlashCommandCall) -> None: from kimi_cli.cli import Reload, SwitchToWeb if command_call.name not in self._available_slash_commands: logger.info("Unknown slash command /{command}", command=command_call.name) console.print( f'[red]Unknown slash command "/{command_call.name}", ' 'type "/" for all available commands[/red]' ) return command = shell_slash_registry.find_command(command_call.name) if command is None: # the input is a soul-level slash command call await self.run_soul_command(command_call.raw_input) return logger.debug( "Running shell-level slash command: /{command} with args: {args}", command=command_call.name, args=command_call.args, ) try: ret = command.func(self, command_call.args) if isinstance(ret, Awaitable): await ret except (Reload, SwitchToWeb): # just propagate raise except (asyncio.CancelledError, KeyboardInterrupt): # Handle Ctrl-C during slash command execution, return to shell prompt logger.debug("Slash command interrupted by KeyboardInterrupt") console.print("[red]Interrupted by user[/red]") except Exception as e: logger.exception("Unknown error:") console.print(f"[red]Unknown error: {e}[/red]") raise # re-raise unknown error async def run_soul_command(self, user_input: str | list[ContentPart]) -> bool: """ Run the soul and handle any known exceptions. Returns: bool: Whether the run is successful. """ logger.info("Running soul with user input: {user_input}", user_input=user_input) cancel_event = asyncio.Event() def _handler(): logger.debug("SIGINT received.") cancel_event.set() loop = asyncio.get_running_loop() remove_sigint = install_sigint_handler(loop, _handler) try: snap = self.soul.status runtime = self.soul.runtime if isinstance(self.soul, KimiSoul) else None await run_soul( self.soul, user_input, lambda wire: visualize( wire.ui_side(merge=False), # shell UI maintain its own merge buffer initial_status=StatusUpdate( context_usage=snap.context_usage, context_tokens=snap.context_tokens, max_context_tokens=snap.max_context_tokens, mcp_status=snap.mcp_status, ), cancel_event=cancel_event, prompt_session=self._prompt_session, steer=self.soul.steer if isinstance(self.soul, KimiSoul) else None, bind_running_input=self._bind_running_input, unbind_running_input=self._unbind_running_input, ), cancel_event, runtime.session.wire_file if runtime else None, runtime, ) return True except LLMNotSet: logger.exception("LLM not set:") console.print('[red]LLM not set, send "/login" to login[/red]') except LLMNotSupported as e: # actually unsupported input/mode should already be blocked by prompt session logger.exception("LLM not supported:") console.print(f"[red]{e}[/red]") except ChatProviderError as e: logger.exception("LLM provider error:") if isinstance(e, APIStatusError) and e.status_code == 401: console.print("[red]Authorization failed, please check your login status[/red]") elif isinstance(e, APIStatusError) and e.status_code == 402: console.print("[red]Membership expired, please renew your plan[/red]") elif isinstance(e, APIStatusError) and e.status_code == 403: console.print("[red]Quota exceeded, please upgrade your plan or retry later[/red]") else: console.print(f"[red]LLM provider error: {e}[/red]") except MaxStepsReached as e: logger.warning("Max steps reached: {n_steps}", n_steps=e.n_steps) console.print(f"[yellow]{e}[/yellow]") except RunCancelled: logger.info("Cancelled by user") console.print("[red]Interrupted by user[/red]") except Exception as e: logger.exception("Unexpected error:") console.print(f"[red]Unexpected error: {e}[/red]") raise # re-raise unknown error finally: remove_sigint() return False async def _auto_update(self) -> None: result = await do_update(print=False, check_only=True) if result == UpdateResult.UPDATE_AVAILABLE: while True: toast( f"new version found, run `{_update_mod.UPGRADE_COMMAND}` to upgrade", topic="update", duration=30.0, ) await asyncio.sleep(60.0) elif result == UpdateResult.UPDATED: toast("auto updated, restart to use the new version", topic="update", duration=5.0) def _start_background_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]: task = asyncio.create_task(coro) self._background_tasks.add(task) def _cleanup(t: asyncio.Task[Any]) -> None: self._background_tasks.discard(t) try: t.result() except asyncio.CancelledError: pass except Exception: logger.exception("Background task failed:") task.add_done_callback(_cleanup) return task def _cancel_background_tasks(self) -> None: """Cancel all background tasks (notification watcher, auto-update, etc.).""" for task in self._background_tasks: task.cancel() self._background_tasks.clear() _KIMI_BLUE = "dodger_blue1" _LOGO = f"""\ [{_KIMI_BLUE}]\ ▐█▛█▛█▌ ▐█████▌\ [{_KIMI_BLUE}]\ """ @dataclass(slots=True) class WelcomeInfoItem: class Level(Enum): INFO = "grey50" WARN = "yellow" ERROR = "red" name: str value: str level: Level = Level.INFO def _print_welcome_info(name: str, info_items: list[WelcomeInfoItem]) -> None: head = Text.from_markup("Welcome to Kimi Code CLI!") help_text = Text.from_markup("[grey50]Send /help for help information.[/grey50]") # Use Table for precise width control logo = Text.from_markup(_LOGO) table = Table(show_header=False, show_edge=False, box=None, padding=(0, 1), expand=False) table.add_column(justify="left") table.add_column(justify="left") table.add_row(logo, Group(head, help_text)) rows: list[RenderableType] = [table] if info_items: rows.append(Text("")) # empty line for item in info_items: rows.append(Text(f"{item.name}: {item.value}", style=item.level.value)) if LATEST_VERSION_FILE.exists(): from kimi_cli.constant import VERSION as current_version latest_version = LATEST_VERSION_FILE.read_text(encoding="utf-8").strip() if semver_tuple(latest_version) > semver_tuple(current_version): rows.append( Text.from_markup( f"\n[yellow]New version available: {latest_version}. " f"Please run `{_update_mod.UPGRADE_COMMAND}` to upgrade.[/yellow]" ) ) console.print( Panel( Group(*rows), border_style=_KIMI_BLUE, expand=False, padding=(1, 2), ) ) ================================================ FILE: src/kimi_cli/ui/shell/console.py ================================================ from __future__ import annotations from rich.console import Console from rich.theme import Theme NEUTRAL_MARKDOWN_THEME = Theme( { "markdown.paragraph": "none", "markdown.block_quote": "none", "markdown.hr": "none", "markdown.item": "none", "markdown.item.bullet": "none", "markdown.item.number": "none", "markdown.link": "none", "markdown.link_url": "none", "markdown.h1": "none", "markdown.h1.border": "none", "markdown.h2": "none", "markdown.h3": "none", "markdown.h4": "none", "markdown.h5": "none", "markdown.h6": "none", "markdown.em": "none", "markdown.strong": "none", "markdown.s": "none", "status.spinner": "none", }, inherit=True, ) _NEUTRAL_MARKDOWN_THEME = NEUTRAL_MARKDOWN_THEME console = Console(highlight=False, theme=NEUTRAL_MARKDOWN_THEME) ================================================ FILE: src/kimi_cli/ui/shell/debug.py ================================================ from __future__ import annotations import json from typing import TYPE_CHECKING from kosong.message import Message from rich.console import Group, RenderableType from rich.panel import Panel from rich.rule import Rule from rich.syntax import Syntax from rich.text import Text from kimi_cli.soul.kimisoul import KimiSoul from kimi_cli.ui.shell.console import console from kimi_cli.ui.shell.slash import registry from kimi_cli.wire.types import ( AudioURLPart, ContentPart, ImageURLPart, TextPart, ThinkPart, ToolCall, VideoURLPart, ) if TYPE_CHECKING: from kimi_cli.ui.shell import Shell def _format_content_part(part: ContentPart) -> Text | Panel | Group: """Format a single content part.""" match part: case TextPart(text=text): # Check if it looks like a system tag if text.strip().startswith("") and text.strip().endswith(""): return Panel( text.strip()[8:-9].strip(), title="[dim]system[/dim]", border_style="dim yellow", padding=(0, 1), ) return Text(text, style="white") case ThinkPart(think=think): return Panel( think, title="[dim]thinking[/dim]", border_style="dim cyan", padding=(0, 1), ) case ImageURLPart(image_url=img): url_display = img.url[:80] + "..." if len(img.url) > 80 else img.url return Text(f"[Image] {url_display}", style="blue") case AudioURLPart(audio_url=audio): url_display = audio.url[:80] + "..." if len(audio.url) > 80 else audio.url id_text = f" (id: {audio.id})" if audio.id else "" return Text(f"[Audio{id_text}] {url_display}", style="blue") case VideoURLPart(video_url=video): url_display = video.url[:80] + "..." if len(video.url) > 80 else video.url return Text(f"[Video] {url_display}", style="blue") case _: return Text(f"[Unknown content type: {type(part).__name__}]", style="red") def _format_tool_call(tool_call: ToolCall) -> Panel: """Format a tool call.""" args = tool_call.function.arguments or "{}" try: args_formatted = json.dumps(json.loads(args), indent=2) args_syntax = Syntax(args_formatted, "json", theme="monokai", padding=(0, 1)) except json.JSONDecodeError: args_syntax = Text(args, style="red") content = Group( Text(f"Function: {tool_call.function.name}", style="bold cyan"), Text(f"Call ID: {tool_call.id}", style="dim"), Text("Arguments:", style="bold"), args_syntax, ) return Panel( content, title="[bold yellow]Tool Call[/bold yellow]", border_style="yellow", padding=(0, 1), ) def _format_message(msg: Message, index: int) -> Panel: """Format a single message.""" # Role styling role_colors = { "system": "magenta", "developer": "magenta", "user": "green", "assistant": "blue", "tool": "yellow", } role_color = role_colors.get(msg.role, "white") role_text = f"[bold {role_color}]{msg.role.upper()}[/bold {role_color}]" # Add name if present if msg.name: role_text += f" [dim]({msg.name})[/dim]" # Add tool call ID for tool messages if msg.tool_call_id: role_text += f" [dim]→ {msg.tool_call_id}[/dim]" # Format content content_items: list[RenderableType] = [] for part in msg.content: formatted = _format_content_part(part) content_items.append(formatted) # Add tool calls if present if msg.tool_calls: if content_items: content_items.append(Text()) # Empty line for tool_call in msg.tool_calls: content_items.append(_format_tool_call(tool_call)) # Combine all content if not content_items: content_items.append(Text("[empty message]", style="dim italic")) group = Group(*content_items) # Create panel title = f"#{index + 1} {role_text}" if msg.partial: title += " [dim italic](partial)[/dim italic]" return Panel( group, title=title, border_style=role_color, padding=(0, 1), ) @registry.command def debug(app: Shell, args: str): """Debug the context""" assert isinstance(app.soul, KimiSoul) context = app.soul.context history = context.history if not history: console.print( Panel( "Context is empty - no messages yet", border_style="yellow", padding=(1, 2), ) ) return # Build the debug output output_items = [ Panel( Group( Text(f"Total messages: {len(history)}", style="bold"), Text(f"Token count: {context.token_count:,}", style="bold"), Text(f"Checkpoints: {context.n_checkpoints}", style="bold"), Text(f"Trajectory: {context.file_backend}", style="dim"), ), title="[bold]Context Info[/bold]", border_style="cyan", padding=(0, 1), ), Rule(style="dim"), ] # Add all messages for idx, msg in enumerate(history): output_items.append(_format_message(msg, idx)) # Display using rich pager display_group = Group(*output_items) # Use pager to display with console.pager(styles=True): console.print(display_group) ================================================ FILE: src/kimi_cli/ui/shell/echo.py ================================================ from __future__ import annotations from kosong.message import Message from rich.text import Text from kimi_cli.ui.shell.prompt import PROMPT_SYMBOL from kimi_cli.utils.message import message_stringify def render_user_echo(message: Message) -> Text: """Render a user message as literal shell transcript text.""" return Text(f"{PROMPT_SYMBOL} {message_stringify(message)}") def render_user_echo_text(text: str) -> Text: """Render the local prompt text exactly as the user saw it in the buffer.""" return Text(f"{PROMPT_SYMBOL} {text}") ================================================ FILE: src/kimi_cli/ui/shell/export_import.py ================================================ from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING from kaos.path import KaosPath from kimi_cli.ui.shell.console import console from kimi_cli.ui.shell.slash import ensure_kimi_soul, registry, shell_mode_registry from kimi_cli.utils.export import is_sensitive_file from kimi_cli.utils.path import sanitize_cli_path, shorten_home from kimi_cli.wire.types import TurnBegin, TurnEnd if TYPE_CHECKING: from kimi_cli.ui.shell import Shell # --------------------------------------------------------------------------- # /export command # --------------------------------------------------------------------------- @registry.command @shell_mode_registry.command async def export(app: Shell, args: str): """Export current session context to a markdown file""" from kimi_cli.utils.export import perform_export soul = ensure_kimi_soul(app) if soul is None: return session = soul.runtime.session result = await perform_export( history=list(soul.context.history), session_id=session.id, work_dir=str(session.work_dir), token_count=soul.context.token_count, args=args, default_dir=Path(str(session.work_dir)), ) if isinstance(result, str): console.print(f"[yellow]{result}[/yellow]") return output, count = result display = shorten_home(KaosPath(str(output))) console.print(f"[green]Exported {count} messages to {display}[/green]") console.print( "[yellow]Note: The exported file may contain sensitive information. " "Please be cautious when sharing it externally.[/yellow]" ) # --------------------------------------------------------------------------- # /import command # --------------------------------------------------------------------------- @registry.command(name="import") @shell_mode_registry.command(name="import") async def import_context(app: Shell, args: str): """Import context from a file or session ID""" from kimi_cli.utils.export import perform_import soul = ensure_kimi_soul(app) if soul is None: return target = sanitize_cli_path(args) if not target: console.print("[yellow]Usage: /import [/yellow]") return session = soul.runtime.session raw_max_context_size = ( soul.runtime.llm.max_context_size if soul.runtime.llm is not None else None ) max_context_size = ( raw_max_context_size if isinstance(raw_max_context_size, int) and raw_max_context_size > 0 else None ) result = await perform_import( target=target, current_session_id=session.id, work_dir=session.work_dir, context=soul.context, max_context_size=max_context_size, ) if isinstance(result, str): console.print(f"[red]{result}[/red]") return source_desc, content_len = result # Write to wire file so the import appears in session replay await soul.wire_file.append_message( TurnBegin(user_input=f"[Imported context from {source_desc}]") ) await soul.wire_file.append_message(TurnEnd()) console.print( f"[green]Imported context from {source_desc} " f"({content_len} chars) into current session.[/green]" ) if source_desc.startswith("file") and is_sensitive_file(Path(target).name): console.print( "[yellow]Warning: This file may contain secrets (API keys, tokens, credentials). " "The content is now part of your session context.[/yellow]" ) ================================================ FILE: src/kimi_cli/ui/shell/keyboard.py ================================================ from __future__ import annotations import asyncio import sys import threading import time from collections.abc import AsyncGenerator, Callable from enum import Enum, auto from kimi_cli.utils.aioqueue import Queue class KeyEvent(Enum): UP = auto() DOWN = auto() LEFT = auto() RIGHT = auto() ENTER = auto() ESCAPE = auto() TAB = auto() SPACE = auto() CTRL_E = auto() NUM_1 = auto() NUM_2 = auto() NUM_3 = auto() NUM_4 = auto() NUM_5 = auto() NUM_6 = auto() class KeyboardListener: def __init__(self) -> None: self._queue = Queue[KeyEvent]() self._cancel_event = threading.Event() self._pause_event = threading.Event() self._paused_event = threading.Event() self._listener: threading.Thread | None = None self._loop: asyncio.AbstractEventLoop | None = None async def start(self) -> None: if self._listener is not None: return self._loop = asyncio.get_running_loop() def emit(event: KeyEvent) -> None: if self._loop is None: return self._loop.call_soon_threadsafe(self._queue.put_nowait, event) self._listener = threading.Thread( target=_listen_for_keyboard_thread, args=(self._cancel_event, self._pause_event, self._paused_event, emit), name="kimi-cli-keyboard-listener", daemon=True, ) self._listener.start() async def stop(self) -> None: self._cancel_event.set() self._pause_event.clear() if self._listener and self._listener.is_alive(): await asyncio.to_thread(self._listener.join) def _pause_sync(self) -> None: self._pause_event.set() self._paused_event.wait() async def pause(self) -> None: await asyncio.to_thread(self._pause_sync) def _resume_sync(self) -> None: self._pause_event.clear() while self._paused_event.is_set() and not self._cancel_event.is_set(): time.sleep(0.01) async def resume(self) -> None: await asyncio.to_thread(self._resume_sync) async def get(self) -> KeyEvent: return await self._queue.get() async def listen_for_keyboard() -> AsyncGenerator[KeyEvent]: listener = KeyboardListener() await listener.start() try: while True: yield await listener.get() finally: await listener.stop() def _listen_for_keyboard_thread( cancel: threading.Event, pause: threading.Event, paused: threading.Event, emit: Callable[[KeyEvent], None], ) -> None: if sys.platform == "win32": _listen_for_keyboard_windows(cancel, pause, paused, emit) else: _listen_for_keyboard_unix(cancel, pause, paused, emit) def _listen_for_keyboard_unix( cancel: threading.Event, pause: threading.Event, paused: threading.Event, emit: Callable[[KeyEvent], None], ) -> None: if sys.platform == "win32": raise RuntimeError("Unix keyboard listener requires a non-Windows platform") import termios fd = sys.stdin.fileno() oldterm = termios.tcgetattr(fd) rawattr = termios.tcgetattr(fd) rawattr[3] = rawattr[3] & ~termios.ICANON & ~termios.ECHO rawattr[6][termios.VMIN] = 0 rawattr[6][termios.VTIME] = 0 raw_enabled = False def enable_raw() -> None: nonlocal raw_enabled if raw_enabled: return termios.tcsetattr(fd, termios.TCSANOW, rawattr) raw_enabled = True def disable_raw() -> None: nonlocal raw_enabled if not raw_enabled: return termios.tcsetattr(fd, termios.TCSANOW, oldterm) raw_enabled = False enable_raw() try: while not cancel.is_set(): if pause.is_set(): disable_raw() paused.set() time.sleep(0.01) continue if paused.is_set(): paused.clear() enable_raw() try: c = sys.stdin.buffer.read(1) except (OSError, ValueError): c = b"" if not c: if cancel.is_set(): break time.sleep(0.01) continue if c == b"\x1b": sequence = c for _ in range(2): if cancel.is_set(): break try: fragment = sys.stdin.buffer.read(1) except (OSError, ValueError): fragment = b"" if not fragment: break sequence += fragment if sequence in _ARROW_KEY_MAP: break event = _ARROW_KEY_MAP.get(sequence) if event is not None: emit(event) elif sequence == b"\x1b": emit(KeyEvent.ESCAPE) elif c in (b"\r", b"\n"): emit(KeyEvent.ENTER) elif c == b" ": emit(KeyEvent.SPACE) elif c == b"\t": emit(KeyEvent.TAB) elif c == b"\x05": # Ctrl+E emit(KeyEvent.CTRL_E) elif c == b"1": emit(KeyEvent.NUM_1) elif c == b"2": emit(KeyEvent.NUM_2) elif c == b"3": emit(KeyEvent.NUM_3) elif c == b"4": emit(KeyEvent.NUM_4) elif c == b"5": emit(KeyEvent.NUM_5) elif c == b"6": emit(KeyEvent.NUM_6) finally: termios.tcsetattr(fd, termios.TCSAFLUSH, oldterm) def _listen_for_keyboard_windows( cancel: threading.Event, pause: threading.Event, paused: threading.Event, emit: Callable[[KeyEvent], None], ) -> None: if sys.platform != "win32": raise RuntimeError("Windows keyboard listener requires a Windows platform") import msvcrt while not cancel.is_set(): if pause.is_set(): paused.set() time.sleep(0.01) continue if paused.is_set(): paused.clear() if msvcrt.kbhit(): c = msvcrt.getch() # Handle special keys (arrow keys, etc.) if c in (b"\x00", b"\xe0"): # Extended key, read the next byte extended = msvcrt.getch() event = _WINDOWS_KEY_MAP.get(extended) if event is not None: emit(event) elif c == b"\x1b": sequence = c for _ in range(2): if cancel.is_set(): break fragment = msvcrt.getch() if msvcrt.kbhit() else b"" if not fragment: break sequence += fragment if sequence in _ARROW_KEY_MAP: break event = _ARROW_KEY_MAP.get(sequence) if event is not None: emit(event) elif sequence == b"\x1b": emit(KeyEvent.ESCAPE) elif c in (b"\r", b"\n"): emit(KeyEvent.ENTER) elif c == b" ": emit(KeyEvent.SPACE) elif c == b"\t": emit(KeyEvent.TAB) elif c == b"\x05": # Ctrl+E emit(KeyEvent.CTRL_E) elif c == b"1": emit(KeyEvent.NUM_1) elif c == b"2": emit(KeyEvent.NUM_2) elif c == b"3": emit(KeyEvent.NUM_3) elif c == b"4": emit(KeyEvent.NUM_4) elif c == b"5": emit(KeyEvent.NUM_5) elif c == b"6": emit(KeyEvent.NUM_6) else: if cancel.is_set(): break time.sleep(0.01) _ARROW_KEY_MAP: dict[bytes, KeyEvent] = { b"\x1b[A": KeyEvent.UP, b"\x1b[B": KeyEvent.DOWN, b"\x1b[C": KeyEvent.RIGHT, b"\x1b[D": KeyEvent.LEFT, } _WINDOWS_KEY_MAP: dict[bytes, KeyEvent] = { b"H": KeyEvent.UP, # Up arrow b"P": KeyEvent.DOWN, # Down arrow b"M": KeyEvent.RIGHT, # Right arrow b"K": KeyEvent.LEFT, # Left arrow } if __name__ == "__main__": async def dev_main(): async for event in listen_for_keyboard(): print(event) asyncio.run(dev_main()) ================================================ FILE: src/kimi_cli/ui/shell/mcp_status.py ================================================ from __future__ import annotations import time from prompt_toolkit.formatted_text import FormattedText from rich.console import Group, RenderableType from rich.spinner import Spinner from rich.text import Text from kimi_cli.utils.rich.columns import BulletColumns from kimi_cli.wire.types import MCPServerSnapshot, MCPStatusSnapshot _SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") def render_mcp_console(snapshot: MCPStatusSnapshot) -> RenderableType: header_text = Text.assemble( ("MCP Servers: ", "bold"), f"{snapshot.connected}/{snapshot.total} connected, {snapshot.tools} tools", ) header: RenderableType = Spinner("dots", header_text) if snapshot.loading else header_text renderables: list[RenderableType] = [BulletColumns(header)] for server in snapshot.servers: color = _status_color(server.status) server_text = f"[{color}]{server.name}[/{color}]" if server.status == "unauthorized": server_text += f" [grey50](unauthorized - run: kimi mcp auth {server.name})[/grey50]" elif server.status != "connected": server_text += f" [grey50]({server.status})[/grey50]" lines: list[RenderableType] = [Text.from_markup(server_text)] for tool_name in server.tools: lines.append( BulletColumns( Text.from_markup(f"[grey50]{tool_name}[/grey50]"), bullet_style="grey50", ) ) renderables.append(BulletColumns(Group(*lines), bullet_style=color)) return Group(*renderables) def render_mcp_prompt(snapshot: MCPStatusSnapshot, *, now: float | None = None) -> FormattedText: if not snapshot.loading: return FormattedText([]) fragments: list[tuple[str, str]] = [] prefix = f"{_spinner_frame(now)} " if snapshot.loading else "" fragments.append( ( "fg:#d4d4d4", ( f"{prefix}MCP Servers: " f"{snapshot.connected}/{snapshot.total} connected, {snapshot.tools} tools" ), ) ) fragments.append(("", "\n")) for server in snapshot.servers: fragments.append((_prompt_status_style(server.status), f"• {server.name}")) detail = _prompt_server_detail(server) if detail: fragments.append(("fg:#7c8594", detail)) fragments.append(("", "\n")) return FormattedText(fragments) def _spinner_frame(now: float | None = None) -> str: timestamp = time.monotonic() if now is None else now return _SPINNER_FRAMES[int(timestamp * 8) % len(_SPINNER_FRAMES)] def _status_color(status: str) -> str: return { "connected": "green", "connecting": "cyan", "pending": "yellow", "failed": "red", "unauthorized": "red", }.get(status, "red") def _prompt_status_style(status: str) -> str: return { "connected": "fg:#56d364", "connecting": "fg:#56a4ff", "pending": "fg:#f2cc60", "failed": "fg:#ff7b72", "unauthorized": "fg:#ff7b72", }.get(status, "fg:#ff7b72") def _prompt_server_detail(server: MCPServerSnapshot) -> str: if server.status == "unauthorized": return f" (unauthorized - run: kimi mcp auth {server.name})" parts: list[str] = [] if server.status != "connected": parts.append(server.status) if server.tools: label = "tool" if len(server.tools) == 1 else "tools" parts.append(f"{len(server.tools)} {label}") return f" ({', '.join(parts)})" if parts else "" ================================================ FILE: src/kimi_cli/ui/shell/oauth.py ================================================ from __future__ import annotations import asyncio from typing import TYPE_CHECKING from rich.status import Status from kimi_cli.auth import KIMI_CODE_PLATFORM_ID from kimi_cli.auth.oauth import login_kimi_code, logout_kimi_code from kimi_cli.auth.platforms import is_managed_provider_key, parse_managed_provider_key from kimi_cli.cli import Reload from kimi_cli.config import save_config from kimi_cli.soul.kimisoul import KimiSoul from kimi_cli.ui.shell.console import console from kimi_cli.ui.shell.setup import select_platform, setup_platform from kimi_cli.ui.shell.slash import ensure_kimi_soul, registry if TYPE_CHECKING: from kimi_cli.ui.shell import Shell async def _login_kimi_code(soul: KimiSoul) -> bool: status: Status | None = None ok = True try: async for event in login_kimi_code(soul.runtime.config): if event.type == "waiting": if status is None: status = console.status("[cyan]Waiting for user authorization...[/cyan]") status.start() continue if status is not None: status.stop() status = None match event.type: case "error": style = "red" case "success": style = "green" case _: style = None console.print(event.message, markup=False, style=style) if event.type == "error": ok = False finally: if status is not None: status.stop() return ok def _current_model_key(soul: KimiSoul) -> str | None: config = soul.runtime.config curr_model_cfg = soul.runtime.llm.model_config if soul.runtime.llm else None if curr_model_cfg is not None: for name, model_cfg in config.models.items(): if model_cfg == curr_model_cfg: return name return config.default_model or None @registry.command(aliases=["setup"]) async def login(app: Shell, args: str) -> None: """Login or setup a platform.""" soul = ensure_kimi_soul(app) if soul is None: return platform = await select_platform() if platform is None: return if platform.id == KIMI_CODE_PLATFORM_ID: ok = await _login_kimi_code(soul) else: ok = await setup_platform(platform) if not ok: return await asyncio.sleep(1) console.clear() raise Reload @registry.command async def logout(app: Shell, args: str) -> None: """Logout from the current platform.""" soul = ensure_kimi_soul(app) if soul is None: return config = soul.runtime.config if not config.is_from_default_location: console.print( "[red]Logout requires the default config file; " "restart without --config/--config-file.[/red]" ) return model_key = _current_model_key(soul) if not model_key: console.print("[yellow]No model selected; nothing to logout.[/yellow]") return model_cfg = config.models.get(model_key) if model_cfg is None: console.print("[yellow]Current model not found; nothing to logout.[/yellow]") return provider_key = model_cfg.provider if not is_managed_provider_key(provider_key): console.print("[yellow]Current provider is not managed; nothing to logout.[/yellow]") return platform_id = parse_managed_provider_key(provider_key) if not platform_id: console.print("[yellow]Current provider is not managed; nothing to logout.[/yellow]") return if platform_id == KIMI_CODE_PLATFORM_ID: ok = True async for event in logout_kimi_code(config): match event.type: case "error": style = "red" case "success": style = "green" case _: style = None console.print(event.message, markup=False, style=style) if event.type == "error": ok = False if not ok: return else: if provider_key in config.providers: del config.providers[provider_key] removed_default = False for key, model in list(config.models.items()): if model.provider != provider_key: continue del config.models[key] if config.default_model == key: removed_default = True if removed_default: config.default_model = "" save_config(config) console.print("[green]✓[/green] Logged out successfully.") await asyncio.sleep(1) console.clear() raise Reload ================================================ FILE: src/kimi_cli/ui/shell/placeholders.py ================================================ from __future__ import annotations import base64 import mimetypes import re from collections.abc import Callable, Sequence from dataclasses import dataclass from difflib import SequenceMatcher from hashlib import sha256 from io import BytesIO from pathlib import Path from typing import Literal, Protocol from PIL import Image from kimi_cli.share import get_share_dir from kimi_cli.utils.logging import logger from kimi_cli.utils.media_tags import wrap_media_part from kimi_cli.utils.string import random_string from kimi_cli.wire.types import ContentPart, ImageURLPart, TextPart _DEFAULT_PROMPT_CACHE_ROOT = get_share_dir() / "prompt-cache" _LEGACY_PROMPT_CACHE_ROOT = Path("/tmp/kimi") _IMAGE_PLACEHOLDER_RE = re.compile( r"\[(?P[a-zA-Z0-9_\-]+):(?P[a-zA-Z0-9_\-\.]+)" r"(?:,(?P\d+)x(?P\d+))?\]" ) _PASTED_TEXT_PLACEHOLDER_RE = re.compile( r"\[Pasted text #(?P\d+)(?: \+(?P\d+) lines?)?\]" ) _TEXT_PASTE_CHAR_THRESHOLD = 1000 _TEXT_PASTE_LINE_THRESHOLD = 15 def sanitize_surrogates(text: str) -> str: """Replace lone UTF-16 surrogates that cannot be encoded as UTF-8. Windows clipboard data sometimes contains unpaired surrogates from applications that use UTF-16 internally. Passing such strings to ``json.dumps`` or writing them to a UTF-8 file raises ``UnicodeEncodeError``, so we replace them with U+FFFD early. """ return text.encode("utf-8", errors="surrogatepass").decode("utf-8", errors="replace") def normalize_pasted_text(text: str) -> str: """Normalize pasted text into the same newline format used by prompt_toolkit.""" return text.replace("\r\n", "\n").replace("\r", "\n") def count_text_lines(text: str) -> int: if not text: return 1 return text.count("\n") + 1 def should_placeholderize_pasted_text(text: str) -> bool: normalized = normalize_pasted_text(text) return ( len(normalized) >= _TEXT_PASTE_CHAR_THRESHOLD or count_text_lines(normalized) >= _TEXT_PASTE_LINE_THRESHOLD ) def build_pasted_text_placeholder(paste_id: int, text: str) -> str: line_count = count_text_lines(text) if line_count <= 1: return f"[Pasted text #{paste_id}]" return f"[Pasted text #{paste_id} +{line_count} lines]" def _guess_image_mime(path: Path) -> str: mime, _ = mimetypes.guess_type(path.name) if mime: return mime return "image/png" def _build_image_part(image_bytes: bytes, mime_type: str) -> ImageURLPart: image_base64 = base64.b64encode(image_bytes).decode("ascii") return ImageURLPart( image_url=ImageURLPart.ImageURL( url=f"data:{mime_type};base64,{image_base64}", ) ) type CachedAttachmentKind = Literal["image"] @dataclass(slots=True) class CachedAttachment: kind: CachedAttachmentKind attachment_id: str path: Path class AttachmentCache: """Persistent cache for placeholder payloads that can safely survive history recall.""" def __init__( self, root: Path | None = None, *, legacy_roots: Sequence[Path] | None = None, ) -> None: self._root = root or _DEFAULT_PROMPT_CACHE_ROOT self._legacy_roots = tuple(legacy_roots or (_LEGACY_PROMPT_CACHE_ROOT,)) self._dir_map: dict[CachedAttachmentKind, str] = {"image": "images"} self._payload_map: dict[tuple[CachedAttachmentKind, str, str], CachedAttachment] = {} def _dir_for(self, kind: CachedAttachmentKind, *, root: Path | None = None) -> Path: return (self._root if root is None else root) / self._dir_map[kind] def _ensure_dir(self, kind: CachedAttachmentKind) -> Path | None: path = self._dir_for(kind) try: path.mkdir(parents=True, exist_ok=True) except OSError as exc: logger.warning( "Failed to create attachment cache dir: {dir} ({error})", dir=path, error=exc, ) return None return path def _reserve_id(self, dir_path: Path, suffix: str) -> str: for _ in range(5): candidate = f"{random_string(8)}{suffix}" if not (dir_path / candidate).exists(): return candidate return f"{random_string(12)}{suffix}" def store_bytes( self, kind: CachedAttachmentKind, suffix: str, payload: bytes ) -> CachedAttachment | None: dir_path = self._ensure_dir(kind) if dir_path is None: return None payload_hash = sha256(payload).hexdigest() cache_key = (kind, suffix, payload_hash) cached = self._payload_map.get(cache_key) if cached is not None: if cached.path.exists(): return cached self._payload_map.pop(cache_key, None) attachment_id = self._reserve_id(dir_path, suffix) path = dir_path / attachment_id try: path.write_bytes(payload) except OSError as exc: logger.warning( "Failed to write cached attachment: {file} ({error})", file=path, error=exc, ) return None cached = CachedAttachment(kind=kind, attachment_id=attachment_id, path=path) self._payload_map[cache_key] = cached return cached def store_image(self, image: Image.Image) -> CachedAttachment | None: png_bytes = BytesIO() image.save(png_bytes, format="PNG") return self.store_bytes("image", ".png", png_bytes.getvalue()) def _candidate_paths(self, kind: CachedAttachmentKind, attachment_id: str) -> list[Path]: roots = (self._root, *self._legacy_roots) return [self._dir_for(kind, root=root) / attachment_id for root in roots] def load_bytes( self, kind: CachedAttachmentKind, attachment_id: str ) -> tuple[Path, bytes] | None: for path in self._candidate_paths(kind, attachment_id): if not path.exists(): continue try: return path, path.read_bytes() except OSError as exc: logger.warning( "Failed to read cached attachment: {file} ({error})", file=path, error=exc, ) return None return None def load_content_parts( self, kind: CachedAttachmentKind, attachment_id: str ) -> list[ContentPart] | None: if kind == "image": payload = self.load_bytes(kind, attachment_id) if payload is None: return None path, image_bytes = payload mime_type = _guess_image_mime(path) part = _build_image_part(image_bytes, mime_type) return wrap_media_part(part, tag="image", attrs={"path": str(path)}) return None def parse_attachment_kind(raw_kind: str) -> CachedAttachmentKind | None: if raw_kind == "image": return "image" return None _parse_attachment_kind = parse_attachment_kind @dataclass(slots=True) class PlaceholderTokenMatch: start: int end: int raw: str handler: PlaceholderHandler match: re.Match[str] class PlaceholderHandler(Protocol): def find_next(self, text: str, start: int = 0) -> PlaceholderTokenMatch | None: ... def resolve_content(self, match: PlaceholderTokenMatch) -> list[ContentPart] | None: ... def expand_text(self, match: PlaceholderTokenMatch) -> str | None: ... def serialize_for_history(self, match: PlaceholderTokenMatch) -> str | None: ... def expand_for_editor(self, match: PlaceholderTokenMatch) -> str | None: ... @dataclass(slots=True) class PastedTextEntry: paste_id: int text: str @property def token(self) -> str: return build_pasted_text_placeholder(self.paste_id, self.text) class PastedTextPlaceholderHandler: def __init__(self) -> None: self._entries: dict[int, PastedTextEntry] = {} self._next_id = 1 def create_placeholder(self, text: str) -> str: normalized = sanitize_surrogates(normalize_pasted_text(text)) entry = PastedTextEntry(paste_id=self._next_id, text=normalized) self._entries[entry.paste_id] = entry self._next_id += 1 return entry.token def maybe_placeholderize(self, text: str) -> str: normalized = normalize_pasted_text(text) if not should_placeholderize_pasted_text(normalized): return normalized return self.create_placeholder(normalized) def entry_for_id(self, paste_id: int) -> PastedTextEntry | None: return self._entries.get(paste_id) def iter_entries_for_command( self, command: str ) -> list[tuple[PlaceholderTokenMatch, PastedTextEntry]]: entries: list[tuple[PlaceholderTokenMatch, PastedTextEntry]] = [] cursor = 0 while match := self.find_next(command, cursor): paste_id = int(match.match.group("id")) entry = self.entry_for_id(paste_id) if entry is not None: entries.append((match, entry)) cursor = match.end return entries def find_next(self, text: str, start: int = 0) -> PlaceholderTokenMatch | None: match = _PASTED_TEXT_PLACEHOLDER_RE.search(text, start) if match is None: return None return PlaceholderTokenMatch( start=match.start(), end=match.end(), raw=match.group(0), handler=self, match=match, ) def resolve_content(self, match: PlaceholderTokenMatch) -> list[ContentPart] | None: paste_id = int(match.match.group("id")) entry = self.entry_for_id(paste_id) if entry is None: return None return [TextPart(text=entry.text)] def expand_text(self, match: PlaceholderTokenMatch) -> str | None: paste_id = int(match.match.group("id")) entry = self.entry_for_id(paste_id) return None if entry is None else entry.text def serialize_for_history(self, match: PlaceholderTokenMatch) -> str | None: return self.expand_text(match) def expand_for_editor(self, match: PlaceholderTokenMatch) -> str | None: return self.expand_text(match) def refold_after_editor(self, edited_text: str, original_command: str) -> str: expanded_original, intervals = self._expanded_text_and_intervals(original_command) if not intervals: return edited_text opcodes = SequenceMatcher( a=expanded_original, b=edited_text, autojunk=False, ).get_opcodes() replacements: list[tuple[int, int, str]] = [] for start, end, token, expected_text in intervals: mapped = self._map_interval(opcodes, start, end) if mapped is None: continue mapped_start, mapped_end = mapped if edited_text[mapped_start:mapped_end] != expected_text: continue replacements.append((mapped_start, mapped_end, token)) result = edited_text for start, end, token in reversed(replacements): result = result[:start] + token + result[end:] return result def _expanded_text_and_intervals( self, command: str ) -> tuple[str, list[tuple[int, int, str, str]]]: parts: list[str] = [] intervals: list[tuple[int, int, str, str]] = [] cursor = 0 expanded_cursor = 0 for match, entry in self.iter_entries_for_command(command): literal = command[cursor : match.start] if literal: parts.append(literal) expanded_cursor += len(literal) start = expanded_cursor parts.append(entry.text) expanded_cursor += len(entry.text) intervals.append((start, expanded_cursor, match.raw, entry.text)) cursor = match.end if cursor < len(command): parts.append(command[cursor:]) return "".join(parts), intervals @staticmethod def _map_interval( opcodes: Sequence[tuple[str, int, int, int, int]], start: int, end: int ) -> tuple[int, int] | None: mapped_start: int | None = None mapped_end: int | None = None cursor = start for tag, i1, i2, j1, _j2 in opcodes: if i2 <= cursor: continue if i1 >= end: break overlap_start = max(i1, cursor, start) overlap_end = min(i2, end) if overlap_start >= overlap_end: continue if tag != "equal": return None segment_start = j1 + (overlap_start - i1) segment_end = j1 + (overlap_end - i1) if mapped_start is None: mapped_start = segment_start elif mapped_end != segment_start: return None mapped_end = segment_end cursor = overlap_end if cursor != end or mapped_start is None or mapped_end is None: return None return mapped_start, mapped_end class ImagePlaceholderHandler: def __init__(self, attachment_cache: AttachmentCache) -> None: self._attachment_cache = attachment_cache def create_placeholder(self, image: Image.Image) -> str | None: cached = self._attachment_cache.store_image(image) if cached is None: return None return f"[image:{cached.attachment_id},{image.width}x{image.height}]" def find_next(self, text: str, start: int = 0) -> PlaceholderTokenMatch | None: match = _IMAGE_PLACEHOLDER_RE.search(text, start) if match is None: return None return PlaceholderTokenMatch( start=match.start(), end=match.end(), raw=match.group(0), handler=self, match=match, ) def resolve_content(self, match: PlaceholderTokenMatch) -> list[ContentPart] | None: kind = parse_attachment_kind(match.match.group("type")) if kind is None: return None return self._attachment_cache.load_content_parts(kind, match.match.group("id")) def expand_text(self, match: PlaceholderTokenMatch) -> str | None: return match.raw def serialize_for_history(self, match: PlaceholderTokenMatch) -> str | None: return match.raw def expand_for_editor(self, match: PlaceholderTokenMatch) -> str | None: return match.raw @dataclass(slots=True) class ResolvedPromptCommand: display_command: str resolved_text: str content: list[ContentPart] class PromptPlaceholderManager: def __init__(self, attachment_cache: AttachmentCache | None = None) -> None: self._attachment_cache = attachment_cache or AttachmentCache() self._text_handler = PastedTextPlaceholderHandler() self._image_handler = ImagePlaceholderHandler(self._attachment_cache) self._handlers: tuple[PlaceholderHandler, ...] = ( self._text_handler, self._image_handler, ) @property def attachment_cache(self) -> AttachmentCache: return self._attachment_cache def maybe_placeholderize_pasted_text(self, text: str) -> str: return self._text_handler.maybe_placeholderize(text) def create_image_placeholder(self, image: Image.Image) -> str | None: return self._image_handler.create_placeholder(image) def resolve_command(self, command: str) -> ResolvedPromptCommand: content: list[ContentPart] = [] resolved_chunks: list[str] = [] cursor = 0 while match := self._find_next_match(command, cursor): if match.start > cursor: literal = command[cursor : match.start] content.append(TextPart(text=literal)) resolved_chunks.append(literal) resolved_content = match.handler.resolve_content(match) if resolved_content is None: content.append(TextPart(text=match.raw)) resolved_chunks.append(match.raw) else: content.extend(resolved_content) expanded = match.handler.expand_text(match) resolved_chunks.append(match.raw if expanded is None else expanded) cursor = match.end if cursor < len(command): literal = command[cursor:] content.append(TextPart(text=literal)) resolved_chunks.append(literal) return ResolvedPromptCommand( display_command=command, resolved_text="".join(resolved_chunks), content=content, ) def serialize_for_history(self, command: str) -> str: return self._rewrite_command( command, lambda handler, match: handler.serialize_for_history(match), ) def expand_for_editor(self, command: str) -> str: return self._rewrite_command( command, lambda handler, match: handler.expand_for_editor(match), ) def refold_after_editor(self, edited_text: str, original_command: str) -> str: return self._text_handler.refold_after_editor(edited_text, original_command) def _find_next_match(self, text: str, start: int = 0) -> PlaceholderTokenMatch | None: earliest: PlaceholderTokenMatch | None = None for handler in self._handlers: match = handler.find_next(text, start) if match is None: continue if earliest is None or match.start < earliest.start: earliest = match return earliest def _rewrite_command( self, command: str, replacer: Callable[[PlaceholderHandler, PlaceholderTokenMatch], str | None], ) -> str: parts: list[str] = [] cursor = 0 while match := self._find_next_match(command, cursor): if match.start > cursor: parts.append(command[cursor : match.start]) replacement = replacer(match.handler, match) parts.append(match.raw if replacement is None else replacement) cursor = match.end if cursor < len(command): parts.append(command[cursor:]) return "".join(parts) ================================================ FILE: src/kimi_cli/ui/shell/prompt.py ================================================ from __future__ import annotations import asyncio import contextlib import json import os import re import shlex import subprocess import time from collections import deque from collections.abc import Awaitable, Callable, Iterable, Sequence from dataclasses import dataclass from enum import Enum from hashlib import md5 from pathlib import Path from typing import Any, Literal, Protocol, cast, override from kaos.path import KaosPath from prompt_toolkit import PromptSession from prompt_toolkit.application.current import get_app_or_none from prompt_toolkit.buffer import Buffer from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard from prompt_toolkit.completion import ( CompleteEvent, Completer, Completion, FuzzyCompleter, WordCompleter, merge_completers, ) from prompt_toolkit.data_structures import Point from prompt_toolkit.document import Document from prompt_toolkit.filters import Condition, has_completions, has_focus, is_done from prompt_toolkit.formatted_text import AnyFormattedText, FormattedText, to_formatted_text from prompt_toolkit.history import InMemoryHistory from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent from prompt_toolkit.keys import Keys from prompt_toolkit.layout.containers import ( ConditionalContainer, Float, FloatContainer, HSplit, Window, ) from prompt_toolkit.layout.controls import UIContent, UIControl from prompt_toolkit.layout.dimension import Dimension from prompt_toolkit.layout.menus import CompletionsMenu from prompt_toolkit.patch_stdout import patch_stdout from prompt_toolkit.styles import Style from prompt_toolkit.utils import get_cwidth from pydantic import BaseModel, ValidationError from kimi_cli.llm import ModelCapability from kimi_cli.share import get_share_dir from kimi_cli.soul import StatusSnapshot, format_context_status from kimi_cli.ui.shell import placeholders as prompt_placeholders from kimi_cli.ui.shell.console import console from kimi_cli.ui.shell.placeholders import ( PromptPlaceholderManager, normalize_pasted_text, sanitize_surrogates, ) from kimi_cli.utils.clipboard import ( grab_media_from_clipboard, is_clipboard_available, ) from kimi_cli.utils.logging import logger from kimi_cli.utils.slashcmd import SlashCommand from kimi_cli.wire.types import ContentPart AttachmentCache = prompt_placeholders.AttachmentCache CachedAttachment = prompt_placeholders.CachedAttachment _parse_attachment_kind = prompt_placeholders.parse_attachment_kind PROMPT_SYMBOL = "✨" PROMPT_SYMBOL_SHELL = "$" PROMPT_SYMBOL_THINKING = "💫" PROMPT_SYMBOL_PLAN = "📋" class SlashCommandCompleter(Completer): """ A completer that: - Shows one line per slash command using the canonical "/name" - Fuzzy-matches by primary name or any alias while inserting the canonical "/name" - Only activates when the current token starts with '/' """ def __init__(self, available_commands: Sequence[SlashCommand[Any]]) -> None: super().__init__() self._available_commands = list(available_commands) self._command_lookup: dict[str, list[SlashCommand[Any]]] = {} words: list[str] = [] for cmd in sorted(self._available_commands, key=lambda c: c.name): if cmd.name not in self._command_lookup: self._command_lookup[cmd.name] = [] words.append(cmd.name) self._command_lookup[cmd.name].append(cmd) for alias in cmd.aliases: if alias in self._command_lookup: self._command_lookup[alias].append(cmd) else: self._command_lookup[alias] = [cmd] words.append(alias) self._word_pattern = re.compile(r"[^\s]+") self._fuzzy_pattern = r"^[^\s]*" self._word_completer = WordCompleter(words, WORD=False, pattern=self._word_pattern) self._fuzzy = FuzzyCompleter(self._word_completer, WORD=False, pattern=self._fuzzy_pattern) @staticmethod def should_complete(document: Document) -> bool: """Return whether slash command completion should be active for the current buffer.""" text = document.text_before_cursor if document.text_after_cursor.strip(): return False last_space = text.rfind(" ") token = text[last_space + 1 :] prefix = text[: last_space + 1] if last_space != -1 else "" return not prefix.strip() and token.startswith("/") @override def get_completions( self, document: Document, complete_event: CompleteEvent ) -> Iterable[Completion]: if not self.should_complete(document): return text = document.text_before_cursor last_space = text.rfind(" ") token = text[last_space + 1 :] typed = token[1:] if typed and typed in self._command_lookup: return mention_doc = Document(text=typed, cursor_position=len(typed)) candidates = list(self._fuzzy.get_completions(mention_doc, complete_event)) seen: set[str] = set() for candidate in candidates: commands = self._command_lookup.get(candidate.text) if not commands: continue for cmd in commands: if cmd.name in seen: continue seen.add(cmd.name) yield Completion( text=f"/{cmd.name}", start_position=-len(token), display=f"/{cmd.name}", display_meta=cmd.description, ) def _truncate_to_width(text: str, width: int) -> str: if width <= 0: return "" total = 0 chars: list[str] = [] for ch in text: ch_width = get_cwidth(ch) if total + ch_width > width: break chars.append(ch) total += ch_width if total == get_cwidth(text): return text + (" " * max(0, width - total)) ellipsis = "..." ellipsis_width = get_cwidth(ellipsis) if width <= ellipsis_width: return "." * width available = width - ellipsis_width total = 0 chars = [] for ch in text: ch_width = get_cwidth(ch) if total + ch_width > available: break chars.append(ch) total += ch_width return "".join(chars) + ellipsis + (" " * max(0, width - total - ellipsis_width)) def _wrap_to_width(text: str, width: int, *, max_lines: int | None = None) -> list[str]: if width <= 0: return [] words = text.split() if not words: return [""] lines: list[str] = [] current_words: list[str] = [] current_width = 0 index = 0 while index < len(words): word = words[index] word_width = get_cwidth(word) separator_width = 1 if current_words else 0 if current_words and current_width + separator_width + word_width <= width: current_words.append(word) current_width += separator_width + word_width index += 1 continue if not current_words and word_width <= width: current_words.append(word) current_width = word_width index += 1 continue if not current_words and word_width > width: current_words.append(_truncate_to_width(word, width).rstrip()) current_width = get_cwidth(current_words[0]) index += 1 lines.append(" ".join(current_words)) current_words = [] current_width = 0 if max_lines is not None and len(lines) == max_lines: remaining = " ".join(words[index:]) if remaining: prefix = f"{lines[-1]} " if lines[-1] else "" lines[-1] = _truncate_to_width(prefix + remaining, width).rstrip() return lines if current_words: line = " ".join(current_words) if max_lines is not None and len(lines) + 1 > max_lines: if lines: lines[-1] = _truncate_to_width(f"{lines[-1]} {line}", width).rstrip() else: lines.append(_truncate_to_width(line, width).rstrip()) else: lines.append(line) return lines def _find_prompt_float_container(layout_container: object) -> FloatContainer | None: if not isinstance(layout_container, HSplit): return None for child in cast(Sequence[object], layout_container.children): float_container = _extract_float_container(child) if float_container is not None: return float_container return None def _extract_float_container(container: object) -> FloatContainer | None: if isinstance(container, FloatContainer): return container if isinstance(container, ConditionalContainer): if isinstance(container.content, FloatContainer): return container.content if isinstance(container.alternative_content, FloatContainer): return container.alternative_content return None class SlashCommandMenuControl(UIControl): """Render slash command completions as a full-width menu that matches the shell UI.""" _MAX_EXPANDED_META_LINES = 3 def __init__( self, *, left_padding: Callable[[], int], scroll_offset: int = 1, ) -> None: self._left_padding = left_padding self._scroll_offset = scroll_offset def has_focus(self) -> bool: return False def preferred_width(self, max_available_width: int) -> int | None: return max_available_width def preferred_height( self, width: int, max_available_height: int, wrap_lines: bool, get_line_prefix: Callable[..., AnyFormattedText] | None, ) -> int | None: app = get_app_or_none() complete_state = ( getattr(app.current_buffer, "complete_state", None) if app is not None else None ) if complete_state is None: return 0 completions = complete_state.completions selected_index = complete_state.complete_index if selected_index is None: return min(max_available_height, len(completions) + 1) menu_width = max(0, width - self._left_padding()) marker_width = 2 command_width = self._command_column_width(completions, menu_width, marker_width) gap_width = 3 if menu_width > command_width + 6 else 1 meta_width = max(0, menu_width - marker_width - command_width - gap_width) selected_meta_lines = self._selected_meta_lines( completions[selected_index].display_meta_text, meta_width, ) return min(max_available_height, len(completions) + len(selected_meta_lines)) def create_content(self, width: int, height: int) -> UIContent: app = get_app_or_none() complete_state = ( getattr(app.current_buffer, "complete_state", None) if app is not None else None ) if complete_state is None or not complete_state.completions: return UIContent() completions = complete_state.completions selected_index = complete_state.complete_index available_rows = max(1, height - 1) menu_width = max(0, width - self._left_padding()) marker_width = 2 command_width = self._command_column_width(completions, menu_width, marker_width) gap_width = 3 if menu_width > command_width + 6 else 1 meta_width = max(0, menu_width - marker_width - command_width - gap_width) rendered_lines: list[FormattedText] = [ FormattedText([("class:slash-completion-menu.separator", "─" * max(0, width))]) ] selected_line_index = 0 if selected_index is None: end = min(len(completions) - 1, available_rows - 1) for index in range(0, end + 1): rendered_lines.append( self._render_single_line_item( width=width, completion=completions[index], marker_width=marker_width, command_width=command_width, meta_width=meta_width, gap_width=gap_width, is_current=False, ) ) return UIContent( get_line=lambda i: rendered_lines[i], line_count=len(rendered_lines), cursor_position=Point(x=0, y=selected_line_index), ) selected_meta_lines = self._selected_meta_lines( completions[selected_index].display_meta_text, meta_width, ) start, end = self._visible_window_bounds( completion_count=len(completions), selected_index=selected_index, available_rows=available_rows, selected_item_height=len(selected_meta_lines), ) selected_line_index = 1 for index in range(start, end + 1): completion = completions[index] if index == selected_index: selected_line_index = len(rendered_lines) rendered_lines.extend( self._render_selected_item_lines( width=width, completion=completion, marker_width=marker_width, command_width=command_width, meta_width=meta_width, gap_width=gap_width, meta_lines=selected_meta_lines, ) ) continue rendered_lines.append( self._render_single_line_item( width=width, completion=completion, marker_width=marker_width, command_width=command_width, meta_width=meta_width, gap_width=gap_width, is_current=False, ) ) return UIContent( get_line=lambda i: rendered_lines[i], line_count=len(rendered_lines), cursor_position=Point(x=0, y=selected_line_index), ) def _selected_meta_lines(self, text: str, meta_width: int) -> list[str]: lines = _wrap_to_width( text, meta_width, max_lines=self._MAX_EXPANDED_META_LINES, ) return lines or [""] def _visible_window_bounds( self, *, completion_count: int, selected_index: int, available_rows: int, selected_item_height: int, ) -> tuple[int, int]: selected_item_height = min(selected_item_height, available_rows) remaining_rows = max(0, available_rows - selected_item_height) before = min(self._scroll_offset, selected_index, remaining_rows) remaining_rows -= before after = min(completion_count - selected_index - 1, remaining_rows) remaining_rows -= after extra_before = min(selected_index - before, remaining_rows) before += extra_before remaining_rows -= extra_before extra_after = min(completion_count - selected_index - 1 - after, remaining_rows) after += extra_after return selected_index - before, selected_index + after def _command_column_width( self, completions: Sequence[Completion], menu_width: int, marker_width: int, ) -> int: if menu_width <= 0: return 0 longest = max((get_cwidth(c.display_text) for c in completions), default=0) preferred = longest + 2 usable_width = max(0, menu_width - marker_width) minimum = min(usable_width, 18) maximum = max(minimum, min(28, usable_width // 2)) return max(minimum, min(preferred, maximum)) def _render_single_line_item( self, *, width: int, completion: Completion, marker_width: int, command_width: int, meta_width: int, gap_width: int, is_current: bool, ) -> FormattedText: padding_width = max(0, width - marker_width - command_width - meta_width - gap_width) left_padding = min(self._left_padding(), padding_width) trailing_width = max( 0, width - left_padding - marker_width - command_width - gap_width - meta_width, ) command_style = ( "class:slash-completion-menu.command.current" if is_current else "class:slash-completion-menu.command" ) meta_style = ( "class:slash-completion-menu.meta.current" if is_current else "class:slash-completion-menu.meta" ) marker_style = ( "class:slash-completion-menu.marker.current" if is_current else "class:slash-completion-menu.marker" ) marker = "› " if is_current else " " fragments: FormattedText = FormattedText() fragments.append(("class:slash-completion-menu", " " * left_padding)) fragments.append((marker_style, marker.ljust(marker_width))) fragments.append( (command_style, _truncate_to_width(completion.display_text, command_width)) ) fragments.append(("class:slash-completion-menu", " " * gap_width)) fragments.append((meta_style, _truncate_to_width(completion.display_meta_text, meta_width))) fragments.append(("class:slash-completion-menu", " " * trailing_width)) return fragments def _render_selected_item_lines( self, *, width: int, completion: Completion, marker_width: int, command_width: int, meta_width: int, gap_width: int, meta_lines: Sequence[str], ) -> list[FormattedText]: lines = [ self._render_single_line_item( width=width, completion=Completion( text=completion.text, start_position=completion.start_position, display=completion.display, display_meta=meta_lines[0], ), marker_width=marker_width, command_width=command_width, meta_width=meta_width, gap_width=gap_width, is_current=True, ) ] continuation_prefix = ( " " * self._left_padding() + " " * marker_width + " " * command_width + " " * gap_width ) continuation_trailing = max( 0, width - get_cwidth(continuation_prefix) - meta_width, ) for meta_line in meta_lines[1:]: fragments: FormattedText = FormattedText() fragments.append(("class:slash-completion-menu", continuation_prefix)) fragments.append( ( "class:slash-completion-menu.meta.current", _truncate_to_width(meta_line, meta_width), ) ) fragments.append(("class:slash-completion-menu", " " * continuation_trailing)) lines.append(fragments) return lines class LocalFileMentionCompleter(Completer): """Offer fuzzy `@` path completion by indexing workspace files.""" _FRAGMENT_PATTERN = re.compile(r"[^\s@]+") _TRIGGER_GUARDS = frozenset((".", "-", "_", "`", "'", '"', ":", "@", "#", "~")) _IGNORED_NAME_GROUPS: dict[str, tuple[str, ...]] = { "vcs_metadata": (".DS_Store", ".bzr", ".git", ".hg", ".svn"), "tooling_caches": ( ".build", ".cache", ".coverage", ".fleet", ".gradle", ".idea", ".ipynb_checkpoints", ".pnpm-store", ".pytest_cache", ".pub-cache", ".ruff_cache", ".swiftpm", ".tox", ".venv", ".vs", ".vscode", ".yarn", ".yarn-cache", ), "js_frontend": ( ".next", ".nuxt", ".parcel-cache", ".svelte-kit", ".turbo", ".vercel", "node_modules", ), "python_packaging": ( "__pycache__", "build", "coverage", "dist", "htmlcov", "pip-wheel-metadata", "venv", ), "java_jvm": (".mvn", "out", "target"), "dotnet_native": ("bin", "cmake-build-debug", "cmake-build-release", "obj"), "bazel_buck": ("bazel-bin", "bazel-out", "bazel-testlogs", "buck-out"), "misc_artifacts": ( ".dart_tool", ".serverless", ".stack-work", ".terraform", ".terragrunt-cache", "DerivedData", "Pods", "deps", "tmp", "vendor", ), } _IGNORED_NAMES = frozenset(name for group in _IGNORED_NAME_GROUPS.values() for name in group) _IGNORED_PATTERN_PARTS: tuple[str, ...] = ( r".*_cache$", r".*-cache$", r".*\.egg-info$", r".*\.dist-info$", r".*\.py[co]$", r".*\.class$", r".*\.sw[po]$", r".*~$", r".*\.(?:tmp|bak)$", ) _IGNORED_PATTERNS = re.compile( "|".join(f"(?:{part})" for part in _IGNORED_PATTERN_PARTS), re.IGNORECASE, ) def __init__( self, root: Path, *, refresh_interval: float = 2.0, limit: int = 1000, ) -> None: self._root = root self._refresh_interval = refresh_interval self._limit = limit self._cache_time: float = 0.0 self._cached_paths: list[str] = [] self._top_cache_time: float = 0.0 self._top_cached_paths: list[str] = [] self._fragment_hint: str | None = None self._word_completer = WordCompleter( self._get_paths, WORD=False, pattern=self._FRAGMENT_PATTERN, ) self._fuzzy = FuzzyCompleter( self._word_completer, WORD=False, pattern=r"^[^\s@]*", ) @classmethod def _is_ignored(cls, name: str) -> bool: if not name: return True if name in cls._IGNORED_NAMES: return True return bool(cls._IGNORED_PATTERNS.fullmatch(name)) def _get_paths(self) -> list[str]: fragment = self._fragment_hint or "" if "/" not in fragment and len(fragment) < 3: return self._get_top_level_paths() return self._get_deep_paths() def _get_top_level_paths(self) -> list[str]: now = time.monotonic() if now - self._top_cache_time <= self._refresh_interval: return self._top_cached_paths entries: list[str] = [] try: for entry in sorted(self._root.iterdir(), key=lambda p: p.name): name = entry.name if self._is_ignored(name): continue entries.append(f"{name}/" if entry.is_dir() else name) if len(entries) >= self._limit: break except OSError: return self._top_cached_paths self._top_cached_paths = entries self._top_cache_time = now return self._top_cached_paths def _get_deep_paths(self) -> list[str]: now = time.monotonic() if now - self._cache_time <= self._refresh_interval: return self._cached_paths paths: list[str] = [] try: for current_root, dirs, files in os.walk(self._root): relative_root = Path(current_root).relative_to(self._root) # Prevent descending into ignored directories. dirs[:] = sorted(d for d in dirs if not self._is_ignored(d)) if relative_root.parts and any( self._is_ignored(part) for part in relative_root.parts ): dirs[:] = [] continue if relative_root.parts: paths.append(relative_root.as_posix() + "/") if len(paths) >= self._limit: break for file_name in sorted(files): if self._is_ignored(file_name): continue relative = (relative_root / file_name).as_posix() if not relative: continue paths.append(relative) if len(paths) >= self._limit: break if len(paths) >= self._limit: break except OSError: return self._cached_paths self._cached_paths = paths self._cache_time = now return self._cached_paths @staticmethod def _extract_fragment(text: str) -> str | None: index = text.rfind("@") if index == -1: return None if index > 0: prev = text[index - 1] if prev.isalnum() or prev in LocalFileMentionCompleter._TRIGGER_GUARDS: return None fragment = text[index + 1 :] if not fragment: return "" if any(ch.isspace() for ch in fragment): return None return fragment def _is_completed_file(self, fragment: str) -> bool: candidate = fragment.rstrip("/") if not candidate: return False try: return (self._root / candidate).is_file() except OSError: return False @override def get_completions( self, document: Document, complete_event: CompleteEvent ) -> Iterable[Completion]: fragment = self._extract_fragment(document.text_before_cursor) if fragment is None: return if self._is_completed_file(fragment): return mention_doc = Document(text=fragment, cursor_position=len(fragment)) self._fragment_hint = fragment try: # First, ask the fuzzy completer for candidates. candidates = list(self._fuzzy.get_completions(mention_doc, complete_event)) # re-rank: prefer basename matches frag_lower = fragment.lower() def _rank(c: Completion) -> tuple[int, ...]: path = c.text base = path.rstrip("/").split("/")[-1].lower() if base.startswith(frag_lower): cat = 0 elif frag_lower in base: cat = 1 else: cat = 2 # preserve original FuzzyCompleter's order in the same category return (cat,) candidates.sort(key=_rank) yield from candidates finally: self._fragment_hint = None class _HistoryEntry(BaseModel): content: str def _load_history_entries(history_file: Path) -> list[_HistoryEntry]: entries: list[_HistoryEntry] = [] if not history_file.exists(): return entries try: with history_file.open(encoding="utf-8") as f: for raw_line in f: line = raw_line.strip() if not line: continue try: record = json.loads(line) except json.JSONDecodeError: logger.warning( "Failed to parse user history line; skipping: {line}", line=line, ) continue try: entry = _HistoryEntry.model_validate(record) entries.append(entry) except ValidationError: logger.warning( "Failed to validate user history entry; skipping: {line}", line=line, ) continue except OSError as exc: logger.warning( "Failed to load user history file: {file} ({error})", file=history_file, error=exc, ) return entries class PromptMode(Enum): AGENT = "agent" SHELL = "shell" def toggle(self) -> PromptMode: return PromptMode.SHELL if self == PromptMode.AGENT else PromptMode.AGENT def __str__(self) -> str: return self.value class UserInput(BaseModel): mode: PromptMode command: str """The plain text representation of the user input.""" resolved_command: str """The text command after UI-only placeholders are expanded.""" content: list[ContentPart] """The rich content parts.""" def __str__(self) -> str: return self.command def __bool__(self) -> bool: return bool(self.command) _IDLE_REFRESH_INTERVAL = 1.0 _RUNNING_REFRESH_INTERVAL = 0.1 _GIT_BRANCH_TTL = 5.0 _GIT_STATUS_TTL = 15.0 _TIP_ROTATE_INTERVAL = 30.0 _MAX_CWD_COLS = 30 _MAX_BRANCH_COLS = 22 @dataclass class _GitBranchState: timestamp: float = 0.0 branch: str | None = None proc: subprocess.Popen[str] | None = None @dataclass class _GitStatusState: timestamp: float = 0.0 dirty: bool = False ahead: int = 0 behind: int = 0 proc: subprocess.Popen[str] | None = None _git_branch_state = _GitBranchState() _git_status_state = _GitStatusState() _GIT_STATUS_AB_RE = re.compile(r"\[(?:ahead (\d+))?(?:, )?(?:behind (\d+))?\]") def _get_git_branch() -> str | None: """Return the current git branch name via a non-blocking cached subprocess.""" state = _git_branch_state now = time.monotonic() # Collect result if a previously launched process has finished if state.proc is not None: returncode = state.proc.poll() if returncode is not None: try: stdout, _ = state.proc.communicate() new_branch = stdout.strip() or None # Branch changed — discard any in-flight status subprocess so it cannot # write stale results for the old branch, then force an immediate refresh. if new_branch != state.branch: if _git_status_state.proc is not None: with contextlib.suppress(Exception): _git_status_state.proc.terminate() _git_status_state.proc = None _git_status_state.timestamp = 0.0 state.branch = new_branch except Exception: state.branch = None state.proc = None # Launch a new process when the TTL has expired and nothing is running if state.timestamp + _GIT_BRANCH_TTL <= now and state.proc is None: state.timestamp = now try: state.proc = subprocess.Popen( ["git", "branch", "--show-current"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, ) except Exception: state.branch = None return state.branch def _get_git_status() -> tuple[bool, int, int]: """Return (dirty, ahead, behind) via a non-blocking cached subprocess. Runs ``git status --porcelain -b`` (includes untracked files so newly created files show as dirty). TTL is longer than the branch check because file-tree scanning is expensive. """ state = _git_status_state now = time.monotonic() if state.proc is not None: returncode = state.proc.poll() if returncode is not None: try: stdout, _ = state.proc.communicate() dirty = False ahead = 0 behind = 0 for line in stdout.splitlines(): if line.startswith("## "): m = _GIT_STATUS_AB_RE.search(line) if m: ahead = int(m.group(1) or 0) behind = int(m.group(2) or 0) elif line.strip(): dirty = True state.dirty = dirty state.ahead = ahead state.behind = behind except Exception: pass state.proc = None elif now - state.timestamp > _GIT_STATUS_TTL: # Subprocess is stuck (e.g. OS pipe buffer full from many untracked files). # Terminate it so the toolbar is not permanently frozen; retry after next TTL. with contextlib.suppress(Exception): state.proc.terminate() state.proc = None state.timestamp = now # delay next spawn by one full TTL if state.timestamp + _GIT_STATUS_TTL <= now and state.proc is None: state.timestamp = now with contextlib.suppress(Exception): state.proc = subprocess.Popen( ["git", "status", "--porcelain", "-b"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, ) return state.dirty, state.ahead, state.behind def _format_git_badge(branch: str, dirty: bool, ahead: int, behind: int) -> str: """Format branch name with an optional status badge: ``main [± ↑3↓1]``.""" parts: list[str] = [] if dirty: parts.append("±") sync = "" if ahead: sync += f"↑{ahead}" if behind: sync += f"↓{behind}" if sync: parts.append(sync) if not parts: return branch return f"{branch} [{' '.join(parts)}]" def _shorten_cwd(path: str) -> str: """Replace the home directory prefix in *path* with ``~``.""" home = str(Path.home()) if path == home: return "~" if path.startswith(home + os.sep): return "~" + path[len(home) :] return path def _display_width(text: str) -> int: """Return the terminal column width of *text*, handling wide Unicode characters.""" return sum(get_cwidth(c) for c in text) def _truncate_left(text: str, max_cols: int) -> str: """Truncate *text* from the left, prepending '…' if it exceeds *max_cols*.""" if max_cols <= 0: return "" if _display_width(text) <= max_cols: return text ellipsis = "…" budget = max_cols - _display_width(ellipsis) chars: list[str] = [] width = 0 for ch in reversed(text): w = get_cwidth(ch) if width + w > budget: break chars.append(ch) width += w return ellipsis + "".join(reversed(chars)) def _truncate_right(text: str, max_cols: int) -> str: """Truncate *text* from the right, appending '…' if it exceeds *max_cols*.""" if max_cols <= 0: return "" if _display_width(text) <= max_cols: return text ellipsis = "…" budget = max_cols - _display_width(ellipsis) chars: list[str] = [] width = 0 for ch in text: w = get_cwidth(ch) if width + w > budget: break chars.append(ch) width += w return "".join(chars) + ellipsis @dataclass(slots=True) class _ToastEntry: topic: str | None """There can be only one toast of each non-None topic in the queue.""" message: str expires_at: float class RunningPromptDelegate(Protocol): def render_running_prompt_body(self, columns: int) -> AnyFormattedText: ... def running_prompt_placeholder(self) -> AnyFormattedText | None: ... def should_handle_running_prompt_key(self, key: str) -> bool: ... def handle_running_prompt_key(self, key: str, event: KeyPressEvent) -> None: ... _toast_queues: dict[Literal["left", "right"], deque[_ToastEntry]] = { "left": deque(), "right": deque(), } """The queue of toasts to show, including the one currently being shown (the first one).""" def toast( message: str, duration: float = 5.0, topic: str | None = None, immediate: bool = False, position: Literal["left", "right"] = "left", ) -> None: queue = _toast_queues[position] duration = max(duration, _IDLE_REFRESH_INTERVAL) entry = _ToastEntry(topic=topic, message=message, expires_at=time.monotonic() + duration) if topic is not None: # Remove existing toasts with the same topic for existing in list(queue): if existing.topic == topic: queue.remove(existing) if immediate: queue.appendleft(entry) else: queue.append(entry) def _current_toast(position: Literal["left", "right"] = "left") -> _ToastEntry | None: queue = _toast_queues[position] now = time.monotonic() while queue and queue[0].expires_at <= now: queue.popleft() if not queue: return None return queue[0] def _build_toolbar_tips(clipboard_available: bool) -> list[str]: tips = [ "ctrl-x: toggle mode", "shift-tab: plan mode", "ctrl-o: editor", "ctrl-j: newline", ] if clipboard_available: tips.append("ctrl-v: paste clipboard") tips.append("@: mention files") return tips _TIP_SEPARATOR = " | " class CustomPromptSession: def __init__( self, *, status_provider: Callable[[], StatusSnapshot], status_block_provider: Callable[[int], AnyFormattedText | None] | None = None, fast_refresh_provider: Callable[[], bool] | None = None, background_task_count_provider: Callable[[], int] | None = None, model_capabilities: set[ModelCapability], model_name: str | None, thinking: bool, agent_mode_slash_commands: Sequence[SlashCommand[Any]], shell_mode_slash_commands: Sequence[SlashCommand[Any]], editor_command_provider: Callable[[], str] = lambda: "", plan_mode_toggle_callback: Callable[[], Awaitable[bool]] | None = None, ) -> None: history_dir = get_share_dir() / "user-history" history_dir.mkdir(parents=True, exist_ok=True) work_dir_id = md5(str(KaosPath.cwd()).encode(encoding="utf-8")).hexdigest() self._history_file = (history_dir / work_dir_id).with_suffix(".jsonl") self._status_provider = status_provider self._status_block_provider = status_block_provider self._fast_refresh_provider = fast_refresh_provider self._background_task_count_provider = background_task_count_provider self._editor_command_provider = editor_command_provider self._plan_mode_toggle_callback = plan_mode_toggle_callback self._model_capabilities = model_capabilities self._model_name = model_name self._last_history_content: str | None = None self._mode: PromptMode = PromptMode.AGENT self._thinking = thinking self._placeholder_manager = PromptPlaceholderManager() # Keep the old attribute for test compatibility and for any external imports. self._attachment_cache = self._placeholder_manager.attachment_cache self._tip_rotation_index: int = 0 self._last_tip_rotate_time: float = time.monotonic() self._last_submission_was_running = False self._running_prompt_previous_mode: PromptMode | None = None self._running_prompt_delegate: RunningPromptDelegate | None = None clipboard_available = is_clipboard_available() self._tips = _build_toolbar_tips(clipboard_available) history_entries = _load_history_entries(self._history_file) history = InMemoryHistory() for entry in history_entries: history.append_string(entry.content) if history_entries: # for consecutive deduplication self._last_history_content = history_entries[-1].content # Build completers self._agent_mode_completer = merge_completers( [ SlashCommandCompleter(agent_mode_slash_commands), # TODO(kaos): we need an async KaosFileMentionCompleter LocalFileMentionCompleter(KaosPath.cwd().unsafe_to_local_path()), ], deduplicate=True, ) self._shell_mode_completer = SlashCommandCompleter(shell_mode_slash_commands) # Build key bindings _kb = KeyBindings() @_kb.add("enter", filter=has_completions) def _(event: KeyPressEvent) -> None: """Accept the first completion when Enter is pressed and completions are shown.""" buff = event.current_buffer if buff.complete_state and buff.complete_state.completions: # Get the current completion, or use the first one if none is selected completion = buff.complete_state.current_completion if not completion: completion = buff.complete_state.completions[0] buff.apply_completion(completion) @_kb.add("c-x", eager=True) def _(event: KeyPressEvent) -> None: if self._running_prompt_delegate is not None: return self._mode = self._mode.toggle() # Apply mode-specific settings self._apply_mode(event) # Redraw UI event.app.invalidate() @_kb.add("s-tab", eager=True) def _(event: KeyPressEvent) -> None: """Toggle plan mode with Shift+Tab.""" if self._running_prompt_delegate is not None: return if self._plan_mode_toggle_callback is not None: async def _toggle() -> None: assert self._plan_mode_toggle_callback is not None new_state = await self._plan_mode_toggle_callback() if new_state: toast("plan mode ON", topic="plan_mode", duration=3.0, immediate=True) else: toast("plan mode OFF", topic="plan_mode", duration=3.0, immediate=True) event.app.invalidate() event.app.create_background_task(_toggle()) event.app.invalidate() @_kb.add("escape", "enter", eager=True) @_kb.add("c-j", eager=True) def _(event: KeyPressEvent) -> None: """Insert a newline when Alt-Enter or Ctrl-J is pressed.""" event.current_buffer.insert_text("\n") @_kb.add("c-o", eager=True) def _(event: KeyPressEvent) -> None: """Open current buffer in external editor.""" self._open_in_external_editor(event) @_kb.add( "up", eager=True, filter=Condition(lambda: self._should_handle_running_prompt_key("up")), ) def _(event: KeyPressEvent) -> None: self._handle_running_prompt_key("up", event) @_kb.add( "down", eager=True, filter=Condition(lambda: self._should_handle_running_prompt_key("down")), ) def _(event: KeyPressEvent) -> None: self._handle_running_prompt_key("down", event) @_kb.add( "left", eager=True, filter=Condition(lambda: self._should_handle_running_prompt_key("left")), ) def _(event: KeyPressEvent) -> None: self._handle_running_prompt_key("left", event) @_kb.add( "right", eager=True, filter=Condition(lambda: self._should_handle_running_prompt_key("right")), ) def _(event: KeyPressEvent) -> None: self._handle_running_prompt_key("right", event) @_kb.add( "tab", eager=True, filter=Condition(lambda: self._should_handle_running_prompt_key("tab")), ) def _(event: KeyPressEvent) -> None: self._handle_running_prompt_key("tab", event) @_kb.add( "enter", eager=True, filter=Condition(lambda: self._should_handle_running_prompt_key("enter")), ) def _(event: KeyPressEvent) -> None: self._handle_running_prompt_key("enter", event) @_kb.add( "space", eager=True, filter=Condition(lambda: self._should_handle_running_prompt_key("space")), ) def _(event: KeyPressEvent) -> None: self._handle_running_prompt_key("space", event) @_kb.add( "c-e", eager=True, filter=Condition(lambda: self._should_handle_running_prompt_key("c-e")), ) def _(event: KeyPressEvent) -> None: self._handle_running_prompt_key("c-e", event) @_kb.add( "escape", eager=True, filter=Condition(lambda: self._should_handle_running_prompt_key("escape")), ) def _(event: KeyPressEvent) -> None: self._handle_running_prompt_key("escape", event) @_kb.add( "1", eager=True, filter=Condition(lambda: self._should_handle_running_prompt_key("1")), ) def _(event: KeyPressEvent) -> None: self._handle_running_prompt_key("1", event) @_kb.add( "2", eager=True, filter=Condition(lambda: self._should_handle_running_prompt_key("2")), ) def _(event: KeyPressEvent) -> None: self._handle_running_prompt_key("2", event) @_kb.add( "3", eager=True, filter=Condition(lambda: self._should_handle_running_prompt_key("3")), ) def _(event: KeyPressEvent) -> None: self._handle_running_prompt_key("3", event) @_kb.add( "4", eager=True, filter=Condition(lambda: self._should_handle_running_prompt_key("4")), ) def _(event: KeyPressEvent) -> None: self._handle_running_prompt_key("4", event) @_kb.add( "5", eager=True, filter=Condition(lambda: self._should_handle_running_prompt_key("5")), ) def _(event: KeyPressEvent) -> None: self._handle_running_prompt_key("5", event) @_kb.add( "6", eager=True, filter=Condition(lambda: self._should_handle_running_prompt_key("6")), ) def _(event: KeyPressEvent) -> None: self._handle_running_prompt_key("6", event) @_kb.add(Keys.BracketedPaste, eager=True) def _(event: KeyPressEvent) -> None: self._handle_bracketed_paste(event) if clipboard_available: @_kb.add("c-v", eager=True) def _(event: KeyPressEvent) -> None: if self._try_paste_media(event): return clipboard_data = event.app.clipboard.get_data() if clipboard_data is None: # type: ignore[reportUnnecessaryComparison] return self._insert_pasted_text(event.current_buffer, clipboard_data.text) event.app.invalidate() clipboard = PyperclipClipboard() else: clipboard = None self._session = PromptSession[str]( message=self._render_message, # prompt_continuation=FormattedText([("fg:#4d4d4d", "... ")]), completer=self._agent_mode_completer, complete_while_typing=True, reserve_space_for_menu=10, key_bindings=_kb, clipboard=clipboard, history=history, bottom_toolbar=self._render_bottom_toolbar, style=Style.from_dict( { "bottom-toolbar": "noreverse", "running-prompt-placeholder": "fg:#7c8594 italic", "running-prompt-separator": "fg:#4a5568", "slash-completion-menu": "", "slash-completion-menu.separator": "fg:#4a5568", "slash-completion-menu.marker": "fg:#4a5568", "slash-completion-menu.marker.current": "fg:#4f9fff", "slash-completion-menu.command": "fg:#a6adba", "slash-completion-menu.meta": "fg:#7c8594", "slash-completion-menu.command.current": "fg:#6fb7ff bold", "slash-completion-menu.meta.current": "fg:#56a4ff", } ), ) self._install_slash_completion_menu() self._apply_mode() # Allow completion to be triggered when the text is changed, # such as when backspace is used to delete text. @self._session.default_buffer.on_text_changed.add_handler def _(buffer: Buffer) -> None: if buffer.complete_while_typing(): buffer.start_completion() self._status_refresh_task: asyncio.Task[None] | None = None def _install_slash_completion_menu(self) -> None: float_container = _find_prompt_float_container(self._session.layout.container) if not isinstance(float_container, FloatContainer): return slash_menu_filter = ( has_focus(self._session.default_buffer) & has_completions & ~is_done & Condition(self._should_show_slash_completion_menu) ) slash_menu = ConditionalContainer( Window( content=SlashCommandMenuControl(left_padding=self._slash_menu_left_padding), dont_extend_height=True, height=Dimension(max=10), style="class:slash-completion-menu", ), filter=slash_menu_filter, ) float_container.floats.insert( 0, Float( left=0, right=0, ycursor=True, content=slash_menu, z_index=10**8, ), ) original_float = next( ( float_ for float_ in float_container.floats[1:] if isinstance(float_.content, CompletionsMenu) ), None, ) if original_float is None: return original_float.content = ConditionalContainer( original_float.content, filter=~Condition(self._should_show_slash_completion_menu), ) def _should_show_slash_completion_menu(self) -> bool: document = self._session.default_buffer.document return SlashCommandCompleter.should_complete(document) def _slash_menu_left_padding(self) -> int: if self._mode == PromptMode.SHELL: return max(1, get_cwidth(f"{PROMPT_SYMBOL_SHELL} ") - 2) if self._status_provider().plan_mode: return max(1, get_cwidth(f"{PROMPT_SYMBOL_PLAN} ") - 2) symbol = PROMPT_SYMBOL_THINKING if self._thinking else PROMPT_SYMBOL return max(1, get_cwidth(f"{symbol} ") - 2) def _render_message(self) -> FormattedText: if self._mode == PromptMode.SHELL: return self._render_shell_prompt_message() return self._render_agent_prompt_message() def _render_shell_prompt_message(self) -> FormattedText: app = get_app_or_none() columns = app.output.get_size().columns if app is not None else 80 fragments: FormattedText = FormattedText() body = self._render_status_block(columns) if body: fragments.extend(body) if not body[-1][1].endswith("\n"): fragments.append(("", "\n")) fragments.append(("", "\n")) fragments.append(("class:running-prompt-separator", "─" * max(0, columns))) fragments.append(("", "\n")) fragments.append(("bold", f"{PROMPT_SYMBOL_SHELL} ")) return fragments def _open_in_external_editor(self, event: KeyPressEvent) -> None: """Open the current buffer content in an external editor.""" from prompt_toolkit.application.run_in_terminal import run_in_terminal from kimi_cli.utils.editor import edit_text_in_editor, get_editor_command configured = self._editor_command_provider() if get_editor_command(configured) is None: toast("No editor found. Set $VISUAL/$EDITOR or run /editor.") return buff = event.current_buffer original_text = buff.text editor_text = self._get_placeholder_manager().expand_for_editor(original_text) async def _run_editor() -> None: result = await run_in_terminal( lambda: edit_text_in_editor(editor_text, configured), in_executor=True ) if result is not None: refolded = self._get_placeholder_manager().refold_after_editor( result, original_text ) buff.document = Document(text=refolded, cursor_position=len(refolded)) event.app.create_background_task(_run_editor()) def _apply_mode(self, event: KeyPressEvent | None = None) -> None: # Apply mode to the active buffer (not the PromptSession itself) try: buff = event.current_buffer if event is not None else self._session.default_buffer except Exception: buff = None if self._mode == PromptMode.SHELL: if buff is not None: buff.completer = self._shell_mode_completer else: if buff is not None: buff.completer = self._agent_mode_completer self._sync_erase_when_done() def _sync_erase_when_done(self) -> None: app = getattr(self._session, "app", None) if app is not None: app.erase_when_done = self._mode == PromptMode.AGENT def _should_handle_running_prompt_key(self, key: str) -> bool: running_prompt = getattr(self, "_running_prompt_delegate", None) return running_prompt is not None and running_prompt.should_handle_running_prompt_key(key) def _handle_running_prompt_key(self, key: str, event: KeyPressEvent) -> None: running_prompt = self._running_prompt_delegate if running_prompt is None: return running_prompt.handle_running_prompt_key(key, event) event.app.invalidate() def invalidate(self) -> None: app = get_app_or_none() if app is not None: app.invalidate() def _render_agent_prompt_message(self) -> FormattedText: app = get_app_or_none() columns = app.output.get_size().columns if app is not None else 80 fragments: FormattedText = FormattedText() body = self._render_agent_prompt_body(columns) if body: fragments.extend(body) if not body[-1][1].endswith("\n"): fragments.append(("", "\n")) fragments.append(("", "\n")) fragments.append(("class:running-prompt-separator", "─" * max(0, columns))) fragments.append(("", "\n")) fragments.extend(self._render_agent_prompt_label()) return fragments def _render_agent_prompt_body(self, columns: int) -> FormattedText: running_prompt = self._running_prompt_delegate if running_prompt is None: return self._render_status_block(columns) return to_formatted_text(running_prompt.render_running_prompt_body(columns)) def _render_status_block(self, columns: int) -> FormattedText: status_block_provider = getattr(self, "_status_block_provider", None) if status_block_provider is None: return FormattedText([]) block = status_block_provider(columns) if block is None: return FormattedText([]) return to_formatted_text(block) def _render_agent_prompt_label(self) -> FormattedText: status = self._status_provider() if status.plan_mode: return FormattedText([("fg:#00aaff", f"{PROMPT_SYMBOL_PLAN} ")]) symbol = PROMPT_SYMBOL_THINKING if self._thinking else PROMPT_SYMBOL return FormattedText([("", f"{symbol} ")]) def __enter__(self) -> CustomPromptSession: if self._status_refresh_task is not None and not self._status_refresh_task.done(): return self async def _refresh() -> None: try: while True: app = get_app_or_none() if app is not None: app.invalidate() try: asyncio.get_running_loop() except RuntimeError: logger.warning("No running loop found, exiting status refresh task") self._status_refresh_task = None break interval = ( _RUNNING_REFRESH_INTERVAL if self._running_prompt_delegate is not None or ( self._fast_refresh_provider is not None and self._fast_refresh_provider() ) else _IDLE_REFRESH_INTERVAL ) await asyncio.sleep(interval) except asyncio.CancelledError: # graceful exit pass self._status_refresh_task = asyncio.create_task(_refresh()) return self def __exit__(self, *_) -> None: if self._status_refresh_task is not None and not self._status_refresh_task.done(): self._status_refresh_task.cancel() self._status_refresh_task = None def _get_placeholder_manager(self) -> PromptPlaceholderManager: manager = getattr(self, "_placeholder_manager", None) if manager is None: attachment_cache = getattr(self, "_attachment_cache", None) manager = PromptPlaceholderManager(attachment_cache=attachment_cache) self._placeholder_manager = manager self._attachment_cache = manager.attachment_cache return manager def _insert_pasted_text(self, buffer: Buffer, text: str) -> None: normalized = normalize_pasted_text(text) if self._mode != PromptMode.AGENT: buffer.insert_text(normalized) return token_or_text = self._get_placeholder_manager().maybe_placeholderize_pasted_text(normalized) buffer.insert_text(token_or_text) def _handle_bracketed_paste(self, event: KeyPressEvent) -> None: self._insert_pasted_text(event.current_buffer, event.data) event.app.invalidate() def _try_paste_media(self, event: KeyPressEvent) -> bool: """Try to paste media from the clipboard. Reads the clipboard once and handles all detected content: non-image files (videos, PDFs, etc.) are inserted as paths, image files are cached and inserted as placeholders. Returns True if any media content was inserted. """ result = grab_media_from_clipboard() if result is None: return False parts: list[str] = [] # 1. Insert file paths (videos, PDFs, etc.) if result.file_paths: logger.debug("Pasted {count} file path(s) from clipboard", count=len(result.file_paths)) for p in result.file_paths: text = str(p) if self._mode == PromptMode.SHELL: text = shlex.quote(text) parts.append(text) # 2. Insert images via cache. if result.images: if "image_in" not in self._model_capabilities: console.print( "[yellow]Image input is not supported by the selected LLM model[/yellow]" ) else: for image in result.images: token = self._get_placeholder_manager().create_image_placeholder(image) if token is None: continue logger.debug( "Pasted image from clipboard placeholder: {token}, {image_size}", token=token, image_size=image.size, ) parts.append(token) if parts: event.current_buffer.insert_text(" ".join(parts)) event.app.invalidate() return bool(parts) async def prompt_next(self) -> UserInput: return await self._prompt_once(append_history=None) @property def last_submission_was_running(self) -> bool: return getattr(self, "_last_submission_was_running", False) def attach_running_prompt(self, delegate: RunningPromptDelegate) -> None: current = getattr(self, "_running_prompt_delegate", None) if current is delegate: return if current is None: self._running_prompt_previous_mode = self._mode self._running_prompt_delegate = delegate self._mode = PromptMode.AGENT self._apply_mode() self.invalidate() def detach_running_prompt(self, delegate: RunningPromptDelegate) -> None: if getattr(self, "_running_prompt_delegate", None) is not delegate: return previous_mode = getattr(self, "_running_prompt_previous_mode", None) self._running_prompt_delegate = None self._running_prompt_previous_mode = None if previous_mode is not None: self._mode = previous_mode self._apply_mode() self.invalidate() async def _prompt_once(self, *, append_history: bool | None) -> UserInput: placeholder = None if self._running_prompt_delegate is not None: placeholder = self._running_prompt_delegate.running_prompt_placeholder() with patch_stdout(raw=True): command = str(await self._session.prompt_async(placeholder=placeholder)).strip() command = command.replace("\x00", "") # just in case null bytes are somehow inserted # Sanitize UTF-16 surrogates that may come from Windows clipboard command = sanitize_surrogates(command) was_running = self._running_prompt_delegate is not None self._last_submission_was_running = was_running if append_history is None: append_history = not was_running if append_history: self._append_history_entry(command) self._tip_rotation_index += 1 return self._build_user_input(command) def _build_user_input(self, command: str) -> UserInput: resolved = self._get_placeholder_manager().resolve_command(command) return UserInput( mode=self._mode, command=resolved.display_command, resolved_command=resolved.resolved_text, content=resolved.content, ) def _append_history_entry(self, text: str) -> None: safe_history_text = self._get_placeholder_manager().serialize_for_history(text).strip() entry = _HistoryEntry(content=safe_history_text) if not entry.content: return # skip if same as last entry if entry.content == self._last_history_content: return try: self._history_file.parent.mkdir(parents=True, exist_ok=True) with self._history_file.open("a", encoding="utf-8") as f: f.write(entry.model_dump_json(ensure_ascii=False) + "\n") self._last_history_content = entry.content except OSError as exc: logger.warning( "Failed to append user history entry: {file} ({error})", file=self._history_file, error=exc, ) def _render_bottom_toolbar(self) -> FormattedText: app = get_app_or_none() assert app is not None columns = app.output.get_size().columns fragments: list[tuple[str, str]] = [] fragments.append(("fg:#4d4d4d", "─" * columns)) fragments.append(("", "\n")) remaining = columns # Time-based tip rotation (every 30 s, independent of user submissions) now = time.monotonic() if now - self._last_tip_rotate_time >= _TIP_ROTATE_INTERVAL: self._tip_rotation_index += 1 self._last_tip_rotate_time = now # Status flags: yolo / plan status = self._status_provider() if status.yolo_enabled: fragments.extend([("bold fg:#ffff00", "yolo"), ("", " ")]) remaining -= 6 # "yolo" = 4, " " = 2 if status.plan_mode: fragments.extend([("bold fg:#00aaff", "plan"), ("", " ")]) remaining -= 6 # Mode indicator (agent / shell) + model name + thinking indicator. # Degrade gracefully on narrow terminals: # full: "agent (model-name ○)" → mid: "agent ○" → bare: "agent" mode = str(self._mode) if self._mode == PromptMode.AGENT and self._model_name: thinking_dot = "●" if self._thinking else "○" mode_full = f"{mode} ({self._model_name} {thinking_dot})" mode_mid = f"{mode} {thinking_dot}" if _display_width(mode_full) <= remaining - 2: mode = mode_full elif _display_width(mode_mid) <= remaining - 2: mode = mode_mid # else: keep bare mode name — model_name and dot are both dropped fragments.extend([("", mode), ("", " ")]) remaining -= _display_width(mode) + 2 # CWD (truncated from left) + git branch with status badge # Degrade gracefully on narrow terminals: full → cwd-only → truncated cwd → skip cwd = _truncate_left(_shorten_cwd(str(KaosPath.cwd())), _MAX_CWD_COLS) branch = _get_git_branch() if branch: dirty, ahead, behind = _get_git_status() branch = _truncate_right(branch, _MAX_BRANCH_COLS) badge = _format_git_badge(branch, dirty, ahead, behind) cwd_text = f"{cwd} {badge}" else: cwd_text = cwd cwd_w = _display_width(cwd_text) if cwd_w > remaining - 2: cwd_text = cwd # drop badge cwd_w = _display_width(cwd_text) if cwd_w > remaining - 2: cwd_text = _truncate_right(cwd, max(0, remaining - 2)) cwd_w = _display_width(cwd_text) if cwd_text and remaining >= cwd_w + 2: fragments.extend([("fg:#666666", cwd_text), ("", " ")]) remaining -= cwd_w + 2 # Active background bash task count bg_count = ( self._background_task_count_provider() if self._background_task_count_provider else 0 ) if bg_count > 0: bg_text = f"⚙ bash: {bg_count}" bg_width = _display_width(bg_text) if remaining >= bg_width + 2: fragments.extend([("fg:#888888", bg_text), ("", " ")]) remaining -= bg_width + 2 # Tips fill remaining space on line 1 tip_text = self._get_two_rotating_tips() if tip_text and _display_width(tip_text) > remaining: tip_text = self._get_one_rotating_tip() if tip_text and _display_width(tip_text) <= remaining: fragments.append(("fg:#555555", tip_text)) # ── line 2: toast (left) + context (right) — always rendered ────── fragments.append(("", "\n")) right_text = self._render_right_span(status) right_width = _display_width(right_text) left_toast = _current_toast("left") if left_toast is not None: max_left = max(0, columns - right_width - 2) if max_left > 0: left_text = left_toast.message if _display_width(left_text) > max_left: left_text = _truncate_right(left_text, max_left) left_width = _display_width(left_text) fragments.append(("", left_text)) else: left_width = 0 else: left_width = 0 fragments.append(("", " " * max(0, columns - left_width - right_width))) fragments.append(("", right_text)) return FormattedText(fragments) def _get_two_rotating_tips(self) -> str | None: """Return a string with exactly 2 tips from the rotation, or fewer if not enough.""" n = len(self._tips) if n == 0: return None if n == 1: return self._tips[0] offset = self._tip_rotation_index % n tip1 = self._tips[offset] tip2 = self._tips[(offset + 1) % n] return f"{tip1}{_TIP_SEPARATOR}{tip2}" def _get_one_rotating_tip(self) -> str | None: """Return the single leading tip for the current rotation.""" if not self._tips: return None return self._tips[self._tip_rotation_index % len(self._tips)] @staticmethod def _render_right_span(status: StatusSnapshot) -> str: current_toast = _current_toast("right") if current_toast is None: return format_context_status( status.context_usage, status.context_tokens, status.max_context_tokens, ) return current_toast.message ================================================ FILE: src/kimi_cli/ui/shell/replay.py ================================================ from __future__ import annotations import asyncio import contextlib from collections import deque from collections.abc import Sequence from dataclasses import dataclass from typing import cast from kosong.message import ContentPart, Message from kosong.tooling import ToolError, ToolOk from kimi_cli.soul.message import is_system_reminder_message from kimi_cli.ui.shell.console import console from kimi_cli.ui.shell.echo import render_user_echo from kimi_cli.ui.shell.visualize import visualize from kimi_cli.utils.aioqueue import QueueShutDown from kimi_cli.utils.logging import logger from kimi_cli.utils.message import message_stringify from kimi_cli.utils.slashcmd import parse_slash_command_call from kimi_cli.wire import Wire from kimi_cli.wire.file import WireFile from kimi_cli.wire.types import ( Event, StatusUpdate, SteerInput, StepBegin, TextPart, ToolResult, TurnBegin, is_event, ) MAX_REPLAY_TURNS = 5 @dataclass(slots=True) class _ReplayTurn: user_message: Message events: list[Event] n_steps: int = 0 async def replay_recent_history( history: Sequence[Message], *, wire_file: WireFile | None = None, ) -> None: """ Replay the most recent user-initiated turns from the provided message history or wire file. """ if not history: # if the context history is empty,either this is a new session # or the context has been cleared return start_idx = _find_replay_start(history) history_turns = ( [] if start_idx is None else _build_replay_turns_from_history(history[start_idx:]) ) turns = await _build_replay_turns_from_wire(wire_file) if not turns or (history_turns and not _same_user_turns(turns, history_turns)): turns = history_turns if not turns: return for turn in turns: wire = Wire() console.print(render_user_echo(turn.user_message)) ui_task = asyncio.create_task( visualize(wire.ui_side(merge=False), initial_status=StatusUpdate()) ) for event in turn.events: wire.soul_side.send(event) await asyncio.sleep(0) # yield to UI loop wire.shutdown() with contextlib.suppress(QueueShutDown): await ui_task async def _build_replay_turns_from_wire(wire_file: WireFile | None) -> list[_ReplayTurn]: if wire_file is None or not wire_file.path.exists(): return [] size = wire_file.path.stat().st_size if size > 20 * 1024 * 1024: logger.info( "Wire file too large for replay, skipping: {file} ({size} bytes)", file=wire_file.path, size=size, ) return [] turns: deque[_ReplayTurn] = deque(maxlen=MAX_REPLAY_TURNS) try: async for record in wire_file.iter_records(): wire_msg = record.to_wire_message() if isinstance(wire_msg, TurnBegin): if _is_clear_command_input(wire_msg.user_input): turns.clear() continue turns.append( _ReplayTurn( user_message=_message_from_user_input(wire_msg.user_input), events=[], ) ) continue if isinstance(wire_msg, SteerInput): turns.append( _ReplayTurn( user_message=_message_from_user_input(wire_msg.user_input), events=[], ) ) continue if not is_event(wire_msg) or not turns: continue current_turn = turns[-1] if isinstance(wire_msg, StepBegin): current_turn.n_steps = wire_msg.n current_turn.events.append(wire_msg) except Exception: logger.exception("Failed to build replay turns from wire file {file}:", file=wire_file.path) return [] return list(turns) def _message_from_user_input(user_input: str | list[ContentPart]) -> Message: content = cast( list[ContentPart], list(user_input) if isinstance(user_input, list) else [TextPart(text=user_input)], ) return Message(role="user", content=content) def _same_user_turns(lhs: Sequence[_ReplayTurn], rhs: Sequence[_ReplayTurn]) -> bool: return [message_stringify(turn.user_message) for turn in lhs] == [ message_stringify(turn.user_message) for turn in rhs ] def _is_clear_command_input(user_input: str | list[ContentPart]) -> bool: if isinstance(user_input, list): text = Message(role="user", content=user_input).extract_text(" ").strip() else: text = str(user_input).strip() call = parse_slash_command_call(text) if call is None: return False return call.name in {"clear", "reset"} def _is_user_message(message: Message) -> bool: # FIXME: should consider non-text tool call results which are sent as user messages if message.role != "user": return False if message.extract_text().startswith("CHECKPOINT"): return False return not is_system_reminder_message(message) def _find_replay_start(history: Sequence[Message]) -> int | None: indices = [idx for idx, message in enumerate(history) if _is_user_message(message)] if not indices: return None # only replay last MAX_REPLAY_TURNS messages return indices[max(0, len(indices) - MAX_REPLAY_TURNS)] def _build_replay_turns_from_history(history: Sequence[Message]) -> list[_ReplayTurn]: turns: list[_ReplayTurn] = [] current_turn: _ReplayTurn | None = None for message in history: if _is_user_message(message): # start a new turn if current_turn is not None: turns.append(current_turn) current_turn = _ReplayTurn(user_message=message, events=[]) elif message.role == "assistant": if current_turn is None: continue current_turn.n_steps += 1 current_turn.events.append(StepBegin(n=current_turn.n_steps)) current_turn.events.extend(message.content) current_turn.events.extend(message.tool_calls or []) elif message.role == "tool": if current_turn is None: continue assert message.tool_call_id is not None if any( isinstance(part, TextPart) and part.text.startswith("ERROR") for part in message.content ): result = ToolError(message="", output="", brief="") else: result = ToolOk(output=message.content) current_turn.events.append( ToolResult(tool_call_id=message.tool_call_id, return_value=result) ) if current_turn is not None: turns.append(current_turn) return turns ================================================ FILE: src/kimi_cli/ui/shell/setup.py ================================================ from __future__ import annotations from typing import TYPE_CHECKING, NamedTuple import aiohttp from prompt_toolkit import PromptSession from prompt_toolkit.shortcuts.choice_input import ChoiceInput from pydantic import SecretStr from kimi_cli import logger from kimi_cli.auth import KIMI_CODE_PLATFORM_ID from kimi_cli.auth.platforms import ( PLATFORMS, ModelInfo, Platform, get_platform_by_name, list_models, managed_model_key, managed_provider_key, ) from kimi_cli.config import ( LLMModel, LLMProvider, MoonshotFetchConfig, MoonshotSearchConfig, load_config, save_config, ) from kimi_cli.ui.shell.console import console from kimi_cli.ui.shell.slash import registry if TYPE_CHECKING: from kimi_cli.ui.shell import Shell async def select_platform() -> Platform | None: platform_name = await _prompt_choice( header="Select a platform (↑↓ navigate, Enter select, Ctrl+C cancel):", choices=[platform.name for platform in PLATFORMS], ) if not platform_name: console.print("[red]No platform selected[/red]") return None platform = get_platform_by_name(platform_name) if platform is None: console.print("[red]Unknown platform[/red]") return None return platform async def setup_platform(platform: Platform) -> bool: result = await _setup_platform(platform) if not result: # error message already printed return False _apply_setup_result(result) thinking_label = "on" if result.thinking else "off" console.print("[green]✓ Setup complete![/green]") console.print(f" Platform: [bold]{result.platform.name}[/bold]") console.print(f" Model: [bold]{result.selected_model.id}[/bold]") console.print(f" Thinking: [bold]{thinking_label}[/bold]") console.print(" Reloading...") return True class _SetupResult(NamedTuple): platform: Platform api_key: SecretStr selected_model: ModelInfo models: list[ModelInfo] thinking: bool async def _setup_platform(platform: Platform) -> _SetupResult | None: # enter the API key api_key = await _prompt_text("Enter your API key", is_password=True) if not api_key: return None # list models try: with console.status("[cyan]Verifying API key...[/cyan]"): models = await list_models(platform, api_key) except aiohttp.ClientResponseError as e: logger.error("Failed to get models: {error}", error=e) console.print(f"[red]Failed to get models: {e.message}[/red]") if e.status == 401 and platform.id != KIMI_CODE_PLATFORM_ID: console.print( "[yellow]Hint: If your API key was obtained from Kimi Code, " 'please select "Kimi Code" instead.[/yellow]' ) return None except Exception as e: logger.error("Failed to get models: {error}", error=e) console.print(f"[red]Failed to get models: {e}[/red]") return None # select the model if not models: console.print("[red]No models available for the selected platform[/red]") return None model_map = {model.id: model for model in models} model_id = await _prompt_choice( header="Select a model (↑↓ navigate, Enter select, Ctrl+C cancel):", choices=list(model_map), ) if not model_id: console.print("[red]No model selected[/red]") return None selected_model = model_map[model_id] # Determine thinking mode based on model capabilities capabilities = selected_model.capabilities thinking: bool if "always_thinking" in capabilities: thinking = True elif "thinking" in capabilities: thinking_selection = await _prompt_choice( header="Enable thinking mode? (↑↓ navigate, Enter select, Ctrl+C cancel):", choices=["on", "off"], ) if not thinking_selection: return None thinking = thinking_selection == "on" else: thinking = False return _SetupResult( platform=platform, api_key=SecretStr(api_key), selected_model=selected_model, models=models, thinking=thinking, ) def _apply_setup_result(result: _SetupResult) -> None: config = load_config() provider_key = managed_provider_key(result.platform.id) model_key = managed_model_key(result.platform.id, result.selected_model.id) config.providers[provider_key] = LLMProvider( type="kimi", base_url=result.platform.base_url, api_key=result.api_key, ) for key, model in list(config.models.items()): if model.provider == provider_key: del config.models[key] for model_info in result.models: capabilities = model_info.capabilities or None config.models[managed_model_key(result.platform.id, model_info.id)] = LLMModel( provider=provider_key, model=model_info.id, max_context_size=model_info.context_length, capabilities=capabilities, ) config.default_model = model_key config.default_thinking = result.thinking if result.platform.search_url: config.services.moonshot_search = MoonshotSearchConfig( base_url=result.platform.search_url, api_key=result.api_key, ) if result.platform.fetch_url: config.services.moonshot_fetch = MoonshotFetchConfig( base_url=result.platform.fetch_url, api_key=result.api_key, ) save_config(config) async def _prompt_choice(*, header: str, choices: list[str]) -> str | None: if not choices: return None try: return await ChoiceInput( message=header, options=[(choice, choice) for choice in choices], default=choices[0], ).prompt_async() except (EOFError, KeyboardInterrupt): return None async def _prompt_text(prompt: str, *, is_password: bool = False) -> str | None: session = PromptSession[str]() try: return str( await session.prompt_async( f" {prompt}: ", is_password=is_password, ) ).strip() except (EOFError, KeyboardInterrupt): return None @registry.command def reload(app: Shell, args: str): """Reload configuration""" from kimi_cli.cli import Reload raise Reload ================================================ FILE: src/kimi_cli/ui/shell/slash.py ================================================ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any, cast from prompt_toolkit.shortcuts.choice_input import ChoiceInput from kimi_cli import logger from kimi_cli.auth.platforms import get_platform_name_for_provider, refresh_managed_models from kimi_cli.cli import Reload, SwitchToWeb from kimi_cli.config import load_config, save_config from kimi_cli.exception import ConfigError from kimi_cli.session import Session from kimi_cli.soul.kimisoul import KimiSoul from kimi_cli.ui.shell.console import console from kimi_cli.ui.shell.mcp_status import render_mcp_console from kimi_cli.ui.shell.task_browser import TaskBrowserApp from kimi_cli.utils.changelog import CHANGELOG from kimi_cli.utils.datetime import format_relative_time from kimi_cli.utils.slashcmd import SlashCommand, SlashCommandRegistry if TYPE_CHECKING: from kimi_cli.ui.shell import Shell type ShellSlashCmdFunc = Callable[[Shell, str], None | Awaitable[None]] """ A function that runs as a Shell-level slash command. Raises: Reload: When the configuration should be reloaded. """ registry = SlashCommandRegistry[ShellSlashCmdFunc]() shell_mode_registry = SlashCommandRegistry[ShellSlashCmdFunc]() def ensure_kimi_soul(app: Shell) -> KimiSoul | None: if not isinstance(app.soul, KimiSoul): console.print("[red]KimiSoul required[/red]") return None return app.soul @registry.command(aliases=["quit"]) @shell_mode_registry.command(aliases=["quit"]) def exit(app: Shell, args: str): """Exit the application""" # should be handled by `Shell` raise NotImplementedError SKILL_COMMAND_PREFIX = "skill:" _KEYBOARD_SHORTCUTS = [ ("Ctrl-X", "Toggle agent/shell mode"), ("Shift-Tab", "Toggle plan mode (read-only research)"), ("Ctrl-O", "Edit in external editor ($VISUAL/$EDITOR)"), ("Ctrl-J / Alt-Enter", "Insert newline"), ("Ctrl-V", "Paste (supports images)"), ("Ctrl-D", "Exit"), ("Ctrl-C", "Interrupt"), ] @registry.command(aliases=["h", "?"]) @shell_mode_registry.command(aliases=["h", "?"]) def help(app: Shell, args: str): """Show help information""" from rich.console import Group, RenderableType from rich.text import Text from kimi_cli.utils.rich.columns import BulletColumns def section(title: str, items: list[tuple[str, str]], color: str) -> BulletColumns: lines: list[RenderableType] = [Text.from_markup(f"[bold]{title}:[/bold]")] for name, desc in items: lines.append( BulletColumns( Text.from_markup(f"[{color}]{name}[/{color}]: [grey50]{desc}[/grey50]"), bullet_style=color, ) ) return BulletColumns(Group(*lines)) renderables: list[RenderableType] = [] renderables.append( BulletColumns( Group( Text.from_markup("[grey50]Help! I need somebody. Help! Not just anybody.[/grey50]"), Text.from_markup("[grey50]Help! You know I need someone. Help![/grey50]"), Text.from_markup("[grey50]\u2015 The Beatles, [italic]Help![/italic][/grey50]"), ), bullet_style="grey50", ) ) renderables.append( BulletColumns( Text( "Sure, Kimi is ready to help! " "Just send me messages and I will help you get things done!" ), ) ) commands: list[SlashCommand[Any]] = [] skills: list[SlashCommand[Any]] = [] for cmd in app.available_slash_commands.values(): if cmd.name.startswith(SKILL_COMMAND_PREFIX): skills.append(cmd) else: commands.append(cmd) renderables.append(section("Keyboard shortcuts", _KEYBOARD_SHORTCUTS, "yellow")) renderables.append( section( "Slash commands", [(c.slash_name(), c.description) for c in sorted(commands, key=lambda c: c.name)], "blue", ) ) if skills: renderables.append( section( "Skills", [(c.slash_name(), c.description) for c in sorted(skills, key=lambda c: c.name)], "cyan", ) ) with console.pager(styles=True): console.print(Group(*renderables)) @registry.command @shell_mode_registry.command def version(app: Shell, args: str): """Show version information""" from kimi_cli.constant import VERSION console.print(f"kimi, version {VERSION}") @registry.command async def model(app: Shell, args: str): """Switch LLM model or thinking mode""" from kimi_cli.llm import derive_model_capabilities soul = ensure_kimi_soul(app) if soul is None: return config = soul.runtime.config await refresh_managed_models(config) if not config.models: console.print('[yellow]No models configured, send "/login" to login.[/yellow]') return if not config.is_from_default_location: console.print( "[yellow]Model switching requires the default config file; " "restart without --config/--config-file.[/yellow]" ) return # Find current model/thinking from runtime (may be overridden by --model/--thinking) curr_model_cfg = soul.runtime.llm.model_config if soul.runtime.llm else None curr_model_name: str | None = None if curr_model_cfg is not None: for name, model_cfg in config.models.items(): if model_cfg == curr_model_cfg: curr_model_name = name break curr_thinking = soul.thinking # Step 1: Select model model_choices: list[tuple[str, str]] = [] for name in sorted(config.models): model_cfg = config.models[name] provider_label = get_platform_name_for_provider(model_cfg.provider) or model_cfg.provider marker = " (current)" if name == curr_model_name else "" label = f"{model_cfg.model} ({provider_label}){marker}" model_choices.append((name, label)) try: selected_model_name = await ChoiceInput( message="Select a model (↑↓ navigate, Enter select, Ctrl+C cancel):", options=model_choices, default=curr_model_name or model_choices[0][0], ).prompt_async() except (EOFError, KeyboardInterrupt): return if not selected_model_name: return selected_model_cfg = config.models[selected_model_name] selected_provider = config.providers.get(selected_model_cfg.provider) if selected_provider is None: console.print(f"[red]Provider not found: {selected_model_cfg.provider}[/red]") return # Step 2: Determine thinking mode capabilities = derive_model_capabilities(selected_model_cfg) new_thinking: bool if "always_thinking" in capabilities: new_thinking = True elif "thinking" in capabilities: thinking_choices: list[tuple[str, str]] = [ ("off", "off" + (" (current)" if not curr_thinking else "")), ("on", "on" + (" (current)" if curr_thinking else "")), ] try: thinking_selection = await ChoiceInput( message="Enable thinking mode? (↑↓ navigate, Enter select, Ctrl+C cancel):", options=thinking_choices, default="on" if curr_thinking else "off", ).prompt_async() except (EOFError, KeyboardInterrupt): return if not thinking_selection: return new_thinking = thinking_selection == "on" else: new_thinking = False # Check if anything changed model_changed = curr_model_name != selected_model_name thinking_changed = curr_thinking != new_thinking if not model_changed and not thinking_changed: console.print( f"[yellow]Already using {selected_model_name} " f"with thinking {'on' if new_thinking else 'off'}.[/yellow]" ) return # Save and reload prev_model = config.default_model prev_thinking = config.default_thinking config.default_model = selected_model_name config.default_thinking = new_thinking try: config_for_save = load_config() config_for_save.default_model = selected_model_name config_for_save.default_thinking = new_thinking save_config(config_for_save) except (ConfigError, OSError) as exc: config.default_model = prev_model config.default_thinking = prev_thinking console.print(f"[red]Failed to save config: {exc}[/red]") return console.print( f"[green]Switched to {selected_model_name} " f"with thinking {'on' if new_thinking else 'off'}. " "Reloading...[/green]" ) raise Reload(session_id=soul.runtime.session.id) @registry.command @shell_mode_registry.command async def editor(app: Shell, args: str): """Set default external editor for Ctrl-O""" from kimi_cli.utils.editor import get_editor_command soul = ensure_kimi_soul(app) if soul is None: return config = soul.runtime.config config_file = config.source_file if config_file is None: console.print( "[yellow]Editor switching is unavailable with inline --config; " "use --config-file to persist this setting.[/yellow]" ) return current_editor = config.default_editor # If args provided directly, use as editor command if args.strip(): new_editor = args.strip() else: options: list[tuple[str, str]] = [ ("code --wait", "VS Code (code --wait)"), ("vim", "Vim"), ("nano", "Nano"), ("", "Auto-detect (use $VISUAL/$EDITOR)"), ] # Mark current selection options = [ (val, label + (" ← current" if val == current_editor else "")) for val, label in options ] try: choice = cast( str | None, await ChoiceInput( message="Select an editor (↑↓ navigate, Enter select, Ctrl+C cancel):", options=options, default=( current_editor if current_editor in {v for v, _ in options} else "code --wait" ), ).prompt_async(), ) except (EOFError, KeyboardInterrupt): return if choice is None: return new_editor = choice # Validate the editor binary is available if new_editor: import shlex import shutil try: parts = shlex.split(new_editor) except ValueError: console.print(f"[red]Invalid editor command: {new_editor}[/red]") return binary = parts[0] if not shutil.which(binary): console.print( f"[yellow]Warning: '{binary}' not found in PATH. " f"Saving anyway — make sure it's installed before using Ctrl-O.[/yellow]" ) if new_editor == current_editor: console.print(f"[yellow]Editor is already set to: {new_editor or 'auto-detect'}[/yellow]") return # Save to disk try: config_for_save = load_config(config_file) config_for_save.default_editor = new_editor save_config(config_for_save, config_file) except (ConfigError, OSError) as exc: console.print(f"[red]Failed to save config: {exc}[/red]") return # Sync in-memory config so Ctrl-O picks it up immediately config.default_editor = new_editor if new_editor: console.print(f"[green]Editor set to: {new_editor}[/green]") else: resolved = get_editor_command() label = " ".join(resolved) if resolved else "none" console.print(f"[green]Editor set to auto-detect (resolved: {label})[/green]") @registry.command(aliases=["release-notes"]) @shell_mode_registry.command(aliases=["release-notes"]) def changelog(app: Shell, args: str): """Show release notes""" from rich.console import Group, RenderableType from rich.text import Text from kimi_cli.utils.rich.columns import BulletColumns renderables: list[RenderableType] = [] for ver, entry in CHANGELOG.items(): title = f"[bold]{ver}[/bold]" if entry.description: title += f": {entry.description}" lines: list[RenderableType] = [Text.from_markup(title)] for item in entry.entries: if item.lower().startswith("lib:"): continue lines.append( BulletColumns( Text.from_markup(f"[grey50]{item}[/grey50]"), bullet_style="grey50", ), ) renderables.append(BulletColumns(Group(*lines))) with console.pager(styles=True): console.print(Group(*renderables)) @registry.command @shell_mode_registry.command def feedback(app: Shell, args: str): """Submit feedback to make Kimi Code CLI better""" import webbrowser ISSUE_URL = "https://github.com/MoonshotAI/kimi-cli/issues" if webbrowser.open(ISSUE_URL): return console.print(f"Please submit feedback at [underline]{ISSUE_URL}[/underline].") @registry.command(aliases=["reset"]) async def clear(app: Shell, args: str): """Clear the context""" if ensure_kimi_soul(app) is None: return await app.run_soul_command("/clear") raise Reload() @registry.command async def new(app: Shell, args: str): """Start a new session""" soul = ensure_kimi_soul(app) if soul is None: return current_session = soul.runtime.session work_dir = current_session.work_dir # Clean up the current session if it has no content, so that chaining # /new commands (or switching away before the first message) does not # leave orphan empty session directories on disk. if current_session.is_empty(): await current_session.delete() session = await Session.create(work_dir) console.print("[green]New session created. Switching...[/green]") raise Reload(session_id=session.id) @registry.command(name="sessions", aliases=["resume"]) async def list_sessions(app: Shell, args: str): """List sessions and resume optionally""" soul = ensure_kimi_soul(app) if soul is None: return work_dir = soul.runtime.session.work_dir current_session = soul.runtime.session current_session_id = current_session.id sessions = [ session for session in await Session.list(work_dir) if session.id != current_session_id ] await current_session.refresh() sessions.insert(0, current_session) choices: list[tuple[str, str]] = [] for session in sessions: time_str = format_relative_time(session.updated_at) marker = " (current)" if session.id == current_session_id else "" label = f"{session.title}, {time_str}{marker}" choices.append((session.id, label)) try: selection = await ChoiceInput( message="Select a session to switch to (↑↓ navigate, Enter select, Ctrl+C cancel):", options=choices, default=choices[0][0], ).prompt_async() except (EOFError, KeyboardInterrupt): return if not selection: return if selection == current_session_id: console.print("[yellow]You are already in this session.[/yellow]") return console.print(f"[green]Switching to session {selection}...[/green]") raise Reload(session_id=selection) @registry.command(name="task") @shell_mode_registry.command(name="task") async def task(app: Shell, args: str): """Browse and manage background tasks""" soul = ensure_kimi_soul(app) if soul is None: return if args.strip(): console.print('[yellow]Usage: "/task" opens the interactive task browser.[/yellow]') return if soul.runtime.role != "root": console.print("[yellow]Background tasks are only available from the root agent.[/yellow]") return await TaskBrowserApp(soul).run() @registry.command def web(app: Shell, args: str): """Open Kimi Code Web UI in browser""" soul = ensure_kimi_soul(app) session_id = soul.runtime.session.id if soul else None raise SwitchToWeb(session_id=session_id) @registry.command async def mcp(app: Shell, args: str): """Show MCP servers and tools""" from rich.live import Live soul = ensure_kimi_soul(app) if soul is None: return await soul.start_background_mcp_loading() snapshot = soul.status.mcp_status if snapshot is None: console.print("[yellow]No MCP servers configured.[/yellow]") return if not snapshot.loading: console.print(render_mcp_console(snapshot)) return with Live( render_mcp_console(snapshot), console=console, refresh_per_second=8, transient=False, ) as live: while True: snapshot = soul.status.mcp_status if snapshot is None: break live.update(render_mcp_console(snapshot), refresh=True) if not snapshot.loading: break await asyncio.sleep(0.125) try: await soul.wait_for_background_mcp_loading() except Exception as e: logger.debug("MCP loading completed with error while rendering /mcp: {error}", error=e) snapshot = soul.status.mcp_status if snapshot is not None: live.update(render_mcp_console(snapshot), refresh=True) from . import ( # noqa: E402 debug, # noqa: F401 # type: ignore[reportUnusedImport] export_import, # noqa: F401 # type: ignore[reportUnusedImport] oauth, # noqa: F401 # type: ignore[reportUnusedImport] setup, # noqa: F401 # type: ignore[reportUnusedImport] update, # noqa: F401 # type: ignore[reportUnusedImport] usage, # noqa: F401 # type: ignore[reportUnusedImport] ) ================================================ FILE: src/kimi_cli/ui/shell/startup.py ================================================ from __future__ import annotations from rich.status import Status from kimi_cli.ui.shell.console import console class ShellStartupProgress: """Transient startup status shown while the shell is initializing.""" def __init__(self, *, enabled: bool | None = None) -> None: self._enabled = console.is_terminal if enabled is None else enabled self._status: Status | None = None def update(self, message: str) -> None: if not self._enabled: return status_message = f"[cyan]{message}[/cyan]" if self._status is None: self._status = console.status(status_message, spinner="dots") self._status.start() return self._status.update(status_message) def stop(self) -> None: if self._status is None: return self._status.stop() self._status = None ================================================ FILE: src/kimi_cli/ui/shell/task_browser.py ================================================ import time from dataclasses import dataclass, field from typing import Literal from prompt_toolkit.application import Application from prompt_toolkit.application.run_in_terminal import run_in_terminal from prompt_toolkit.filters import Condition from prompt_toolkit.formatted_text import StyleAndTextTuples from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent from prompt_toolkit.layout import HSplit, Layout, VSplit, Window from prompt_toolkit.layout.controls import FormattedTextControl from prompt_toolkit.styles import Style from prompt_toolkit.widgets import Box, Frame, RadioList from rich.console import Group from rich.panel import Panel from rich.text import Text from kimi_cli.background import TaskView, is_terminal_status from kimi_cli.soul.kimisoul import KimiSoul from kimi_cli.ui.shell.console import console from kimi_cli.utils.datetime import format_duration, format_relative_time TaskBrowserFilter = Literal["all", "active"] _EMPTY_TASK_ID = "__empty__" _PREVIEW_MAX_LINES = 6 _PREVIEW_MAX_BYTES = 4_000 _FULL_OUTPUT_MAX_BYTES = 200_000 _FULL_OUTPUT_MAX_LINES = 4_000 _AUTO_REFRESH_SECONDS = 1.0 _FLASH_MESSAGE_SECONDS = 3.0 def format_task_choice(view: TaskView, *, now: float | None = None) -> str: description = view.spec.description.strip() or "(no description)" return " · ".join( [ f"[{view.runtime.status}]", description, view.spec.id, view.spec.kind, _task_timing_label(view, now=now) or "updated just now", ] ) @dataclass(slots=True) class TaskBrowserModel: soul: KimiSoul filter_mode: TaskBrowserFilter = "all" message: str = "" message_expires_at: float | None = None pending_stop_task_id: str | None = None all_views: list[TaskView] = field(default_factory=lambda: []) visible_views: list[TaskView] = field(default_factory=lambda: []) @property def manager(self): return self.soul.runtime.background_tasks @property def config(self): return self.soul.runtime.config.background def refresh(self, selected_task_id: str | None = None) -> tuple[list[tuple[str, str]], str]: self.manager.reconcile() self.all_views = self.manager.list_tasks(limit=None) self.all_views.sort(key=_task_sort_key) if self.filter_mode == "active": self.visible_views = [ view for view in self.all_views if not is_terminal_status(view.runtime.status) ] else: self.visible_views = list(self.all_views) if not self.visible_views: label = ( "No active background tasks." if self.filter_mode == "active" else "No background tasks in this session." ) self.pending_stop_task_id = None return [(_EMPTY_TASK_ID, label)], _EMPTY_TASK_ID values = [(view.spec.id, format_task_choice(view)) for view in self.visible_views] valid_ids = {task_id for task_id, _label in values} selected = selected_task_id if selected_task_id in valid_ids else values[0][0] if self.pending_stop_task_id not in valid_ids: self.pending_stop_task_id = None return values, selected def view_for(self, task_id: str | None) -> TaskView | None: if not task_id or task_id == _EMPTY_TASK_ID: return None for view in self.visible_views: if view.spec.id == task_id: return view return self.manager.get_task(task_id) def set_message(self, text: str, *, duration_s: float = _FLASH_MESSAGE_SECONDS) -> None: self.message = text self.message_expires_at = time.time() + duration_s def current_message(self) -> str | None: if not self.message: return None if self.message_expires_at is None: return self.message if time.time() > self.message_expires_at: self.message = "" self.message_expires_at = None return None return self.message def summary_fragments(self) -> StyleAndTextTuples: counts = { "running": 0, "starting": 0, "failed": 0, "completed": 0, "killed": 0, "lost": 0, } for view in self.all_views: counts[view.runtime.status] = counts.get(view.runtime.status, 0) + 1 scope = "ALL" if self.filter_mode == "all" else "ACTIVE" return [ ("class:header.title", " TASK BROWSER "), ("class:header.meta", f" filter={scope} "), ("class:status.running", f" {counts['running']} running "), ("class:status.info", f" {counts['starting']} starting "), ("class:status.error", f" {counts['failed']} failed "), ("class:status.success", f" {counts['completed']} completed "), ("class:status.warning", f" {counts['killed'] + counts['lost']} interrupted "), ("class:header.meta", f" {len(self.all_views)} total "), ] def detail_text(self, task_id: str | None) -> str: view = self.view_for(task_id) if view is None: return "Select a task from the list." terminal_reason = "timed_out" if view.runtime.timed_out else view.runtime.status lines = [ f"Task ID: {view.spec.id}", f"Status: {view.runtime.status}", f"Description: {view.spec.description}", f"Kind: {view.spec.kind}", ] timing = _task_timing_label(view) if timing: lines.append(f"Time: {timing}") if view.spec.cwd: lines.append(f"Cwd: {view.spec.cwd}") if view.spec.command: lines.append(f"Command: {view.spec.command}") if view.runtime.exit_code is not None: lines.append(f"Exit code: {view.runtime.exit_code}") lines.append(f"Terminal reason: {terminal_reason}") if view.runtime.failure_reason: lines.append(f"Reason: {view.runtime.failure_reason}") return "\n".join(lines) def preview_text(self, task_id: str | None) -> str: view = self.view_for(task_id) if view is None: return "No output to preview." preview = self.manager.tail_output( view.spec.id, max_bytes=_PREVIEW_MAX_BYTES, max_lines=_PREVIEW_MAX_LINES, ) if not preview: return "[no output available]" return preview def full_output(self, task_id: str | None) -> str: view = self.view_for(task_id) if view is None: return "[no output available]" path = self.manager.store.output_path(view.spec.id) total_size = path.stat().st_size if path.exists() else 0 output = self.manager.tail_output( view.spec.id, max_bytes=max(self.config.read_max_bytes * 10, _FULL_OUTPUT_MAX_BYTES), max_lines=_FULL_OUTPUT_MAX_LINES, ) max_bytes = max(self.config.read_max_bytes * 10, _FULL_OUTPUT_MAX_BYTES) if total_size > max_bytes: return ( f"[showing last {max_bytes} bytes of {total_size} bytes]\n\n" f"{output or '[no output available]'}" ) return output or "[no output available]" def footer_fragments(self, task_id: str | None) -> StyleAndTextTuples: if self.pending_stop_task_id is not None: label = self.pending_stop_task_id return [ ("class:footer.warning", f" Confirm stop {label}? "), ("class:footer.key", "Y"), ("class:footer.text", " confirm "), ("class:footer.key", "N"), ("class:footer.text", " cancel "), ] fragments: StyleAndTextTuples = [ ("class:footer.key", " Enter "), ("class:footer.text", "output "), ("class:footer.key", "S"), ("class:footer.text", " stop "), ("class:footer.key", "R"), ("class:footer.text", " refresh "), ("class:footer.key", "Tab"), ("class:footer.text", " filter "), ("class:footer.key", "Q"), ("class:footer.text", " exit "), ("class:footer.meta", f" auto-refresh {_AUTO_REFRESH_SECONDS:.0f}s "), ] if message := self.current_message(): fragments.extend( [ ("class:footer.meta", " | "), ("class:footer.flash", f" {message} "), ] ) return fragments class TaskBrowserApp: def __init__(self, soul: KimiSoul): self._model = TaskBrowserModel(soul) task_values, selected = self._model.refresh() self._task_list = RadioList( values=task_values, default=selected, show_numbers=False, select_on_focus=True, open_character="", select_character=">", close_character="", show_cursor=False, show_scrollbar=False, container_style="class:task-list", checked_style="class:task-list.checked", ) self._app = self._build_app() async def run(self) -> None: await self._app.run_async() @property def _selected_task_id(self) -> str | None: current = self._task_list.current_value if current == _EMPTY_TASK_ID: return None return current def _open_output(self, app: Application[object], task_id: str) -> None: app.create_background_task(self._show_output_in_terminal(task_id)) async def _show_output_in_terminal(self, task_id: str) -> None: def render() -> None: view = self._model.view_for(task_id) if view is None: console.print(f"[yellow]Task not found: {task_id}[/yellow]") return with console.pager(styles=True): console.print(_build_full_output_renderable(view, self._model.full_output(task_id))) await run_in_terminal(render) def _toggle_filter(self) -> None: self._model.filter_mode = "active" if self._model.filter_mode == "all" else "all" self._model.set_message( "Showing active tasks only." if self._model.filter_mode == "active" else "Showing all tasks." ) self._sync_views() def _refresh_views(self) -> None: self._model.set_message("Refreshed.") self._sync_views() def _request_stop_for_selected_task(self) -> None: view = self._model.view_for(self._selected_task_id) if view is None: self._model.set_message("No task selected.") elif is_terminal_status(view.runtime.status): self._model.set_message(f"Task {view.spec.id} is already {view.runtime.status}.") else: self._model.pending_stop_task_id = view.spec.id self._model.message = "" self._model.message_expires_at = None def _confirm_stop_request(self) -> None: task_id = self._model.pending_stop_task_id self._model.pending_stop_task_id = None if task_id is None: return view = self._model.view_for(task_id) if view is None: self._model.set_message(f"Task not found: {task_id}") elif is_terminal_status(view.runtime.status): self._model.set_message(f"Task {task_id} is already {view.runtime.status}.") else: self._model.manager.kill(task_id) self._model.set_message(f"Stop requested for task {task_id}.") self._sync_views() def _cancel_stop_request(self) -> None: self._model.pending_stop_task_id = None self._model.set_message("Stop cancelled.") def _build_app(self) -> Application[None]: kb = KeyBindings() @Condition def stop_pending() -> bool: return self._model.pending_stop_task_id is not None @kb.add("q") @kb.add("escape", filter=~stop_pending) @kb.add("c-c") def _exit(event: KeyPressEvent) -> None: event.app.exit() @kb.add("tab", filter=~stop_pending) def _toggle_filter(event: KeyPressEvent) -> None: self._toggle_filter() event.app.invalidate() @kb.add("r", filter=~stop_pending) def _refresh(event: KeyPressEvent) -> None: self._refresh_views() event.app.invalidate() @kb.add("s", filter=~stop_pending) def _stop(event: KeyPressEvent) -> None: self._request_stop_for_selected_task() event.app.invalidate() @kb.add("y", filter=stop_pending) def _confirm_stop(event: KeyPressEvent) -> None: self._confirm_stop_request() event.app.invalidate() @kb.add("n", filter=stop_pending) @kb.add("escape", filter=stop_pending) def _cancel_stop(event: KeyPressEvent) -> None: self._cancel_stop_request() event.app.invalidate() @kb.add("enter", filter=~stop_pending, eager=True) @kb.add("o", filter=~stop_pending) def _show_output(event: KeyPressEvent) -> None: task_id = self._selected_task_id if task_id is None: self._model.set_message("No task selected.") event.app.invalidate() return self._open_output(event.app, task_id) # Handlers are registered via @kb.add decorators above; mark as accessed. _ = (_exit, _toggle_filter, _refresh, _stop, _confirm_stop, _cancel_stop, _show_output) body = VSplit( [ Frame( Box(self._task_list, padding=1), title=lambda: f" Tasks [{self._model.filter_mode}] ", ), HSplit( [ Frame( Window( FormattedTextControl(self._detail_fragments), wrap_lines=True, ), title=" Detail ", ), Frame( Window( FormattedTextControl(self._preview_fragments), wrap_lines=True, ), title=" Preview Output ", ), ] ), ] ) footer = Window( FormattedTextControl(self._footer_fragments), height=1, style="class:footer", ) header = Window( FormattedTextControl(self._header_fragments), height=1, style="class:header", ) return Application( layout=Layout( HSplit( [ header, body, footer, ] ), focused_element=self._task_list, ), key_bindings=kb, full_screen=True, erase_when_done=True, style=_task_browser_style(), refresh_interval=_AUTO_REFRESH_SECONDS, before_render=lambda _app: self._sync_views(), ) def _sync_views(self) -> None: values, selected = self._model.refresh(self._selected_task_id) self._task_list.values = values self._task_list.current_value = selected self._task_list.current_values = [selected] for index, (value, _label) in enumerate(values): if value == selected: self._task_list._selected_index = index # pyright: ignore[reportPrivateUsage] break def _header_fragments(self) -> StyleAndTextTuples: return self._model.summary_fragments() def _detail_fragments(self) -> StyleAndTextTuples: return [("", self._model.detail_text(self._selected_task_id))] def _preview_fragments(self) -> StyleAndTextTuples: return [("", self._model.preview_text(self._selected_task_id))] def _footer_fragments(self) -> StyleAndTextTuples: return self._model.footer_fragments(self._selected_task_id) def _build_full_output_renderable(view: TaskView, output: str) -> Panel: return Panel( Group( Text(f"Task ID: {view.spec.id}", style="bold"), Text(f"Status: {view.runtime.status}"), Text(f"Description: {view.spec.description}"), Text(""), Text(output), ), title="Background Task Output", border_style="cyan", ) def _task_sort_key(view: TaskView) -> tuple[int, float]: if not is_terminal_status(view.runtime.status): return (0, view.spec.created_at) finished_at = view.runtime.finished_at or view.runtime.updated_at or view.spec.created_at return (1, -finished_at) def _task_timing_label(view: TaskView, *, now: float | None = None) -> str | None: current = now if now is not None else time.time() if view.runtime.finished_at is not None: return f"finished {format_relative_time(view.runtime.finished_at)}" if view.runtime.started_at is not None: seconds = max(0, int(current - view.runtime.started_at)) return f"running {format_duration(seconds)}" return f"updated {format_relative_time(view.runtime.updated_at)}" def _task_browser_style() -> Style: return Style.from_dict( { "header": "bg:#1f2937 #e5e7eb", "header.title": "bg:#1f2937 #67e8f9 bold", "header.meta": "bg:#1f2937 #9ca3af", "status.running": "bg:#1f2937 #86efac bold", "status.success": "bg:#1f2937 #86efac", "status.warning": "bg:#1f2937 #fbbf24", "status.error": "bg:#1f2937 #fca5a5", "status.info": "bg:#1f2937 #93c5fd", "task-list": "bg:#111827 #d1d5db", "task-list.checked": "bg:#164e63 #ecfeff bold", "frame.border": "#155e75", "frame.label": "bg:#0f172a #67e8f9 bold", "footer": "bg:#0f172a #cbd5e1", "footer.key": "bg:#0f172a #67e8f9 bold", "footer.text": "bg:#0f172a #cbd5e1", "footer.warning": "bg:#7f1d1d #fecaca bold", "footer.meta": "bg:#0f172a #94a3b8", } ) ================================================ FILE: src/kimi_cli/ui/shell/update.py ================================================ from __future__ import annotations import asyncio import os import platform import re import shutil import stat import tarfile import tempfile from enum import Enum, auto from pathlib import Path import aiohttp from kimi_cli.share import get_share_dir from kimi_cli.ui.shell.console import console from kimi_cli.utils.aiohttp import new_client_session from kimi_cli.utils.logging import logger BASE_URL = "https://cdn.kimi.com/binaries/kimi-cli" LATEST_VERSION_URL = f"{BASE_URL}/latest" INSTALL_DIR = Path.home() / ".local" / "bin" # Upgrade command shown in toast notifications. Can be overridden by wrappers UPGRADE_COMMAND = "uv tool upgrade kimi-cli" class UpdateResult(Enum): UPDATE_AVAILABLE = auto() UPDATED = auto() UP_TO_DATE = auto() FAILED = auto() UNSUPPORTED = auto() _UPDATE_LOCK = asyncio.Lock() def semver_tuple(version: str) -> tuple[int, int, int]: v = version.strip() if v.startswith("v"): v = v[1:] match = re.match(r"^(\d+)\.(\d+)(?:\.(\d+))?", v) if not match: return (0, 0, 0) major = int(match.group(1)) minor = int(match.group(2)) patch = int(match.group(3) or 0) return (major, minor, patch) def _detect_target() -> str | None: sys_name = platform.system() mach = platform.machine() if mach in ("x86_64", "amd64", "AMD64"): arch = "x86_64" elif mach in ("arm64", "aarch64"): arch = "aarch64" else: logger.error("Unsupported architecture: {mach}", mach=mach) return None if sys_name == "Darwin": os_name = "apple-darwin" elif sys_name == "Linux": os_name = "unknown-linux-gnu" else: logger.error("Unsupported OS: {sys_name}", sys_name=sys_name) return None return f"{arch}-{os_name}" async def _get_latest_version(session: aiohttp.ClientSession) -> str | None: try: async with session.get(LATEST_VERSION_URL) as resp: resp.raise_for_status() data = await resp.text() return data.strip() except aiohttp.ClientError: logger.exception("Failed to get latest version:") return None async def do_update(*, print: bool = True, check_only: bool = False) -> UpdateResult: async with _UPDATE_LOCK: return await _do_update(print=print, check_only=check_only) LATEST_VERSION_FILE = get_share_dir() / "latest_version.txt" async def _do_update(*, print: bool, check_only: bool) -> UpdateResult: from kimi_cli.constant import VERSION as current_version def _print(message: str) -> None: if print: console.print(message) target = _detect_target() if not target: _print("[red]Failed to detect target platform.[/red]") return UpdateResult.UNSUPPORTED async with new_client_session() as session: logger.info("Checking for updates...") _print("Checking for updates...") latest_version = await _get_latest_version(session) if not latest_version: _print("[red]Failed to check for updates.[/red]") return UpdateResult.FAILED logger.debug("Latest version: {latest_version}", latest_version=latest_version) LATEST_VERSION_FILE.write_text(latest_version, encoding="utf-8") cur_t = semver_tuple(current_version) lat_t = semver_tuple(latest_version) if cur_t >= lat_t: logger.debug("Already up to date: {current_version}", current_version=current_version) _print("[green]Already up to date.[/green]") return UpdateResult.UP_TO_DATE if check_only: logger.info( "Update available: current={current_version}, latest={latest_version}", current_version=current_version, latest_version=latest_version, ) _print(f"[yellow]Update available: {latest_version}[/yellow]") return UpdateResult.UPDATE_AVAILABLE logger.info( "Updating from {current_version} to {latest_version}...", current_version=current_version, latest_version=latest_version, ) _print(f"Updating from {current_version} to {latest_version}...") filename = f"kimi-{latest_version}-{target}.tar.gz" download_url = f"{BASE_URL}/{latest_version}/{filename}" with tempfile.TemporaryDirectory(prefix="kimi-cli-") as tmpdir: tar_path = os.path.join(tmpdir, filename) logger.info("Downloading from {download_url}...", download_url=download_url) _print("[grey50]Downloading...[/grey50]") try: async with session.get(download_url) as resp: resp.raise_for_status() with open(tar_path, "wb") as f: async for chunk in resp.content.iter_chunked(1024 * 64): if chunk: f.write(chunk) except aiohttp.ClientError: logger.exception( "Failed to download update from {download_url}", download_url=download_url, ) _print("[red]Failed to download.[/red]") return UpdateResult.FAILED except Exception: logger.exception("Failed to download:") _print("[red]Failed to download.[/red]") return UpdateResult.FAILED logger.info("Extracting archive {tar_path}...", tar_path=tar_path) _print("[grey50]Extracting...[/grey50]") try: with tarfile.open(tar_path, "r:gz") as tar: tar.extractall(tmpdir) binary_path = None for root, _, files in os.walk(tmpdir): if "kimi" in files: binary_path = os.path.join(root, "kimi") break if not binary_path: logger.error("Binary 'kimi' not found in archive.") _print("[red]Binary 'kimi' not found in archive.[/red]") return UpdateResult.FAILED except Exception: logger.exception("Failed to extract archive:") _print("[red]Failed to extract archive.[/red]") return UpdateResult.FAILED INSTALL_DIR.mkdir(parents=True, exist_ok=True) dest_path = INSTALL_DIR / "kimi" logger.info("Installing to {dest_path}...", dest_path=dest_path) _print("[grey50]Installing...[/grey50]") try: shutil.copy2(binary_path, dest_path) os.chmod( dest_path, os.stat(dest_path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH, ) except Exception: logger.exception("Failed to install:") _print("[red]Failed to install.[/red]") return UpdateResult.FAILED _print("[green]Updated successfully![/green]") _print("[yellow]Restart Kimi Code CLI to use the new version.[/yellow]") return UpdateResult.UPDATED # @meta_command # async def update(app: "Shell", args: list[str]): # """Check for updates""" # await do_update(print=True) # @meta_command(name="check-update") # async def check_update(app: "Shell", args: list[str]): # """Check for updates""" # await do_update(print=True, check_only=True) ================================================ FILE: src/kimi_cli/ui/shell/usage.py ================================================ """This file is pure vibe-coded. If any bugs are found, let's just rewrite it...""" from __future__ import annotations from collections.abc import Mapping, Sequence from dataclasses import dataclass from typing import TYPE_CHECKING, Any, cast import aiohttp from rich.console import Group, RenderableType from rich.panel import Panel from rich.progress_bar import ProgressBar from rich.table import Table from rich.text import Text from kimi_cli.auth import KIMI_CODE_PLATFORM_ID from kimi_cli.auth.platforms import get_platform_by_id, parse_managed_provider_key from kimi_cli.config import LLMModel from kimi_cli.soul.kimisoul import KimiSoul from kimi_cli.ui.shell.console import console from kimi_cli.ui.shell.slash import registry from kimi_cli.utils.aiohttp import new_client_session from kimi_cli.utils.datetime import format_duration if TYPE_CHECKING: from kimi_cli.ui.shell import Shell @dataclass(slots=True, frozen=True) class UsageRow: label: str used: int limit: int reset_hint: str | None = None @registry.command(aliases=["/status"]) async def usage(app: Shell, args: str): """Display API usage and quota information""" assert isinstance(app.soul, KimiSoul) if app.soul.runtime.llm is None: console.print("[red]LLM not set. Please run /login first.[/red]") return provider = app.soul.runtime.llm.provider_config if provider is None: console.print("[red]LLM provider configuration not found.[/red]") return usage_url = _usage_url(app.soul.runtime.llm.model_config) if usage_url is None: console.print("[yellow]Usage is available on Kimi Code platform only.[/yellow]") return with console.status("[cyan]Fetching usage...[/cyan]"): api_key = app.soul.runtime.oauth.resolve_api_key(provider.api_key, provider.oauth) try: payload = await _fetch_usage(usage_url, api_key) except aiohttp.ClientResponseError as e: message = "Failed to fetch usage." if e.status == 401: message = "Authorization failed. Please check your API key." elif e.status == 404: message = "Usage endpoint not available. Try Kimi For Coding." console.print(f"[red]{message}[/red]") return except aiohttp.ClientError as e: console.print(f"[red]Failed to fetch usage: {e}[/red]") return summary, limits = _parse_usage_payload(payload) if summary is None and not limits: console.print("[yellow]No usage data available.[/yellow]") return console.print(_build_usage_panel(summary, limits)) def _usage_url(model: LLMModel | None) -> str | None: if model is None: return None platform_id = parse_managed_provider_key(model.provider) if platform_id is None: return None platform = get_platform_by_id(platform_id) if platform is None or platform.id != KIMI_CODE_PLATFORM_ID: return None base_url = platform.base_url.rstrip("/") return f"{base_url}/usages" async def _fetch_usage(url: str, api_key: str) -> Mapping[str, Any]: async with ( new_client_session() as session, session.get( url, headers={"Authorization": f"Bearer {api_key}"}, raise_for_status=True, ) as resp, ): return await resp.json() def _parse_usage_payload( payload: Mapping[str, Any], ) -> tuple[UsageRow | None, list[UsageRow]]: summary: UsageRow | None = None limits: list[UsageRow] = [] usage = payload.get("usage") if isinstance(usage, Mapping): usage_map: Mapping[str, Any] = cast(Mapping[str, Any], usage) summary = _to_usage_row(usage_map, default_label="Weekly limit") raw_limits_obj = payload.get("limits") if isinstance(raw_limits_obj, Sequence): limits_seq: Sequence[Any] = cast(Sequence[Any], raw_limits_obj) for idx, item in enumerate(limits_seq): if not isinstance(item, Mapping): continue item_map: Mapping[str, Any] = cast(Mapping[str, Any], item) detail_raw = item_map.get("detail") detail: Mapping[str, Any] = ( cast(Mapping[str, Any], detail_raw) if isinstance(detail_raw, Mapping) else item_map ) # window may contain duration/timeUnit window_raw = item_map.get("window") window: Mapping[str, Any] = ( cast(Mapping[str, Any], window_raw) if isinstance(window_raw, Mapping) else {} ) label = _limit_label(item_map, detail, window, idx) row = _to_usage_row(detail, default_label=label) if row: limits.append(row) return summary, limits def _to_usage_row(data: Mapping[str, Any], *, default_label: str) -> UsageRow | None: limit = _to_int(data.get("limit")) # Support both "used" and "remaining" (used = limit - remaining) used = _to_int(data.get("used")) if used is None: remaining = _to_int(data.get("remaining")) if remaining is not None and limit is not None: used = limit - remaining if used is None and limit is None: return None return UsageRow( label=str(data.get("name") or data.get("title") or default_label), used=used or 0, limit=limit or 0, reset_hint=_reset_hint(data), ) def _limit_label( item: Mapping[str, Any], detail: Mapping[str, Any], window: Mapping[str, Any], idx: int, ) -> str: # Try to extract a human-readable label for key in ("name", "title", "scope"): if val := (item.get(key) or detail.get(key)): return str(val) # Convert duration to readable format (e.g., 300 minutes -> "5h quota") # Check window first, then item, then detail duration = _to_int(window.get("duration") or item.get("duration") or detail.get("duration")) time_unit = window.get("timeUnit") or item.get("timeUnit") or detail.get("timeUnit") or "" if duration: if "MINUTE" in time_unit: if duration >= 60 and duration % 60 == 0: return f"{duration // 60}h limit" return f"{duration}m limit" if "HOUR" in time_unit: return f"{duration}h limit" if "DAY" in time_unit: return f"{duration}d limit" return f"{duration}s limit" return f"Limit #{idx + 1}" def _reset_hint(data: Mapping[str, Any]) -> str | None: for key in ("reset_at", "resetAt", "reset_time", "resetTime"): if val := data.get(key): return _format_reset_time(str(val)) for key in ("reset_in", "resetIn", "ttl", "window"): seconds = _to_int(data.get(key)) if seconds: return f"resets in {format_duration(seconds)}" return None def _format_reset_time(val: str) -> str: """Format ISO timestamp to a readable duration.""" from datetime import UTC, datetime try: # Parse ISO format like "2025-12-23T05:24:18.443553353Z" # Truncate nanoseconds to microseconds for Python compatibility if "." in val and val.endswith("Z"): base, frac = val[:-1].split(".") frac = frac[:6] # Keep only microseconds val = f"{base}.{frac}Z" dt = datetime.fromisoformat(val.replace("Z", "+00:00")) now = datetime.now(UTC) delta = dt - now if delta.total_seconds() <= 0: return "reset" return f"resets in {format_duration(int(delta.total_seconds()))}" except (ValueError, TypeError): return f"resets at {val}" def _to_int(value: Any) -> int | None: try: return int(value) except (TypeError, ValueError): return None def _build_usage_panel(summary: UsageRow | None, limits: list[UsageRow]) -> Panel: rows = ([summary] if summary else []) + limits if not rows: return Panel( Text("No usage data", style="grey50"), title="API Usage", border_style="wheat4" ) # Calculate label width for alignment label_width = max(len(r.label) for r in rows) label_width = max(label_width, 6) # minimum width lines: list[RenderableType] = [] for row in rows: lines.append(_format_row(row, label_width)) return Panel( Group(*lines), title="API Usage", border_style="wheat4", padding=(0, 2), expand=False, ) def _format_row(row: UsageRow, label_width: int) -> RenderableType: ratio = (row.limit - row.used) / row.limit if row.limit > 0 else 0 color = _ratio_color(ratio) label = Text(f"{row.label:<{label_width}} ", style="cyan") bar = ProgressBar(total=row.limit or 1, completed=row.used, width=20, complete_style=color) detail = Text() percent = ratio * 100 detail.append(f" {percent:.0f}% left", style="bold") if row.reset_hint: detail.append(f" ({row.reset_hint})", style="grey50") t = Table.grid(padding=0) t.add_column(width=label_width + 2) t.add_column(width=20) t.add_column() t.add_row(label, bar, detail) return t def _ratio_color(ratio: float) -> str: if ratio >= 0.9: return "red" if ratio >= 0.7: return "yellow" return "green" ================================================ FILE: src/kimi_cli/ui/shell/visualize.py ================================================ from __future__ import annotations import asyncio import json from collections import deque from collections.abc import Awaitable, Callable from contextlib import asynccontextmanager, suppress from io import StringIO from typing import Any, NamedTuple, cast import streamingjson # type: ignore[reportMissingTypeStubs] from kosong.message import Message from kosong.tooling import ToolError, ToolOk from prompt_toolkit.application.run_in_terminal import run_in_terminal from prompt_toolkit.buffer import Buffer from prompt_toolkit.document import Document from prompt_toolkit.formatted_text import ANSI from prompt_toolkit.key_binding import KeyPressEvent from rich.console import Console as RichConsole from rich.console import Group, RenderableType from rich.live import Live from rich.markup import escape from rich.padding import Padding from rich.panel import Panel from rich.spinner import Spinner from rich.style import Style from rich.text import Text from kimi_cli.soul import format_context_status from kimi_cli.tools import extract_key_argument from kimi_cli.ui.shell.console import NEUTRAL_MARKDOWN_THEME, console from kimi_cli.ui.shell.echo import render_user_echo, render_user_echo_text from kimi_cli.ui.shell.keyboard import KeyboardListener, KeyEvent from kimi_cli.ui.shell.prompt import ( CustomPromptSession, UserInput, ) from kimi_cli.utils.aioqueue import QueueShutDown from kimi_cli.utils.diff import format_unified_diff from kimi_cli.utils.logging import logger from kimi_cli.utils.rich.columns import BulletColumns from kimi_cli.utils.rich.markdown import Markdown from kimi_cli.utils.rich.syntax import KimiSyntax from kimi_cli.wire import WireUISide from kimi_cli.wire.types import ( ApprovalRequest, ApprovalResponse, BackgroundTaskDisplayBlock, BriefDisplayBlock, CompactionBegin, CompactionEnd, ContentPart, DiffDisplayBlock, MCPLoadingBegin, MCPLoadingEnd, Notification, QuestionRequest, ShellDisplayBlock, StatusUpdate, SteerInput, StepBegin, StepInterrupted, SubagentEvent, TextPart, ThinkPart, TodoDisplayBlock, ToolCall, ToolCallPart, ToolCallRequest, ToolResult, ToolReturnValue, TurnBegin, TurnEnd, WireMessage, ) MAX_SUBAGENT_TOOL_CALLS_TO_SHOW = 4 MAX_LIVE_NOTIFICATIONS = 4 # Truncation limits for approval request display MAX_PREVIEW_LINES = 4 async def visualize( wire: WireUISide, *, initial_status: StatusUpdate, cancel_event: asyncio.Event | None = None, prompt_session: CustomPromptSession | None = None, steer: Callable[[str | list[ContentPart]], None] | None = None, bind_running_input: Callable[[Callable[[UserInput], None], Callable[[], None]], None] | None = None, unbind_running_input: Callable[[], None] | None = None, ): """ A loop to consume agent events and visualize the agent behavior. Args: wire: Communication channel with the agent initial_status: Initial status snapshot cancel_event: Event that can be set (e.g., by ESC key) to cancel the run """ if prompt_session is not None and steer is not None: view = _PromptLiveView( initial_status, prompt_session=prompt_session, steer=steer, cancel_event=cancel_event, ) prompt_session.attach_running_prompt(view) def _cancel_running_input() -> None: if cancel_event is not None: cancel_event.set() if bind_running_input is not None: bind_running_input(view.handle_local_input, _cancel_running_input) else: view = _LiveView(initial_status, cancel_event) try: await view.visualize_loop(wire) finally: if prompt_session is not None and steer is not None: if unbind_running_input is not None: unbind_running_input() assert isinstance(view, _PromptLiveView) prompt_session.detach_running_prompt(view) class _ContentBlock: def __init__(self, is_think: bool): self.is_think = is_think self._spinner = Spinner("dots", "Thinking..." if is_think else "Composing...") self.raw_text = "" def compose(self) -> RenderableType: return self._spinner def compose_final(self) -> RenderableType: return BulletColumns( Markdown( self.raw_text, style="grey50 italic" if self.is_think else "", ), bullet_style="grey50" if self.is_think else None, ) def append(self, content: str) -> None: self.raw_text += content class _ToolCallBlock: class FinishedSubCall(NamedTuple): call: ToolCall result: ToolReturnValue def __init__(self, tool_call: ToolCall): self._tool_name = tool_call.function.name self._lexer = streamingjson.Lexer() if tool_call.function.arguments is not None: self._lexer.append_string(tool_call.function.arguments) self._argument = extract_key_argument(self._lexer, self._tool_name) self._full_url = self._extract_full_url(tool_call.function.arguments, self._tool_name) self._result: ToolReturnValue | None = None self._ongoing_subagent_tool_calls: dict[str, ToolCall] = {} self._last_subagent_tool_call: ToolCall | None = None self._n_finished_subagent_tool_calls = 0 self._finished_subagent_tool_calls = deque[_ToolCallBlock.FinishedSubCall]( maxlen=MAX_SUBAGENT_TOOL_CALLS_TO_SHOW ) self._spinning_dots = Spinner("dots", text="") self._renderable: RenderableType = self._compose() def compose(self) -> RenderableType: return self._renderable @property def finished(self) -> bool: return self._result is not None def append_args_part(self, args_part: str): if self.finished: return self._lexer.append_string(args_part) # TODO: maybe don't extract detail if it's already stable argument = extract_key_argument(self._lexer, self._tool_name) if argument and argument != self._argument: self._argument = argument self._full_url = self._extract_full_url(self._lexer.complete_json(), self._tool_name) self._renderable = BulletColumns( self._build_headline_text(), bullet=self._spinning_dots, ) def finish(self, result: ToolReturnValue): self._result = result self._renderable = self._compose() def append_sub_tool_call(self, tool_call: ToolCall): self._ongoing_subagent_tool_calls[tool_call.id] = tool_call self._last_subagent_tool_call = tool_call def append_sub_tool_call_part(self, tool_call_part: ToolCallPart): if self._last_subagent_tool_call is None: return if not tool_call_part.arguments_part: return if self._last_subagent_tool_call.function.arguments is None: self._last_subagent_tool_call.function.arguments = tool_call_part.arguments_part else: self._last_subagent_tool_call.function.arguments += tool_call_part.arguments_part def finish_sub_tool_call(self, tool_result: ToolResult): self._last_subagent_tool_call = None sub_tool_call = self._ongoing_subagent_tool_calls.pop(tool_result.tool_call_id, None) if sub_tool_call is None: return self._finished_subagent_tool_calls.append( _ToolCallBlock.FinishedSubCall( call=sub_tool_call, result=tool_result.return_value, ) ) self._n_finished_subagent_tool_calls += 1 self._renderable = self._compose() def _compose(self) -> RenderableType: lines: list[RenderableType] = [ self._build_headline_text(), ] if self._n_finished_subagent_tool_calls > MAX_SUBAGENT_TOOL_CALLS_TO_SHOW: n_hidden = self._n_finished_subagent_tool_calls - MAX_SUBAGENT_TOOL_CALLS_TO_SHOW lines.append( BulletColumns( Text( f"{n_hidden} more tool call{'s' if n_hidden > 1 else ''} ...", style="grey50 italic", ), bullet_style="grey50", ) ) for sub_call, sub_result in self._finished_subagent_tool_calls: argument = extract_key_argument( sub_call.function.arguments or "", sub_call.function.name ) sub_url = self._extract_full_url(sub_call.function.arguments, sub_call.function.name) sub_text = Text() sub_text.append("Used ") sub_text.append(sub_call.function.name, style="blue") if argument: sub_text.append(" (", style="grey50") arg_style = Style(color="grey50", link=sub_url) if sub_url else "grey50" sub_text.append(argument, style=arg_style) sub_text.append(")", style="grey50") lines.append( BulletColumns( sub_text, bullet_style="green" if not sub_result.is_error else "red", ) ) if self._result is not None: for block in self._result.display: if isinstance(block, BriefDisplayBlock): style = "grey50" if not self._result.is_error else "red" if block.text: lines.append(Markdown(block.text, style=style)) elif isinstance(block, TodoDisplayBlock): markdown = self._render_todo_markdown(block) if markdown: lines.append(Markdown(markdown, style="grey50")) elif isinstance(block, BackgroundTaskDisplayBlock): lines.append( Markdown( (f"`{block.task_id}` [{block.status}] {block.description}"), style="grey50", ) ) if self.finished: assert self._result is not None return BulletColumns( Group(*lines), bullet_style="green" if not self._result.is_error else "red", ) else: return BulletColumns( Group(*lines), bullet=self._spinning_dots, ) @staticmethod def _extract_full_url(arguments: str | None, tool_name: str) -> str | None: """Extract the full URL from FetchURL tool arguments.""" if tool_name != "FetchURL" or not arguments: return None try: args = json.loads(arguments) except (json.JSONDecodeError, TypeError): return None if isinstance(args, dict): url = cast(dict[str, Any], args).get("url") if url: return str(url) return None def _build_headline_text(self) -> Text: text = Text() text.append("Used " if self.finished else "Using ") text.append(self._tool_name, style="blue") if self._argument: text.append(" (", style="grey50") arg_style = Style(color="grey50", link=self._full_url) if self._full_url else "grey50" text.append(self._argument, style=arg_style) text.append(")", style="grey50") return text def _render_todo_markdown(self, block: TodoDisplayBlock) -> str: lines: list[str] = [] for todo in block.items: normalized = todo.status.replace("_", " ").lower() match normalized: case "pending": lines.append(f"- {todo.title}") case "in progress": lines.append(f"- {todo.title} ←") case "done": lines.append(f"- ~~{todo.title}~~") case _: lines.append(f"- {todo.title}") return "\n".join(lines) class _ApprovalContentBlock(NamedTuple): """A pre-rendered content block for approval request with line count.""" text: str lines: int style: str = "" lexer: str = "" class _NotificationBlock: _SEVERITY_STYLE = { "info": "cyan", "success": "green", "warning": "yellow", "error": "red", } def __init__(self, notification: Notification): self.notification = notification def compose(self) -> RenderableType: style = self._SEVERITY_STYLE.get(self.notification.severity, "cyan") lines: list[RenderableType] = [Text(self.notification.title, style=f"bold {style}")] body = self.notification.body.strip() if body: body_lines = body.splitlines() preview = "\n".join(body_lines[:2]) if len(body_lines) > 2: preview += "\n..." lines.append(Text(preview, style="grey50")) return BulletColumns(Group(*lines), bullet_style=style) class _ApprovalRequestPanel: def __init__(self, request: ApprovalRequest): self.request = request self.options: list[tuple[str, ApprovalResponse.Kind]] = [ ("Approve once", "approve"), ("Approve for this session", "approve_for_session"), ("Reject, tell Kimi what to do instead", "reject"), ] self.selected_index = 0 # Pre-render all content blocks with line counts self._content_blocks: list[_ApprovalContentBlock] = [] last_diff_path: str | None = None # Handle description (only if no display blocks) if request.description and not request.display: text = request.description.rstrip("\n") self._content_blocks.append( _ApprovalContentBlock(text=text, lines=text.count("\n") + 1) ) # Handle display blocks for block in request.display: if isinstance(block, DiffDisplayBlock): # File path or ellipsis if block.path != last_diff_path: self._content_blocks.append( _ApprovalContentBlock(text=block.path, lines=1, style="bold") ) last_diff_path = block.path else: self._content_blocks.append( _ApprovalContentBlock(text="⋮", lines=1, style="dim") ) # Diff content diff_text = format_unified_diff( block.old_text, block.new_text, block.path, include_file_header=False, ).rstrip("\n") self._content_blocks.append( _ApprovalContentBlock( text=diff_text, lines=diff_text.count("\n") + 1, lexer="diff" ) ) elif isinstance(block, ShellDisplayBlock): text = block.command.rstrip("\n") self._content_blocks.append( _ApprovalContentBlock( text=text, lines=text.count("\n") + 1, lexer=block.language ) ) last_diff_path = None elif isinstance(block, BriefDisplayBlock) and block.text: text = block.text.rstrip("\n") self._content_blocks.append( _ApprovalContentBlock(text=text, lines=text.count("\n") + 1, style="grey50") ) last_diff_path = None self._total_lines = sum(b.lines for b in self._content_blocks) self.has_expandable_content = self._total_lines > MAX_PREVIEW_LINES def render(self) -> RenderableType: """Render the approval menu as a bordered panel.""" content_lines: list[RenderableType] = [ Text.from_markup( "[yellow]" f"{escape(self.request.sender)} is requesting approval to " f"{escape(self.request.action)}:[/yellow]" ) ] content_lines.append(Text("")) # Render content with line budget remaining = MAX_PREVIEW_LINES for block in self._content_blocks: if remaining <= 0: break content_lines.append(self._render_block(block, remaining)) remaining -= min(block.lines, remaining) if self.has_expandable_content: content_lines.append(Text("... (truncated, ctrl-e to expand)", style="dim italic")) lines: list[RenderableType] = [] if content_lines: lines.append(Padding(Group(*content_lines), (0, 0, 0, 1))) # Add menu options with number key labels if lines: lines.append(Text("")) for i, (option_text, _) in enumerate(self.options): num = i + 1 if i == self.selected_index: lines.append(Text(f"\u2192 [{num}] {option_text}", style="cyan")) else: lines.append(Text(f" [{num}] {option_text}", style="grey50")) # Keyboard hints lines.append(Text("")) hint = " \u25b2/\u25bc select 1/2/3 choose \u21b5 confirm" if self.has_expandable_content: hint += " ctrl-e expand" lines.append(Text(hint, style="dim")) return Panel( Group(*lines), border_style="bold yellow", title="[bold yellow]\u26a0 ACTION REQUIRED[/bold yellow]", title_align="left", padding=(0, 1), ) def _render_block( self, block: _ApprovalContentBlock, max_lines: int | None = None ) -> RenderableType: """Render a content block, optionally truncated.""" text = block.text if max_lines is not None and block.lines > max_lines: # Truncate to max_lines text = "\n".join(text.split("\n")[:max_lines]) if block.lexer: return KimiSyntax(text, block.lexer) return Text(text, style=block.style) def render_full(self) -> list[RenderableType]: """Render full content for pager (no truncation).""" return [self._render_block(block) for block in self._content_blocks] def move_up(self): """Move selection up.""" self.selected_index = (self.selected_index - 1) % len(self.options) def move_down(self): """Move selection down.""" self.selected_index = (self.selected_index + 1) % len(self.options) def get_selected_response(self) -> ApprovalResponse.Kind: """Get the approval response based on selected option.""" return self.options[self.selected_index][1] def _show_approval_in_pager(panel: _ApprovalRequestPanel) -> None: """Show the full approval request content in a pager.""" with console.screen(), console.pager(styles=True): # Header: matches the style in _ApprovalRequestPanel.render() console.print( Text.from_markup( "[yellow]⚠ " f"{escape(panel.request.sender)} is requesting approval to " f"{escape(panel.request.action)}:[/yellow]" ) ) console.print() # Render full content (no truncation) for renderable in panel.render_full(): console.print(renderable) OTHER_OPTION_LABEL = "Other" class _QuestionRequestPanel: """Renders structured questions for the user to answer interactively.""" def __init__(self, request: QuestionRequest): self.request = request self._current_question_index = 0 self._answers: dict[str, str] = {} self._saved_selections: dict[int, tuple[int, set[int]]] = {} self._selected_index = 0 self._multi_selected: set[int] = set() self._body_text: str = "" self.has_expandable_content: bool = False self._setup_current_question() def _setup_current_question(self) -> None: q = self._current_question self._options = [(o.label, o.description) for o in q.options] other_label = q.other_label or OTHER_OPTION_LABEL other_desc = q.other_description or "" self._options.append((other_label, other_desc)) idx = self._current_question_index if idx in self._saved_selections: saved_idx, saved_multi = self._saved_selections[idx] self._selected_index = min(saved_idx, len(self._options) - 1) self._multi_selected = saved_multi elif q.question in self._answers: answer = self._answers[q.question] if q.multi_select: answer_labels = [a.strip() for a in answer.split(", ")] known_labels = {label for label, _ in self._options[:-1]} self._multi_selected = set() for i, (label, _) in enumerate(self._options[:-1]): if label in answer_labels: self._multi_selected.add(i) # Unmatched labels = Other text if any(answer_label not in known_labels for answer_label in answer_labels): self._multi_selected.add(len(self._options) - 1) self._selected_index = min(self._multi_selected) if self._multi_selected else 0 else: for i, (label, _) in enumerate(self._options): if label == answer: self._selected_index = i break else: # Unknown submitted label should map to the synthetic "Other" option. self._selected_index = len(self._options) - 1 self._multi_selected = set() else: self._selected_index = 0 self._multi_selected = set() self._recompute_body() def _recompute_body(self) -> None: """Recompute body content state for the current question.""" body = self._current_question.body self._body_text = body.rstrip("\n") if body else "" self.has_expandable_content = bool(self._body_text) @property def _current_question(self): return self.request.questions[self._current_question_index] @property def is_other_selected(self) -> bool: return self._selected_index == len(self._options) - 1 @property def is_multi_select(self) -> bool: return self._current_question.multi_select @property def current_question_text(self) -> str: return self._current_question.question def should_prompt_other_input(self) -> bool: """Whether pressing ENTER should open free-text input for the current question.""" if not self.is_multi_select: return self.is_other_selected other_idx = len(self._options) - 1 return other_idx in self._multi_selected def select_index(self, index: int) -> bool: """Select an option by index. Returns False when index is out of range.""" if not (0 <= index < len(self._options)): return False self._selected_index = index return True def render(self) -> RenderableType: q = self._current_question lines: list[RenderableType] = [] # Tab bar for multi-question navigation if len(self.request.questions) > 1: tab_parts: list[str] = [] for i, qi in enumerate(self.request.questions): label = escape(qi.header or f"Q{i + 1}") if i == self._current_question_index: icon, style = "\u25cf", "bold cyan" elif qi.question in self._answers: icon, style = "\u2713", "green" else: icon, style = "\u25cb", "grey50" tab_parts.append(f"[{style}]({icon}) {label}[/{style}]") lines.append(Text.from_markup(" ".join(tab_parts))) lines.append(Text("")) # Question text (header is now shown in the tab bar) lines.append(Text.from_markup(f"[yellow]? {escape(q.question)}[/yellow]")) if q.multi_select: lines.append(Text(" (SPACE to toggle, ENTER to submit)", style="dim italic")) lines.append(Text("")) # Body hint: prompt user to view full content if self._body_text: lines.append( Text.from_markup( "[bold cyan] \u25b6 Press ctrl-e to view full content[/bold cyan]" ) ) lines.append(Text("")) # Options with number key labels for i, (label, description) in enumerate(self._options): num = i + 1 if q.multi_select: checked = "\u2713" if i in self._multi_selected else " " prefix = f"\\[{checked}]" if i == self._selected_index: option_line = Text.from_markup(f"[cyan]{prefix} {escape(label)}[/cyan]") else: option_line = Text.from_markup(f"[grey50]{prefix} {escape(label)}[/grey50]") else: if i == self._selected_index: option_line = Text.from_markup(f"[cyan]\u2192 \\[{num}] {escape(label)}[/cyan]") else: option_line = Text.from_markup(f"[grey50] \\[{num}] {escape(label)}[/grey50]") lines.append(option_line) if description: lines.append(Text(f" {description}", style="dim")) # Keyboard hints if len(self.request.questions) > 1: lines.append(Text("")) lines.append( Text( " \u25c4/\u25ba switch question " "\u25b2/\u25bc select \u21b5 submit esc exit", style="dim", ) ) return Panel( Group(*lines), border_style="bold cyan", title="[bold cyan]? QUESTION[/bold cyan]", title_align="left", padding=(0, 1), ) def go_to(self, index: int) -> None: """Jump to a specific question by index, saving current UI state first.""" if index == self._current_question_index: return if not (0 <= index < len(self.request.questions)): return # Save current cursor state (not as an answer — only submit() writes answers) self._saved_selections[self._current_question_index] = ( self._selected_index, set(self._multi_selected), ) self._current_question_index = index self._setup_current_question() def next_tab(self) -> None: """Switch to the next question tab (no wrap).""" if self._current_question_index < len(self.request.questions) - 1: self.go_to(self._current_question_index + 1) def prev_tab(self) -> None: """Switch to the previous question tab (no wrap).""" if self._current_question_index > 0: self.go_to(self._current_question_index - 1) def move_up(self) -> None: self._selected_index = (self._selected_index - 1) % len(self._options) def move_down(self) -> None: self._selected_index = (self._selected_index + 1) % len(self._options) def toggle_select(self) -> None: """Toggle selection for multi-select mode.""" if not self.is_multi_select: return if self._selected_index in self._multi_selected: self._multi_selected.discard(self._selected_index) else: self._multi_selected.add(self._selected_index) def submit(self) -> bool: """Submit the current answer and advance. Returns True if all questions are answered.""" q = self._current_question if q.multi_select: # Check if "Other" is among the selected other_idx = len(self._options) - 1 if other_idx in self._multi_selected: return False # caller should handle Other input selected_labels = [ self._options[i][0] for i in sorted(self._multi_selected) if i < len(q.options) ] if not selected_labels: return False # don't allow empty multi-select submission self._answers[q.question] = ", ".join(selected_labels) else: if self.is_other_selected: return False # caller should handle Other input self._answers[q.question] = self._options[self._selected_index][0] # Clear stale draft so returning to this question uses the submitted answer self._saved_selections.pop(self._current_question_index, None) return self._advance() def submit_other(self, text: str) -> bool: """Submit 'Other' text for the current question. Returns True if all done.""" q = self._current_question if q.multi_select: # Include both selected options and the custom text other_idx = len(self._options) - 1 selected_labels = [ self._options[i][0] for i in sorted(self._multi_selected) if i < len(q.options) and i != other_idx ] if text: selected_labels.append(text) self._answers[q.question] = ", ".join(selected_labels) if selected_labels else text else: self._answers[q.question] = text # Clear stale draft so returning to this question uses the submitted answer self._saved_selections.pop(self._current_question_index, None) return self._advance() def _advance(self) -> bool: """Move to the next unanswered question. Returns True if all questions are done.""" total = len(self.request.questions) # Check if all questions have been answered if len(self._answers) >= total: return True # Find the next unanswered question (starting from current + 1, wrapping) for offset in range(1, total + 1): idx = (self._current_question_index + offset) % total if self.request.questions[idx].question not in self._answers: self._current_question_index = idx self._setup_current_question() return False return True def get_answers(self) -> dict[str, str]: return self._answers def render_full_body(self) -> list[RenderableType]: """Render full body content for pager display (no truncation).""" if not self._body_text: return [] return [Markdown(self._body_text)] def _show_question_body_in_pager(panel: _QuestionRequestPanel) -> None: """Show the full question body content in a pager.""" with console.screen(), console.pager(styles=True): console.print(Text.from_markup(f"[yellow]? {escape(panel.current_question_text)}[/yellow]")) console.print() for renderable in panel.render_full_body(): console.print(renderable) async def _prompt_other_input(question_text: str) -> str: """Prompt the user for free-text input when 'Other' is selected.""" from prompt_toolkit import PromptSession console.print(Text.from_markup(f"\n[yellow]? {escape(question_text)}[/yellow]")) console.print(Text(" Enter your answer:", style="dim")) try: session: PromptSession[str] = PromptSession() return (await session.prompt_async(" > ")).strip() except (EOFError, KeyboardInterrupt): return "" class _StatusBlock: def __init__(self, initial: StatusUpdate) -> None: self.text = Text("", justify="right") self._context_usage: float = 0.0 self._context_tokens: int = 0 self._max_context_tokens: int = 0 self.update(initial) def render(self) -> RenderableType: return self.text def update(self, status: StatusUpdate) -> None: if status.context_usage is not None: self._context_usage = status.context_usage if status.context_tokens is not None: self._context_tokens = status.context_tokens if status.max_context_tokens is not None: self._max_context_tokens = status.max_context_tokens if status.context_usage is not None: self.text.plain = format_context_status( self._context_usage, self._context_tokens, self._max_context_tokens, ) def _render_renderable_to_ansi(renderable: RenderableType, *, columns: int) -> str: width = max(20, columns) buf = StringIO() render_console = RichConsole( file=buf, force_terminal=True, color_system="truecolor", width=width, theme=NEUTRAL_MARKDOWN_THEME, highlight=False, ) render_console.print(renderable, end="") return buf.getvalue() @asynccontextmanager async def _keyboard_listener( handler: Callable[[KeyboardListener, KeyEvent], Awaitable[None]], ): listener = KeyboardListener() await listener.start() async def _keyboard(): while True: event = await listener.get() await handler(listener, event) task = asyncio.create_task(_keyboard()) try: yield finally: task.cancel() with suppress(asyncio.CancelledError): await task await listener.stop() class _LiveView: def __init__(self, initial_status: StatusUpdate, cancel_event: asyncio.Event | None = None): self._cancel_event = cancel_event self._mooning_spinner: Spinner | None = None self._compacting_spinner: Spinner | None = None self._mcp_loading_spinner: Spinner | None = None self._current_content_block: _ContentBlock | None = None self._tool_call_blocks: dict[str, _ToolCallBlock] = {} self._last_tool_call_block: _ToolCallBlock | None = None self._approval_request_queue = deque[ApprovalRequest]() """ It is possible that multiple subagents request approvals at the same time, in which case we will have to queue them up and show them one by one. """ self._current_approval_request_panel: _ApprovalRequestPanel | None = None self._reject_all_following = False self._question_request_queue = deque[QuestionRequest]() self._current_question_panel: _QuestionRequestPanel | None = None self._notification_blocks = deque[_NotificationBlock]() self._live_notification_blocks = deque[_NotificationBlock](maxlen=MAX_LIVE_NOTIFICATIONS) self._status_block = _StatusBlock(initial_status) self._need_recompose = False def _reset_live_shape(self, live: Live) -> None: # Rich doesn't expose a public API to clear Live's cached render height. # After leaving the pager, stale height causes cursor restores to jump, # so we reset the private _shape to re-anchor the next refresh. live._live_render._shape = None # type: ignore[reportPrivateUsage] async def visualize_loop(self, wire: WireUISide): with Live( self.compose(), console=console, refresh_per_second=10, transient=True, vertical_overflow="visible", ) as live: async def keyboard_handler(listener: KeyboardListener, event: KeyEvent) -> None: # Handle Ctrl+E specially - pause Live while the pager is active if event == KeyEvent.CTRL_E: if self.has_expandable_panel(): await listener.pause() live.stop() try: self._show_expandable_panel_content() finally: # Reset live render shape so the next refresh re-anchors cleanly. self._reset_live_shape(live) live.start() live.update(self.compose(), refresh=True) await listener.resume() return # Handle ENTER/SPACE on question panel when "Other" is selected if self._should_prompt_question_other_for_key(event): panel = self._current_question_panel assert panel is not None question_text = panel.current_question_text await listener.pause() live.stop() try: text = await _prompt_other_input(question_text) finally: self._reset_live_shape(live) live.start() await listener.resume() self._submit_question_other_text(text) live.update(self.compose(), refresh=True) return self.dispatch_keyboard_event(event) if self._need_recompose: live.update(self.compose(), refresh=True) self._need_recompose = False async with _keyboard_listener(keyboard_handler): while True: try: msg = await wire.receive() except QueueShutDown: self.cleanup(is_interrupt=False) live.update(self.compose(), refresh=True) break if isinstance(msg, StepInterrupted): self.cleanup(is_interrupt=True) live.update(self.compose(), refresh=True) break self.dispatch_wire_message(msg) if self._need_recompose: live.update(self.compose(), refresh=True) self._need_recompose = False def refresh_soon(self) -> None: self._need_recompose = True def has_expandable_panel(self) -> bool: return ( self._expandable_approval_panel() is not None or self._expandable_question_panel() is not None ) def _expandable_approval_panel(self) -> _ApprovalRequestPanel | None: panel = self._current_approval_request_panel if panel is not None and panel.has_expandable_content: return panel return None def _expandable_question_panel(self) -> _QuestionRequestPanel | None: panel = self._current_question_panel if panel is not None and panel.has_expandable_content: return panel return None def _show_expandable_panel_content(self) -> bool: if approval_panel := self._expandable_approval_panel(): _show_approval_in_pager(approval_panel) return True if question_panel := self._expandable_question_panel(): _show_question_body_in_pager(question_panel) return True return False def _should_prompt_question_other_for_key(self, key: KeyEvent) -> bool: panel = self._current_question_panel if panel is None or not panel.should_prompt_other_input(): return False return key == KeyEvent.ENTER or (key == KeyEvent.SPACE and not panel.is_multi_select) def _submit_question_other_text(self, text: str) -> None: panel = self._current_question_panel if panel is None: return all_done = panel.submit_other(text) if all_done: panel.request.resolve(panel.get_answers()) self.show_next_question_request() self.refresh_soon() def compose(self, *, include_status: bool = True) -> RenderableType: """Compose the live view display content.""" blocks: list[RenderableType] = [] if self._mcp_loading_spinner is not None: blocks.append(self._mcp_loading_spinner) elif self._mooning_spinner is not None: blocks.append(self._mooning_spinner) elif self._compacting_spinner is not None: blocks.append(self._compacting_spinner) else: if self._current_content_block is not None: blocks.append(self._current_content_block.compose()) for tool_call in self._tool_call_blocks.values(): blocks.append(tool_call.compose()) if self._current_approval_request_panel: blocks.append(self._current_approval_request_panel.render()) if self._current_question_panel: blocks.append(self._current_question_panel.render()) for notification in self._live_notification_blocks: blocks.append(notification.compose()) if include_status: blocks.append(self._status_block.render()) return Group(*blocks) def dispatch_wire_message(self, msg: WireMessage) -> None: """Dispatch the Wire message to UI components.""" assert not isinstance(msg, StepInterrupted) # handled in visualize_loop if isinstance(msg, StepBegin): self.cleanup(is_interrupt=False) self._mcp_loading_spinner = None self._mooning_spinner = Spinner("moon", "") self.refresh_soon() return if self._mooning_spinner is not None: # any message other than StepBegin should end the mooning state self._mooning_spinner = None self.refresh_soon() match msg: case TurnBegin(): self.flush_content() case SteerInput(user_input=user_input): self.cleanup(is_interrupt=False) content: list[ContentPart] if isinstance(user_input, list): content = list(user_input) else: content = [TextPart(text=user_input)] console.print(render_user_echo(Message(role="user", content=content))) case TurnEnd(): pass case CompactionBegin(): self._compacting_spinner = Spinner("balloon", "Compacting...") self.refresh_soon() case CompactionEnd(): self._compacting_spinner = None self.refresh_soon() case MCPLoadingBegin(): self._mcp_loading_spinner = Spinner("dots", "Connecting to MCP servers...") self.refresh_soon() case MCPLoadingEnd(): self._mcp_loading_spinner = None self.refresh_soon() case StatusUpdate(): self._status_block.update(msg) case Notification(): self.append_notification(msg) case ContentPart(): self.append_content(msg) case ToolCall(): self.append_tool_call(msg) case ToolCallPart(): self.append_tool_call_part(msg) case ToolResult(): self.append_tool_result(msg) case ApprovalResponse(): # we don't need to handle this because the request is resolved on UI pass case SubagentEvent(): self.handle_subagent_event(msg) case ApprovalRequest(): self.request_approval(msg) case QuestionRequest(): self.request_question(msg) case ToolCallRequest(): logger.warning("Unexpected ToolCallRequest in shell UI: {msg}", msg=msg) def _try_submit_question(self) -> None: """Submit the current question answer; if all done, resolve and advance.""" panel = self._current_question_panel if panel is None: return all_done = panel.submit() if all_done: panel.request.resolve(panel.get_answers()) self.show_next_question_request() def dispatch_keyboard_event(self, event: KeyEvent) -> None: # Handle question panel keyboard events if self._current_question_panel is not None: match event: case KeyEvent.UP: self._current_question_panel.move_up() case KeyEvent.DOWN: self._current_question_panel.move_down() case KeyEvent.LEFT: self._current_question_panel.prev_tab() case KeyEvent.RIGHT | KeyEvent.TAB: self._current_question_panel.next_tab() case KeyEvent.SPACE: if self._current_question_panel.is_multi_select: self._current_question_panel.toggle_select() else: self._try_submit_question() case KeyEvent.ENTER: # "Other" is handled in keyboard_handler (async context) self._try_submit_question() case KeyEvent.ESCAPE: self._current_question_panel.request.resolve({}) self.show_next_question_request() case ( KeyEvent.NUM_1 | KeyEvent.NUM_2 | KeyEvent.NUM_3 | KeyEvent.NUM_4 | KeyEvent.NUM_5 ): # Number keys select option in question panel num_map = { KeyEvent.NUM_1: 0, KeyEvent.NUM_2: 1, KeyEvent.NUM_3: 2, KeyEvent.NUM_4: 3, KeyEvent.NUM_5: 4, } idx = num_map[event] panel = self._current_question_panel if panel.select_index(idx): if panel.is_multi_select: panel.toggle_select() elif not panel.is_other_selected: # Auto-submit for single-select (unless "Other") self._try_submit_question() case _: pass self.refresh_soon() return # handle ESC key to cancel the run if event == KeyEvent.ESCAPE and self._cancel_event is not None: self._cancel_event.set() return # Handle approval panel keyboard events if self._current_approval_request_panel is not None: match event: case KeyEvent.UP: self._current_approval_request_panel.move_up() self.refresh_soon() case KeyEvent.DOWN: self._current_approval_request_panel.move_down() self.refresh_soon() case KeyEvent.ENTER: self._submit_approval() case KeyEvent.NUM_1 | KeyEvent.NUM_2 | KeyEvent.NUM_3: # Number keys directly select and submit approval option num_map = { KeyEvent.NUM_1: 0, KeyEvent.NUM_2: 1, KeyEvent.NUM_3: 2, } idx = num_map[event] if idx < len(self._current_approval_request_panel.options): self._current_approval_request_panel.selected_index = idx self._submit_approval() case _: pass return def _submit_approval(self) -> None: """Submit the currently selected approval response.""" assert self._current_approval_request_panel is not None resp = self._current_approval_request_panel.get_selected_response() self._current_approval_request_panel.request.resolve(resp) if resp == "approve_for_session": to_remove_from_queue: list[ApprovalRequest] = [] for request in self._approval_request_queue: # approve all queued requests with the same action if request.action == self._current_approval_request_panel.request.action: request.resolve("approve_for_session") to_remove_from_queue.append(request) for request in to_remove_from_queue: self._approval_request_queue.remove(request) elif resp == "reject": # one rejection should stop the step immediately while self._approval_request_queue: self._approval_request_queue.popleft().resolve("reject") self._reject_all_following = True self.show_next_approval_request() def cleanup(self, is_interrupt: bool) -> None: """Cleanup the live view on step end or interruption.""" self.flush_content() for block in self._tool_call_blocks.values(): if not block.finished: # this should not happen, but just in case block.finish( ToolError(message="", brief="Interrupted") if is_interrupt else ToolOk(output="") ) self._last_tool_call_block = None self.flush_finished_tool_calls() self.flush_notifications() while self._approval_request_queue: # should not happen, but just in case self._approval_request_queue.popleft().resolve("reject") self._current_approval_request_panel = None self._reject_all_following = False while self._question_request_queue: self._question_request_queue.popleft().resolve({}) self._current_question_panel = None def flush_content(self) -> None: """Flush the current content block.""" if self._current_content_block is not None: console.print(self._current_content_block.compose_final()) self._current_content_block = None self.refresh_soon() def flush_finished_tool_calls(self) -> None: """Flush all leading finished tool call blocks.""" tool_call_ids = list(self._tool_call_blocks.keys()) for tool_call_id in tool_call_ids: block = self._tool_call_blocks[tool_call_id] if not block.finished: break self._tool_call_blocks.pop(tool_call_id) console.print(block.compose()) if self._last_tool_call_block == block: self._last_tool_call_block = None self.refresh_soon() def flush_notifications(self) -> None: """Flush rendered notifications to terminal history.""" self._live_notification_blocks.clear() while self._notification_blocks: console.print(self._notification_blocks.popleft().compose()) self.refresh_soon() def append_content(self, part: ContentPart) -> None: match part: case ThinkPart(think=text) | TextPart(text=text): if not text: return is_think = isinstance(part, ThinkPart) if self._current_content_block is None: self._current_content_block = _ContentBlock(is_think) self.refresh_soon() elif self._current_content_block.is_think != is_think: self.flush_content() self._current_content_block = _ContentBlock(is_think) self.refresh_soon() self._current_content_block.append(text) case _: # TODO: support more content part types pass def append_tool_call(self, tool_call: ToolCall) -> None: self.flush_content() self._tool_call_blocks[tool_call.id] = _ToolCallBlock(tool_call) self._last_tool_call_block = self._tool_call_blocks[tool_call.id] self.refresh_soon() def append_tool_call_part(self, part: ToolCallPart) -> None: if not part.arguments_part: return if self._last_tool_call_block is None: return self._last_tool_call_block.append_args_part(part.arguments_part) self.refresh_soon() def append_tool_result(self, result: ToolResult) -> None: if block := self._tool_call_blocks.get(result.tool_call_id): block.finish(result.return_value) self.flush_finished_tool_calls() self.refresh_soon() def append_notification(self, notification: Notification) -> None: block = _NotificationBlock(notification) self._notification_blocks.append(block) self._live_notification_blocks.append(block) self.refresh_soon() def request_approval(self, request: ApprovalRequest) -> None: # If we're rejecting all following requests, reject immediately if self._reject_all_following: request.resolve("reject") return self._approval_request_queue.append(request) if self._current_approval_request_panel is None: console.bell() self.show_next_approval_request() def show_next_approval_request(self) -> None: """ Show the next approval request from the queue. If there are no pending requests, clear the current approval panel. """ if not self._approval_request_queue: if self._current_approval_request_panel is not None: self._current_approval_request_panel = None self.refresh_soon() return while self._approval_request_queue: request = self._approval_request_queue.popleft() if request.resolved: # skip resolved requests continue self._current_approval_request_panel = _ApprovalRequestPanel(request) self.refresh_soon() break else: # All queued requests were already resolved if self._current_approval_request_panel is not None: self._current_approval_request_panel = None self.refresh_soon() def request_question(self, request: QuestionRequest) -> None: self._question_request_queue.append(request) if self._current_question_panel is None: console.bell() self.show_next_question_request() def show_next_question_request(self) -> None: """Show the next question request from the queue.""" if not self._question_request_queue: if self._current_question_panel is not None: self._current_question_panel = None self.refresh_soon() return while self._question_request_queue: request = self._question_request_queue.popleft() if request.resolved: continue self._current_question_panel = _QuestionRequestPanel(request) self.refresh_soon() break else: # All queued requests were already resolved if self._current_question_panel is not None: self._current_question_panel = None self.refresh_soon() def handle_subagent_event(self, event: SubagentEvent) -> None: block = self._tool_call_blocks.get(event.task_tool_call_id) if block is None: return match event.event: case ToolCall() as tool_call: block.append_sub_tool_call(tool_call) case ToolCallPart() as tool_call_part: block.append_sub_tool_call_part(tool_call_part) case ToolResult() as tool_result: block.finish_sub_tool_call(tool_result) self.refresh_soon() case _: # ignore other events for now # TODO: may need to handle multi-level nested subagents pass class _PromptLiveView(_LiveView): _KEY_MAP: dict[str, KeyEvent] = { "up": KeyEvent.UP, "down": KeyEvent.DOWN, "left": KeyEvent.LEFT, "right": KeyEvent.RIGHT, "tab": KeyEvent.TAB, "enter": KeyEvent.ENTER, "space": KeyEvent.SPACE, "escape": KeyEvent.ESCAPE, "1": KeyEvent.NUM_1, "2": KeyEvent.NUM_2, "3": KeyEvent.NUM_3, "4": KeyEvent.NUM_4, "5": KeyEvent.NUM_5, } def __init__( self, initial_status: StatusUpdate, *, prompt_session: CustomPromptSession, steer: Callable[[str | list[ContentPart]], None], cancel_event: asyncio.Event | None = None, ) -> None: super().__init__(initial_status, cancel_event) self._prompt_session = prompt_session self._steer = steer self._awaiting_question_other_input = False self._pending_local_steers: deque[str | list[ContentPart]] = deque() self._turn_ended = False async def visualize_loop(self, wire: WireUISide): try: while True: try: msg = await wire.receive() except QueueShutDown: self.cleanup(is_interrupt=False) self._flush_prompt_refresh() break if isinstance(msg, StepInterrupted): self.cleanup(is_interrupt=True) self._flush_prompt_refresh() break if isinstance(msg, TurnEnd): self._turn_ended = True self.cleanup(is_interrupt=False) self._flush_prompt_refresh() break self.dispatch_wire_message(msg) self._flush_prompt_refresh() finally: self._awaiting_question_other_input = False self._pending_local_steers.clear() self._turn_ended = False self._prompt_session.invalidate() def handle_local_input(self, user_input: UserInput) -> None: if not user_input or self._turn_ended: return console.print(render_user_echo_text(user_input.command)) self._pending_local_steers.append(list(user_input.content)) self._steer(user_input.content) self._flush_prompt_refresh() def dispatch_wire_message(self, msg: WireMessage) -> None: if isinstance(msg, SteerInput) and self._pending_local_steers: pending = self._pending_local_steers[0] if pending == msg.user_input: self._pending_local_steers.popleft() return super().dispatch_wire_message(msg) def render_running_prompt_body(self, columns: int) -> ANSI: if self._turn_ended: return ANSI("") renderable = self.compose(include_status=False) body = _render_renderable_to_ansi(renderable, columns=columns).rstrip("\n") lines = [body] if body else [""] if self._awaiting_question_other_input: lines.append("\x1b[2mEnter the custom answer, then press Enter.\x1b[0m") return ANSI("\n".join(lines)) def running_prompt_placeholder(self) -> str | None: return None def should_handle_running_prompt_key(self, key: str) -> bool: if self._turn_ended: return False if key == "c-e": return self.has_expandable_panel() if self._awaiting_question_other_input: return key in {"enter", "escape"} if key == "escape": return self._cancel_event is not None or self._current_question_panel is not None if self._current_question_panel is not None: return key in { "up", "down", "left", "right", "tab", "space", "enter", "1", "2", "3", "4", "5", } if self._current_approval_request_panel is not None: return key in {"up", "down", "enter", "1", "2", "3"} return False def handle_running_prompt_key(self, key: str, event: KeyPressEvent) -> None: if key == "c-e": event.app.create_background_task(self._show_panel_in_pager()) return if self._awaiting_question_other_input: if key == "enter": self._submit_question_other_input(event.current_buffer) elif key == "escape": self._clear_buffer(event.current_buffer) self._awaiting_question_other_input = False self.refresh_soon() self._flush_prompt_refresh() return mapped = self._KEY_MAP.get(key) if mapped is not None and self._should_prompt_question_other_for_key(mapped): text = event.current_buffer.text.strip() if text: self._submit_question_other_input(event.current_buffer) else: self._clear_buffer(event.current_buffer) self._awaiting_question_other_input = True self.refresh_soon() self._flush_prompt_refresh() return if mapped is None: return if ( self._current_question_panel is not None or self._current_approval_request_panel is not None ): self._clear_buffer(event.current_buffer) self.dispatch_keyboard_event(mapped) self._flush_prompt_refresh() async def _show_panel_in_pager(self) -> None: await run_in_terminal(self._show_expandable_panel_content) self._prompt_session.invalidate() def _submit_question_other_input(self, buffer: Buffer) -> None: panel = self._current_question_panel if panel is None: self._clear_buffer(buffer) self._awaiting_question_other_input = False return text = buffer.text.strip() self._clear_buffer(buffer) self._awaiting_question_other_input = False self._submit_question_other_text(text) @staticmethod def _clear_buffer(buffer: Buffer) -> None: if buffer.text: buffer.document = Document(text="", cursor_position=0) def _flush_prompt_refresh(self) -> None: if self._need_recompose: self._prompt_session.invalidate() self._need_recompose = False def cleanup(self, is_interrupt: bool) -> None: self._awaiting_question_other_input = False super().cleanup(is_interrupt) ================================================ FILE: src/kimi_cli/utils/__init__.py ================================================ ================================================ FILE: src/kimi_cli/utils/aiohttp.py ================================================ from __future__ import annotations import ssl import aiohttp import certifi _ssl_context = ssl.create_default_context(cafile=certifi.where()) def new_client_session() -> aiohttp.ClientSession: return aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=_ssl_context)) ================================================ FILE: src/kimi_cli/utils/aioqueue.py ================================================ from __future__ import annotations import asyncio import sys if sys.version_info >= (3, 13): QueueShutDown = asyncio.QueueShutDown # type: ignore[assignment] class Queue[T](asyncio.Queue[T]): """Asyncio Queue with shutdown support.""" else: class QueueShutDown(Exception): """Raised when operating on a shut down queue.""" class _Shutdown: """Sentinel for queue shutdown.""" _SHUTDOWN = _Shutdown() class Queue[T](asyncio.Queue[T | _Shutdown]): """Asyncio Queue with shutdown support for Python < 3.13.""" def __init__(self) -> None: super().__init__() self._shutdown = False def shutdown(self, immediate: bool = False) -> None: if self._shutdown: return self._shutdown = True if immediate: self._queue.clear() getters = list(getattr(self, "_getters", [])) count = max(1, len(getters)) self._enqueue_shutdown(count) def _enqueue_shutdown(self, count: int) -> None: for _ in range(count): try: super().put_nowait(_SHUTDOWN) except asyncio.QueueFull: self._queue.clear() super().put_nowait(_SHUTDOWN) async def get(self) -> T: if self._shutdown and self.empty(): raise QueueShutDown item = await super().get() if isinstance(item, _Shutdown): raise QueueShutDown return item def get_nowait(self) -> T: if self._shutdown and self.empty(): raise QueueShutDown item = super().get_nowait() if isinstance(item, _Shutdown): raise QueueShutDown return item async def put(self, item: T) -> None: if self._shutdown: raise QueueShutDown await super().put(item) def put_nowait(self, item: T) -> None: if self._shutdown: raise QueueShutDown super().put_nowait(item) ================================================ FILE: src/kimi_cli/utils/broadcast.py ================================================ import asyncio from kimi_cli.utils.aioqueue import Queue class BroadcastQueue[T]: """ A broadcast queue that allows multiple subscribers to receive published items. """ def __init__(self) -> None: self._queues: set[Queue[T]] = set() def subscribe(self) -> Queue[T]: """Create a new subscription queue.""" queue: Queue[T] = Queue() self._queues.add(queue) return queue def unsubscribe(self, queue: Queue[T]) -> None: """Remove a subscription queue.""" self._queues.discard(queue) async def publish(self, item: T) -> None: """Publish an item to all subscription queues.""" await asyncio.gather(*(queue.put(item) for queue in self._queues)) def publish_nowait(self, item: T) -> None: """Publish an item to all subscription queues without waiting.""" for queue in self._queues: queue.put_nowait(item) def shutdown(self, immediate: bool = False) -> None: """Close all subscription queues.""" for queue in self._queues: queue.shutdown(immediate=immediate) self._queues.clear() ================================================ FILE: src/kimi_cli/utils/changelog.py ================================================ from __future__ import annotations from pathlib import Path from typing import NamedTuple class ReleaseEntry(NamedTuple): description: str entries: list[str] def parse_changelog(md_text: str) -> dict[str, ReleaseEntry]: """Parse a subset of Keep a Changelog-style markdown into a map: version -> (description, entries) Parsing rules: - Versions are denoted by level-2 headings starting with '## [' Example: `## [v0.10.1] - 2025-09-18` or `## [Unreleased]` - For each version section, description is the first contiguous block of non-empty lines that do not start with '-' or '#'. - Entries are all markdown list items starting with '- ' under that version (across any subheadings like '### Added'). """ lines = md_text.splitlines() result: dict[str, ReleaseEntry] = {} current_ver: str | None = None collecting_desc = False desc_lines: list[str] = [] bullet_lines: list[str] = [] seen_content_after_header = False def commit(): nonlocal current_ver, desc_lines, bullet_lines, result if current_ver is None: return description = "\n".join([line.strip() for line in desc_lines]).strip() # Deduplicate and normalize entries norm_entries = [ line.strip()[2:].strip() for line in bullet_lines if line.strip().startswith("- ") ] result[current_ver] = ReleaseEntry(description=description, entries=norm_entries) for raw in lines: line = raw.rstrip() # Format: `## 0.75 (2026-01-09)` or `## Unreleased` if line.startswith("## "): commit() ver = line[3:].strip() # Remove trailing date in parentheses if present if "(" in ver: ver = ver[: ver.find("(")].strip() current_ver = ver desc_lines = [] bullet_lines = [] collecting_desc = True seen_content_after_header = False continue if current_ver is None: # Skip until first version section continue if not line.strip(): # blank line ends initial description block only after we've seen content if collecting_desc and seen_content_after_header: collecting_desc = False continue seen_content_after_header = True if line.lstrip().startswith("### "): collecting_desc = False continue if line.lstrip().startswith("- "): collecting_desc = False bullet_lines.append(line.strip()) continue if collecting_desc: # Accumulate description until a blank line or bullets/subheadings desc_lines.append(line.strip()) # else: ignore any other free-form text after description block # Final flush commit() return result def format_release_notes(changelog: dict[str, ReleaseEntry], include_lib_changes: bool) -> str: parts: list[str] = [] for ver, entry in changelog.items(): s = f"[bold]{ver}[/bold]" if entry.description: s += f": {entry.description}" if entry.entries: for it in entry.entries: if it.lower().startswith("lib:") and not include_lib_changes: continue s += "\n[markdown.item.bullet]• [/]" + it parts.append(s + "\n") return "\n".join(parts).strip() CHANGELOG = parse_changelog( (Path(__file__).parent.parent / "CHANGELOG.md").read_text(encoding="utf-8") ) ================================================ FILE: src/kimi_cli/utils/clipboard.py ================================================ from __future__ import annotations import importlib import os import sys from collections.abc import Iterable from dataclasses import dataclass from pathlib import Path from typing import Any, cast import pyperclip from PIL import Image, ImageGrab # Video file extensions recognized for clipboard paste. _VIDEO_SUFFIXES: frozenset[str] = frozenset( {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".webm", ".m4v", ".flv", ".3gp", ".3g2"} ) @dataclass(frozen=True, slots=True) class ClipboardResult: """Result of reading media from the clipboard. Both fields may be non-empty when the clipboard contains a mix of image files and non-image files (videos, PDFs, etc.). """ images: tuple[Image.Image, ...] file_paths: tuple[Path, ...] def is_clipboard_available() -> bool: """Check if the Pyperclip clipboard is available.""" try: pyperclip.paste() return True except Exception: return False def grab_media_from_clipboard() -> ClipboardResult | None: """Read media from the clipboard. Inspects the clipboard once and returns all detected media. Image files are returned as loaded PIL images; non-image files (videos, PDFs, etc.) are returned as file paths. On macOS the native pasteboard API is tried first to avoid misidentifying a file's thumbnail as clipboard image data. """ # 1. Try macOS native API for file paths (most reliable for Finder copies). if sys.platform == "darwin": file_paths = _read_clipboard_file_paths_macos_native() images, non_image_paths = _classify_file_paths(file_paths) if images or non_image_paths: return ClipboardResult( images=tuple(images), file_paths=tuple(non_image_paths), ) # 2. Try PIL ImageGrab as fallback. # - On macOS this uses AppleScript «class furl» for file paths, # or reads raw image data (TIFF/PNG) from the pasteboard. # - On other platforms this is the primary clipboard access method. payload = ImageGrab.grabclipboard() if payload is None: return None if isinstance(payload, Image.Image): # Raw image data (screenshot or thumbnail). # If we reach here, the macOS native path lookup did not find any # file paths, so this is safe to treat as a real image. return ClipboardResult(images=(payload,), file_paths=()) # payload is a list of file path strings. images, non_image_paths = _classify_file_paths(payload) if images or non_image_paths: return ClipboardResult( images=tuple(images), file_paths=tuple(non_image_paths), ) return None def _classify_file_paths( paths: Iterable[os.PathLike[str] | str], ) -> tuple[list[Image.Image], list[Path]]: """Classify clipboard file paths into images and non-image files. Returns ``(images, non_image_paths)`` where *images* contains loaded PIL images and *non_image_paths* contains paths to videos, documents, and other non-image files. """ resolved: list[Path] = [] for item in paths: try: path = Path(item) except (TypeError, ValueError): continue if not path.is_file(): continue resolved.append(path) images: list[Image.Image] = [] non_image_paths: list[Path] = [] for path in resolved: # Video files are never opened as images. if path.suffix.lower() in _VIDEO_SUFFIXES: non_image_paths.append(path) continue try: with Image.open(path) as img: img.load() images.append(img.copy()) except Exception: non_image_paths.append(path) return images, non_image_paths def _read_clipboard_file_paths_macos_native() -> list[Path]: try: appkit = cast(Any, importlib.import_module("AppKit")) foundation = cast(Any, importlib.import_module("Foundation")) except Exception: return [] NSPasteboard = appkit.NSPasteboard NSURL = foundation.NSURL options_key = getattr( appkit, "NSPasteboardURLReadingFileURLsOnlyKey", "NSPasteboardURLReadingFileURLsOnlyKey", ) pb = NSPasteboard.generalPasteboard() options = {options_key: True} try: urls: list[Any] | None = pb.readObjectsForClasses_options_([NSURL], options) except Exception: urls = None paths: list[Path] = [] if urls: for url in urls: try: path = url.path() except Exception: continue if path: paths.append(Path(str(path))) if paths: return paths try: file_list = cast(list[str] | str | None, pb.propertyListForType_("NSFilenamesPboardType")) except Exception: return [] if not file_list: return [] file_items: list[str] = [] if isinstance(file_list, list): file_items.extend(item for item in file_list if item) else: file_items.append(file_list) return [Path(item) for item in file_items] ================================================ FILE: src/kimi_cli/utils/datetime.py ================================================ from datetime import datetime, timedelta def format_relative_time(timestamp: float) -> str: """Format a timestamp as a relative time string.""" now = datetime.now() dt = datetime.fromtimestamp(timestamp) diff = now - dt if diff < timedelta(minutes=5): return "just now" if diff < timedelta(hours=1): minutes = int(diff.total_seconds() / 60) return f"{minutes}m ago" if diff < timedelta(days=1): hours = int(diff.total_seconds() / 3600) return f"{hours}h ago" if diff < timedelta(days=7): return f"{diff.days}d ago" return dt.strftime("%m-%d") def format_duration(seconds: int) -> str: """Format a duration in seconds using short units.""" delta = timedelta(seconds=seconds) parts: list[str] = [] days = delta.days if days: parts.append(f"{days}d") hours, remainder = divmod(delta.seconds, 3600) minutes, secs = divmod(remainder, 60) if hours: parts.append(f"{hours}h") if minutes: parts.append(f"{minutes}m") if secs and not parts: parts.append(f"{secs}s") return " ".join(parts) or "0s" ================================================ FILE: src/kimi_cli/utils/diff.py ================================================ from __future__ import annotations import difflib from difflib import SequenceMatcher from kimi_cli.tools.display import DiffDisplayBlock N_CONTEXT_LINES = 3 def format_unified_diff( old_text: str, new_text: str, path: str = "", *, include_file_header: bool = True, ) -> str: """ Format a unified diff between old_text and new_text. Args: old_text: The original text. new_text: The new text. path: Optional file path for the diff header. include_file_header: Whether to include the ---/+++ file header lines. Returns: A unified diff string. """ old_lines = old_text.splitlines(keepends=True) new_lines = new_text.splitlines(keepends=True) # Ensure lines end with newline for proper diff formatting if old_lines and not old_lines[-1].endswith("\n"): old_lines[-1] += "\n" if new_lines and not new_lines[-1].endswith("\n"): new_lines[-1] += "\n" fromfile = f"a/{path}" if path else "a/file" tofile = f"b/{path}" if path else "b/file" diff = list( difflib.unified_diff( old_lines, new_lines, fromfile=fromfile, tofile=tofile, lineterm="\n", ) ) if ( not include_file_header and len(diff) >= 2 and diff[0].startswith("--- ") and diff[1].startswith("+++ ") ): diff = diff[2:] return "".join(diff) def build_diff_blocks( path: str, old_text: str, new_text: str, ) -> list[DiffDisplayBlock]: """Build diff display blocks grouped with small context windows.""" old_lines = old_text.splitlines() new_lines = new_text.splitlines() matcher = SequenceMatcher(None, old_lines, new_lines, autojunk=False) blocks: list[DiffDisplayBlock] = [] for group in matcher.get_grouped_opcodes(n=N_CONTEXT_LINES): if not group: continue i1 = group[0][1] i2 = group[-1][2] j1 = group[0][3] j2 = group[-1][4] blocks.append( DiffDisplayBlock( path=path, old_text="\n".join(old_lines[i1:i2]), new_text="\n".join(new_lines[j1:j2]), ) ) return blocks ================================================ FILE: src/kimi_cli/utils/editor.py ================================================ """External editor utilities for editing text in $VISUAL/$EDITOR.""" from __future__ import annotations import contextlib import os import shlex import shutil import subprocess import tempfile from pathlib import Path from kimi_cli.utils.logging import logger from kimi_cli.utils.subprocess_env import get_clean_env # VSCode needs --wait to block until the file is closed. _EDITOR_CANDIDATES = [ (["code", "--wait"], "code"), (["vim"], "vim"), (["vi"], "vi"), (["nano"], "nano"), ] def get_editor_command(configured: str = "") -> list[str] | None: """Determine the editor command to use. Priority: *configured* (from config) -> $VISUAL -> $EDITOR -> auto-detect. Auto-detect order: code --wait -> vim -> vi -> nano. """ if configured: try: return shlex.split(configured) except ValueError: logger.warning("Invalid configured editor value: {}", configured) for var in ("VISUAL", "EDITOR"): value = os.environ.get(var) if value: try: return shlex.split(value) except ValueError: logger.warning("Invalid {} value: {}", var, value) continue for cmd, binary in _EDITOR_CANDIDATES: if shutil.which(binary): return cmd return None def edit_text_in_editor(text: str, configured: str = "") -> str | None: """Open *text* in an external editor and return the edited result. Returns ``None`` if the editor failed or the user quit without saving. """ editor_cmd = get_editor_command(configured) if editor_cmd is None: logger.warning("No editor found. Set $VISUAL or $EDITOR.") return None fd, tmpfile = tempfile.mkstemp(suffix=".md", prefix="kimi-edit-") try: with os.fdopen(fd, "w", encoding="utf-8") as f: f.write(text) mtime_before = os.path.getmtime(tmpfile) try: returncode = subprocess.call(editor_cmd + [tmpfile], env=get_clean_env()) except OSError as exc: logger.warning("Failed to launch editor {}: {}", editor_cmd, exc) return None if returncode != 0: logger.warning("Editor exited with non-zero return code: {}", returncode) return None mtime_after = os.path.getmtime(tmpfile) if mtime_after == mtime_before: return None edited = Path(tmpfile).read_text(encoding="utf-8") if edited.endswith("\n"): edited = edited[:-1] return edited finally: with contextlib.suppress(OSError): os.unlink(tmpfile) ================================================ FILE: src/kimi_cli/utils/environment.py ================================================ from __future__ import annotations import platform from dataclasses import dataclass from typing import Literal from kaos.path import KaosPath @dataclass(slots=True, frozen=True, kw_only=True) class Environment: os_kind: Literal["Windows", "Linux", "macOS"] | str os_arch: str os_version: str shell_name: Literal["bash", "sh", "Windows PowerShell"] shell_path: KaosPath @staticmethod async def detect() -> Environment: match platform.system(): case "Darwin": os_kind = "macOS" case "Windows": os_kind = "Windows" case "Linux": os_kind = "Linux" case system: os_kind = system os_arch = platform.machine() os_version = platform.version() if os_kind == "Windows": shell_name = "Windows PowerShell" shell_path = KaosPath("powershell.exe") else: possible_paths = [ KaosPath("/bin/bash"), KaosPath("/usr/bin/bash"), KaosPath("/usr/local/bin/bash"), ] fallback_path = KaosPath("/bin/sh") for path in possible_paths: if await path.is_file(): shell_name = "bash" shell_path = path break else: shell_name = "sh" shell_path = fallback_path return Environment( os_kind=os_kind, os_arch=os_arch, os_version=os_version, shell_name=shell_name, shell_path=shell_path, ) ================================================ FILE: src/kimi_cli/utils/envvar.py ================================================ from __future__ import annotations import os _TRUE_VALUES = {"1", "true", "t", "yes", "y"} def get_env_bool(name: str, default: bool = False) -> bool: value = os.getenv(name) if value is None: return default return value.strip().lower() in _TRUE_VALUES ================================================ FILE: src/kimi_cli/utils/export.py ================================================ from __future__ import annotations import json from collections.abc import Sequence from datetime import datetime from pathlib import Path from textwrap import shorten from typing import TYPE_CHECKING, cast import aiofiles from kaos.path import KaosPath from kosong.message import Message from kimi_cli.soul.message import is_system_reminder_message, system from kimi_cli.utils.message import message_stringify from kimi_cli.utils.path import sanitize_cli_path from kimi_cli.wire.types import ( AudioURLPart, ContentPart, ImageURLPart, TextPart, ThinkPart, ToolCall, VideoURLPart, ) if TYPE_CHECKING: from kimi_cli.soul.context import Context # --------------------------------------------------------------------------- # Export helpers # --------------------------------------------------------------------------- _HINT_KEYS = ("path", "file_path", "command", "query", "url", "name", "pattern") """Common tool-call argument keys whose values make good one-line hints.""" def _is_checkpoint_message(msg: Message) -> bool: """Check if a message is an internal checkpoint marker.""" if msg.role != "user" or len(msg.content) != 1: return False part = msg.content[0] return isinstance(part, TextPart) and part.text.strip().startswith("CHECKPOINT") def _is_internal_user_message(msg: Message) -> bool: """Check if a user message is internal bookkeeping rather than real user input.""" return _is_checkpoint_message(msg) or is_system_reminder_message(msg) def _extract_tool_call_hint(args_json: str) -> str: """Extract a brief human-readable hint from tool-call arguments. Looks for well-known keys (path, command, …) and falls back to the first short string value. Returns ``""`` when nothing useful is found. """ try: parsed: object = json.loads(args_json) except (json.JSONDecodeError, TypeError): return "" if not isinstance(parsed, dict): return "" args = cast(dict[str, object], parsed) # Prefer well-known keys for key in _HINT_KEYS: val = args.get(key) if isinstance(val, str) and val.strip(): return shorten(val, width=60, placeholder="…") # Fallback: first short string value for val in args.values(): if isinstance(val, str) and 0 < len(val) <= 80: return shorten(val, width=60, placeholder="…") return "" def _format_content_part_md(part: ContentPart) -> str: """Convert a single ContentPart to markdown text.""" match part: case TextPart(text=text): return text case ThinkPart(think=think): if not think.strip(): return "" return f"
Thinking\n\n{think}\n\n
" case ImageURLPart(): return "[image]" case AudioURLPart(): return "[audio]" case VideoURLPart(): return "[video]" case _: return f"[{part.type}]" def _format_tool_call_md(tool_call: ToolCall) -> str: """Convert a ToolCall to a markdown sub-section with a readable title.""" args_raw = tool_call.function.arguments or "{}" hint = _extract_tool_call_hint(args_raw) title = f"#### Tool Call: {tool_call.function.name}" if hint: title += f" (`{hint}`)" try: args_formatted = json.dumps(json.loads(args_raw), indent=2, ensure_ascii=False) except json.JSONDecodeError: args_formatted = args_raw return f"{title}\n\n```json\n{args_formatted}\n```" def _format_tool_result_md(msg: Message, tool_name: str, hint: str) -> str: """Format a tool result message as a collapsible markdown block.""" call_id = msg.tool_call_id or "unknown" # Use _format_content_part_md for consistency with the rest of the module # (message_stringify loses ThinkPart and leaks tags) result_parts: list[str] = [] for part in msg.content: text = _format_content_part_md(part) if text.strip(): result_parts.append(text) result_text = "\n".join(result_parts) summary = f"Tool Result: {tool_name}" if hint: summary += f" (`{hint}`)" return ( f"
{summary}\n\n" f"\n" f"{result_text}\n\n" "
" ) def _group_into_turns(history: Sequence[Message]) -> list[list[Message]]: """Group messages into logical turns, each starting at a real user message.""" turns: list[list[Message]] = [] current: list[Message] = [] for msg in history: if _is_internal_user_message(msg): continue if msg.role == "user" and current: turns.append(current) current = [] current.append(msg) if current: turns.append(current) return turns def _format_turn_md(messages: list[Message], turn_number: int) -> str: """Format a logical turn as a markdown section. A turn typically contains: user message -> assistant (thinking + text + tool_calls) -> tool results -> assistant (more text + tool_calls) -> tool results -> assistant (final) All assistant/tool messages are grouped under a single ``### Assistant`` heading. """ lines: list[str] = [f"## Turn {turn_number}", ""] # tool_call_id -> (function_name, hint) tool_call_info: dict[str, tuple[str, str]] = {} assistant_header_written = False for msg in messages: if _is_internal_user_message(msg): continue if msg.role == "user": lines.append("### User") lines.append("") for part in msg.content: text = _format_content_part_md(part) if text.strip(): lines.append(text) lines.append("") elif msg.role == "assistant": if not assistant_header_written: lines.append("### Assistant") lines.append("") assistant_header_written = True # Content parts (thinking, text, media) for part in msg.content: text = _format_content_part_md(part) if text.strip(): lines.append(text) lines.append("") # Tool calls if msg.tool_calls: for tc in msg.tool_calls: hint = _extract_tool_call_hint(tc.function.arguments or "{}") tool_call_info[tc.id] = (tc.function.name, hint) lines.append(_format_tool_call_md(tc)) lines.append("") elif msg.role == "tool": tc_id = msg.tool_call_id or "" name, hint = tool_call_info.get(tc_id, ("unknown", "")) lines.append(_format_tool_result_md(msg, name, hint)) lines.append("") elif msg.role in ("system", "developer"): lines.append(f"### {msg.role.capitalize()}") lines.append("") for part in msg.content: text = _format_content_part_md(part) if text.strip(): lines.append(text) lines.append("") return "\n".join(lines) def _build_overview( history: Sequence[Message], turns: list[list[Message]], token_count: int, ) -> str: """Build the Overview section from existing data (no LLM call).""" # Topic: first real user message text, truncated topic = "" for msg in history: if msg.role == "user" and not _is_internal_user_message(msg): topic = shorten(message_stringify(msg), width=80, placeholder="…") break # Count tool calls across all messages n_tool_calls = sum(len(msg.tool_calls) for msg in history if msg.tool_calls) lines = [ "## Overview", "", f"- **Topic**: {topic}" if topic else "- **Topic**: (empty)", f"- **Conversation**: {len(turns)} turns | " f"{n_tool_calls} tool calls | {token_count:,} tokens", "", "---", ] return "\n".join(lines) def build_export_markdown( session_id: str, work_dir: str, history: Sequence[Message], token_count: int, now: datetime, ) -> str: """Build the full export markdown string.""" lines: list[str] = [ "---", f"session_id: {session_id}", f"exported_at: {now.isoformat(timespec='seconds')}", f"work_dir: {work_dir}", f"message_count: {len(history)}", f"token_count: {token_count}", "---", "", "# Kimi Session Export", "", ] turns = _group_into_turns(history) lines.append(_build_overview(history, turns, token_count)) lines.append("") for idx, turn_messages in enumerate(turns): lines.append(_format_turn_md(turn_messages, idx + 1)) return "\n".join(lines) # --------------------------------------------------------------------------- # Import helpers # --------------------------------------------------------------------------- _IMPORTABLE_EXTENSIONS: frozenset[str] = frozenset( { # Markdown / plain text ".md", ".markdown", ".txt", ".text", ".rst", # Data / config ".json", ".jsonl", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".csv", ".tsv", ".xml", ".env", ".properties", # Source code ".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".kt", ".go", ".rs", ".c", ".cpp", ".h", ".hpp", ".cs", ".rb", ".php", ".swift", ".scala", ".sh", ".bash", ".zsh", ".fish", ".ps1", ".bat", ".cmd", ".r", ".R", ".lua", ".pl", ".pm", ".ex", ".exs", ".erl", ".hs", ".ml", ".sql", ".graphql", ".proto", # Web ".html", ".htm", ".css", ".scss", ".sass", ".less", ".svg", # Logs ".log", # Documentation ".tex", ".bib", ".org", ".adoc", ".wiki", } ) """File extensions accepted by ``/import``. Only text-based formats are supported — importing binary files (images, PDFs, archives, …) is rejected with a friendly message.""" def is_importable_file(path_str: str) -> bool: """Return True if *path_str* has an extension in the importable whitelist. Files with no extension are also accepted (could be READMEs, Makefiles, …). """ suffix = Path(path_str).suffix.lower() return suffix == "" or suffix in _IMPORTABLE_EXTENSIONS def _stringify_content_parts(parts: Sequence[ContentPart]) -> str: """Serialize a list of ContentParts to readable text, preserving ThinkPart.""" segments: list[str] = [] for part in parts: match part: case TextPart(text=text): if text.strip(): segments.append(text) case ThinkPart(think=think): if think.strip(): segments.append(f"\n{think}\n") case ImageURLPart(): segments.append("[image]") case AudioURLPart(): segments.append("[audio]") case VideoURLPart(): segments.append("[video]") case _: segments.append(f"[{part.type}]") return "\n".join(segments) def _stringify_tool_calls(tool_calls: Sequence[ToolCall]) -> str: """Serialize tool calls to readable text.""" lines: list[str] = [] for tc in tool_calls: args_raw = tc.function.arguments or "{}" try: args = json.loads(args_raw) args_str = json.dumps(args, ensure_ascii=False) except (json.JSONDecodeError, TypeError): args_str = args_raw lines.append(f"Tool Call: {tc.function.name}({args_str})") return "\n".join(lines) def stringify_context_history(history: Sequence[Message]) -> str: """Convert a sequence of Messages to a readable text transcript. Preserves ThinkPart content, tool call information, and tool results so that an AI receiving the imported context has a complete picture. """ parts: list[str] = [] for msg in history: if _is_internal_user_message(msg): continue role_label = msg.role.upper() segments: list[str] = [] # Content parts (text, thinking, media) content_text = _stringify_content_parts(msg.content) if content_text.strip(): segments.append(content_text) # Tool calls (only on assistant messages) if msg.tool_calls: segments.append(_stringify_tool_calls(msg.tool_calls)) if not segments: continue header = f"[{role_label}]" if msg.role == "tool" and msg.tool_call_id: header = f"[{role_label}] (call_id: {msg.tool_call_id})" parts.append(f"{header}\n" + "\n".join(segments)) return "\n\n".join(parts) # --------------------------------------------------------------------------- # Shared command logic # --------------------------------------------------------------------------- async def perform_export( history: Sequence[Message], session_id: str, work_dir: str, token_count: int, args: str, default_dir: Path, ) -> tuple[Path, int] | str: """Perform the full export operation. Returns ``(output_path, message_count)`` on success, or an error message string on failure. """ if not history: return "No messages to export." now = datetime.now().astimezone() short_id = session_id[:8] default_name = f"kimi-export-{short_id}-{now.strftime('%Y%m%d-%H%M%S')}.md" cleaned = sanitize_cli_path(args) if cleaned: # sanitize_cli_path only strips quotes; it preserves trailing separators. directory_hint = cleaned.endswith(("/", "\\")) output = Path(cleaned).expanduser() if not output.is_absolute(): output = default_dir / output # Keep explicit "directory intent" even when the directory does not exist yet. if directory_hint or output.is_dir(): output = output / default_name else: output = default_dir / default_name content = build_export_markdown( session_id=session_id, work_dir=work_dir, history=history, token_count=token_count, now=now, ) try: output.parent.mkdir(parents=True, exist_ok=True) async with aiofiles.open(output, "w", encoding="utf-8") as f: await f.write(content) except OSError as e: return f"Failed to write export file: {e}" return (output, len(history)) MAX_IMPORT_SIZE = 10 * 1024 * 1024 # 10 MB """Maximum size (in bytes) of a file that can be imported via ``/import``.""" _SENSITIVE_FILE_PATTERNS: tuple[str, ...] = ( ".env", "credentials", "secrets", ".pem", ".key", ".p12", ".pfx", ".keystore", ) """File-name substrings that indicate potentially sensitive content.""" def is_sensitive_file(filename: str) -> bool: """Return True if *filename* looks like it may contain secrets.""" name = filename.lower() return any(pat in name for pat in _SENSITIVE_FILE_PATTERNS) def _validate_import_token_budget( estimated_tokens: int, current_token_count: int, max_context_size: int | None, ) -> str | None: """Return an error if importing would push the session over the context budget. *estimated_tokens* is the pre-computed token estimate for the import message. The check is ``current_token_count + estimated_tokens <= max_context_size``. """ if max_context_size is None or max_context_size <= 0: return None total_after_import = current_token_count + estimated_tokens if total_after_import <= max_context_size: return None return ( "Imported content is too large for the current model context " f"(~{estimated_tokens:,} import tokens + {current_token_count:,} existing " f"= ~{total_after_import:,} total > {max_context_size:,} token limit). " "Please import a smaller file or session." ) async def resolve_import_source( target: str, current_session_id: str, work_dir: KaosPath, ) -> tuple[str, str] | str: """Resolve the import source to ``(content, source_desc)`` or an error message. This function handles I/O and source-level validation (file type, encoding, byte-size cap). Session-level concerns like token budget are checked by :func:`perform_import`. """ from kimi_cli.session import Session from kimi_cli.soul.context import Context target_path = Path(target).expanduser() if not target_path.is_absolute(): target_path = Path(str(work_dir)) / target_path if target_path.exists() and target_path.is_dir(): return "The specified path is a directory; please provide a file to import." if target_path.exists() and target_path.is_file(): if not is_importable_file(target_path.name): return ( f"Unsupported file type '{target_path.suffix}'. " "/import only supports text-based files " "(e.g. .md, .txt, .json, .py, .log, …)." ) try: file_size = target_path.stat().st_size except OSError as e: return f"Failed to read file: {e}" if file_size > MAX_IMPORT_SIZE: limit_mb = MAX_IMPORT_SIZE // (1024 * 1024) return ( f"File is too large ({file_size / 1024 / 1024:.1f} MB). " f"Maximum import size is {limit_mb} MB." ) try: async with aiofiles.open(target_path, encoding="utf-8") as f: content = await f.read() except UnicodeDecodeError: return ( f"Cannot import '{target_path.name}': " "the file does not appear to be valid UTF-8 text." ) except OSError as e: return f"Failed to read file: {e}" if not content.strip(): return "The file is empty, nothing to import." return (content, f"file '{target_path.name}'") # Not a file on disk — try as session ID if target == current_session_id: return "Cannot import the current session into itself." source_session = await Session.find(work_dir, target) if source_session is None: return f"'{target}' is not a valid file path or session ID." source_context = Context(source_session.context_file) try: restored = await source_context.restore() except Exception as e: return f"Failed to load source session: {e}" if not restored or not source_context.history: return "The source session has no messages." content = stringify_context_history(source_context.history) content_bytes = len(content.encode("utf-8")) if content_bytes > MAX_IMPORT_SIZE: limit_mb = MAX_IMPORT_SIZE // (1024 * 1024) actual_mb = content_bytes / 1024 / 1024 return ( f"Session content is too large ({actual_mb:.1f} MB). " f"Maximum import size is {limit_mb} MB." ) return (content, f"session '{target}'") def build_import_message(content: str, source_desc: str) -> Message: """Build the ``Message`` to append to context for an import operation.""" import_text = f'\n{content}\n' return Message( role="user", content=[ system( f"The user has imported context from {source_desc}. " "This is a prior conversation history that may be relevant " "to the current session. " "Please review this context and use it to inform your responses." ), TextPart(text=import_text), ], ) async def perform_import( target: str, current_session_id: str, work_dir: KaosPath, context: Context, max_context_size: int | None = None, ) -> tuple[str, int] | str: """High-level import operation: resolve source, validate, build message, update context. Returns ``(source_desc, content_len)`` on success, or an error message string. *content_len* is the raw imported content length in characters (excluding wrapper markup), suitable for user-facing display. The caller is responsible for any additional side-effects (wire file writes, UI output, etc.). """ from kimi_cli.soul.compaction import estimate_text_tokens result = await resolve_import_source( target=target, current_session_id=current_session_id, work_dir=work_dir, ) if isinstance(result, str): return result content, source_desc = result message = build_import_message(content, source_desc) # Token budget check — reject before mutating context. estimated = estimate_text_tokens([message]) if error := _validate_import_token_budget(estimated, context.token_count, max_context_size): return error await context.append_message(message) await context.update_token_count(context.token_count + estimated) return (source_desc, len(content)) ================================================ FILE: src/kimi_cli/utils/frontmatter.py ================================================ from __future__ import annotations from pathlib import Path from typing import Any, cast import yaml def parse_frontmatter(text: str) -> dict[str, Any] | None: """ Parse YAML frontmatter from a text blob. Raises: ValueError: If the frontmatter YAML is invalid. """ lines = text.splitlines() if not lines or lines[0].strip() != "---": return None frontmatter_lines: list[str] = [] for line in lines[1:]: if line.strip() == "---": break frontmatter_lines.append(line) else: return None frontmatter = "\n".join(frontmatter_lines).strip() if not frontmatter: return None try: raw_data: Any = yaml.safe_load(frontmatter) except yaml.YAMLError as exc: raise ValueError("Invalid frontmatter YAML.") from exc if not isinstance(raw_data, dict): raise ValueError("Frontmatter YAML must be a mapping.") return cast(dict[str, Any], raw_data) def read_frontmatter(path: Path) -> dict[str, Any] | None: """ Read the YAML frontmatter at the start of a file. Args: path: Path to an existing file that may contain frontmatter. """ return parse_frontmatter(path.read_text(encoding="utf-8", errors="replace")) ================================================ FILE: src/kimi_cli/utils/io.py ================================================ from __future__ import annotations import contextlib import json import os import tempfile from pathlib import Path from typing import Any def atomic_json_write(data: Any, path: Path) -> None: """Write JSON data to a file atomically using tmp-file + os.replace. This prevents data corruption if the process crashes mid-write: either the old file is kept intact or the new file is fully committed. """ fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".tmp") try: with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) f.flush() os.fsync(f.fileno()) os.replace(tmp_path, path) except BaseException: with contextlib.suppress(OSError): os.unlink(tmp_path) raise ================================================ FILE: src/kimi_cli/utils/logging.py ================================================ from __future__ import annotations import codecs import contextlib import locale import os import sys import threading from collections.abc import Iterator from typing import IO from kimi_cli import logger class StderrRedirector: def __init__(self, level: str = "ERROR") -> None: self._level = level self._encoding: str | None = None self._installed = False self._lock = threading.Lock() self._original_fd: int | None = None self._read_fd: int | None = None self._thread: threading.Thread | None = None def install(self) -> None: with self._lock: if self._installed: return with contextlib.suppress(Exception): sys.stderr.flush() if self._original_fd is None: with contextlib.suppress(OSError): self._original_fd = os.dup(2) if self._encoding is None: self._encoding = ( sys.stderr.encoding or locale.getpreferredencoding(False) or "utf-8" ) read_fd, write_fd = os.pipe() os.dup2(write_fd, 2) os.close(write_fd) self._read_fd = read_fd self._thread = threading.Thread( target=self._drain, name="kimi-stderr-redirect", daemon=True ) self._thread.start() self._installed = True def uninstall(self) -> None: with self._lock: if not self._installed: return if self._original_fd is not None: os.dup2(self._original_fd, 2) self._installed = False if self._thread is not None: self._thread.join(timeout=2.0) self._thread = None def _drain(self) -> None: buffer = "" read_fd = self._read_fd if read_fd is None: return encoding = self._encoding or "utf-8" decoder = codecs.getincrementaldecoder(encoding)(errors="replace") try: while True: chunk = os.read(read_fd, 4096) if not chunk: break buffer += decoder.decode(chunk) while "\n" in buffer: line, buffer = buffer.split("\n", 1) self._log_line(line) except Exception: logger.exception("Failed to read redirected stderr") finally: buffer += decoder.decode(b"", final=True) if buffer: self._log_line(buffer) with contextlib.suppress(OSError): os.close(read_fd) def _log_line(self, line: str) -> None: text = line.rstrip("\r") if not text: return logger.opt(depth=2).log(self._level, text) def open_original_stderr_handle(self) -> IO[bytes] | None: if self._original_fd is None: return None dup_fd = os.dup(self._original_fd) os.set_inheritable(dup_fd, True) return os.fdopen(dup_fd, "wb", closefd=True) _stderr_redirector: StderrRedirector | None = None def redirect_stderr_to_logger(level: str = "ERROR") -> None: global _stderr_redirector if _stderr_redirector is None: _stderr_redirector = StderrRedirector(level=level) _stderr_redirector.install() def restore_stderr() -> None: if _stderr_redirector is not None: _stderr_redirector.uninstall() @contextlib.contextmanager def open_original_stderr() -> Iterator[IO[bytes] | None]: redirector = _stderr_redirector if redirector is None: yield None return stream = redirector.open_original_stderr_handle() try: yield stream finally: if stream is not None: stream.close() ================================================ FILE: src/kimi_cli/utils/media_tags.py ================================================ from __future__ import annotations from collections.abc import Mapping from html import escape from kimi_cli.wire.types import ContentPart, TextPart def _format_tag(tag: str, attrs: Mapping[str, str | None] | None = None) -> str: if not attrs: return f"<{tag}>" rendered: list[str] = [] for key, value in sorted(attrs.items()): if not value: continue rendered.append(f'{key}="{escape(str(value), quote=True)}"') if not rendered: return f"<{tag}>" return f"<{tag} " + " ".join(rendered) + ">" def wrap_media_part( part: ContentPart, *, tag: str, attrs: Mapping[str, str | None] | None = None ) -> list[ContentPart]: return [ TextPart(text=_format_tag(tag, attrs)), part, TextPart(text=f""), ] ================================================ FILE: src/kimi_cli/utils/message.py ================================================ from __future__ import annotations from kosong.message import Message from kimi_cli.wire.types import AudioURLPart, ImageURLPart, TextPart, VideoURLPart def message_stringify(message: Message) -> str: """Get a string representation of a message.""" # TODO: this should be merged into `kosong.message.Message.extract_text` parts: list[str] = [] for part in message.content: if isinstance(part, TextPart): parts.append(part.text) elif isinstance(part, ImageURLPart): parts.append("[image]") elif isinstance(part, AudioURLPart): suffix = f":{part.audio_url.id}" if part.audio_url.id else "" parts.append(f"[audio{suffix}]") elif isinstance(part, VideoURLPart): parts.append("[video]") else: parts.append(f"[{part.type}]") return "".join(parts) ================================================ FILE: src/kimi_cli/utils/path.py ================================================ from __future__ import annotations import asyncio import os import re from collections.abc import Sequence from pathlib import Path, PurePath from stat import S_ISDIR import aiofiles.os from kaos.path import KaosPath _ROTATION_OPEN_FLAGS = os.O_CREAT | os.O_EXCL | os.O_WRONLY _ROTATION_FILE_MODE = 0o600 async def _reserve_rotation_path(path: Path) -> bool: """Atomically create an empty file as a reservation for *path*.""" def _create() -> None: fd = os.open(str(path), _ROTATION_OPEN_FLAGS, _ROTATION_FILE_MODE) os.close(fd) try: await asyncio.to_thread(_create) except FileExistsError: return False return True async def next_available_rotation(path: Path) -> Path | None: """Return a reserved rotation path for *path* or ``None`` if parent is missing. The caller must overwrite/reuse the returned path immediately because this helper commits an empty placeholder file to guarantee uniqueness. It is therefore suited for rotating *files* (like history logs) but **not** directory creation. """ if not path.parent.exists(): return None base_name = path.stem suffix = path.suffix pattern = re.compile(rf"^{re.escape(base_name)}_(\d+){re.escape(suffix)}$") max_num = 0 for entry in await aiofiles.os.listdir(path.parent): if match := pattern.match(entry): max_num = max(max_num, int(match.group(1))) next_num = max_num + 1 while True: next_path = path.parent / f"{base_name}_{next_num}{suffix}" if await _reserve_rotation_path(next_path): return next_path next_num += 1 async def list_directory(work_dir: KaosPath) -> str: """Return an ``ls``-like listing of *work_dir*. This helper is used mainly to provide context to the LLM (for example ``KIMI_WORK_DIR_LS``) and to show top-level directory contents in tools. It should therefore be robust against per-entry filesystem issues such as broken symlinks or permission errors: a single bad entry must not crash the whole CLI. """ entries: list[str] = [] # Iterate entries; tolerate per-entry stat failures (broken symlinks, permissions, etc.). async for entry in work_dir.iterdir(): try: st = await entry.stat() except OSError: # Broken symlink, permission error, etc. – keep listing other entries. entries.append(f"?--------- {'?':>10} {entry.name} [stat failed]") continue mode = "d" if S_ISDIR(st.st_mode) else "-" mode += "r" if st.st_mode & 0o400 else "-" mode += "w" if st.st_mode & 0o200 else "-" mode += "x" if st.st_mode & 0o100 else "-" mode += "r" if st.st_mode & 0o040 else "-" mode += "w" if st.st_mode & 0o020 else "-" mode += "x" if st.st_mode & 0o010 else "-" mode += "r" if st.st_mode & 0o004 else "-" mode += "w" if st.st_mode & 0o002 else "-" mode += "x" if st.st_mode & 0o001 else "-" entries.append(f"{mode} {st.st_size:>10} {entry.name}") return "\n".join(entries) def shorten_home(path: KaosPath) -> KaosPath: """ Convert absolute path to use `~` for home directory. """ try: home = KaosPath.home() p = path.relative_to(home) return KaosPath("~") / p except Exception: return path def sanitize_cli_path(raw: str) -> str: """Strip surrounding quotes from a CLI path argument. On macOS, dragging a file into the terminal wraps the path in single quotes (e.g. ``'/path/to/file'``). This helper strips matching outer quotes (single or double) so downstream path handling works correctly. """ raw = raw.strip() if len(raw) >= 2 and ((raw[0] == "'" and raw[-1] == "'") or (raw[0] == '"' and raw[-1] == '"')): raw = raw[1:-1] return raw def is_within_directory(path: KaosPath, directory: KaosPath) -> bool: """ Check whether *path* is contained within *directory* using pure path semantics. Both arguments should already be canonicalized (e.g. via KaosPath.canonical()). """ candidate = PurePath(str(path)) base = PurePath(str(directory)) try: candidate.relative_to(base) return True except ValueError: return False def is_within_workspace( path: KaosPath, work_dir: KaosPath, additional_dirs: Sequence[KaosPath] = (), ) -> bool: """ Check whether *path* is within the workspace (work_dir or any additional directory). """ if is_within_directory(path, work_dir): return True return any(is_within_directory(path, d) for d in additional_dirs) ================================================ FILE: src/kimi_cli/utils/proctitle.py ================================================ from __future__ import annotations import sys def set_process_title(title: str) -> None: """Set the OS-level process title visible in ps/top/terminal panels.""" try: import setproctitle setproctitle.setproctitle(title) except ImportError: pass def set_terminal_title(title: str) -> None: """Set the terminal tab/window title via ANSI OSC escape sequence. Only writes when stderr is a TTY to avoid polluting piped output. """ if not sys.stderr.isatty(): return try: sys.stderr.write(f"\033]0;{title}\007") sys.stderr.flush() except OSError: pass def init_process_name(name: str = "Kimi Code") -> None: """Initialize process name: OS process title + terminal tab title.""" set_process_title(name) set_terminal_title(name) ================================================ FILE: src/kimi_cli/utils/pyinstaller.py ================================================ from __future__ import annotations from PyInstaller.utils.hooks import collect_data_files, collect_submodules hiddenimports = collect_submodules("kimi_cli.tools") + ["setproctitle"] datas = ( collect_data_files( "kimi_cli", includes=[ "agents/**/*.yaml", "agents/**/*.md", "deps/bin/**", "prompts/**/*.md", "skills/**", "tools/**/*.md", "web/static/**", "vis/static/**", "CHANGELOG.md", ], excludes=[ "tools/*.md", ], ) + collect_data_files( "dateparser", includes=["**/*.pkl"], ) + collect_data_files( "fastmcp", includes=["../fastmcp-*.dist-info/*"], ) ) ================================================ FILE: src/kimi_cli/utils/rich/__init__.py ================================================ """Project-wide Rich configuration helpers.""" from __future__ import annotations import re from typing import Final from rich import _wrap # Regex used by Rich to compute break opportunities during wrapping. _DEFAULT_WRAP_PATTERN: Final[re.Pattern[str]] = re.compile(r"\s*\S+\s*") _CHAR_WRAP_PATTERN: Final[re.Pattern[str]] = re.compile(r".", re.DOTALL) def enable_character_wrap() -> None: """Switch Rich's wrapping logic to break on every character. Rich's default behavior tries to preserve whole words; we override the internal regex so markdown rendering can fold text at any column once it exceeds the terminal width. """ _wrap.re_word = _CHAR_WRAP_PATTERN def restore_word_wrap() -> None: """Restore Rich's default word-based wrapping.""" _wrap.re_word = _DEFAULT_WRAP_PATTERN # Apply character-based wrapping globally for the CLI. enable_character_wrap() ================================================ FILE: src/kimi_cli/utils/rich/columns.py ================================================ from __future__ import annotations from rich.columns import Columns from rich.console import Console, ConsoleOptions, RenderableType, RenderResult from rich.measure import Measurement from rich.segment import Segment from rich.text import Text class _ShrinkToWidth: def __init__(self, renderable: RenderableType, max_width: int) -> None: self._renderable = renderable self._max_width = max(max_width, 1) def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measurement: width = self._resolve_width(options) return Measurement(0, width) def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: width = self._resolve_width(options) child_options = options.update(width=width) yield from console.render(self._renderable, child_options) def _resolve_width(self, options: ConsoleOptions) -> int: return max(1, min(self._max_width, options.max_width)) def _strip_trailing_spaces(segments: list[Segment]) -> list[Segment]: lines = list(Segment.split_lines(segments)) trimmed: list[Segment] = [] n_lines = len(lines) for index, line in enumerate(lines): line_segments = list(line) while line_segments: segment = line_segments[-1] if segment.control is not None: break trimmed_text = segment.text.rstrip(" ") if trimmed_text != segment.text: if trimmed_text: line_segments[-1] = Segment(trimmed_text, segment.style, segment.control) break line_segments.pop() continue break trimmed.extend(line_segments) if index != n_lines - 1: trimmed.append(Segment.line()) if trimmed: trimmed.append(Segment.line()) return trimmed class BulletColumns: def __init__( self, renderable: RenderableType, *, bullet_style: str | None = None, bullet: RenderableType | None = None, padding: int = 1, ) -> None: self._renderable = renderable self._bullet = bullet self._bullet_style = bullet_style self._padding = padding def _bullet_renderable(self) -> RenderableType: if self._bullet is not None: return self._bullet return Text("•", style=self._bullet_style or "") def _available_width(self, console: Console, options: ConsoleOptions, bullet_width: int) -> int: max_width = options.max_width or console.width or (bullet_width + self._padding + 1) available = max_width - bullet_width - self._padding return max(available, 1) def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measurement: bullet = self._bullet_renderable() bullet_measure = Measurement.get(console, options, bullet) bullet_width = max(bullet_measure.maximum, 1) available = self._available_width(console, options, bullet_width) constrained = _ShrinkToWidth(self._renderable, available) columns = Columns([bullet, constrained], expand=False, padding=(0, self._padding)) return Measurement.get(console, options, columns) def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: bullet = self._bullet_renderable() bullet_measure = Measurement.get(console, options, bullet) bullet_width = max(bullet_measure.maximum, 1) available = self._available_width(console, options, bullet_width) columns = Columns( [bullet, _ShrinkToWidth(self._renderable, available)], expand=False, padding=(0, self._padding), ) segments = list(console.render(columns, options)) trimmed = _strip_trailing_spaces(segments) yield from trimmed ================================================ FILE: src/kimi_cli/utils/rich/markdown.py ================================================ # This file is modified from https://github.com/Textualize/rich/blob/4d6d631a3d2deddf8405522d4b8c976a6d35726c/rich/markdown.py # pyright: standard from __future__ import annotations import sys from collections.abc import Iterable, Mapping from typing import ClassVar, get_args from markdown_it import MarkdownIt from markdown_it.token import Token from rich import box from rich._loop import loop_first from rich._stack import Stack from rich.console import Console, ConsoleOptions, JustifyMethod, RenderResult from rich.containers import Renderables from rich.jupyter import JupyterMixin from rich.rule import Rule from rich.segment import Segment from rich.style import Style, StyleStack from rich.syntax import Syntax, SyntaxTheme from rich.table import Table from rich.text import Text, TextType from kimi_cli.utils.rich.syntax import KIMI_ANSI_THEME_NAME, resolve_code_theme LIST_INDENT_WIDTH = 2 _FALLBACK_STYLES: Mapping[str, Style] = { "markdown.paragraph": Style(), "markdown.h1": Style(color="bright_white", bold=True), "markdown.h1.underline": Style(color="bright_white", bold=True), "markdown.h2": Style(color="white", bold=True, underline=True), "markdown.h3": Style(bold=True), "markdown.h4": Style(bold=True), "markdown.h5": Style(bold=True), "markdown.h6": Style(dim=True, italic=True), "markdown.code": Style(color="bright_cyan", bold=True), "markdown.code_block": Style(color="bright_cyan"), "markdown.item": Style(), "markdown.item.bullet": Style(), "markdown.item.number": Style(), "markdown.em": Style(italic=True), "markdown.strong": Style(bold=True), "markdown.s": Style(strike=True), "markdown.link": Style(color="bright_blue", underline=True), "markdown.link_url": Style(color="cyan", underline=True), "markdown.block_quote": Style(), "markdown.hr": Style(color="grey58"), } def _strip_background(text: Text) -> Text: """Return a copy of ``text`` with all background colors removed.""" clean = Text( text.plain, justify=text.justify, overflow=text.overflow, no_wrap=text.no_wrap, end=text.end, tab_size=text.tab_size, ) if text.style: base_style = text.style if not isinstance(base_style, Style): base_style = Style.parse(str(base_style)) base_style = base_style.copy() if base_style._bgcolor is not None: base_style._bgcolor = None clean.stylize(base_style, 0, len(clean)) for span in text.spans: style = span.style if style is None: continue new_style = Style.parse(str(style)) if not isinstance(style, Style) else style.copy() if new_style._bgcolor is not None: new_style._bgcolor = None clean.stylize(new_style, span.start, span.end) return clean class MarkdownElement: new_line: ClassVar[bool] = True @classmethod def create(cls, markdown: Markdown, token: Token) -> MarkdownElement: """Factory to create markdown element, Args: markdown (Markdown): The parent Markdown object. token (Token): A node from markdown-it. Returns: MarkdownElement: A new markdown element """ return cls() def on_enter(self, context: MarkdownContext) -> None: """Called when the node is entered. Args: context (MarkdownContext): The markdown context. """ def on_text(self, context: MarkdownContext, text: TextType) -> None: """Called when text is parsed. Args: context (MarkdownContext): The markdown context. """ def on_leave(self, context: MarkdownContext) -> None: """Called when the parser leaves the element. Args: context (MarkdownContext): [description] """ def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool: """Called when a child element is closed. This method allows a parent element to take over rendering of its children. Args: context (MarkdownContext): The markdown context. child (MarkdownElement): The child markdown element. Returns: bool: Return True to render the element, or False to not render the element. """ return True def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: return () class UnknownElement(MarkdownElement): """An unknown element. Hopefully there will be no unknown elements, and we will have a MarkdownElement for everything in the document. """ class TextElement(MarkdownElement): """Base class for elements that render text.""" style_name = "none" def on_enter(self, context: MarkdownContext) -> None: self.style = context.enter_style(self.style_name) self.text = Text(justify="left") def on_text(self, context: MarkdownContext, text: TextType) -> None: self.text.append(text, context.current_style if isinstance(text, str) else None) def on_leave(self, context: MarkdownContext) -> None: context.leave_style() class Paragraph(TextElement): """A Paragraph.""" style_name = "markdown.paragraph" justify: JustifyMethod @classmethod def create(cls, markdown: Markdown, token: Token) -> Paragraph: return cls(justify=markdown.justify or "left") def __init__(self, justify: JustifyMethod) -> None: self.justify = justify def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: self.text.justify = self.justify yield self.text class Heading(TextElement): """A heading.""" @classmethod def create(cls, markdown: Markdown, token: Token) -> Heading: return cls(token.tag) def on_enter(self, context: MarkdownContext) -> None: self.text = Text() context.enter_style(self.style_name) def __init__(self, tag: str) -> None: self.tag = tag self.style_name = f"markdown.{tag}" super().__init__() def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: text = self.text text.justify = "left" width = max(1, text.cell_len) if self.tag == "h1": underline = Text("═" * width) underline.stylize("markdown.h1.underline") yield text yield underline else: yield text class CodeBlock(TextElement): """A code block with syntax highlighting.""" style_name = "markdown.code_block" @classmethod def create(cls, markdown: Markdown, token: Token) -> CodeBlock: node_info = token.info or "" lexer_name = node_info.partition(" ")[0] return cls(lexer_name or "text", markdown.code_theme) def __init__(self, lexer_name: str, theme: str | SyntaxTheme) -> None: self.lexer_name = lexer_name self.theme = theme def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: code = str(self.text).rstrip() syntax = Syntax( code, self.lexer_name, theme=self.theme, word_wrap=True, background_color=None, padding=0, ) highlighted = syntax.highlight(code) highlighted.rstrip() stripped = _strip_background(highlighted) stripped.rstrip() yield stripped class BlockQuote(TextElement): """A block quote.""" style_name = "markdown.block_quote" def __init__(self) -> None: self.elements: Renderables = Renderables() def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool: self.elements.append(child) return False def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: render_options = options.update(width=options.max_width - 4) style = self.style.without_color lines = console.render_lines(self.elements, render_options, style=style) new_line = Segment("\n") padding = Segment("▌ ", style) for line in lines: yield padding yield from line yield new_line class HorizontalRule(MarkdownElement): """A horizontal rule to divide sections.""" new_line = False def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: style = _FALLBACK_STYLES["markdown.hr"].copy() yield Rule(style=style) class TableElement(MarkdownElement): """MarkdownElement corresponding to `table_open`.""" def __init__(self) -> None: self.header: TableHeaderElement | None = None self.body: TableBodyElement | None = None def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool: if isinstance(child, TableHeaderElement): self.header = child elif isinstance(child, TableBodyElement): self.body = child else: raise RuntimeError("Couldn't process markdown table.") return False def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: table = Table(box=box.SIMPLE_HEAVY, show_edge=False) if self.header is not None and self.header.row is not None: for column in self.header.row.cells: table.add_column(column.content) if self.body is not None: for row in self.body.rows: row_content = [element.content for element in row.cells] table.add_row(*row_content) yield table class TableHeaderElement(MarkdownElement): """MarkdownElement corresponding to `thead_open` and `thead_close`.""" def __init__(self) -> None: self.row: TableRowElement | None = None def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool: assert isinstance(child, TableRowElement) self.row = child return False class TableBodyElement(MarkdownElement): """MarkdownElement corresponding to `tbody_open` and `tbody_close`.""" def __init__(self) -> None: self.rows: list[TableRowElement] = [] def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool: assert isinstance(child, TableRowElement) self.rows.append(child) return False class TableRowElement(MarkdownElement): """MarkdownElement corresponding to `tr_open` and `tr_close`.""" def __init__(self) -> None: self.cells: list[TableDataElement] = [] def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool: assert isinstance(child, TableDataElement) self.cells.append(child) return False class TableDataElement(MarkdownElement): """MarkdownElement corresponding to `td_open` and `td_close` and `th_open` and `th_close`.""" @classmethod def create(cls, markdown: Markdown, token: Token) -> MarkdownElement: style = str(token.attrs.get("style")) or "" justify: JustifyMethod if "text-align:right" in style: justify = "right" elif "text-align:center" in style: justify = "center" elif "text-align:left" in style: justify = "left" else: justify = "default" assert justify in get_args(JustifyMethod) return cls(justify=justify) def __init__(self, justify: JustifyMethod) -> None: self.content: Text = Text("", justify=justify) self.justify = justify def on_text(self, context: MarkdownContext, text: TextType) -> None: text = Text(text) if isinstance(text, str) else text text.stylize(context.current_style) self.content.append_text(text) class ListElement(MarkdownElement): """A list element.""" @classmethod def create(cls, markdown: Markdown, token: Token) -> ListElement: return cls(token.type, int(token.attrs.get("start", 1))) def __init__(self, list_type: str, list_start: int | None) -> None: self.items: list[ListItem] = [] self.list_type = list_type self.list_start = list_start def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool: assert isinstance(child, ListItem) self.items.append(child) return False def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: if self.list_type == "bullet_list_open": for item in self.items: yield from item.render_bullet(console, options) else: number = 1 if self.list_start is None else self.list_start last_number = number + len(self.items) for index, item in enumerate(self.items): yield from item.render_number(console, options, number + index, last_number) class ListItem(TextElement): """An item in a list.""" style_name = "markdown.item" @staticmethod def _line_starts_with_list_marker(text: str) -> bool: stripped = text.lstrip() if not stripped: return False if stripped.startswith(("• ", "- ", "* ")): return True index = 0 while index < len(stripped) and stripped[index].isdigit(): index += 1 if index == 0 or index >= len(stripped): return False marker = stripped[index] has_space = index + 1 < len(stripped) and stripped[index + 1] == " " return marker in {".", ")"} and has_space @classmethod def create(cls, markdown: Markdown, token: Token) -> MarkdownElement: # `list_item_open` levels grow by 2 for each nested list depth. depth = max(0, (token.level - 1) // 2) return cls(indent=depth) def __init__(self, indent: int = 0) -> None: self.indent = indent self.elements: Renderables = Renderables() def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool: self.elements.append(child) return False def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult: lines = console.render_lines(self.elements, options, style=self.style) indent_padding_len = LIST_INDENT_WIDTH * self.indent indent_text = " " * indent_padding_len bullet = Segment("• ") new_line = Segment("\n") bullet_width = len(bullet.text) for first, line in loop_first(lines): if first: if indent_text: yield Segment(indent_text) yield bullet else: plain = "".join(segment.text for segment in line) if self._line_starts_with_list_marker(plain): prefix = "" else: existing = len(plain) - len(plain.lstrip(" ")) target = indent_padding_len + bullet_width missing = max(0, target - existing) prefix = " " * missing if prefix: yield Segment(prefix) yield from line yield new_line def render_number( self, console: Console, options: ConsoleOptions, number: int, last_number: int ) -> RenderResult: lines = console.render_lines(self.elements, options, style=self.style) new_line = Segment("\n") indent_padding_len = LIST_INDENT_WIDTH * self.indent indent_text = " " * indent_padding_len numeral_text = f"{number}. " numeral = Segment(numeral_text) numeral_width = len(numeral_text) for first, line in loop_first(lines): if first: if indent_text: yield Segment(indent_text) yield numeral else: plain = "".join(segment.text for segment in line) if self._line_starts_with_list_marker(plain): prefix = "" else: existing = len(plain) - len(plain.lstrip(" ")) target = indent_padding_len + numeral_width missing = max(0, target - existing) prefix = " " * missing if prefix: yield Segment(prefix) yield from line yield new_line class Link(TextElement): @classmethod def create(cls, markdown: Markdown, token: Token) -> MarkdownElement: url = token.attrs.get("href", "#") return cls(token.content, str(url)) def __init__(self, text: str, href: str): self.text = Text(text) self.href = href class ImageItem(TextElement): """Renders a placeholder for an image.""" new_line = False @classmethod def create(cls, markdown: Markdown, token: Token) -> MarkdownElement: """Factory to create markdown element, Args: markdown (Markdown): The parent Markdown object. token (Any): A token from markdown-it. Returns: MarkdownElement: A new markdown element """ return cls(str(token.attrs.get("src", "")), markdown.hyperlinks) def __init__(self, destination: str, hyperlinks: bool) -> None: self.destination = destination self.hyperlinks = hyperlinks self.link: str | None = None super().__init__() def on_enter(self, context: MarkdownContext) -> None: self.link = context.current_style.link self.text = Text(justify="left") super().on_enter(context) def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: link_style = Style(link=self.link or self.destination or None) title = self.text or Text(self.destination.strip("/").rsplit("/", 1)[-1]) if self.hyperlinks: title.stylize(link_style) text = Text.assemble("🌆 ", title, " ", end="") yield text class MarkdownContext: """Manages the console render state.""" def __init__( self, console: Console, options: ConsoleOptions, style: Style, fallback_styles: Mapping[str, Style], inline_code_lexer: str | None = None, inline_code_theme: str | SyntaxTheme = KIMI_ANSI_THEME_NAME, ) -> None: self.console = console self.options = options self.style_stack: StyleStack = StyleStack(style) self.stack: Stack[MarkdownElement] = Stack() self._fallback_styles = fallback_styles self._syntax: Syntax | None = None if inline_code_lexer is not None: self._syntax = Syntax("", inline_code_lexer, theme=inline_code_theme) @property def current_style(self) -> Style: """Current style which is the product of all styles on the stack.""" return self.style_stack.current def on_text(self, text: str, node_type: str) -> None: """Called when the parser visits text.""" if node_type in {"fence", "code_inline"} and self._syntax is not None: highlighted = self._syntax.highlight(text) highlighted.rstrip() stripped = _strip_background(highlighted) combined = Text.assemble(stripped, style=self.style_stack.current) self.stack.top.on_text(self, combined) else: self.stack.top.on_text(self, text) def enter_style(self, style_name: str | Style) -> Style: """Enter a style context.""" if isinstance(style_name, Style): style = style_name else: fallback = self._fallback_styles.get(style_name, Style()) style = self.console.get_style(style_name, default=fallback) style = fallback + style style = style.copy() if isinstance(style_name, str) and style_name == "markdown.block_quote": style = style.without_color if ( isinstance(style_name, str) and style_name in {"markdown.code", "markdown.code_block"} and style._bgcolor is not None ): style._bgcolor = None self.style_stack.push(style) return self.current_style def leave_style(self) -> Style: """Leave a style context.""" style = self.style_stack.pop() return style class Markdown(JupyterMixin): """A Markdown renderable. Args: markup (str): A string containing markdown. code_theme (str, optional): Pygments theme for code blocks. Defaults to "kimi-ansi". See https://pygments.org/styles/ for code themes. justify (JustifyMethod, optional): Justify value for paragraphs. Defaults to None. style (Union[str, Style], optional): Optional style to apply to markdown. hyperlinks (bool, optional): Enable hyperlinks. Defaults to ``True``. inline_code_lexer: (str, optional): Lexer to use if inline code highlighting is enabled. Defaults to None. inline_code_theme: (Optional[str], optional): Pygments theme for inline code highlighting, or None for no highlighting. Defaults to None. """ elements: ClassVar[dict[str, type[MarkdownElement]]] = { "paragraph_open": Paragraph, "heading_open": Heading, "fence": CodeBlock, "code_block": CodeBlock, "blockquote_open": BlockQuote, "hr": HorizontalRule, "bullet_list_open": ListElement, "ordered_list_open": ListElement, "list_item_open": ListItem, "image": ImageItem, "table_open": TableElement, "tbody_open": TableBodyElement, "thead_open": TableHeaderElement, "tr_open": TableRowElement, "td_open": TableDataElement, "th_open": TableDataElement, } inlines = {"em", "strong", "code", "s"} def __init__( self, markup: str, code_theme: str = KIMI_ANSI_THEME_NAME, justify: JustifyMethod | None = None, style: str | Style = "none", hyperlinks: bool = True, inline_code_lexer: str | None = None, inline_code_theme: str | None = None, ) -> None: parser = MarkdownIt().enable("strikethrough").enable("table") self.markup = markup self.parsed = parser.parse(markup) self.code_theme = resolve_code_theme(code_theme) self.justify: JustifyMethod | None = justify self.style = style self.hyperlinks = hyperlinks self.inline_code_lexer = inline_code_lexer self.inline_code_theme = resolve_code_theme(inline_code_theme or code_theme) def _flatten_tokens(self, tokens: Iterable[Token]) -> Iterable[Token]: """Flattens the token stream.""" for token in tokens: is_fence = token.type == "fence" is_image = token.tag == "img" if token.children and not (is_image or is_fence): yield from self._flatten_tokens(token.children) else: yield token def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: """Render markdown to the console.""" style = console.get_style(self.style, default="none") options = options.update(height=None) context = MarkdownContext( console, options, style, _FALLBACK_STYLES, inline_code_lexer=self.inline_code_lexer, inline_code_theme=self.inline_code_theme, ) tokens = self.parsed inline_style_tags = self.inlines new_line = False _new_line_segment = Segment.line() render_started = False for token in self._flatten_tokens(tokens): node_type = token.type tag = token.tag entering = token.nesting == 1 exiting = token.nesting == -1 self_closing = token.nesting == 0 if node_type in {"text", "html_inline", "html_block"}: # Render HTML tokens as plain text so safeword markup stays visible. if context.stack: context.on_text(token.content, node_type) else: # Orphan text/html blocks can appear outside any element (e.g. ). paragraph = Paragraph(justify=self.justify or "left") paragraph.on_enter(context) paragraph.on_text(context, token.content) paragraph.on_leave(context) if new_line and render_started: yield _new_line_segment rendered = console.render(paragraph, context.options) for segment in rendered: render_started = True yield segment new_line = paragraph.new_line elif node_type == "hardbreak": context.on_text("\n", node_type) elif node_type == "softbreak": context.on_text(" ", node_type) elif node_type == "link_open": href = str(token.attrs.get("href", "")) if self.hyperlinks: link_style = console.get_style("markdown.link_url", default="none") link_style += Style(link=href) context.enter_style(link_style) else: context.stack.push(Link.create(self, token)) elif node_type == "link_close": if self.hyperlinks: context.leave_style() else: element = context.stack.pop() assert isinstance(element, Link) link_style = console.get_style("markdown.link", default="none") context.enter_style(link_style) context.on_text(element.text.plain, node_type) context.leave_style() context.on_text(" (", node_type) link_url_style = console.get_style("markdown.link_url", default="none") context.enter_style(link_url_style) context.on_text(element.href, node_type) context.leave_style() context.on_text(")", node_type) elif tag in inline_style_tags and node_type != "fence" and node_type != "code_block": if entering: # If it's an opening inline token e.g. strong, em, etc. # Then we move into a style context i.e. push to stack. context.enter_style(f"markdown.{tag}") elif exiting: # If it's a closing inline style, then we pop the style # off of the stack, to move out of the context of it... context.leave_style() else: # If it's a self-closing inline style e.g. `code_inline` context.enter_style(f"markdown.{tag}") if token.content: context.on_text(token.content, node_type) context.leave_style() else: # Map the markdown tag -> MarkdownElement renderable element_class = self.elements.get(token.type) or UnknownElement element = element_class.create(self, token) if entering or self_closing: context.stack.push(element) element.on_enter(context) if exiting: # CLOSING tag element = context.stack.pop() should_render = not context.stack or ( context.stack and context.stack.top.on_child_close(context, element) ) if should_render: if new_line and render_started: yield _new_line_segment rendered = console.render(element, context.options) for segment in rendered: render_started = True yield segment elif self_closing: # SELF-CLOSING tags (e.g. text, code, image) context.stack.pop() text = token.content if text is not None: element.on_text(context, text) should_render = ( not context.stack or context.stack and context.stack.top.on_child_close(context, element) ) if should_render: if new_line and node_type != "inline" and render_started: yield _new_line_segment rendered = console.render(element, context.options) for segment in rendered: render_started = True yield segment if exiting or self_closing: element.on_leave(context) new_line = element.new_line if __name__ == "__main__": import argparse import sys parser = argparse.ArgumentParser(description="Render Markdown to the console with Rich") parser.add_argument( "path", metavar="PATH", help="path to markdown file, or - for stdin", ) parser.add_argument( "-c", "--force-color", dest="force_color", action="store_true", default=None, help="force color for non-terminals", ) parser.add_argument( "-t", "--code-theme", dest="code_theme", default=KIMI_ANSI_THEME_NAME, help='code theme (pygments name or "kimi-ansi")', ) parser.add_argument( "-i", "--inline-code-lexer", dest="inline_code_lexer", default=None, help="inline_code_lexer", ) parser.add_argument( "-y", "--hyperlinks", dest="hyperlinks", action="store_true", help="enable hyperlinks", ) parser.add_argument( "-w", "--width", type=int, dest="width", default=None, help="width of output (default will auto-detect)", ) parser.add_argument( "-j", "--justify", dest="justify", action="store_true", help="enable full text justify", ) parser.add_argument( "-p", "--page", dest="page", action="store_true", help="use pager to scroll output", ) args = parser.parse_args() from rich.console import Console if args.path == "-": markdown_body = sys.stdin.read() else: with open(args.path, encoding="utf-8") as markdown_file: markdown_body = markdown_file.read() markdown = Markdown( markdown_body, justify="full" if args.justify else "left", code_theme=args.code_theme, hyperlinks=args.hyperlinks, inline_code_lexer=args.inline_code_lexer, ) if args.page: import io import pydoc fileio = io.StringIO() console = Console(file=fileio, force_terminal=args.force_color, width=args.width) console.print(markdown) pydoc.pager(fileio.getvalue()) else: console = Console(force_terminal=args.force_color, width=args.width, record=True) console.print(markdown) ================================================ FILE: src/kimi_cli/utils/rich/markdown_sample.md ================================================ # Markdown Sample Document This is a comprehensive sample document showcasing various Markdown elements. ## Level 2 Heading ### Level 3 Heading Here's some regular text with **bold text**, *italic text*, and `inline code`. ## Lists ### Unordered List - First item - Second item - Nested item 1 - Nested item 2 - Third item ### Ordered List 1. First step 2. Second step 1. Sub-step A 2. Sub-step B 3. Third step ### Mixed List 1. First item - Sub-item with bullet - Another sub-item 2. Second item 1. Numbered sub-item 2. Another numbered sub-item ## Links and References Here's a [link to GitHub](https://github.com) and another [relative link](../README.md). ## Code Blocks ```python def hello_world(): """A simple function to demonstrate code blocks.""" print("Hello, World!") return 42 # Call the function result = hello_world() ``` ```bash # Bash example echo "This is a bash script" ls -la /tmp ``` ## Blockquotes > This is a blockquote. > It can span multiple lines. > > > And it can be nested too! ## Tables | Column 1 | Column 2 | Column 3 | |----------|----------|----------| | Cell 1 | Cell 2 | Cell 3 | | Left | Center | Right | | Foo | Bar | Baz | ## Horizontal Rules --- Here's some text after a horizontal rule. --- ## Inline Formatting You can combine **bold and *italic*** text, or use `code` within paragraphs. **Important**: Always test your `code` snippets before deployment. ## Advanced Features ### Task Lists - [x] Completed task - [ ] Pending task - [ ] Another pending task ### Definition Lists Term 1 : Definition of term 1 Term 2 : Definition of term 2 : Another definition for term 2 --- *This document demonstrates comprehensive Markdown formatting capabilities.* ================================================ FILE: src/kimi_cli/utils/rich/markdown_sample_short.md ================================================ - First - Second ================================================ FILE: src/kimi_cli/utils/rich/syntax.py ================================================ from __future__ import annotations from typing import Any from pygments.token import ( Comment, Generic, Keyword, Name, Number, Operator, Punctuation, String, ) from pygments.token import ( Literal as PygmentsLiteral, ) from pygments.token import ( Text as PygmentsText, ) from pygments.token import ( Token as PygmentsToken, ) from rich.style import Style from rich.syntax import ANSISyntaxTheme, Syntax, SyntaxTheme KIMI_ANSI_THEME_NAME = "kimi-ansi" KIMI_ANSI_THEME = ANSISyntaxTheme( { PygmentsToken: Style(color="default"), PygmentsText: Style(color="default"), Comment: Style(color="bright_black", italic=True), Keyword: Style(color="bright_magenta", bold=True), Keyword.Constant: Style(color="bright_magenta", bold=True), Keyword.Declaration: Style(color="bright_magenta", bold=True), Keyword.Namespace: Style(color="bright_magenta", bold=True), Keyword.Pseudo: Style(color="bright_magenta"), Keyword.Reserved: Style(color="bright_magenta", bold=True), Keyword.Type: Style(color="bright_magenta", bold=True), Name: Style(color="default"), Name.Attribute: Style(color="cyan"), Name.Builtin: Style(color="bright_cyan"), Name.Builtin.Pseudo: Style(color="bright_magenta"), Name.Builtin.Type: Style(color="bright_cyan", bold=True), Name.Class: Style(color="bright_cyan", bold=True), Name.Constant: Style(color="bright_magenta"), Name.Decorator: Style(color="bright_magenta"), Name.Entity: Style(color="bright_cyan"), Name.Exception: Style(color="bright_magenta", bold=True), Name.Function: Style(color="bright_blue"), Name.Label: Style(color="bright_cyan"), Name.Namespace: Style(color="bright_cyan"), Name.Other: Style(color="bright_blue"), Name.Property: Style(color="bright_blue"), Name.Tag: Style(color="bright_blue"), Name.Variable: Style(color="bright_blue"), PygmentsLiteral: Style(color="bright_green"), PygmentsLiteral.Date: Style(color="green"), String: Style(color="yellow"), String.Doc: Style(color="yellow", italic=True), String.Interpol: Style(color="yellow"), String.Affix: Style(color="yellow"), Number: Style(color="bright_green"), Operator: Style(color="default"), Punctuation: Style(color="default"), Generic.Deleted: Style(color="red"), Generic.Emph: Style(italic=True), Generic.Error: Style(color="bright_red", bold=True), Generic.Heading: Style(color="bright_cyan", bold=True), Generic.Inserted: Style(color="green"), Generic.Output: Style(color="bright_black"), Generic.Prompt: Style(color="bright_magenta"), Generic.Strong: Style(bold=True), Generic.Subheading: Style(color="bright_cyan"), Generic.Traceback: Style(color="bright_red", bold=True), } ) def resolve_code_theme(theme: str | SyntaxTheme) -> str | SyntaxTheme: if isinstance(theme, str) and theme.lower() == KIMI_ANSI_THEME_NAME: return KIMI_ANSI_THEME return theme class KimiSyntax(Syntax): def __init__(self, code: str, lexer: str, **kwargs: Any) -> None: if "theme" not in kwargs or kwargs["theme"] is None: kwargs["theme"] = KIMI_ANSI_THEME super().__init__(code, lexer, **kwargs) if __name__ == "__main__": from rich.console import Console from rich.text import Text console = Console() examples = [ ("diff", "diff", "@@ -1,2 +1,2 @@\n-line one\n+line uno\n"), ( "python", "python", 'def greet(name: str) -> str:\n return f"Hi, {name}!"\n', ), ("bash", "bash", "set -euo pipefail\nprintf '%s\\n' \"hello\"\n"), ] for idx, (title, lexer, code) in enumerate(examples): if idx: console.print() console.print(Text(f"[{title}]", style="bold")) console.print(KimiSyntax(code, lexer)) ================================================ FILE: src/kimi_cli/utils/signals.py ================================================ from __future__ import annotations import asyncio import contextlib import signal from collections.abc import Callable def install_sigint_handler( loop: asyncio.AbstractEventLoop, handler: Callable[[], None] ) -> Callable[[], None]: """ Install a SIGINT handler that works on Unix and Windows. On Unix event loops, prefer `loop.add_signal_handler`. On Windows (or other platforms) where it is not implemented, fall back to `signal.signal`. The fallback cannot be removed from the loop, but we restore the previous handler on uninstall. Returns: A function that removes the installed handler. It is guaranteed that no exceptions are raised when calling the returned function. """ try: loop.add_signal_handler(signal.SIGINT, handler) def remove() -> None: with contextlib.suppress(RuntimeError): loop.remove_signal_handler(signal.SIGINT) return remove except RuntimeError: # Windows ProactorEventLoop and some environments do not support # add_signal_handler. Use synchronous signal handling as a fallback. previous = signal.getsignal(signal.SIGINT) signal.signal(signal.SIGINT, lambda signum, frame: handler()) def remove() -> None: with contextlib.suppress(RuntimeError): signal.signal(signal.SIGINT, previous) return remove ================================================ FILE: src/kimi_cli/utils/slashcmd.py ================================================ import re from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass from typing import overload @dataclass(frozen=True, slots=True, kw_only=True) class SlashCommand[F: Callable[..., None | Awaitable[None]]]: name: str description: str func: F aliases: list[str] def slash_name(self): """/name (aliases)""" if self.aliases: return f"/{self.name} ({', '.join(self.aliases)})" return f"/{self.name}" class SlashCommandRegistry[F: Callable[..., None | Awaitable[None]]]: """Registry for slash commands.""" def __init__(self) -> None: self._commands: dict[str, SlashCommand[F]] = {} """Primary name -> SlashCommand""" self._command_aliases: dict[str, SlashCommand[F]] = {} """Primary name or alias -> SlashCommand""" @overload def command(self, func: F, /) -> F: ... @overload def command( self, *, name: str | None = None, aliases: Sequence[str] | None = None, ) -> Callable[[F], F]: ... def command( self, func: F | None = None, *, name: str | None = None, aliases: Sequence[str] | None = None, ) -> F | Callable[[F], F]: """ Decorator to register a slash command with optional custom name and aliases. Usage examples: @registry.command def help(app: App, args: str): ... @registry.command(name="run") def start(app: App, args: str): ... @registry.command(aliases=["h", "?", "assist"]) def help(app: App, args: str): ... """ def _register(f: F) -> F: primary = name or f.__name__ alias_list = list(aliases) if aliases else [] # Create the primary command with aliases cmd = SlashCommand[F]( name=primary, description=(f.__doc__ or "").strip(), func=f, aliases=alias_list, ) # Register primary command self._commands[primary] = cmd self._command_aliases[primary] = cmd # Register aliases pointing to the same command for alias in alias_list: self._command_aliases[alias] = cmd return f if func is not None: return _register(func) return _register def find_command(self, name: str) -> SlashCommand[F] | None: return self._command_aliases.get(name) def list_commands(self) -> list[SlashCommand[F]]: """Get all unique primary slash commands (without duplicating aliases).""" return list(self._commands.values()) @dataclass(frozen=True, slots=True, kw_only=True) class SlashCommandCall: name: str args: str raw_input: str def parse_slash_command_call(user_input: str) -> SlashCommandCall | None: """ Parse a slash command call from user input. Returns: SlashCommandCall if a slash command is found, else None. The `args` field contains the raw argument string after the command name. """ user_input = user_input.strip() if not user_input or not user_input.startswith("/"): return None name_match = re.match(r"^\/([a-zA-Z0-9_-]+(?::[a-zA-Z0-9_-]+)*)", user_input) if not name_match: return None command_name = name_match.group(1) if len(user_input) > name_match.end() and not user_input[name_match.end()].isspace(): return None raw_args = user_input[name_match.end() :].lstrip() return SlashCommandCall(name=command_name, args=raw_args, raw_input=user_input) ================================================ FILE: src/kimi_cli/utils/string.py ================================================ from __future__ import annotations import random import re import string _NEWLINE_RE = re.compile(r"[\r\n]+") def shorten_middle(text: str, width: int, remove_newline: bool = True) -> str: """Shorten the text by inserting ellipsis in the middle.""" if len(text) <= width: return text if remove_newline: text = _NEWLINE_RE.sub(" ", text) return text[: width // 2] + "..." + text[-width // 2 :] def random_string(length: int = 8) -> str: """Generate a random string of fixed length.""" letters = string.ascii_lowercase return "".join(random.choice(letters) for _ in range(length)) ================================================ FILE: src/kimi_cli/utils/subprocess_env.py ================================================ """Utilities for subprocess environment handling. This module provides utilities to handle environment variables when spawning subprocesses from a PyInstaller-frozen application. The main issue is that PyInstaller's bootloader modifies LD_LIBRARY_PATH to prioritize bundled libraries, which can cause conflicts when spawning external programs that expect system libraries. See: https://pyinstaller.org/en/stable/common-issues-and-pitfalls.html """ from __future__ import annotations import os import sys # Environment variables that PyInstaller may modify on Linux _PYINSTALLER_LD_VARS = [ "LD_LIBRARY_PATH", "LD_PRELOAD", ] def get_clean_env(base_env: dict[str, str] | None = None) -> dict[str, str]: """ Get a clean environment suitable for spawning subprocesses. In a PyInstaller-frozen application on Linux, this function restores the original library path environment variables, preventing subprocesses from loading incompatible bundled libraries. Args: base_env: Base environment to start from. If None, uses os.environ. Returns: A dictionary of environment variables safe for subprocess use. """ env = dict(base_env if base_env is not None else os.environ) # Only process in PyInstaller frozen environment on Linux if not getattr(sys, "frozen", False) or sys.platform != "linux": return env for var in _PYINSTALLER_LD_VARS: orig_key = f"{var}_ORIG" if orig_key in env: # Restore the original value that was saved by PyInstaller bootloader env[var] = env[orig_key] elif var in env: # Variable was not set before PyInstaller modified it, so remove it del env[var] return env ================================================ FILE: src/kimi_cli/utils/term.py ================================================ from __future__ import annotations import contextlib import os import re import sys import time def ensure_new_line() -> None: """Ensure the next prompt starts at column 0 regardless of prior command output.""" if not sys.stdout.isatty() or not sys.stdin.isatty(): return needs_break = True if sys.platform == "win32": column = _cursor_column_windows() needs_break = column not in (None, 0) else: column = _cursor_column_unix() needs_break = column not in (None, 1) if needs_break: _write_newline() def ensure_tty_sane() -> None: """Restore basic tty settings so Ctrl-C works after raw-mode operations.""" if sys.platform == "win32" or not sys.stdin.isatty(): return try: import termios except Exception: return try: fd = sys.stdin.fileno() attrs = termios.tcgetattr(fd) except Exception: return desired = termios.ISIG | termios.IEXTEN | termios.ICANON | termios.ECHO if (attrs[3] & desired) == desired: return attrs[3] |= desired with contextlib.suppress(OSError): termios.tcsetattr(fd, termios.TCSADRAIN, attrs) def _cursor_position_unix() -> tuple[int, int] | None: """Get cursor position (row, column) on Unix. Both are 1-indexed.""" assert sys.platform != "win32" import select import termios import tty _CURSOR_QUERY = "\x1b[6n" _CURSOR_POSITION_RE = re.compile(r"\x1b\[(\d+);(\d+)R") fd = sys.stdin.fileno() oldterm = termios.tcgetattr(fd) try: tty.setcbreak(fd) sys.stdout.write(_CURSOR_QUERY) sys.stdout.flush() response = "" deadline = time.monotonic() + 0.2 while time.monotonic() < deadline: timeout = max(0.01, deadline - time.monotonic()) ready, _, _ = select.select([sys.stdin], [], [], timeout) if not ready: continue try: chunk = os.read(fd, 32) except OSError: break if not chunk: break response += chunk.decode(encoding="utf-8", errors="ignore") match = _CURSOR_POSITION_RE.search(response) if match: return int(match.group(1)), int(match.group(2)) finally: termios.tcsetattr(fd, termios.TCSADRAIN, oldterm) return None def _cursor_column_unix() -> int | None: pos = _cursor_position_unix() return pos[1] if pos else None def _cursor_position_windows() -> tuple[int, int] | None: """Get cursor position (row, column) on Windows. Both are 1-indexed.""" assert sys.platform == "win32" import ctypes from ctypes import wintypes kernel32 = ctypes.windll.kernel32 _STD_OUTPUT_HANDLE = -11 # Windows API constant for standard output handle handle = kernel32.GetStdHandle(_STD_OUTPUT_HANDLE) invalid_handle_value = ctypes.c_void_p(-1).value if handle in (0, invalid_handle_value): return None class COORD(ctypes.Structure): _fields_ = [("X", wintypes.SHORT), ("Y", wintypes.SHORT)] class SMALL_RECT(ctypes.Structure): _fields_ = [ ("Left", wintypes.SHORT), ("Top", wintypes.SHORT), ("Right", wintypes.SHORT), ("Bottom", wintypes.SHORT), ] class CONSOLE_SCREEN_BUFFER_INFO(ctypes.Structure): _fields_ = [ ("dwSize", COORD), ("dwCursorPosition", COORD), ("wAttributes", wintypes.WORD), ("srWindow", SMALL_RECT), ("dwMaximumWindowSize", COORD), ] csbi = CONSOLE_SCREEN_BUFFER_INFO() if not kernel32.GetConsoleScreenBufferInfo(handle, ctypes.byref(csbi)): return None # Windows returns 0-indexed, convert to 1-indexed for consistency return int(csbi.dwCursorPosition.Y) + 1, int(csbi.dwCursorPosition.X) + 1 def _cursor_column_windows() -> int | None: pos = _cursor_position_windows() return pos[1] if pos else None def _write_newline() -> None: sys.stdout.write("\n") sys.stdout.flush() def get_cursor_row() -> int | None: """Get the current cursor row (1-indexed).""" if not sys.stdout.isatty() or not sys.stdin.isatty(): return None if sys.platform == "win32": pos = _cursor_position_windows() else: pos = _cursor_position_unix() return pos[0] if pos else None if __name__ == "__main__": print("test", end="", flush=True) ensure_new_line() print("next line") ================================================ FILE: src/kimi_cli/utils/typing.py ================================================ from types import UnionType from typing import Any, TypeAliasType, Union, get_args, get_origin def flatten_union(tp: Any) -> tuple[Any, ...]: """ If `tp` is a `UnionType`, return its flattened arguments as a tuple. Otherwise, return a tuple with `tp` as the only element. """ if isinstance(tp, TypeAliasType): tp = tp.__value__ origin = get_origin(tp) if origin in (UnionType, Union): args = get_args(tp) flattened_args: list[Any] = [] for arg in args: flattened_args.extend(flatten_union(arg)) return tuple(flattened_args) else: return (tp,) ================================================ FILE: src/kimi_cli/vis/__init__.py ================================================ ================================================ FILE: src/kimi_cli/vis/api/__init__.py ================================================ from kimi_cli.vis.api.sessions import router as sessions_router from kimi_cli.vis.api.statistics import router as statistics_router from kimi_cli.vis.api.system import router as system_router __all__ = ["sessions_router", "statistics_router", "system_router"] ================================================ FILE: src/kimi_cli/vis/api/sessions.py ================================================ """Vis API for reading session tracing data.""" from __future__ import annotations import contextlib import io import json import logging import re import shutil import zipfile from pathlib import Path from typing import Any from uuid import uuid4 import aiofiles from fastapi import APIRouter, HTTPException, UploadFile from fastapi.responses import StreamingResponse from kimi_cli.metadata import load_metadata from kimi_cli.share import get_share_dir from kimi_cli.wire.file import WireFileMetadata, parse_wire_file_line router = APIRouter(prefix="/api/vis", tags=["vis"]) logger = logging.getLogger(__name__) def collect_events( msg_type: str, payload: dict[str, Any], out: list[tuple[str, dict[str, Any]]], ) -> None: """Recursively unwrap SubagentEvent and collect (type, payload) pairs.""" if msg_type == "SubagentEvent": inner: dict[str, Any] | None = payload.get("event") if isinstance(inner, dict): inner_type: str = inner.get("type", "") inner_payload: dict[str, Any] = inner.get("payload", {}) if inner_type: collect_events(inner_type, inner_payload, out) else: out.append((msg_type, payload)) _SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9_-]+$") _IMPORTED_HASH = "__imported__" def _get_imported_root() -> Path: """Return the root directory for imported sessions.""" return get_share_dir() / "imported_sessions" def _find_session_dir(work_dir_hash: str, session_id: str) -> Path | None: """Find session directory by work_dir_hash and session_id.""" if not _SESSION_ID_RE.match(session_id): return None if work_dir_hash == _IMPORTED_HASH: session_dir = _get_imported_root() / session_id if session_dir.is_dir(): return session_dir return None if not _SESSION_ID_RE.match(work_dir_hash): return None sessions_root = get_share_dir() / "sessions" session_dir = sessions_root / work_dir_hash / session_id if session_dir.is_dir(): return session_dir return None def get_work_dir_for_hash(hash_dir_name: str) -> str | None: """Look up the work directory path from metadata for a given hash directory name.""" try: metadata = load_metadata() except Exception: return None from hashlib import md5 from kaos.local import local_kaos for wd in metadata.work_dirs: path_md5 = md5(wd.path.encode(encoding="utf-8")).hexdigest() dir_basename = path_md5 if wd.kaos == local_kaos.name else f"{wd.kaos}_{path_md5}" if dir_basename == hash_dir_name: return wd.path return None def _scan_session_dir( session_dir: Path, work_dir_hash: str, work_dir: str | None, *, imported: bool = False, ) -> dict[str, Any] | None: """Extract session info from a session directory.""" if not session_dir.is_dir(): return None wire_path = session_dir / "wire.jsonl" context_path = session_dir / "context.jsonl" state_path = session_dir / "state.json" # Get last updated time from most recent file mtimes: list[float] = [] for p in [wire_path, context_path, state_path]: if p.exists(): mtimes.append(p.stat().st_mtime) # Extract title and count turns from wire.jsonl title = "" turn_count = 0 if wire_path.exists(): try: with wire_path.open(encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: parsed = parse_wire_file_line(line) except Exception: logger.debug("Skipped malformed line in %s", wire_path) continue if isinstance(parsed, WireFileMetadata): continue if parsed.message.type == "TurnBegin": turn_count += 1 if turn_count == 1: user_input = parsed.message.payload.get("user_input", "") if isinstance(user_input, str): title = user_input[:100] elif isinstance(user_input, list) and user_input: first = user_input[0] if isinstance(first, dict): title = str(first.get("text", ""))[:100] except Exception: pass # File sizes (cheap stat calls) wire_size = wire_path.stat().st_size if wire_path.exists() else 0 context_size = context_path.stat().st_size if context_path.exists() else 0 state_size = state_path.stat().st_size if state_path.exists() else 0 # Read metadata.json if it exists metadata_info: dict[str, Any] | None = None metadata_path = session_dir / "metadata.json" if metadata_path.exists(): with contextlib.suppress(Exception): metadata_info = json.loads(metadata_path.read_text(encoding="utf-8")) return { "session_id": session_dir.name, "session_dir": str(session_dir), "work_dir": work_dir, "work_dir_hash": work_dir_hash, "title": title, "last_updated": max(mtimes) if mtimes else 0, "has_wire": wire_path.exists(), "has_context": context_path.exists(), "has_state": state_path.exists(), "metadata": metadata_info, "wire_size": wire_size, "context_size": context_size, "state_size": state_size, "total_size": wire_size + context_size + state_size, "turns": turn_count, "imported": imported, } @router.get("/sessions") def list_sessions() -> list[dict[str, Any]]: """List all available sessions across all work directories.""" results: list[dict[str, Any]] = [] # Scan normal sessions sessions_root = get_share_dir() / "sessions" if sessions_root.exists(): for work_dir_hash_dir in sessions_root.iterdir(): if not work_dir_hash_dir.is_dir(): continue work_dir = get_work_dir_for_hash(work_dir_hash_dir.name) for session_dir in work_dir_hash_dir.iterdir(): info = _scan_session_dir(session_dir, work_dir_hash_dir.name, work_dir) if info: results.append(info) # Scan imported sessions imported_root = _get_imported_root() if imported_root.exists(): for session_dir in imported_root.iterdir(): info = _scan_session_dir( session_dir, _IMPORTED_HASH, None, imported=True, ) if info: results.append(info) results.sort(key=lambda s: s["last_updated"], reverse=True) return results @router.get("/sessions/{work_dir_hash}/{session_id}/wire") async def get_wire_events(work_dir_hash: str, session_id: str) -> dict[str, Any]: """Read and parse wire.jsonl for a session.""" session_dir = _find_session_dir(work_dir_hash, session_id) if session_dir is None: raise HTTPException(status_code=404, detail="Session not found") wire_path = session_dir / "wire.jsonl" if not wire_path.exists(): return {"total": 0, "events": []} events: list[dict[str, Any]] = [] index = 0 async with aiofiles.open(wire_path, encoding="utf-8") as f: async for line in f: line = line.strip() if not line: continue try: parsed = parse_wire_file_line(line) except Exception: logger.debug("Skipped malformed line in %s", wire_path) continue if isinstance(parsed, WireFileMetadata): continue events.append( { "index": index, "timestamp": parsed.timestamp, "type": parsed.message.type, "payload": parsed.message.payload, } ) index += 1 return {"total": len(events), "events": events} @router.get("/sessions/{work_dir_hash}/{session_id}/context") async def get_context_messages(work_dir_hash: str, session_id: str) -> dict[str, Any]: """Read and parse context.jsonl for a session.""" session_dir = _find_session_dir(work_dir_hash, session_id) if session_dir is None: raise HTTPException(status_code=404, detail="Session not found") context_path = session_dir / "context.jsonl" if not context_path.exists(): return {"total": 0, "messages": []} messages: list[dict[str, Any]] = [] index = 0 async with aiofiles.open(context_path, encoding="utf-8") as f: async for line in f: line = line.strip() if not line: continue try: msg = json.loads(line) except json.JSONDecodeError: logger.debug("Skipped malformed line in %s", context_path) continue msg["index"] = index messages.append(msg) index += 1 return {"total": len(messages), "messages": messages} @router.get("/sessions/{work_dir_hash}/{session_id}/state") async def get_session_state(work_dir_hash: str, session_id: str) -> dict[str, Any]: """Read state.json for a session.""" session_dir = _find_session_dir(work_dir_hash, session_id) if session_dir is None: raise HTTPException(status_code=404, detail="Session not found") state_path = session_dir / "state.json" if not state_path.exists(): return {} async with aiofiles.open(state_path, encoding="utf-8") as f: content = await f.read() try: return json.loads(content) except json.JSONDecodeError as err: raise HTTPException(status_code=500, detail="Invalid state.json") from err @router.get("/sessions/{work_dir_hash}/{session_id}/summary") async def get_session_summary(work_dir_hash: str, session_id: str) -> dict[str, Any]: """Compute summary statistics for a session by scanning wire.jsonl.""" session_dir = _find_session_dir(work_dir_hash, session_id) if session_dir is None: raise HTTPException(status_code=404, detail="Session not found") wire_path = session_dir / "wire.jsonl" context_path = session_dir / "context.jsonl" state_path = session_dir / "state.json" wire_size = wire_path.stat().st_size if wire_path.exists() else 0 context_size = context_path.stat().st_size if context_path.exists() else 0 state_size = state_path.stat().st_size if state_path.exists() else 0 zeros: dict[str, Any] = { "turns": 0, "steps": 0, "tool_calls": 0, "errors": 0, "compactions": 0, "duration_sec": 0, "input_tokens": 0, "output_tokens": 0, "wire_size": wire_size, "context_size": context_size, "state_size": state_size, "total_size": wire_size + context_size + state_size, } if not wire_path.exists(): return zeros turns = steps = tool_calls = errors = compactions = 0 input_tokens = output_tokens = 0 first_ts = 0.0 last_ts = 0.0 async with aiofiles.open(wire_path, encoding="utf-8") as f: async for line in f: line = line.strip() if not line: continue try: parsed = parse_wire_file_line(line) except Exception: logger.debug("Skipped malformed line in %s", wire_path) continue if isinstance(parsed, WireFileMetadata): continue ts = parsed.timestamp msg_type = parsed.message.type payload = parsed.message.payload if first_ts == 0: first_ts = ts last_ts = ts # Collect (type, payload) pairs, unwrapping SubagentEvent recursively events_to_process: list[tuple[str, dict[str, Any]]] = [] collect_events(msg_type, payload, events_to_process) for ev_type, ev_payload in events_to_process: if ev_type == "TurnBegin": turns += 1 elif ev_type == "StepBegin": steps += 1 elif ev_type == "ToolCall": tool_calls += 1 elif ev_type == "CompactionBegin": compactions += 1 elif ev_type == "StepInterrupted": errors += 1 elif ev_type == "ToolResult": rv: dict[str, Any] | None = ev_payload.get("return_value") if isinstance(rv, dict) and rv.get("is_error"): errors += 1 elif ev_type == "ApprovalResponse": if ev_payload.get("response") == "reject": errors += 1 elif ev_type == "StatusUpdate": tu: dict[str, Any] | None = ev_payload.get("token_usage") if isinstance(tu, dict): input_tokens += ( int(tu.get("input_other", 0)) + int(tu.get("input_cache_read", 0)) + int(tu.get("input_cache_creation", 0)) ) output_tokens += int(tu.get("output", 0)) return { "turns": turns, "steps": steps, "tool_calls": tool_calls, "errors": errors, "compactions": compactions, "duration_sec": last_ts - first_ts if last_ts > first_ts else 0, "input_tokens": input_tokens, "output_tokens": output_tokens, "wire_size": wire_size, "context_size": context_size, "state_size": state_size, "total_size": wire_size + context_size + state_size, } @router.get("/sessions/{work_dir_hash}/{session_id}/download") def download_session(work_dir_hash: str, session_id: str) -> StreamingResponse: """Download all files in a session directory as a ZIP archive.""" session_dir = _find_session_dir(work_dir_hash, session_id) if session_dir is None: raise HTTPException(status_code=404, detail="Session not found") buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: for file_path in sorted(session_dir.iterdir()): if file_path.is_file(): zf.write(file_path, arcname=file_path.name) buf.seek(0) filename = f"session-{session_id}.zip" return StreamingResponse( buf, media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @router.post("/sessions/import") async def import_session(file: UploadFile) -> dict[str, Any]: """Import a session from an uploaded ZIP archive.""" if not file.filename or not file.filename.endswith(".zip"): raise HTTPException(status_code=400, detail="Only .zip files are accepted") content = await file.read() if not content: raise HTTPException(status_code=400, detail="Empty file") # Reject uploads larger than 200 MB _MAX_UPLOAD_BYTES = 200 * 1024 * 1024 if len(content) > _MAX_UPLOAD_BYTES: raise HTTPException(status_code=413, detail="File too large (max 200 MB)") # Validate ZIP buf = io.BytesIO(content) try: zf = zipfile.ZipFile(buf, "r") except zipfile.BadZipFile as err: raise HTTPException(status_code=400, detail="Invalid ZIP file") from err with zf: names = zf.namelist() # Must contain wire.jsonl or context.jsonl at root or under exactly one directory _VALID_FILES = ("wire.jsonl", "context.jsonl") has_valid = any( n in _VALID_FILES or (n.count("/") == 1 and n.endswith(_VALID_FILES)) for n in names ) if not has_valid: raise HTTPException( status_code=400, detail="ZIP must contain wire.jsonl or context.jsonl at the top level " "(or inside a single directory)", ) session_id = uuid4().hex[:16] imported_root = _get_imported_root() session_dir = imported_root / session_id session_dir.mkdir(parents=True, exist_ok=True) # Zip Slip protection: reject entries with path traversal or absolute paths for info in zf.infolist(): if info.filename.startswith("/") or ".." in info.filename.split("/"): shutil.rmtree(session_dir, ignore_errors=True) raise HTTPException( status_code=400, detail="ZIP contains unsafe path entries", ) # Extract - handle both flat ZIPs and ZIPs with a single top-level directory zf.extractall(session_dir) # If all files are under a single subdirectory, flatten them entries = list(session_dir.iterdir()) if len(entries) == 1 and entries[0].is_dir(): nested_dir = entries[0] for item in nested_dir.iterdir(): shutil.move(str(item), str(session_dir / item.name)) nested_dir.rmdir() return { "session_id": session_id, "work_dir_hash": _IMPORTED_HASH, } @router.delete("/sessions/{work_dir_hash}/{session_id}") def delete_session(work_dir_hash: str, session_id: str) -> dict[str, str]: """Delete an imported session.""" if work_dir_hash != _IMPORTED_HASH: raise HTTPException(status_code=403, detail="Only imported sessions can be deleted") if not _SESSION_ID_RE.match(session_id): raise HTTPException(status_code=400, detail="Invalid session ID") session_dir = _get_imported_root() / session_id if not session_dir.is_dir(): raise HTTPException(status_code=404, detail="Session not found") shutil.rmtree(session_dir) return {"status": "deleted"} ================================================ FILE: src/kimi_cli/vis/api/statistics.py ================================================ """Vis API for aggregate statistics across all sessions.""" from __future__ import annotations import time from collections import defaultdict from datetime import UTC, datetime, timedelta from typing import Any from fastapi import APIRouter from kimi_cli.share import get_share_dir from kimi_cli.vis.api.sessions import collect_events, get_work_dir_for_hash from kimi_cli.wire.file import WireFileMetadata, parse_wire_file_line router = APIRouter(prefix="/api/vis", tags=["vis"]) # Simple in-memory cache: (result, timestamp) _cache: dict[str, tuple[dict[str, Any], float]] = {} _CACHE_TTL = 60 # seconds @router.get("/statistics") def get_statistics() -> dict[str, Any]: """Aggregate statistics across all sessions.""" now = time.time() cached = _cache.get("statistics") if cached and (now - cached[1]) < _CACHE_TTL: return cached[0] sessions_root = get_share_dir() / "sessions" if not sessions_root.exists(): empty: dict[str, Any] = { "total_sessions": 0, "total_turns": 0, "total_tokens": {"input": 0, "output": 0}, "total_duration_sec": 0, "tool_usage": [], "daily_usage": [], "per_project": [], } _cache["statistics"] = (empty, now) return empty total_sessions = 0 total_turns = 0 total_input_tokens = 0 total_output_tokens = 0 total_duration_sec = 0.0 # tool_name -> { count, error_count } tool_stats: dict[str, dict[str, int]] = defaultdict(lambda: {"count": 0, "error_count": 0}) # date_str -> { sessions, turns } daily_stats: dict[str, dict[str, int]] = defaultdict(lambda: {"sessions": 0, "turns": 0}) # work_dir -> { sessions, turns } project_stats: dict[str, dict[str, int]] = defaultdict(lambda: {"sessions": 0, "turns": 0}) for work_dir_hash_dir in sessions_root.iterdir(): if not work_dir_hash_dir.is_dir(): continue work_dir = get_work_dir_for_hash(work_dir_hash_dir.name) or work_dir_hash_dir.name for session_dir in work_dir_hash_dir.iterdir(): if not session_dir.is_dir(): continue wire_path = session_dir / "wire.jsonl" if not wire_path.exists(): continue total_sessions += 1 session_turns = 0 session_input_tokens = 0 session_output_tokens = 0 first_ts = 0.0 last_ts = 0.0 session_date: str | None = None # Track pending tool calls for error attribution pending_tools: dict[str, str] = {} # tool_call_id -> tool_name try: with wire_path.open(encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: parsed = parse_wire_file_line(line) except Exception: continue if isinstance(parsed, WireFileMetadata): continue ts = parsed.timestamp msg_type = parsed.message.type payload = parsed.message.payload if first_ts == 0: first_ts = ts # Determine date from first timestamp try: dt = datetime.fromtimestamp(ts, tz=UTC) session_date = dt.strftime("%Y-%m-%d") except Exception: pass last_ts = ts # Collect (type, payload) pairs, unwrapping SubagentEvent recursively events_to_process: list[tuple[str, dict[str, Any]]] = [] collect_events(msg_type, payload, events_to_process) for ev_type, ev_payload in events_to_process: if ev_type == "TurnBegin": session_turns += 1 elif ev_type == "ToolCall": fn: dict[str, Any] | None = ev_payload.get("function") tool_id: str = ev_payload.get("id", "") if isinstance(fn, dict): name: str = fn.get("name", "unknown") tool_stats[name]["count"] += 1 if tool_id: pending_tools[tool_id] = name elif ev_type == "ToolResult": tool_call_id: str = ev_payload.get("tool_call_id", "") rv: dict[str, Any] | None = ev_payload.get("return_value") if isinstance(rv, dict) and rv.get("is_error"): tool_name = pending_tools.get(tool_call_id) if tool_name: tool_stats[tool_name]["error_count"] += 1 pending_tools.pop(tool_call_id, None) elif ev_type == "StatusUpdate": tu: dict[str, Any] | None = ev_payload.get("token_usage") if isinstance(tu, dict): session_input_tokens += ( int(tu.get("input_other", 0)) + int(tu.get("input_cache_read", 0)) + int(tu.get("input_cache_creation", 0)) ) session_output_tokens += int(tu.get("output", 0)) except Exception: continue total_turns += session_turns total_input_tokens += session_input_tokens total_output_tokens += session_output_tokens duration = last_ts - first_ts if last_ts > first_ts else 0 total_duration_sec += duration # Aggregate daily if session_date: daily_stats[session_date]["sessions"] += 1 daily_stats[session_date]["turns"] += session_turns # Aggregate per project project_stats[work_dir]["sessions"] += 1 project_stats[work_dir]["turns"] += session_turns # Build tool_usage: top 20 by count tool_usage = sorted( [ {"name": name, "count": stats["count"], "error_count": stats["error_count"]} for name, stats in tool_stats.items() ], key=lambda x: x["count"], reverse=True, )[:20] # Build daily_usage: last 30 days today = datetime.now(tz=UTC) daily_usage: list[dict[str, Any]] = [] for i in range(29, -1, -1): d = today - timedelta(days=i) date_str = d.strftime("%Y-%m-%d") entry = daily_stats.get(date_str, {"sessions": 0, "turns": 0}) daily_usage.append( { "date": date_str, "sessions": entry["sessions"], "turns": entry["turns"], } ) # Build per_project: top 10 by turns per_project = sorted( [ {"work_dir": wd, "sessions": stats["sessions"], "turns": stats["turns"]} for wd, stats in project_stats.items() ], key=lambda x: x["turns"], reverse=True, )[:10] result: dict[str, Any] = { "total_sessions": total_sessions, "total_turns": total_turns, "total_tokens": {"input": total_input_tokens, "output": total_output_tokens}, "total_duration_sec": total_duration_sec, "tool_usage": tool_usage, "daily_usage": daily_usage, "per_project": per_project, } _cache["statistics"] = (result, now) return result ================================================ FILE: src/kimi_cli/vis/api/system.py ================================================ """Vis API for server capabilities and metadata.""" from __future__ import annotations import sys from typing import Any from fastapi import APIRouter router = APIRouter(prefix="/api/vis", tags=["vis"]) @router.get("/capabilities") def get_capabilities() -> dict[str, Any]: """Return server capabilities that affect frontend feature visibility.""" return {"open_in_supported": sys.platform in {"darwin", "win32"}} ================================================ FILE: src/kimi_cli/vis/app.py ================================================ """Kimi Agent Tracing Visualizer application.""" from __future__ import annotations import socket import webbrowser from pathlib import Path from typing import Any, cast from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.staticfiles import StaticFiles from kimi_cli.vis.api import sessions_router, statistics_router, system_router from kimi_cli.web.api.open_in import router as open_in_router STATIC_DIR = Path(__file__).parent / "static" GZIP_MINIMUM_SIZE = 1024 GZIP_COMPRESSION_LEVEL = 6 DEFAULT_PORT = 5495 MAX_PORT_ATTEMPTS = 10 def create_app() -> FastAPI: """Create the FastAPI application for the tracing visualizer.""" application = FastAPI( title="Kimi Agent Tracing Visualizer", docs_url=None, separate_input_output_schemas=False, ) application.add_middleware( cast(Any, GZipMiddleware), minimum_size=GZIP_MINIMUM_SIZE, compresslevel=GZIP_COMPRESSION_LEVEL, ) application.add_middleware( cast(Any, CORSMiddleware), allow_origins=["*"], # Local-only tool; port is dynamic so wildcard is acceptable allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) application.include_router(sessions_router) application.include_router(statistics_router) application.include_router(system_router) application.include_router(open_in_router) @application.get("/healthz") async def health_probe() -> dict[str, Any]: # pyright: ignore[reportUnusedFunction] return {"status": "ok"} if STATIC_DIR.exists(): application.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static") return application def find_available_port(host: str, start_port: int, max_attempts: int = MAX_PORT_ATTEMPTS) -> int: """Find an available port starting from start_port.""" for offset in range(max_attempts): port = start_port + offset with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: s.bind((host, port)) return port except OSError: continue raise RuntimeError( f"Cannot find available port in range {start_port}-{start_port + max_attempts - 1}" ) def _print_banner(lines: list[str]) -> None: """Print a boxed banner, reusing the same tag conventions as kimi web.""" import textwrap processed: list[str] = [] for line in lines: if line == "
": processed.append(line) elif not line: processed.append("") elif line.startswith("
") or line.startswith(""): processed.append(line) else: processed.extend(textwrap.wrap(line, width=78)) def strip_tags(s: str) -> str: return s.removeprefix("
").removeprefix("") content_lines = [strip_tags(line) for line in processed if line != "
"] width = max(60, *(len(line) for line in content_lines)) top = "+" + "=" * (width + 2) + "+" print(top) for line in processed: if line == "
": print("|" + "-" * (width + 2) + "|") elif line.startswith("
"): content = line.removeprefix("
") print(f"| {content.center(width)} |") elif line.startswith(""): content = line.removeprefix("") print(f"| {content.ljust(width)} |") else: print(f"| {line.ljust(width)} |") print(top) def run_vis_server( host: str = "127.0.0.1", port: int = DEFAULT_PORT, reload: bool = False, open_browser: bool = True, ) -> None: """Run the visualizer web server.""" import threading import uvicorn actual_port = find_available_port(host, port) if actual_port != port: print(f"\nPort {port} is in use, using port {actual_port} instead") url = f"http://{host}:{actual_port}" banner_lines = [ "
██╗ ██╗██╗███╗ ███╗██╗ ██╗ ██╗██╗███████╗", "
██║ ██╔╝██║████╗ ████║██║ ██║ ██║██║██╔════╝", "
█████╔╝ ██║██╔████╔██║██║ ██║ ██║██║███████╗", "
██╔═██╗ ██║██║╚██╔╝██║██║ ╚██╗ ██╔╝██║╚════██║", "
██║ ██╗██║██║ ╚═╝ ██║██║ ╚████╔╝ ██║███████║", "
╚═╝ ╚═╝╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝╚══════╝", "", "
AGENT TRACING VISUALIZER (Technical Preview)", "", "
", "", f" ➜ Local {url}", "", "
", "", " This feature is in Technical Preview and may be unstable.", " Please report issues to the kimi-cli team.", "", ] _print_banner(banner_lines) if open_browser: def open_browser_after_delay() -> None: import time time.sleep(1.5) webbrowser.open(url) thread = threading.Thread(target=open_browser_after_delay, daemon=True) thread.start() uvicorn.run( "kimi_cli.vis.app:create_app", factory=True, host=host, port=actual_port, reload=reload, log_level="info", timeout_graceful_shutdown=3, ) __all__ = ["create_app", "find_available_port", "run_vis_server"] ================================================ FILE: src/kimi_cli/web/__init__.py ================================================ """Kimi Code CLI Web Interface.""" from kimi_cli.web.app import create_app, run_web_server __all__ = ["create_app", "run_web_server"] ================================================ FILE: src/kimi_cli/web/api/__init__.py ================================================ """API routes.""" from kimi_cli.web.api import config, open_in, sessions config_router = config.router sessions_router = sessions.router work_dirs_router = sessions.work_dirs_router open_in_router = open_in.router __all__ = [ "config_router", "open_in_router", "sessions_router", "work_dirs_router", ] ================================================ FILE: src/kimi_cli/web/api/config.py ================================================ """Config API routes.""" from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel, Field from kimi_cli import logger from kimi_cli.config import LLMModel, get_config_file, load_config, save_config from kimi_cli.llm import ProviderType, derive_model_capabilities from kimi_cli.web.runner.process import KimiCLIRunner router = APIRouter(prefix="/api/config", tags=["config"]) class ConfigModel(LLMModel): """Model configuration for frontend.""" name: str = Field(description="Model key in kimi-cli config (Config.models)") provider_type: ProviderType = Field(description="Provider type (LLMProvider.type)") class GlobalConfig(BaseModel): """Global configuration snapshot for frontend.""" default_model: str = Field(description="Current default model key") default_thinking: bool = Field(description="Current default thinking mode") models: list[ConfigModel] = Field(description="All configured models") class UpdateGlobalConfigRequest(BaseModel): """Request to update global config.""" default_model: str | None = Field(default=None, description="New default model key") default_thinking: bool | None = Field(default=None, description="New default thinking mode") restart_running_sessions: bool | None = Field( default=None, description="Whether to restart running sessions" ) force_restart_busy_sessions: bool | None = Field( default=None, description="Whether to force restart busy sessions" ) class UpdateGlobalConfigResponse(BaseModel): """Response after updating global config.""" config: GlobalConfig = Field(description="Updated config snapshot") restarted_session_ids: list[str] | None = Field( default=None, description="IDs of restarted sessions" ) skipped_busy_session_ids: list[str] | None = Field( default=None, description="IDs of busy sessions that were skipped" ) class ConfigToml(BaseModel): """Raw config.toml content.""" content: str = Field(description="Raw TOML content") path: str = Field(description="Path to config file") class UpdateConfigTomlRequest(BaseModel): """Request to update config.toml.""" content: str = Field(description="New TOML content") class UpdateConfigTomlResponse(BaseModel): """Response after updating config.toml.""" success: bool = Field(description="Whether the update was successful") error: str | None = Field(default=None, description="Error message if failed") def _build_global_config() -> GlobalConfig: """Build GlobalConfig from kimi-cli config.""" config = load_config() models: list[ConfigModel] = [] for model_name, model in config.models.items(): provider = config.providers.get(model.provider) if provider is None: continue # Derive capabilities derived_caps = derive_model_capabilities(model) capabilities = derived_caps or None models.append( ConfigModel( name=model_name, model=model.model, provider=model.provider, provider_type=provider.type, max_context_size=model.max_context_size, capabilities=capabilities, ) ) return GlobalConfig( default_model=config.default_model, default_thinking=config.default_thinking, models=models, ) def _get_runner(req: Request) -> KimiCLIRunner: """Get KimiCLIRunner from FastAPI app state.""" return req.app.state.runner def _ensure_sensitive_apis_allowed(request: Request) -> None: """Block sensitive config writes when restricted.""" if getattr(request.app.state, "restrict_sensitive_apis", False): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Sensitive config APIs are disabled in this mode.", ) @router.get("/", summary="Get global (kimi-cli) config snapshot") async def get_global_config() -> GlobalConfig: """Get global (kimi-cli) config snapshot.""" return _build_global_config() @router.patch("/", summary="Update global (kimi-cli) default model/thinking") async def update_global_config( request: UpdateGlobalConfigRequest, http_request: Request, runner: KimiCLIRunner = Depends(_get_runner), ) -> UpdateGlobalConfigResponse: """Update global (kimi-cli) default model/thinking.""" _ensure_sensitive_apis_allowed(http_request) config = load_config() # Validate and update default_model if request.default_model is not None: if request.default_model not in config.models: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Model '{request.default_model}' not found in config", ) config.default_model = request.default_model # Update default_thinking if request.default_thinking is not None: config.default_thinking = request.default_thinking # Save config save_config(config) # Restart running workers to apply config changes restarted: list[str] = [] skipped_busy: list[str] = [] restart_running = request.restart_running_sessions if restart_running is None: restart_running = True # Default to restarting sessions if restart_running: summary = await runner.restart_running_workers( reason="config_update", force=request.force_restart_busy_sessions or False, ) restarted = [str(sid) for sid in summary.restarted_session_ids] skipped_busy = [str(sid) for sid in summary.skipped_busy_session_ids] return UpdateGlobalConfigResponse( config=_build_global_config(), restarted_session_ids=restarted if restarted else None, skipped_busy_session_ids=skipped_busy if skipped_busy else None, ) @router.get("/toml", summary="Get kimi-cli config.toml") async def get_config_toml(http_request: Request) -> ConfigToml: """Get kimi-cli config.toml.""" _ensure_sensitive_apis_allowed(http_request) config_file = get_config_file() if not config_file.exists(): return ConfigToml(content="", path=str(config_file)) return ConfigToml(content=config_file.read_text(encoding="utf-8"), path=str(config_file)) @router.put("/toml", summary="Update kimi-cli config.toml") async def update_config_toml( request: UpdateConfigTomlRequest, http_request: Request, ) -> UpdateConfigTomlResponse: """Update kimi-cli config.toml.""" from kimi_cli.config import load_config_from_string _ensure_sensitive_apis_allowed(http_request) try: # Validate the config first load_config_from_string(request.content) # Write to file config_file = get_config_file() config_file.parent.mkdir(parents=True, exist_ok=True) config_file.write_text(request.content, encoding="utf-8") return UpdateConfigTomlResponse(success=True) except Exception as e: logger.warning(f"Failed to update config.toml: {e}") return UpdateConfigTomlResponse(success=False, error=str(e)) ================================================ FILE: src/kimi_cli/web/api/open_in.py ================================================ """Open local apps for a path on the host machine.""" from __future__ import annotations import asyncio import subprocess import sys from pathlib import Path from typing import Literal from fastapi import APIRouter, HTTPException, status from pydantic import BaseModel from kimi_cli import logger router = APIRouter(prefix="/api/open-in", tags=["open-in"]) class OpenInRequest(BaseModel): """Open path in a local app.""" app: Literal["finder", "cursor", "vscode", "iterm", "terminal", "antigravity"] path: str class OpenInResponse(BaseModel): """Open path response.""" ok: bool detail: str | None = None def _resolve_path(path: str) -> Path: """Resolve and validate a path (file or directory).""" resolved = Path(path).expanduser() try: resolved = resolved.resolve() except FileNotFoundError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Path does not exist: {path}", ) from None if not resolved.exists(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Path does not exist: {path}", ) return resolved def _run_command(args: list[str]) -> None: subprocess.run( args, check=True, capture_output=True, text=True, ) def _spawn_process(args: list[str]) -> None: subprocess.Popen(args, close_fds=True) def _open_app(app_name: str, path: Path, fallback: str | None = None) -> None: try: _run_command(["open", "-a", app_name, str(path)]) return except subprocess.CalledProcessError as exc: if fallback is None: raise logger.warning("Open with {} failed: {}", app_name, exc) _run_command(["open", "-a", fallback, str(path)]) def _open_terminal(path: Path) -> None: script = f'tell application "Terminal" to do script "cd " & quoted form of "{path}"' _run_command(["osascript", "-e", script]) def _open_iterm(path: Path) -> None: script = "\n".join( [ 'tell application "iTerm"', " create window with default profile", " tell current session of current window", f' write text "cd " & quoted form of "{path}"', " end tell", "end tell", ] ) try: _run_command(["osascript", "-e", script]) except subprocess.CalledProcessError: script = script.replace('"iTerm"', '"iTerm2"') _run_command(["osascript", "-e", script]) def _open_windows_app(command: str, path: Path) -> None: _run_command(["cmd", "/c", "start", "", command, str(path)]) def _open_windows_explorer(path: Path, *, is_file: bool) -> None: if is_file: _spawn_process(["explorer", f"/select,{path}"]) else: _spawn_process(["explorer", str(path)]) def _open_windows_terminal(path: Path) -> None: try: _run_command(["cmd", "/c", "start", "", "wt.exe", "-d", str(path)]) except subprocess.CalledProcessError as exc: logger.warning("Open with Windows Terminal failed: {}", exc) _run_command(["cmd", "/c", "start", "", "cmd.exe", "/K", f'cd /d "{path}"']) def _open_in_macos(app: OpenInRequest, path: Path, *, is_file: bool) -> None: match app.app: case "finder": if is_file: # Reveal file in Finder _run_command(["open", "-R", str(path)]) else: _run_command(["open", str(path)]) case "cursor": _open_app("Cursor", path) case "vscode": _open_app("Visual Studio Code", path, fallback="Code") case "antigravity": _open_app("Antigravity", path) case "iterm": # Terminal apps need directory directory = path.parent if is_file else path _open_iterm(directory) case "terminal": directory = path.parent if is_file else path _open_terminal(directory) case _: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Unsupported app: {app.app}", ) def _open_in_windows(app: OpenInRequest, path: Path, *, is_file: bool) -> None: match app.app: case "finder": _open_windows_explorer(path, is_file=is_file) case "cursor": _open_windows_app("cursor", path) case "vscode": _open_windows_app("code", path) case "terminal": directory = path.parent if is_file else path _open_windows_terminal(directory) case "iterm" | "antigravity": raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"{app.app} is not supported on Windows.", ) case _: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Unsupported app: {app.app}", ) def _open_in_sync(request: OpenInRequest, path: Path, *, is_file: bool) -> None: if sys.platform == "darwin": _open_in_macos(request, path, is_file=is_file) else: _open_in_windows(request, path, is_file=is_file) @router.post("", summary="Open a path in a local application") async def open_in(request: OpenInRequest) -> OpenInResponse: if sys.platform not in {"darwin", "win32"}: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Open-in is only supported on macOS and Windows.", ) path = _resolve_path(request.path) is_file = path.is_file() try: await asyncio.to_thread(_open_in_sync, request, path, is_file=is_file) except subprocess.CalledProcessError as exc: logger.warning("Open-in failed ({}): {}", request.app, exc) detail = exc.stderr.strip() if exc.stderr else "Failed to open application." raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail, ) from exc return OpenInResponse(ok=True) ================================================ FILE: src/kimi_cli/web/api/sessions.py ================================================ """Sessions API routes.""" from __future__ import annotations import asyncio import json import mimetypes import os import re import shutil import time from datetime import UTC, datetime from pathlib import Path from typing import Any, cast from urllib.parse import quote from uuid import UUID, uuid4 from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status from fastapi.responses import FileResponse, Response from kaos.path import KaosPath from pydantic import BaseModel, Field from starlette.websockets import WebSocket, WebSocketDisconnect from kimi_cli import logger from kimi_cli.metadata import load_metadata, save_metadata from kimi_cli.session import Session as KimiCLISession from kimi_cli.utils.subprocess_env import get_clean_env from kimi_cli.web.auth import is_origin_allowed, is_private_ip, verify_token from kimi_cli.web.models import ( GenerateTitleRequest, GenerateTitleResponse, GitDiffStats, GitFileDiff, Session, SessionStatus, UpdateSessionRequest, ) from kimi_cli.web.runner.messages import new_session_status_message, send_history_complete from kimi_cli.web.runner.process import KimiCLIRunner from kimi_cli.web.store.sessions import ( JointSession, SessionMetadata, invalidate_sessions_cache, load_session_by_id, load_session_metadata, load_sessions_page, run_auto_archive, save_session_metadata, ) from kimi_cli.wire.jsonrpc import ( ErrorCodes, JSONRPCErrorObject, JSONRPCErrorResponse, JSONRPCInMessageAdapter, JSONRPCPromptMessage, ) from kimi_cli.wire.serde import deserialize_wire_message from kimi_cli.wire.types import is_request router = APIRouter(prefix="/api/sessions", tags=["sessions"]) work_dirs_router = APIRouter(prefix="/api/work-dirs", tags=["work-dirs"]) # Constants MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100MB DEFAULT_MAX_PUBLIC_PATH_DEPTH = 6 SENSITIVE_PATH_PARTS = { "id_rsa", "id_ed25519", "known_hosts", "credentials", ".aws", ".ssh", ".gnupg", ".kube", ".npmrc", ".pypirc", ".netrc", } SENSITIVE_PATH_EXTENSIONS = { ".pem", ".key", ".p12", ".pfx", ".kdbx", ".der", } # Home directory patterns to detect if resolved path escapes to sensitive locations SENSITIVE_HOME_PATHS = { ".ssh", ".gnupg", ".aws", ".kube", } CHECKPOINT_USER_PATTERN = re.compile(r"^CHECKPOINT \d+$") def sanitize_filename(filename: str) -> str: """Remove potentially dangerous characters from filename.""" # Keep only alphanumeric, dots, underscores, hyphens, and spaces safe = "".join(c for c in filename if c.isalnum() or c in "._- ") return safe.strip() or "unnamed" def get_runner(req: Request) -> KimiCLIRunner: """Get the KimiCLIRunner from the FastAPI app state.""" return req.app.state.runner def get_runner_ws(ws: WebSocket) -> KimiCLIRunner: """Get the KimiCLIRunner from the FastAPI app state (for WebSocket routes).""" return ws.app.state.runner def get_editable_session( session_id: UUID, runner: KimiCLIRunner, ) -> JointSession: """Get a session and verify it's not busy.""" session = load_session_by_id(session_id) if session is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Session not found", ) # Check if session is busy session_process = runner.get_session(session_id) if session_process and session_process.is_busy: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Session is busy. Please wait for it to complete before modifying.", ) return session def _relative_parts(path: Path) -> list[str]: return [part for part in path.parts if part not in {"", "."}] def _is_sensitive_relative_path(rel_path: Path) -> bool: parts = _relative_parts(rel_path) for part in parts: if part.startswith("."): return True if part.lower() in SENSITIVE_PATH_PARTS: return True return rel_path.suffix.lower() in SENSITIVE_PATH_EXTENSIONS def _contains_symlink(path: Path, base: Path) -> bool: """Check if any component of the path (relative to base) is a symlink.""" try: current = base rel_parts = path.relative_to(base).parts for part in rel_parts: current = current / part if current.is_symlink(): return True except (ValueError, OSError): return True return False def _is_path_in_sensitive_location(path: Path) -> bool: """Check if resolved path points to a sensitive location (e.g., ~/.ssh, ~/.aws).""" try: home = Path.home() if path.is_relative_to(home): rel_to_home = path.relative_to(home) first_part = rel_to_home.parts[0] if rel_to_home.parts else "" if first_part in SENSITIVE_HOME_PATHS: return True except (ValueError, RuntimeError): pass return False def _ensure_public_file_access_allowed( rel_path: Path, restrict_sensitive_apis: bool, max_path_depth: int = DEFAULT_MAX_PUBLIC_PATH_DEPTH, ) -> None: if not restrict_sensitive_apis: return rel_parts = _relative_parts(rel_path) if len(rel_parts) > max_path_depth: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Path too deep for public access " f"(max depth: {max_path_depth}, current: {len(rel_parts)}).", ) if _is_sensitive_relative_path(rel_path): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access to sensitive files is disabled.", ) def _read_wire_lines(wire_file: Path) -> list[str]: """Read and parse wire.jsonl into JSONRPC event strings (runs in thread).""" result: list[str] = [] with open(wire_file, encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: record = json.loads(line) if not isinstance(record, dict): continue record = cast(dict[str, Any], record) record_type = record.get("type") if isinstance(record_type, str) and record_type == "metadata": continue message_raw = record.get("message") if not isinstance(message_raw, dict): continue message_raw = cast(dict[str, Any], message_raw) message = deserialize_wire_message(message_raw) _is_req = is_request(message) event_msg: dict[str, Any] = { "jsonrpc": "2.0", "method": "request" if _is_req else "event", "params": message_raw, } if _is_req: # JSON-RPC requests require a top-level ``id`` so the # client can correlate its response. Use the request's # own ``id`` field (e.g. ApprovalRequest.id, # QuestionRequest.id). Note: ``message_raw`` wraps data # as ``{"type": ..., "payload": {...}}`` so the id lives # on the deserialized object, not at the raw dict top level. event_msg["id"] = message.id result.append(json.dumps(event_msg, ensure_ascii=False)) except (json.JSONDecodeError, KeyError, ValueError, TypeError): continue return result async def replay_history(ws: WebSocket, session_dir: Path) -> None: """Replay historical wire messages from wire.jsonl to a WebSocket.""" wire_file = session_dir / "wire.jsonl" if not await asyncio.to_thread(wire_file.exists): return try: lines = await asyncio.to_thread(_read_wire_lines, wire_file) for event_text in lines: await ws.send_text(event_text) except Exception: pass @router.get("/", summary="List all sessions") async def list_sessions( runner: KimiCLIRunner = Depends(get_runner), limit: int = 100, offset: int = 0, q: str | None = None, archived: bool | None = None, ) -> list[Session]: """List sessions with optional pagination and search. Args: limit: Maximum number of sessions to return (default 100, max 500). offset: Number of sessions to skip (default 0). q: Optional search query to filter by title or work_dir. archived: Filter by archived status. - None (default): Only return non-archived sessions. - True: Only return archived sessions. """ if limit <= 0: limit = 100 if limit > 500: limit = 500 if offset < 0: offset = 0 # Run auto-archive in background (throttled internally, runs at most once per 5 minutes) await asyncio.to_thread(run_auto_archive) sessions = load_sessions_page(limit=limit, offset=offset, query=q, archived=archived) for session in sessions: session_process = runner.get_session(session.session_id) session.is_running = session_process is not None and session_process.is_running session.status = session_process.status if session_process else None return cast(list[Session], sessions) @router.get("/{session_id}", summary="Get session") async def get_session( session_id: UUID, runner: KimiCLIRunner = Depends(get_runner), ) -> Session | None: """Get a session by ID.""" session = load_session_by_id(session_id) if session is not None: session_process = runner.get_session(session_id) session.is_running = session_process is not None and session_process.is_running session.status = session_process.status if session_process else None return session @router.post("/", summary="Create a new session") async def create_session(request: CreateSessionRequest | None = None) -> Session: """Create a new session.""" # Use provided work_dir or default to user's home directory if request and request.work_dir: work_dir_path = Path(request.work_dir).expanduser().resolve() # Validate the directory exists if not work_dir_path.exists(): if request.create_dir: # Auto-create the directory try: work_dir_path.mkdir(parents=True, exist_ok=True) except PermissionError as e: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Permission denied: cannot create directory {request.work_dir}", ) from e except OSError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Failed to create directory: {e}", ) from e else: # Return 404 to indicate directory does not exist raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Directory does not exist: {request.work_dir}", ) if not work_dir_path.is_dir(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Path is not a directory: {request.work_dir}", ) work_dir = KaosPath.unsafe_from_local_path(work_dir_path) else: work_dir = KaosPath.unsafe_from_local_path(Path.home()) kimi_cli_session = await KimiCLISession.create(work_dir=work_dir) context_file = kimi_cli_session.dir / "context.jsonl" invalidate_sessions_cache() invalidate_work_dirs_cache() return Session( session_id=UUID(kimi_cli_session.id), title=kimi_cli_session.title, last_updated=datetime.fromtimestamp(context_file.stat().st_mtime, tz=UTC), is_running=False, status=SessionStatus( session_id=UUID(kimi_cli_session.id), state="stopped", seq=0, worker_id=None, reason=None, detail=None, updated_at=datetime.now(UTC), ), work_dir=str(work_dir), session_dir=str(kimi_cli_session.dir), ) class CreateSessionRequest(BaseModel): """Create session request.""" work_dir: str | None = None create_dir: bool = False # Whether to auto-create directory if it doesn't exist class ForkSessionRequest(BaseModel): """Fork session request.""" turn_index: int = Field(..., ge=0) # 0-based, fork includes this turn and all previous turns class UploadSessionFileResponse(BaseModel): """Upload file response.""" path: str filename: str size: int @router.post("/{session_id}/files", summary="Upload file to session") async def upload_session_file( session_id: UUID, file: UploadFile, runner: KimiCLIRunner = Depends(get_runner), ) -> UploadSessionFileResponse: """Upload a file to a session.""" session = get_editable_session(session_id, runner) session_dir = session.kimi_cli_session.dir upload_dir = session_dir / "uploads" upload_dir.mkdir(parents=True, exist_ok=True) # Read and validate file size content = await file.read() if len(content) > MAX_UPLOAD_SIZE: raise HTTPException( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail=f"File too large (max {MAX_UPLOAD_SIZE // 1024 // 1024}MB)", ) # Generate safe filename file_name = str(uuid4()) if file.filename: safe_name = sanitize_filename(file.filename) name, ext = os.path.splitext(safe_name) file_name = f"{name}_{file_name[:6]}{ext}" upload_path = upload_dir / file_name upload_path.write_bytes(content) return UploadSessionFileResponse( path=str(upload_path), filename=file_name, size=len(content), ) @router.get( "/{session_id}/uploads/{path:path}", summary="Get uploaded file from session uploads", ) async def get_session_upload_file( session_id: UUID, path: str, ) -> Response: """Get a file from a session's uploads directory.""" session = load_session_by_id(session_id) if session is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Session not found", ) uploads_dir = (session.kimi_cli_session.dir / "uploads").resolve() if not uploads_dir.exists(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Uploads directory not found", ) file_path = (uploads_dir / path).resolve() if not file_path.is_relative_to(uploads_dir): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid path: path traversal not allowed", ) if not file_path.exists() or not file_path.is_file(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="File not found", ) media_type, _ = mimetypes.guess_type(file_path.name) encoded_filename = quote(file_path.name, safe="") return FileResponse( file_path, media_type=media_type or "application/octet-stream", headers={ "Content-Disposition": f"inline; filename*=UTF-8''{encoded_filename}", }, ) @router.get( "/{session_id}/files/{path:path}", summary="Get file or list directory from session work_dir", ) async def get_session_file( session_id: UUID, path: str, request: Request, ) -> Response: """Get a file or list directory from session work directory.""" session = load_session_by_id(session_id) if session is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Session not found", ) # Security check: prevent path traversal attacks using resolve() work_dir = Path(str(session.kimi_cli_session.work_dir)).resolve() requested_path = work_dir / path file_path = requested_path.resolve() # Check path traversal if not file_path.is_relative_to(work_dir): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid path: path traversal not allowed", ) rel_path = file_path.relative_to(work_dir) restrict_sensitive_apis = getattr(request.app.state, "restrict_sensitive_apis", False) max_path_depth = ( getattr(request.app.state, "max_public_path_depth", None) or DEFAULT_MAX_PUBLIC_PATH_DEPTH ) # Additional security checks when restricting sensitive APIs if restrict_sensitive_apis: # Check for symlinks in the path if _contains_symlink(requested_path, work_dir): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Symbolic links are not allowed in public mode.", ) # Check if resolved path points to sensitive location if _is_path_in_sensitive_location(file_path): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access to sensitive system directories is not allowed.", ) _ensure_public_file_access_allowed(rel_path, restrict_sensitive_apis, max_path_depth) if not file_path.exists(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="File not found", ) if file_path.is_dir(): result: list[dict[str, str | int]] = [] for subpath in file_path.iterdir(): if restrict_sensitive_apis: rel_subpath = rel_path / subpath.name if _is_sensitive_relative_path(rel_subpath): continue if subpath.is_dir(): result.append({"name": subpath.name, "type": "directory"}) else: result.append( { "name": subpath.name, "type": "file", "size": subpath.stat().st_size, } ) result.sort(key=lambda x: (cast(str, x["type"]), cast(str, x["name"]))) return Response(content=json.dumps(result), media_type="application/json") content = file_path.read_bytes() media_type, _ = mimetypes.guess_type(file_path.name) encoded_filename = quote(file_path.name, safe="") return Response( content=content, media_type=media_type or "application/octet-stream", headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"}, ) def _update_last_session_id(session: JointSession) -> None: """Update last_session_id for the session's work directory.""" kimi_session = session.kimi_cli_session work_dir = kimi_session.work_dir metadata = load_metadata() work_dir_meta = metadata.get_work_dir_meta(work_dir) if work_dir_meta is None: work_dir_meta = metadata.new_work_dir_meta(work_dir) work_dir_meta.last_session_id = kimi_session.id save_metadata(metadata) @router.delete("/{session_id}", summary="Delete a session") async def delete_session(session_id: UUID, runner: KimiCLIRunner = Depends(get_runner)) -> None: """Delete a session.""" session = get_editable_session(session_id, runner) session_process = runner.get_session(session_id) if session_process is not None: await session_process.stop() wd_meta = session.kimi_cli_session.work_dir_meta if wd_meta.last_session_id == str(session_id): metadata = load_metadata() for wd in metadata.work_dirs: if wd.path == wd_meta.path: wd.last_session_id = None break save_metadata(metadata) session_dir = session.kimi_cli_session.dir if session_dir.exists(): shutil.rmtree(session_dir) invalidate_sessions_cache() @router.patch("/{session_id}", summary="Update session") async def update_session( session_id: UUID, request: UpdateSessionRequest, runner: KimiCLIRunner = Depends(get_runner), ) -> Session: """Update a session (e.g., rename title or archive/unarchive).""" session = get_editable_session(session_id, runner) session_dir = session.kimi_cli_session.dir # Load existing metadata metadata = load_session_metadata(session_dir, str(session_id)) # Update title if provided if request.title is not None: metadata = metadata.model_copy(update={"title": request.title}) # Update archived status if provided if request.archived is not None: updates: dict[str, bool | float | None] = {"archived": request.archived} if request.archived: # User manually archived: set archived_at, reset auto_archive_exempt updates["archived_at"] = time.time() updates["auto_archive_exempt"] = False else: # User manually unarchived: clear archived_at, set auto_archive_exempt # This prevents the session from being auto-archived again updates["archived_at"] = None updates["auto_archive_exempt"] = True metadata = metadata.model_copy(update=updates) # Save metadata save_session_metadata(session_dir, metadata) # Invalidate cache to force reload invalidate_sessions_cache() # Return updated session updated_session = load_session_by_id(session_id) if updated_session is None: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to reload session after update", ) return updated_session def extract_first_turn_from_wire(session_dir: Path) -> tuple[str, str] | None: """Extract the first turn's user message and assistant response from wire.jsonl. Returns: tuple[str, str] | None: (user_message, assistant_response) or None if not found """ wire_file = session_dir / "wire.jsonl" if not wire_file.exists(): return None user_message: str | None = None assistant_response_parts: list[str] = [] in_first_turn = False try: with open(wire_file, encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: record = json.loads(line) message = record.get("message", {}) msg_type = message.get("type") if msg_type == "TurnBegin": if in_first_turn: # Second turn started, stop break in_first_turn = True user_input = message.get("payload", {}).get("user_input") if user_input: from kosong.message import Message msg = Message(role="user", content=user_input) user_message = msg.extract_text(" ") elif msg_type == "ContentPart" and in_first_turn: payload = message.get("payload", {}) if payload.get("type") == "text" and payload.get("text"): assistant_response_parts.append(payload["text"]) elif msg_type == "TurnEnd" and in_first_turn: break except json.JSONDecodeError: continue except OSError: return None if user_message and assistant_response_parts: return (user_message, "".join(assistant_response_parts)) return None def truncate_wire_at_turn(wire_path: Path, turn_index: int) -> list[str]: """Read wire.jsonl and return all lines up to and including the given turn. Args: wire_path: Path to the wire.jsonl file turn_index: 0-based turn index. Returns turns 0..turn_index inclusive. Returns: List of raw JSON lines (including the metadata header) Raises: ValueError: If turn_index is out of range """ if not wire_path.exists(): raise ValueError("wire.jsonl not found") lines: list[str] = [] current_turn = -1 # Will become 0 on first TurnBegin with open(wire_path, encoding="utf-8") as f: for line in f: stripped = line.strip() if not stripped: continue try: record: dict[str, Any] = json.loads(stripped) except json.JSONDecodeError: continue # Always keep metadata header if record.get("type") == "metadata": lines.append(stripped) continue message: dict[str, Any] = record.get("message", {}) msg_type: str | None = message.get("type") if msg_type == "TurnBegin": current_turn += 1 if current_turn > turn_index: break if current_turn <= turn_index: lines.append(stripped) # Stop after the TurnEnd of the target turn if msg_type == "TurnEnd" and current_turn == turn_index: break if current_turn < turn_index: raise ValueError(f"turn_index {turn_index} out of range (max turn: {current_turn})") return lines def _is_checkpoint_user_message(record: dict[str, Any]) -> bool: """Whether a context line is the synthetic user checkpoint marker.""" if record.get("role") != "user": return False content = record.get("content") if isinstance(content, str): return CHECKPOINT_USER_PATTERN.fullmatch(content.strip()) is not None parts = cast(list[Any], content) if isinstance(content, list) else [] if len(parts) == 1 and isinstance(parts[0], dict): first_part = cast(dict[str, Any], parts[0]) text = first_part.get("text") if isinstance(text, str): return CHECKPOINT_USER_PATTERN.fullmatch(text.strip()) is not None return False def truncate_context_at_turn(context_path: Path, turn_index: int) -> list[str]: """Read context.jsonl and return all lines up to and including the given turn. Turn detection is based on real user messages, excluding synthetic checkpoint user entries like ``CHECKPOINT N``. Unlike wire truncation, this is best-effort: if context has fewer user turns than ``turn_index`` (e.g. slash-command turns that did not mutate context), return all available context lines instead of failing. """ if not context_path.exists(): return [] lines: list[str] = [] current_turn = -1 # Will become 0 on first real user message with open(context_path, encoding="utf-8") as f: for line in f: stripped = line.strip() if not stripped: continue try: record: dict[str, Any] = json.loads(stripped) except json.JSONDecodeError: continue if record.get("role") == "user" and not _is_checkpoint_user_message(record): current_turn += 1 if current_turn > turn_index: break if current_turn <= turn_index: lines.append(stripped) return lines @router.post("/{session_id}/fork", summary="Fork a session at a specific turn") async def fork_session( session_id: UUID, request: ForkSessionRequest, runner: KimiCLIRunner = Depends(get_runner), ) -> Session: """Fork a session, creating a new session with history up to the specified turn. The new session shares the same work_dir as the original session. """ source_session = get_editable_session(session_id, runner) source_dir = source_session.kimi_cli_session.dir wire_path = source_dir / "wire.jsonl" context_path = source_dir / "context.jsonl" try: truncated_wire_lines = truncate_wire_at_turn(wire_path, request.turn_index) truncated_context_lines = truncate_context_at_turn(context_path, request.turn_index) except ValueError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) from e # Create new session with the same work_dir. # Only write the essential files explicitly — do NOT copytree the whole # source directory, which would bring in rotated context backups # (context_N.jsonl) and subagent contexts (context_sub_N.jsonl). work_dir = source_session.kimi_cli_session.work_dir new_session = await KimiCLISession.create(work_dir=work_dir) new_session_dir = new_session.dir # Copy only the video files that are actually referenced in the truncated # wire history. Videos are referenced by path (