Repository: sipeed/picoclaw Branch: main Commit: fe87376d6a6f Files: 695 Total size: 3.9 MB Directory structure: gitextract_tfefpv8c/ ├── .dockerignore ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── feature_request.md │ │ └── general-task---todo.md │ ├── dependabot.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── build.yml │ ├── docker-build.yml │ ├── nightly.yml │ ├── pr.yml │ ├── release.yml │ └── upload-tos.yml ├── .gitignore ├── .golangci.yaml ├── .goreleaser.yaml ├── CONTRIBUTING.md ├── CONTRIBUTING.zh.md ├── LICENSE ├── Makefile ├── README.fr.md ├── README.id.md ├── README.it.md ├── README.ja.md ├── README.md ├── README.pt-br.md ├── README.vi.md ├── README.zh.md ├── ROADMAP.md ├── cmd/ │ ├── picoclaw/ │ │ ├── internal/ │ │ │ ├── agent/ │ │ │ │ ├── command.go │ │ │ │ ├── command_test.go │ │ │ │ └── helpers.go │ │ │ ├── auth/ │ │ │ │ ├── command.go │ │ │ │ ├── command_test.go │ │ │ │ ├── helpers.go │ │ │ │ ├── login.go │ │ │ │ ├── login_test.go │ │ │ │ ├── logout.go │ │ │ │ ├── logout_test.go │ │ │ │ ├── models.go │ │ │ │ ├── models_test.go │ │ │ │ ├── status.go │ │ │ │ └── status_test.go │ │ │ ├── cron/ │ │ │ │ ├── add.go │ │ │ │ ├── add_test.go │ │ │ │ ├── command.go │ │ │ │ ├── command_test.go │ │ │ │ ├── disable.go │ │ │ │ ├── disable_test.go │ │ │ │ ├── enable.go │ │ │ │ ├── enable_test.go │ │ │ │ ├── helpers.go │ │ │ │ ├── list.go │ │ │ │ ├── list_test.go │ │ │ │ ├── remove.go │ │ │ │ └── remove_test.go │ │ │ ├── gateway/ │ │ │ │ ├── command.go │ │ │ │ └── command_test.go │ │ │ ├── helpers.go │ │ │ ├── helpers_test.go │ │ │ ├── migrate/ │ │ │ │ ├── command.go │ │ │ │ └── command_test.go │ │ │ ├── model/ │ │ │ │ ├── command.go │ │ │ │ └── command_test.go │ │ │ ├── onboard/ │ │ │ │ ├── command.go │ │ │ │ ├── command_test.go │ │ │ │ ├── helpers.go │ │ │ │ └── helpers_test.go │ │ │ ├── skills/ │ │ │ │ ├── command.go │ │ │ │ ├── command_test.go │ │ │ │ ├── helpers.go │ │ │ │ ├── install.go │ │ │ │ ├── install_test.go │ │ │ │ ├── installbuiltin.go │ │ │ │ ├── installbuiltin_test.go │ │ │ │ ├── list.go │ │ │ │ ├── list_test.go │ │ │ │ ├── listbuiltin.go │ │ │ │ ├── listbuiltin_test.go │ │ │ │ ├── remove.go │ │ │ │ ├── remove_test.go │ │ │ │ ├── search.go │ │ │ │ ├── search_test.go │ │ │ │ ├── show.go │ │ │ │ └── show_test.go │ │ │ ├── status/ │ │ │ │ ├── command.go │ │ │ │ ├── command_test.go │ │ │ │ └── helpers.go │ │ │ └── version/ │ │ │ ├── command.go │ │ │ └── command_test.go │ │ ├── main.go │ │ └── main_test.go │ └── picoclaw-launcher-tui/ │ ├── internal/ │ │ ├── config/ │ │ │ └── store.go │ │ └── ui/ │ │ ├── app.go │ │ ├── channel.go │ │ ├── gateway_posix.go │ │ ├── gateway_windows.go │ │ ├── menu.go │ │ ├── model.go │ │ └── style.go │ └── main.go ├── config/ │ └── config.example.json ├── docker/ │ ├── Dockerfile │ ├── Dockerfile.full │ ├── Dockerfile.goreleaser │ ├── Dockerfile.goreleaser.launcher │ ├── docker-compose.full.yml │ ├── docker-compose.yml │ └── entrypoint.sh ├── docs/ │ ├── ANTIGRAVITY_AUTH.md │ ├── ANTIGRAVITY_USAGE.md │ ├── agent-refactor/ │ │ └── README.md │ ├── channels/ │ │ ├── dingtalk/ │ │ │ └── README.zh.md │ │ ├── discord/ │ │ │ └── README.zh.md │ │ ├── feishu/ │ │ │ └── README.zh.md │ │ ├── line/ │ │ │ └── README.zh.md │ │ ├── maixcam/ │ │ │ └── README.zh.md │ │ ├── matrix/ │ │ │ ├── README.md │ │ │ └── README.zh.md │ │ ├── onebot/ │ │ │ └── README.zh.md │ │ ├── qq/ │ │ │ └── README.zh.md │ │ ├── slack/ │ │ │ └── README.zh.md │ │ ├── telegram/ │ │ │ └── README.zh.md │ │ └── wecom/ │ │ ├── wecom_aibot/ │ │ │ └── README.zh.md │ │ ├── wecom_app/ │ │ │ └── README.zh.md │ │ └── wecom_bot/ │ │ └── README.zh.md │ ├── chat-apps.md │ ├── configuration.md │ ├── credential_encryption.md │ ├── debug.md │ ├── design/ │ │ ├── issue-783-investigation-and-fix-plan.zh.md │ │ ├── provider-refactoring-tests.md │ │ └── provider-refactoring.md │ ├── docker.md │ ├── fr/ │ │ ├── chat-apps.md │ │ ├── configuration.md │ │ ├── docker.md │ │ ├── providers.md │ │ ├── spawn-tasks.md │ │ ├── tools_configuration.md │ │ └── troubleshooting.md │ ├── it/ │ │ └── configuration.md │ ├── ja/ │ │ ├── chat-apps.md │ │ ├── configuration.md │ │ ├── docker.md │ │ ├── providers.md │ │ ├── spawn-tasks.md │ │ ├── tools_configuration.md │ │ └── troubleshooting.md │ ├── migration/ │ │ └── model-list-migration.md │ ├── providers.md │ ├── pt-br/ │ │ ├── chat-apps.md │ │ ├── configuration.md │ │ ├── docker.md │ │ ├── providers.md │ │ ├── spawn-tasks.md │ │ ├── tools_configuration.md │ │ └── troubleshooting.md │ ├── spawn-tasks.md │ ├── tools_configuration.md │ ├── troubleshooting.md │ ├── vi/ │ │ ├── chat-apps.md │ │ ├── configuration.md │ │ ├── docker.md │ │ ├── providers.md │ │ ├── spawn-tasks.md │ │ ├── tools_configuration.md │ │ └── troubleshooting.md │ └── zh/ │ ├── chat-apps.md │ ├── configuration.md │ ├── docker.md │ ├── providers.md │ ├── spawn-tasks.md │ ├── tools_configuration.md │ └── troubleshooting.md ├── go.mod ├── go.sum ├── pkg/ │ ├── agent/ │ │ ├── context.go │ │ ├── context_cache_test.go │ │ ├── context_test.go │ │ ├── instance.go │ │ ├── instance_test.go │ │ ├── loop.go │ │ ├── loop_mcp.go │ │ ├── loop_mcp_test.go │ │ ├── loop_media.go │ │ ├── loop_test.go │ │ ├── memory.go │ │ ├── mock_provider_test.go │ │ ├── model_resolution.go │ │ ├── registry.go │ │ ├── registry_test.go │ │ ├── thinking.go │ │ └── thinking_test.go │ ├── auth/ │ │ ├── anthropic_usage.go │ │ ├── anthropic_usage_test.go │ │ ├── oauth.go │ │ ├── oauth_test.go │ │ ├── pkce.go │ │ ├── pkce_test.go │ │ ├── store.go │ │ ├── store_test.go │ │ ├── token.go │ │ └── token_test.go │ ├── bus/ │ │ ├── bus.go │ │ ├── bus_test.go │ │ └── types.go │ ├── channels/ │ │ ├── README.md │ │ ├── README.zh.md │ │ ├── base.go │ │ ├── base_test.go │ │ ├── dingtalk/ │ │ │ ├── dingtalk.go │ │ │ └── init.go │ │ ├── discord/ │ │ │ ├── discord.go │ │ │ ├── discord_resolve_test.go │ │ │ ├── discord_test.go │ │ │ └── init.go │ │ ├── errors.go │ │ ├── errors_test.go │ │ ├── errutil.go │ │ ├── errutil_test.go │ │ ├── feishu/ │ │ │ ├── common.go │ │ │ ├── common_test.go │ │ │ ├── feishu_32.go │ │ │ ├── feishu_64.go │ │ │ ├── feishu_64_test.go │ │ │ ├── init.go │ │ │ └── token_cache.go │ │ ├── interfaces.go │ │ ├── interfaces_command_test.go │ │ ├── irc/ │ │ │ ├── handler.go │ │ │ ├── init.go │ │ │ ├── irc.go │ │ │ └── irc_test.go │ │ ├── line/ │ │ │ ├── init.go │ │ │ ├── line.go │ │ │ └── line_test.go │ │ ├── maixcam/ │ │ │ ├── init.go │ │ │ └── maixcam.go │ │ ├── manager.go │ │ ├── manager_channel.go │ │ ├── manager_channel_test.go │ │ ├── manager_test.go │ │ ├── matrix/ │ │ │ ├── init.go │ │ │ ├── matrix.go │ │ │ └── matrix_test.go │ │ ├── media.go │ │ ├── onebot/ │ │ │ ├── init.go │ │ │ └── onebot.go │ │ ├── pico/ │ │ │ ├── init.go │ │ │ ├── pico.go │ │ │ └── protocol.go │ │ ├── qq/ │ │ │ ├── botgo_logger.go │ │ │ ├── init.go │ │ │ ├── qq.go │ │ │ └── qq_test.go │ │ ├── registry.go │ │ ├── slack/ │ │ │ ├── init.go │ │ │ ├── slack.go │ │ │ └── slack_test.go │ │ ├── split.go │ │ ├── split_test.go │ │ ├── telegram/ │ │ │ ├── command_registration.go │ │ │ ├── command_registration_test.go │ │ │ ├── init.go │ │ │ ├── parse_markdown_to_md_v2.go │ │ │ ├── parse_markdown_to_md_v2_test.go │ │ │ ├── parser_markdown_to_html.go │ │ │ ├── telegram.go │ │ │ ├── telegram_dispatch_test.go │ │ │ ├── telegram_group_command_filter_test.go │ │ │ ├── telegram_test.go │ │ │ └── testdata/ │ │ │ └── md2_all_formats.txt │ │ ├── webhook.go │ │ ├── wecom/ │ │ │ ├── aibot.go │ │ │ ├── aibot_test.go │ │ │ ├── aibot_ws.go │ │ │ ├── aibot_ws_test.go │ │ │ ├── app.go │ │ │ ├── app_test.go │ │ │ ├── bot.go │ │ │ ├── bot_test.go │ │ │ ├── common.go │ │ │ ├── dedupe.go │ │ │ ├── dedupe_test.go │ │ │ └── init.go │ │ ├── whatsapp/ │ │ │ ├── init.go │ │ │ ├── whatsapp.go │ │ │ └── whatsapp_command_test.go │ │ └── whatsapp_native/ │ │ ├── init.go │ │ ├── whatsapp_command_test.go │ │ ├── whatsapp_native.go │ │ └── whatsapp_native_stub.go │ ├── commands/ │ │ ├── builtin.go │ │ ├── builtin_test.go │ │ ├── cmd_check.go │ │ ├── cmd_clear.go │ │ ├── cmd_help.go │ │ ├── cmd_list.go │ │ ├── cmd_reload.go │ │ ├── cmd_show.go │ │ ├── cmd_start.go │ │ ├── cmd_switch.go │ │ ├── cmd_switch_test.go │ │ ├── definition.go │ │ ├── definition_test.go │ │ ├── executor.go │ │ ├── executor_test.go │ │ ├── handler_agents.go │ │ ├── registry.go │ │ ├── registry_test.go │ │ ├── request.go │ │ ├── request_test.go │ │ ├── runtime.go │ │ └── show_list_handlers_test.go │ ├── config/ │ │ ├── config.go │ │ ├── config_test.go │ │ ├── defaults.go │ │ ├── envkeys.go │ │ ├── migration.go │ │ ├── migration_test.go │ │ ├── model_config_test.go │ │ ├── multikey_test.go │ │ ├── version.go │ │ └── version_test.go │ ├── constants/ │ │ └── channels.go │ ├── credential/ │ │ ├── credential.go │ │ ├── credential_test.go │ │ ├── keygen.go │ │ ├── keygen_test.go │ │ ├── store.go │ │ └── store_test.go │ ├── cron/ │ │ ├── service.go │ │ └── service_test.go │ ├── devices/ │ │ ├── events/ │ │ │ └── events.go │ │ ├── service.go │ │ ├── source.go │ │ └── sources/ │ │ ├── usb_linux.go │ │ └── usb_stub.go │ ├── fileutil/ │ │ └── file.go │ ├── gateway/ │ │ └── gateway.go │ ├── health/ │ │ └── server.go │ ├── heartbeat/ │ │ ├── service.go │ │ └── service_test.go │ ├── identity/ │ │ ├── identity.go │ │ └── identity_test.go │ ├── logger/ │ │ ├── logger.go │ │ ├── logger_3rd_party.go │ │ └── logger_test.go │ ├── mcp/ │ │ ├── manager.go │ │ └── manager_test.go │ ├── media/ │ │ ├── store.go │ │ ├── store_test.go │ │ └── tempdir.go │ ├── memory/ │ │ ├── jsonl.go │ │ ├── jsonl_test.go │ │ ├── migration.go │ │ ├── migration_test.go │ │ └── store.go │ ├── migrate/ │ │ ├── internal/ │ │ │ ├── common.go │ │ │ ├── common_test.go │ │ │ └── types.go │ │ ├── migrate.go │ │ ├── migrate_test.go │ │ └── sources/ │ │ └── openclaw/ │ │ ├── common.go │ │ ├── openclaw_config.go │ │ ├── openclaw_config_test.go │ │ ├── openclaw_handler.go │ │ └── openclaw_handler_test.go │ ├── providers/ │ │ ├── anthropic/ │ │ │ ├── provider.go │ │ │ ├── provider_test.go │ │ │ └── thinking_test.go │ │ ├── anthropic_messages/ │ │ │ ├── provider.go │ │ │ └── provider_test.go │ │ ├── antigravity_provider.go │ │ ├── antigravity_provider_test.go │ │ ├── azure/ │ │ │ ├── provider.go │ │ │ └── provider_test.go │ │ ├── claude_cli_provider.go │ │ ├── claude_cli_provider_integration_test.go │ │ ├── claude_cli_provider_test.go │ │ ├── claude_provider.go │ │ ├── claude_provider_test.go │ │ ├── codex_cli_credentials.go │ │ ├── codex_cli_credentials_test.go │ │ ├── codex_cli_provider.go │ │ ├── codex_cli_provider_integration_test.go │ │ ├── codex_cli_provider_test.go │ │ ├── codex_provider.go │ │ ├── codex_provider_test.go │ │ ├── common/ │ │ │ ├── common.go │ │ │ └── common_test.go │ │ ├── cooldown.go │ │ ├── cooldown_test.go │ │ ├── error_classifier.go │ │ ├── error_classifier_test.go │ │ ├── factory.go │ │ ├── factory_provider.go │ │ ├── factory_provider_test.go │ │ ├── factory_test.go │ │ ├── fallback.go │ │ ├── fallback_multikey_test.go │ │ ├── fallback_test.go │ │ ├── github_copilot_provider.go │ │ ├── http_provider.go │ │ ├── legacy_provider.go │ │ ├── model_ref.go │ │ ├── model_ref_test.go │ │ ├── openai_compat/ │ │ │ ├── provider.go │ │ │ └── provider_test.go │ │ ├── protocoltypes/ │ │ │ └── types.go │ │ ├── tool_call_extract.go │ │ ├── toolcall_utils.go │ │ └── types.go │ ├── routing/ │ │ ├── agent_id.go │ │ ├── agent_id_test.go │ │ ├── classifier.go │ │ ├── features.go │ │ ├── route.go │ │ ├── route_test.go │ │ ├── router.go │ │ ├── router_test.go │ │ ├── session_key.go │ │ └── session_key_test.go │ ├── session/ │ │ ├── jsonl_backend.go │ │ ├── jsonl_backend_test.go │ │ ├── manager.go │ │ ├── manager_test.go │ │ └── session_store.go │ ├── skills/ │ │ ├── clawhub_registry.go │ │ ├── clawhub_registry_test.go │ │ ├── installer.go │ │ ├── installer_test.go │ │ ├── loader.go │ │ ├── loader_test.go │ │ ├── registry.go │ │ ├── registry_test.go │ │ ├── search_cache.go │ │ └── search_cache_test.go │ ├── state/ │ │ ├── state.go │ │ └── state_test.go │ ├── tools/ │ │ ├── base.go │ │ ├── cron.go │ │ ├── cron_test.go │ │ ├── edit.go │ │ ├── edit_test.go │ │ ├── filesystem.go │ │ ├── filesystem_test.go │ │ ├── i2c.go │ │ ├── i2c_linux.go │ │ ├── i2c_other.go │ │ ├── mcp_tool.go │ │ ├── mcp_tool_test.go │ │ ├── message.go │ │ ├── message_test.go │ │ ├── registry.go │ │ ├── registry_test.go │ │ ├── result.go │ │ ├── result_test.go │ │ ├── search_tool.go │ │ ├── search_tools_test.go │ │ ├── send_file.go │ │ ├── send_file_test.go │ │ ├── shell.go │ │ ├── shell_process_unix.go │ │ ├── shell_process_windows.go │ │ ├── shell_test.go │ │ ├── shell_timeout_unix_test.go │ │ ├── skills_install.go │ │ ├── skills_install_test.go │ │ ├── skills_search.go │ │ ├── skills_search_test.go │ │ ├── spawn.go │ │ ├── spawn_status.go │ │ ├── spawn_status_test.go │ │ ├── spawn_test.go │ │ ├── spi.go │ │ ├── spi_linux.go │ │ ├── spi_other.go │ │ ├── subagent.go │ │ ├── subagent_tool_test.go │ │ ├── toolloop.go │ │ ├── types.go │ │ ├── web.go │ │ └── web_test.go │ ├── utils/ │ │ ├── bm25.go │ │ ├── bm25_test.go │ │ ├── download.go │ │ ├── http_client.go │ │ ├── http_client_test.go │ │ ├── http_retry.go │ │ ├── http_retry_test.go │ │ ├── markdown.go │ │ ├── markdown_test.go │ │ ├── media.go │ │ ├── skills.go │ │ ├── string.go │ │ ├── string_test.go │ │ └── zip.go │ └── voice/ │ ├── transcriber.go │ └── transcriber_test.go ├── scripts/ │ ├── build-macos-app.sh │ ├── icon.icns │ ├── setup.iss │ ├── test-docker-mcp.sh │ └── test-irc.sh ├── web/ │ ├── Makefile │ ├── README.md │ ├── backend/ │ │ ├── .gitignore │ │ ├── api/ │ │ │ ├── channels.go │ │ │ ├── config.go │ │ │ ├── config_test.go │ │ │ ├── gateway.go │ │ │ ├── gateway_host.go │ │ │ ├── gateway_host_test.go │ │ │ ├── gateway_test.go │ │ │ ├── launcher_config.go │ │ │ ├── launcher_config_test.go │ │ │ ├── log.go │ │ │ ├── model_status.go │ │ │ ├── models.go │ │ │ ├── models_test.go │ │ │ ├── oauth.go │ │ │ ├── oauth_test.go │ │ │ ├── pico.go │ │ │ ├── pico_test.go │ │ │ ├── router.go │ │ │ ├── session.go │ │ │ ├── session_test.go │ │ │ ├── skills.go │ │ │ ├── skills_test.go │ │ │ ├── startup.go │ │ │ ├── startup_test.go │ │ │ ├── tools.go │ │ │ └── tools_test.go │ │ ├── app_runtime.go │ │ ├── dist/ │ │ │ └── .gitkeep │ │ ├── embed.go │ │ ├── embed_test.go │ │ ├── i18n.go │ │ ├── launcherconfig/ │ │ │ ├── config.go │ │ │ └── config_test.go │ │ ├── main.go │ │ ├── middleware/ │ │ │ ├── access_control.go │ │ │ ├── access_control_test.go │ │ │ └── middleware.go │ │ ├── model/ │ │ │ └── status.go │ │ ├── systray.go │ │ ├── systray_unix.go │ │ ├── systray_windows.go │ │ ├── tray_stub_nocgo.go │ │ ├── utils/ │ │ │ ├── banner.go │ │ │ ├── onboard.go │ │ │ ├── onboard_test.go │ │ │ └── runtime.go │ │ └── winres/ │ │ └── winres.json │ ├── frontend/ │ │ ├── .editorconfig │ │ ├── .gitignore │ │ ├── .prettierignore │ │ ├── components.json │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── package.json │ │ ├── prettier.config.js │ │ ├── public/ │ │ │ └── site.webmanifest │ │ ├── scripts/ │ │ │ └── ensure-backend-gitkeep.cjs │ │ ├── src/ │ │ │ ├── api/ │ │ │ │ ├── channels.ts │ │ │ │ ├── gateway.ts │ │ │ │ ├── models.ts │ │ │ │ ├── oauth.ts │ │ │ │ ├── pico.ts │ │ │ │ ├── sessions.ts │ │ │ │ ├── skills.ts │ │ │ │ ├── system.ts │ │ │ │ └── tools.ts │ │ │ ├── components/ │ │ │ │ ├── app-header.tsx │ │ │ │ ├── app-layout.tsx │ │ │ │ ├── app-sidebar.tsx │ │ │ │ ├── channels/ │ │ │ │ │ ├── channel-config-page.tsx │ │ │ │ │ ├── channel-display-name.ts │ │ │ │ │ └── channel-forms/ │ │ │ │ │ ├── discord-form.tsx │ │ │ │ │ ├── feishu-form.tsx │ │ │ │ │ ├── generic-form.tsx │ │ │ │ │ ├── slack-form.tsx │ │ │ │ │ └── telegram-form.tsx │ │ │ │ ├── chat/ │ │ │ │ │ ├── assistant-message.tsx │ │ │ │ │ ├── chat-composer.tsx │ │ │ │ │ ├── chat-empty-state.tsx │ │ │ │ │ ├── chat-page.tsx │ │ │ │ │ ├── model-selector.tsx │ │ │ │ │ ├── session-history-menu.tsx │ │ │ │ │ ├── typing-indicator.tsx │ │ │ │ │ └── user-message.tsx │ │ │ │ ├── config/ │ │ │ │ │ ├── config-page.tsx │ │ │ │ │ ├── config-sections.tsx │ │ │ │ │ ├── form-model.ts │ │ │ │ │ └── raw-config-page.tsx │ │ │ │ ├── credentials/ │ │ │ │ │ ├── anthropic-credential-card.tsx │ │ │ │ │ ├── antigravity-credential-card.tsx │ │ │ │ │ ├── credential-card.tsx │ │ │ │ │ ├── credentials-page.tsx │ │ │ │ │ ├── device-code-sheet.tsx │ │ │ │ │ ├── logout-confirm-dialog.tsx │ │ │ │ │ ├── openai-credential-card.tsx │ │ │ │ │ └── provider-status-line.tsx │ │ │ │ ├── logs/ │ │ │ │ │ ├── ansi-log-line.tsx │ │ │ │ │ ├── logs-page.tsx │ │ │ │ │ └── logs-panel.tsx │ │ │ │ ├── models/ │ │ │ │ │ ├── add-model-sheet.tsx │ │ │ │ │ ├── delete-model-dialog.tsx │ │ │ │ │ ├── edit-model-sheet.tsx │ │ │ │ │ ├── model-card.tsx │ │ │ │ │ ├── models-page.tsx │ │ │ │ │ ├── provider-icon.tsx │ │ │ │ │ ├── provider-label.ts │ │ │ │ │ └── provider-section.tsx │ │ │ │ ├── page-header.tsx │ │ │ │ ├── secret-placeholder.ts │ │ │ │ ├── shared-form.tsx │ │ │ │ ├── skills/ │ │ │ │ │ └── skills-page.tsx │ │ │ │ ├── tools/ │ │ │ │ │ └── tools-page.tsx │ │ │ │ └── ui/ │ │ │ │ ├── alert-dialog.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── collapsible.tsx │ │ │ │ ├── dropdown-menu.tsx │ │ │ │ ├── field.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── label.tsx │ │ │ │ ├── scroll-area.tsx │ │ │ │ ├── select.tsx │ │ │ │ ├── separator.tsx │ │ │ │ ├── sheet.tsx │ │ │ │ ├── sidebar.tsx │ │ │ │ ├── skeleton.tsx │ │ │ │ ├── switch.tsx │ │ │ │ ├── textarea.tsx │ │ │ │ └── tooltip.tsx │ │ │ ├── features/ │ │ │ │ └── chat/ │ │ │ │ ├── controller.ts │ │ │ │ ├── history.ts │ │ │ │ ├── protocol.ts │ │ │ │ ├── state.ts │ │ │ │ └── websocket.ts │ │ │ ├── hooks/ │ │ │ │ ├── use-chat-models.ts │ │ │ │ ├── use-credentials-page.ts │ │ │ │ ├── use-gateway-logs.ts │ │ │ │ ├── use-gateway.ts │ │ │ │ ├── use-log-wrap-columns.ts │ │ │ │ ├── use-mobile.ts │ │ │ │ ├── use-pico-chat.ts │ │ │ │ ├── use-session-history.ts │ │ │ │ ├── use-sidebar-channels.ts │ │ │ │ └── use-theme.ts │ │ │ ├── i18n/ │ │ │ │ ├── index.ts │ │ │ │ └── locales/ │ │ │ │ ├── en.json │ │ │ │ └── zh.json │ │ │ ├── index.css │ │ │ ├── lib/ │ │ │ │ ├── ansi-log.ts │ │ │ │ └── utils.ts │ │ │ ├── main.tsx │ │ │ ├── routeTree.gen.ts │ │ │ ├── routes/ │ │ │ │ ├── __root.tsx │ │ │ │ ├── agent/ │ │ │ │ │ ├── skills.tsx │ │ │ │ │ └── tools.tsx │ │ │ │ ├── agent.tsx │ │ │ │ ├── channels/ │ │ │ │ │ ├── $name.tsx │ │ │ │ │ └── route.tsx │ │ │ │ ├── config.raw.tsx │ │ │ │ ├── config.tsx │ │ │ │ ├── credentials.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── logs.tsx │ │ │ │ └── models.tsx │ │ │ └── store/ │ │ │ ├── chat.ts │ │ │ ├── gateway.ts │ │ │ └── index.ts │ │ ├── tsconfig.app.json │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ └── picoclaw-launcher.desktop └── workspace/ ├── AGENTS.md ├── IDENTITY.md ├── SOUL.md ├── USER.md ├── memory/ │ └── MEMORY.md └── skills/ ├── github/ │ └── SKILL.md ├── hardware/ │ ├── SKILL.md │ └── references/ │ ├── board-pinout.md │ └── common-devices.md ├── skill-creator/ │ └── SKILL.md ├── summarize/ │ └── SKILL.md ├── tmux/ │ ├── SKILL.md │ └── scripts/ │ ├── find-sessions.sh │ └── wait-for-text.sh └── weather/ └── SKILL.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ .git .gitignore build/ .picoclaw/ config/ .env .env.example *.md LICENSE assets/ ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Report a bug or unexpected behavior title: "[BUG]" labels: bug assignees: '' --- ## Quick Summary ## Environment & Tools - **PicoClaw Version:** (e.g., v0.1.2 or commit hash) - **Go Version:** (e.g., go 1.22) - **AI Model & Provider:** (e.g., GPT-4o via OpenAI / DeepSeek via SiliconFlow) - **Operating System:** (e.g., Ubuntu 22.04 / macOS / Android Termux) - **Channels:** (e.g., Discord, Telegram, Feishu, ...) ## 📸 Steps to Reproduce 1. 2. 3. ## ❌ Actual Behavior ## ✅ Expected Behavior ## 💬 Additional Context ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest a new idea or improvement title: "[Feature]" labels: enhancement assignees: '' --- ## 🎯 The Goal / Use Case ## 💡 Proposed Solution ## 🛠 Potential Implementation (Optional) ## 🚦 Impact & Roadmap Alignment - [ ] This is a Core Feature - [ ] This is a Nice-to-Have / Enhancement - [ ] This aligns with the current Roadmap ## 🔄 Alternatives Considered ## 💬 Additional Context ================================================ FILE: .github/ISSUE_TEMPLATE/general-task---todo.md ================================================ --- name: General Task / Todo about: A specific piece of work like doc, refactoring, or maintenance. title: "[Task]" labels: '' assignees: '' --- ## 📝 Objective ## 📋 To-Do List - [ ] Step 1 - [ ] Step 2 - [ ] Step 3 ## 🎯 Definition of Done (Acceptance Criteria) - [ ] Documentation is updated in the README/docs folder. - [ ] Code follows project linting standards. - [ ] (If applicable) Basic tests pass. ## 💡 Context / Motivation ## 🔗 Related Issues / PRs - Fixes # - Relates to # ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: # Go dependencies (entire repo) - package-ecosystem: "gomod" directory: "/" schedule: interval: "weekly" labels: - "dependencies" - "go" # Frontend dependencies - package-ecosystem: "npm" directory: "/web/frontend" schedule: interval: "weekly" labels: - "dependencies" - "frontend" # GitHub Actions - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" ================================================ FILE: .github/pull_request_template.md ================================================ ## 📝 Description ## 🗣️ Type of Change - [ ] 🐞 Bug fix (non-breaking change which fixes an issue) - [ ] ✨ New feature (non-breaking change which adds functionality) - [ ] 📖 Documentation update - [ ] ⚡ Code refactoring (no functional changes, no api changes) ## 🤖 AI Code Generation - [ ] 🤖 Fully AI-generated (100% AI, 0% Human) - [ ] 🛠️ Mostly AI-generated (AI draft, Human verified/modified) - [ ] 👨‍💻 Mostly Human-written (Human lead, AI assisted or none) ## 🔗 Related Issue ## 📚 Technical Context (Skip for Docs) - **Reference URL:** - **Reasoning:** ## 🧪 Test Environment - **Hardware:** - **OS:** - **Model/Provider:** - **Channels:** ## 📸 Evidence (Optional)
Click to view Logs/Screenshots
## ☑️ Checklist - [ ] My code/docs follow the style of this project. - [ ] I have performed a self-review of my own changes. - [ ] I have updated the documentation accordingly. ================================================ FILE: .github/workflows/build.yml ================================================ name: build on: push: branches: [ "main" ] jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 - name: Setup Go uses: actions/setup-go@v6 with: go-version-file: go.mod - name: Build run: make build-all ================================================ FILE: .github/workflows/docker-build.yml ================================================ name: 🐳 Build & Push Docker Image on: workflow_call: inputs: tag: description: "Release tag" required: true type: string env: GHCR_REGISTRY: ghcr.io GHCR_IMAGE_NAME: ${{ github.repository_owner }}/picoclaw DOCKERHUB_REGISTRY: docker.io DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} jobs: build: name: 🏗️ Build Docker Image runs-on: ubuntu-latest permissions: contents: read packages: write steps: # ── Checkout ────────────────────────────── - name: 📥 Checkout repository uses: actions/checkout@v6 with: ref: ${{ inputs.tag }} # ── Docker Buildx ───────────────────────── - name: 🔧 Set up Docker Buildx uses: docker/setup-buildx-action@v4 # ── Login to GHCR ───────────────────────── - name: 🔑 Login to GitHub Container Registry uses: docker/login-action@v4 with: registry: ${{ env.GHCR_REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} # ── Login to Docker Hub ──────────────────── - name: 🔑 Login to Docker Hub uses: docker/login-action@v4 with: registry: ${{ env.DOCKERHUB_REGISTRY }} username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} # ── Metadata (tags & labels) ────────────── - name: 🏷️ Prepare image tags id: tags shell: bash run: | tag="${{ inputs.tag }}" echo "ghcr_tag=${{ env.GHCR_REGISTRY }}/${{ env.GHCR_IMAGE_NAME }}:${tag}" >> "$GITHUB_OUTPUT" echo "ghcr_latest=${{ env.GHCR_REGISTRY }}/${{ env.GHCR_IMAGE_NAME }}:latest" >> "$GITHUB_OUTPUT" echo "dockerhub_tag=${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }}:${tag}" >> "$GITHUB_OUTPUT" echo "dockerhub_latest=${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }}:latest" >> "$GITHUB_OUTPUT" # ── Build & Push ────────────────────────── - name: 🚀 Build and push Docker image uses: docker/build-push-action@v7 with: context: . push: true tags: | ${{ steps.tags.outputs.ghcr_tag }} ${{ steps.tags.outputs.ghcr_latest }} ${{ steps.tags.outputs.dockerhub_tag }} ${{ steps.tags.outputs.dockerhub_latest }} cache-from: type=gha cache-to: type=gha,mode=max platforms: linux/amd64,linux/arm64,linux/riscv64 ================================================ FILE: .github/workflows/nightly.yml ================================================ name: Nightly Build on: schedule: - cron: '0 0 * * *' workflow_dispatch: permissions: contents: read jobs: nightly: name: Nightly Build runs-on: ubuntu-latest permissions: contents: write packages: write steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - name: Compute version id: version run: | DATE=$(date -u +%Y%m%d) SHA=$(git rev-parse --short=8 HEAD) BASE_VERSION=$(git describe --tags --match "v*" --exclude "*nightly*" --abbrev=0 2>/dev/null || true) if [ -z "$BASE_VERSION" ] || [ "$BASE_VERSION" = "v0.0.0" ]; then VERSION="v0.0.0-nightly.${DATE}.${SHA}" else VERSION="${BASE_VERSION}-nightly.${DATE}.${SHA}" fi COMPARE_URL="https://github.com/${{ github.repository }}/commits/main" if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "v0.0.0" ]; then COMPARE_URL="https://github.com/${{ github.repository }}/compare/${BASE_VERSION}...main" fi echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "changelog=**Full Changelog**: $COMPARE_URL" >> "$GITHUB_OUTPUT" - name: Setup Go from go.mod id: setup-go uses: actions/setup-go@v6 with: go-version-file: go.mod - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: 22 - name: Setup pnpm run: corepack enable && corepack prepare pnpm@latest --activate - name: Set up QEMU uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub uses: docker/login-action@v4 with: registry: docker.io username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Create local tag for GoReleaser run: git tag "${{ steps.version.outputs.version }}" - name: Run GoReleaser uses: goreleaser/goreleaser-action@v7 with: distribution: goreleaser version: ~> v2 args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} GOVERSION: ${{ steps.setup-go.outputs.go-version }} GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }} NIGHTLY_BUILD: "true" MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} - name: Update nightly release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.version.outputs.version }} run: | CHANGELOG='${{ steps.version.outputs.changelog }}' NOTES=$(cat </dev/null || true # Force-update nightly tag to current HEAD git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git tag -fa nightly -m "Nightly build ${VERSION}" git push origin nightly # Collect release artifacts from goreleaser dist/ ASSETS=() for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt; do [ -f "$f" ] && ASSETS+=("$f") done # Create nightly release (prerelease, NOT latest) gh release create nightly \ --title "Nightly Build" \ --notes "$NOTES" \ --target "${{ github.sha }}" \ --prerelease \ --latest=false \ "${ASSETS[@]}" ================================================ FILE: .github/workflows/pr.yml ================================================ name: PR on: pull_request: { } jobs: lint: name: Linter runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 - name: Setup Go uses: actions/setup-go@v6 with: go-version-file: go.mod - name: Run go generate run: go generate ./... - name: Golangci Lint uses: golangci/golangci-lint-action@v9 with: version: v2.10.1 vuln_check: name: Security Check runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 with: persist-credentials: false - name: Setup Go uses: actions/setup-go@v6 with: go-version-file: go.mod - name: Run Govulncheck uses: golang/govulncheck-action@v1 with: go-package: ./... test: name: Tests runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 - name: Setup Go uses: actions/setup-go@v6 with: go-version-file: go.mod - name: Run go generate run: go generate ./... - name: Run go test run: go test ./... ================================================ FILE: .github/workflows/release.yml ================================================ name: Create Tag and Release on: workflow_dispatch: inputs: tag: description: "Release tag (required, e.g. v0.2.0)" required: true type: string prerelease: description: "Mark as pre-release" required: false type: boolean default: false draft: description: "Create as draft" required: false type: boolean default: false upload_tos: description: "Upload to Volcengine TOS" required: false type: boolean default: true jobs: create-tag: name: Create Git Tag runs-on: ubuntu-latest permissions: contents: write steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - name: Create and push tag shell: bash env: RELEASE_TAG: ${{ inputs.tag }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG" git push origin "$RELEASE_TAG" release: name: GoReleaser Release needs: create-tag runs-on: ubuntu-latest permissions: contents: write packages: write steps: - name: Checkout tag uses: actions/checkout@v6 with: fetch-depth: 0 ref: ${{ inputs.tag }} - name: Setup Go from go.mod id: setup-go uses: actions/setup-go@v6 with: go-version-file: go.mod - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: 22 - name: Setup pnpm run: corepack enable && corepack prepare pnpm@latest --activate - name: Set up QEMU uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub uses: docker/login-action@v4 with: registry: docker.io username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Run GoReleaser uses: goreleaser/goreleaser-action@v7 with: distribution: goreleaser version: ~> v2 args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} GOVERSION: ${{ steps.setup-go.outputs.go-version }} MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} - name: Apply release flags shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh release edit "${{ inputs.tag }}" \ --draft=${{ inputs.draft }} \ --prerelease=${{ inputs.prerelease }} upload-tos: name: Upload to TOS needs: release if: ${{ inputs.upload_tos }} uses: ./.github/workflows/upload-tos.yml with: tag: ${{ inputs.tag }} secrets: inherit ================================================ FILE: .github/workflows/upload-tos.yml ================================================ name: Upload to Volcengine TOS on: workflow_dispatch: inputs: tag: description: "Release tag to download and upload (e.g. v0.2.0)" required: true type: string workflow_call: inputs: tag: description: "Release tag to download and upload" required: true type: string jobs: upload-tos: name: Upload to Volcengine TOS runs-on: ubuntu-latest steps: - name: Download release assets env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | mkdir -p artifacts gh release download "${{ inputs.tag }}" \ --repo "${{ github.repository }}" \ --dir artifacts \ --pattern "*.tar.gz" \ --pattern "*.zip" \ --pattern "*.rpm" \ --pattern "*.deb" - name: Upload to Volcengine TOS env: AWS_ACCESS_KEY_ID: ${{ secrets.VOLC_TOS_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.VOLC_TOS_SECRET_KEY }} AWS_DEFAULT_REGION: cn-beijing run: | aws configure set default.s3.addressing_style virtual TOS_ENDPOINT="https://tos-s3-cn-beijing.volces.com" # Upload to versioned directory aws s3 sync artifacts/ "s3://picoclaw-downloads/${{ inputs.tag }}/" \ --endpoint-url "$TOS_ENDPOINT" # Upload to latest (overwrite) aws s3 sync artifacts/ "s3://picoclaw-downloads/latest/" \ --endpoint-url "$TOS_ENDPOINT" \ --delete ================================================ FILE: .gitignore ================================================ # Binaries # Go build artifacts bin/ build/ *.exe *.dll *.so *.dylib *.test *.out /picoclaw /picoclaw-test cmd/**/workspace # Picoclaw specific # PicoClaw .picoclaw/ config.json sessions/ build/ # Coverage # Secrets & Config (keep templates, ignore actual secrets) .env config/config.json # Test coverage.txt coverage.html # OS .DS_Store # Ralph workspace ralph/ .ralph/ tasks/ # Plans docs/plans/ # Editors .vscode/ .idea/ # Added by goreleaser init: dist/ *.vite/ # Windows Application Icon/Resource *.syso # Test telegram integration cmd/telegram/ # Keep embedded backend dist directory placeholder in VCS !web/backend/dist/ web/backend/dist/* !web/backend/dist/.gitkeep ================================================ FILE: .golangci.yaml ================================================ version: "2" linters: default: all disable: # TODO: Tweak for current project needs - containedctx - cyclop - depguard - dupword - err113 - exhaustruct - funcorder - gochecknoglobals - godot - intrange - ireturn - nlreturn - noctx - noinlineerr - nonamedreturns - tagliatelle - testpackage - varnamelen - wrapcheck - wsl - wsl_v5 # TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step) - contextcheck - embeddedstructfieldcheck - errcheck - errchkjson - errorlint - exhaustive - forbidigo - forcetypeassert - funlen - gochecknoinits - gocognit - goconst - gocritic - gocyclo - godox - gosec - ineffassign - lll - maintidx - mnd - modernize - nestif - nilnil - paralleltest - perfsprint - revive - staticcheck - tagalign - testifylint - thelper - unparam - usestdlibvars - usetesting settings: errcheck: check-type-assertions: true check-blank: true exhaustive: default-signifies-exhaustive: true funlen: lines: 120 statements: 40 gocognit: min-complexity: 25 gocyclo: min-complexity: 20 govet: enable-all: true disable: - fieldalignment lll: line-length: 120 tab-width: 4 misspell: locale: US mnd: checks: - argument - assign - case - condition - operation - return nakedret: max-func-lines: 3 revive: enable-all-rules: true rules: - name: add-constant disabled: true - name: argument-limit arguments: - 7 severity: warning - name: banned-characters disabled: true - name: cognitive-complexity disabled: true - name: comment-spacings arguments: - nolint severity: warning - name: cyclomatic disabled: true - name: file-header disabled: true - name: function-result-limit arguments: - 3 severity: warning - name: function-length disabled: true - name: line-length-limit disabled: true - name: max-public-structs disabled: true - name: modifies-value-receiver disabled: true - name: package-comments disabled: true - name: unused-receiver disabled: true exclusions: generated: lax rules: - linters: - lll source: '^//go:generate ' - linters: - funlen - maintidx - gocognit - gocyclo path: _test\.go$ - linters: - nolintlint path: 'pkg/tools/(i2c\.go|spi\.go)$' issues: max-issues-per-linter: 0 max-same-issues: 0 formatters: enable: - gci - gofmt - gofumpt - goimports - golines settings: gci: sections: - standard - default - localmodule custom-order: true gofmt: simplify: true rewrite-rules: - pattern: "interface{}" replacement: "any" - pattern: "a[b:len(a)]" replacement: "a[b:]" golines: max-len: 120 ================================================ FILE: .goreleaser.yaml ================================================ # yaml-language-server: $schema=https://goreleaser.com/static/schema.json # vim: set ts=2 sw=2 tw=0 fo=cnqoj version: 2 before: hooks: - go mod tidy - go generate ./... - sh -c 'cd web/frontend && pnpm install && pnpm build:backend' - go install github.com/tc-hib/go-winres@latest - go-winres make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }} builds: - id: picoclaw env: - CGO_ENABLED=0 tags: - stdjson ldflags: - -s -w - -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }} - -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }} - -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }} - -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ .Env.GOVERSION }} goos: - linux - windows - darwin - freebsd - netbsd goarch: - amd64 - arm64 - riscv64 - loong64 - arm - s390x - mipsle goarm: - "6" - "7" gomips: - softfloat main: ./cmd/picoclaw ignore: - goos: windows goarch: arm - goos: netbsd goarch: s390x - goos: netbsd goarch: mips64 - goos: netbsd goarch: arm - id: picoclaw-launcher binary: picoclaw-launcher env: - CGO_ENABLED=0 tags: - stdjson ldflags: - -s -w goos: - linux - windows - darwin - freebsd - netbsd goarch: - amd64 - arm64 - riscv64 - loong64 - arm - s390x - mipsle goarm: - "6" - "7" gomips: - softfloat main: ./web/backend ignore: - goos: windows goarch: arm - goos: netbsd goarch: s390x - goos: netbsd goarch: mips64 - goos: netbsd goarch: arm - id: picoclaw-launcher-tui binary: picoclaw-launcher-tui env: - CGO_ENABLED=0 tags: - stdjson ldflags: - -s -w goos: - linux - windows - darwin - freebsd - netbsd goarch: - amd64 - arm64 - riscv64 - loong64 - arm - s390x - mipsle goarm: - "6" - "7" gomips: - softfloat main: ./cmd/picoclaw-launcher-tui ignore: - goos: windows goarch: arm - goos: netbsd goarch: s390x - goos: netbsd goarch: mips64 - goos: netbsd goarch: arm dockers_v2: - id: picoclaw dockerfile: docker/Dockerfile.goreleaser extra_files: - docker/entrypoint.sh ids: - picoclaw images: - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw" - 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}' tags: - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}{{ .Tag }}{{ end }}' - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}' platforms: - linux/amd64 - linux/arm64 - linux/riscv64 - id: picoclaw-launcher dockerfile: docker/Dockerfile.goreleaser.launcher ids: - picoclaw - picoclaw-launcher - picoclaw-launcher-tui images: - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw" - 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}' tags: - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}{{ .Tag }}-launcher{{ end }}' - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}launcher{{ end }}' platforms: - linux/amd64 - linux/arm64 - linux/riscv64 notarize: macos: - enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}' ids: - picoclaw - picoclaw-launcher - picoclaw-launcher-tui sign: certificate: "{{.Env.MACOS_SIGN_P12}}" password: "{{.Env.MACOS_SIGN_PASSWORD}}" notarize: issuer_id: "{{.Env.MACOS_NOTARY_ISSUER_ID}}" key_id: "{{.Env.MACOS_NOTARY_KEY_ID}}" key: "{{.Env.MACOS_NOTARY_KEY}}" wait: true timeout: 20m archives: - formats: [tar.gz] # this name template makes the OS and Arch compatible with the results of `uname`. name_template: >- {{ .ProjectName }}_ {{- title .Os }}_ {{- if eq .Arch "amd64" }}x86_64 {{- else if eq .Arch "386" }}i386 {{- else }}{{ .Arch }}{{ end }} {{- if .Arm }}v{{ .Arm }}{{ end }} # use zip for windows archives format_overrides: - goos: windows formats: [zip] nfpms: - id: picoclaw ids: - picoclaw - picoclaw-launcher - picoclaw-launcher-tui package_name: picoclaw file_name_template: >- {{ .PackageName }}_ {{- if eq .Arch "amd64" }}x86_64 {{- else if eq .Arch "arm64" }}aarch64 {{- else if eq .Arch "arm" }}armv{{ .Arm }} {{- else }}{{ .Arch }}{{ end }} vendor: picoclaw homepage: https://github.com/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw maintainer: picoclaw contributors description: picoclaw - a tool for managing and running tasks license: MIT formats: - rpm - deb bindir: /usr/bin contents: - src: web/picoclaw-launcher.desktop dst: /usr/share/applications/picoclaw-launcher.desktop - src: web/picoclaw-launcher.png dst: /usr/share/icons/hicolor/512x512/apps/picoclaw-launcher.png changelog: sort: asc filters: exclude: - "^docs:" - "^test:" # upx: # - enabled: true # compress: best # lzma: true release: disable: '{{ isEnvSet "NIGHTLY_BUILD" }}' footer: >- --- Released by [GoReleaser](https://github.com/goreleaser/goreleaser). ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to PicoClaw Thank you for your interest in contributing to PicoClaw! This project is a community-driven effort to build the lightweight and versatile personal AI assistant. We welcome contributions of all kinds: bug fixes, features, documentation, translations, and testing. PicoClaw itself was substantially developed with AI assistance — we embrace this approach and have built our contribution process around it. ## Table of Contents - [Code of Conduct](#code-of-conduct) - [Ways to Contribute](#ways-to-contribute) - [Getting Started](#getting-started) - [Development Setup](#development-setup) - [Making Changes](#making-changes) - [AI-Assisted Contributions](#ai-assisted-contributions) - [Pull Request Process](#pull-request-process) - [Branch Strategy](#branch-strategy) - [Code Review](#code-review) - [Communication](#communication) --- ## Code of Conduct We are committed to maintaining a welcoming and respectful community. Be kind, constructive, and assume good faith. Harassment or discrimination of any kind will not be tolerated. --- ## Ways to Contribute - **Bug reports** — Open an issue using the bug report template. - **Feature requests** — Open an issue using the feature request template; discuss before implementing. - **Code** — Fix bugs or implement features. See the workflow below. - **Documentation** — Improve READMEs, docs, inline comments, or translations. - **Testing** — Run PicoClaw on new hardware, channels, or LLM providers and report your results. For substantial new features, please open an issue first to discuss the design before writing code. This prevents wasted effort and ensures alignment with the project's direction. --- ## Getting Started 1. **Fork** the repository on GitHub. 2. **Clone** your fork locally: ```bash git clone https://github.com//picoclaw.git cd picoclaw ``` 3. Add the upstream remote: ```bash git remote add upstream https://github.com/sipeed/picoclaw.git ``` --- ## Development Setup ### Prerequisites - Go 1.25 or later - `make` ### Build ```bash make build # Build binary (runs go generate first) make generate # Run go generate only make check # Full pre-commit check: deps + fmt + vet + test ``` ### Running Tests ```bash make test # Run all tests go test -run TestName -v ./pkg/session/ # Run a single test go test -bench=. -benchmem -run='^$' ./... # Run benchmarks ``` ### Code Style ```bash make fmt # Format code make vet # Static analysis make lint # Full linter run ``` All CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early. --- ## Making Changes ### Branching Always branch off `main` and target `main` in your PR. Never push directly to `main` or any `release/*` branch: ```bash git checkout main git pull upstream main git checkout -b your-feature-branch ``` Use descriptive branch names, e.g. `fix/telegram-timeout`, `feat/ollama-provider`, `docs/contributing-guide`. ### Commits - Write clear, concise commit messages in English. - Use the imperative mood: "Add retry logic" not "Added retry logic". - Reference the related issue when relevant: `Fix session leak (#123)`. - Keep commits focused. One logical change per commit is preferred. - For minor cleanups or typo fixes, squash them into a single commit before opening a PR. - Refer to https://www.conventionalcommits.org/zh-hans/v1.0.0/ ### Keeping Up to Date Rebase your branch onto upstream `main` before opening a PR: ```bash git fetch upstream git rebase upstream/main ``` --- ## AI-Assisted Contributions PicoClaw was built with substantial AI assistance, and we fully embrace AI-assisted development. However, contributors must understand their responsibilities when using AI tools. ### Disclosure Is Required Every PR must disclose AI involvement using the PR template's **🤖 AI Code Generation** section. There are three levels: | Level | Description | |---|---| | 🤖 Fully AI-generated | AI wrote the code; contributor reviewed and validated it | | 🛠️ Mostly AI-generated | AI produced the draft; contributor made significant modifications | | 👨‍💻 Mostly Human-written | Contributor led; AI provided suggestions or none at all | Honest disclosure is expected. There is no stigma attached to any level — what matters is the quality of the contribution. ### You Are Responsible for What You Submit Using AI to generate code does not reduce your responsibility as the contributor. Before opening a PR with AI-generated code, you must: - **Read and understand** every line of the generated code. - **Test it** in a real environment (see the Test Environment section of the PR template). - **Check for security issues** — AI models can generate subtly insecure code (e.g., path traversal, injection, credential exposure). Review carefully. - **Verify correctness** — AI-generated logic can be plausible-sounding but wrong. Validate the behavior, not just the syntax. PRs where it is clear the contributor has not read or tested the AI-generated code will be closed without review. ### AI-Generated Code Quality Standards AI-generated contributions are held to the **same quality bar** as human-written code: - It must pass all CI checks (`make check`). - It must be idiomatic Go and consistent with the existing codebase style. - It must not introduce unnecessary abstractions, dead code, or over-engineering. - It must include or update tests where appropriate. ### Security Review AI-generated code requires extra security scrutiny. Pay special attention to: - File path handling and sandbox escapes (see commit `244eb0b` for a real example) - External input validation in channel handlers and tool implementations - Credential or secret handling - Command execution (`exec.Command`, shell invocations) If you are unsure whether a piece of AI-generated code is safe, say so in the PR — reviewers will help. --- ## Pull Request Process ### Before Opening a PR - [ ] Run `make check` and ensure it passes locally. - [ ] Fill in the PR template completely, including the AI disclosure section. - [ ] Link any related issue(s) in the PR description. - [ ] Keep the PR focused. Avoid bundling unrelated changes together. ### PR Template Sections The PR template asks for: - **Description** — What does this change do and why? - **Type of Change** — Bug fix, feature, docs, or refactor. - **AI Code Generation** — Disclosure of AI involvement (required). - **Related Issue** — Link to the issue this addresses. - **Technical Context** — Reference URLs and reasoning (skip for pure docs PRs). - **Test Environment** — Hardware, OS, model/provider, and channels used for testing. - **Evidence** — Optional logs or screenshots demonstrating the change works. - **Checklist** — Self-review confirmation. ### PR Size Prefer small, reviewable PRs. A PR that changes 200 lines across 5 files is much easier to review than one that changes 2000 lines across 30 files. If your feature is large, consider splitting it into a series of smaller, logically complete PRs. --- ## Branch Strategy ### Long-Lived Branches - **`main`** — the active development branch. All feature PRs target `main`. The branch is protected: direct pushes are not permitted, and at least one maintainer approval is required before merging. - **`release/x.y`** — stable release branches, cut from `main` when a version is ready to ship. These branches are more strictly protected than `main`. ### Requirements to Merge into `main` A PR can only be merged when all of the following are satisfied: 1. **CI passes** — All GitHub Actions workflows (lint, test, build) must be green. 2. **Reviewer approval** — At least one maintainer has approved the PR. 3. **No unresolved review comments** — All review threads must be resolved. 4. **PR template is complete** — Including AI disclosure and test environment. ### Who Can Merge Only maintainers can merge PRs. Contributors cannot merge their own PRs, even if they have write access. ### Merge Strategy We use **squash merge** for most PRs to keep the `main` history clean and readable. Each merged PR becomes a single commit referencing the PR number, e.g.: ``` feat: Add Ollama provider support (#491) ``` If a PR consists of multiple independent, well-separated commits that tell a clear story, a regular merge may be used at the maintainer's discretion. ### Release Branches When a version is ready, maintainers cut a `release/x.y` branch from `main`. After that point: - **New features are not backported.** The release branch receives no new functionality after it is cut. - **Security fixes and critical bug fixes are cherry-picked.** If a fix in `main` qualifies (security vulnerability, data loss, crash), maintainers will cherry-pick the relevant commit(s) onto the affected `release/x.y` branch and issue a patch release. If you believe a fix in `main` should be backported to a release branch, note it in the PR description or open a separate issue. The decision rests with the maintainers. Release branches have stricter protections than `main` and are never directly pushed to under any circumstances. --- ## Code Review ### For Contributors - Respond to review comments within a reasonable time. If you need more time, say so. - When you update a PR in response to feedback, briefly note what changed (e.g., "Updated to use `sync.RWMutex` as suggested"). - If you disagree with feedback, engage respectfully. Explain your reasoning; reviewers can be wrong too. - Do not force-push after a review has started — it makes it harder for reviewers to see what changed. Use additional commits instead; the maintainer will squash on merge. ### For Reviewers Review for: 1. **Correctness** — Does the code do what it claims? Are there edge cases? 2. **Security** — Especially for AI-generated code, tool implementations, and channel handlers. 3. **Architecture** — Is the approach consistent with the existing design? 4. **Simplicity** — Is there a simpler solution? Does this add unnecessary complexity? 5. **Tests** — Are the changes covered by tests? Are existing tests still meaningful? Be constructive and specific. "This could have a race condition if two goroutines call this concurrently — consider using a mutex here" is better than "this looks wrong". ### Reviewer List Once your PR is submitted, you can reach out to the assigned reviewers listed in the following table. |Function| Reviewer| |--- |--- | |Provider|@yinwm | |Channel |@yinwm/@alexhoshina | |Agent |@lxowalle/@Zhaoyikaiii| |Tools |@lxowalle| |SKill || |MCP || |Optimization|@lxowalle| |Security|| |AI CI |@imguoguo| |UX || |Document|| --- ## Communication - **GitHub Issues** — Bug reports, feature requests, design discussions. - **GitHub Discussions** — General questions, ideas, community conversation. - **Pull Request comments** — Code-specific feedback. - **Wechat&Discord** — We will invite you when you have at least one merged PR When in doubt, open an issue before writing code. It costs little and prevents wasted effort. --- ## A Note on the Project's AI-Driven Origin PicoClaw's architecture was substantially designed and implemented with AI assistance, guided by human oversight. If you find something that looks odd or over-engineered, it may be an artifact of that process — opening an issue to discuss it is always welcome. We believe AI-assisted development done responsibly produces great results. We also believe humans must remain accountable for what they ship. These two beliefs are not in conflict. Thank you for contributing! ================================================ FILE: CONTRIBUTING.zh.md ================================================ # 参与贡献 PicoClaw 感谢你对 PicoClaw 的关注!本项目是一个社区驱动的开源项目,目标是构建 轻量灵活,人人可用 的个人AI助手。我们欢迎一切形式的贡献:Bug 修复、新功能、文档、翻译和测试。 PicoClaw 本身在很大程度上是借助 AI 辅助开发的——我们拥抱这种方式,并围绕它构建了贡献流程。 ## 目录 - [行为准则](#行为准则) - [贡献方式](#贡献方式) - [快速开始](#快速开始) - [开发环境配置](#开发环境配置) - [提交修改](#提交修改) - [AI 辅助贡献](#ai-辅助贡献) - [Pull Request 流程](#pull-request-流程) - [分支策略](#分支策略) - [代码审查](#代码审查) - [沟通渠道](#沟通渠道) --- ## 行为准则 我们致力于维护一个友好、互相尊重的社区环境。请保持善意、建设性的态度,并善意地理解他人。任何形式的骚扰或歧视均不被接受。 --- ## 贡献方式 - **Bug 反馈** — 使用 Bug 报告模板提交 Issue。 - **功能建议** — 使用功能请求模板提交 Issue,建议在开始实现前先进行讨论。 - **代码贡献** — 修复 Bug 或实现新功能,参见下方工作流程。 - **文档改进** — 完善 README、文档、代码注释或翻译。 - **测试与验证** — 在新硬件、新渠道或新 LLM 提供商上运行 PicoClaw 并反馈结果。 对于较大的新功能,请先提交 Issue 讨论设计方案,再动手写代码。这能避免无效投入,也确保与项目方向保持一致。 --- ## 快速开始 1. 在 GitHub 上 **Fork** 本仓库。 2. 将你的 Fork **克隆**到本地: ```bash git clone https://github.com/<你的用户名>/picoclaw.git cd picoclaw ``` 3. 添加上游远程仓库: ```bash git remote add upstream https://github.com/sipeed/picoclaw.git ``` --- ## 开发环境配置 ### 前置依赖 - Go 1.25 或更高版本 - `make` ### 构建 ```bash make build # 构建二进制文件(会先执行 go generate) make generate # 仅执行 go generate make check # 完整的提交前检查:deps + fmt + vet + test ``` ### 运行测试 ```bash make test # 运行所有测试 go test -run TestName -v ./pkg/session/ # 运行单个测试 go test -bench=. -benchmem -run='^$' ./... # 运行基准测试 ``` ### 代码风格 ```bash make fmt # 格式化代码 make vet # 静态分析 make lint # 完整的 lint 检查 ``` 所有 CI 检查通过后 PR 才能被合并。推送代码前请先在本地运行 `make check`,提前发现问题。 --- ## 提交修改 ### 分支管理 始终从 `main` 分支切出,并在 PR 中以 `main` 为目标分支。不要直接向 `main` 或任何 `release/*` 分支推送代码: ```bash git checkout main git pull upstream main git checkout -b 你的功能分支名 ``` 请使用描述性的分支名,例如:`fix/telegram-timeout`、`feat/ollama-provider`、`docs/contributing-guide`。 ### Commit 规范 - 使用英文撰写清晰、简洁的 commit 信息。 - 使用祈使句:写 "Add retry logic",而不是 "Added retry logic"。 - 有关联 Issue 时请引用:`Fix session leak (#123)`。 - 保持 commit 专注,每个 commit 只做一件事。 - 对于小的清理或拼写修正,提 PR 前请将其合并为一个 commit。 - 按照 https://www.conventionalcommits.org/zh-hans/v1.0.0/ 规范来撰写 ### 保持与上游同步 提 PR 前,请将你的分支变基到上游 `main`: ```bash git fetch upstream git rebase upstream/main ``` --- ## AI 辅助贡献 PicoClaw 在很大程度上借助 AI 辅助开发,我们完全拥抱这种开发方式。但贡献者必须清楚地了解自己在使用 AI 工具时所承担的责任。 ### 必须披露 AI 使用情况 每个 PR 都必须通过 PR 模板中的 **🤖 AI 代码生成** 部分披露 AI 参与情况,共分三个级别: | 级别 | 说明 | |---|---| | 🤖 完全由 AI 生成 | AI 编写代码,贡献者负责审查和验证 | | 🛠️ 主要由 AI 生成 | AI 起草,贡献者做了较大修改 | | 👨‍💻 主要由人工编写 | 贡献者主导,AI 仅提供辅助或未使用 AI | 我们期望你诚实填写。三种级别均可接受,没有任何歧视——重要的是贡献的质量。 ### 你对提交的代码负全责 使用 AI 生成代码并不能减轻你作为贡献者的责任。在提交含有 AI 生成代码的 PR 之前,你必须: - **逐行阅读并理解**生成的代码。 - **在真实环境中测试**(参见 PR 模板中的测试环境部分)。 - **检查安全问题** — AI 模型可能生成存在安全隐患的代码(如路径穿越、注入攻击、凭据泄露等),请仔细审查。 - **验证正确性** — AI 生成的逻辑可能听起来合理但实际上是错误的,请验证行为,而不仅仅是语法。 如果明显可以看出贡献者没有阅读或测试 AI 生成的代码,该 PR 将被直接关闭,不予审查。 ### AI 生成代码的质量标准 AI 生成的代码与人工编写的代码遵循**相同的质量要求**: - 必须通过所有 CI 检查(`make check`)。 - 必须符合 Go 惯用写法,并与现有代码库的风格保持一致。 - 不得引入不必要的抽象、死代码或过度设计。 - 须在适当的地方包含或更新测试。 ### 安全审查 AI 生成的代码需要格外仔细的安全审查。请特别关注以下方面: - 文件路径处理与沙箱逃逸(项目历史中的 commit `244eb0b` 就是真实案例) - channel 处理器和 tool 实现中的外部输入校验 - 凭据或密钥的处理 - 命令执行(`exec.Command`、shell 调用等) 如果你不确定某段 AI 生成代码是否安全,请在 PR 中说明——审查者会帮助判断。 --- ## Pull Request 流程 ### 提 PR 前的检查 - [ ] 在本地运行 `make check` 并确认通过。 - [ ] 完整填写 PR 模板,包括 AI 披露部分。 - [ ] 在 PR 描述中关联相关 Issue。 - [ ] 保持 PR 专注,避免将不相关的修改混在一起。 ### PR 模板各部分说明 PR 模板要求填写: - **描述** — 这个改动做了什么,为什么要做? - **变更类型** — Bug 修复、新功能、文档或重构。 - **AI 代码生成** — AI 参与情况披露(必填)。 - **关联 Issue** — 此 PR 解决的 Issue 链接。 - **技术背景** — 参考链接和设计理由(纯文档类 PR 可跳过)。 - **测试环境** — 用于测试的硬件、操作系统、模型/提供商和渠道。 - **验证证据** — 可选的日志或截图,用于证明改动有效。 - **检查清单** — 自我审查确认。 ### PR 规模 请尽量提交小而易于审查的 PR。一个涉及 5 个文件共 200 行改动的 PR,远比涉及 30 个文件共 2000 行改动的 PR 容易审查。如果你的功能较大,可以考虑将其拆分为一系列逻辑完整的小 PR。 --- ## 分支策略 ### 长期分支 - **`main`** — 活跃开发分支。所有功能 PR 均以 `main` 为目标。该分支受保护:禁止直接推送,合并前必须获得至少一名维护者的批准。 - **`release/x.y`** — 稳定发布分支,在某个版本准备发布时从 `main` 切出。这些分支的保护级别高于 `main`。 ### 合并到 `main` 的前提条件 PR 必须同时满足以下所有条件,才能被合并: 1. **CI 全部通过** — 所有 GitHub Actions 工作流(lint、test、build)均为绿色。 2. **获得审查者批准** — 至少一名维护者已批准该 PR。 3. **无未解决的审查意见** — 所有审查讨论线程均已关闭。 4. **PR 模板填写完整** — 包括 AI 披露和测试环境信息。 ### 谁可以合并 只有维护者才能合并 PR。贡献者不能合并自己的 PR,即使拥有写权限也不行。 ### 合并策略 为保持 `main` 历史清晰可读,我们对大多数 PR 使用 **Squash Merge**。每个合并的 PR 变为一个包含 PR 编号的单独 commit,例如: ``` feat: Add Ollama provider support (#491) ``` 如果一个 PR 包含多个独立、结构清晰、能讲述完整故事的 commit,维护者可视情况使用普通 merge。 ### Release 分支 当某个版本准备就绪时,维护者会从 `main` 切出 `release/x.y` 分支。此后: - **新功能不会被回溯(backport)。** Release 分支切出后,不再接收任何新功能。 - **安全修复和关键 Bug 修复会被 cherry-pick 进来。** 若 `main` 上的某个修复属于安全漏洞、数据丢失或崩溃类问题,维护者会将相关 commit cherry-pick 到受影响的 `release/x.y` 分支,并发布补丁版本。 如果你认为 `main` 上的某个修复应该被回溯到某个 release 分支,请在 PR 描述中注明,或单独开一个 Issue 说明。最终决定由维护者做出。 Release 分支的保护级别高于 `main`,在任何情况下均不允许直接推送。 --- ## 代码审查 ### 对贡献者的建议 - 在合理时间内回复审查意见。如果需要更多时间,请告知。 - 更新 PR 以响应反馈时,简要说明改动内容(例如:"按建议改用了 `sync.RWMutex`")。 - 如果你不同意某条反馈,请礼貌地阐述你的理由——审查者也可能有判断失误的时候。 - 审查开始后请不要 force push——这会让审查者难以追踪变化。请使用额外的 commit,维护者在合并时会进行 squash。 ### 对审查者的建议 审查重点: 1. **正确性** — 代码是否实现了其声称的功能?是否存在边界情况? 2. **安全性** — 对 AI 生成代码、tool 实现和 channel 处理器尤其需要关注。 3. **架构** — 实现方式是否与现有设计一致? 4. **简洁性** — 是否有更简单的方案?是否引入了不必要的复杂度? 5. **测试** — 改动是否有测试覆盖?现有测试是否仍然有意义? 请给出建设性且具体的反馈。"如果两个 goroutine 同时调用这个函数可能会有竞态条件,建议在这里加一个 mutex" 远比 "这里看起来有问题" 更有帮助。 ### 审查者列表 提交对应PR后,可以参考下表联系对应的审查人员沟通 |Function| Reviewer| |--- |--- | |Provider|@yinwm | |Channel |@yinwm/@alexhoshina | |Agent |@lxowalle/@Zhaoyikaiii| |Tools |@lxowalle| |SKill || |MCP || |Optimization|@lxowalle| |Security|| |AI CI |@imguoguo| |UX || |Document|| --- ## 沟通渠道 - **GitHub Issues** — Bug 报告、功能建议、设计讨论。 - **GitHub Discussions** — 一般性问题、想法交流、社区讨论。 - **Pull Request 评论** — 与具体代码相关的反馈。 - **Wechat&Discord** — 当你有至少一个已合并的PR后,我们会邀请你加入开发者交流群 有疑问时,请先开 Issue 讨论,再动手写代码。这几乎没有成本,却能避免大量无效投入。 --- ## 关于本项目的 AI 驱动起源 PicoClaw 的架构在人工监督下,经由 AI 辅助完成了大量设计和实现工作。如果你发现某处看起来奇怪或过度设计,这可能是该过程留下的痕迹——欢迎提 Issue 讨论。 我们相信,负责任地使用 AI 辅助开发能产生优秀的成果。我们同样相信,人类必须对自己提交的内容负责。这两点并不矛盾。 感谢你的贡献! ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2026 PicoClaw contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ .PHONY: all build install uninstall clean help test # Build variables BINARY_NAME=picoclaw BUILD_DIR=build CMD_DIR=cmd/$(BINARY_NAME) MAIN_GO=$(CMD_DIR)/main.go # Version VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev") BUILD_TIME=$(shell date +%FT%T%z) GO_VERSION=$(shell $(GO) version | awk '{print $$3}') CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w # Go variables GO?=CGO_ENABLED=0 go WEB_GO?=$(GO) GOFLAGS?=-v -tags stdjson # Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600). # # Bytes (octal): \004 \024 \000 \160 → little-endian 0x70001404 # 0x70000000 EF_MIPS_ARCH_32R2 MIPS32 Release 2 # 0x00001000 EF_MIPS_ABI_O32 O32 ABI # 0x00000400 EF_MIPS_NAN2008 IEEE 754-2008 NaN encoding # 0x00000004 EF_MIPS_CPIC PIC calling sequence # # Go's GOMIPS=softfloat emits no FP instructions, so the NaN mode is irrelevant # at runtime — this is purely an ELF metadata fix to satisfy the kernel's check. # patchelf cannot modify e_flags; dd at a fixed offset is the most portable way. # # Ref: https://codebrowser.dev/linux/linux/arch/mips/include/asm/elf.h.html define PATCH_MIPS_FLAGS @if [ -f "$(1)" ]; then \ printf '\004\024\000\160' | dd of=$(1) bs=1 seek=36 count=4 conv=notrunc 2>/dev/null || \ { echo "Error: failed to patch MIPS e_flags for $(1)"; exit 1; }; \ else \ echo "Error: $(1) not found, cannot patch MIPS e_flags"; exit 1; \ fi endef # Golangci-lint GOLANGCI_LINT?=golangci-lint # Installation INSTALL_PREFIX?=$(HOME)/.local INSTALL_BIN_DIR=$(INSTALL_PREFIX)/bin INSTALL_MAN_DIR=$(INSTALL_PREFIX)/share/man/man1 INSTALL_TMP_SUFFIX=.new # Workspace and Skills PICOCLAW_HOME?=$(HOME)/.picoclaw WORKSPACE_DIR?=$(PICOCLAW_HOME)/workspace WORKSPACE_SKILLS_DIR=$(WORKSPACE_DIR)/skills BUILTIN_SKILLS_DIR=$(CURDIR)/skills # OS detection UNAME_S:=$(shell uname -s) UNAME_M:=$(shell uname -m) # Platform-specific settings ifeq ($(UNAME_S),Linux) PLATFORM=linux ifeq ($(UNAME_M),x86_64) ARCH=amd64 else ifeq ($(UNAME_M),aarch64) ARCH=arm64 else ifeq ($(UNAME_M),armv81) ARCH=arm64 else ifeq ($(UNAME_M),loongarch64) ARCH=loong64 else ifeq ($(UNAME_M),riscv64) ARCH=riscv64 else ifeq ($(UNAME_M),mipsel) ARCH=mipsle else ARCH=$(UNAME_M) endif else ifeq ($(UNAME_S),Darwin) PLATFORM=darwin WEB_GO=CGO_ENABLED=1 go ifeq ($(UNAME_M),x86_64) ARCH=amd64 else ifeq ($(UNAME_M),arm64) ARCH=arm64 else ARCH=$(UNAME_M) endif else PLATFORM=$(UNAME_S) ARCH=$(UNAME_M) endif BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH) # Default target all: build ## generate: Run generate generate: @echo "Run generate..." @rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true @$(GO) generate ./... @echo "Run generate complete" ## build: Build the picoclaw binary for current platform build: generate @echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..." @mkdir -p $(BUILD_DIR) @$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR) @echo "Build complete: $(BINARY_PATH)" @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) ## build-launcher: Build the picoclaw-launcher (web console) binary build-launcher: @echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..." @mkdir -p $(BUILD_DIR) @if [ ! -f web/backend/dist/index.html ]; then \ echo "Building frontend..."; \ cd web/frontend && pnpm install && pnpm build:backend; \ fi @$(WEB_GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend @ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher @echo "Build complete: $(BUILD_DIR)/picoclaw-launcher" ## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary build-whatsapp-native: generate ## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..." @echo "Building for multiple platforms..." @mkdir -p $(BUILD_DIR) GOOS=linux GOARCH=amd64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) $(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle) GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR) GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) ## @$(GO) build $(GOFLAGS) -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR) @echo "Build complete" ## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) ## build-linux-arm: Build for Linux ARMv7 (e.g. Raspberry Pi Zero 2 W 32-bit) build-linux-arm: generate @echo "Building for linux/arm (GOARM=7)..." @mkdir -p $(BUILD_DIR) GOOS=linux GOARCH=arm GOARM=7 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm" ## build-linux-arm64: Build for Linux ARM64 (e.g. Raspberry Pi Zero 2 W 64-bit) build-linux-arm64: generate @echo "Building for linux/arm64..." @mkdir -p $(BUILD_DIR) GOOS=linux GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64" ## build-linux-mipsle: Build for Linux MIPS32 LE build-linux-mipsle: generate @echo "Building for linux/mipsle (softfloat)..." @mkdir -p $(BUILD_DIR) GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) $(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle) @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle" ## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit) build-pi-zero: build-linux-arm build-linux-arm64 @echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)" ## build-all: Build picoclaw for all platforms build-all: generate @echo "Building for multiple platforms..." @mkdir -p $(BUILD_DIR) GOOS=linux GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) GOOS=linux GOARCH=arm GOARM=7 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) GOOS=linux GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) GOOS=linux GOARCH=loong64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) GOOS=linux GOARCH=riscv64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) $(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle) GOOS=linux GOARCH=arm GOARM=7 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR) GOOS=darwin GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR) GOOS=windows GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) GOOS=netbsd GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR) GOOS=netbsd GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR) @echo "All builds complete" ## install: Install picoclaw to system and copy builtin skills install: build @echo "Installing $(BINARY_NAME)..." @mkdir -p $(INSTALL_BIN_DIR) # Copy binary with temporary suffix to ensure atomic update @cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX) @chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX) @mv -f $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX) $(INSTALL_BIN_DIR)/$(BINARY_NAME) @echo "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)" @echo "Installation complete!" ## uninstall: Remove picoclaw from system uninstall: @echo "Uninstalling $(BINARY_NAME)..." @rm -f $(INSTALL_BIN_DIR)/$(BINARY_NAME) @echo "Removed binary from $(INSTALL_BIN_DIR)/$(BINARY_NAME)" @echo "Note: Only the executable file has been deleted." @echo "If you need to delete all configurations (config.json, workspace, etc.), run 'make uninstall-all'" ## uninstall-all: Remove picoclaw and all data uninstall-all: @echo "Removing workspace and skills..." @rm -rf $(PICOCLAW_HOME) @echo "Removed workspace: $(PICOCLAW_HOME)" @echo "Complete uninstallation done!" ## clean: Remove build artifacts clean: @echo "Cleaning build artifacts..." @rm -rf $(BUILD_DIR) @echo "Clean complete" ## vet: Run go vet for static analysis vet: generate @packages="$$(go list ./...)" && \ $(GO) vet $$(printf '%s\n' "$$packages" | grep -v '^github.com/sipeed/picoclaw/web/') @cd web/backend && $(WEB_GO) vet ./... ## test: Test Go code test: generate @$(GO) test $$(go list ./... | grep -v github.com/sipeed/picoclaw/web/) @cd web && make test ## fmt: Format Go code fmt: @$(GOLANGCI_LINT) fmt ## lint: Run linters lint: @$(GOLANGCI_LINT) run ## fix: Fix linting issues fix: @$(GOLANGCI_LINT) run --fix ## deps: Download dependencies deps: @$(GO) mod download @$(GO) mod verify ## update-deps: Update dependencies update-deps: @$(GO) get -u ./... @$(GO) mod tidy ## check: Run vet, fmt, and verify dependencies check: deps fmt vet test ## run: Build and run picoclaw run: build @$(BUILD_DIR)/$(BINARY_NAME) $(ARGS) ## docker-build: Build Docker image (minimal Alpine-based) docker-build: @echo "Building minimal Docker image (Alpine-based)..." docker compose -f docker/docker-compose.yml build picoclaw-agent picoclaw-gateway ## docker-build-full: Build Docker image with full MCP support (Node.js 24) docker-build-full: @echo "Building full-featured Docker image (Node.js 24)..." docker compose -f docker/docker-compose.full.yml build picoclaw-agent picoclaw-gateway ## docker-test: Test MCP tools in Docker container docker-test: @echo "Testing MCP tools in Docker..." @chmod +x scripts/test-docker-mcp.sh @./scripts/test-docker-mcp.sh ## docker-run: Run picoclaw gateway in Docker (Alpine-based) docker-run: docker compose -f docker/docker-compose.yml --profile gateway up ## docker-run-full: Run picoclaw gateway in Docker (full-featured) docker-run-full: docker compose -f docker/docker-compose.full.yml --profile gateway up ## docker-run-agent: Run picoclaw agent in Docker (interactive, Alpine-based) docker-run-agent: docker compose -f docker/docker-compose.yml run --rm picoclaw-agent ## docker-run-agent-full: Run picoclaw agent in Docker (interactive, full-featured) docker-run-agent-full: docker compose -f docker/docker-compose.full.yml run --rm picoclaw-agent ## docker-clean: Clean Docker images and volumes docker-clean: docker compose -f docker/docker-compose.yml down -v docker compose -f docker/docker-compose.full.yml down -v docker rmi picoclaw:latest picoclaw:full 2>/dev/null || true ## build-macos-app: Build PicoClaw macOS .app bundle (no terminal window) build-macos-app: @echo "Building macOS .app bundle..." @if [ "$(UNAME_S)" != "Darwin" ]; then \ echo "Error: This target is only available on macOS"; \ exit 1; \ fi @cd web && $(MAKE) build && cd .. @./scripts/build-macos-app.sh $(BINARY_NAME)-$(PLATFORM)-$(ARCH) @echo "macOS .app bundle created: $(BUILD_DIR)/PicoClaw.app" ## help: Show this help message help: @echo "picoclaw Makefile" @echo "" @echo "Usage:" @echo " make [target]" @echo "" @echo "Targets:" @grep -E '^## ' $(MAKEFILE_LIST) | sort | awk -F': ' '{printf " %-16s %s\n", substr($$1, 4), $$2}' @echo "" @echo "Examples:" @echo " make build # Build for current platform" @echo " make install # Install to ~/.local/bin" @echo " make uninstall # Remove from /usr/local/bin" @echo " make install-skills # Install skills to workspace" @echo " make docker-build # Build minimal Docker image" @echo " make docker-test # Test MCP tools in Docker" @echo "" @echo "Environment Variables:" @echo " INSTALL_PREFIX # Installation prefix (default: ~/.local)" @echo " WORKSPACE_DIR # Workspace directory (default: ~/.picoclaw/workspace)" @echo " VERSION # Version string (default: git describe)" @echo "" @echo "Current Configuration:" @echo " Platform: $(PLATFORM)/$(ARCH)" @echo " Binary: $(BINARY_PATH)" @echo " Install Prefix: $(INSTALL_PREFIX)" @echo " Workspace: $(WORKSPACE_DIR)" ================================================ FILE: README.fr.md ================================================
PicoClaw

PicoClaw : Assistant IA Ultra-Efficace en Go

Matériel à $10 · <10 Mo de RAM · Démarrage en <1s · 皮皮虾,我们走!

Go Hardware License
Website Docs Wiki
Twitter Discord

[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md)
--- > **PicoClaw** est un projet open-source indépendant initié par [Sipeed](https://sipeed.com). Il est entièrement écrit en **Go** — ce n'est pas un fork d'OpenClaw, de NanoBot ou de tout autre projet. 🦐 **PicoClaw** est un assistant personnel IA ultra-léger inspiré de [NanoBot](https://github.com/HKUDS/nanobot), entièrement réécrit en **Go** via un processus d'auto-amorçage (self-bootstrapping) — où l'agent IA lui-même a piloté l'intégralité de la migration architecturale et de l'optimisation du code. ⚡️ **Extrêmement léger :** Fonctionne sur du matériel à seulement **$10** avec **<10 Mo** de RAM. C'est 99% de mémoire en moins qu'OpenClaw et 98% moins cher qu'un Mac mini !

> [!CAUTION] > **🚨 SÉCURITÉ & CANAUX OFFICIELS** > > * **PAS DE CRYPTO :** PicoClaw n'a **AUCUN** token/jeton officiel. Toute annonce sur `pump.fun` ou d'autres plateformes de trading est une **ARNAQUE**. > > * **DOMAINE OFFICIEL :** Le **SEUL** site officiel est **[picoclaw.io](https://picoclaw.io)**, et le site de l'entreprise est **[sipeed.com](https://sipeed.com)**. > * **Attention :** De nombreux domaines `.ai/.org/.com/.net/...` sont enregistrés par des tiers. > * **Attention :** PicoClaw est en phase de développement précoce et peut présenter des problèmes de sécurité réseau non résolus. Ne déployez pas en environnement de production avant la version v1.0. > * **Note :** PicoClaw a récemment fusionné de nombreuses PR, ce qui peut entraîner une empreinte mémoire plus importante (10–20 Mo) dans les dernières versions. Nous prévoyons de prioriser l'optimisation des ressources dès que l'ensemble des fonctionnalités sera stabilisé. ## 📢 Actualités 2026-03-17 🚀 **v0.2.3 publié !** Interface système tray (Windows & Linux), suivi de statut des sous-agents (`spawn_status`), rechargement à chaud expérimental du gateway, portes de sécurité cron, et 2 correctifs de sécurité. PicoClaw atteint **25K ⭐** ! 2026-03-09 🎉 **v0.2.1 — Plus grande mise à jour !** Support du protocole MCP, 4 nouveaux canaux (Matrix/IRC/WeCom/Discord Proxy), 3 nouveaux fournisseurs (Kimi/Minimax/Avian), pipeline de vision, stockage mémoire JSONL, et routage de modèles. 2026-02-28 📦 **v0.2.0** publié avec support Docker Compose et lanceur Web UI. 2026-02-26 🎉 PicoClaw a atteint **20K étoiles** en seulement 17 jours ! L'orchestration automatique des canaux et les interfaces de capacités sont arrivées.
Actualités précédentes... 2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Les rôles de mainteneurs communautaires et la [feuille de route](ROADMAP.md) sont officiellement publiés. 2026-02-13 🎉 PicoClaw a atteint 5000 étoiles en 4 jours ! La Feuille de Route du Projet et le Groupe de Développeurs sont en cours de mise en place. 2026-02-09 🎉 **PicoClaw est lancé !** Construit en 1 jour pour apporter les Agents IA au matériel à $10 avec <10 Mo de RAM. 🦐 PicoClaw, c'est parti !
## ✨ Fonctionnalités 🪶 **Ultra-Léger** : Empreinte mémoire <10 Mo — 99% plus petit que les fonctionnalités essentielles d'OpenClaw.* 💰 **Coût Minimal** : Suffisamment efficace pour fonctionner sur du matériel à $10 — 98% moins cher qu'un Mac mini. ⚡️ **Démarrage Éclair** : Temps de démarrage 400X plus rapide, boot en <1 seconde même sur un cœur unique à 0,6 GHz. 🌍 **Véritable Portabilité** : Un seul binaire autonome pour RISC-V, ARM, MIPS et x86. Un clic et c'est parti ! 🤖 **Auto-Construit par l'IA** : Implémentation native en Go de manière autonome — 95% du cœur généré par l'Agent avec affinement humain dans la boucle. 🔌 **Support MCP** : Intégration native du [Model Context Protocol](https://modelcontextprotocol.io/) — connectez n'importe quel serveur MCP pour étendre les capacités de l'agent. 👁️ **Pipeline de Vision** : Envoyez des images et fichiers directement à l'agent — encodage base64 automatique pour les LLM multimodaux. 🧠 **Routage Intelligent** : Routage de modèles basé sur des règles — les requêtes simples vont vers des modèles légers, économisant les coûts API. _*Les versions récentes peuvent utiliser 10–20 Mo en raison des fusions rapides de fonctionnalités. L'optimisation des ressources est prévue. La comparaison de démarrage est basée sur des benchmarks à cœur unique 0,8 GHz (voir tableau ci-dessous)._ | | OpenClaw | NanoBot | **PicoClaw** | | ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | | **Langage** | TypeScript | Python | **Go** | | **RAM** | >1 Go | >100 Mo | **< 10 Mo*** | | **Démarrage**
(cœur 0,8 GHz) | >500s | >30s | **<1s** | | **Coût** | Mac Mini $599 | La plupart des SBC Linux
~$50 | **N'importe quelle carte Linux**
**À partir de $10** | PicoClaw ## 🦾 Démonstration ### 🛠️ Flux de Travail Standard de l'Assistant

🧩 Ingénieur Full-Stack

🗂️ Gestion des Logs & Planification

🔎 Recherche Web & Apprentissage

Développer • Déployer • Mettre à l'échelle Planifier • Automatiser • Mémoriser Découvrir • Analyser • Tendances
### 📱 Utiliser sur d'anciens téléphones Android Donnez une seconde vie à votre téléphone d'il y a dix ans ! Transformez-le en assistant IA intelligent avec PicoClaw. Démarrage rapide : 1. **Installez [Termux](https://github.com/termux/termux-app)** (Téléchargez depuis [GitHub Releases](https://github.com/termux/termux-app/releases), ou recherchez sur F-Droid / Google Play). 2. **Exécutez les commandes** ```bash # Téléchargez la dernière version depuis https://github.com/sipeed/picoclaw/releases wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz tar xzf picoclaw_Linux_arm64.tar.gz pkg install proot termux-chroot ./picoclaw onboard ``` Puis suivez les instructions de la section « Démarrage Rapide » pour terminer la configuration ! PicoClaw ### 🐜 Déploiement Innovant à Faible Empreinte PicoClaw peut être déployé sur pratiquement n'importe quel appareil Linux ! - 9,9$ [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) version E (Ethernet) ou W (WiFi6), pour un Assistant Domotique Minimaliste - 30~$50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou 100$ [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) pour la Maintenance Automatisée de Serveurs - 50$ [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou 100$ [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) pour la Surveillance Intelligente 🌟 Encore plus de scénarios de déploiement vous attendent ! ## 📦 Installation ### Installer avec un binaire précompilé Téléchargez le binaire pour votre plateforme depuis la page des [Releases](https://github.com/sipeed/picoclaw/releases). ### Installer depuis les sources (dernières fonctionnalités, recommandé pour le développement) ```bash git clone https://github.com/sipeed/picoclaw.git cd picoclaw make deps # Compiler, pas besoin d'installer make build # Compiler pour plusieurs plateformes make build-all # Compiler pour Raspberry Pi Zero 2 W (32-bit : make build-linux-arm ; 64-bit : make build-linux-arm64) make build-pi-zero # Compiler et Installer make install ``` **Raspberry Pi Zero 2 W :** Utilisez le binaire correspondant à votre OS : Raspberry Pi OS 32-bit → `make build-linux-arm` ; 64-bit → `make build-linux-arm64`. Ou exécutez `make build-pi-zero` pour compiler les deux. ## 📚 Documentation Pour des guides détaillés, consultez la documentation ci-dessous. Ce README ne couvre que le démarrage rapide. | Sujet | Description | |-------|-------------| | 🐳 [Docker & Démarrage Rapide](docs/fr/docker.md) | Configuration Docker Compose, modes Launcher/Agent, configuration rapide | | 💬 [Applications de Chat](docs/fr/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom, et plus | | ⚙️ [Configuration](docs/fr/configuration.md) | Variables d'environnement, structure du workspace, sources de compétences, bac à sable de sécurité, heartbeat | | 🔌 [Fournisseurs & Modèles](docs/fr/providers.md) | 20+ fournisseurs LLM, routage de modèles, configuration model_list, architecture des fournisseurs | | 🔄 [Spawn & Tâches Asynchrones](docs/fr/spawn-tasks.md) | Tâches rapides, tâches longues avec spawn, orchestration asynchrone de sous-agents | | 🐛 [Dépannage](docs/fr/troubleshooting.md) | Problèmes courants et solutions | | 🔧 [Configuration des Outils](docs/fr/tools_configuration.md) | Activation/désactivation par outil, politiques exec | ## ClawdChat Rejoignez le Réseau Social d'Agents Connectez PicoClaw au Réseau Social d'Agents simplement en envoyant un seul message via le CLI ou n'importe quelle application de chat intégrée. **Lisez `https://clawdchat.ai/skill.md` et suivez les instructions pour rejoindre [ClawdChat.ai](https://clawdchat.ai)** ## 🖥️ Référence CLI | Commande | Description | | ------------------------- | ---------------------------------- | | `picoclaw onboard` | Initialiser la config & le workspace | | `picoclaw agent -m "..."` | Discuter avec l'agent | | `picoclaw agent` | Mode chat interactif | | `picoclaw gateway` | Démarrer le gateway | | `picoclaw status` | Afficher le statut | | `picoclaw version` | Afficher les infos de version | | `picoclaw cron list` | Lister les tâches planifiées | | `picoclaw cron add ...` | Ajouter une tâche planifiée | | `picoclaw cron disable` | Désactiver une tâche planifiée | | `picoclaw cron remove` | Supprimer une tâche planifiée | | `picoclaw skills list` | Lister les compétences installées | | `picoclaw skills install` | Installer une compétence | | `picoclaw migrate` | Migrer les données des anciennes versions | | `picoclaw auth login` | S'authentifier auprès des fournisseurs | ### Tâches Planifiées / Rappels PicoClaw prend en charge les rappels planifiés et les tâches récurrentes via l'outil `cron` : * **Rappels ponctuels** : « Rappelle-moi dans 10 minutes » → se déclenche une fois après 10 min * **Tâches récurrentes** : « Rappelle-moi toutes les 2 heures » → se déclenche toutes les 2 heures * **Expressions cron** : « Rappelle-moi à 9h chaque jour » → utilise une expression cron ## 🤝 Contribuer & Feuille de Route Les PR sont les bienvenues ! Le code est intentionnellement petit et lisible. 🤗 Consultez notre [Feuille de Route Communautaire](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) complète. Groupe de développeurs en construction, rejoignez-nous après votre première PR fusionnée ! Groupes d'utilisateurs : discord : PicoClaw ================================================ FILE: README.id.md ================================================
PicoClaw

PicoClaw: Asisten AI Super Ringan berbasis Go

Perangkat Keras $10 · RAM <10MB · Boot <1 Detik · Ayo, Berangkat!

Go Hardware License
Website Docs Wiki
Twitter Discord

[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [English](README.md) | **Bahasa Indonesia**
--- > **PicoClaw** adalah proyek open-source independen yang diinisiasi oleh [Sipeed](https://sipeed.com). Ditulis sepenuhnya dalam **Go** — bukan fork dari OpenClaw, NanoBot, atau proyek lainnya. 🦐 PicoClaw adalah asisten AI pribadi yang super ringan, terinspirasi dari [NanoBot](https://github.com/HKUDS/nanobot), ditulis ulang sepenuhnya dalam Go melalui proses "self-bootstrapping" — di mana AI Agent itu sendiri yang memandu seluruh migrasi arsitektur dan optimasi kode. ⚡️ Berjalan di perangkat keras $10 dengan RAM <10MB: Hemat 99% memori dibanding OpenClaw dan 98% lebih murah dibanding Mac mini!

> [!CAUTION] > **🚨 KEAMANAN & SALURAN RESMI** > > * **TANPA KRIPTO:** PicoClaw **TIDAK** memiliki token/koin resmi. Semua klaim di `pump.fun` atau platform trading lainnya adalah **PENIPUAN**. > > * **DOMAIN RESMI:** Satu-satunya website resmi adalah **[picoclaw.io](https://picoclaw.io)**, dan website perusahaan adalah **[sipeed.com](https://sipeed.com)** > * **Peringatan:** Banyak domain `.ai/.org/.com/.net/...` yang didaftarkan oleh pihak ketiga. > * **Peringatan:** PicoClaw masih dalam tahap pengembangan awal dan mungkin memiliki masalah keamanan jaringan yang belum teratasi. Jangan deploy ke lingkungan produksi sebelum rilis v1.0. > * **Catatan:** PicoClaw baru-baru ini menggabungkan banyak PR, yang mungkin mengakibatkan penggunaan memori lebih besar (10–20MB) pada versi terbaru. Kami berencana untuk memprioritaskan optimasi sumber daya segera setelah fitur saat ini mencapai kondisi stabil. ## 📢 Berita 2026-03-17 🚀 **v0.2.3 Dirilis!** UI system tray (Windows & Linux), pelacakan status sub-agent (`spawn_status`), eksperimental gateway hot-reload, gerbang keamanan cron, dan 2 perbaikan keamanan. PicoClaw kini di **25K ⭐**! 2026-03-09 🎉 **v0.2.1 — Update terbesar!** Dukungan protokol MCP, 4 channel baru (Matrix/IRC/WeCom/Discord Proxy), 3 provider baru (Kimi/Minimax/Avian), pipeline vision, penyimpanan memori JSONL, dan routing model. 2026-02-28 📦 **v0.2.0** dirilis dengan dukungan Docker Compose dan launcher Web UI. 2026-02-26 🎉 PicoClaw mencapai **20K bintang** hanya dalam 17 hari! Orkestrasi channel otomatis dan antarmuka kapabilitas diluncurkan.
Berita lama... 2026-02-16 🎉 PicoClaw mencapai 12K bintang dalam satu minggu! Peran maintainer komunitas dan [roadmap](ROADMAP.md) resmi diposting. 2026-02-13 🎉 PicoClaw mencapai 5000 bintang dalam 4 hari! Roadmap Proyek dan pengaturan Grup Pengembang sedang berjalan. 2026-02-09 🎉 **PicoClaw Diluncurkan!** Dibangun dalam 1 hari untuk menghadirkan AI Agent ke perangkat keras $10 dengan RAM <10MB. 🦐 PicoClaw, Ayo Berangkat!
## ✨ Fitur 🪶 **Super Ringan**: Penggunaan memori <10MB — 99% lebih kecil dari fungsionalitas inti OpenClaw.* 💰 **Biaya Minimal**: Cukup efisien untuk berjalan di perangkat keras $10 — 98% lebih murah dari Mac mini. ⚡️ **Secepat Kilat**: Waktu startup 400X lebih cepat, boot dalam <1 detik bahkan di prosesor single core 0,6GHz. 🌍 **Portabilitas Sejati**: Satu binary mandiri untuk RISC-V, ARM, MIPS, dan x86, Satu Klik Langsung Jalan! 🤖 **AI-Bootstrapped**: Implementasi Go-native secara otonom — 95% kode inti dihasilkan oleh Agent dengan penyempurnaan human-in-the-loop. 🔌 **Dukungan MCP**: Integrasi [Model Context Protocol](https://modelcontextprotocol.io/) native — hubungkan server MCP mana pun untuk memperluas kapabilitas agent. 👁️ **Pipeline Vision**: Kirim gambar dan file langsung ke agent — encoding base64 otomatis untuk LLM multimodal. 🧠 **Routing Cerdas**: Routing model berbasis aturan — kueri sederhana diarahkan ke model ringan, menghemat biaya API. _*Versi terbaru mungkin menggunakan 10–20MB karena penggabungan fitur yang cepat. Optimasi sumber daya direncanakan. Perbandingan startup berdasarkan benchmark prosesor single-core 0,8GHz (lihat tabel di bawah)._ | | OpenClaw | NanoBot | **PicoClaw** | | ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | | **Bahasa** | TypeScript | Python | **Go** | | **RAM** | >1GB | >100MB | **< 10MB*** | | **Startup**
(0,8GHz core) | >500d | >30d | **<1d** | | **Biaya** | Mac Mini $599 | Kebanyakan Linux SBC
~$50 | **Semua Board Linux**
**Mulai dari $10** | PicoClaw ## 🦾 Demonstrasi ### 🛠️ Alur Kerja Asisten Standar

🧩 Full-Stack Engineer

🗂️ Pencatatan & Manajemen Perencanaan

🔎 Pencarian Web & Pembelajaran

Develop • Deploy • Scale Jadwal • Otomasi • Memori Penemuan • Wawasan • Tren
### 📱 Jalankan di HP Android Lama Berikan kehidupan kedua untuk HP lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw. Panduan Cepat: 1. **Instal [Termux](https://github.com/termux/termux-app)** (Unduh dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play). 2. **Jalankan perintah** ```bash # Unduh rilis terbaru dari https://github.com/sipeed/picoclaw/releases wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz tar xzf picoclaw_Linux_arm64.tar.gz pkg install proot termux-chroot ./picoclaw onboard ``` Kemudian ikuti instruksi di bagian "Panduan Cepat" untuk menyelesaikan konfigurasi! PicoClaw ### 🐜 Deploy Inovatif dengan Footprint Rendah PicoClaw dapat di-deploy di hampir semua perangkat Linux! - $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versi E(Ethernet) atau W(WiFi6), untuk Home Assistant Minimal - $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), atau $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) untuk Pemeliharaan Server Otomatis - $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) atau $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) untuk Pemantauan Cerdas 🌟 Lebih Banyak Kasus Deploy Menanti! ## 📦 Instalasi ### Instal dengan binary yang sudah dikompilasi Unduh binary untuk platform Anda dari halaman [Releases](https://github.com/sipeed/picoclaw/releases). ### Instal dari source (fitur terbaru, disarankan untuk pengembangan) ```bash git clone https://github.com/sipeed/picoclaw.git cd picoclaw make deps # Build, tidak perlu instal make build # Build untuk berbagai platform make build-all # Build untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) make build-pi-zero # Build dan Instal make install ``` **Raspberry Pi Zero 2 W:** Gunakan binary yang sesuai dengan OS Anda: Raspberry Pi OS 32-bit → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Atau jalankan `make build-pi-zero` untuk build keduanya. ## 📚 Dokumentasi Untuk panduan lengkap, lihat dokumen di bawah. README ini hanya berisi panduan cepat. | Topik | Deskripsi | |-------|-----------| | 🐳 [Docker & Panduan Cepat](docs/docker.md) | Pengaturan Docker Compose, mode Launcher/Agent, konfigurasi Panduan Cepat | | 💬 [Aplikasi Chat](docs/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom, dan lainnya | | ⚙️ [Konfigurasi](docs/configuration.md) | Variabel environment, tata letak workspace, sumber skill, sandbox keamanan, heartbeat | | 🔌 [Provider & Model](docs/providers.md) | 20+ provider LLM, routing model, konfigurasi model_list, arsitektur provider | | 🔄 [Spawn & Tugas Async](docs/spawn-tasks.md) | Tugas cepat, tugas panjang dengan spawn, orkestrasi sub-agent async | | 🐛 [Pemecahan Masalah](docs/troubleshooting.md) | Masalah umum dan solusinya | | 🔧 [Konfigurasi Tools](docs/tools_configuration.md) | Aktifkan/nonaktifkan tool, kebijakan exec | ## ClawdChat Bergabung dengan Jaringan Sosial Agent Hubungkan Picoclaw ke Jaringan Sosial Agent hanya dengan mengirim satu pesan melalui CLI atau Aplikasi Chat terintegrasi. **Baca `https://clawdchat.ai/skill.md` dan ikuti instruksi untuk bergabung dengan [ClawdChat.ai](https://clawdchat.ai)** ## 🖥️ Referensi CLI | Perintah | Deskripsi | | ------------------------- | -------------------------------- | | `picoclaw onboard` | Inisialisasi konfigurasi & workspace | | `picoclaw agent -m "..."` | Chat dengan agent | | `picoclaw agent` | Mode chat interaktif | | `picoclaw gateway` | Mulai gateway | | `picoclaw status` | Tampilkan status | | `picoclaw version` | Tampilkan info versi | | `picoclaw cron list` | Daftar semua tugas terjadwal | | `picoclaw cron add ...` | Tambah tugas terjadwal | | `picoclaw cron disable` | Nonaktifkan tugas terjadwal | | `picoclaw cron remove` | Hapus tugas terjadwal | | `picoclaw skills list` | Daftar skill yang terinstal | | `picoclaw skills install` | Instal skill | | `picoclaw migrate` | Migrasi data dari versi lama | | `picoclaw auth login` | Autentikasi dengan provider | ### Tugas Terjadwal / Pengingat PicoClaw mendukung pengingat terjadwal dan tugas berulang melalui tool `cron`: * **Pengingat satu kali**: "Ingatkan saya dalam 10 menit" → terpicu sekali setelah 10 menit * **Tugas berulang**: "Ingatkan saya setiap 2 jam" → terpicu setiap 2 jam * **Ekspresi cron**: "Ingatkan saya jam 9 pagi setiap hari" → menggunakan ekspresi cron ## 🤝 Kontribusi & Roadmap PR sangat diterima! Codebase sengaja dibuat kecil dan mudah dibaca. 🤗 Lihat [Roadmap Komunitas](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) lengkap kami. Grup pengembang sedang dibangun, bergabunglah setelah PR pertama Anda di-merge! Grup Pengguna: discord: PicoClaw ================================================ FILE: README.it.md ================================================
PicoClaw

PicoClaw: Assistente IA Ultra-Efficiente in Go

Hardware da $10 · <10MB RAM · Boot in <1s · 皮皮虾,我们走!

Go Hardware License
Website Docs Wiki
Twitter Discord

[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [English](README.md)
--- > **PicoClaw** è un progetto open-source indipendente avviato da [Sipeed](https://sipeed.com). È scritto interamente in **Go** — non è un fork di OpenClaw, NanoBot o di qualsiasi altro progetto. 🦐 PicoClaw è un assistente IA personale ultra-leggero ispirato a [NanoBot](https://github.com/HKUDS/nanobot), riscritto da zero in Go attraverso un processo di auto-bootstrapping, in cui l'agente IA stesso ha guidato l'intera migrazione architetturale e l'ottimizzazione del codice. ⚡️ Funziona su hardware da $10 con meno di 10MB di RAM: il 99% di memoria in meno rispetto a OpenClaw e il 98% più economico di un Mac mini!

> [!CAUTION] > **🚨 SICUREZZA & CANALI UFFICIALI** > > * **NESSUNA CRYPTO:** PicoClaw non ha **NESSUN** token/coin ufficiale. Qualsiasi annuncio su `pump.fun` o altre piattaforme di trading è una **TRUFFA**. > > * **DOMINIO UFFICIALE:** L'**UNICO** sito ufficiale è **[picoclaw.io](https://picoclaw.io)**, e il sito aziendale è **[sipeed.com](https://sipeed.com)**. > * **Attenzione:** Molti domini `.ai/.org/.com/.net/...` sono registrati da terze parti. > * **Attenzione:** PicoClaw è in fase di sviluppo iniziale e potrebbe avere problemi di sicurezza di rete non risolti. Non distribuire in ambienti di produzione prima della release v1.0. > * **Nota:** PicoClaw ha recentemente unito molte PR, il che potrebbe comportare un'impronta di memoria maggiore (10–20MB) nelle ultime versioni. Prevediamo di dare priorità all'ottimizzazione delle risorse non appena il set di funzionalità corrente raggiungerà uno stato stabile. ## 📢 Novità 2026-03-17 🚀 **v0.2.3 rilasciata!** Interfaccia system tray (Windows & Linux), tracciamento dello stato dei sub-agent (`spawn_status`), hot-reload sperimentale del gateway, gate di sicurezza per cron e 2 correzioni di sicurezza. PicoClaw raggiunge **25K ⭐**! 2026-03-09 🎉 **v0.2.1 — Il più grande aggiornamento di sempre!** Supporto al protocollo MCP, 4 nuovi canali (Matrix/IRC/WeCom/Discord Proxy), 3 nuovi provider (Kimi/Minimax/Avian), pipeline di visione, store di memoria JSONL e routing dei modelli. 2026-02-28 📦 **v0.2.0** rilasciata con supporto Docker Compose e launcher Web UI. 2026-02-26 🎉 PicoClaw ha raggiunto **20K stelle** in soli 17 giorni! Arrivate l'orchestrazione automatica dei canali e le interfacce di capacità.
Notizie precedenti... 2026-02-16 🎉 PicoClaw ha raggiunto 12K stelle in una settimana! Ruoli di maintainer della community e [roadmap](ROADMAP.md) pubblicati ufficialmente. 2026-02-13 🎉 PicoClaw ha raggiunto 5000 stelle in 4 giorni! Roadmap del progetto e gruppo sviluppatori in fase di avvio. 2026-02-09 🎉 **PicoClaw lanciato!** Costruito in 1 giorno per portare gli agenti IA su hardware da $10 con <10MB di RAM. 🦐 PicoClaw, andiamo!
## ✨ Caratteristiche 🪶 **Ultra-Leggero**: Impronta di memoria <10MB — il 99% più piccolo delle funzionalità principali di OpenClaw.* 💰 **Costo Minimo**: Abbastanza efficiente da girare su hardware da $10 — il 98% più economico di un Mac mini. ⚡️ **Avvio Fulmineo**: Tempo di avvio 400 volte più veloce, boot in meno di 1 secondo anche su un singolo core a 0,6 GHz. 🌍 **Vera Portabilità**: Singolo binario autonomo per RISC-V, ARM, MIPS e x86. Un click e si parte! 🤖 **Auto-Costruito dall'IA**: Implementazione nativa in Go in modo autonomo — 95% del core generato dall'Agent con perfezionamento umano nel ciclo. 🔌 **Supporto MCP**: Integrazione nativa del [Model Context Protocol](https://modelcontextprotocol.io/) — connetti qualsiasi server MCP per estendere le capacità dell'agent. 👁️ **Pipeline di Visione**: Invia immagini e file direttamente all'agent — codifica base64 automatica per LLM multimodali. 🧠 **Routing Intelligente**: Routing dei modelli basato su regole — le query semplici vanno verso modelli leggeri, risparmiando sui costi API. _*Le versioni recenti potrebbero usare 10–20MB a causa delle fusioni rapide di funzionalità. L'ottimizzazione delle risorse è pianificata. Il confronto dell'avvio è basato su benchmark con singolo core a 0,8 GHz (vedi tabella sotto)._ | | OpenClaw | NanoBot | **PicoClaw** | | ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | | **Linguaggio** | TypeScript | Python | **Go** | | **RAM** | >1GB | >100MB | **< 10MB*** | | **Avvio**
(core 0,8 GHz) | >500s | >30s | **<1s** | | **Costo** | Mac Mini $599 | La maggior parte degli SBC Linux
~$50 | **Qualsiasi scheda Linux**
**A partire da $10** | PicoClaw ## 🦾 Dimostrazione ### 🛠️ Flussi di Lavoro Standard dell'Assistente

🧩 Ingegnere Full-Stack

🗂️ Gestione Log & Pianificazione

🔎 Ricerca Web & Apprendimento

Sviluppa • Distribuisci • Scala Pianifica • Automatizza • Memorizza Scopri • Analizza • Tendenze
### 📱 Usa su vecchi telefoni Android Dai una seconda vita al tuo telefono di dieci anni fa! Trasformalo in un assistente IA intelligente con PicoClaw. Avvio rapido: 1. **Installa [Termux](https://github.com/termux/termux-app)** (Scarica da [GitHub Releases](https://github.com/termux/termux-app/releases), o cerca su F-Droid / Google Play). 2. **Esegui i comandi** ```bash # Scarica l'ultima release da https://github.com/sipeed/picoclaw/releases wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz tar xzf picoclaw_Linux_arm64.tar.gz pkg install proot termux-chroot ./picoclaw onboard ``` Poi segui le istruzioni nella sezione "Avvio Rapido" per completare la configurazione! PicoClaw ### 🐜 Deploy Innovativo a Bassa Impronta PicoClaw può essere distribuito su quasi qualsiasi dispositivo Linux! - $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versione E (Ethernet) o W (WiFi6), per un Assistente Domotico Minimale - $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), o $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) per la Manutenzione Automatizzata dei Server - $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) o $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) per il Monitoraggio Intelligente 🌟 Molti altri scenari di deploy ti aspettano! ## 📦 Installazione ### Installa con binario precompilato Scarica il binario per la tua piattaforma dalla pagina delle [Releases](https://github.com/sipeed/picoclaw/releases). ### Installa dai sorgenti (ultime funzionalità, consigliato per lo sviluppo) ```bash git clone https://github.com/sipeed/picoclaw.git cd picoclaw make deps # Compila, senza installare make build # Compila per più piattaforme make build-all # Compila per Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) make build-pi-zero # Compila e Installa make install ``` **Raspberry Pi Zero 2 W:** Usa il binario che corrisponde al tuo OS: Raspberry Pi OS 32-bit → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Oppure esegui `make build-pi-zero` per compilare entrambi. ## 📚 Documentazione Per guide dettagliate, consulta la documentazione qui sotto. Il README copre solo l'avvio rapido. | Argomento | Descrizione | |-----------|-------------| | 🐳 [Docker & Avvio Rapido](docs/docker.md) | Configurazione Docker Compose, modalità Launcher/Agent, configurazione rapida | | 💬 [App di Chat](docs/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom e altro | | ⚙️ [Configurazione](docs/it/configuration.md) | Variabili d'ambiente, struttura del workspace, sorgenti delle skill, sandbox di sicurezza, heartbeat | | 🔌 [Provider & Modelli](docs/providers.md) | 20+ provider LLM, routing dei modelli, configurazione model_list, architettura dei provider | | 🔄 [Spawn & Task Asincroni](docs/spawn-tasks.md) | Task veloci, task lunghi con spawn, orchestrazione asincrona di sub-agent | | 🐛 [Risoluzione Problemi](docs/troubleshooting.md) | Problemi comuni e soluzioni | | 🔧 [Configurazione degli Strumenti](docs/tools_configuration.md) | Abilitazione/disabilitazione per strumento, politiche exec | ## ClawdChat Unisciti al Social Network degli Agent Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singolo messaggio tramite CLI o qualsiasi app di chat integrata. **Leggi `https://clawdchat.ai/skill.md` e segui le istruzioni per unirti a [ClawdChat.ai](https://clawdchat.ai)** ## 🖥️ Riferimento CLI | Comando | Descrizione | | ------------------------- | ---------------------------------- | | `picoclaw onboard` | Inizializza config & workspace | | `picoclaw agent -m "..."` | Chatta con l'agent | | `picoclaw agent` | Modalità chat interattiva | | `picoclaw gateway` | Avvia il gateway | | `picoclaw status` | Mostra lo stato | | `picoclaw version` | Mostra le info sulla versione | | `picoclaw cron list` | Elenca tutti i job pianificati | | `picoclaw cron add ...` | Aggiunge un job pianificato | | `picoclaw cron disable` | Disabilita un job pianificato | | `picoclaw cron remove` | Rimuove un job pianificato | | `picoclaw skills list` | Elenca le skill installate | | `picoclaw skills install` | Installa una skill | | `picoclaw migrate` | Migra i dati dalle versioni precedenti | | `picoclaw auth login` | Autenticazione con i provider | ### Task Pianificati / Promemoria PicoClaw supporta promemoria pianificati e task ricorrenti tramite lo strumento `cron`: * **Promemoria una tantum**: "Ricordami tra 10 minuti" → si attiva una volta dopo 10 min * **Task ricorrenti**: "Ricordami ogni 2 ore" → si attiva ogni 2 ore * **Espressioni cron**: "Ricordami alle 9 ogni giorno" → usa un'espressione cron ## 🤝 Contribuisci & Roadmap Le PR sono benvenute! Il codice è volutamente piccolo e leggibile. 🤗 Consulta la nostra [Roadmap della Community](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) completa. Gruppo sviluppatori in costruzione, unisciti dopo la tua prima PR accettata! Gruppi utenti: discord: PicoClaw ================================================ FILE: README.ja.md ================================================
PicoClaw

PicoClaw: Go で書かれた超効率 AI アシスタント

$10 ハードウェア · <10MB RAM · <1秒起動 · 行くぜ、シャコ!

Go Hardware License
Website Docs Wiki
Twitter Discord

[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md)
--- > **PicoClaw** は [Sipeed](https://sipeed.com) が立ち上げた独立したオープンソースプロジェクトです。完全に **Go 言語**で一から書かれており、OpenClaw、NanoBot、その他のプロジェクトのフォークではありません。 🦐 PicoClaw は [NanoBot](https://github.com/HKUDS/nanobot) にインスパイアされた超軽量パーソナル AI アシスタントです。Go でゼロからリファクタリングされ、AI エージェント自身がアーキテクチャの移行とコード最適化を推進するセルフブートストラッピングプロセスで構築されました。 ⚡️ $10 のハードウェアで 10MB 未満の RAM で動作:OpenClaw より 99% 少ないメモリ、Mac mini より 98% 安い!

> [!CAUTION] > **🚨 セキュリティ&公式チャンネル** > > * **暗号通貨なし:** PicoClaw には公式トークン/コインは**一切ありません**。`pump.fun` やその他の取引プラットフォームでの主張はすべて**詐欺**です。 > > * **公式ドメイン:** **唯一**の公式サイトは **[picoclaw.io](https://picoclaw.io)**、企業サイトは **[sipeed.com](https://sipeed.com)** です。 > * **注意:** 多くの `.ai/.org/.com/.net/...` ドメインは第三者によって登録されています。 > * **注意:** PicoClaw は初期開発段階にあり、未解決のネットワークセキュリティ問題がある可能性があります。v1.0 リリース前に本番環境へのデプロイは避けてください。 > * **注記:** PicoClaw は最近多くの PR をマージしており、最新バージョンではメモリフットプリントが大きくなる場合があります(10〜20MB)。機能セットが安定次第、リソース最適化を優先する予定です。 ## 📢 ニュース 2026-03-17 🚀 **v0.2.3 リリース!** システムトレイ UI(Windows & Linux)、サブエージェントステータス追跡(`spawn_status`)、実験的ゲートウェイホットリロード、cron セキュリティゲート、セキュリティ修正 2 件。PicoClaw **25K ⭐** 達成! 2026-03-09 🎉 **v0.2.1 — 史上最大のアップデート!** MCP プロトコル対応、4 つの新チャネル(Matrix/IRC/WeCom/Discord Proxy)、3 つの新プロバイダー(Kimi/Minimax/Avian)、ビジョンパイプライン、JSONL メモリストア、モデルルーティング。 2026-02-28 📦 **v0.2.0** リリース — Docker Compose 対応と Web UI ランチャー。 2026-02-26 🎉 PicoClaw がわずか 17 日で **20K スター** 達成!チャネル自動オーケストレーションとケイパビリティインターフェースが実装されました。
過去のニュース... 2026-02-16 🎉 PicoClaw が 1 週間で 12K スター達成!コミュニティメンテナーの役割と[ロードマップ](ROADMAP.md)が正式に公開されました。 2026-02-13 🎉 PicoClaw が 4 日間で 5000 スター達成!プロジェクトロードマップと開発者グループの準備が進行中。 2026-02-09 🎉 **PicoClaw リリース!** $10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 行くぜ、シャコ!
## ✨ 特徴 🪶 **超軽量**: メモリフットプリント 10MB 未満 — OpenClaw のコア機能より 99% 小さい。* 💰 **最小コスト**: $10 ハードウェアで動作 — Mac mini より 98% 安い。 ⚡️ **超高速**: 起動時間 400 倍高速、0.6GHz シングルコアでも 1 秒未満で起動。 🌍 **真のポータビリティ**: RISC-V、ARM、MIPS、x86 対応の単一バイナリ。ワンクリックで Go! 🤖 **AI ブートストラップ**: 自律的な Go ネイティブ実装 — コアの 95% が AI 生成、人間によるレビュー付き。 🔌 **MCP 対応**: ネイティブ [Model Context Protocol](https://modelcontextprotocol.io/) 統合 — 任意の MCP サーバーに接続してエージェント機能を拡張。 👁️ **ビジョンパイプライン**: 画像やファイルをエージェントに直接送信 — マルチモーダル LLM 向けの自動 base64 エンコーディング。 🧠 **スマートルーティング**: ルールベースのモデルルーティング — 簡単なクエリは軽量モデルへ、API コストを節約。 _*最近のバージョンでは急速な機能マージにより 10〜20MB になる場合があります。リソース最適化は計画中です。起動時間の比較は 0.8GHz シングルコアベンチマークに基づいています(下表参照)。_ | | OpenClaw | NanoBot | **PicoClaw** | | ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | | **言語** | TypeScript | Python | **Go** | | **RAM** | >1GB | >100MB | **< 10MB*** | | **起動時間**
(0.8GHz コア) | >500秒 | >30秒 | **<1秒** | | **コスト** | Mac Mini $599 | 大半の Linux SBC
~$50 | **あらゆる Linux ボード**
**最安 $10** | PicoClaw ## 🦾 デモンストレーション ### 🛠️ スタンダードアシスタントワークフロー

🧩 フルスタックエンジニア

🗂️ ログ&計画管理

🔎 Web 検索&学習

開発 · デプロイ · スケール スケジュール · 自動化 · メモリ 発見 · インサイト · トレンド
### 📱 古い Android スマホで動かす 10 年前のスマホに第二の人生を!PicoClaw でスマート AI アシスタントに変身させましょう。クイックスタート: 1. **[Termux](https://github.com/termux/termux-app) をインストール**([GitHub Releases](https://github.com/termux/termux-app/releases) からダウンロード、または F-Droid / Google Play で検索)。 2. **コマンドを実行** ```bash # https://github.com/sipeed/picoclaw/releases から最新リリースをダウンロード wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz tar xzf picoclaw_Linux_arm64.tar.gz pkg install proot termux-chroot ./picoclaw onboard ``` その後「クイックスタート」セクションの手順に従って設定を完了してください! PicoClaw ### 🐜 革新的な省フットプリントデプロイ PicoClaw はほぼすべての Linux デバイスにデプロイできます! - $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) または W(WiFi6) バージョン、最小ホームアシスタントに - $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html) または $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) サーバー自動メンテナンスに - $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) または $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) スマート監視に 🌟 もっと多くのデプロイ事例が待っています! ## 📦 インストール ### コンパイル済みバイナリでインストール [リリースページ](https://github.com/sipeed/picoclaw/releases) からお使いのプラットフォーム用のバイナリをダウンロードしてください。 ### ソースからインストール(最新機能、開発向け推奨) ```bash git clone https://github.com/sipeed/picoclaw.git cd picoclaw make deps # ビルド(インストール不要) make build # 複数プラットフォーム向けビルド make build-all # Raspberry Pi Zero 2 W 向けビルド(32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) make build-pi-zero # ビルドとインストール make install ``` **Raspberry Pi Zero 2 W:** OS に合ったバイナリを使用してください:32-bit Raspberry Pi OS → `make build-linux-arm`、64-bit → `make build-linux-arm64`。または `make build-pi-zero` で両方をビルド。 ## 📚 ドキュメント 詳細なガイドは以下のドキュメントを参照してください。この README はクイックスタートのみをカバーしています。 | トピック | 説明 | |---------|------| | 🐳 [Docker & クイックスタート](docs/ja/docker.md) | Docker Compose セットアップ、Launcher/Agent モード、クイックスタート設定 | | 💬 [チャットアプリ](docs/ja/chat-apps.md) | Telegram、Discord、WhatsApp、Matrix、QQ、Slack、IRC、DingTalk、LINE、Feishu、WeCom など | | ⚙️ [設定](docs/ja/configuration.md) | 環境変数、ワークスペース構成、スキルソース、セキュリティサンドボックス、ハートビート | | 🔌 [プロバイダー&モデル](docs/ja/providers.md) | 20 以上の LLM プロバイダー、モデルルーティング、model_list 設定、プロバイダーアーキテクチャ | | 🔄 [Spawn & 非同期タスク](docs/ja/spawn-tasks.md) | クイックタスク、spawn による長時間タスク、非同期サブエージェントオーケストレーション | | 🐛 [トラブルシューティング](docs/ja/troubleshooting.md) | よくある問題と解決策 | | 🔧 [ツール設定](docs/ja/tools_configuration.md) | ツールごとの有効/無効、exec ポリシー | ## ClawdChat エージェントソーシャルネットワークに参加 CLI または統合チャットアプリからメッセージを 1 つ送るだけで、PicoClaw をエージェントソーシャルネットワークに接続できます。 **`https://clawdchat.ai/skill.md` を読み、指示に従って [ClawdChat.ai](https://clawdchat.ai) に参加してください** ## 🖥️ CLI リファレンス | コマンド | 説明 | | ------------------------- | ------------------------------ | | `picoclaw onboard` | 設定&ワークスペースの初期化 | | `picoclaw agent -m "..."` | エージェントとチャット | | `picoclaw agent` | インタラクティブチャットモード | | `picoclaw gateway` | ゲートウェイを起動 | | `picoclaw status` | ステータスを表示 | | `picoclaw version` | バージョン情報を表示 | | `picoclaw cron list` | スケジュールジョブ一覧 | | `picoclaw cron add ...` | スケジュールジョブを追加 | | `picoclaw cron disable` | スケジュールジョブを無効化 | | `picoclaw cron remove` | スケジュールジョブを削除 | | `picoclaw skills list` | インストール済みスキル一覧 | | `picoclaw skills install` | スキルをインストール | | `picoclaw migrate` | 旧バージョンからデータを移行 | | `picoclaw auth login` | プロバイダーへの認証 | ### スケジュールタスク / リマインダー PicoClaw は `cron` ツールによるスケジュールリマインダーと定期タスクをサポートしています: * **ワンタイムリマインダー**: 「10分後にリマインド」→ 10分後に1回トリガー * **定期タスク**: 「2時間ごとにリマインド」→ 2時間ごとにトリガー * **Cron 式**: 「毎日9時にリマインド」→ cron 式を使用 ## 🤝 コントリビュート&ロードマップ PR 歓迎!コードベースは意図的に小さく読みやすくしています。🤗 完全な[コミュニティロードマップ](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md)をご覧ください。 開発者グループ構築中、最初の PR がマージされたら参加できます! ユーザーグループ: discord: PicoClaw ================================================ FILE: README.md ================================================
PicoClaw

PicoClaw: Ultra-Efficient AI Assistant in Go

$10 Hardware · <10MB RAM · <1s Boot · 皮皮虾,我们走!

Go Hardware License
Website Docs Wiki
Twitter Discord

[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **English**
--- > **PicoClaw** is an independent open-source project initiated by [Sipeed](https://sipeed.com). It is written entirely in **Go** — not a fork of OpenClaw, NanoBot, or any other project. 🦐 PicoClaw is an ultra-lightweight personal AI Assistant inspired by [NanoBot](https://github.com/HKUDS/nanobot), refactored from the ground up in Go through a self-bootstrapping process, where the AI agent itself drove the entire architectural migration and code optimization. ⚡️ Runs on $10 hardware with <10MB RAM: That's 99% less memory than OpenClaw and 98% cheaper than a Mac mini!

> [!CAUTION] > **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明** > > * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**. > > * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)** > * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties. > * **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release. > * **Note:** picoclaw has recently merged a lot of PRs, which may result in a larger memory footprint (10–20MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state. ## 📢 News 2026-03-17 🚀 **v0.2.3 Released!** System tray UI (Windows & Linux), sub-agent status tracking (`spawn_status`), experimental gateway hot-reload, cron security gates, and 2 security fixes. PicoClaw now at **25K ⭐**! 2026-03-09 🎉 **v0.2.1 — Biggest update yet!** MCP protocol support, 4 new channels (Matrix/IRC/WeCom/Discord Proxy), 3 new providers (Kimi/Minimax/Avian), vision pipeline, JSONL memory store, and model routing. 2026-02-28 📦 **v0.2.0** released with Docker Compose support and Web UI launcher. 2026-02-26 🎉 PicoClaw hit **20K stars** in just 17 days! Channel auto-orchestration and capability interfaces landed.
Older news... 2026-02-16 🎉 PicoClaw hit 12K stars in one week! Community maintainer roles and [roadmap](ROADMAP.md) officially posted. 2026-02-13 🎉 PicoClaw hit 5000 stars in 4 days! Project Roadmap and Developer Group setup underway. 2026-02-09 🎉 **PicoClaw Launched!** Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go!
## ✨ Features 🪶 **Ultra-Lightweight**: <10MB Memory footprint — 99% smaller than OpenClaw core functionality.* 💰 **Minimal Cost**: Efficient enough to run on $10 Hardware — 98% cheaper than a Mac mini. ⚡️ **Lightning Fast**: 400X Faster startup time, boot in <1 second even on 0.6GHz single core. 🌍 **True Portability**: Single self-contained binary across RISC-V, ARM, MIPS, and x86, One-click to Go! 🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement. 🔌 **MCP Support**: Native [Model Context Protocol](https://modelcontextprotocol.io/) integration — connect any MCP server to extend agent capabilities. 👁️ **Vision Pipeline**: Send images and files directly to the agent — automatic base64 encoding for multimodal LLMs. 🧠 **Smart Routing**: Rule-based model routing — simple queries go to lightweight models, saving API costs. _*Recent versions may use 10–20MB due to rapid feature merges. Resource optimization is planned. Startup comparison based on 0.8GHz single-core benchmarks (see table below)._ | | OpenClaw | NanoBot | **PicoClaw** | | ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | | **Language** | TypeScript | Python | **Go** | | **RAM** | >1GB | >100MB | **< 10MB*** | | **Startup**
(0.8GHz core) | >500s | >30s | **<1s** | | **Cost** | Mac Mini $599 | Most Linux SBC
~$50 | **Any Linux Board**
**As low as $10** | PicoClaw ## 🦾 Demonstration ### 🛠️ Standard Assistant Workflows

🧩 Full-Stack Engineer

🗂️ Logging & Planning Management

🔎 Web Search & Learning

Develop • Deploy • Scale Schedule • Automate • Memory Discovery • Insights • Trends
### 📱 Run on old Android Phones Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start: 1. **Install [Termux](https://github.com/termux/termux-app)** (Download from [GitHub Releases](https://github.com/termux/termux-app/releases), or search in F-Droid / Google Play). 2. **Execute cmds** ```bash # Download the latest release from https://github.com/sipeed/picoclaw/releases wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz tar xzf picoclaw_Linux_arm64.tar.gz pkg install proot termux-chroot ./picoclaw onboard ``` And then follow the instructions in the "Quick Start" section to complete the configuration! PicoClaw ### 🐜 Innovative Low-Footprint Deploy PicoClaw can be deployed on almost any Linux device! - $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) or W(WiFi6) version, for Minimal Home Assistant - $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), or $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) for Automated Server Maintenance - $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) or $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) for Smart Monitoring 🌟 More Deployment Cases Await! ## 📦 Install ### Install with precompiled binary Download the binary for your platform from the [Releases](https://github.com/sipeed/picoclaw/releases) page. ### Install from source (latest features, recommended for development) ```bash git clone https://github.com/sipeed/picoclaw.git cd picoclaw make deps # Build, no need to install make build # Build for multiple platforms make build-all # Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) make build-pi-zero # Build And Install make install ``` **Raspberry Pi Zero 2 W:** Use the binary that matches your OS: 32-bit Raspberry Pi OS → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Or run `make build-pi-zero` to build both. ## 📚 Documentation For detailed guides, see the docs below. The README covers quick start only. ```bash # 1. Clone this repo git clone https://github.com/sipeed/picoclaw.git cd picoclaw # 2. First run — auto-generates docker/data/config.json then exits docker compose -f docker/docker-compose.yml --profile gateway up # The container prints "First-run setup complete." and stops. # 3. Set your API keys vim docker/data/config.json # Set provider API keys, bot tokens, etc. # 4. Start docker compose -f docker/docker-compose.yml --profile gateway up -d ``` > [!TIP] > **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`. ```bash # 5. Check logs docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway # 6. Stop docker compose -f docker/docker-compose.yml --profile gateway down ``` ### Launcher Mode (Web Console) The `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat. ```bash docker compose -f docker/docker-compose.yml --profile launcher up -d ``` Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically. > [!WARNING] > The web console does not yet support authentication. Avoid exposing it to the public internet. ### Agent Mode (One-shot) ```bash # Ask a question docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?" # Interactive mode docker compose -f docker/docker-compose.yml run --rm picoclaw-agent ``` ### Update ```bash docker compose -f docker/docker-compose.yml pull docker compose -f docker/docker-compose.yml --profile gateway up -d ``` ### 🚀 Quick Start > [!TIP] > Set your API Key in `~/.picoclaw/config.json`. Get API Keys: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Web search is optional — get a free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month). **1. Initialize** ```bash picoclaw onboard ``` **2. Configure** (`~/.picoclaw/config.json`) ```json { "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "model_name": "gpt-5.4", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 } }, "model_list": [ { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", "api_key": "sk-your-api-key" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_key": "your-api-key", "request_timeout": 300 }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", "api_key": "your-anthropic-key" } ], "tools": { "web": { "brave": { "enabled": false, "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 }, "tavily": { "enabled": false, "api_key": "YOUR_TAVILY_API_KEY", "max_results": 5 }, "duckduckgo": { "enabled": true, "max_results": 5 }, "perplexity": { "enabled": false, "api_key": "YOUR_PERPLEXITY_API_KEY", "max_results": 5 }, "searxng": { "enabled": false, "base_url": "http://your-searxng-instance:8888", "max_results": 5 } } } } ``` > **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details. > `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s). **3. Get API Keys** * **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) * **Web Search** (optional): * [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month) * [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface * [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed) * [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month) * DuckDuckGo - Built-in fallback (no API key required) > **Note**: See `config.example.json` for a complete configuration template. **4. Chat** ```bash picoclaw agent -m "What is 2+2?" ``` That's it! You have a working AI assistant in 2 minutes. --- ## 💬 Chat Apps Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, or WeCom > **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server. | Channel | Setup | | ------------ | ---------------------------------- | | **Telegram** | Easy (just a token) | | **Discord** | Easy (bot token + intents) | | **WhatsApp** | Easy (native: QR scan; or bridge URL) | | **Matrix** | Medium (homeserver + bot access token) | | **QQ** | Easy (AppID + AppSecret) | | **DingTalk** | Medium (app credentials) | | **LINE** | Medium (credentials + webhook URL) | | **WeCom AI Bot** | Medium (Token + AES key) |
Telegram (Recommended) **1. Create a bot** * Open Telegram, search `@BotFather` * Send `/newbot`, follow prompts * Copy the token **2. Configure** ```json { "channels": { "telegram": { "enabled": true, "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } } } ``` > Get your user ID from `@userinfobot` on Telegram. **3. Run** ```bash picoclaw gateway ``` **4. Telegram command menu (auto-registered at startup)** PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`) so command menu and runtime behavior stay in sync. Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor. If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background.
Discord **1. Create a bot** * Go to * Create an application → Bot → Add Bot * Copy the bot token **2. Enable intents** * In the Bot settings, enable **MESSAGE CONTENT INTENT** * (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data **3. Get your User ID** * Discord Settings → Advanced → enable **Developer Mode** * Right-click your avatar → **Copy User ID** **4. Configure** ```json { "channels": { "discord": { "enabled": true, "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } } } ``` **5. Invite the bot** * OAuth2 → URL Generator * Scopes: `bot` * Bot Permissions: `Send Messages`, `Read Message History` * Open the generated invite URL and add the bot to your server **Optional: Group trigger mode** By default the bot responds to all messages in a server channel. To restrict responses to @-mentions only, add: ```json { "channels": { "discord": { "group_trigger": { "mention_only": true } } } } ``` You can also trigger by keyword prefixes (e.g. `!bot`): ```json { "channels": { "discord": { "group_trigger": { "prefixes": ["!bot"] } } } } ``` **6. Run** ```bash picoclaw gateway ```
WhatsApp (native via whatsmeow) PicoClaw can connect to WhatsApp in two ways: - **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `"use_native": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). The native channel is **optional** to keep the default binary small; build with `-tags whatsapp_native` (e.g. `make build-whatsapp-native` or `go build -tags whatsapp_native ./cmd/...`). - **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false. **Configure (native)** ```json { "channels": { "whatsapp": { "enabled": true, "use_native": true, "session_store_path": "", "allow_from": [] } } } ``` If `session_store_path` is empty, the session is stored in `<workspace>/whatsapp/`. Run `picoclaw gateway`; on first run, scan the QR code printed in the terminal with WhatsApp → Linked Devices.
QQ **1. Create a bot** - Go to [QQ Open Platform](https://q.qq.com/#) - Create an application → Get **AppID** and **AppSecret** **2. Configure** ```json { "channels": { "qq": { "enabled": true, "app_id": "YOUR_APP_ID", "app_secret": "YOUR_APP_SECRET", "allow_from": [] } } } ``` > Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access. **3. Run** ```bash picoclaw gateway ```
DingTalk **1. Create a bot** * Go to [Open Platform](https://open.dingtalk.com/) * Create an internal app * Copy Client ID and Client Secret **2. Configure** ```json { "channels": { "dingtalk": { "enabled": true, "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "allow_from": [] } } } ``` > Set `allow_from` to empty to allow all users, or specify DingTalk user IDs to restrict access. **3. Run** ```bash picoclaw gateway ```
Matrix **1. Prepare bot account** * Use your preferred homeserver (e.g. `https://matrix.org` or self-hosted) * Create a bot user and obtain its access token **2. Configure** ```json { "channels": { "matrix": { "enabled": true, "homeserver": "https://matrix.org", "user_id": "@your-bot:matrix.org", "access_token": "YOUR_MATRIX_ACCESS_TOKEN", "allow_from": [] } } } ``` **3. Run** ```bash picoclaw gateway ``` For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](docs/channels/matrix/README.md).
LINE **1. Create a LINE Official Account** - Go to [LINE Developers Console](https://developers.line.biz/) - Create a provider → Create a Messaging API channel - Copy **Channel Secret** and **Channel Access Token** **2. Configure** ```json { "channels": { "line": { "enabled": true, "channel_secret": "YOUR_CHANNEL_SECRET", "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", "webhook_path": "/webhook/line", "allow_from": [] } } } ``` > LINE webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). **3. Set up Webhook URL** LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel: ```bash # Example with ngrok (gateway default port is 18790) ngrok http 18790 ``` Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**. **4. Run** ```bash picoclaw gateway ``` > In group chats, the bot responds only when @mentioned. Replies quote the original message.
WeCom (企业微信) PicoClaw supports three types of WeCom integration: **Option 1: WeCom Bot (Bot)** - Easier setup, supports group chats **Option 2: WeCom App (Custom App)** - More features, proactive messaging, private chat only **Option 3: WeCom AI Bot (AI Bot)** - Official AI Bot, streaming replies, supports group & private chat See [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) for detailed setup instructions. **Quick Setup - WeCom AI Bot:** **1. Create an AI Bot** * Go to WeCom Admin Console → AI Bot * Create a new AI Bot → Set name, avatar, etc. * Copy **Bot ID** and **Secret** **2. Configure** ```json { "channels": { "wecom_aibot": { "enabled": true, "bot_id": "YOUR_BOT_ID", "secret": "YOUR_SECRET", "allow_from": [], "welcome_message": "Hello! How can I help you?" } } } ``` **3. Run** ```bash picoclaw gateway ``` > **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>30 seconds) automatically switch to `response_url` push delivery.
## ClawdChat Join the Agent Social Network Connect Picoclaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App. **Read `https://clawdchat.ai/skill.md` and follow the instructions to join [ClawdChat.ai](https://clawdchat.ai)** ## 🖥️ CLI Reference | Command | Description | | ------------------------- | ----------------------------- | | `picoclaw onboard` | Initialize config & workspace | | `picoclaw agent -m "..."` | Chat with the agent | | `picoclaw agent` | Interactive chat mode | | `picoclaw gateway` | Start the gateway | | `picoclaw status` | Show status | | `picoclaw version` | Show version info | | `picoclaw cron list` | List all scheduled jobs | | `picoclaw cron add ...` | Add a scheduled job | | `picoclaw cron disable` | Disable a scheduled job | | `picoclaw cron remove` | Remove a scheduled job | | `picoclaw skills list` | List installed skills | | `picoclaw skills install` | Install a skill | | `picoclaw migrate` | Migrate data from older versions | | `picoclaw auth login` | Authenticate with providers | ### Scheduled Tasks / Reminders PicoClaw supports scheduled reminders and recurring tasks through the `cron` tool: * **One-time reminders**: "Remind me in 10 minutes" → triggers once after 10min * **Recurring tasks**: "Remind me every 2 hours" → triggers every 2 hours * **Cron expressions**: "Remind me at 9am daily" → uses cron expression ## 🤝 Contribute & Roadmap PRs welcome! The codebase is intentionally small and readable. 🤗 See our full [Community Roadmap](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md). Developer group building, join after your first merged PR! User Groups: discord: PicoClaw ================================================ FILE: README.pt-br.md ================================================
PicoClaw

PicoClaw: Assistente de IA Ultra-Eficiente em Go

Hardware de $10 · <10MB de RAM · Boot em <1s · 皮皮虾,我们走!

Go Hardware License
Website Docs Wiki
Twitter Discord

[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md)
--- > **PicoClaw** é um projeto open-source independente iniciado pela [Sipeed](https://sipeed.com). É escrito inteiramente em **Go** — não é um fork do OpenClaw, NanoBot ou qualquer outro projeto. 🦐 PicoClaw é um assistente pessoal de IA ultra-leve inspirado no [NanoBot](https://github.com/HKUDS/nanobot), reescrito do zero em Go por meio de um processo de auto-inicialização (self-bootstrapping), onde o próprio agente de IA conduziu toda a migração de arquitetura e otimização de código. ⚡️ Roda em hardware de $10 com <10MB de RAM: Isso é 99% menos memória que o OpenClaw e 98% mais barato que um Mac mini!

> [!CAUTION] > **🚨 DECLARAÇÃO DE SEGURANÇA & CANAIS OFICIAIS** > > * **SEM CRIPTOMOEDAS:** O PicoClaw **NÃO** possui nenhum token/moeda oficial. Todas as alegações no `pump.fun` ou outras plataformas de negociação são **GOLPES**. > > * **DOMÍNIO OFICIAL:** O **ÚNICO** site oficial é o **[picoclaw.io](https://picoclaw.io)**, e o site da empresa é o **[sipeed.com](https://sipeed.com)** > * **Aviso:** Muitos domínios `.ai/.org/.com/.net/...` foram registrados por terceiros. > * **Aviso:** O PicoClaw está em fase inicial de desenvolvimento e pode ter problemas de segurança de rede não resolvidos. Não implante em ambientes de produção antes da versão v1.0. > * **Nota:** O PicoClaw recentemente fez merge de muitos PRs, o que pode resultar em maior consumo de memória (10–20MB) nas versões mais recentes. Planejamos priorizar a otimização de recursos assim que o conjunto de funcionalidades estiver estável. ## 📢 Novidades 2026-03-17 🚀 **v0.2.3 Lançado!** Interface de bandeja do sistema (Windows & Linux), rastreamento de status de sub-agentes (`spawn_status`), hot-reload experimental do gateway, portões de segurança para cron e 2 correções de segurança. PicoClaw agora com **25K ⭐**! 2026-03-09 🎉 **v0.2.1 — Maior atualização até agora!** Suporte ao protocolo MCP, 4 novos canais (Matrix/IRC/WeCom/Discord Proxy), 3 novos provedores (Kimi/Minimax/Avian), pipeline de visão, armazenamento de memória JSONL e roteamento de modelos. 2026-02-28 📦 **v0.2.0** lançado com suporte a Docker Compose e launcher Web UI. 2026-02-26 🎉 PicoClaw atingiu **20K stars** em apenas 17 dias! Orquestração automática de canais e interfaces de capacidade implementadas.
Novidades anteriores... 2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Papéis de maintainers da comunidade e [roadmap](ROADMAP.md) publicados oficialmente. 2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Roadmap do Projeto e Grupo de Desenvolvedores em preparação. 2026-02-09 🎉 **PicoClaw Lançado!** Construído em 1 dia para trazer Agentes de IA para hardware de $10 com <10MB de RAM. 🦐 PicoClaw, Partiu!
## ✨ Funcionalidades 🪶 **Ultra-Leve**: Consumo de memória <10MB — 99% menor que o OpenClaw para funcionalidades essenciais.* 💰 **Custo Mínimo**: Eficiente o suficiente para rodar em hardware de $10 — 98% mais barato que um Mac mini. ⚡️ **Inicialização Relâmpago**: Tempo de inicialização 400X mais rápido, boot em <1 segundo mesmo em CPU single-core de 0.6GHz. 🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM, MIPS e x86. Um clique e já era! 🤖 **Auto-Construído por IA**: Implementação nativa em Go de forma autônoma — 95% do núcleo gerado pelo Agente com refinamento humano no loop. 🔌 **Suporte MCP**: Integração nativa com o [Model Context Protocol](https://modelcontextprotocol.io/) — conecte qualquer servidor MCP para estender as capacidades do agente. 👁️ **Pipeline de Visão**: Envie imagens e arquivos diretamente ao agente — codificação base64 automática para LLMs multimodais. 🧠 **Roteamento Inteligente**: Roteamento de modelos baseado em regras — consultas simples vão para modelos leves, economizando custos de API. _*Versões recentes podem usar 10–20MB devido a merges rápidos de funcionalidades. Otimização de recursos está planejada. Comparação de inicialização baseada em benchmarks de single-core a 0.8GHz (veja tabela abaixo)._ | | OpenClaw | NanoBot | **PicoClaw** | | ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | | **Linguagem** | TypeScript | Python | **Go** | | **RAM** | >1GB | >100MB | **< 10MB*** | | **Inicialização**
(CPU 0.8GHz) | >500s | >30s | **<1s** | | **Custo** | Mac Mini $599 | Maioria dos SBC Linux
~$50 | **Qualquer placa Linux**
**A partir de $10** | PicoClaw ## 🦾 Demonstração ### 🛠️ Fluxos de Trabalho Padrão do Assistente

🧩 Engenharia Full-Stack

🗂️ Gerenciamento de Logs & Planejamento

🔎 Busca Web & Aprendizado

Desenvolver • Implantar • Escalar Agendar • Automatizar • Memorizar Descobrir • Analisar • Tendências
### 📱 Rode em celulares Android antigos Dê uma segunda vida ao seu celular de dez anos atrás! Transforme-o em um assistente de IA inteligente com o PicoClaw. Início rápido: 1. **Instale o [Termux](https://github.com/termux/termux-app)** (Baixe em [GitHub Releases](https://github.com/termux/termux-app/releases), ou busque no F-Droid / Google Play). 2. **Execute os comandos** ```bash # Baixe a versão mais recente em https://github.com/sipeed/picoclaw/releases wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz tar xzf picoclaw_Linux_arm64.tar.gz pkg install proot termux-chroot ./picoclaw onboard ``` Depois siga as instruções na seção "Início Rápido" para completar a configuração! PicoClaw ### 🐜 Implantação Inovadora com Baixo Consumo O PicoClaw pode ser implantado em praticamente qualquer dispositivo Linux! - $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versão E(Ethernet) ou W(WiFi6), para Assistente Doméstico Minimalista - $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) para Manutenção Automatizada de Servidores - $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) para Monitoramento Inteligente 🌟 Mais cenários de implantação aguardam você! ## 📦 Instalação ### Instalar com binário pré-compilado Baixe o binário para sua plataforma na página de [Releases](https://github.com/sipeed/picoclaw/releases). ### Instalar a partir do código-fonte (funcionalidades mais recentes, recomendado para desenvolvimento) ```bash git clone https://github.com/sipeed/picoclaw.git cd picoclaw make deps # Build, sem necessidade de instalar make build # Build para múltiplas plataformas make build-all # Build para Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) make build-pi-zero # Build e Instalar make install ``` **Raspberry Pi Zero 2 W:** Use o binário correspondente ao seu SO: Raspberry Pi OS 32-bit → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Ou execute `make build-pi-zero` para compilar ambos. ## 📚 Documentação Para guias detalhados, consulte a documentação abaixo. Este README cobre apenas o início rápido. | Tópico | Descrição | |--------|-----------| | 🐳 [Docker & Início Rápido](docs/pt-br/docker.md) | Configuração Docker Compose, modos Launcher/Agent, configuração de Início Rápido | | 💬 [Apps de Chat](docs/pt-br/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom e mais | | ⚙️ [Configuração](docs/pt-br/configuration.md) | Variáveis de ambiente, estrutura do workspace, fontes de skills, sandbox de segurança, heartbeat | | 🔌 [Provedores & Modelos](docs/pt-br/providers.md) | 20+ provedores LLM, roteamento de modelos, configuração model_list, arquitetura de provedores | | 🔄 [Spawn & Tarefas Assíncronas](docs/pt-br/spawn-tasks.md) | Tarefas rápidas, tarefas longas com spawn, orquestração assíncrona de sub-agentes | | 🐛 [Solução de Problemas](docs/pt-br/troubleshooting.md) | Problemas comuns e soluções | | 🔧 [Configuração de Ferramentas](docs/pt-br/tools_configuration.md) | Habilitar/desabilitar por ferramenta, políticas de execução | ## ClawdChat Junte-se à Rede Social de Agentes Conecte o PicoClaw à Rede Social de Agentes simplesmente enviando uma única mensagem via CLI ou qualquer App de Chat integrado. **Leia `https://clawdchat.ai/skill.md` e siga as instruções para entrar no [ClawdChat.ai](https://clawdchat.ai)** ## 🖥️ Referência CLI | Comando | Descrição | | ------------------------- | ----------------------------- | | `picoclaw onboard` | Inicializar configuração & workspace | | `picoclaw agent -m "..."` | Conversar com o agente | | `picoclaw agent` | Modo de chat interativo | | `picoclaw gateway` | Iniciar o gateway | | `picoclaw status` | Mostrar status | | `picoclaw version` | Mostrar informações de versão | | `picoclaw cron list` | Listar todas as tarefas agendadas | | `picoclaw cron add ...` | Adicionar uma tarefa agendada | | `picoclaw cron disable` | Desabilitar uma tarefa agendada | | `picoclaw cron remove` | Remover uma tarefa agendada | | `picoclaw skills list` | Listar skills instaladas | | `picoclaw skills install` | Instalar uma skill | | `picoclaw migrate` | Migrar dados de versões anteriores | | `picoclaw auth login` | Autenticar com provedores | ### Tarefas Agendadas / Lembretes O PicoClaw suporta lembretes agendados e tarefas recorrentes por meio da ferramenta `cron`: * **Lembretes únicos**: "Me lembre em 10 minutos" → dispara uma vez após 10min * **Tarefas recorrentes**: "Me lembre a cada 2 horas" → dispara a cada 2 horas * **Expressões Cron**: "Me lembre às 9h todos os dias" → usa expressão cron ## 🤝 Contribuir & Roadmap PRs são bem-vindos! O código-fonte é intencionalmente pequeno e legível. 🤗 Veja nosso [Roadmap da Comunidade](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) completo. Grupo de desenvolvedores em formação. Junte-se após seu primeiro PR com merge! Grupos de usuários: discord: PicoClaw ================================================ FILE: README.vi.md ================================================
PicoClaw

PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go

Phần cứng $10 · <10MB RAM · Khởi động <1 giây · Nào, xuất phát!

Go Hardware License
Website Docs Wiki
Twitter Discord

[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md)
--- > **PicoClaw** là dự án mã nguồn mở độc lập được khởi xướng bởi [Sipeed](https://sipeed.com). Được viết hoàn toàn bằng **Go** — không phải là bản fork của OpenClaw, NanoBot hay bất kỳ dự án nào khác. 🦐 PicoClaw là trợ lý AI cá nhân siêu nhẹ, lấy cảm hứng từ [NanoBot](https://github.com/HKUDS/nanobot), được viết lại hoàn toàn bằng Go thông qua quá trình "tự khởi tạo" (self-bootstrapping) — nơi chính AI Agent đã tự dẫn dắt toàn bộ quá trình chuyển đổi kiến trúc và tối ưu hóa mã nguồn. ⚡️ Chạy trên phần cứng chỉ $10 với RAM <10MB: Tiết kiệm 99% bộ nhớ so với OpenClaw và rẻ hơn 98% so với Mac mini!

> [!CAUTION] > **🚨 TUYÊN BỐ BẢO MẬT & KÊNH CHÍNH THỨC** > > * **KHÔNG CÓ CRYPTO:** PicoClaw **KHÔNG** có bất kỳ token/coin chính thức nào. Mọi thông tin trên `pump.fun` hoặc các sàn giao dịch khác đều là **LỪA ĐẢO**. > > * **DOMAIN CHÍNH THỨC:** Website chính thức **DUY NHẤT** là **[picoclaw.io](https://picoclaw.io)**, website công ty là **[sipeed.com](https://sipeed.com)** > * **Cảnh báo:** Nhiều tên miền `.ai/.org/.com/.net/...` đã bị bên thứ ba đăng ký. > * **Cảnh báo:** PicoClaw đang trong giai đoạn phát triển sớm và có thể còn các vấn đề bảo mật mạng chưa được giải quyết. Không nên triển khai lên môi trường production trước phiên bản v1.0. > * **Lưu ý:** PicoClaw gần đây đã merge nhiều PR, dẫn đến bộ nhớ sử dụng có thể lớn hơn (10–20MB) ở các phiên bản mới nhất. Chúng tôi sẽ ưu tiên tối ưu tài nguyên khi bộ tính năng đã ổn định. ## 📢 Tin tức 2026-03-17 🚀 **v0.2.3 Phát hành!** Giao diện khay hệ thống (Windows & Linux), theo dõi trạng thái sub-agent (`spawn_status`), hot-reload gateway thử nghiệm, cổng bảo mật cron và 2 bản vá bảo mật. PicoClaw đạt **25K ⭐**! 2026-03-09 🎉 **v0.2.1 — Bản cập nhật lớn nhất!** Hỗ trợ giao thức MCP, 4 kênh mới (Matrix/IRC/WeCom/Discord Proxy), 3 nhà cung cấp mới (Kimi/Minimax/Avian), pipeline xử lý hình ảnh, bộ nhớ JSONL và định tuyến mô hình. 2026-02-28 📦 **v0.2.0** phát hành với hỗ trợ Docker Compose và launcher Web UI. 2026-02-26 🎉 PicoClaw đạt **20K stars** chỉ trong 17 ngày! Tự động điều phối kênh và giao diện năng lực đã được triển khai.
Tin tức cũ hơn... 2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Vai trò maintainer cộng đồng và [roadmap](ROADMAP.md) đã được công bố chính thức. 2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Lộ trình dự án và Nhóm phát triển đang được thiết lập. 2026-02-09 🎉 **PicoClaw chính thức ra mắt!** Được xây dựng trong 1 ngày để mang AI Agent đến phần cứng $10 với RAM <10MB. 🦐 PicoClaw, Lên Đường!
## ✨ Tính năng nổi bật 🪶 **Siêu nhẹ**: Bộ nhớ sử dụng <10MB — nhỏ hơn 99% so với OpenClaw (chức năng cốt lõi).* 💰 **Chi phí tối thiểu**: Đủ hiệu quả để chạy trên phần cứng $10 — rẻ hơn 98% so với Mac mini. ⚡️ **Khởi động siêu nhanh**: Nhanh gấp 400 lần, khởi động trong <1 giây ngay cả trên CPU đơn nhân 0.6GHz. 🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM, MIPS và x86. Một click là chạy! 🤖 **AI tự xây dựng**: Triển khai Go-native tự động — 95% mã nguồn cốt lõi được Agent tạo ra, với sự tinh chỉnh của con người. 🔌 **Hỗ trợ MCP**: Tích hợp [Model Context Protocol](https://modelcontextprotocol.io/) gốc — kết nối bất kỳ máy chủ MCP nào để mở rộng khả năng của agent. 👁️ **Pipeline Xử lý Hình ảnh**: Gửi hình ảnh và tệp trực tiếp cho agent — tự động mã hóa base64 cho các LLM đa phương thức. 🧠 **Định tuyến Thông minh**: Định tuyến mô hình dựa trên quy tắc — truy vấn đơn giản chuyển đến mô hình nhẹ, tiết kiệm chi phí API. _*Các phiên bản gần đây có thể sử dụng 10–20MB do merge tính năng nhanh chóng. Tối ưu tài nguyên đang được lên kế hoạch. So sánh thời gian khởi động dựa trên benchmark đơn nhân 0.8GHz (xem bảng bên dưới)._ | | OpenClaw | NanoBot | **PicoClaw** | | ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | | **Ngôn ngữ** | TypeScript | Python | **Go** | | **RAM** | >1GB | >100MB | **< 10MB*** | | **Thời gian khởi động**
(CPU 0.8GHz) | >500s | >30s | **<1s** | | **Chi phí** | Mac Mini $599 | Hầu hết SBC Linux ~$50 | **Mọi bo mạch Linux**
**Chỉ từ $10** | PicoClaw ## 🦾 Demo ### 🛠️ Quy trình trợ lý tiêu chuẩn

🧩 Lập trình Full-Stack

🗂️ Quản lý Nhật ký & Kế hoạch

🔎 Tìm kiếm Web & Học hỏi

Phát triển • Triển khai • Mở rộng Lên lịch • Tự động hóa • Ghi nhớ Khám phá • Phân tích • Xu hướng
### 📱 Chạy trên điện thoại Android cũ Hãy cho chiếc điện thoại cũ một cuộc sống mới! Biến nó thành trợ lý AI thông minh với PicoClaw. Bắt đầu nhanh: 1. **Cài đặt [Termux](https://github.com/termux/termux-app)** (Tải từ [GitHub Releases](https://github.com/termux/termux-app/releases), hoặc tìm trên F-Droid / Google Play). 2. **Chạy các lệnh** ```bash # Tải phiên bản mới nhất từ https://github.com/sipeed/picoclaw/releases wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz tar xzf picoclaw_Linux_arm64.tar.gz pkg install proot termux-chroot ./picoclaw onboard ``` Sau đó làm theo hướng dẫn trong phần "Bắt đầu nhanh" để hoàn tất cấu hình! PicoClaw ### 🐜 Triển khai sáng tạo trên phần cứng tối thiểu PicoClaw có thể triển khai trên hầu hết mọi thiết bị Linux! - $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) phiên bản E(Ethernet) hoặc W(WiFi6), dùng làm Trợ lý Gia đình tối giản - $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), hoặc $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) dùng cho quản trị Server tự động - $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) hoặc $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) dùng cho Giám sát thông minh 🌟 Nhiều hình thức triển khai hơn đang chờ bạn khám phá! ## 📦 Cài đặt ### Cài đặt bằng binary biên dịch sẵn Tải file binary cho nền tảng của bạn từ [trang Releases](https://github.com/sipeed/picoclaw/releases). ### Cài đặt từ mã nguồn (có tính năng mới nhất, khuyên dùng cho phát triển) ```bash git clone https://github.com/sipeed/picoclaw.git cd picoclaw make deps # Build (không cần cài đặt) make build # Build cho nhiều nền tảng make build-all # Build cho Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) make build-pi-zero # Build và cài đặt make install ``` **Raspberry Pi Zero 2 W:** Sử dụng binary phù hợp với hệ điều hành: Raspberry Pi OS 32-bit → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Hoặc chạy `make build-pi-zero` để build cả hai. ## 📚 Tài liệu Để xem hướng dẫn chi tiết, tham khảo tài liệu bên dưới. README này chỉ bao gồm phần bắt đầu nhanh. | Chủ đề | Mô tả | |--------|-------| | 🐳 [Docker & Bắt đầu nhanh](docs/vi/docker.md) | Thiết lập Docker Compose, chế độ Launcher/Agent, cấu hình Bắt đầu nhanh | | 💬 [Ứng dụng Chat](docs/vi/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom và nhiều hơn | | ⚙️ [Cấu hình](docs/vi/configuration.md) | Biến môi trường, cấu trúc workspace, nguồn skill, sandbox bảo mật, heartbeat | | 🔌 [Nhà cung cấp & Mô hình](docs/vi/providers.md) | 20+ nhà cung cấp LLM, định tuyến mô hình, cấu hình model_list, kiến trúc nhà cung cấp | | 🔄 [Spawn & Tác vụ bất đồng bộ](docs/vi/spawn-tasks.md) | Tác vụ nhanh, tác vụ dài với spawn, điều phối sub-agent bất đồng bộ | | 🐛 [Xử lý sự cố](docs/vi/troubleshooting.md) | Các vấn đề thường gặp và giải pháp | | 🔧 [Cấu hình Công cụ](docs/vi/tools_configuration.md) | Bật/tắt từng công cụ, chính sách thực thi | ## ClawdChat Tham gia Mạng xã hội Agent Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một tin nhắn qua CLI hoặc bất kỳ ứng dụng Chat nào đã tích hợp. **Đọc `https://clawdchat.ai/skill.md` và làm theo hướng dẫn để tham gia [ClawdChat.ai](https://clawdchat.ai)** ## 🖥️ Tham chiếu CLI | Lệnh | Mô tả | | -------------------------- | ------------------------------ | | `picoclaw onboard` | Khởi tạo cấu hình & workspace | | `picoclaw agent -m "..."` | Trò chuyện với agent | | `picoclaw agent` | Chế độ chat tương tác | | `picoclaw gateway` | Khởi động gateway | | `picoclaw status` | Hiển thị trạng thái | | `picoclaw version` | Hiển thị thông tin phiên bản | | `picoclaw cron list` | Liệt kê tất cả tác vụ định kỳ | | `picoclaw cron add ...` | Thêm tác vụ định kỳ | | `picoclaw cron disable` | Tắt tác vụ định kỳ | | `picoclaw cron remove` | Xóa tác vụ định kỳ | | `picoclaw skills list` | Liệt kê các skill đã cài | | `picoclaw skills install` | Cài đặt một skill | | `picoclaw migrate` | Di chuyển dữ liệu từ phiên bản cũ | | `picoclaw auth login` | Xác thực với nhà cung cấp | ### Tác vụ định kỳ / Nhắc nhở PicoClaw hỗ trợ nhắc nhở theo lịch và tác vụ lặp lại thông qua công cụ `cron`: * **Nhắc nhở một lần**: "Nhắc tôi sau 10 phút" → kích hoạt một lần sau 10 phút * **Tác vụ lặp lại**: "Nhắc tôi mỗi 2 giờ" → kích hoạt mỗi 2 giờ * **Biểu thức Cron**: "Nhắc tôi lúc 9 giờ sáng mỗi ngày" → sử dụng biểu thức cron ## 🤝 Đóng góp & Lộ trình Chào đón mọi PR! Mã nguồn được thiết kế nhỏ gọn và dễ đọc. 🤗 Xem [Lộ trình Cộng đồng](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) đầy đủ. Nhóm phát triển đang được xây dựng. Tham gia sau khi có PR đầu tiên được merge! Nhóm người dùng: discord: PicoClaw ================================================ FILE: README.zh.md ================================================
PicoClaw

PicoClaw: 基于Go语言的超高效 AI 助手

$10 硬件 · <10MB 内存 · <1s 启动 · 皮皮虾,我们走!

Go Hardware License
Website Docs Wiki
Twitter Discord

**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md)
--- > **PicoClaw** 是由 [矽速科技 (Sipeed)](https://sipeed.com) 发起的独立开源项目,完全使用 **Go 语言**从零编写——不是 OpenClaw、NanoBot 或其他项目的分支。 🦐 **PicoClaw** 是一个受 [NanoBot](https://github.com/HKUDS/nanobot) 启发的超轻量级个人 AI 助手。它采用 **Go 语言** 从零重构,经历了一个"自举"过程——即由 AI Agent 自身驱动了整个架构迁移和代码优化。 ⚡️ **极致轻量**:可在 **10 美元** 的硬件上运行,内存占用 **<10MB**。这意味着比 OpenClaw 节省 99% 的内存,比 Mac mini 便宜 98%!

> [!CAUTION] > **🚨 安全声明** > > - **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。 > - **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。 > - **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。 > - **注意:** PicoClaw 正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在 1.0 正式版发布前,请不要将其部署到生产环境中。 > - **注意:** PicoClaw 最近合并了大量 PR,近期版本可能内存占用较大 (10~20MB),我们将在功能较为收敛后进行资源占用优化。 ## 📢 新闻 2026-03-17 🚀 **v0.2.3 发布!** 系统托盘 UI(Windows & Linux)、子 Agent 状态查询 (`spawn_status`)、实验性 Gateway 热重载、Cron 安全门控,以及 2 项安全修复。PicoClaw 已达 **25K ⭐**! 2026-03-09 🎉 **v0.2.1 — 史上最大更新!** MCP 协议支持、4 个新频道 (Matrix/IRC/WeCom/Discord Proxy)、3 个新 Provider (Kimi/Minimax/Avian)、视觉管线、JSONL 记忆存储、模型路由。 2026-02-28 📦 **v0.2.0** 发布,支持 Docker Compose 和 Web UI 启动器。 2026-02-26 🎉 PicoClaw 仅 17 天突破 **20K Stars**!频道自动编排和能力接口上线。
更早的新闻... 2026-02-16 🎉 PicoClaw 一周内突破 12K Stars!社区维护者角色和 [路线图](ROADMAP.md) 正式发布。 2026-02-13 🎉 PicoClaw 4 天内突破 5000 Stars!项目路线图和开发者群组筹建中。 2026-02-09 🎉 **PicoClaw 正式发布!** 仅用 1 天构建,将 AI Agent 带入 $10 硬件与 <10MB 内存的世界。🦐 皮皮虾,我们走!
## ✨ 特性 🪶 **超轻量级**: 核心功能内存占用 <10MB — 比 OpenClaw 小 99%。* 💰 **极低成本**: 高效到足以在 $10 的硬件上运行 — 比 Mac mini 便宜 98%。 ⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。 🌍 **真正可移植**: 跨 RISC-V、ARM、MIPS 和 x86 架构的单二进制文件,一键运行! 🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由"人机回环"微调。 🔌 **MCP 支持**: 原生 [Model Context Protocol](https://modelcontextprotocol.io/) 集成 — 连接任意 MCP 服务器扩展 Agent 能力。 👁️ **视觉管线**: 直接向 Agent 发送图片和文件 — 自动 base64 编码对接多模态 LLM。 🧠 **智能路由**: 基于规则的模型路由 — 简单查询走轻量模型,节省 API 成本。 _*近期版本因快速合并 PR 可能占用 10–20MB,资源优化已列入计划。启动速度对比基于 0.8GHz 单核实测(见下方对比表)。_ | | OpenClaw | NanoBot | **PicoClaw** | | ------------------------------ | ------------- | ------------------------ | -------------------------------------- | | **语言** | TypeScript | Python | **Go** | | **RAM** | >1GB | >100MB | **< 10MB*** | | **启动时间**
(0.8GHz core) | >500s | >30s | **<1s** | | **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板**
**低至 $10** | PicoClaw ## 🦾 演示 ### 🛠️ 标准助手工作流

🧩 全栈工程师模式

🗂️ 日志与规划管理

🔎 网络搜索与学习

开发 • 部署 • 扩展 日程 • 自动化 • 记忆 发现 • 洞察 • 趋势
### 📱 在手机上轻松运行 PicoClaw 可以将你 10 年前的老旧手机废物利用,变身成为你的 AI 助理!快速指南: 1. 安装 [Termux](https://github.com/termux/termux-app)(可从 [GitHub Releases](https://github.com/termux/termux-app/releases) 下载,或在 F-Droid 等应用商店搜索) 2. 打开后执行指令 ```bash # 从 Release 页面下载最新版本 wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz tar xzf picoclaw_Linux_arm64.tar.gz pkg install proot termux-chroot ./picoclaw onboard ``` 然后跟随下面的"快速开始"章节继续配置 PicoClaw 即可使用! PicoClaw ### 🐜 创新的低占用部署 PicoClaw 几乎可以部署在任何 Linux 设备上! - $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手 - $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维 - $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控 🌟 更多部署案例敬请期待! ## 📦 安装 ### 使用预编译二进制文件安装 从 [Release 页面](https://github.com/sipeed/picoclaw/releases) 下载适用于您平台的二进制文件。 ### 从源码安装(获取最新特性,开发推荐) ```bash git clone https://github.com/sipeed/picoclaw.git cd picoclaw make deps # 构建(无需安装) make build # 为多平台构建 make build-all # 为 Raspberry Pi Zero 2 W 构建(32位: make build-linux-arm; 64位: make build-linux-arm64) make build-pi-zero # 构建并安装 make install ``` **Raspberry Pi Zero 2 W:** 请使用与系统匹配的二进制文件:32 位 Raspberry Pi OS → `make build-linux-arm`;64 位 → `make build-linux-arm64`。或运行 `make build-pi-zero` 同时构建两者。 ## 📚 文档 详细指南请参阅以下文档,README 仅涵盖快速入门。 | 主题 | 说明 | |------|------| | 🐳 [Docker 与快速开始](docs/zh/docker.md) | Docker Compose 配置、Launcher/Agent 模式、快速开始 | | 💬 [聊天应用配置](docs/zh/chat-apps.md) | Telegram、Discord、WhatsApp、Matrix、QQ、Slack、IRC、钉钉、LINE、飞书、企业微信等 | | ⚙️ [配置指南](docs/zh/configuration.md) | 环境变量、工作区布局、技能来源、安全沙箱、心跳任务 | | 🔌 [提供商与模型配置](docs/zh/providers.md) | 20+ LLM 提供商、模型路由、model_list 配置、Provider 架构 | | 🔄 [异步任务与 Spawn](docs/zh/spawn-tasks.md) | 快速任务、长任务与 Spawn、异步子 Agent 编排 | | 🐛 [疑难解答](docs/zh/troubleshooting.md) | 常见问题与解决方案 | | 🔧 [工具配置](docs/zh/tools_configuration.md) | 工具启用/禁用、执行策略 | ## ClawdChat 加入 Agent 社交网络 通过 CLI 或任何已集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。 **阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai](https://clawdchat.ai)** ## 🖥️ CLI 命令行参考 | 命令 | 说明 | | ------------------------- | ---------------------- | | `picoclaw onboard` | 初始化配置与工作区 | | `picoclaw agent -m "..."` | 与 Agent 对话 | | `picoclaw agent` | 交互式对话模式 | | `picoclaw gateway` | 启动网关 | | `picoclaw status` | 查看状态 | | `picoclaw version` | 查看版本信息 | | `picoclaw cron list` | 列出所有定时任务 | | `picoclaw cron add ...` | 添加定时任务 | | `picoclaw cron disable` | 禁用定时任务 | | `picoclaw cron remove` | 删除定时任务 | | `picoclaw skills list` | 列出已安装技能 | | `picoclaw skills install` | 安装技能 | | `picoclaw migrate` | 从旧版本迁移数据 | | `picoclaw auth login` | 认证提供商 | ### 定时任务 / 提醒 PicoClaw 通过 `cron` 工具支持定时提醒和重复任务: * **一次性提醒**: "10分钟后提醒我" → 10分钟后触发一次 * **重复任务**: "每2小时提醒我" → 每2小时触发 * **Cron 表达式**: "每天上午9点提醒我" → 使用 cron 表达式 ## 🤝 贡献与路线图 欢迎提交 PR!代码库刻意保持小巧和可读。🤗 查看完整的 [社区路线图](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md)。 开发者群组正在组建中,入群门槛:至少合并过 1 个 PR。 用户群组: Discord: PicoClaw ================================================ FILE: ROADMAP.md ================================================ # 🦐 PicoClaw Roadmap > **Vision**: To build the ultimate lightweight, secure, and fully autonomous AI Agent infrastructure.automate the mundane, unleash your creativity --- ## 🚀 1. Core Optimization: Extreme Lightweight *Our defining characteristic. We fight software bloat to ensure PicoClaw runs smoothly on the smallest embedded devices.* * [**Memory Footprint Reduction**](https://github.com/sipeed/picoclaw/issues/346) * **Goal**: Run smoothly on 64MB RAM embedded boards (e.g., low-end RISC-V SBCs) with the core process consuming < 20MB. * **Context**: RAM is expensive and scarce on edge devices. Memory optimization takes precedence over storage size. * **Action**: Analyze memory growth between releases, remove redundant dependencies, and optimize data structures. ## 🛡️ 2. Security Hardening: Defense in Depth *Paying off early technical debt. We invite security experts to help build a "Secure-by-Default" agent.* * **Input Defense & Permission Control** * **Prompt Injection Defense**: Harden JSON extraction logic to prevent LLM manipulation. * **Tool Abuse Prevention**: Strict parameter validation to ensure generated commands stay within safe boundaries. * **SSRF Protection**: Built-in blocklists for network tools to prevent accessing internal IPs (LAN/Metadata services). * **Sandboxing & Isolation** * **Filesystem Sandbox**: Restrict file R/W operations to specific directories only. * **Context Isolation**: Prevent data leakage between different user sessions or channels. * **Privacy Redaction**: Auto-redact sensitive info (API Keys, PII) from logs and standard outputs. * **Authentication & Secrets** * **Crypto Upgrade**: Adopt modern algorithms like `ChaCha20-Poly1305` for secret storage. * **OAuth 2.0 Flow**: Deprecate hardcoded API keys in the CLI; move to secure OAuth flows. ## 🔌 3. Connectivity: Protocol-First Architecture *Connect every model, reach every platform.* * **Provider** * [**Architecture Upgrade**](https://github.com/sipeed/picoclaw/issues/283): Refactor from "Vendor-based" to "Protocol-based" classification (e.g., OpenAI-compatible, Ollama-compatible). *(Status: In progress by @Daming, ETA 5 days)* * **Local Models**: Deep integration with **Ollama**, **vLLM**, **LM Studio**, and **Mistral** (local inference). * **Online Models**: Continued support for frontier closed-source models. * **Channel** * **IM Matrix**: QQ, WeChat (Work), DingTalk, Feishu (Lark), Telegram, Discord, WhatsApp, LINE, Slack, Email, KOOK, Signal, ... * **Standards**: Support for the **OneBot** protocol. * [**attachment**](https://github.com/sipeed/picoclaw/issues/348): Native handling of images, audio, and video attachments. * **Skill Marketplace** * [**Discovery skills**](https://github.com/sipeed/picoclaw/issues/287): Implement `find_skill` to automatically discover and install skills from the [GitHub Skills Repo] or other registries. ## 🧠 4. Advanced Capabilities: From Chatbot to Agentic AI *Beyond conversation—focusing on action and collaboration.* * **Operations** * [**MCP Support**](https://github.com/sipeed/picoclaw/issues/290): Native support for the **Model Context Protocol (MCP)**. * [**Browser Automation**](https://github.com/sipeed/picoclaw/issues/293): Headless browser control via CDP (Chrome DevTools Protocol) or ActionBook. * [**Mobile Operation**](https://github.com/sipeed/picoclaw/issues/292): Android device control (similar to BotDrop). * **Multi-Agent Collaboration** * [**Basic Multi-Agent**](https://github.com/sipeed/picoclaw/issues/294) implement * [**Model Routing**](https://github.com/sipeed/picoclaw/issues/295): "Smart Routing" — dispatch simple tasks to small/local models (fast/cheap) and complex tasks to SOTA models (smart). * [**Swarm Mode**](https://github.com/sipeed/picoclaw/issues/284): Collaboration between multiple PicoClaw instances on the same network. * [**AIEOS**](https://github.com/sipeed/picoclaw/issues/296): Exploring AI-Native Operating System interaction paradigms. ## 📚 5. Developer Experience (DevEx) & Documentation *Lowering the barrier to entry so anyone can deploy in minutes.* * [**QuickGuide (Zero-Config Start)**](https://github.com/sipeed/picoclaw/issues/350) * Interactive CLI Wizard: If launched without config, automatically detect the environment and guide the user through Token/Network setup step-by-step. * **Comprehensive Documentation** * **Platform Guides**: Dedicated guides for Windows, macOS, Linux, and Android. * **Step-by-Step Tutorials**: "Babysitter-level" guides for configuring Providers and Channels. * **AI-Assisted Docs**: Using AI to auto-generate API references and code comments (with human verification to prevent hallucinations). ## 🤖 6. Engineering: AI-Powered Open Source *Born from Vibe Coding, we continue to use AI to accelerate development.* * **AI-Enhanced CI/CD** * Integrate AI for automated Code Review, Linting, and PR Labeling. * **Bot Noise Reduction**: Optimize bot interactions to keep PR timelines clean. * **Issue Triage**: AI agents to analyze incoming issues and suggest preliminary fixes. ## 🎨 7. Brand & Community * [**Logo Design**](https://github.com/sipeed/picoclaw/issues/297): We are looking for a **Mantis Shrimp (Stomatopoda)** logo design! * *Concept*: Needs to reflect "Small but Mighty" and "Lightning Fast Strikes." --- ### 🤝 Call for Contributions We welcome community contributions to any item on this roadmap! Please comment on the relevant Issue or submit a PR. Let's build the best Edge AI Agent together! ================================================ FILE: cmd/picoclaw/internal/agent/command.go ================================================ package agent import ( "github.com/spf13/cobra" ) func NewAgentCommand() *cobra.Command { var ( message string sessionKey string model string debug bool ) cmd := &cobra.Command{ Use: "agent", Short: "Interact with the agent directly", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return agentCmd(message, sessionKey, model, debug) }, } cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging") cmd.Flags().StringVarP(&message, "message", "m", "", "Send a single message (non-interactive mode)") cmd.Flags().StringVarP(&sessionKey, "session", "s", "cli:default", "Session key") cmd.Flags().StringVarP(&model, "model", "", "", "Model to use") return cmd } ================================================ FILE: cmd/picoclaw/internal/agent/command_test.go ================================================ package agent import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewAgentCommand(t *testing.T) { cmd := NewAgentCommand() require.NotNil(t, cmd) assert.Equal(t, "agent", cmd.Use) assert.Equal(t, "Interact with the agent directly", cmd.Short) assert.Len(t, cmd.Aliases, 0) assert.False(t, cmd.HasSubCommands()) assert.Nil(t, cmd.Run) assert.NotNil(t, cmd.RunE) assert.Nil(t, cmd.PersistentPreRun) assert.Nil(t, cmd.PersistentPostRun) assert.True(t, cmd.HasFlags()) assert.NotNil(t, cmd.Flags().Lookup("debug")) assert.NotNil(t, cmd.Flags().Lookup("message")) assert.NotNil(t, cmd.Flags().Lookup("session")) assert.NotNil(t, cmd.Flags().Lookup("model")) } ================================================ FILE: cmd/picoclaw/internal/agent/helpers.go ================================================ package agent import ( "bufio" "context" "fmt" "io" "os" "path/filepath" "strings" "github.com/ergochat/readline" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" ) func agentCmd(message, sessionKey, model string, debug bool) error { if sessionKey == "" { sessionKey = "cli:default" } if debug { logger.SetLevel(logger.DEBUG) fmt.Println("🔍 Debug mode enabled") } cfg, err := internal.LoadConfig() if err != nil { return fmt.Errorf("error loading config: %w", err) } if model != "" { cfg.Agents.Defaults.ModelName = model } provider, modelID, err := providers.CreateProvider(cfg) if err != nil { return fmt.Errorf("error creating provider: %w", err) } // Use the resolved model ID from provider creation if modelID != "" { cfg.Agents.Defaults.ModelName = modelID } msgBus := bus.NewMessageBus() defer msgBus.Close() agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) defer agentLoop.Close() // Print agent startup info (only for interactive mode) startupInfo := agentLoop.GetStartupInfo() logger.InfoCF("agent", "Agent initialized", map[string]any{ "tools_count": startupInfo["tools"].(map[string]any)["count"], "skills_total": startupInfo["skills"].(map[string]any)["total"], "skills_available": startupInfo["skills"].(map[string]any)["available"], }) if message != "" { ctx := context.Background() response, err := agentLoop.ProcessDirect(ctx, message, sessionKey) if err != nil { return fmt.Errorf("error processing message: %w", err) } fmt.Printf("\n%s %s\n", internal.Logo, response) return nil } fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", internal.Logo) interactiveMode(agentLoop, sessionKey) return nil } func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { prompt := fmt.Sprintf("%s You: ", internal.Logo) rl, err := readline.NewEx(&readline.Config{ Prompt: prompt, HistoryFile: filepath.Join(os.TempDir(), ".picoclaw_history"), HistoryLimit: 100, InterruptPrompt: "^C", EOFPrompt: "exit", }) if err != nil { fmt.Printf("Error initializing readline: %v\n", err) fmt.Println("Falling back to simple input mode...") simpleInteractiveMode(agentLoop, sessionKey) return } defer rl.Close() for { line, err := rl.Readline() if err != nil { if err == readline.ErrInterrupt || err == io.EOF { fmt.Println("\nGoodbye!") return } fmt.Printf("Error reading input: %v\n", err) continue } input := strings.TrimSpace(line) if input == "" { continue } if input == "exit" || input == "quit" { fmt.Println("Goodbye!") return } ctx := context.Background() response, err := agentLoop.ProcessDirect(ctx, input, sessionKey) if err != nil { fmt.Printf("Error: %v\n", err) continue } fmt.Printf("\n%s %s\n\n", internal.Logo, response) } } func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) { reader := bufio.NewReader(os.Stdin) for { fmt.Print(fmt.Sprintf("%s You: ", internal.Logo)) line, err := reader.ReadString('\n') if err != nil { if err == io.EOF { fmt.Println("\nGoodbye!") return } fmt.Printf("Error reading input: %v\n", err) continue } input := strings.TrimSpace(line) if input == "" { continue } if input == "exit" || input == "quit" { fmt.Println("Goodbye!") return } ctx := context.Background() response, err := agentLoop.ProcessDirect(ctx, input, sessionKey) if err != nil { fmt.Printf("Error: %v\n", err) continue } fmt.Printf("\n%s %s\n\n", internal.Logo, response) } } ================================================ FILE: cmd/picoclaw/internal/auth/command.go ================================================ package auth import "github.com/spf13/cobra" func NewAuthCommand() *cobra.Command { cmd := &cobra.Command{ Use: "auth", Short: "Manage authentication (login, logout, status)", RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }, } cmd.AddCommand( newLoginCommand(), newLogoutCommand(), newStatusCommand(), newModelsCommand(), ) return cmd } ================================================ FILE: cmd/picoclaw/internal/auth/command_test.go ================================================ package auth import ( "slices" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewAuthCommand(t *testing.T) { cmd := NewAuthCommand() require.NotNil(t, cmd) assert.Equal(t, "auth", cmd.Use) assert.Equal(t, "Manage authentication (login, logout, status)", cmd.Short) assert.Len(t, cmd.Aliases, 0) assert.Nil(t, cmd.Run) assert.NotNil(t, cmd.RunE) assert.Nil(t, cmd.PersistentPreRun) assert.Nil(t, cmd.PersistentPostRun) assert.False(t, cmd.HasFlags()) assert.True(t, cmd.HasSubCommands()) allowedCommands := []string{ "login", "logout", "status", "models", } subcommands := cmd.Commands() assert.Len(t, subcommands, len(allowedCommands)) for _, subcmd := range subcommands { found := slices.Contains(allowedCommands, subcmd.Name()) assert.True(t, found, "unexpected subcommand %q", subcmd.Name()) assert.Len(t, subcmd.Aliases, 0) assert.False(t, subcmd.Hidden) assert.False(t, subcmd.HasSubCommands()) assert.Nil(t, subcmd.Run) assert.NotNil(t, subcmd.RunE) assert.Nil(t, subcmd.PersistentPreRun) assert.Nil(t, subcmd.PersistentPostRun) } } ================================================ FILE: cmd/picoclaw/internal/auth/helpers.go ================================================ package auth import ( "bufio" "encoding/json" "fmt" "io" "net/http" "os" "strings" "time" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" ) const ( supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity" defaultAnthropicModel = "claude-sonnet-4.6" ) func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error { switch provider { case "openai": return authLoginOpenAI(useDeviceCode) case "anthropic": return authLoginAnthropic(useOauth) case "google-antigravity", "antigravity": return authLoginGoogleAntigravity() default: return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg) } } func authLoginOpenAI(useDeviceCode bool) error { cfg := auth.OpenAIOAuthConfig() var cred *auth.AuthCredential var err error if useDeviceCode { cred, err = auth.LoginDeviceCode(cfg) } else { cred, err = auth.LoginBrowser(cfg) } if err != nil { return fmt.Errorf("login failed: %w", err) } if err = auth.SetCredential("openai", cred); err != nil { return fmt.Errorf("failed to save credentials: %w", err) } appCfg, err := internal.LoadConfig() if err == nil { // Update Providers (legacy format) appCfg.Providers.OpenAI.AuthMethod = "oauth" // Update or add openai in ModelList foundOpenAI := false for i := range appCfg.ModelList { if isOpenAIModel(appCfg.ModelList[i].Model) { appCfg.ModelList[i].AuthMethod = "oauth" foundOpenAI = true break } } // If no openai in ModelList, add it if !foundOpenAI { appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ ModelName: "gpt-5.4", Model: "openai/gpt-5.4", AuthMethod: "oauth", }) } // Update default model to use OpenAI appCfg.Agents.Defaults.ModelName = "gpt-5.4" if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil { return fmt.Errorf("could not update config: %w", err) } } fmt.Println("Login successful!") if cred.AccountID != "" { fmt.Printf("Account: %s\n", cred.AccountID) } fmt.Println("Default model set to: gpt-5.4") return nil } func authLoginGoogleAntigravity() error { cfg := auth.GoogleAntigravityOAuthConfig() cred, err := auth.LoginBrowser(cfg) if err != nil { return fmt.Errorf("login failed: %w", err) } cred.Provider = "google-antigravity" // Fetch user email from Google userinfo email, err := fetchGoogleUserEmail(cred.AccessToken) if err != nil { fmt.Printf("Warning: could not fetch email: %v\n", err) } else { cred.Email = email fmt.Printf("Email: %s\n", email) } // Fetch Cloud Code Assist project ID projectID, err := providers.FetchAntigravityProjectID(cred.AccessToken) if err != nil { fmt.Printf("Warning: could not fetch project ID: %v\n", err) fmt.Println("You may need Google Cloud Code Assist enabled on your account.") } else { cred.ProjectID = projectID fmt.Printf("Project: %s\n", projectID) } if err = auth.SetCredential("google-antigravity", cred); err != nil { return fmt.Errorf("failed to save credentials: %w", err) } appCfg, err := internal.LoadConfig() if err == nil { // Update Providers (legacy format, for backward compatibility) appCfg.Providers.Antigravity.AuthMethod = "oauth" // Update or add antigravity in ModelList foundAntigravity := false for i := range appCfg.ModelList { if isAntigravityModel(appCfg.ModelList[i].Model) { appCfg.ModelList[i].AuthMethod = "oauth" foundAntigravity = true break } } // If no antigravity in ModelList, add it if !foundAntigravity { appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ ModelName: "gemini-flash", Model: "antigravity/gemini-3-flash", AuthMethod: "oauth", }) } // Update default model appCfg.Agents.Defaults.ModelName = "gemini-flash" if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil { fmt.Printf("Warning: could not update config: %v\n", err) } } fmt.Println("\n✓ Google Antigravity login successful!") fmt.Println("Default model set to: gemini-flash") fmt.Println("Try it: picoclaw agent -m \"Hello world\"") return nil } func authLoginAnthropic(useOauth bool) error { if useOauth { return authLoginAnthropicSetupToken() } fmt.Println("Anthropic login method:") fmt.Println(" 1) Setup token (from `claude setup-token`) (Recommended)") fmt.Println(" 2) API key (from console.anthropic.com)") scanner := bufio.NewScanner(os.Stdin) for { fmt.Print("Choose [1]: ") choice := "1" if scanner.Scan() { text := strings.TrimSpace(scanner.Text()) if text != "" { choice = text } } switch choice { case "1": return authLoginAnthropicSetupToken() case "2": return authLoginPasteToken("anthropic") default: fmt.Printf("Invalid choice: %s. Please enter 1 or 2.\n", choice) } } } func authLoginAnthropicSetupToken() error { cred, err := auth.LoginSetupToken(os.Stdin) if err != nil { return fmt.Errorf("login failed: %w", err) } if err = auth.SetCredential("anthropic", cred); err != nil { return fmt.Errorf("failed to save credentials: %w", err) } appCfg, err := internal.LoadConfig() if err == nil { appCfg.Providers.Anthropic.AuthMethod = "oauth" found := false for i := range appCfg.ModelList { if isAnthropicModel(appCfg.ModelList[i].Model) { appCfg.ModelList[i].AuthMethod = "oauth" found = true break } } if !found { appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ ModelName: defaultAnthropicModel, Model: "anthropic/" + defaultAnthropicModel, AuthMethod: "oauth", }) // Only set default model if user has no default configured yet if appCfg.Agents.Defaults.GetModelName() == "" { appCfg.Agents.Defaults.ModelName = defaultAnthropicModel } } if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil { return fmt.Errorf("could not update config: %w", err) } } fmt.Println("Setup token saved for Anthropic!") return nil } func fetchGoogleUserEmail(accessToken string) (string, error) { req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil) if err != nil { return "", err } req.Header.Set("Authorization", "Bearer "+accessToken) client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("reading userinfo response: %w", err) } if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("userinfo request failed: %s", string(body)) } var userInfo struct { Email string `json:"email"` } if err := json.Unmarshal(body, &userInfo); err != nil { return "", err } return userInfo.Email, nil } func authLoginPasteToken(provider string) error { cred, err := auth.LoginPasteToken(provider, os.Stdin) if err != nil { return fmt.Errorf("login failed: %w", err) } if err = auth.SetCredential(provider, cred); err != nil { return fmt.Errorf("failed to save credentials: %w", err) } appCfg, err := internal.LoadConfig() if err == nil { switch provider { case "anthropic": appCfg.Providers.Anthropic.AuthMethod = "token" // Update ModelList found := false for i := range appCfg.ModelList { if isAnthropicModel(appCfg.ModelList[i].Model) { appCfg.ModelList[i].AuthMethod = "token" found = true break } } if !found { appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ ModelName: defaultAnthropicModel, Model: "anthropic/" + defaultAnthropicModel, AuthMethod: "token", }) appCfg.Agents.Defaults.ModelName = defaultAnthropicModel } case "openai": appCfg.Providers.OpenAI.AuthMethod = "token" // Update ModelList found := false for i := range appCfg.ModelList { if isOpenAIModel(appCfg.ModelList[i].Model) { appCfg.ModelList[i].AuthMethod = "token" found = true break } } if !found { appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ ModelName: "gpt-5.4", Model: "openai/gpt-5.4", AuthMethod: "token", }) } // Update default model appCfg.Agents.Defaults.ModelName = "gpt-5.4" } if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil { return fmt.Errorf("could not update config: %w", err) } } fmt.Printf("Token saved for %s!\n", provider) if appCfg != nil { fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.GetModelName()) } return nil } func authLogoutCmd(provider string) error { if provider != "" { if err := auth.DeleteCredential(provider); err != nil { return fmt.Errorf("failed to remove credentials: %w", err) } appCfg, err := internal.LoadConfig() if err == nil { // Clear AuthMethod in ModelList for i := range appCfg.ModelList { switch provider { case "openai": if isOpenAIModel(appCfg.ModelList[i].Model) { appCfg.ModelList[i].AuthMethod = "" } case "anthropic": if isAnthropicModel(appCfg.ModelList[i].Model) { appCfg.ModelList[i].AuthMethod = "" } case "google-antigravity", "antigravity": if isAntigravityModel(appCfg.ModelList[i].Model) { appCfg.ModelList[i].AuthMethod = "" } } } // Clear AuthMethod in Providers (legacy) switch provider { case "openai": appCfg.Providers.OpenAI.AuthMethod = "" case "anthropic": appCfg.Providers.Anthropic.AuthMethod = "" case "google-antigravity", "antigravity": appCfg.Providers.Antigravity.AuthMethod = "" } config.SaveConfig(internal.GetConfigPath(), appCfg) } fmt.Printf("Logged out from %s\n", provider) return nil } if err := auth.DeleteAllCredentials(); err != nil { return fmt.Errorf("failed to remove credentials: %w", err) } appCfg, err := internal.LoadConfig() if err == nil { // Clear all AuthMethods in ModelList for i := range appCfg.ModelList { appCfg.ModelList[i].AuthMethod = "" } // Clear all AuthMethods in Providers (legacy) appCfg.Providers.OpenAI.AuthMethod = "" appCfg.Providers.Anthropic.AuthMethod = "" appCfg.Providers.Antigravity.AuthMethod = "" config.SaveConfig(internal.GetConfigPath(), appCfg) } fmt.Println("Logged out from all providers") return nil } func authStatusCmd() error { store, err := auth.LoadStore() if err != nil { return fmt.Errorf("failed to load auth store: %w", err) } if len(store.Credentials) == 0 { fmt.Println("No authenticated providers.") fmt.Println("Run: picoclaw auth login --provider ") return nil } fmt.Println("\nAuthenticated Providers:") fmt.Println("------------------------") for provider, cred := range store.Credentials { status := "active" if cred.IsExpired() { status = "expired" } else if cred.NeedsRefresh() { status = "needs refresh" } fmt.Printf(" %s:\n", provider) fmt.Printf(" Method: %s\n", cred.AuthMethod) fmt.Printf(" Status: %s\n", status) if cred.AccountID != "" { fmt.Printf(" Account: %s\n", cred.AccountID) } if cred.Email != "" { fmt.Printf(" Email: %s\n", cred.Email) } if cred.ProjectID != "" { fmt.Printf(" Project: %s\n", cred.ProjectID) } if !cred.ExpiresAt.IsZero() { fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04")) } if provider == "anthropic" && cred.AuthMethod == "oauth" { usage, err := auth.FetchAnthropicUsage(cred.AccessToken) if err != nil { fmt.Printf(" Usage: unavailable (%v)\n", err) } else { fmt.Printf(" Usage (5h): %.1f%%\n", usage.FiveHourUtilization*100) fmt.Printf(" Usage (7d): %.1f%%\n", usage.SevenDayUtilization*100) } } } return nil } func authModelsCmd() error { cred, err := auth.GetCredential("google-antigravity") if err != nil || cred == nil { return fmt.Errorf( "not logged in to Google Antigravity.\nrun: picoclaw auth login --provider google-antigravity", ) } // Refresh token if needed if cred.NeedsRefresh() && cred.RefreshToken != "" { oauthCfg := auth.GoogleAntigravityOAuthConfig() refreshed, refreshErr := auth.RefreshAccessToken(cred, oauthCfg) if refreshErr == nil { cred = refreshed _ = auth.SetCredential("google-antigravity", cred) } } projectID := cred.ProjectID if projectID == "" { return fmt.Errorf("no project id stored. Try logging in again") } fmt.Printf("Fetching models for project: %s\n\n", projectID) models, err := providers.FetchAntigravityModels(cred.AccessToken, projectID) if err != nil { return fmt.Errorf("error fetching models: %w", err) } if len(models) == 0 { return fmt.Errorf("no models available") } fmt.Println("Available Antigravity Models:") fmt.Println("-----------------------------") for _, m := range models { status := "✓" if m.IsExhausted { status = "✗ (quota exhausted)" } name := m.ID if m.DisplayName != "" { name = fmt.Sprintf("%s (%s)", m.ID, m.DisplayName) } fmt.Printf(" %s %s\n", status, name) } return nil } // isAntigravityModel checks if a model string belongs to antigravity provider func isAntigravityModel(model string) bool { return model == "antigravity" || model == "google-antigravity" || strings.HasPrefix(model, "antigravity/") || strings.HasPrefix(model, "google-antigravity/") } // isOpenAIModel checks if a model string belongs to openai provider func isOpenAIModel(model string) bool { return model == "openai" || strings.HasPrefix(model, "openai/") } // isAnthropicModel checks if a model string belongs to anthropic provider func isAnthropicModel(model string) bool { return model == "anthropic" || strings.HasPrefix(model, "anthropic/") } ================================================ FILE: cmd/picoclaw/internal/auth/login.go ================================================ package auth import "github.com/spf13/cobra" func newLoginCommand() *cobra.Command { var ( provider string useDeviceCode bool useOauth bool ) cmd := &cobra.Command{ Use: "login", Short: "Login via OAuth or paste token", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return authLoginCmd(provider, useDeviceCode, useOauth) }, } cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)") cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)") cmd.Flags().BoolVar( &useOauth, "setup-token", false, "Use setup-token flow for Anthropic (from `claude setup-token`)", ) _ = cmd.MarkFlagRequired("provider") return cmd } ================================================ FILE: cmd/picoclaw/internal/auth/login_test.go ================================================ package auth import ( "testing" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewLoginSubCommand(t *testing.T) { cmd := newLoginCommand() require.NotNil(t, cmd) assert.Equal(t, "Login via OAuth or paste token", cmd.Short) assert.True(t, cmd.HasFlags()) assert.NotNil(t, cmd.Flags().Lookup("device-code")) providerFlag := cmd.Flags().Lookup("provider") require.NotNil(t, providerFlag) val, found := providerFlag.Annotations[cobra.BashCompOneRequiredFlag] require.True(t, found) require.NotEmpty(t, val) assert.Equal(t, "true", val[0]) } ================================================ FILE: cmd/picoclaw/internal/auth/logout.go ================================================ package auth import "github.com/spf13/cobra" func newLogoutCommand() *cobra.Command { var provider string cmd := &cobra.Command{ Use: "logout", Short: "Remove stored credentials", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return authLogoutCmd(provider) }, } cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to logout from (openai, anthropic); empty = all") return cmd } ================================================ FILE: cmd/picoclaw/internal/auth/logout_test.go ================================================ package auth import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewLogoutSubcommand(t *testing.T) { cmd := newLogoutCommand() require.NotNil(t, cmd) assert.Equal(t, "Remove stored credentials", cmd.Short) assert.True(t, cmd.HasFlags()) assert.NotNil(t, cmd.Flags().Lookup("provider")) } ================================================ FILE: cmd/picoclaw/internal/auth/models.go ================================================ package auth import "github.com/spf13/cobra" func newModelsCommand() *cobra.Command { cmd := &cobra.Command{ Use: "models", Short: "Show available models", RunE: func(_ *cobra.Command, _ []string) error { return authModelsCmd() }, } return cmd } ================================================ FILE: cmd/picoclaw/internal/auth/models_test.go ================================================ package auth import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewModelsCommand(t *testing.T) { cmd := newModelsCommand() require.NotNil(t, cmd) assert.Equal(t, "models", cmd.Use) assert.Equal(t, "Show available models", cmd.Short) assert.False(t, cmd.HasFlags()) } ================================================ FILE: cmd/picoclaw/internal/auth/status.go ================================================ package auth import "github.com/spf13/cobra" func newStatusCommand() *cobra.Command { cmd := &cobra.Command{ Use: "status", Short: "Show current auth status", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return authStatusCmd() }, } return cmd } ================================================ FILE: cmd/picoclaw/internal/auth/status_test.go ================================================ package auth import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewStatusSubcommand(t *testing.T) { cmd := newStatusCommand() require.NotNil(t, cmd) assert.Equal(t, "Show current auth status", cmd.Short) assert.False(t, cmd.HasFlags()) } ================================================ FILE: cmd/picoclaw/internal/cron/add.go ================================================ package cron import ( "fmt" "github.com/spf13/cobra" "github.com/sipeed/picoclaw/pkg/cron" ) func newAddCommand(storePath func() string) *cobra.Command { var ( name string message string every int64 cronExp string deliver bool channel string to string ) cmd := &cobra.Command{ Use: "add", Short: "Add a new scheduled job", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { if every <= 0 && cronExp == "" { return fmt.Errorf("either --every or --cron must be specified") } var schedule cron.CronSchedule if every > 0 { everyMS := every * 1000 schedule = cron.CronSchedule{Kind: "every", EveryMS: &everyMS} } else { schedule = cron.CronSchedule{Kind: "cron", Expr: cronExp} } cs := cron.NewCronService(storePath(), nil) job, err := cs.AddJob(name, schedule, message, deliver, channel, to) if err != nil { return fmt.Errorf("error adding job: %w", err) } fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID) return nil }, } cmd.Flags().StringVarP(&name, "name", "n", "", "Job name") cmd.Flags().StringVarP(&message, "message", "m", "", "Message for agent") cmd.Flags().Int64VarP(&every, "every", "e", 0, "Run every N seconds") cmd.Flags().StringVarP(&cronExp, "cron", "c", "", "Cron expression (e.g. '0 9 * * *')") cmd.Flags().BoolVarP(&deliver, "deliver", "d", false, "Deliver response to channel") cmd.Flags().StringVar(&to, "to", "", "Recipient for delivery") cmd.Flags().StringVar(&channel, "channel", "", "Channel for delivery") _ = cmd.MarkFlagRequired("name") _ = cmd.MarkFlagRequired("message") cmd.MarkFlagsMutuallyExclusive("every", "cron") return cmd } ================================================ FILE: cmd/picoclaw/internal/cron/add_test.go ================================================ package cron import ( "testing" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewAddSubcommand(t *testing.T) { fn := func() string { return "" } cmd := newAddCommand(fn) require.NotNil(t, cmd) assert.Equal(t, "add", cmd.Use) assert.Equal(t, "Add a new scheduled job", cmd.Short) assert.True(t, cmd.HasFlags()) assert.NotNil(t, cmd.Flags().Lookup("every")) assert.NotNil(t, cmd.Flags().Lookup("cron")) assert.NotNil(t, cmd.Flags().Lookup("deliver")) assert.NotNil(t, cmd.Flags().Lookup("to")) assert.NotNil(t, cmd.Flags().Lookup("channel")) nameFlag := cmd.Flags().Lookup("name") require.NotNil(t, nameFlag) messageFlag := cmd.Flags().Lookup("message") require.NotNil(t, messageFlag) val, found := nameFlag.Annotations[cobra.BashCompOneRequiredFlag] require.True(t, found) require.NotEmpty(t, val) assert.Equal(t, "true", val[0]) val, found = messageFlag.Annotations[cobra.BashCompOneRequiredFlag] require.True(t, found) require.NotEmpty(t, val) assert.Equal(t, "true", val[0]) } func TestNewAddCommandEveryAndCronMutuallyExclusive(t *testing.T) { cmd := newAddCommand(func() string { return "testing" }) cmd.SetArgs([]string{ "--name", "job", "--message", "hello", "--every", "10", "--cron", "0 9 * * *", }) err := cmd.Execute() require.Error(t, err) } ================================================ FILE: cmd/picoclaw/internal/cron/command.go ================================================ package cron import ( "fmt" "path/filepath" "github.com/spf13/cobra" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" ) func NewCronCommand() *cobra.Command { var storePath string cmd := &cobra.Command{ Use: "cron", Aliases: []string{"c"}, Short: "Manage scheduled tasks", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }, // Resolve storePath at execution time so it reflects the current config // and is shared across all subcommands. PersistentPreRunE: func(_ *cobra.Command, _ []string) error { cfg, err := internal.LoadConfig() if err != nil { return fmt.Errorf("error loading config: %w", err) } storePath = filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json") return nil }, } cmd.AddCommand( newListCommand(func() string { return storePath }), newAddCommand(func() string { return storePath }), newRemoveCommand(func() string { return storePath }), newEnableCommand(func() string { return storePath }), newDisableCommand(func() string { return storePath }), ) return cmd } ================================================ FILE: cmd/picoclaw/internal/cron/command_test.go ================================================ package cron import ( "slices" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewCronCommand(t *testing.T) { cmd := NewCronCommand() require.NotNil(t, cmd) assert.Equal(t, "Manage scheduled tasks", cmd.Short) assert.Len(t, cmd.Aliases, 1) assert.True(t, cmd.HasAlias("c")) assert.False(t, cmd.HasFlags()) assert.Nil(t, cmd.Run) assert.NotNil(t, cmd.RunE) assert.NotNil(t, cmd.PersistentPreRunE) assert.Nil(t, cmd.PersistentPreRun) assert.Nil(t, cmd.PersistentPostRun) assert.True(t, cmd.HasSubCommands()) allowedCommands := []string{ "list", "add", "remove", "enable", "disable", } subcommands := cmd.Commands() assert.Len(t, subcommands, len(allowedCommands)) for _, subcmd := range subcommands { found := slices.Contains(allowedCommands, subcmd.Name()) assert.True(t, found, "unexpected subcommand %q", subcmd.Name()) assert.Len(t, subcmd.Aliases, 0) assert.False(t, subcmd.Hidden) assert.False(t, subcmd.HasSubCommands()) assert.Nil(t, subcmd.Run) assert.NotNil(t, subcmd.RunE) assert.Nil(t, subcmd.PersistentPreRun) assert.Nil(t, subcmd.PersistentPostRun) } } ================================================ FILE: cmd/picoclaw/internal/cron/disable.go ================================================ package cron import "github.com/spf13/cobra" func newDisableCommand(storePath func() string) *cobra.Command { return &cobra.Command{ Use: "disable", Short: "Disable a job", Args: cobra.ExactArgs(1), Example: `picoclaw cron disable 1`, RunE: func(_ *cobra.Command, args []string) error { cronSetJobEnabled(storePath(), args[0], false) return nil }, } } ================================================ FILE: cmd/picoclaw/internal/cron/disable_test.go ================================================ package cron import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestDisableSubcommand(t *testing.T) { fn := func() string { return "" } cmd := newDisableCommand(fn) require.NotNil(t, cmd) assert.Equal(t, "disable", cmd.Use) assert.Equal(t, "Disable a job", cmd.Short) assert.True(t, cmd.HasExample()) } ================================================ FILE: cmd/picoclaw/internal/cron/enable.go ================================================ package cron import "github.com/spf13/cobra" func newEnableCommand(storePath func() string) *cobra.Command { return &cobra.Command{ Use: "enable", Short: "Enable a job", Args: cobra.ExactArgs(1), Example: `picoclaw cron enable 1`, RunE: func(_ *cobra.Command, args []string) error { cronSetJobEnabled(storePath(), args[0], true) return nil }, } } ================================================ FILE: cmd/picoclaw/internal/cron/enable_test.go ================================================ package cron import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestEnableSubcommand(t *testing.T) { fn := func() string { return "" } cmd := newEnableCommand(fn) require.NotNil(t, cmd) assert.Equal(t, "enable", cmd.Use) assert.Equal(t, "Enable a job", cmd.Short) assert.True(t, cmd.HasExample()) } ================================================ FILE: cmd/picoclaw/internal/cron/helpers.go ================================================ package cron import ( "fmt" "time" "github.com/sipeed/picoclaw/pkg/cron" ) func cronListCmd(storePath string) { cs := cron.NewCronService(storePath, nil) jobs := cs.ListJobs(true) // Show all jobs, including disabled if len(jobs) == 0 { fmt.Println("No scheduled jobs.") return } fmt.Println("\nScheduled Jobs:") fmt.Println("----------------") for _, job := range jobs { var schedule string if job.Schedule.Kind == "every" && job.Schedule.EveryMS != nil { schedule = fmt.Sprintf("every %ds", *job.Schedule.EveryMS/1000) } else if job.Schedule.Kind == "cron" { schedule = job.Schedule.Expr } else { schedule = "one-time" } nextRun := "scheduled" if job.State.NextRunAtMS != nil { nextTime := time.UnixMilli(*job.State.NextRunAtMS) nextRun = nextTime.Format("2006-01-02 15:04") } status := "enabled" if !job.Enabled { status = "disabled" } fmt.Printf(" %s (%s)\n", job.Name, job.ID) fmt.Printf(" Schedule: %s\n", schedule) fmt.Printf(" Status: %s\n", status) fmt.Printf(" Next run: %s\n", nextRun) } } func cronRemoveCmd(storePath, jobID string) { cs := cron.NewCronService(storePath, nil) if cs.RemoveJob(jobID) { fmt.Printf("✓ Removed job %s\n", jobID) } else { fmt.Printf("✗ Job %s not found\n", jobID) } } func cronSetJobEnabled(storePath, jobID string, enabled bool) { cs := cron.NewCronService(storePath, nil) job := cs.EnableJob(jobID, enabled) if job != nil { fmt.Printf("✓ Job '%s' enabled\n", job.Name) } else { fmt.Printf("✗ Job %s not found\n", jobID) } } ================================================ FILE: cmd/picoclaw/internal/cron/list.go ================================================ package cron import "github.com/spf13/cobra" func newListCommand(storePath func() string) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "List all scheduled jobs", Args: cobra.NoArgs, RunE: func(_ *cobra.Command, _ []string) error { cronListCmd(storePath()) return nil }, } return cmd } ================================================ FILE: cmd/picoclaw/internal/cron/list_test.go ================================================ package cron import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewListSubcommand(t *testing.T) { fn := func() string { return "" } cmd := newListCommand(fn) require.NotNil(t, cmd) assert.Equal(t, "List all scheduled jobs", cmd.Short) } ================================================ FILE: cmd/picoclaw/internal/cron/remove.go ================================================ package cron import "github.com/spf13/cobra" func newRemoveCommand(storePath func() string) *cobra.Command { cmd := &cobra.Command{ Use: "remove", Short: "Remove a job by ID", Args: cobra.ExactArgs(1), Example: `picoclaw cron remove 1`, RunE: func(_ *cobra.Command, args []string) error { cronRemoveCmd(storePath(), args[0]) return nil }, } return cmd } ================================================ FILE: cmd/picoclaw/internal/cron/remove_test.go ================================================ package cron import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewRemoveSubcommand(t *testing.T) { fn := func() string { return "" } cmd := newRemoveCommand(fn) require.NotNil(t, cmd) assert.Equal(t, "Remove a job by ID", cmd.Short) assert.True(t, cmd.HasExample()) } ================================================ FILE: cmd/picoclaw/internal/gateway/command.go ================================================ package gateway import ( "fmt" "github.com/spf13/cobra" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/pkg/gateway" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) func NewGatewayCommand() *cobra.Command { var debug bool var noTruncate bool var allowEmpty bool cmd := &cobra.Command{ Use: "gateway", Aliases: []string{"g"}, Short: "Start picoclaw gateway", Args: cobra.NoArgs, PreRunE: func(_ *cobra.Command, _ []string) error { if noTruncate && !debug { return fmt.Errorf("the --no-truncate option can only be used in conjunction with --debug (-d)") } if noTruncate { utils.SetDisableTruncation(true) logger.Info("String truncation is globally disabled via 'no-truncate' flag") } return nil }, RunE: func(_ *cobra.Command, _ []string) error { return gateway.Run(debug, internal.GetConfigPath(), allowEmpty) }, } cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging") cmd.Flags().BoolVarP(&noTruncate, "no-truncate", "T", false, "Disable string truncation in debug logs") cmd.Flags().BoolVarP( &allowEmpty, "allow-empty", "E", false, "Continue starting even when no default model is configured", ) return cmd } ================================================ FILE: cmd/picoclaw/internal/gateway/command_test.go ================================================ package gateway import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewGatewayCommand(t *testing.T) { cmd := NewGatewayCommand() require.NotNil(t, cmd) assert.Equal(t, "gateway", cmd.Use) assert.Equal(t, "Start picoclaw gateway", cmd.Short) assert.Len(t, cmd.Aliases, 1) assert.True(t, cmd.HasAlias("g")) assert.Nil(t, cmd.Run) assert.NotNil(t, cmd.RunE) assert.Nil(t, cmd.PersistentPreRun) assert.Nil(t, cmd.PersistentPostRun) assert.False(t, cmd.HasSubCommands()) assert.True(t, cmd.HasFlags()) assert.NotNil(t, cmd.Flags().Lookup("debug")) assert.NotNil(t, cmd.Flags().Lookup("allow-empty")) } ================================================ FILE: cmd/picoclaw/internal/helpers.go ================================================ package internal import ( "os" "path/filepath" "github.com/sipeed/picoclaw/pkg/config" ) const Logo = "🦞" // GetPicoclawHome returns the picoclaw home directory. // Priority: $PICOCLAW_HOME > ~/.picoclaw func GetPicoclawHome() string { if home := os.Getenv(config.EnvHome); home != "" { return home } home, _ := os.UserHomeDir() return filepath.Join(home, ".picoclaw") } func GetConfigPath() string { if configPath := os.Getenv(config.EnvConfig); configPath != "" { return configPath } return filepath.Join(GetPicoclawHome(), "config.json") } func LoadConfig() (*config.Config, error) { return config.LoadConfig(GetConfigPath()) } // FormatVersion returns the version string with optional git commit // Deprecated: Use pkg/config.FormatVersion instead func FormatVersion() string { return config.FormatVersion() } // FormatBuildInfo returns build time and go version info // Deprecated: Use pkg/config.FormatBuildInfo instead func FormatBuildInfo() (string, string) { return config.FormatBuildInfo() } // GetVersion returns the version string // Deprecated: Use pkg/config.GetVersion instead func GetVersion() string { return config.GetVersion() } ================================================ FILE: cmd/picoclaw/internal/helpers_test.go ================================================ package internal import ( "path/filepath" "runtime" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGetConfigPath(t *testing.T) { t.Setenv("HOME", "/tmp/home") got := GetConfigPath() want := filepath.Join("/tmp/home", ".picoclaw", "config.json") assert.Equal(t, want, got) } func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) { t.Setenv("PICOCLAW_HOME", "/custom/picoclaw") t.Setenv("HOME", "/tmp/home") got := GetConfigPath() want := filepath.Join("/custom/picoclaw", "config.json") assert.Equal(t, want, got) } func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) { t.Setenv("PICOCLAW_CONFIG", "/custom/config.json") t.Setenv("PICOCLAW_HOME", "/custom/picoclaw") t.Setenv("HOME", "/tmp/home") got := GetConfigPath() want := "/custom/config.json" assert.Equal(t, want, got) } func TestGetConfigPath_Windows(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("windows-specific HOME behavior varies; run on windows") } testUserProfilePath := `C:\Users\Test` t.Setenv("USERPROFILE", testUserProfilePath) got := GetConfigPath() want := filepath.Join(testUserProfilePath, ".picoclaw", "config.json") require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want) } ================================================ FILE: cmd/picoclaw/internal/migrate/command.go ================================================ package migrate import ( "github.com/spf13/cobra" "github.com/sipeed/picoclaw/pkg/migrate" ) func NewMigrateCommand() *cobra.Command { var opts migrate.Options cmd := &cobra.Command{ Use: "migrate", Short: "Migrate from xxxclaw(openclaw, etc.) to picoclaw", Args: cobra.NoArgs, Example: ` picoclaw migrate picoclaw migrate --from openclaw picoclaw migrate --dry-run picoclaw migrate --refresh picoclaw migrate --force`, RunE: func(cmd *cobra.Command, _ []string) error { m := migrate.NewMigrateInstance(opts) result, err := m.Run(opts) if err != nil { return err } if !opts.DryRun { m.PrintSummary(result) } return nil }, } cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be migrated without making changes") cmd.Flags().StringVar(&opts.Source, "from", "openclaw", "Source to migrate from (e.g., openclaw)") cmd.Flags().BoolVar(&opts.Refresh, "refresh", false, "Re-sync workspace files from OpenClaw (repeatable)") cmd.Flags().BoolVar(&opts.ConfigOnly, "config-only", false, "Only migrate config, skip workspace files") cmd.Flags().BoolVar(&opts.WorkspaceOnly, "workspace-only", false, "Only migrate workspace files, skip config") cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompts") cmd.Flags().StringVar(&opts.SourceHome, "source-home", "", "Override source home directory (default: ~/.openclaw)") cmd.Flags().StringVar(&opts.TargetHome, "target-home", "", "Override target home directory (default: ~/.picoclaw)") return cmd } ================================================ FILE: cmd/picoclaw/internal/migrate/command_test.go ================================================ package migrate import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewMigrateCommand(t *testing.T) { cmd := NewMigrateCommand() require.NotNil(t, cmd) assert.Equal(t, "migrate", cmd.Use) assert.Equal(t, "Migrate from xxxclaw(openclaw, etc.) to picoclaw", cmd.Short) assert.Len(t, cmd.Aliases, 0) assert.True(t, cmd.HasExample()) assert.False(t, cmd.HasSubCommands()) assert.Nil(t, cmd.Run) assert.NotNil(t, cmd.RunE) assert.Nil(t, cmd.PersistentPreRun) assert.Nil(t, cmd.PersistentPostRun) assert.True(t, cmd.HasFlags()) assert.NotNil(t, cmd.Flags().Lookup("dry-run")) assert.NotNil(t, cmd.Flags().Lookup("refresh")) assert.NotNil(t, cmd.Flags().Lookup("config-only")) assert.NotNil(t, cmd.Flags().Lookup("workspace-only")) assert.NotNil(t, cmd.Flags().Lookup("force")) assert.NotNil(t, cmd.Flags().Lookup("source-home")) assert.NotNil(t, cmd.Flags().Lookup("target-home")) } ================================================ FILE: cmd/picoclaw/internal/model/command.go ================================================ package model import ( "fmt" "github.com/spf13/cobra" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/pkg/config" ) // LocalModel is a special model name that indicates that the model is local and with or without api_key. const LocalModel = "local-model" func NewModelCommand() *cobra.Command { cmd := &cobra.Command{ Use: "model [model_name]", Short: "Show or change the default model", Long: `Show or change the default model configuration. If no argument is provided, shows the current default model. If a model name is provided, sets it as the default model. Examples: picoclaw model # Show current default model picoclaw model gpt-5.2 # Set gpt-5.2 as default picoclaw model claude-sonnet-4.6 # Set claude-sonnet-4.6 as default picoclaw model local-model # Set local VLLM server as default Note: 'local-model' is a special value for using a local VLLM server (running at localhost:8000 by default) which does not require an API key.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { configPath := internal.GetConfigPath() // Load current config cfg, err := config.LoadConfig(configPath) if err != nil { return fmt.Errorf("failed to load config: %w", err) } if len(args) == 0 { // Show current default model showCurrentModel(cfg) return nil } // Set new default model modelName := args[0] return setDefaultModel(configPath, cfg, modelName) }, } return cmd } func showCurrentModel(cfg *config.Config) { defaultModel := cfg.Agents.Defaults.ModelName if defaultModel == "" { defaultModel = cfg.Agents.Defaults.Model } if defaultModel == "" { fmt.Println("No default model is currently set.") fmt.Println("\nAvailable models in your config:") listAvailableModels(cfg) } else { fmt.Printf("Current default model: %s\n", defaultModel) fmt.Println("\nAvailable models in your config:") listAvailableModels(cfg) } } func listAvailableModels(cfg *config.Config) { if len(cfg.ModelList) == 0 { fmt.Println(" No models configured in model_list") return } defaultModel := cfg.Agents.Defaults.ModelName if defaultModel == "" { defaultModel = cfg.Agents.Defaults.Model } for _, model := range cfg.ModelList { marker := " " if model.ModelName == defaultModel { marker = "> " } if model.APIKey == "" { continue } fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model) } } func setDefaultModel(configPath string, cfg *config.Config, modelName string) error { // Validate that the model exists in model_list modelFound := false for _, model := range cfg.ModelList { if model.APIKey != "" && model.ModelName == modelName { modelFound = true break } } if !modelFound && modelName != LocalModel { return fmt.Errorf("cannot found model '%s' in config", modelName) } // Update the default model // Clear old model field and set new model_name oldModel := cfg.Agents.Defaults.ModelName if oldModel == "" { oldModel = cfg.Agents.Defaults.Model } cfg.Agents.Defaults.ModelName = modelName cfg.Agents.Defaults.Model = "" // Clear deprecated field // Save config back to file if err := config.SaveConfig(configPath, cfg); err != nil { return fmt.Errorf("failed to save config: %w", err) } fmt.Printf("✓ Default model changed from '%s' to '%s'\n", formatModelName(oldModel), modelName) fmt.Println("\nThe new default model will be used for all agent interactions.") return nil } func formatModelName(name string) string { if name == "" { return "(none)" } return name } ================================================ FILE: cmd/picoclaw/internal/model/command_test.go ================================================ package model import ( "bytes" "io" "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/sipeed/picoclaw/pkg/config" ) var configPath = "" func initTest(t *testing.T) { tmpDir := t.TempDir() configPath = filepath.Join(tmpDir, "config.json") _ = os.Setenv("PICOCLAW_CONFIG", configPath) } // captureStdout captures stdout during the execution of fn and returns the captured output func captureStdout(fn func()) string { oldStdout := os.Stdout r, w, _ := os.Pipe() os.Stdout = w fn() w.Close() os.Stdout = oldStdout var buf bytes.Buffer io.Copy(&buf, r) return buf.String() } func TestNewModelCommand(t *testing.T) { cmd := NewModelCommand() require.NotNil(t, cmd) assert.Equal(t, "model [model_name]", cmd.Use) assert.Equal(t, "Show or change the default model", cmd.Short) assert.Len(t, cmd.Aliases, 0) assert.False(t, cmd.HasFlags()) assert.Nil(t, cmd.Run) assert.NotNil(t, cmd.RunE) assert.Nil(t, cmd.PersistentPreRunE) assert.Nil(t, cmd.PersistentPreRun) assert.Nil(t, cmd.PersistentPostRun) } func TestShowCurrentModel_WithDefaultModel(t *testing.T) { cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "gpt-4", }, }, ModelList: []config.ModelConfig{ {ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"}, {ModelName: "claude-3", Model: "anthropic/claude-3", APIKey: "test"}, }, } output := captureStdout(func() { showCurrentModel(cfg) }) assert.Contains(t, output, "Current default model: gpt-4") assert.Contains(t, output, "Available models in your config:") assert.Contains(t, output, "gpt-4") assert.Contains(t, output, "claude-3") } func TestShowCurrentModel_NoDefaultModel(t *testing.T) { cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "", Model: "", }, }, ModelList: []config.ModelConfig{ {ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"}, }, } output := captureStdout(func() { showCurrentModel(cfg) }) assert.Contains(t, output, "No default model is currently set.") assert.Contains(t, output, "Available models in your config:") } func TestShowCurrentModel_BackwardCompatibility(t *testing.T) { cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Model: "legacy-model", }, }, ModelList: []config.ModelConfig{}, } output := captureStdout(func() { showCurrentModel(cfg) }) assert.Contains(t, output, "Current default model: legacy-model") } func TestListAvailableModels_Empty(t *testing.T) { cfg := &config.Config{ ModelList: []config.ModelConfig{}, } output := captureStdout(func() { listAvailableModels(cfg) }) assert.Contains(t, output, "No models configured in model_list") } func TestListAvailableModels_WithModels(t *testing.T) { cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "gpt-4", }, }, ModelList: []config.ModelConfig{ {ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"}, {ModelName: "claude-3", Model: "anthropic/claude-3", APIKey: "test"}, {ModelName: "no-key-model", Model: "openai/test", APIKey: ""}, }, } output := captureStdout(func() { listAvailableModels(cfg) }) assert.NotEmpty(t, output) assert.Contains(t, output, "> - gpt-4 (openai/gpt-4)") assert.Contains(t, output, "claude-3 (anthropic/claude-3)") assert.NotContains(t, output, "no-key-model") } func TestSetDefaultModel_ValidModel(t *testing.T) { initTest(t) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "old-model", }, }, ModelList: []config.ModelConfig{ {ModelName: "new-model", Model: "openai/new-model", APIKey: "test"}, {ModelName: "old-model", Model: "openai/old-model", APIKey: "test"}, }, } output := captureStdout(func() { err := setDefaultModel(configPath, cfg, "new-model") assert.NoError(t, err) }) assert.Contains(t, output, "Default model changed from 'old-model' to 'new-model'") // Verify config was updated updatedCfg, err := config.LoadConfig(configPath) require.NoError(t, err) assert.Equal(t, "new-model", updatedCfg.Agents.Defaults.ModelName) assert.Empty(t, updatedCfg.Agents.Defaults.Model) } func TestSetDefaultModel_LegacyModelField(t *testing.T) { initTest(t) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Model: "legacy-old", }, }, ModelList: []config.ModelConfig{ {ModelName: "new-model", Model: "openai/new-model", APIKey: "test"}, }, } output := captureStdout(func() { err := setDefaultModel(configPath, cfg, "new-model") assert.NoError(t, err) }) assert.Contains(t, output, "Default model changed from 'legacy-old' to 'new-model'") } func TestSetDefaultModel_InvalidModel(t *testing.T) { initTest(t) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "existing-model", }, }, ModelList: []config.ModelConfig{ {ModelName: "existing-model", Model: "openai/existing", APIKey: "test"}, }, } assert.Error(t, setDefaultModel(configPath, cfg, "nonexistent-model")) } func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) { initTest(t) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "existing-model", }, }, ModelList: []config.ModelConfig{ {ModelName: "existing-model", Model: "openai/existing", APIKey: "test"}, {ModelName: "no-key-model", Model: "openai/nokey", APIKey: ""}, }, } assert.Error(t, setDefaultModel(configPath, cfg, "no-key-model")) } func TestSetDefaultModel_SaveConfigError(t *testing.T) { // Use an invalid path to trigger save error invalidPath := "/nonexistent/directory/config.json" cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "old-model", }, }, ModelList: []config.ModelConfig{ {ModelName: "new-model", Model: "openai/new-model", APIKey: "test"}, }, } err := setDefaultModel(invalidPath, cfg, "new-model") assert.Error(t, err) assert.Contains(t, err.Error(), "failed to save config") } func TestFormatModelName(t *testing.T) { tests := []struct { name string input string expected string }{ {"empty string", "", "(none)"}, {"simple model", "gpt-4", "gpt-4"}, {"model with version", "claude-sonnet-4.6", "claude-sonnet-4.6"}, {"model with spaces", "my model", "my model"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := formatModelName(tt.input) assert.Equal(t, tt.expected, result) }) } } func TestModelCommandExecution_Show(t *testing.T) { initTest(t) // Create a test config cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "test-model", }, }, ModelList: []config.ModelConfig{ {ModelName: "test-model", Model: "openai/test", APIKey: "test"}, }, } err := config.SaveConfig(configPath, cfg) require.NoError(t, err) cmd := NewModelCommand() output := captureStdout(func() { err = cmd.RunE(cmd, []string{}) assert.NoError(t, err) }) assert.Contains(t, output, "Current default model: test-model") } func TestModelCommandExecution_Set(t *testing.T) { initTest(t) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "old-model", }, }, ModelList: []config.ModelConfig{ {ModelName: "old-model", Model: "openai/old", APIKey: "test"}, {ModelName: "new-model", Model: "openai/new", APIKey: "test"}, }, } err := config.SaveConfig(configPath, cfg) require.NoError(t, err) cmd := NewModelCommand() output := captureStdout(func() { err = cmd.RunE(cmd, []string{"new-model"}) assert.NoError(t, err) }) assert.Contains(t, output, "Default model changed from 'old-model' to 'new-model'") } func TestModelCommandExecution_TooManyArgs(t *testing.T) { cmd := NewModelCommand() err := cmd.RunE(cmd, []string{"model1", "model2"}) assert.Error(t, err) } func TestListAvailableModels_MarkerLogic(t *testing.T) { cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "middle-model", }, }, ModelList: []config.ModelConfig{ {ModelName: "first-model", Model: "openai/first", APIKey: "test"}, {ModelName: "middle-model", Model: "openai/middle", APIKey: "test"}, {ModelName: "last-model", Model: "openai/last", APIKey: "test"}, }, } output := captureStdout(func() { listAvailableModels(cfg) }) assert.Contains(t, output, " - first-model (openai/first)") assert.Contains(t, output, "> - middle-model (openai/middle)") assert.Contains(t, output, " - last-model (openai/last)") } ================================================ FILE: cmd/picoclaw/internal/onboard/command.go ================================================ package onboard import ( "embed" "github.com/spf13/cobra" ) //go:generate cp -r ../../../../workspace . //go:embed workspace var embeddedFiles embed.FS func NewOnboardCommand() *cobra.Command { var encrypt bool cmd := &cobra.Command{ Use: "onboard", Aliases: []string{"o"}, Short: "Initialize picoclaw configuration and workspace", Run: func(cmd *cobra.Command, args []string) { onboard(encrypt) }, } cmd.Flags().BoolVar(&encrypt, "enc", false, "Enable credential encryption (generates SSH key and prompts for passphrase)") return cmd } ================================================ FILE: cmd/picoclaw/internal/onboard/command_test.go ================================================ package onboard import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewOnboardCommand(t *testing.T) { cmd := NewOnboardCommand() require.NotNil(t, cmd) assert.Equal(t, "onboard", cmd.Use) assert.Equal(t, "Initialize picoclaw configuration and workspace", cmd.Short) assert.Len(t, cmd.Aliases, 1) assert.True(t, cmd.HasAlias("o")) assert.NotNil(t, cmd.Run) assert.Nil(t, cmd.RunE) assert.Nil(t, cmd.PersistentPreRun) assert.Nil(t, cmd.PersistentPostRun) assert.True(t, cmd.HasFlags()) encFlag := cmd.Flags().Lookup("enc") require.NotNil(t, encFlag, "expected --enc flag to be registered") assert.Equal(t, "false", encFlag.DefValue, "--enc should default to false") assert.False(t, cmd.HasSubCommands()) } ================================================ FILE: cmd/picoclaw/internal/onboard/helpers.go ================================================ package onboard import ( "fmt" "io/fs" "os" "path/filepath" "golang.org/x/term" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/credential" ) func onboard(encrypt bool) { configPath := internal.GetConfigPath() configExists := false if _, err := os.Stat(configPath); err == nil { configExists = true if encrypt { // Only ask for confirmation when *both* config and SSH key already exist, // indicating a full re-onboard that would reset the config to defaults. sshKeyPath, _ := credential.DefaultSSHKeyPath() if _, err := os.Stat(sshKeyPath); err == nil { // Both exist — confirm a full reset. fmt.Printf("Config already exists at %s\n", configPath) fmt.Print("Overwrite config with defaults? (y/n): ") var response string fmt.Scanln(&response) if response != "y" { fmt.Println("Aborted.") return } configExists = false // user agreed to reset; treat as fresh } // Config exists but SSH key is missing — keep existing config, only add SSH key. } } var err error if encrypt { fmt.Println("\nSet up credential encryption") fmt.Println("-----------------------------") passphrase, pErr := promptPassphrase() if pErr != nil { fmt.Printf("Error: %v\n", pErr) os.Exit(1) } // Expose the passphrase to credential.PassphraseProvider (which calls // os.Getenv by default) so that SaveConfig can encrypt api_keys. // This process is a one-shot CLI tool; the env var is never exposed outside // the current process and disappears when it exits. os.Setenv(credential.PassphraseEnvVar, passphrase) if err = setupSSHKey(); err != nil { fmt.Printf("Error generating SSH key: %v\n", err) os.Exit(1) } } var cfg *config.Config if configExists { // Preserve the existing config; SaveConfig will re-encrypt api_keys with the new passphrase. cfg, err = config.LoadConfig(configPath) if err != nil { fmt.Printf("Error loading existing config: %v\n", err) os.Exit(1) } } else { cfg = config.DefaultConfig() } if err := config.SaveConfig(configPath, cfg); err != nil { fmt.Printf("Error saving config: %v\n", err) os.Exit(1) } workspace := cfg.WorkspacePath() createWorkspaceTemplates(workspace) fmt.Printf("\n%s picoclaw is ready!\n", internal.Logo) fmt.Println("\nNext steps:") if encrypt { fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:") fmt.Println(" export PICOCLAW_KEY_PASSPHRASE= # Linux/macOS") fmt.Println(" set PICOCLAW_KEY_PASSPHRASE= # Windows cmd") fmt.Println("") fmt.Println(" 2. Add your API key to", configPath) } else { fmt.Println(" 1. Add your API key to", configPath) } fmt.Println("") fmt.Println(" Recommended:") fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)") fmt.Println(" - Ollama: https://ollama.com (local, free)") fmt.Println("") fmt.Println(" See README.md for 17+ supported providers.") fmt.Println("") fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"") } // promptPassphrase reads the encryption passphrase twice from the terminal // (with echo disabled) and returns it. Returns an error if the passphrase is // empty or if the two inputs do not match. func promptPassphrase() (string, error) { fmt.Print("Enter passphrase for credential encryption: ") p1, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Println() if err != nil { return "", fmt.Errorf("reading passphrase: %w", err) } if len(p1) == 0 { return "", fmt.Errorf("passphrase must not be empty") } fmt.Print("Confirm passphrase: ") p2, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Println() if err != nil { return "", fmt.Errorf("reading passphrase confirmation: %w", err) } if string(p1) != string(p2) { return "", fmt.Errorf("passphrases do not match") } return string(p1), nil } // setupSSHKey generates the picoclaw-specific SSH key at ~/.ssh/picoclaw_ed25519.key. // If the key already exists the user is warned and asked to confirm overwrite. // Answering anything other than "y" keeps the existing key (not an error). func setupSSHKey() error { keyPath, err := credential.DefaultSSHKeyPath() if err != nil { return fmt.Errorf("cannot determine SSH key path: %w", err) } if _, err := os.Stat(keyPath); err == nil { fmt.Printf("\n⚠️ WARNING: %s already exists.\n", keyPath) fmt.Println(" Overwriting will invalidate any credentials previously encrypted with this key.") fmt.Print(" Overwrite? (y/n): ") var response string fmt.Scanln(&response) if response != "y" { fmt.Println("Keeping existing SSH key.") return nil } } if err := credential.GenerateSSHKey(keyPath); err != nil { return err } fmt.Printf("SSH key generated: %s\n", keyPath) return nil } func createWorkspaceTemplates(workspace string) { err := copyEmbeddedToTarget(workspace) if err != nil { fmt.Printf("Error copying workspace templates: %v\n", err) } } func copyEmbeddedToTarget(targetDir string) error { // Ensure target directory exists if err := os.MkdirAll(targetDir, 0o755); err != nil { return fmt.Errorf("Failed to create target directory: %w", err) } // Walk through all files in embed.FS err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } // Skip directories if d.IsDir() { return nil } // Read embedded file data, err := embeddedFiles.ReadFile(path) if err != nil { return fmt.Errorf("Failed to read embedded file %s: %w", path, err) } new_path, err := filepath.Rel("workspace", path) if err != nil { return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err) } // Build target file path targetPath := filepath.Join(targetDir, new_path) // Ensure target file's directory exists if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err) } // Write file if err := os.WriteFile(targetPath, data, 0o644); err != nil { return fmt.Errorf("Failed to write file %s: %w", targetPath, err) } return nil }) return err } ================================================ FILE: cmd/picoclaw/internal/onboard/helpers_test.go ================================================ package onboard import ( "os" "path/filepath" "testing" ) func TestCopyEmbeddedToTargetUsesAgentsMarkdown(t *testing.T) { targetDir := t.TempDir() if err := copyEmbeddedToTarget(targetDir); err != nil { t.Fatalf("copyEmbeddedToTarget() error = %v", err) } agentsPath := filepath.Join(targetDir, "AGENTS.md") if _, err := os.Stat(agentsPath); err != nil { t.Fatalf("expected %s to exist: %v", agentsPath, err) } legacyPath := filepath.Join(targetDir, "AGENT.md") if _, err := os.Stat(legacyPath); !os.IsNotExist(err) { t.Fatalf("expected legacy file %s to be absent, got err=%v", legacyPath, err) } } ================================================ FILE: cmd/picoclaw/internal/skills/command.go ================================================ package skills import ( "fmt" "path/filepath" "github.com/spf13/cobra" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/pkg/skills" ) type deps struct { workspace string installer *skills.SkillInstaller skillsLoader *skills.SkillsLoader } func NewSkillsCommand() *cobra.Command { var d deps cmd := &cobra.Command{ Use: "skills", Short: "Manage skills", PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { cfg, err := internal.LoadConfig() if err != nil { return fmt.Errorf("error loading config: %w", err) } d.workspace = cfg.WorkspacePath() installer, err := skills.NewSkillInstaller( d.workspace, cfg.Tools.Skills.Github.Token, cfg.Tools.Skills.Github.Proxy, ) if err != nil { return fmt.Errorf("error creating skills installer: %w", err) } d.installer = installer // get global config directory and builtin skills directory globalDir := filepath.Dir(internal.GetConfigPath()) globalSkillsDir := filepath.Join(globalDir, "skills") builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills") d.skillsLoader = skills.NewSkillsLoader(d.workspace, globalSkillsDir, builtinSkillsDir) return nil }, RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }, } installerFn := func() (*skills.SkillInstaller, error) { if d.installer == nil { return nil, fmt.Errorf("skills installer is not initialized") } return d.installer, nil } loaderFn := func() (*skills.SkillsLoader, error) { if d.skillsLoader == nil { return nil, fmt.Errorf("skills loader is not initialized") } return d.skillsLoader, nil } workspaceFn := func() (string, error) { if d.workspace == "" { return "", fmt.Errorf("workspace is not initialized") } return d.workspace, nil } cmd.AddCommand( newListCommand(loaderFn), newInstallCommand(installerFn), newInstallBuiltinCommand(workspaceFn), newListBuiltinCommand(), newRemoveCommand(installerFn), newSearchCommand(), newShowCommand(loaderFn), ) return cmd } ================================================ FILE: cmd/picoclaw/internal/skills/command_test.go ================================================ package skills import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewSkillsCommand(t *testing.T) { cmd := NewSkillsCommand() require.NotNil(t, cmd) assert.Equal(t, "skills", cmd.Use) assert.Equal(t, "Manage skills", cmd.Short) assert.Len(t, cmd.Aliases, 0) assert.False(t, cmd.HasFlags()) assert.Nil(t, cmd.Run) assert.NotNil(t, cmd.RunE) assert.NotNil(t, cmd.PersistentPreRunE) assert.Nil(t, cmd.PersistentPreRun) assert.Nil(t, cmd.PersistentPostRun) } ================================================ FILE: cmd/picoclaw/internal/skills/helpers.go ================================================ package skills import ( "context" "fmt" "io" "os" "path/filepath" "strings" "time" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/utils" ) const skillsSearchMaxResults = 20 func skillsListCmd(loader *skills.SkillsLoader) { allSkills := loader.ListSkills() if len(allSkills) == 0 { fmt.Println("No skills installed.") return } fmt.Println("\nInstalled Skills:") fmt.Println("------------------") for _, skill := range allSkills { fmt.Printf(" ✓ %s (%s)\n", skill.Name, skill.Source) if skill.Description != "" { fmt.Printf(" %s\n", skill.Description) } } } func skillsInstallCmd(installer *skills.SkillInstaller, repo string) error { fmt.Printf("Installing skill from %s...\n", repo) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := installer.InstallFromGitHub(ctx, repo); err != nil { return fmt.Errorf("failed to install skill: %w", err) } fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo)) return nil } // skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub). func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) error { err := utils.ValidateSkillIdentifier(registryName) if err != nil { return fmt.Errorf("✗ invalid registry name: %w", err) } err = utils.ValidateSkillIdentifier(slug) if err != nil { return fmt.Errorf("✗ invalid slug: %w", err) } fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName) registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub), }) registry := registryMgr.GetRegistry(registryName) if registry == nil { return fmt.Errorf("✗ registry '%s' not found or not enabled. check your config.json.", registryName) } workspace := cfg.WorkspacePath() targetDir := filepath.Join(workspace, "skills", slug) if _, err = os.Stat(targetDir); err == nil { return fmt.Errorf("\u2717 skill '%s' already installed at %s", slug, targetDir) } ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil { return fmt.Errorf("\u2717 failed to create skills directory: %v", err) } result, err := registry.DownloadAndInstall(ctx, slug, "", targetDir) if err != nil { rmErr := os.RemoveAll(targetDir) if rmErr != nil { fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr) } return fmt.Errorf("✗ failed to install skill: %w", err) } if result.IsMalwareBlocked { rmErr := os.RemoveAll(targetDir) if rmErr != nil { fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr) } return fmt.Errorf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug) } if result.IsSuspicious { fmt.Printf("\u26a0\ufe0f Warning: skill '%s' is flagged as suspicious.\n", slug) } fmt.Printf("\u2713 Skill '%s' v%s installed successfully!\n", slug, result.Version) if result.Summary != "" { fmt.Printf(" %s\n", result.Summary) } return nil } func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) { fmt.Printf("Removing skill '%s'...\n", skillName) if err := installer.Uninstall(skillName); err != nil { fmt.Printf("✗ Failed to remove skill: %v\n", err) os.Exit(1) } fmt.Printf("✓ Skill '%s' removed successfully!\n", skillName) } func skillsInstallBuiltinCmd(workspace string) { builtinSkillsDir := "./picoclaw/skills" workspaceSkillsDir := filepath.Join(workspace, "skills") fmt.Printf("Copying builtin skills to workspace...\n") skillsToInstall := []string{ "weather", "news", "stock", "calculator", } for _, skillName := range skillsToInstall { builtinPath := filepath.Join(builtinSkillsDir, skillName) workspacePath := filepath.Join(workspaceSkillsDir, skillName) if _, err := os.Stat(builtinPath); err != nil { fmt.Printf("⊘ Builtin skill '%s' not found: %v\n", skillName, err) continue } if err := os.MkdirAll(workspacePath, 0o755); err != nil { fmt.Printf("✗ Failed to create directory for %s: %v\n", skillName, err) continue } if err := copyDirectory(builtinPath, workspacePath); err != nil { fmt.Printf("✗ Failed to copy %s: %v\n", skillName, err) } } fmt.Println("\n✓ All builtin skills installed!") fmt.Println("Now you can use them in your workspace.") } func skillsListBuiltinCmd() { cfg, err := internal.LoadConfig() if err != nil { fmt.Printf("Error loading config: %v\n", err) return } builtinSkillsDir := filepath.Join(filepath.Dir(cfg.WorkspacePath()), "picoclaw", "skills") fmt.Println("\nAvailable Builtin Skills:") fmt.Println("-----------------------") entries, err := os.ReadDir(builtinSkillsDir) if err != nil { fmt.Printf("Error reading builtin skills: %v\n", err) return } if len(entries) == 0 { fmt.Println("No builtin skills available.") return } for _, entry := range entries { if entry.IsDir() { skillName := entry.Name() skillFile := filepath.Join(builtinSkillsDir, skillName, "SKILL.md") description := "No description" if _, err := os.Stat(skillFile); err == nil { data, err := os.ReadFile(skillFile) if err == nil { content := string(data) if idx := strings.Index(content, "\n"); idx > 0 { firstLine := content[:idx] if strings.Contains(firstLine, "description:") { descLine := strings.Index(content[idx:], "\n") if descLine > 0 { description = strings.TrimSpace(content[idx+descLine : idx+descLine]) } } } } } status := "✓" fmt.Printf(" %s %s\n", status, entry.Name()) if description != "" { fmt.Printf(" %s\n", description) } } } } func skillsSearchCmd(query string) { fmt.Println("Searching for available skills...") cfg, err := internal.LoadConfig() if err != nil { fmt.Printf("✗ Failed to load config: %v\n", err) return } registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub), }) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() results, err := registryMgr.SearchAll(ctx, query, skillsSearchMaxResults) if err != nil { fmt.Printf("✗ Failed to fetch skills list: %v\n", err) return } if len(results) == 0 { fmt.Println("No skills available.") return } fmt.Printf("\nAvailable Skills (%d):\n", len(results)) fmt.Println("--------------------") for _, result := range results { fmt.Printf(" 📦 %s\n", result.DisplayName) fmt.Printf(" %s\n", result.Summary) fmt.Printf(" Slug: %s\n", result.Slug) fmt.Printf(" Registry: %s\n", result.RegistryName) if result.Version != "" { fmt.Printf(" Version: %s\n", result.Version) } fmt.Println() } } func skillsShowCmd(loader *skills.SkillsLoader, skillName string) { content, ok := loader.LoadSkill(skillName) if !ok { fmt.Printf("✗ Skill '%s' not found\n", skillName) return } fmt.Printf("\n📦 Skill: %s\n", skillName) fmt.Println("----------------------") fmt.Println(content) } func copyDirectory(src, dst string) error { return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { if err != nil { return err } relPath, err := filepath.Rel(src, path) if err != nil { return err } dstPath := filepath.Join(dst, relPath) if info.IsDir() { return os.MkdirAll(dstPath, info.Mode()) } srcFile, err := os.Open(path) if err != nil { return err } defer srcFile.Close() dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode()) if err != nil { return err } defer dstFile.Close() _, err = io.Copy(dstFile, srcFile) return err }) } ================================================ FILE: cmd/picoclaw/internal/skills/install.go ================================================ package skills import ( "fmt" "github.com/spf13/cobra" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/pkg/skills" ) func newInstallCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command { var registry string cmd := &cobra.Command{ Use: "install", Short: "Install skill from GitHub", Example: ` picoclaw skills install sipeed/picoclaw-skills/weather picoclaw skills install --registry clawhub github `, Args: func(cmd *cobra.Command, args []string) error { if registry != "" { if len(args) != 1 { return fmt.Errorf("when --registry is set, exactly 1 argument is required: ") } return nil } if len(args) != 1 { return fmt.Errorf("exactly 1 argument is required: ") } return nil }, RunE: func(_ *cobra.Command, args []string) error { installer, err := installerFn() if err != nil { return err } if registry != "" { cfg, err := internal.LoadConfig() if err != nil { return err } return skillsInstallFromRegistry(cfg, registry, args[0]) } return skillsInstallCmd(installer, args[0]) }, } cmd.Flags().StringVar(®istry, "registry", "", "Install from registry: --registry ") return cmd } ================================================ FILE: cmd/picoclaw/internal/skills/install_test.go ================================================ package skills import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewInstallSubcommand(t *testing.T) { cmd := newInstallCommand(nil) require.NotNil(t, cmd) assert.Equal(t, "install", cmd.Use) assert.Equal(t, "Install skill from GitHub", cmd.Short) assert.Nil(t, cmd.Run) assert.NotNil(t, cmd.RunE) assert.True(t, cmd.HasExample()) assert.False(t, cmd.HasSubCommands()) assert.True(t, cmd.HasFlags()) assert.NotNil(t, cmd.Flags().Lookup("registry")) assert.Len(t, cmd.Aliases, 0) } func TestInstallCommandArgs(t *testing.T) { tests := []struct { name string args []string registry string expectError bool errorMsg string }{ { name: "no registry, one arg", args: []string{"sipeed/picoclaw-skills/weather"}, registry: "", expectError: false, }, { name: "no registry, no args", args: []string{}, registry: "", expectError: true, errorMsg: "exactly 1 argument is required: ", }, { name: "no registry, too many args", args: []string{"arg1", "arg2"}, registry: "", expectError: true, errorMsg: "exactly 1 argument is required: ", }, { name: "with registry, one arg", args: []string{"weather-skill"}, registry: "clawhub", expectError: false, }, { name: "with registry, no args", args: []string{}, registry: "clawhub", expectError: true, errorMsg: "when --registry is set, exactly 1 argument is required: ", }, { name: "with registry, too many args", args: []string{"arg1", "arg2"}, registry: "clawhub", expectError: true, errorMsg: "when --registry is set, exactly 1 argument is required: ", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cmd := newInstallCommand(nil) if tt.registry != "" { require.NoError(t, cmd.Flags().Set("registry", tt.registry)) } err := cmd.Args(cmd, tt.args) if tt.expectError { require.Error(t, err) assert.Equal(t, tt.errorMsg, err.Error()) } else { require.NoError(t, err) } }) } } ================================================ FILE: cmd/picoclaw/internal/skills/installbuiltin.go ================================================ package skills import "github.com/spf13/cobra" func newInstallBuiltinCommand(workspaceFn func() (string, error)) *cobra.Command { cmd := &cobra.Command{ Use: "install-builtin", Short: "Install all builtin skills to workspace", Example: `picoclaw skills install-builtin`, RunE: func(_ *cobra.Command, _ []string) error { workspace, err := workspaceFn() if err != nil { return err } skillsInstallBuiltinCmd(workspace) return nil }, } return cmd } ================================================ FILE: cmd/picoclaw/internal/skills/installbuiltin_test.go ================================================ package skills import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewInstallbuiltinSubcommand(t *testing.T) { cmd := newInstallBuiltinCommand(nil) require.NotNil(t, cmd) assert.Equal(t, "install-builtin", cmd.Use) assert.Equal(t, "Install all builtin skills to workspace", cmd.Short) assert.Nil(t, cmd.Run) assert.NotNil(t, cmd.RunE) assert.True(t, cmd.HasExample()) assert.False(t, cmd.HasSubCommands()) assert.False(t, cmd.HasFlags()) assert.Len(t, cmd.Aliases, 0) } ================================================ FILE: cmd/picoclaw/internal/skills/list.go ================================================ package skills import ( "github.com/spf13/cobra" "github.com/sipeed/picoclaw/pkg/skills" ) func newListCommand(loaderFn func() (*skills.SkillsLoader, error)) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "List installed skills", Example: `picoclaw skills list`, RunE: func(_ *cobra.Command, _ []string) error { loader, err := loaderFn() if err != nil { return err } skillsListCmd(loader) return nil }, } return cmd } ================================================ FILE: cmd/picoclaw/internal/skills/list_test.go ================================================ package skills import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewListSubcommand(t *testing.T) { cmd := newListCommand(nil) require.NotNil(t, cmd) assert.Equal(t, "list", cmd.Use) assert.Equal(t, "List installed skills", cmd.Short) assert.Nil(t, cmd.Run) assert.NotNil(t, cmd.RunE) assert.True(t, cmd.HasExample()) assert.False(t, cmd.HasSubCommands()) assert.False(t, cmd.HasFlags()) assert.Len(t, cmd.Aliases, 0) } ================================================ FILE: cmd/picoclaw/internal/skills/listbuiltin.go ================================================ package skills import "github.com/spf13/cobra" func newListBuiltinCommand() *cobra.Command { cmd := &cobra.Command{ Use: "list-builtin", Short: "List available builtin skills", Example: `picoclaw skills list-builtin`, Run: func(_ *cobra.Command, _ []string) { skillsListBuiltinCmd() }, } return cmd } ================================================ FILE: cmd/picoclaw/internal/skills/listbuiltin_test.go ================================================ package skills import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewListbuiltinSubcommand(t *testing.T) { cmd := newListBuiltinCommand() require.NotNil(t, cmd) assert.Equal(t, "list-builtin", cmd.Use) assert.Equal(t, "List available builtin skills", cmd.Short) assert.NotNil(t, cmd.Run) assert.True(t, cmd.HasExample()) assert.False(t, cmd.HasSubCommands()) assert.False(t, cmd.HasFlags()) assert.Len(t, cmd.Aliases, 0) } ================================================ FILE: cmd/picoclaw/internal/skills/remove.go ================================================ package skills import ( "github.com/spf13/cobra" "github.com/sipeed/picoclaw/pkg/skills" ) func newRemoveCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command { cmd := &cobra.Command{ Use: "remove", Aliases: []string{"rm", "uninstall"}, Short: "Remove installed skill", Args: cobra.ExactArgs(1), Example: `picoclaw skills remove weather`, RunE: func(_ *cobra.Command, args []string) error { installer, err := installerFn() if err != nil { return err } skillsRemoveCmd(installer, args[0]) return nil }, } return cmd } ================================================ FILE: cmd/picoclaw/internal/skills/remove_test.go ================================================ package skills import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewRemoveSubcommand(t *testing.T) { cmd := newRemoveCommand(nil) require.NotNil(t, cmd) assert.Equal(t, "remove", cmd.Use) assert.Equal(t, "Remove installed skill", cmd.Short) assert.Nil(t, cmd.Run) assert.NotNil(t, cmd.RunE) assert.True(t, cmd.HasExample()) assert.False(t, cmd.HasSubCommands()) assert.False(t, cmd.HasFlags()) assert.Len(t, cmd.Aliases, 2) assert.True(t, cmd.HasAlias("rm")) assert.True(t, cmd.HasAlias("uninstall")) } ================================================ FILE: cmd/picoclaw/internal/skills/search.go ================================================ package skills import ( "github.com/spf13/cobra" ) func newSearchCommand() *cobra.Command { cmd := &cobra.Command{ Use: "search [query]", Short: "Search available skills", Args: cobra.MaximumNArgs(1), RunE: func(_ *cobra.Command, args []string) error { query := "" if len(args) == 1 { query = args[0] } skillsSearchCmd(query) return nil }, } return cmd } ================================================ FILE: cmd/picoclaw/internal/skills/search_test.go ================================================ package skills import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewSearchSubcommand(t *testing.T) { cmd := newSearchCommand() require.NotNil(t, cmd) assert.Equal(t, "search [query]", cmd.Use) assert.Equal(t, "Search available skills", cmd.Short) assert.Nil(t, cmd.Run) assert.NotNil(t, cmd.RunE) assert.False(t, cmd.HasSubCommands()) assert.False(t, cmd.HasFlags()) assert.Len(t, cmd.Aliases, 0) } ================================================ FILE: cmd/picoclaw/internal/skills/show.go ================================================ package skills import ( "github.com/spf13/cobra" "github.com/sipeed/picoclaw/pkg/skills" ) func newShowCommand(loaderFn func() (*skills.SkillsLoader, error)) *cobra.Command { cmd := &cobra.Command{ Use: "show", Short: "Show skill details", Args: cobra.ExactArgs(1), Example: `picoclaw skills show weather`, RunE: func(_ *cobra.Command, args []string) error { loader, err := loaderFn() if err != nil { return err } skillsShowCmd(loader, args[0]) return nil }, } return cmd } ================================================ FILE: cmd/picoclaw/internal/skills/show_test.go ================================================ package skills import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewShowSubcommand(t *testing.T) { cmd := newShowCommand(nil) require.NotNil(t, cmd) assert.Equal(t, "show", cmd.Use) assert.Equal(t, "Show skill details", cmd.Short) assert.Nil(t, cmd.Run) assert.NotNil(t, cmd.RunE) assert.True(t, cmd.HasExample()) assert.False(t, cmd.HasSubCommands()) assert.False(t, cmd.HasFlags()) assert.Len(t, cmd.Aliases, 0) } ================================================ FILE: cmd/picoclaw/internal/status/command.go ================================================ package status import ( "github.com/spf13/cobra" ) func NewStatusCommand() *cobra.Command { cmd := &cobra.Command{ Use: "status", Aliases: []string{"s"}, Short: "Show picoclaw status", Run: func(cmd *cobra.Command, args []string) { statusCmd() }, } return cmd } ================================================ FILE: cmd/picoclaw/internal/status/command_test.go ================================================ package status import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewStatusCommand(t *testing.T) { cmd := NewStatusCommand() require.NotNil(t, cmd) assert.Equal(t, "status", cmd.Use) assert.Len(t, cmd.Aliases, 1) assert.True(t, cmd.HasAlias("s")) assert.Equal(t, "Show picoclaw status", cmd.Short) assert.False(t, cmd.HasSubCommands()) assert.NotNil(t, cmd.Run) assert.Nil(t, cmd.RunE) assert.Nil(t, cmd.PersistentPreRun) assert.Nil(t, cmd.PersistentPostRun) } ================================================ FILE: cmd/picoclaw/internal/status/helpers.go ================================================ package status import ( "fmt" "os" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" ) func statusCmd() { cfg, err := internal.LoadConfig() if err != nil { fmt.Printf("Error loading config: %v\n", err) return } configPath := internal.GetConfigPath() fmt.Printf("%s picoclaw Status\n", internal.Logo) fmt.Printf("Version: %s\n", config.FormatVersion()) build, _ := config.FormatBuildInfo() if build != "" { fmt.Printf("Build: %s\n", build) } fmt.Println() if _, err := os.Stat(configPath); err == nil { fmt.Println("Config:", configPath, "✓") } else { fmt.Println("Config:", configPath, "✗") } workspace := cfg.WorkspacePath() if _, err := os.Stat(workspace); err == nil { fmt.Println("Workspace:", workspace, "✓") } else { fmt.Println("Workspace:", workspace, "✗") } if _, err := os.Stat(configPath); err == nil { fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName()) hasOpenRouter := cfg.Providers.OpenRouter.APIKey != "" hasAnthropic := cfg.Providers.Anthropic.APIKey != "" hasOpenAI := cfg.Providers.OpenAI.APIKey != "" hasGemini := cfg.Providers.Gemini.APIKey != "" hasZhipu := cfg.Providers.Zhipu.APIKey != "" hasQwen := cfg.Providers.Qwen.APIKey != "" hasGroq := cfg.Providers.Groq.APIKey != "" hasVLLM := cfg.Providers.VLLM.APIBase != "" hasMoonshot := cfg.Providers.Moonshot.APIKey != "" hasDeepSeek := cfg.Providers.DeepSeek.APIKey != "" hasVolcEngine := cfg.Providers.VolcEngine.APIKey != "" hasNvidia := cfg.Providers.Nvidia.APIKey != "" hasOllama := cfg.Providers.Ollama.APIBase != "" status := func(enabled bool) string { if enabled { return "✓" } return "not set" } fmt.Println("OpenRouter API:", status(hasOpenRouter)) fmt.Println("Anthropic API:", status(hasAnthropic)) fmt.Println("OpenAI API:", status(hasOpenAI)) fmt.Println("Gemini API:", status(hasGemini)) fmt.Println("Zhipu API:", status(hasZhipu)) fmt.Println("Qwen API:", status(hasQwen)) fmt.Println("Groq API:", status(hasGroq)) fmt.Println("Moonshot API:", status(hasMoonshot)) fmt.Println("DeepSeek API:", status(hasDeepSeek)) fmt.Println("VolcEngine API:", status(hasVolcEngine)) fmt.Println("Nvidia API:", status(hasNvidia)) if hasVLLM { fmt.Printf("vLLM/Local: ✓ %s\n", cfg.Providers.VLLM.APIBase) } else { fmt.Println("vLLM/Local: not set") } if hasOllama { fmt.Printf("Ollama: ✓ %s\n", cfg.Providers.Ollama.APIBase) } else { fmt.Println("Ollama: not set") } store, _ := auth.LoadStore() if store != nil && len(store.Credentials) > 0 { fmt.Println("\nOAuth/Token Auth:") for provider, cred := range store.Credentials { status := "authenticated" if cred.IsExpired() { status = "expired" } else if cred.NeedsRefresh() { status = "needs refresh" } fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status) } } } } ================================================ FILE: cmd/picoclaw/internal/version/command.go ================================================ package version import ( "fmt" "github.com/spf13/cobra" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/pkg/config" ) func NewVersionCommand() *cobra.Command { cmd := &cobra.Command{ Use: "version", Aliases: []string{"v"}, Short: "Show version information", Run: func(_ *cobra.Command, _ []string) { printVersion() }, } return cmd } func printVersion() { fmt.Printf("%s picoclaw %s\n", internal.Logo, config.FormatVersion()) build, goVer := config.FormatBuildInfo() if build != "" { fmt.Printf(" Build: %s\n", build) } if goVer != "" { fmt.Printf(" Go: %s\n", goVer) } } ================================================ FILE: cmd/picoclaw/internal/version/command_test.go ================================================ package version import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewVersionCommand(t *testing.T) { cmd := NewVersionCommand() require.NotNil(t, cmd) assert.Equal(t, "version", cmd.Use) assert.Len(t, cmd.Aliases, 1) assert.True(t, cmd.HasAlias("v")) assert.False(t, cmd.HasFlags()) assert.Equal(t, "Show version information", cmd.Short) assert.False(t, cmd.HasSubCommands()) assert.NotNil(t, cmd.Run) assert.Nil(t, cmd.RunE) assert.Nil(t, cmd.PersistentPreRun) assert.Nil(t, cmd.PersistentPostRun) } ================================================ FILE: cmd/picoclaw/main.go ================================================ // PicoClaw - Ultra-lightweight personal AI agent // Inspired by and based on nanobot: https://github.com/HKUDS/nanobot // License: MIT // // Copyright (c) 2026 PicoClaw contributors package main import ( "fmt" "os" "github.com/spf13/cobra" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/model" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/status" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/version" "github.com/sipeed/picoclaw/pkg/config" ) func NewPicoclawCommand() *cobra.Command { short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, config.GetVersion()) cmd := &cobra.Command{ Use: "picoclaw", Short: short, Example: "picoclaw version", } cmd.AddCommand( onboard.NewOnboardCommand(), agent.NewAgentCommand(), auth.NewAuthCommand(), gateway.NewGatewayCommand(), status.NewStatusCommand(), cron.NewCronCommand(), migrate.NewMigrateCommand(), skills.NewSkillsCommand(), model.NewModelCommand(), version.NewVersionCommand(), ) return cmd } const ( colorBlue = "\033[1;38;2;62;93;185m" colorRed = "\033[1;38;2;213;70;70m" banner = "\r\n" + colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" + colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" + colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" + colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" + colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" + colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " + "\033[0m\r\n" ) func main() { fmt.Printf("%s", banner) cmd := NewPicoclawCommand() if err := cmd.Execute(); err != nil { os.Exit(1) } } ================================================ FILE: cmd/picoclaw/main_test.go ================================================ package main import ( "fmt" "slices" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/pkg/config" ) func TestNewPicoclawCommand(t *testing.T) { cmd := NewPicoclawCommand() require.NotNil(t, cmd) short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, config.GetVersion()) assert.Equal(t, "picoclaw", cmd.Use) assert.Equal(t, short, cmd.Short) assert.True(t, cmd.HasSubCommands()) assert.True(t, cmd.HasAvailableSubCommands()) assert.False(t, cmd.HasFlags()) assert.Nil(t, cmd.Run) assert.Nil(t, cmd.RunE) assert.Nil(t, cmd.PersistentPreRun) assert.Nil(t, cmd.PersistentPostRun) allowedCommands := []string{ "agent", "auth", "cron", "gateway", "migrate", "model", "onboard", "skills", "status", "version", } subcommands := cmd.Commands() assert.Len(t, subcommands, len(allowedCommands)) for _, subcmd := range subcommands { found := slices.Contains(allowedCommands, subcmd.Name()) assert.True(t, found, "unexpected subcommand %q", subcmd.Name()) assert.False(t, subcmd.Hidden) } } ================================================ FILE: cmd/picoclaw-launcher-tui/internal/config/store.go ================================================ package configstore import ( "errors" "os" "path/filepath" picoclawconfig "github.com/sipeed/picoclaw/pkg/config" ) const ( configDirName = ".picoclaw" configFileName = "config.json" ) func ConfigPath() (string, error) { dir, err := ConfigDir() if err != nil { return "", err } return filepath.Join(dir, configFileName), nil } func ConfigDir() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(home, configDirName), nil } func Load() (*picoclawconfig.Config, error) { path, err := ConfigPath() if err != nil { return nil, err } return picoclawconfig.LoadConfig(path) } func Save(cfg *picoclawconfig.Config) error { if cfg == nil { return errors.New("config is nil") } path, err := ConfigPath() if err != nil { return err } return picoclawconfig.SaveConfig(path, cfg) } ================================================ FILE: cmd/picoclaw-launcher-tui/internal/ui/app.go ================================================ package ui import ( "fmt" "os" "os/exec" "path/filepath" "strings" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" configstore "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/config" picoclawconfig "github.com/sipeed/picoclaw/pkg/config" ) type appState struct { app *tview.Application pages *tview.Pages stack []string config *picoclawconfig.Config configPath string gatewayCmd *exec.Cmd menus map[string]*Menu original []byte hasOriginal bool backupPath string dirty bool logPath string } func Run() error { applyStyles() cfg, err := configstore.Load() if err != nil { return err } path, err := configstore.ConfigPath() if err != nil { return err } if cfg == nil { cfg = picoclawconfig.DefaultConfig() } originalData, hasOriginal := loadOriginalConfig(path) backupPath := path + ".bak" if hasOriginal { _ = writeBackupConfig(backupPath, originalData) } logPath := filepath.Join(filepath.Dir(path), "gateway.log") state := &appState{ app: tview.NewApplication(), pages: tview.NewPages(), config: cfg, configPath: path, menus: map[string]*Menu{}, original: originalData, hasOriginal: hasOriginal, backupPath: backupPath, logPath: logPath, } state.push("main", state.mainMenu()) root := tview.NewFlex().SetDirection(tview.FlexRow) root.AddItem(bannerView(), 6, 0, false) root.AddItem(state.pages, 0, 1, true) root.AddItem(footerView(), 1, 0, false) if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil { return err } return nil } func (s *appState) push(name string, primitive tview.Primitive) { s.pages.AddPage(name, primitive, true, true) s.stack = append(s.stack, name) s.pages.SwitchToPage(name) if menu, ok := primitive.(*Menu); ok { s.menus[name] = menu } } func (s *appState) pop() { if len(s.stack) == 0 { return } last := s.stack[len(s.stack)-1] s.pages.RemovePage(last) s.stack = s.stack[:len(s.stack)-1] if len(s.stack) == 0 { s.app.Stop() return } current := s.stack[len(s.stack)-1] s.pages.SwitchToPage(current) if menu, ok := s.menus[current]; ok { s.refreshMenu(current, menu) } } func (s *appState) mainMenu() tview.Primitive { menu := NewMenu("Menu", nil) refreshMainMenu(menu, s) menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyEsc: s.requestExit() return nil } return event }) return menu } func (s *appState) refreshMenu(name string, menu *Menu) { switch name { case "main": refreshMainMenu(menu, s) case "model": refreshModelMenuFromState(menu, s) case "channel": refreshChannelMenuFromState(menu, s) } } func (s *appState) countChannels() (enabled int, total int) { c := s.config.Channels entries := []bool{ c.Telegram.Enabled, c.Discord.Enabled, c.QQ.Enabled, c.MaixCam.Enabled, c.WhatsApp.Enabled, c.Feishu.Enabled, c.DingTalk.Enabled, c.Slack.Enabled, c.Matrix.Enabled, c.LINE.Enabled, c.OneBot.Enabled, c.WeCom.Enabled, c.WeComApp.Enabled, } total = len(entries) for _, v := range entries { if v { enabled++ } } return enabled, total } func refreshMainMenuIfPresent(s *appState) { if menu, ok := s.menus["main"]; ok { refreshMainMenu(menu, s) } } func refreshMainMenu(menu *Menu, s *appState) { selectedModel := s.selectedModelName() modelReady := selectedModel != "" channelReady := s.hasEnabledChannel() enabledCount, totalChannels := s.countChannels() gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning() gatewayLabel := "Start Gateway" gatewayDescription := "Launch gateway for channels" if gatewayRunning { gatewayLabel = "Stop Gateway" gatewayDescription = "Gateway running" } items := []MenuItem{ { Label: rootModelLabel(selectedModel), Description: rootModelDescription(), Action: func() { s.push("model", s.modelMenu()) }, MainColor: func() *tcell.Color { if modelReady { return nil } color := tcell.ColorGray return &color }(), }, { Label: rootChannelLabel(channelReady), Description: fmt.Sprintf("%d/%d enabled", enabledCount, totalChannels), Action: func() { s.push("channel", s.channelMenu()) }, MainColor: func() *tcell.Color { if channelReady { return nil } color := tcell.ColorGray return &color }(), }, { Label: "Start Talk", Description: "Open picoclaw agent in terminal", Action: func() { s.requestStartTalk() }, Disabled: !modelReady, }, { Label: gatewayLabel, Description: gatewayDescription, Action: func() { if gatewayRunning { s.stopGateway() } else { s.requestStartGateway() } refreshMainMenu(menu, s) }, Disabled: !gatewayRunning && (!modelReady || !channelReady), }, { Label: "View Gateway Log", Description: "Open gateway.log", Action: func() { s.viewGatewayLog() }, }, { Label: "Exit", Description: "Exit the TUI", Action: func() { s.requestExit() }, }, } menu.applyItems(items) } func (s *appState) applyChangesValidated() bool { if err := s.config.ValidateModelList(); err != nil { s.showMessage("Validation failed", err.Error()) return false } if err := s.validateAgentModel(); err != nil { s.showMessage("Validation failed", err.Error()) return false } if err := configstore.Save(s.config); err != nil { s.showMessage("Save failed", err.Error()) return false } if data, err := os.ReadFile(s.configPath); err == nil { s.original = data s.hasOriginal = true _ = writeBackupConfig(s.backupPath, data) } return true } func (s *appState) requestExit() { if s.dirty { s.confirmApplyOrDiscard(func() { s.app.Stop() }, func() { s.discardChanges() s.app.Stop() }) return } s.app.Stop() } func (s *appState) requestStartTalk() { if s.dirty { s.confirmApplyOrDiscard(func() { s.startTalk() }, func() { s.startTalk() }) return } s.startTalk() } func (s *appState) requestStartGateway() { if s.dirty { s.confirmApplyOrDiscard(func() { s.startGateway() }, func() { s.startGateway() }) return } s.startGateway() } func (s *appState) viewGatewayLog() { data, err := os.ReadFile(s.logPath) if err != nil { s.showMessage("Log not found", "gateway.log not found") return } text := tview.NewTextView() text.SetBorder(true).SetTitle("Gateway Log") text.SetText(string(data)) text.SetDoneFunc(func(key tcell.Key) { s.pages.RemovePage("log") }) text.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyEsc { s.pages.RemovePage("log") return nil } return event }) s.pages.AddPage("log", text, true, true) } func (s *appState) selectedModelName() string { modelName := strings.TrimSpace(s.config.Agents.Defaults.Model) if modelName == "" { return "" } if !s.isActiveModelValid() { return "" } return modelName } func rootModelLabel(selected string) string { if selected == "" { return "Model (None)" } return "Model (" + selected + ")" } func rootModelDescription() string { return "Using SPACE to choose your model" } func rootChannelLabel(valid bool) string { if !valid { return "Channel (no channel enabled)" } return "Channel" } func (s *appState) startTalk() { if !s.isActiveModelValid() { s.showMessage("Model required", "Select a valid model before starting talk") return } if !s.applyChangesValidated() { return } s.app.Suspend(func() { cmd := exec.Command("picoclaw", "agent") cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr _ = cmd.Run() }) } func (s *appState) startGateway() { if !s.isActiveModelValid() { s.showMessage("Model required", "Select a valid model before starting gateway") return } if !s.hasEnabledChannel() { s.showMessage("Channel required", "Enable at least one channel before starting gateway") return } if !s.applyChangesValidated() { return } _ = stopGatewayProcess() cmd := exec.Command("picoclaw", "gateway") logFile, err := os.OpenFile(s.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { s.showMessage("Gateway failed", err.Error()) return } cmd.Stdout = logFile cmd.Stderr = logFile if err := cmd.Start(); err != nil { s.showMessage("Gateway failed", err.Error()) _ = logFile.Close() return } _ = logFile.Close() s.gatewayCmd = cmd } func (s *appState) stopGateway() { _ = stopGatewayProcess() if s.gatewayCmd != nil && s.gatewayCmd.Process != nil { _ = s.gatewayCmd.Process.Kill() } s.gatewayCmd = nil } func (s *appState) isGatewayRunning() bool { return isGatewayProcessRunning() } func (s *appState) validateAgentModel() error { modelName := strings.TrimSpace(s.config.Agents.Defaults.Model) if modelName == "" { return nil } _, err := s.config.GetModelConfig(modelName) return err } func (s *appState) isActiveModelValid() bool { modelName := strings.TrimSpace(s.config.Agents.Defaults.Model) if modelName == "" { return false } cfg, err := s.config.GetModelConfig(modelName) if err != nil { return false } hasKey := strings.TrimSpace(cfg.APIKey) != "" || strings.TrimSpace(cfg.AuthMethod) == "oauth" hasModel := strings.TrimSpace(cfg.Model) != "" return hasKey && hasModel } func (s *appState) hasEnabledChannel() bool { c := s.config.Channels return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled || c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled || c.Matrix.Enabled || c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled } func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) { if s.pages.HasPage("apply") { return } modal := tview.NewModal(). SetText("Apply changes or discard before continuing?"). AddButtons([]string{"Cancel", "Discard", "Apply"}). SetDoneFunc(func(buttonIndex int, buttonLabel string) { s.pages.RemovePage("apply") switch buttonLabel { case "Discard": s.discardChanges() if onDiscard != nil { onDiscard() } case "Apply": if s.applyChangesValidated() { s.dirty = false if onApply != nil { onApply() } } } }) modal.SetBorder(true) s.pages.AddPage("apply", modal, true, true) } func (s *appState) discardChanges() { if s.hasOriginal { _ = writeOriginalConfig(s.configPath, s.original) } else { _ = os.Remove(s.configPath) } _ = os.Remove(s.backupPath) if cfg, err := configstore.Load(); err == nil && cfg != nil { s.config = cfg } s.dirty = false refreshMainMenuIfPresent(s) } func (s *appState) showMessage(title, message string) { if s.pages.HasPage("message") { return } modal := tview.NewModal(). SetText(strings.TrimSpace(message)). AddButtons([]string{"OK"}). SetDoneFunc(func(_ int, _ string) { s.pages.RemovePage("message") }) modal.SetTitle(title).SetBorder(true) modal.SetBackgroundColor(tview.Styles.ContrastBackgroundColor) modal.SetTextColor(tview.Styles.PrimaryTextColor) modal.SetButtonBackgroundColor(tcell.NewRGBColor(112, 102, 255)) modal.SetButtonTextColor(tview.Styles.PrimaryTextColor) s.pages.AddPage("message", modal, true, true) } func loadOriginalConfig(path string) ([]byte, bool) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil, false } return nil, false } return data, true } func writeOriginalConfig(path string, data []byte) error { return os.WriteFile(path, data, 0o600) } func writeBackupConfig(path string, data []byte) error { return os.WriteFile(path, data, 0o600) } ================================================ FILE: cmd/picoclaw-launcher-tui/internal/ui/channel.go ================================================ package ui import ( "fmt" "strings" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" picoclawconfig "github.com/sipeed/picoclaw/pkg/config" ) func (s *appState) buildChannelMenuItems() []MenuItem { return []MenuItem{ channelItem( "Telegram", "Telegram bot settings", s.config.Channels.Telegram.Enabled, func() { s.push("channel-telegram", s.telegramForm()) }, ), channelItem( "Discord", "Discord bot settings", s.config.Channels.Discord.Enabled, func() { s.push("channel-discord", s.discordForm()) }, ), channelItem( "QQ", "QQ bot settings", s.config.Channels.QQ.Enabled, func() { s.push("channel-qq", s.qqForm()) }, ), channelItem( "MaixCam", "MaixCam gateway", s.config.Channels.MaixCam.Enabled, func() { s.push("channel-maixcam", s.maixcamForm()) }, ), channelItem( "WhatsApp", "WhatsApp bridge", s.config.Channels.WhatsApp.Enabled, func() { s.push("channel-whatsapp", s.whatsappForm()) }, ), channelItem( "Feishu", "Feishu bot settings", s.config.Channels.Feishu.Enabled, func() { s.push("channel-feishu", s.feishuForm()) }, ), channelItem( "DingTalk", "DingTalk bot settings", s.config.Channels.DingTalk.Enabled, func() { s.push("channel-dingtalk", s.dingtalkForm()) }, ), channelItem( "Slack", "Slack bot settings", s.config.Channels.Slack.Enabled, func() { s.push("channel-slack", s.slackForm()) }, ), channelItem( "Matrix", "Matrix bot settings", s.config.Channels.Matrix.Enabled, func() { s.push("channel-matrix", s.matrixForm()) }, ), channelItem( "LINE", "LINE bot settings", s.config.Channels.LINE.Enabled, func() { s.push("channel-line", s.lineForm()) }, ), channelItem( "OneBot", "OneBot settings", s.config.Channels.OneBot.Enabled, func() { s.push("channel-onebot", s.onebotForm()) }, ), channelItem( "WeCom", "WeCom bot settings", s.config.Channels.WeCom.Enabled, func() { s.push("channel-wecom", s.wecomForm()) }, ), channelItem( "WeCom App", "WeCom App settings", s.config.Channels.WeComApp.Enabled, func() { s.push("channel-wecomapp", s.wecomAppForm()) }, ), } } func (s *appState) channelMenu() tview.Primitive { menu := NewMenu("Channels", s.buildChannelMenuItems()) menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyEsc { s.pop() return nil } return event }) return menu } func refreshChannelMenuFromState(menu *Menu, s *appState) { menu.applyItems(s.buildChannelMenuItems()) } func (s *appState) telegramForm() tview.Primitive { cfg := &s.config.Channels.Telegram form := baseChannelForm("Telegram", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("Token", cfg.Token, 128, nil, func(text string) { cfg.Token = strings.TrimSpace(text) }) form.AddInputField("Proxy", cfg.Proxy, 128, nil, func(text string) { cfg.Proxy = strings.TrimSpace(text) }) addAllowFromField(form, &cfg.AllowFrom) return wrapWithBack(form, s) } func (s *appState) discordForm() tview.Primitive { cfg := &s.config.Channels.Discord form := baseChannelForm("Discord", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("Token", cfg.Token, 128, nil, func(text string) { cfg.Token = strings.TrimSpace(text) }) form.AddCheckbox("Mention Only", cfg.MentionOnly, func(checked bool) { cfg.MentionOnly = checked }) addAllowFromField(form, &cfg.AllowFrom) return wrapWithBack(form, s) } func (s *appState) qqForm() tview.Primitive { cfg := &s.config.Channels.QQ form := baseChannelForm("QQ", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) { cfg.AppID = strings.TrimSpace(text) }) form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) { cfg.AppSecret = strings.TrimSpace(text) }) addAllowFromField(form, &cfg.AllowFrom) return wrapWithBack(form, s) } func (s *appState) maixcamForm() tview.Primitive { cfg := &s.config.Channels.MaixCam form := baseChannelForm("MaixCam", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("Host", cfg.Host, 64, nil, func(text string) { cfg.Host = strings.TrimSpace(text) }) addIntField(form, "Port", cfg.Port, func(value int) { cfg.Port = value }) addAllowFromField(form, &cfg.AllowFrom) return wrapWithBack(form, s) } func (s *appState) whatsappForm() tview.Primitive { cfg := &s.config.Channels.WhatsApp form := baseChannelForm("WhatsApp", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("Bridge URL", cfg.BridgeURL, 128, nil, func(text string) { cfg.BridgeURL = strings.TrimSpace(text) }) addAllowFromField(form, &cfg.AllowFrom) return wrapWithBack(form, s) } func (s *appState) feishuForm() tview.Primitive { cfg := &s.config.Channels.Feishu form := baseChannelForm("Feishu", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) { cfg.AppID = strings.TrimSpace(text) }) form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) { cfg.AppSecret = strings.TrimSpace(text) }) form.AddInputField("Encrypt Key", cfg.EncryptKey, 128, nil, func(text string) { cfg.EncryptKey = strings.TrimSpace(text) }) form.AddInputField("Verification Token", cfg.VerificationToken, 128, nil, func(text string) { cfg.VerificationToken = strings.TrimSpace(text) }) addAllowFromField(form, &cfg.AllowFrom) return wrapWithBack(form, s) } func (s *appState) dingtalkForm() tview.Primitive { cfg := &s.config.Channels.DingTalk form := baseChannelForm("DingTalk", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("Client ID", cfg.ClientID, 64, nil, func(text string) { cfg.ClientID = strings.TrimSpace(text) }) form.AddInputField("Client Secret", cfg.ClientSecret, 128, nil, func(text string) { cfg.ClientSecret = strings.TrimSpace(text) }) addAllowFromField(form, &cfg.AllowFrom) return wrapWithBack(form, s) } func (s *appState) slackForm() tview.Primitive { cfg := &s.config.Channels.Slack form := baseChannelForm("Slack", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("Bot Token", cfg.BotToken, 128, nil, func(text string) { cfg.BotToken = strings.TrimSpace(text) }) form.AddInputField("App Token", cfg.AppToken, 128, nil, func(text string) { cfg.AppToken = strings.TrimSpace(text) }) addAllowFromField(form, &cfg.AllowFrom) return wrapWithBack(form, s) } func (s *appState) lineForm() tview.Primitive { cfg := &s.config.Channels.LINE form := baseChannelForm("LINE", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("Channel Secret", cfg.ChannelSecret, 128, nil, func(text string) { cfg.ChannelSecret = strings.TrimSpace(text) }) form.AddInputField("Channel Access Token", cfg.ChannelAccessToken, 128, nil, func(text string) { cfg.ChannelAccessToken = strings.TrimSpace(text) }) form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) { cfg.WebhookHost = strings.TrimSpace(text) }) addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value }) form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) { cfg.WebhookPath = strings.TrimSpace(text) }) addAllowFromField(form, &cfg.AllowFrom) return wrapWithBack(form, s) } func (s *appState) matrixForm() tview.Primitive { cfg := &s.config.Channels.Matrix form := baseChannelForm("Matrix", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("Homeserver", cfg.Homeserver, 128, nil, func(text string) { cfg.Homeserver = strings.TrimSpace(text) }) form.AddInputField("User ID", cfg.UserID, 128, nil, func(text string) { cfg.UserID = strings.TrimSpace(text) }) form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) { cfg.AccessToken = strings.TrimSpace(text) }) form.AddInputField("Device ID", cfg.DeviceID, 128, nil, func(text string) { cfg.DeviceID = strings.TrimSpace(text) }) form.AddCheckbox("Join On Invite", cfg.JoinOnInvite, func(checked bool) { cfg.JoinOnInvite = checked }) addAllowFromField(form, &cfg.AllowFrom) return wrapWithBack(form, s) } func (s *appState) onebotForm() tview.Primitive { cfg := &s.config.Channels.OneBot form := baseChannelForm("OneBot", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("WS URL", cfg.WSUrl, 128, nil, func(text string) { cfg.WSUrl = strings.TrimSpace(text) }) form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) { cfg.AccessToken = strings.TrimSpace(text) }) addIntField( form, "Reconnect Interval", cfg.ReconnectInterval, func(value int) { cfg.ReconnectInterval = value }, ) form.AddInputField( "Group Trigger Prefix", strings.Join(cfg.GroupTriggerPrefix, ","), 128, nil, func(text string) { cfg.GroupTriggerPrefix = splitCSV(text) }, ) addAllowFromField(form, &cfg.AllowFrom) return wrapWithBack(form, s) } func (s *appState) wecomForm() tview.Primitive { cfg := &s.config.Channels.WeCom form := baseChannelForm("WeCom", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("Token", cfg.Token, 128, nil, func(text string) { cfg.Token = strings.TrimSpace(text) }) form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) { cfg.EncodingAESKey = strings.TrimSpace(text) }) form.AddInputField("Webhook URL", cfg.WebhookURL, 128, nil, func(text string) { cfg.WebhookURL = strings.TrimSpace(text) }) form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) { cfg.WebhookHost = strings.TrimSpace(text) }) addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value }) form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) { cfg.WebhookPath = strings.TrimSpace(text) }) addAllowFromField(form, &cfg.AllowFrom) addIntField( form, "Reply Timeout", cfg.ReplyTimeout, func(value int) { cfg.ReplyTimeout = value }, ) return wrapWithBack(form, s) } func (s *appState) wecomAppForm() tview.Primitive { cfg := &s.config.Channels.WeComApp form := baseChannelForm("WeCom App", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form.AddInputField("Corp ID", cfg.CorpID, 64, nil, func(text string) { cfg.CorpID = strings.TrimSpace(text) }) form.AddInputField("Corp Secret", cfg.CorpSecret, 128, nil, func(text string) { cfg.CorpSecret = strings.TrimSpace(text) }) addInt64Field(form, "Agent ID", cfg.AgentID, func(value int64) { cfg.AgentID = value }) form.AddInputField("Token", cfg.Token, 128, nil, func(text string) { cfg.Token = strings.TrimSpace(text) }) form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) { cfg.EncodingAESKey = strings.TrimSpace(text) }) form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) { cfg.WebhookHost = strings.TrimSpace(text) }) addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value }) form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) { cfg.WebhookPath = strings.TrimSpace(text) }) addAllowFromField(form, &cfg.AllowFrom) addIntField( form, "Reply Timeout", cfg.ReplyTimeout, func(value int) { cfg.ReplyTimeout = value }, ) return wrapWithBack(form, s) } func (s *appState) makeChannelOnEnabled(enabledPtr *bool) func(bool) { return func(v bool) { *enabledPtr = v s.dirty = true refreshMainMenuIfPresent(s) if menu, ok := s.menus["channel"]; ok { refreshChannelMenuFromState(menu, s) } } } func addAllowFromField(form *tview.Form, allowFrom *picoclawconfig.FlexibleStringSlice) { form.AddInputField("Allow From", strings.Join(*allowFrom, ","), 128, nil, func(text string) { *allowFrom = splitCSV(text) }) } func baseChannelForm(title string, enabled bool, onEnabled func(bool)) *tview.Form { form := tview.NewForm() form.SetBorder(true).SetTitle(fmt.Sprintf("Channel: %s", title)) form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123)) form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22)) form.AddCheckbox("Enabled", enabled, func(checked bool) { onEnabled(checked) }) return form } func wrapWithBack(form *tview.Form, s *appState) tview.Primitive { form.AddButton("Back", func() { s.pop() }) form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyEsc { s.pop() return nil } return event }) return form } func splitCSV(input string) picoclawconfig.FlexibleStringSlice { parts := strings.Split(strings.TrimSpace(input), ",") cleaned := make([]string, 0, len(parts)) for _, part := range parts { value := strings.TrimSpace(part) if value == "" { continue } cleaned = append(cleaned, value) } return cleaned } func addIntField(form *tview.Form, label string, value int, onChange func(int)) { form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) { var parsed int if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil { onChange(parsed) } }) } func addInt64Field(form *tview.Form, label string, value int64, onChange func(int64)) { form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) { var parsed int64 if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil { onChange(parsed) } }) } func channelItem(label, description string, enabled bool, action MenuAction) MenuItem { item := MenuItem{ Label: label, Description: description, Action: action, } if !enabled { color := tcell.ColorGray item.MainColor = &color } return item } ================================================ FILE: cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go ================================================ //go:build !windows // +build !windows package ui import "os/exec" func isGatewayProcessRunning() bool { cmd := exec.Command("sh", "-c", "pgrep -f 'picoclaw\\s+gateway' >/dev/null 2>&1") return cmd.Run() == nil } func stopGatewayProcess() error { cmd := exec.Command("sh", "-c", "pkill -f 'picoclaw\\s+gateway' >/dev/null 2>&1") return cmd.Run() } ================================================ FILE: cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go ================================================ //go:build windows // +build windows package ui import "os/exec" func isGatewayProcessRunning() bool { cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq picoclaw.exe") return cmd.Run() == nil } func stopGatewayProcess() error { cmd := exec.Command("taskkill", "/F", "/IM", "picoclaw.exe") return cmd.Run() } ================================================ FILE: cmd/picoclaw-launcher-tui/internal/ui/menu.go ================================================ package ui import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) type MenuAction func() type MenuItem struct { Label string Description string Action MenuAction Disabled bool MainColor *tcell.Color DescColor *tcell.Color } type Menu struct { *tview.Table items []MenuItem } func NewMenu(title string, items []MenuItem) *Menu { table := tview.NewTable().SetSelectable(true, false) table.SetBorder(true).SetTitle(title) table.SetBorders(false) menu := &Menu{Table: table, items: items} menu.applyItems(items) menu.SetSelectedFunc(func(row, _ int) { if row < 0 || row >= len(menu.items) { return } item := menu.items[row] if item.Disabled || item.Action == nil { return } item.Action() }) menu.SetSelectedStyle( tcell.StyleDefault.Foreground(tview.Styles.InverseTextColor). Background(tcell.NewRGBColor(189, 147, 249)), ) return menu } func (m *Menu) applyItems(items []MenuItem) { m.items = items m.Clear() for row, item := range items { label := item.Label if item.Disabled && label != "" { label = label + " (disabled)" } left := tview.NewTableCell(label) right := tview.NewTableCell(item.Description).SetAlign(tview.AlignRight) if item.MainColor != nil { left.SetTextColor(*item.MainColor) } if item.DescColor != nil { right.SetTextColor(*item.DescColor) } else { right.SetTextColor(tview.Styles.TertiaryTextColor) } if item.Disabled { left.SetTextColor(tcell.ColorGray) right.SetTextColor(tcell.ColorGray) } m.SetCell(row, 0, left) m.SetCell(row, 1, right) } } ================================================ FILE: cmd/picoclaw-launcher-tui/internal/ui/model.go ================================================ package ui import ( "fmt" "io" "net/http" "strings" "time" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" picoclawconfig "github.com/sipeed/picoclaw/pkg/config" ) func (s *appState) modelMenu() tview.Primitive { items := make([]MenuItem, 0, 1+len(s.config.ModelList)) currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model) for i := range s.config.ModelList { index := i model := s.config.ModelList[i] isValid := isModelValid(model) desc := model.APIBase if desc == "" { desc = model.AuthMethod } if desc == "" { desc = "api_key required" } label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model) if model.ModelName == currentModel && currentModel != "" { label = "* " + label } isSelected := model.ModelName == currentModel && currentModel != "" items = append(items, MenuItem{ Label: label, Description: desc, MainColor: modelStatusColor(isValid, isSelected), Action: func() { s.push(fmt.Sprintf("model-%d", index), s.modelForm(index)) }, }) } // Add model entry appended at the end so the models map to rows 1..N items = append(items, MenuItem{ Label: "**Add model**", Description: "Append a new model entry", Action: func() { newName := s.nextAvailableModelName("new-model") s.addModel( picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"}, ) s.push( fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1), ) }, }, ) menu := NewMenu("Models", items) menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyEsc { s.pop() return nil } if event.Rune() == ' ' { row, _ := menu.GetSelection() if row >= 0 && row < len(s.config.ModelList) { model := s.config.ModelList[row] if !isModelValid(model) { s.showMessage( "Invalid model", "Select a model with api_key or oauth auth_method", ) return nil } s.config.Agents.Defaults.Model = model.ModelName s.dirty = true refreshModelMenu(menu, s.config.Agents.Defaults.Model, s.config.ModelList) refreshMainMenuIfPresent(s) } return nil } return event }) return menu } func (s *appState) modelForm(index int) tview.Primitive { model := &s.config.ModelList[index] form := tview.NewForm() form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName)) addInput(form, "Model Name", model.ModelName, func(value string) { if value == "" { s.showMessage("Invalid model name", "Model Name cannot be empty") return } if s.modelNameExists(value, index) { s.showMessage("Duplicate model name", fmt.Sprintf("Model Name '%s' already exists", value)) return } oldName := model.ModelName model.ModelName = value if s.config.Agents.Defaults.Model == oldName { s.config.Agents.Defaults.Model = value } s.dirty = true form.SetTitle(fmt.Sprintf("Model: %s", model.ModelName)) refreshMainMenuIfPresent(s) if menu, ok := s.menus["model"]; ok { refreshModelMenuFromState(menu, s) } }) addInput(form, "Model", model.Model, func(value string) { model.Model = value s.dirty = true refreshMainMenuIfPresent(s) if menu, ok := s.menus["model"]; ok { refreshModelMenuFromState(menu, s) } }) addInput(form, "API Base", model.APIBase, func(value string) { model.APIBase = value s.dirty = true refreshMainMenuIfPresent(s) if menu, ok := s.menus["model"]; ok { refreshModelMenuFromState(menu, s) } }) addInput(form, "API Key", model.APIKey, func(value string) { model.APIKey = value s.dirty = true refreshMainMenuIfPresent(s) if menu, ok := s.menus["model"]; ok { refreshModelMenuFromState(menu, s) } }) addInput(form, "Proxy", model.Proxy, func(value string) { model.Proxy = value }) addInput(form, "Auth Method", model.AuthMethod, func(value string) { model.AuthMethod = value s.dirty = true refreshMainMenuIfPresent(s) if menu, ok := s.menus["model"]; ok { refreshModelMenuFromState(menu, s) } }) addInput(form, "Connect Mode", model.ConnectMode, func(value string) { model.ConnectMode = value }) addInput(form, "Workspace", model.Workspace, func(value string) { model.Workspace = value }) addInput(form, "Max Tokens Field", model.MaxTokensField, func(value string) { model.MaxTokensField = value }) addIntInput(form, "RPM", model.RPM, func(value int) { model.RPM = value }) addIntInput(form, "Request Timeout", model.RequestTimeout, func(value int) { model.RequestTimeout = value }) form.AddButton("Delete", func() { pageName := "confirm-delete-model" if s.pages.HasPage(pageName) { return } modal := tview.NewModal(). SetText("Are you sure you want to delete this model?"). AddButtons([]string{"Cancel", "Delete"}). SetDoneFunc(func(buttonIndex int, buttonLabel string) { s.pages.RemovePage(pageName) if buttonLabel == "Delete" { s.deleteModel(index) } }) modal.SetTitle("Confirm Delete").SetBorder(true) s.pages.AddPage(pageName, modal, true, true) }) form.AddButton("Test", func() { s.testModel(model) }) form.AddButton("Back", func() { s.pop() }) form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyEsc { s.pop() return nil } return event }) return form } func addInput(form *tview.Form, label, value string, onChange func(string)) { form.AddInputField(label, value, 128, nil, func(text string) { onChange(strings.TrimSpace(text)) }) } func addIntInput(form *tview.Form, label string, value int, onChange func(int)) { form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) { var parsed int if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil { onChange(parsed) } }) } func (s *appState) addModel(model picoclawconfig.ModelConfig) { s.config.ModelList = append(s.config.ModelList, model) } func (s *appState) deleteModel(index int) { if index < 0 || index >= len(s.config.ModelList) { return } s.config.ModelList = append(s.config.ModelList[:index], s.config.ModelList[index+1:]...) s.pop() } func modelStatusColor(valid bool, selected bool) *tcell.Color { if valid { color := tview.Styles.PrimaryTextColor return &color } color := tcell.ColorGray return &color } func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) { for i, model := range models { row := i label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model) isValid := isModelValid(model) if model.ModelName == currentModel && currentModel != "" { label = "* " + label } cell := menu.GetCell(row, 0) if cell != nil { cell.SetText(label) isSelected := model.ModelName == currentModel && currentModel != "" color := modelStatusColor(isValid, isSelected) if color != nil { cell.SetTextColor(*color) } } } } func refreshModelMenuFromState(menu *Menu, s *appState) { items := make([]MenuItem, 0, 1+len(s.config.ModelList)) currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model) for i := range s.config.ModelList { index := i model := s.config.ModelList[i] isValid := isModelValid(model) desc := model.APIBase if desc == "" { desc = model.AuthMethod } if desc == "" { desc = "api_key required" } label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model) if model.ModelName == currentModel && currentModel != "" { label = "* " + label } isSelected := model.ModelName == currentModel && currentModel != "" items = append(items, MenuItem{ Label: label, Description: desc, MainColor: modelStatusColor(isValid, isSelected), Action: func() { s.push(fmt.Sprintf("model-%d", index), s.modelForm(index)) }, }) } items = append(items, MenuItem{ Label: "**Add Model**", Description: "Append a new model entry", Action: func() { newName := s.nextAvailableModelName("new-model") s.addModel( picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"}, ) s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1)) }, }, ) menu.applyItems(items) } func isModelValid(model picoclawconfig.ModelConfig) bool { hasKey := strings.TrimSpace(model.APIKey) != "" || strings.TrimSpace(model.AuthMethod) == "oauth" hasModel := strings.TrimSpace(model.Model) != "" return hasKey && hasModel } func (s *appState) modelNameExists(name string, excludeIndex int) bool { target := strings.TrimSpace(name) if target == "" { return false } for i := range s.config.ModelList { if i == excludeIndex { continue } if strings.TrimSpace(s.config.ModelList[i].ModelName) == target { return true } } return false } func (s *appState) nextAvailableModelName(base string) string { name := strings.TrimSpace(base) if name == "" { name = "new-model" } if !s.modelNameExists(name, -1) { return name } for i := 2; ; i++ { candidate := fmt.Sprintf("%s-%d", name, i) if !s.modelNameExists(candidate, -1) { return candidate } } } func (s *appState) testModel(model *picoclawconfig.ModelConfig) { if model == nil { return } if strings.TrimSpace(model.APIKey) == "" { s.showMessage("Missing API Key", "Set api_key before testing") return } base := strings.TrimSpace(model.APIBase) if base == "" { s.showMessage("Missing API Base", "Set api_base before testing") return } modelID := strings.TrimSpace(model.Model) if modelID == "" { s.showMessage("Missing Model", "Set model before testing") return } if !strings.HasPrefix(modelID, "openai/") { s.showMessage("Unsupported model", "Only openai/* models are supported for test") return } modelName := strings.TrimPrefix(modelID, "openai/") endpoint := strings.TrimRight(base, "/") + "/chat/completions" payload := fmt.Sprintf( `{"model":"%s","messages":[{"role":"user","content":"ping"}],"max_tokens":1}`, modelName, ) client := &http.Client{Timeout: 10 * time.Second} request, err := http.NewRequest("POST", endpoint, strings.NewReader(payload)) if err != nil { s.showMessage("Test failed", err.Error()) return } request.Header.Set("Content-Type", "application/json") request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(model.APIKey)) resp, err := client.Do(request) if err != nil { s.showMessage("Test failed", err.Error()) return } defer resp.Body.Close() if resp.StatusCode >= 200 && resp.StatusCode < 300 { s.showMessage("Test OK", resp.Status) return } body, err := io.ReadAll(io.LimitReader(resp.Body, 2048)) if err != nil { s.showMessage("Test failed", fmt.Sprintf("failed to read response: %v", err)) return } s.showMessage( "Test failed", fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))), ) } ================================================ FILE: cmd/picoclaw-launcher-tui/internal/ui/style.go ================================================ package ui import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) const ( colorBlue = "[#3e5db9]" colorRed = "[#d54646]" banner = "\r\n[::b]" + colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" + colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" + colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" + colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" + colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" + colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " + "[:]" ) func applyStyles() { tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(12, 13, 22) tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(34, 19, 53) tview.Styles.MoreContrastBackgroundColor = tcell.NewRGBColor(18, 18, 32) tview.Styles.BorderColor = tcell.NewRGBColor(112, 102, 255) tview.Styles.TitleColor = tcell.NewRGBColor(255, 121, 198) tview.Styles.GraphicsColor = tcell.NewRGBColor(139, 233, 253) tview.Styles.PrimaryTextColor = tcell.NewRGBColor(241, 250, 255) tview.Styles.SecondaryTextColor = tcell.NewRGBColor(80, 250, 123) tview.Styles.TertiaryTextColor = tcell.NewRGBColor(139, 233, 253) tview.Styles.InverseTextColor = tcell.NewRGBColor(12, 13, 22) tview.Styles.ContrastSecondaryTextColor = tcell.NewRGBColor(189, 147, 249) } func bannerView() *tview.TextView { text := tview.NewTextView() text.SetDynamicColors(true) text.SetTextAlign(tview.AlignCenter) text.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor) text.SetText(banner) text.SetBorder(false) return text } const footerText = "Esc: Back/Exit | Enter: Enter | ←↓↑→ : Move | Space: Select | Tab/Shift+Tab: Switch" func footerView() *tview.TextView { text := tview.NewTextView() text.SetTextAlign(tview.AlignCenter) text.SetText(footerText) text.SetBackgroundColor(tview.Styles.MoreContrastBackgroundColor) text.SetTextColor(tview.Styles.PrimaryTextColor) text.SetBorder(false) return text } ================================================ FILE: cmd/picoclaw-launcher-tui/main.go ================================================ package main import ( "fmt" "os" "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/ui" ) func main() { if err := ui.Run(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } ================================================ FILE: config/config.example.json ================================================ { "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "restrict_to_workspace": true, "model_name": "gpt-5.4", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20, "summarize_message_threshold": 20, "summarize_token_percent": 75, "tool_feedback": { "enabled": false, "max_args_length": 300 } } }, "model_list": [ { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_key": "sk-your-openai-key", "api_base": "https://api.openai.com/v1" }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", "api_key": "sk-ant-your-key", "api_base": "https://api.anthropic.com/v1", "thinking_level": "high" }, { "_comment": "Anthropic Messages API - use native format for direct Anthropic API access", "model_name": "claude-opus-4-6", "model": "anthropic-messages/claude-opus-4-6", "api_key": "sk-ant-your-key", "api_base": "https://api.anthropic.com" }, { "model_name": "gemini", "model": "antigravity/gemini-2.0-flash", "auth_method": "oauth" }, { "model_name": "deepseek", "model": "deepseek/deepseek-chat", "api_key": "sk-your-deepseek-key" }, { "model_name": "longcat", "model": "longcat/LongCat-Flash-Thinking", "api_key": "your-longcat-api-key" }, { "model_name": "modelscope-qwen", "model": "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507", "api_key": "your-modelscope-access-token", "api_base": "https://api-inference.modelscope.cn/v1" }, { "model_name": "azure-gpt5", "model": "azure/my-gpt5-deployment", "api_key": "your-azure-api-key", "api_base": "https://your-resource.openai.azure.com" }, { "model_name": "loadbalanced-gpt-5.4", "model": "openai/gpt-5.4", "api_key": "sk-key1", "api_base": "https://api1.example.com/v1" }, { "model_name": "loadbalanced-gpt-5.4", "model": "openai/gpt-5.4", "api_key": "sk-key2", "api_base": "https://api2.example.com/v1" } ], "channels": { "telegram": { "enabled": false, "token": "YOUR_TELEGRAM_BOT_TOKEN", "base_url": "", "proxy": "", "allow_from": ["YOUR_USER_ID"], "use_markdown_v2": false, "reasoning_channel_id": "" }, "discord": { "enabled": false, "token": "YOUR_DISCORD_BOT_TOKEN", "proxy": "", "allow_from": [], "group_trigger": { "mention_only": false }, "reasoning_channel_id": "" }, "qq": { "enabled": false, "app_id": "YOUR_QQ_APP_ID", "app_secret": "YOUR_QQ_APP_SECRET", "allow_from": [], "reasoning_channel_id": "" }, "maixcam": { "enabled": false, "host": "0.0.0.0", "port": 18790, "allow_from": [], "reasoning_channel_id": "" }, "whatsapp": { "enabled": false, "bridge_url": "ws://localhost:3001", "use_native": false, "session_store_path": "", "allow_from": [], "reasoning_channel_id": "" }, "feishu": { "enabled": false, "app_id": "", "app_secret": "", "encrypt_key": "", "verification_token": "", "allow_from": [], "reasoning_channel_id": "", "random_reaction_emoji": [], "is_lark": false }, "dingtalk": { "enabled": false, "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "allow_from": [], "reasoning_channel_id": "" }, "slack": { "enabled": false, "bot_token": "xoxb-YOUR-BOT-TOKEN", "app_token": "xapp-YOUR-APP-TOKEN", "allow_from": [], "reasoning_channel_id": "" }, "matrix": { "enabled": false, "homeserver": "https://matrix.org", "user_id": "@your-bot:matrix.org", "access_token": "YOUR_MATRIX_ACCESS_TOKEN", "device_id": "", "join_on_invite": true, "allow_from": [], "group_trigger": { "mention_only": true }, "placeholder": { "enabled": true, "text": "Thinking... 💭" }, "reasoning_channel_id": "" }, "line": { "enabled": false, "channel_secret": "YOUR_LINE_CHANNEL_SECRET", "channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN", "webhook_path": "/webhook/line", "allow_from": [], "reasoning_channel_id": "" }, "onebot": { "enabled": false, "ws_url": "ws://127.0.0.1:3001", "access_token": "", "reconnect_interval": 5, "group_trigger_prefix": [], "allow_from": [], "reasoning_channel_id": "" }, "wecom": { "_comment": "WeCom Bot - Easier setup, supports group chats", "enabled": false, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", "webhook_path": "/webhook/wecom", "allow_from": [], "reply_timeout": 5, "reasoning_channel_id": "" }, "wecom_app": { "_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only.", "enabled": false, "corp_id": "YOUR_CORP_ID", "corp_secret": "YOUR_CORP_SECRET", "agent_id": 1000002, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-app", "allow_from": [], "reply_timeout": 5, "reasoning_channel_id": "" }, "wecom_aibot": { "_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.", "enabled": false, "bot_id": "YOUR_BOT_ID", "secret": "YOUR_SECRET", "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-aibot", "max_steps": 10, "welcome_message": "Hello! I'm your AI assistant. How can I help you today?", "reasoning_channel_id": "" }, "irc": { "enabled": false, "server": "irc.libera.chat:6697", "tls": true, "nick": "mybot", "user": "", "real_name": "", "password": "", "nickserv_password": "", "sasl_user": "", "sasl_password": "", "channels": [ "#mychannel" ], "request_caps": [ "server-time", "message-tags" ], "allow_from": [], "group_trigger": { "mention_only": true }, "typing": { "enabled": false }, "reasoning_channel_id": "" } }, "providers": { "_comment": "DEPRECATED: Use model_list instead. This will be removed in a future version", "anthropic": { "api_key": "", "api_base": "" }, "openai": { "api_key": "", "api_base": "", "web_search": true }, "openrouter": { "api_key": "sk-or-v1-xxx", "api_base": "" }, "groq": { "api_key": "gsk_xxx", "api_base": "" }, "zhipu": { "api_key": "YOUR_ZHIPU_API_KEY", "api_base": "" }, "gemini": { "api_key": "", "api_base": "" }, "vllm": { "api_key": "", "api_base": "" }, "nvidia": { "api_key": "nvapi-xxx", "api_base": "", "proxy": "http://127.0.0.1:7890" }, "moonshot": { "api_key": "sk-xxx", "api_base": "" }, "qwen": { "api_key": "sk-xxx", "api_base": "" }, "ollama": { "api_key": "", "api_base": "http://localhost:11434/v1" }, "cerebras": { "api_key": "", "api_base": "" }, "volcengine": { "api_key": "", "api_base": "" }, "mistral": { "api_key": "", "api_base": "https://api.mistral.ai/v1" }, "avian": { "api_key": "", "api_base": "https://api.avian.io/v1" }, "longcat": { "api_key": "", "api_base": "https://api.longcat.chat/openai" }, "modelscope": { "api_key": "", "api_base": "https://api-inference.modelscope.cn/v1" } }, "tools": { "allow_read_paths": null, "allow_write_paths": null, "web": { "enabled": true, "prefer_native": true, "fetch_limit_bytes": 10485760, "format": "plaintext", "brave": { "enabled": false, "api_key": "YOUR_BRAVE_API_KEY", "api_keys": [ "YOUR_BRAVE_API_KEY" ], "max_results": 5 }, "tavily": { "enabled": false, "api_key": "", "base_url": "", "max_results": 0 }, "duckduckgo": { "enabled": true, "max_results": 5 }, "perplexity": { "enabled": false, "api_key": "pplx-xxx", "api_keys": [ "pplx-xxx" ], "max_results": 5 }, "searxng": { "enabled": false, "base_url": "http://localhost:8888", "max_results": 5 }, "glm_search": { "enabled": false, "api_key": "", "base_url": "https://open.bigmodel.cn/api/paas/v4/web_search", "search_engine": "search_std", "max_results": 5 }, "fetch_limit_bytes": 10485760, "private_host_whitelist": [] }, "cron": { "enabled": true, "exec_timeout_minutes": 5 }, "mcp": { "enabled": false, "discovery": { "enabled": false, "ttl": 5, "max_search_results": 5, "use_bm25": true, "use_regex": false }, "servers": { "context7": { "enabled": false, "type": "http", "url": "https://mcp.context7.com/mcp", "headers": { "CONTEXT7_API_KEY": "ctx7sk-xx" } }, "filesystem": { "enabled": false, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-filesystem", "/tmp" ] }, "github": { "enabled": false, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-github" ], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" } }, "brave-search": { "enabled": false, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-brave-search" ], "env": { "BRAVE_API_KEY": "YOUR_BRAVE_API_KEY" } }, "postgres": { "enabled": false, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-postgres", "postgresql://user:password@localhost/dbname" ] }, "slack": { "enabled": false, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-slack" ], "env": { "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" } } } }, "exec": { "enabled": true, "enable_deny_patterns": true, "custom_deny_patterns": null, "custom_allow_patterns": null }, "skills": { "enabled": true, "registries": { "clawhub": { "enabled": true, "base_url": "https://clawhub.ai", "auth_token": "", "search_path": "", "skills_path": "", "download_path": "", "timeout": 0, "max_zip_size": 0, "max_response_size": 0 } }, "github": { "proxy": "http://127.0.0.1:7891", "token": "" }, "max_concurrent_searches": 2, "search_cache": { "max_size": 50, "ttl_seconds": 300 } }, "media_cleanup": { "enabled": true, "max_age_minutes": 30, "interval_minutes": 5 }, "append_file": { "enabled": true }, "edit_file": { "enabled": true }, "find_skills": { "enabled": true }, "i2c": { "enabled": false }, "install_skill": { "enabled": true }, "list_dir": { "enabled": true }, "message": { "enabled": true }, "read_file": { "enabled": true }, "spawn": { "enabled": true }, "spi": { "enabled": false }, "subagent": { "enabled": true }, "web_fetch": { "enabled": true }, "write_file": { "enabled": true } }, "heartbeat": { "enabled": true, "interval": 30 }, "devices": { "enabled": false, "monitor_usb": true }, "voice": { "echo_transcription": false }, "gateway": { "host": "127.0.0.1", "port": 18790, "hot_reload": false } } ================================================ FILE: docker/Dockerfile ================================================ # ============================================================ # Stage 1: Build the picoclaw binary # ============================================================ FROM golang:1.25-alpine AS builder RUN apk add --no-cache git make WORKDIR /src # Cache dependencies COPY go.mod go.sum ./ RUN go mod download # Copy source and build COPY . . RUN make build # ============================================================ # Stage 2: Minimal runtime image # ============================================================ FROM alpine:3.23 RUN apk add --no-cache ca-certificates tzdata curl # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget -q --spider http://localhost:18790/health || exit 1 # Copy binary COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw # Create non-root user and group RUN addgroup -g 1000 picoclaw && \ adduser -D -u 1000 -G picoclaw picoclaw # Switch to non-root user USER picoclaw # Run onboard to create initial directories and config RUN /usr/local/bin/picoclaw onboard ENTRYPOINT ["picoclaw"] CMD ["gateway"] ================================================ FILE: docker/Dockerfile.full ================================================ # ============================================================ # Stage 1: Build the picoclaw binary # ============================================================ FROM golang:1.26.0-alpine AS builder RUN apk add --no-cache git make WORKDIR /src # Cache dependencies COPY go.mod go.sum ./ RUN go mod download # Copy source and build COPY . . RUN make build # ============================================================ # Stage 2: Node.js-based runtime with full MCP support # ============================================================ FROM node:24-alpine3.23 # Install runtime dependencies RUN apk add --no-cache \ ca-certificates \ curl \ git \ python3 \ py3-pip # Install uv and symlink to system path RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \ ln -s /root/.local/bin/uv /usr/local/bin/uv && \ ln -s /root/.local/bin/uvx /usr/local/bin/uvx && \ uv --version # Copy binary COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw # Create picoclaw home directory RUN /usr/local/bin/picoclaw onboard ENTRYPOINT ["picoclaw"] CMD ["gateway"] ================================================ FILE: docker/Dockerfile.goreleaser ================================================ FROM alpine:3.21 ARG TARGETPLATFORM RUN apk add --no-cache ca-certificates tzdata COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw COPY docker/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] ================================================ FILE: docker/Dockerfile.goreleaser.launcher ================================================ FROM alpine:3.21 ARG TARGETPLATFORM RUN apk add --no-cache ca-certificates tzdata COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw COPY $TARGETPLATFORM/picoclaw-launcher /usr/local/bin/picoclaw-launcher COPY $TARGETPLATFORM/picoclaw-launcher-tui /usr/local/bin/picoclaw-launcher-tui ENTRYPOINT ["picoclaw-launcher"] CMD ["-public", "-no-browser"] ================================================ FILE: docker/docker-compose.full.yml ================================================ services: # ───────────────────────────────────────────── # PicoClaw Agent (one-shot query) - Full MCP Support # docker compose -f docker/docker-compose.full.yml run --rm picoclaw-agent -m "Hello" # ───────────────────────────────────────────── picoclaw-agent: build: context: .. dockerfile: docker/Dockerfile.full container_name: picoclaw-agent-full profiles: - agent volumes: - ../config/config.json:/root/.picoclaw/config.json:ro - picoclaw-workspace:/root/.picoclaw/workspace - picoclaw-npm-cache:/root/.npm # npm cache for faster MCP server installs entrypoint: ["picoclaw", "agent"] stdin_open: true tty: true # ───────────────────────────────────────────── # PicoClaw Gateway (Long-running Bot) - Full MCP Support # docker compose -f docker/docker-compose.full.yml --profile gateway up # ───────────────────────────────────────────── picoclaw-gateway: build: context: .. dockerfile: docker/Dockerfile.full container_name: picoclaw-gateway-full restart: unless-stopped profiles: - gateway volumes: # Configuration file - ../config/config.json:/root/.picoclaw/config.json:ro # Persistent workspace (sessions, memory, logs) - picoclaw-workspace:/root/.picoclaw/workspace # NPM cache for faster MCP server installs - picoclaw-npm-cache:/root/.npm command: ["gateway"] volumes: picoclaw-workspace: picoclaw-npm-cache: # Cache npm packages to speed up MCP server installations ================================================ FILE: docker/docker-compose.yml ================================================ services: # ───────────────────────────────────────────── # PicoClaw Agent (one-shot query) # docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Hello" # ───────────────────────────────────────────── picoclaw-agent: image: docker.io/sipeed/picoclaw:latest container_name: picoclaw-agent profiles: - agent # Uncomment to access host network; leave commented unless needed. #extra_hosts: # - "host.docker.internal:host-gateway" volumes: - ./data:/root/.picoclaw entrypoint: ["picoclaw", "agent"] stdin_open: true tty: true # ───────────────────────────────────────────── # PicoClaw Gateway (Long-running Bot) # docker compose -f docker/docker-compose.yml --profile gateway up # ───────────────────────────────────────────── picoclaw-gateway: image: docker.io/sipeed/picoclaw:latest container_name: picoclaw-gateway restart: on-failure profiles: - gateway # Uncomment to access host network; leave commented unless needed. #extra_hosts: # - "host.docker.internal:host-gateway" volumes: - ./data:/root/.picoclaw # ───────────────────────────────────────────── # PicoClaw Launcher (Web Console + Gateway) # docker compose -f docker/docker-compose.yml --profile launcher up # ───────────────────────────────────────────── picoclaw-launcher: image: docker.io/sipeed/picoclaw:launcher container_name: picoclaw-launcher restart: on-failure profiles: - launcher environment: - PICOCLAW_GATEWAY_HOST=0.0.0.0 ports: - "127.0.0.1:18800:18800" - "127.0.0.1:18790:18790" volumes: - ./data:/root/.picoclaw ================================================ FILE: docker/entrypoint.sh ================================================ #!/bin/sh set -e # First-run: neither config nor workspace exists. # If config.json is already mounted but workspace is missing we skip onboard to # avoid the interactive "Overwrite? (y/n)" prompt hanging in a non-TTY container. if [ ! -d "${HOME}/.picoclaw/workspace" ] && [ ! -f "${HOME}/.picoclaw/config.json" ]; then picoclaw onboard echo "" echo "First-run setup complete." echo "Edit ${HOME}/.picoclaw/config.json (add your API key, etc.) then restart the container." exit 0 fi exec picoclaw gateway "$@" ================================================ FILE: docs/ANTIGRAVITY_AUTH.md ================================================ # Antigravity Authentication & Integration Guide ## Overview **Antigravity** (Google Cloud Code Assist) is a Google-backed AI model provider that offers access to models like Claude Opus 4.6 and Gemini through Google's Cloud infrastructure. This document provides a complete guide on how authentication works, how to fetch models, and how to implement a new provider in PicoClaw. --- ## Table of Contents 1. [Authentication Flow](#authentication-flow) 2. [OAuth Implementation Details](#oauth-implementation-details) 3. [Token Management](#token-management) 4. [Models List Fetching](#models-list-fetching) 5. [Usage Tracking](#usage-tracking) 6. [Provider Plugin Structure](#provider-plugin-structure) 7. [Integration Requirements](#integration-requirements) 8. [API Endpoints](#api-endpoints) 9. [Configuration](#configuration) 10. [Creating a New Provider in PicoClaw](#creating-a-new-provider-in-picoclaw) --- ## Authentication Flow ### 1. OAuth 2.0 with PKCE Antigravity uses **OAuth 2.0 with PKCE (Proof Key for Code Exchange)** for secure authentication: ``` ┌─────────────┐ ┌─────────────────┐ │ Client │ ───(1) Generate PKCE Pair────────> │ │ │ │ ───(2) Open Auth URL─────────────> │ Google OAuth │ │ │ │ Server │ │ │ <──(3) Redirect with Code───────── │ │ │ │ └─────────────────┘ │ │ ───(4) Exchange Code for Tokens──> │ Token URL │ │ │ │ │ │ │ <──(5) Access + Refresh Tokens──── │ │ └─────────────┘ └─────────────────┘ ``` ### 2. Detailed Steps #### Step 1: Generate PKCE Parameters ```typescript function generatePkce(): { verifier: string; challenge: string } { const verifier = randomBytes(32).toString("hex"); const challenge = createHash("sha256").update(verifier).digest("base64url"); return { verifier, challenge }; } ``` #### Step 2: Build Authorization URL ```typescript const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; const REDIRECT_URI = "http://localhost:51121/oauth-callback"; function buildAuthUrl(params: { challenge: string; state: string }): string { const url = new URL(AUTH_URL); url.searchParams.set("client_id", CLIENT_ID); url.searchParams.set("response_type", "code"); url.searchParams.set("redirect_uri", REDIRECT_URI); url.searchParams.set("scope", SCOPES.join(" ")); url.searchParams.set("code_challenge", params.challenge); url.searchParams.set("code_challenge_method", "S256"); url.searchParams.set("state", params.state); url.searchParams.set("access_type", "offline"); url.searchParams.set("prompt", "consent"); return url.toString(); } ``` **Required Scopes:** ```typescript const SCOPES = [ "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/cclog", "https://www.googleapis.com/auth/experimentsandconfigs", ]; ``` #### Step 3: Handle OAuth Callback **Automatic Mode (Local Development):** - Start a local HTTP server on port 51121 - Wait for the redirect from Google - Extract the authorization code from the query parameters **Manual Mode (Remote/Headless):** - Display the authorization URL to the user - User completes authentication in their browser - User pastes the full redirect URL back into the terminal - Parse the code from the pasted URL #### Step 4: Exchange Code for Tokens ```typescript const TOKEN_URL = "https://oauth2.googleapis.com/token"; async function exchangeCode(params: { code: string; verifier: string; }): Promise<{ access: string; refresh: string; expires: number }> { const response = await fetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code: params.code, grant_type: "authorization_code", redirect_uri: REDIRECT_URI, code_verifier: params.verifier, }), }); const data = await response.json(); return { access: data.access_token, refresh: data.refresh_token, expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, // 5 min buffer }; } ``` #### Step 5: Fetch Additional User Data **User Email:** ```typescript async function fetchUserEmail(accessToken: string): Promise { const response = await fetch( "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", { headers: { Authorization: `Bearer ${accessToken}` } } ); const data = await response.json(); return data.email; } ``` **Project ID (Required for API calls):** ```typescript async function fetchProjectId(accessToken: string): Promise { const headers = { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "User-Agent": "google-api-nodejs-client/9.15.1", "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", "Client-Metadata": JSON.stringify({ ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI", }), }; const response = await fetch( "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", { method: "POST", headers, body: JSON.stringify({ metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI", }, }), } ); const data = await response.json(); return data.cloudaicompanionProject || "rising-fact-p41fc"; // Default fallback } ``` --- ## OAuth Implementation Details ### Client Credentials **Important:** These are base64-encoded in the source code for sync with pi-ai: ```typescript const decode = (s: string) => Buffer.from(s, "base64").toString(); const CLIENT_ID = decode( "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==" ); const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY="); ``` ### OAuth Flow Modes 1. **Automatic Flow** (Local machines with browser): - Opens browser automatically - Local callback server captures redirect - No user interaction required after initial auth 2. **Manual Flow** (Remote/headless/WSL2): - URL displayed for manual copy-paste - User completes auth in external browser - User pastes full redirect URL back ```typescript function shouldUseManualOAuthFlow(isRemote: boolean): boolean { return isRemote || isWSL2Sync(); } ``` --- ## Token Management ### Auth Profile Structure ```typescript type OAuthCredential = { type: "oauth"; provider: "google-antigravity"; access: string; // Access token refresh: string; // Refresh token expires: number; // Expiration timestamp (ms since epoch) email?: string; // User email projectId?: string; // Google Cloud project ID }; ``` ### Token Refresh The credential includes a refresh token that can be used to obtain new access tokens when the current one expires. The expiration is set with a 5-minute buffer to prevent race conditions. --- ## Models List Fetching ### Fetch Available Models ```typescript const BASE_URL = "https://cloudcode-pa.googleapis.com"; async function fetchAvailableModels( accessToken: string, projectId: string ): Promise { const headers = { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "User-Agent": "antigravity", "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", }; const response = await fetch( `${BASE_URL}/v1internal:fetchAvailableModels`, { method: "POST", headers, body: JSON.stringify({ project: projectId }), } ); const data = await response.json(); // Returns models with quota information return Object.entries(data.models).map(([modelId, modelInfo]) => ({ id: modelId, displayName: modelInfo.displayName, quotaInfo: { remainingFraction: modelInfo.quotaInfo?.remainingFraction, resetTime: modelInfo.quotaInfo?.resetTime, isExhausted: modelInfo.quotaInfo?.isExhausted, }, })); } ``` ### Response Format ```typescript type FetchAvailableModelsResponse = { models?: Record; }; ``` --- ## Usage Tracking ### Fetch Usage Data ```typescript export async function fetchAntigravityUsage( token: string, timeoutMs: number ): Promise { // 1. Fetch credits and plan info const loadCodeAssistRes = await fetch( `${BASE_URL}/v1internal:loadCodeAssist`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ metadata: { ideType: "ANTIGRAVITY", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI", }, }), } ); // Extract credits info const { availablePromptCredits, planInfo, currentTier } = data; // 2. Fetch model quotas const modelsRes = await fetch( `${BASE_URL}/v1internal:fetchAvailableModels`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, body: JSON.stringify({ project: projectId }), } ); // Build usage windows return { provider: "google-antigravity", displayName: "Google Antigravity", windows: [ { label: "Credits", usedPercent: calculateUsedPercent(available, monthly) }, // Individual model quotas... ], plan: currentTier?.name || planType, }; } ``` ### Usage Response Structure ```typescript type ProviderUsageSnapshot = { provider: "google-antigravity"; displayName: string; windows: UsageWindow[]; plan?: string; error?: string; }; type UsageWindow = { label: string; // "Credits" or model ID usedPercent: number; // 0-100 resetAt?: number; // Timestamp when quota resets }; ``` --- ## Provider Plugin Structure ### Plugin Definition ```typescript const antigravityPlugin = { id: "google-antigravity-auth", name: "Google Antigravity Auth", description: "OAuth flow for Google Antigravity (Cloud Code Assist)", configSchema: emptyPluginConfigSchema(), register(api: PicoClawPluginApi) { api.registerProvider({ id: "google-antigravity", label: "Google Antigravity", docsPath: "/providers/models", aliases: ["antigravity"], auth: [ { id: "oauth", label: "Google OAuth", hint: "PKCE + localhost callback", kind: "oauth", run: async (ctx: ProviderAuthContext) => { // OAuth implementation here }, }, ], }); }, }; ``` ### ProviderAuthContext ```typescript type ProviderAuthContext = { config: PicoClawConfig; agentDir?: string; workspaceDir?: string; prompter: WizardPrompter; // UI prompts/notifications runtime: RuntimeEnv; // Logging, etc. isRemote: boolean; // Whether running remotely openUrl: (url: string) => Promise; // Browser opener oauth: { createVpsAwareHandlers: Function; }; }; ``` ### ProviderAuthResult ```typescript type ProviderAuthResult = { profiles: Array<{ profileId: string; credential: AuthProfileCredential; }>; configPatch?: Partial; defaultModel?: string; notes?: string[]; }; ``` --- ## Integration Requirements ### 1. Required Environment/Dependencies - Go ≥ 1.21 - PicoClaw codebase (`pkg/providers/` and `pkg/auth/`) - `crypto` and `net/http` standard library packages ### 2. Required Headers for API Calls ```typescript const REQUIRED_HEADERS = { "Authorization": `Bearer ${accessToken}`, "Content-Type": "application/json", "User-Agent": "antigravity", // or "google-api-nodejs-client/9.15.1" "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", }; // For loadCodeAssist calls, also include: const CLIENT_METADATA = { ideType: "ANTIGRAVITY", // or "IDE_UNSPECIFIED" platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI", }; ``` ### 3. Model Schema Sanitization Antigravity uses Gemini-compatible models, so tool schemas must be sanitized: ```typescript const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([ "patternProperties", "additionalProperties", "$schema", "$id", "$ref", "$defs", "definitions", "examples", "minLength", "maxLength", "minimum", "maximum", "multipleOf", "pattern", "format", "minItems", "maxItems", "uniqueItems", "minProperties", "maxProperties", ]); // Clean schema before sending function cleanToolSchemaForGemini(schema: Record): unknown { // Remove unsupported keywords // Ensure top-level has type: "object" // Flatten anyOf/oneOf unions } ``` ### 4. Thinking Block Handling (Claude Models) For Antigravity Claude models, thinking blocks require special handling: ```typescript const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/; export function sanitizeAntigravityThinkingBlocks( messages: AgentMessage[] ): AgentMessage[] { // Validate thinking signatures // Normalize signature fields // Discard unsigned thinking blocks } ``` --- ## API Endpoints ### Authentication Endpoints | Endpoint | Method | Purpose | |----------|--------|---------| | `https://accounts.google.com/o/oauth2/v2/auth` | GET | OAuth authorization | | `https://oauth2.googleapis.com/token` | POST | Token exchange | | `https://www.googleapis.com/oauth2/v1/userinfo` | GET | User info (email) | ### Cloud Code Assist Endpoints | Endpoint | Method | Purpose | |----------|--------|---------| | `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | POST | Load project info, credits, plan | | `https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` | POST | List available models with quotas | | `https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse` | POST | Chat streaming endpoint | **API Request Format (Chat):** The `v1internal:streamGenerateContent` endpoint expects an envelope wrapping the standard Gemini request: ```json { "project": "your-project-id", "model": "model-id", "request": { "contents": [...], "systemInstruction": {...}, "generationConfig": {...}, "tools": [...] }, "requestType": "agent", "userAgent": "antigravity", "requestId": "agent-timestamp-random" } ``` **API Response Format (SSE):** Each SSE message (`data: {...}`) is wrapped in a `response` field: ```json { "response": { "candidates": [...], "usageMetadata": {...}, "modelVersion": "...", "responseId": "..." }, "traceId": "...", "metadata": {} } ``` --- ## Configuration ### config.json Configuration ```json { "model_list": [ { "model_name": "gemini-flash", "model": "antigravity/gemini-3-flash", "auth_method": "oauth" } ], "agents": { "defaults": { "model": "gemini-flash" } } } ``` ### Auth Profile Storage Auth profiles are stored in `~/.picoclaw/auth.json`: ```json { "credentials": { "google-antigravity": { "access_token": "ya29...", "refresh_token": "1//...", "expires_at": "2026-01-01T00:00:00Z", "provider": "google-antigravity", "auth_method": "oauth", "email": "user@example.com", "project_id": "my-project-id" } } } ``` --- ## Creating a New Provider in PicoClaw PicoClaw providers are implemented as Go packages under `pkg/providers/`. To add a new provider: ### Step-by-Step Implementation #### 1. Create Provider File Create a new Go file in `pkg/providers/`: ``` pkg/providers/ └── your_provider.go ``` #### 2. Implement the Provider Interface Your provider must implement the `Provider` interface defined in `pkg/providers/types.go`: ```go package providers type YourProvider struct { apiKey string apiBase string } func NewYourProvider(apiKey, apiBase, proxy string) *YourProvider { if apiBase == "" { apiBase = "https://api.your-provider.com/v1" } return &YourProvider{apiKey: apiKey, apiBase: apiBase} } func (p *YourProvider) Chat(ctx context.Context, messages []Message, tools []Tool, cb StreamCallback) error { // Implement chat completion with streaming } ``` #### 3. Register in the Factory Add your provider to the protocol switch in `pkg/providers/factory.go`: ```go case "your-provider": return NewYourProvider(sel.apiKey, sel.apiBase, sel.proxy), nil ``` #### 4. Add Default Config (Optional) Add a default entry in `pkg/config/defaults.go`: ```go { ModelName: "your-model", Model: "your-provider/model-name", APIKey: "", }, ``` #### 5. Add Auth Support (Optional) If your provider requires OAuth or special authentication, add a case to `cmd/picoclaw/cmd_auth.go`: ```go case "your-provider": authLoginYourProvider() ``` #### 6. Configure via `config.json` ```json { "model_list": [ { "model_name": "your-model", "model": "your-provider/model-name", "api_key": "your-api-key", "api_base": "https://api.your-provider.com/v1" } ] } ``` --- ## Testing Your Implementation ### CLI Commands ```bash # Authenticate with a provider picoclaw auth login --provider your-provider # List models (for Antigravity) picoclaw auth models # Start the gateway picoclaw gateway # Run an agent with a specific model picoclaw agent -m "Hello" --model your-model ``` ### Environment Variables for Testing ```bash # Override default model export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model # Override provider settings export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]' ``` --- ## References - **Source Files:** - `pkg/providers/antigravity_provider.go` - Antigravity provider implementation - `pkg/auth/oauth.go` - OAuth flow implementation - `pkg/auth/store.go` - Auth credential storage (`~/.picoclaw/auth.json`) - `pkg/providers/factory.go` - Provider factory and protocol routing - `pkg/providers/types.go` - Provider interface definitions - `cmd/picoclaw/cmd_auth.go` - Auth CLI commands - **Documentation:** - `docs/ANTIGRAVITY_USAGE.md` - Antigravity usage guide - `docs/migration/model-list-migration.md` - Migration guide --- ## Notes 1. **Google Cloud Project:** Antigravity requires Gemini for Google Cloud to be enabled on your Google Cloud project 2. **Quotas:** Uses Google Cloud project quotas (not separate billing) 3. **Model Access:** Available models depend on your Google Cloud project configuration 4. **Thinking Blocks:** Claude models via Antigravity require special handling of thinking blocks with signatures 5. **Schema Sanitization:** Tool schemas must be sanitized to remove unsupported JSON Schema keywords --- --- ## Common Error Handling ### 1. Rate Limiting (HTTP 429) Antigravity returns a 429 error when project/model quotas are exhausted. The error response often contains a `quotaResetDelay` in the `details` field. **Example 429 Error:** ```json { "error": { "code": 429, "message": "You have exhausted your capacity on this model. Your quota will reset after 4h30m28s.", "status": "RESOURCE_EXHAUSTED", "details": [ { "@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": { "quotaResetDelay": "4h30m28.060903746s" } } ] } } ``` ### 2. Empty Responses (Restricted Models) Some models might show up in the available models list but return an empty response (200 OK but empty SSE stream). This usually happens for preview or restricted models that the current project doesn't have permission to use. **Treatment:** Treat empty responses as errors informing the user that the model might be restricted or invalid for their project. --- ## Troubleshooting ### "Token expired" - Refresh OAuth tokens: `picoclaw auth login --provider antigravity` ### "Gemini for Google Cloud is not enabled" - Enable the API in your Google Cloud Console ### "Project not found" - Ensure your Google Cloud project has the necessary APIs enabled - Check that the project ID is correctly fetched during authentication ### Models not appearing in list - Verify OAuth authentication completed successfully - Check auth profile storage: `~/.picoclaw/auth.json` - Re-run `picoclaw auth login --provider antigravity` ================================================ FILE: docs/ANTIGRAVITY_USAGE.md ================================================ # Using Antigravity Provider in PicoClaw This guide explains how to set up and use the **Antigravity** (Google Cloud Code Assist) provider in PicoClaw. ## Prerequisites 1. A Google account. 2. Google Cloud Code Assist enabled (usually available via the "Gemini for Google Cloud" onboarding). ## 1. Authentication To authenticate with Antigravity, run the following command: ```bash picoclaw auth login --provider antigravity ``` ### Manual Authentication (Headless/VPS) If you are running on a server (Coolify/Docker) and cannot reach `localhost`, follow these steps: 1. Run the command above. 2. Copy the URL provided and open it in your local browser. 3. Complete the login. 4. Your browser will redirect to a `localhost:51121` URL (which will fail to load). 5. **Copy that final URL** from your browser's address bar. 6. **Paste it back into the terminal** where PicoClaw is waiting. PicoClaw will extract the authorization code and complete the process automatically. ## 2. Managing Models ### List Available Models To see which models your project has access to and check their quotas: ```bash picoclaw auth models ``` ### Switch Models You can change the default model in `~/.picoclaw/config.json` or override it via the CLI: ```bash # Override for a single command picoclaw agent -m "Hello" --model claude-opus-4-6-thinking ``` ## 3. Real-world Usage (Coolify/Docker) If you are deploying via Coolify or Docker, follow these steps to test: 1. **Environment Variables**: * `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-flash` 2. **Authentication persistence**: If you've logged in locally, you can copy your credentials to the server: ```bash scp ~/.picoclaw/auth.json user@your-server:~/.picoclaw/ ``` *Alternatively*, run the `auth login` command once on the server if you have terminal access. ## 4. Troubleshooting * **Empty Response**: If a model returns an empty reply, it may be restricted for your project. Try `gemini-3-flash` or `claude-opus-4-6-thinking`. * **429 Rate Limit**: Antigravity has strict quotas. PicoClaw will display the "reset time" in the error message if you hit a limit. * **404 Not Found**: Ensure you are using a model ID from the `picoclaw auth models` list. Use the short ID (e.g., `gemini-3-flash`) not the full path. ## 5. Summary of Working Models Based on testing, the following models are most reliable: * `gemini-3-flash` (Fast, highly available) * `gemini-2.5-flash-lite` (Lightweight) * `claude-opus-4-6-thinking` (Powerful, includes reasoning) ================================================ FILE: docs/agent-refactor/README.md ================================================ # Agent Refactor ## What this directory is for This directory is the working area for the current Agent refactor. The purpose of this refactor is simple: the project needs a smaller, clearer, and more stable Agent model before more Agent-related behavior is added. The codebase already contains meaningful Agent behavior. What it still lacks is a sufficiently explicit and stable semantic boundary around that behavior. This refactor exists to fix that first. --- ## Refactor stance This is a maintenance-led consolidation effort. It is not a general invitation to expand Agent behavior in parallel. During this refactor window, Agent-related work should converge on the current refactor track instead of branching into new semantics. That means: - concept clarification before feature expansion - boundary tightening before abstraction growth - semantic consolidation before new behavior --- ## Core rule: minimum concepts only This refactor follows one hard rule: **do not introduce a new concept unless it is strictly necessary** More explicitly: - if an existing concept can be clarified, reuse it - if an existing boundary can be made explicit, do that first - if a behavior can be expressed without a new abstraction, do not add one - "future flexibility" is not enough justification on its own The goal of this refactor is not to grow the model. The goal is to reduce ambiguity. --- ## What is being clarified This refactor is currently concerned with the following questions: 1. what an `Agent` is 2. what an `AgentLoop` is 3. what the lifecycle of `AgentLoop` is 4. what the event surface around `AgentLoop` is 5. how persona / identity is assembled 6. how capabilities are represented 7. how context boundaries and compression work 8. how subagent coordination works These are the current working boundaries. If they need to be adjusted, they should be adjusted explicitly rather than drift implicitly in code. --- ## Status of this directory The documents here are working materials. They are not final or immutable. If current notes are incomplete, incorrectly split, or too broad, they should be revised. This directory should evolve with the refactor rather than pretending the first draft is complete. --- ## Suggested document split This directory may eventually contain notes such as: - `agent-overview.md` - what an Agent is - `agent-loop.md` - AgentLoop contract, lifecycle, event surface - `persona.md` - persona and identity assembly - `capability.md` - tools / skills / MCP capability semantics - `context.md` - context scope, history, summary, compression - `subagent.md` - subagent coordination rules These files should be added only when they help clarify the current refactor work. This directory should not turn into a generic architecture dump. --- ## What this directory is not for This directory is not intended for: - broad speculative architecture - future multi-node protocol design not required by the current refactor - parallel feature planning unrelated to Agent consolidation - adding new concepts before current ones are made clear If a topic does not directly help reduce ambiguity in the current Agent model, it probably does not belong here yet. --- ## Relationship to implementation Implementation changes should not keep redefining Agent semantics implicitly. If a PR changes or depends on Agent semantics, those semantics should either already exist here or be clarified in a linked issue first. This directory is here to make implementation narrower and more disciplined. --- ## Relationship to GitHub tracking The umbrella issue for this refactor should point here. The issue is the coordination surface. This directory is the repository-local working surface. --- ## Summary The main question of this refactor is not: - what more can Agent do The main question is: - what is the smallest stable model that current Agent behavior can be organized around ================================================ FILE: docs/channels/dingtalk/README.zh.md ================================================ # 钉钉 钉钉是阿里巴巴的企业通讯平台,在中国职场中广受欢迎。它采用流式 SDK 来维持持久连接。 ## 配置 ```json { "channels": { "dingtalk": { "enabled": true, "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "allow_from": [] } } } ``` | 字段 | 类型 | 必填 | 描述 | | ------------- | ------ | ---- | -------------------------------- | | enabled | bool | 是 | 是否启用钉钉频道 | | client_id | string | 是 | 钉钉应用的 Client ID | | client_secret | string | 是 | 钉钉应用的 Client Secret | | allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | ## 设置流程 1. 前往 [钉钉开放平台](https://open.dingtalk.com/) 2. 创建一个企业内部应用 3. 从应用设置中获取 Client ID 和 Client Secret 4. 配置OAuth和事件订阅(如需要) 5. 将 Client ID 和 Client Secret 填入配置文件中 ================================================ FILE: docs/channels/discord/README.zh.md ================================================ # Discord Discord 是一个专为社区设计的免费语音、视频和文本聊天应用。PicoClaw 通过 Discord Bot API 连接到 Discord 服务器,支持接收和发送消息。 ## 配置 ```json { "channels": { "discord": { "enabled": true, "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"], "group_trigger": { "mention_only": false } } } } ``` | 字段 | 类型 | 必填 | 描述 | | ------------ | ------ | ---- | -------------------------------- | | enabled | bool | 是 | 是否启用 Discord 频道 | | token | string | 是 | Discord 机器人 Token | | allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | | group_trigger | object | 否 | 群组触发设置(示例: { "mention_only": false }) | ## 设置流程 1. 前往 [Discord 开发者门户](https://discord.com/developers/applications) 创建一个新的应用 2. 启用 Intents: - Message Content Intent - Server Members Intent 3. 获取 Bot Token 4. 将 Bot Token 填入配置文件中 5. 邀请机器人加入服务器并授予必要权限(例如发送消息、读取消息历史等) ================================================ FILE: docs/channels/feishu/README.zh.md ================================================ # 飞书 飞书(国际版名称:Lark)是字节跳动旗下的企业协作平台。它通过事件驱动的 Webhook 同时支持中国和全球市场。 ## 配置 ```json { "channels": { "feishu": { "enabled": true, "app_id": "cli_xxx", "app_secret": "xxx", "encrypt_key": "", "verification_token": "", "allow_from": [], "is_lark": false } } } ``` | 字段 | 类型 | 必填 | 描述 | | --------------------- | ------ | ---- | ------------------------------------------------------------------------------------------------ | | enabled | bool | 是 | 是否启用飞书频道 | | app_id | string | 是 | 飞书应用的 App ID(以cli\_开头) | | app_secret | string | 是 | 飞书应用的 App Secret | | encrypt_key | string | 否 | 事件回调加密密钥 | | verification_token | string | 否 | 用于Webhook事件验证的Token | | allow_from | array | 否 | 用户ID白名单,空表示所有用户 | | random_reaction_emoji | array | 否 | 随机添加的表情列表,空则使用默认 "Pin" | | is_lark | bool | 否 | 是否使用 Lark 国际版域名(`open.larksuite.com`),默认为 `false`(使用飞书域名 `open.feishu.cn`) | ## 设置流程 1. 前往 [飞书开放平台](https://open.feishu.cn/)(国际版用户请前往 [Lark 开放平台](https://open.larksuite.com/))创建应用程序 2. 获取 App ID 和 App Secret 3. 配置事件订阅和Webhook URL 4. 设置加密(可选,生产环境建议启用) 5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中 6. 自定义你希望 PicoClaw react 你消息时的表情(可选, Reference URL: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)) ================================================ FILE: docs/channels/line/README.zh.md ================================================ # Line PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的支持。 ## 配置 ```json { "channels": { "line": { "enabled": true, "channel_secret": "YOUR_CHANNEL_SECRET", "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", "webhook_path": "/webhook/line", "allow_from": [] } } } ``` | 字段 | 类型 | 必填 | 描述 | | -------------------- | ------ | ---- | ------------------------------------------ | | enabled | bool | 是 | 是否启用 LINE Channel | | channel_secret | string | 是 | LINE Messaging API 的 Channel Secret | | channel_access_token | string | 是 | LINE Messaging API 的 Channel Access Token | | webhook_path | string | 否 | Webhook 的路径 (默认为 /webhook/line) | | allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | ## 设置流程 1. 前往 [LINE Developers Console](https://developers.line.biz/console/) 创建一个服务提供商和一个 Messaging API Channel 2. 获取 Channel Secret 和 Channel Access Token 3. 配置Webhook: - LINE 要求 Webhook 必须使用 HTTPS 协议,因此需要部署一个支持 HTTPS 的服务器,或者使用反向代理工具如 ngrok 将本地服务器暴露到公网 - PicoClaw 现在使用共享的 Gateway HTTP 服务器来接收所有渠道的 webhook 回调,默认监听地址为 127.0.0.1:18790 - 将 Webhook URL 设置为 `https://your-domain.com/webhook/line`,然后将外部域名反向代理到本机的 Gateway(默认端口 18790) - 启用 Webhook 并验证 URL 4. 将 Channel Secret 和 Channel Access Token 填入配置文件中 ================================================ FILE: docs/channels/maixcam/README.zh.md ================================================ # MaixCam MaixCam 是专用于连接矽速科技 MaixCAM 与 MaixCAM2 AI 摄像设备的通道。它采用 TCP 套接字实现双向通信,支持边缘 AI 部署场景。 ## 配置 ```json { "channels": { "maixcam": { "enabled": true, "server_address": "0.0.0.0:8899", "allow_from": [] } } } ``` | 字段 | 类型 | 必填 | 描述 | | -------------- | ------ | ---- | -------------------------------- | | enabled | bool | 是 | 是否启用 MaixCam 频道 | | server_address | string | 是 | TCP 服务器监听地址和端口 | | allow_from | array | 否 | 设备ID白名单,空表示允许所有设备 | ## 使用场景 MaixCam 通道使 PicoClaw 能够作为边缘设备的 AI 后端运行: - **智能监控** :MaixCAM 发送图像帧,PicoClaw 通过视觉模型进行分析 - **物联网控制** :设备发送传感器数据,PicoClaw 协调响应 - **离线AI** :在本地网络部署 PicoClaw 实现低延迟推理 ================================================ FILE: docs/channels/matrix/README.md ================================================ # Matrix Channel Configuration Guide ## 1. Example Configuration Add this to `config.json`: ```json { "channels": { "matrix": { "enabled": true, "homeserver": "https://matrix.org", "user_id": "@your-bot:matrix.org", "access_token": "YOUR_MATRIX_ACCESS_TOKEN", "device_id": "", "join_on_invite": true, "allow_from": [], "group_trigger": { "mention_only": true }, "placeholder": { "enabled": true, "text": "Thinking..." }, "reasoning_channel_id": "", "message_format": "richtext" } } } ``` ## 2. Field Reference | Field | Type | Required | Description | |----------------------|----------|----------|-------------| | enabled | bool | Yes | Enable or disable the Matrix channel | | homeserver | string | Yes | Matrix homeserver URL (for example `https://matrix.org`) | | user_id | string | Yes | Bot Matrix user ID (for example `@bot:matrix.org`) | | access_token | string | Yes | Bot access token | | device_id | string | No | Optional Matrix device ID | | join_on_invite | bool | No | Auto-join invited rooms | | allow_from | []string | No | User whitelist (Matrix user IDs) | | group_trigger | object | No | Group trigger strategy (`mention_only` / `prefixes`) | | placeholder | object | No | Placeholder message config | | reasoning_channel_id | string | No | Target channel for reasoning output | | message_format | string | No | Output format: `"richtext"` (default) renders markdown as HTML; `"plain"` sends plain text only | ## 3. Currently Supported - Text message send/receive with markdown rendering (bold, italic, headers, code blocks, etc.) - Configurable message format (`richtext` / `plain`) - Incoming image/audio/video/file download (MediaStore first, local path fallback) - Incoming audio normalization into existing transcription flow (`[audio: ...]`) - Outgoing image/audio/video/file upload and send - Group trigger rules (including mention-only mode) - Typing state (`m.typing`) - Placeholder message + final reply replacement - Auto-join invited rooms (can be disabled) ## 4. TODO - Rich media metadata improvements (for example image/video size and thumbnails) ================================================ FILE: docs/channels/matrix/README.zh.md ================================================ # Matrix 通道配置指南 ## 1. 配置示例 在 `config.json` 中添加: ```json { "channels": { "matrix": { "enabled": true, "homeserver": "https://matrix.org", "user_id": "@your-bot:matrix.org", "access_token": "YOUR_MATRIX_ACCESS_TOKEN", "device_id": "", "join_on_invite": true, "allow_from": [], "group_trigger": { "mention_only": true }, "placeholder": { "enabled": true, "text": "Thinking... 💭" }, "reasoning_channel_id": "" } } } ``` ## 2. 参数说明 | 字段 | 类型 | 必填 | 说明 | |----------------------|----------|------|------| | enabled | bool | 是 | 是否启用 Matrix 通道 | | homeserver | string | 是 | Matrix 服务器地址(例如 `https://matrix.org`) | | user_id | string | 是 | 机器人 Matrix 用户 ID(例如 `@bot:matrix.org`) | | access_token | string | 是 | 机器人 access token | | device_id | string | 否 | 设备 ID(可选) | | join_on_invite | bool | 否 | 是否自动加入邀请房间 | | allow_from | []string | 否 | 白名单用户(Matrix 用户 ID) | | group_trigger | object | 否 | 群聊触发策略(支持 `mention_only` / `prefixes`) | | placeholder | object | 否 | 占位消息配置 | | reasoning_channel_id | string | 否 | 思维链输出目标通道 | ## 3. 当前支持 - 文本消息收发 - 图片/音频/视频/文件消息入站下载(写入 MediaStore / 本地路径回退) - 音频消息按统一标记进入现有转写流程(`[audio: ...]`) - 图片/音频/视频/文件消息出站发送(上传到 Matrix 媒体库后发送) - 群聊触发规则(支持仅 @ 提及时响应) - Typing 状态(`m.typing`) - 占位消息(`Thinking... 💭`)+ 最终回复替换 - 自动加入邀请房间(可关闭) ## 4. TODO - 富媒体细节增强(如 image/video 的尺寸、缩略图等 metadata) ================================================ FILE: docs/channels/onebot/README.zh.md ================================================ # OneBot OneBot 是一个面向 QQ 机器人的开放协议标准,为多种 QQ 机器人实现(例如 go-cqhttp、Mirai)提供了统一的接口。它使用 WebSocket 进行通信。 ## 配置 ```json { "channels": { "onebot": { "enabled": true, "ws_url": "ws://localhost:8080", "access_token": "", "allow_from": [] } } } ``` | 字段 | 类型 | 必填 | 描述 | | ------------ | ------ | ---- | -------------------------------- | | enabled | bool | 是 | 是否启用 OneBot 频道 | | ws_url | string | 是 | OneBot 服务器的 WebSocket URL | | access_token | string | 否 | 连接 OneBot 服务器的访问令牌 | | allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | ## 设置流程 1. 部署一个 OneBot 兼容的实现(例如napcat) 2. 配置 OneBot 实现以启用 WebSocket 服务并设置访问令牌(如果需要) 3. 将 WebSocket URL 和访问令牌填入配置文件中 ================================================ FILE: docs/channels/qq/README.zh.md ================================================ # QQ PicoClaw 通过 QQ 开放平台的官方机器人 API 提供对 QQ 的支持。 ## 配置 ```json { "channels": { "qq": { "enabled": true, "app_id": "YOUR_APP_ID", "app_secret": "YOUR_APP_SECRET", "allow_from": [], "max_base64_file_size_mib": 0 } } } ``` | 字段 | 类型 | 必填 | 描述 | | -------------------- | ------ | ---- | ------------------------------------------------------------ | | enabled | bool | 是 | 是否启用 QQ Channel | | app_id | string | 是 | QQ 机器人应用的 App ID | | app_secret | string | 是 | QQ 机器人应用的 App Secret | | allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | | max_base64_file_size_mib | int | 否 | 本地文件转 base64 上传的最大体积,单位 MiB;`0` 表示不限制。仅影响本地文件,不影响 URL 直传 | ## 设置流程 1. 前往 [QQ 开放平台](https://q.qq.com/) 创建一个机器人 2. 通过仪表盘获取 App ID 和 App Secret 3. 开启机器人沙箱模式, 将用户和群添加到沙箱中 4. 将 App ID 和 App Secret 填入配置文件中 ================================================ FILE: docs/channels/slack/README.zh.md ================================================ # Slack Slack 是全球领先的企业级即时通讯平台。PicoClaw 采用 Slack 的 Socket Mode 实现实时双向通信,无需配置公开的 Webhook 端点。 ## 配置 ```json { "channels": { "slack": { "enabled": true, "bot_token": "xoxb-...", "app_token": "xapp-...", "allow_from": [] } } } ``` | 字段 | 类型 | 必填 | 描述 | | ---------- | ------ | ---- | -------------------------------------------------------- | | enabled | bool | 是 | 是否启用 Slack 频道 | | bot_token | string | 是 | Slack 机器人的 Bot User OAuth Token (以 xoxb- 开头) | | app_token | string | 是 | Slack 应用的 Socket Mode App Level Token (以 xapp- 开头) | | allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | ## 设置流程 1. 前往 [Slack API](https://api.slack.com/) 创建一个新的 Slack 应用 2. 启用 Socket Mode 并获取 App Level Token 3. 添加 Bot Token Scopes(例如`chat:write`、`im:history`等) 4. 安装应用到工作区并获取 Bot User OAuth Token 5. 将 Bot Token 和 App Token 填入配置文件中 ================================================ FILE: docs/channels/telegram/README.zh.md ================================================ # Telegram Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器人的通信。它支持文本消息、媒体附件(照片、语音、音频、文档)、通过 Groq Whisper 进行语音转录以及内置命令处理器。 ## 配置 ```json { "channels": { "telegram": { "enabled": true, "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", "allow_from": ["123456789"], "proxy": "" } } } ``` | 字段 | 类型 | 必填 | 描述 | | ---------- | ------ | ---- | --------------------------------------------------------- | | enabled | bool | 是 | 是否启用 Telegram 频道 | | token | string | 是 | Telegram 机器人 API Token | | allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | | proxy | string | 否 | 连接 Telegram API 的代理 URL (例如 http://127.0.0.1:7890) | ## 设置流程 1. 在 Telegram 中搜索 `@BotFather` 2. 发送 `/newbot` 命令并按照提示创建新机器人 3. 获取 HTTP API Token 4. 将 Token 填入配置文件中 5. (可选) 配置 `allow_from` 以限制允许互动的用户 ID (可通过 `@userinfobot` 获取 ID) ================================================ FILE: docs/channels/wecom/wecom_aibot/README.zh.md ================================================ # 企业微信智能机器人 (AI Bot) 企业微信智能机器人(AI Bot)是企业微信官方提供的 AI 对话接入方式,支持私聊与群聊,内置流式响应协议。PicoClaw 当前同时支持两种接入模式: - WebSocket 长连接模式:使用 `bot_id` + `secret`,优先级更高,推荐使用 - Webhook 短连接模式:使用 `token` + `encoding_aes_key`,兼容传统回调,并支持超时后通过 `response_url` 主动推送最终回复 ## 与其他 WeCom 通道的对比 | 特性 | WeCom Bot | WeCom App | **WeCom AI Bot** | |------|-----------|-----------|-----------------| | 私聊 | ✅ | ✅ | ✅ | | 群聊 | ✅ | ❌ | ✅ | | 流式输出 | ❌ | ❌ | ✅ | | 超时主动推送 | ❌ | ✅ | ✅ | | 配置复杂度 | 低 | 高 | 中 | ## 配置 ### WebSocket 长连接模式(推荐) ```json { "channels": { "wecom_aibot": { "enabled": true, "bot_id": "YOUR_BOT_ID", "secret": "YOUR_SECRET", "allow_from": [], "welcome_message": "你好!有什么可以帮助你的吗?", "max_steps": 10 } } } ``` ### Webhook 短连接模式 ```json { "channels": { "wecom_aibot": { "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-aibot", "allow_from": [], "welcome_message": "你好!有什么可以帮助你的吗?", "processing_message": "⏳ Processing, please wait. The results will be sent shortly.", "max_steps": 10 } } } ``` ### WebSocket 模式字段 | 字段 | 类型 | 必填 | 描述 | |--------|--------|------|--------------------------------------------| | bot_id | string | 是 | AI Bot 的唯一标识,在 AI Bot 管理页面配置 | | secret | string | 是 | AI Bot 的密钥,在 AI Bot 管理页面配置 | ### Webhook 模式字段 | 字段 | 类型 | 必填 | 描述 | |------------------|--------|------|----------------------------------------------| | token | string | 是 | 回调验证令牌,在 AI Bot 管理页面配置 | | encoding_aes_key | string | 是 | 43 字符 AES 密钥,在 AI Bot 管理页面随机生成 | | webhook_path | string | 否 | Webhook 路径,默认 `/webhook/wecom-aibot` | | processing_message | string | 否 | 流式超时后返回给用户的提示语 | ### 通用字段 | 字段 | 类型 | 必填 | 描述 | |-----------------|--------|------|------------------------------------------| | allow_from | array | 否 | 用户 ID 白名单,空数组表示允许所有用户 | | welcome_message | string | 否 | 用户进入聊天时发送的欢迎语,留空则不发送 | | reply_timeout | int | 否 | 回复超时时间(秒,默认:5) | | max_steps | int | 否 | Agent 最大执行步骤数(默认:10) | ## 模式选择 - 当 `bot_id` 和 `secret` 同时存在时,PicoClaw 会优先使用 WebSocket 长连接模式 - 否则,当 `token` 和 `encoding_aes_key` 同时存在时,PicoClaw 会使用 Webhook 短连接模式 ## 设置流程 ### WebSocket 长连接模式 1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin) 2. 进入"应用管理" → "智能机器人",创建或选择一个 AI Bot 3. 在 AI Bot 配置页面,配置 Bot 的名称、头像等信息,获取 `Bot ID` 和 `Secret` 4. 在 PicoClaw 配置文件中添加上述配置,重启 PicoClaw ### Webhook 短连接模式 1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin) 2. 进入"应用管理" → "智能机器人",创建或选择一个 AI Bot 3. 在 AI Bot 配置页面,填写"消息接收"信息: - **URL**:`http://:18791/webhook/wecom-aibot` - **Token**:随机生成或自定义 - **EncodingAESKey**:点击"随机生成",得到 43 字符密钥 4. 将 Token 和 EncodingAESKey 填入 PicoClaw 配置文件,启动服务后回到管理后台保存 > [!TIP] > 服务器需要能被企业微信服务器访问。如在内网或本地开发,可使用 [ngrok](https://ngrok.com) 或 frp 做内网穿透。 ## Webhook 模式的流式响应协议 Webhook 模式使用"流式拉取"协议,区别于普通 Webhook 的一次性回复: ``` 用户发消息 │ ▼ PicoClaw 立即返回 {finish: false}(Agent 开始处理) │ ▼ 企业微信每隔约 1 秒拉取一次 {msgtype: "stream", stream: {id: "..."}} │ ├─ Agent 未完成 → 返回 {finish: false}(继续等待) │ └─ Agent 完成 → 返回 {finish: true, content: "回答内容"} ``` **超时处理**(任务超过约 30 秒): 若 Agent 处理时间超过轮询窗口,PicoClaw 会: 1. 立即关闭流,向用户显示 `processing_message` 提示语 2. Agent 继续在后台运行 3. Agent 完成后,通过消息中携带的 `response_url` 将最终回复主动推送给用户 > `response_url` 由企业微信颁发,有效期 1 小时,只可使用一次,无需加密,直接 POST markdown 消息体即可。 ## 超时提示语 配置 `processing_message` 后,当 Webhook 模式的流式轮询超时并切换到 `response_url` 主动推送模式时,PicoClaw 会先返回这段提示语来结束当前流。 ```json "processing_message": "⏳ Processing, please wait. The results will be sent shortly." ``` ## 欢迎语 配置 `welcome_message` 后,当用户打开与 AI Bot 的聊天窗口时(`enter_chat` 事件),PicoClaw 会自动回复该欢迎语。留空则静默忽略。 ```json "welcome_message": "你好!我是 PicoClaw AI 助手,有什么可以帮你?" ``` ## 常见问题 ### WebSocket 模式无法连接 - 检查 `bot_id` 和 `secret` 是否填写正确 - 查看日志中是否有 WebSocket 连接或鉴权失败信息 - 确认服务器可以访问企业微信长连接接口 ### 回调 URL 验证失败 - 确认 `token` 与 `encoding_aes_key` 填写正确 - 确认服务器防火墙已开放对应端口 - 检查 PicoClaw 日志是否收到了来自企业微信的验证请求 ### 消息没有回复 - 检查 `allow_from` 是否意外限制了发送者 - 查看日志中是否出现 `context canceled` 或 Agent 错误 - 确认 Agent 配置(`model_name` 等)正确 ### 超长任务没有收到最终推送 - 确认消息回调中携带了 `response_url` - 确认服务器能主动访问外网 - 查看日志关键词 `response_url mode` 和 `Sending reply via response_url` ## 参考文档 - [企业微信 AI Bot 接入文档](https://developer.work.weixin.qq.com/document/path/101463) - [流式响应协议说明](https://developer.work.weixin.qq.com/document/path/100719) - [response_url 主动回复](https://developer.work.weixin.qq.com/document/path/101138) ================================================ FILE: docs/channels/wecom/wecom_app/README.zh.md ================================================ # 企业微信自建应用 企业微信自建应用是指企业在企业微信中创建的应用,主要用于企业内部使用。通过企业微信自建应用,企业可以实现与员工的高效沟通和协作,提高工作效率。 ## 配置 ```json { "channels": { "wecom_app": { "enabled": true, "corp_id": "wwxxxxxxxxxxxxxxxx", "corp_secret": "YOUR_CORP_SECRET", "agent_id": 1000002, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-app", "allow_from": [], "reply_timeout": 5 } } } ``` | 字段 | 类型 | 必填 | 描述 | | ---------------- | ------ | ---- | ---------------------------------------- | | corp_id | string | 是 | 企业 ID | | corp_secret | string | 是 | 应用程序密钥 | | agent_id | int | 是 | 应用程序代理 ID | | token | string | 是 | 回调验证令牌 | | encoding_aes_key | string | 是 | 43 字符 AES 密钥 | | webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-app) | | allow_from | array | 否 | 用户 ID 白名单 | | reply_timeout | int | 否 | 回复超时时间(秒) | ## 设置流程 1. 登录 [企业微信管理后台](https://work.weixin.qq.com/) 2. 进入“应用管理” -> “创建应用” 3. 获取企业 ID (CorpID) 和应用 Secret 4. 在应用设置中配置“接收消息”,获取 Token 和 EncodingAESKey 5. 设置回调 URL 为 `http://:/webhook/wecom-app` 6. 将 CorpID, Secret, AgentID 等信息填入配置文件 注意: PicoClaw 现在使用共享的 Gateway HTTP 服务器来接收所有渠道的 webhook 回调,默认监听地址为 127.0.0.1:18790。如需从公网接收回调,请把外部域名反向代理到 Gateway(默认端口 18790)。 ================================================ FILE: docs/channels/wecom/wecom_bot/README.zh.md ================================================ # 企业微信机器人 企业微信机器人是企业微信提供的一种快速接入方式,可以通过 Webhook URL 接收消息。 ## 配置 ```json { "channels": { "wecom": { "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", "webhook_path": "/webhook/wecom", "allow_from": [], "reply_timeout": 5 } } } ``` | 字段 | 类型 | 必填 | 描述 | | ---------------- | ------ | ---- | -------------------------------------------- | | token | string | 是 | 签名验证代币 | | encoding_aes_key | string | 是 | 用于解密的 43 字符 AES 密钥 | | webhook_url | string | 是 | 用于发送回复的企业微信群聊机器人 Webhook URL | | webhook_path | string | 否 | Webhook 端点路径(默认:/webhook/wecom) | | allow_from | array | 否 | 用户 ID 白名单(空值 = 允许所有用户) | | reply_timeout | int | 否 | 回复超时时间(单位:秒,默认值:5) | ## 设置流程 1. 在企业微信群中添加机器人 2. 获取 Webhook URL 3. (如需接收消息) 在机器人配置页面设置接收消息的 API 地址(回调地址)以及 Token 和 EncodingAESKey 4. 将相关信息填入配置文件 注意: PicoClaw 现在使用共享的 Gateway HTTP 服务器来接收所有渠道的 webhook 回调,默认监听地址为 127.0.0.1:18790。如需从公网接收回调,请把外部域名反向代理到 Gateway(默认端口 18790)。 ================================================ FILE: docs/chat-apps.md ================================================ # 💬 Chat Apps Configuration > Back to [README](../README.md) ## 💬 Chat Apps Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MaixCam, or Pico (native protocol) > **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server. | Channel | Setup | | ------------ | ---------------------------------- | | **Telegram** | Easy (just a token) | | **Discord** | Easy (bot token + intents) | | **WhatsApp** | Easy (native: QR scan; or bridge URL) | | **Matrix** | Medium (homeserver + bot access token) | | **QQ** | Easy (AppID + AppSecret) | | **DingTalk** | Medium (app credentials) | | **LINE** | Medium (credentials + webhook URL) | | **WeCom AI Bot** | Medium (Token + AES key) | | **Feishu** | Medium (App ID + Secret, WebSocket mode) | | **Slack** | Medium (Bot token + App token) | | **IRC** | Medium (server + TLS config) | | **OneBot** | Medium (QQ via OneBot protocol) | | **MaixCam** | Easy (Sipeed hardware integration) | | **Pico** | Native PicoClaw protocol |
Telegram (Recommended) **1. Create a bot** * Open Telegram, search `@BotFather` * Send `/newbot`, follow prompts * Copy the token **2. Configure** ```json { "channels": { "telegram": { "enabled": true, "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"], "use_markdown_v2": false, } } } ``` > Get your user ID from `@userinfobot` on Telegram. **3. Run** ```bash picoclaw gateway ``` **4. Telegram command menu (auto-registered at startup)** PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`) so command menu and runtime behavior stay in sync. Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor. If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background. **4. Advanced Formatting** You can set use_markdown_v2: true to enable enhanced formatting options. This allows the bot to utilize the full range of Telegram MarkdownV2 features, including nested styles, spoilers, and custom fixed-width blocks.
Discord **1. Create a bot** * Go to * Create an application → Bot → Add Bot * Copy the bot token **2. Enable intents** * In the Bot settings, enable **MESSAGE CONTENT INTENT** * (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data **3. Get your User ID** * Discord Settings → Advanced → enable **Developer Mode** * Right-click your avatar → **Copy User ID** **4. Configure** ```json { "channels": { "discord": { "enabled": true, "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } } } ``` **5. Invite the bot** * OAuth2 → URL Generator * Scopes: `bot` * Bot Permissions: `Send Messages`, `Read Message History` * Open the generated invite URL and add the bot to your server **Optional: Group trigger mode** By default the bot responds to all messages in a server channel. To restrict responses to @-mentions only, add: ```json { "channels": { "discord": { "group_trigger": { "mention_only": true } } } } ``` You can also trigger by keyword prefixes (e.g. `!bot`): ```json { "channels": { "discord": { "group_trigger": { "prefixes": ["!bot"] } } } } ``` **6. Run** ```bash picoclaw gateway ```
WhatsApp (native via whatsmeow) PicoClaw can connect to WhatsApp in two ways: - **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `"use_native": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). The native channel is **optional** to keep the default binary small; build with `-tags whatsapp_native` (e.g. `make build-whatsapp-native` or `go build -tags whatsapp_native ./cmd/...`). - **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false. **Configure (native)** ```json { "channels": { "whatsapp": { "enabled": true, "use_native": true, "session_store_path": "", "allow_from": [] } } } ``` If `session_store_path` is empty, the session is stored in `/whatsapp/`. Run `picoclaw gateway`; on first run, scan the QR code printed in the terminal with WhatsApp → Linked Devices.
QQ **1. Create a bot** - Go to [QQ Open Platform](https://q.qq.com/#) - Create an application → Get **AppID** and **AppSecret** **2. Configure** ```json { "channels": { "qq": { "enabled": true, "app_id": "YOUR_APP_ID", "app_secret": "YOUR_APP_SECRET", "allow_from": [] } } } ``` > Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access. **3. Run** ```bash picoclaw gateway ```
DingTalk **1. Create a bot** * Go to [Open Platform](https://open.dingtalk.com/) * Create an internal app * Copy Client ID and Client Secret **2. Configure** ```json { "channels": { "dingtalk": { "enabled": true, "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "allow_from": [] } } } ``` > Set `allow_from` to empty to allow all users, or specify DingTalk user IDs to restrict access. **3. Run** ```bash picoclaw gateway ```
Matrix **1. Prepare bot account** * Use your preferred homeserver (e.g. `https://matrix.org` or self-hosted) * Create a bot user and obtain its access token **2. Configure** ```json { "channels": { "matrix": { "enabled": true, "homeserver": "https://matrix.org", "user_id": "@your-bot:matrix.org", "access_token": "YOUR_MATRIX_ACCESS_TOKEN", "allow_from": [] } } } ``` **3. Run** ```bash picoclaw gateway ``` For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](docs/channels/matrix/README.md).
LINE **1. Create a LINE Official Account** - Go to [LINE Developers Console](https://developers.line.biz/) - Create a provider → Create a Messaging API channel - Copy **Channel Secret** and **Channel Access Token** **2. Configure** ```json { "channels": { "line": { "enabled": true, "channel_secret": "YOUR_CHANNEL_SECRET", "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", "webhook_path": "/webhook/line", "allow_from": [] } } } ``` > LINE webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). **3. Set up Webhook URL** LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel: ```bash # Example with ngrok (gateway default port is 18790) ngrok http 18790 ``` Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**. **4. Run** ```bash picoclaw gateway ``` > In group chats, the bot responds only when @mentioned. Replies quote the original message.
WeCom (企业微信) PicoClaw supports three types of WeCom integration: **Option 1: WeCom Bot (Bot)** - Easier setup, supports group chats **Option 2: WeCom App (Custom App)** - More features, proactive messaging, private chat only **Option 3: WeCom AI Bot (AI Bot)** - Official AI Bot, streaming replies, supports group & private chat See [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) for detailed setup instructions. **Quick Setup - WeCom Bot:** **1. Create a bot** * Go to WeCom Admin Console → Group Chat → Add Group Bot * Copy the webhook URL (format: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) **2. Configure** ```json { "channels": { "wecom": { "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", "webhook_path": "/webhook/wecom", "allow_from": [] } } } ``` > WeCom webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). **Quick Setup - WeCom App:** **1. Create an app** * Go to WeCom Admin Console → App Management → Create App * Copy **AgentId** and **Secret** * Go to "My Company" page, copy **CorpID** **2. Configure receive message** * In App details, click "Receive Message" → "Set API" * Set URL to `http://your-server:18790/webhook/wecom-app` * Generate **Token** and **EncodingAESKey** **3. Configure** ```json { "channels": { "wecom_app": { "enabled": true, "corp_id": "wwxxxxxxxxxxxxxxxx", "corp_secret": "YOUR_CORP_SECRET", "agent_id": 1000002, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-app", "allow_from": [] } } } ``` **4. Run** ```bash picoclaw gateway ``` > **Note**: WeCom webhook callbacks are served on the Gateway port (default 18790). Use a reverse proxy for HTTPS. **Quick Setup - WeCom AI Bot:** **1. Create an AI Bot** * Go to WeCom Admin Console → App Management → AI Bot * In the AI Bot settings, configure callback URL: `http://your-server:18791/webhook/wecom-aibot` * Copy **Token** and click "Random Generate" for **EncodingAESKey** **2. Configure** ```json { "channels": { "wecom_aibot": { "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-aibot", "allow_from": [], "welcome_message": "Hello! How can I help you?", "processing_message": "⏳ Processing, please wait. The results will be sent shortly." } } } ``` **3. Run** ```bash picoclaw gateway ``` > **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>30 seconds) automatically switch to `response_url` push delivery.
================================================ FILE: docs/configuration.md ================================================ # ⚙️ Configuration Guide > Back to [README](../README.md) ## ⚙️ Configuration Config file: `~/.picoclaw/config.json` ### Environment Variables You can override default paths using environment variables. This is useful for portable installations, containerized deployments, or running picoclaw as a system service. These variables are independent and control different paths. | Variable | Description | Default Path | |-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| | `PICOCLAW_CONFIG` | Overrides the path to the configuration file. This directly tells picoclaw which `config.json` to load, ignoring all other locations. | `~/.picoclaw/config.json` | | `PICOCLAW_HOME` | Overrides the root directory for picoclaw data. This changes the default location of the `workspace` and other data directories. | `~/.picoclaw` | **Examples:** ```bash # Run picoclaw using a specific config file # The workspace path will be read from within that config file PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway # Run picoclaw with all its data stored in /opt/picoclaw # Config will be loaded from the default ~/.picoclaw/config.json # Workspace will be created at /opt/picoclaw/workspace PICOCLAW_HOME=/opt/picoclaw picoclaw agent # Use both for a fully customized setup PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway ``` ### Workspace Layout PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspace`): ``` ~/.picoclaw/workspace/ ├── sessions/ # Conversation sessions and history ├── memory/ # Long-term memory (MEMORY.md) ├── state/ # Persistent state (last channel, etc.) ├── cron/ # Scheduled jobs database ├── skills/ # Custom skills ├── AGENT.md # Agent behavior guide ├── HEARTBEAT.md # Periodic task prompts (checked every 30 min) ├── IDENTITY.md # Agent identity ├── SOUL.md # Agent soul └── USER.md # User preferences ``` > **Note:** Changes to `AGENT.md`, `SOUL.md`, `USER.md` and `memory/MEMORY.md` are automatically detected at runtime via file modification time (mtime) tracking. You do **not** need to restart the gateway after editing these files — the agent picks up the new content on the next request. ### Skill Sources By default, skills are loaded from: 1. `~/.picoclaw/workspace/skills` (workspace) 2. `~/.picoclaw/skills` (global) 3. `/skills` (builtin) For advanced/test setups, you can override the builtin skills root with: ```bash export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ``` ### Unified Command Execution Policy - Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`. - Channel adapters no longer consume generic commands locally; they forward inbound text to the bus/agent path. Telegram still auto-registers supported commands at startup. - Unknown slash command (for example `/foo`) passes through to normal LLM processing. - Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing. ### Agent Bindings (Route messages to specific agents) Use `bindings` in `config.json` to route incoming messages to different agents by channel/account/context. ```json { "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "model_name": "gpt-4o-mini" }, "list": [ { "id": "main", "default": true, "name": "Main Assistant" }, { "id": "support", "name": "Support Assistant" }, { "id": "sales", "name": "Sales Assistant" } ] }, "bindings": [ { "agent_id": "support", "match": { "channel": "telegram", "account_id": "*", "peer": { "kind": "direct", "id": "user123" } } }, { "agent_id": "sales", "match": { "channel": "discord", "account_id": "my-discord-bot", "guild_id": "987654321" } } ] } ``` #### `bindings` fields | Field | Required | Description | |-------|----------|-------------| | `agent_id` | Yes | Target agent id in `agents.list` | | `match.channel` | Yes | Channel name (e.g. `telegram`, `discord`) | | `match.account_id` | No | Channel account filter. Use `"*"` for all accounts of that channel. If omitted, only default account is matched | | `match.peer.kind` + `match.peer.id` | No | Exact peer match (e.g. direct chat / topic / group id) | | `match.guild_id` | No | Guild/server-level match | | `match.team_id` | No | Team/workspace-level match | #### Matching priority When multiple bindings exist, PicoClaw resolves in this order: 1. `peer` 2. `parent_peer` (for thread/topic parent contexts) 3. `guild_id` 4. `team_id` 5. `account_id` (non-wildcard) 6. channel wildcard (`account_id: "*"`) 7. default agent If a binding points to a missing `agent_id`, PicoClaw falls back to the default agent. #### How matching works (step-by-step) 1. PicoClaw first filters bindings by `match.channel` (must equal current channel). 2. It then filters by `match.account_id`: - omitted: match only the channel's default account - `"*"`: match all accounts on this channel - explicit value: exact account id match (case-insensitive) 3. From the remaining candidates, it applies the priority chain above and stops at the first hit. In other words: **channel + account form the candidate set; peer/guild/team then decide final winner**. #### Common recipes **1) Route one specific DM user to a specialist agent** ```json { "agent_id": "support", "match": { "channel": "telegram", "account_id": "*", "peer": { "kind": "direct", "id": "user123" } } } ``` **2) Route one Discord server (guild) to a dedicated agent** ```json { "agent_id": "sales", "match": { "channel": "discord", "account_id": "my-discord-bot", "guild_id": "987654321" } } ``` **3) Route all remaining traffic of a channel to a fallback agent** ```json { "agent_id": "main", "match": { "channel": "discord", "account_id": "*" } } ``` #### Authoring guidelines (important) - Keep exactly one clear default agent in `agents.list` (`"default": true`). - Put specific rules (`peer`, `guild_id`, `team_id`) and broad rules (`account_id: "*"` only) together safely; priority already guarantees specific rules win. - Avoid duplicate rules with the same specificity and match values. If duplicates exist, the first matching entry in the config array wins. - Ensure every `agent_id` exists in `agents.list`; unknown IDs silently fall back to default. #### Troubleshooting checklist - **Rule not taking effect?** Check `match.channel` spelling first (must be exact). - **Expected account-specific routing but still using default?** Verify `match.account_id` equals actual runtime account id. - **Wildcard catches too much traffic?** Add more specific `peer/guild/team` rules for critical paths. - **Unexpected default fallback?** Confirm `agent_id` exists and is not misspelled. ### 🔒 Security Sandbox PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace. #### Default Configuration ```json { "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "restrict_to_workspace": true } } } ``` | Option | Default | Description | | ----------------------- | ----------------------- | ----------------------------------------- | | `workspace` | `~/.picoclaw/workspace` | Working directory for the agent | | `restrict_to_workspace` | `true` | Restrict file/command access to workspace | #### Protected Tools When `restrict_to_workspace: true`, the following tools are sandboxed: | Tool | Function | Restriction | | ------------- | ---------------- | -------------------------------------- | | `read_file` | Read files | Only files within workspace | | `write_file` | Write files | Only files within workspace | | `list_dir` | List directories | Only directories within workspace | | `edit_file` | Edit files | Only files within workspace | | `append_file` | Append to files | Only files within workspace | | `exec` | Execute commands | Command paths must be within workspace | #### Additional Exec Protection Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous commands: * `rm -rf`, `del /f`, `rmdir /s` — Bulk deletion * `format`, `mkfs`, `diskpart` — Disk formatting * `dd if=` — Disk imaging * Writing to `/dev/sd[a-z]` — Direct disk writes * `shutdown`, `reboot`, `poweroff` — System shutdown * Fork bomb `:(){ :|:& };:` ### File Access Control | Config Key | Type | Default | Description | |------------|------|---------|-------------| | `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace | | `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace | ### Exec Security | Config Key | Type | Default | Description | |------------|------|---------|-------------| | `tools.exec.allow_remote` | bool | `false` | Allow exec tool from remote channels (Telegram/Discord etc.) | | `tools.exec.enable_deny_patterns` | bool | `true` | Enable dangerous command interception | | `tools.exec.custom_deny_patterns` | string[] | `[]` | Custom regex patterns to block | | `tools.exec.custom_allow_patterns` | string[] | `[]` | Custom regex patterns to allow | > **Security Note:** Symlink protection is enabled by default — all file paths are resolved through `filepath.EvalSymlinks` before whitelist matching, preventing symlink escape attacks. #### Known Limitation: Child Processes From Build Tools The exec safety guard only inspects the command line PicoClaw launches directly. It does not recursively inspect child processes spawned by allowed developer tools such as `make`, `go run`, `cargo`, `npm run`, or custom build scripts. That means a top-level command can still compile or launch other binaries after it passes the initial guard check. In practice, treat build scripts, Makefiles, package scripts, and generated binaries as executable code that needs the same level of review as a direct shell command. For higher-risk environments: * Review build scripts before execution. * Prefer approval/manual review for compile-and-run workflows. * Run PicoClaw inside a container or VM if you need stronger isolation than the built-in guard provides. #### Error Examples ``` [ERROR] tool: Tool execution failed {tool=exec, error=Command blocked by safety guard (path outside working dir)} ``` ``` [ERROR] tool: Tool execution failed {tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} ``` #### Disabling Restrictions (Security Risk) If you need the agent to access paths outside the workspace: **Method 1: Config file** ```json { "agents": { "defaults": { "restrict_to_workspace": false } } } ``` **Method 2: Environment variable** ```bash export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false ``` > ⚠️ **Warning**: Disabling this restriction allows the agent to access any path on your system. Use with caution in controlled environments only. #### Security Boundary Consistency The `restrict_to_workspace` setting applies consistently across all execution paths: | Execution Path | Security Boundary | | ---------------- | ---------------------------- | | Main Agent | `restrict_to_workspace` ✅ | | Subagent / Spawn | Inherits same restriction ✅ | | Heartbeat tasks | Inherits same restriction ✅ | All paths share the same workspace restriction — there's no way to bypass the security boundary through subagents or scheduled tasks. ### Heartbeat (Periodic Tasks) PicoClaw can perform periodic tasks automatically. Create a `HEARTBEAT.md` file in your workspace: ```markdown # Periodic Tasks - Check my email for important messages - Review my calendar for upcoming events - Check the weather forecast ``` The agent will read this file every 30 minutes (configurable) and execute any tasks using available tools. #### Async Tasks with Spawn For long-running tasks (web search, API calls), use the `spawn` tool to create a **subagent**: ```markdown # Periodic Tasks ================================================ FILE: docs/credential_encryption.md ================================================ # Credential Encryption PicoClaw supports encrypting `api_key` values in `model_list` configuration entries. Encrypted keys are stored as `enc://` strings and decrypted automatically at startup. --- ## Quick Start **1. Set your passphrase** ```bash export PICOCLAW_KEY_PASSPHRASE="your-passphrase" ``` **2. Encrypt an API key** Run `picoclaw onboard` — it prompts for your passphrase and generates the SSH key, then automatically re-encrypts any plaintext `api_key` entries in your config on the next `SaveConfig` call. The resulting `enc://` value will look like: ``` enc://AAAA...base64... ``` **3. Paste the output into your config** ```json { "model_list": [ { "model_name": "gpt-4o", "api_key": "enc://AAAA...base64...", "base_url": "https://api.openai.com/v1" } ] } ``` --- ## Supported `api_key` Formats | Format | Example | Behaviour | |--------|---------|-----------| | Plaintext | `sk-abc123` | Used as-is | | File reference | `file://openai.key` | Content read from the same directory as the config file | | Encrypted | `enc://` | Decrypted at startup using `PICOCLAW_KEY_PASSPHRASE` | | Empty | `""` | Passed through unchanged (used with `auth_method: oauth`) | --- ## Cryptographic Design ### Key Derivation Encryption uses **HKDF-SHA256** with an optional SSH private key as a second factor. ``` Without SSH key (passphrase only): ikm = SHA256(passphrase) aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) With SSH key (recommended): sshHash = SHA256(ssh_private_key_file_bytes) ikm = HMAC-SHA256(key=sshHash, message=passphrase) aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) ``` ### Encryption ``` AES-256-GCM(key=aes_key, nonce=random[12], plaintext=api_key) ``` ### Wire Format ``` enc:// ``` | Field | Size | Description | |-------|------|-------------| | `salt` | 16 bytes | Random per encryption; fed into HKDF | | `nonce` | 12 bytes | Random per encryption; AES-GCM IV | | `ciphertext` | variable | AES-256-GCM ciphertext + 16-byte authentication tag | The GCM authentication tag is appended to the ciphertext automatically. Any tampering causes decryption to fail with an error rather than returning corrupt plaintext. ### Performance | Operation | Time (ARM Cortex-A) | |-----------|---------------------| | Key derivation (HKDF) | < 1 ms | | AES-256-GCM decrypt | < 1 ms | | **Total startup overhead** | **< 2 ms per key** | --- ## Two-Factor Security with SSH Key When a SSH private key is provided, breaking the encryption requires **both**: 1. The **passphrase** (`PICOCLAW_KEY_PASSPHRASE`) 2. The **SSH private key file** This means a leaked config file alone is not sufficient to recover the API key, even if the passphrase is weak. The SSH key contributes 256 bits of entropy (Ed25519) regardless of passphrase strength. ### Threat Model | Attacker Has | Can Decrypt? | |---|---| | Config file only | No — needs passphrase + SSH key | | SSH key only | No — needs passphrase | | Passphrase only | No — needs SSH key | | Config file + SSH key + passphrase | Yes — full compromise | --- ## Environment Variables | Variable | Required | Description | |----------|----------|-------------| | `PICOCLAW_KEY_PASSPHRASE` | Yes (for `enc://`) | Passphrase used for key derivation | | `PICOCLAW_SSH_KEY_PATH` | No | Path to SSH private key. Set to `""` to disable auto-detection and use passphrase-only mode | ### SSH Key Auto-Detection If `PICOCLAW_SSH_KEY_PATH` is not set, PicoClaw looks for the picoclaw-specific key: ``` ~/.ssh/picoclaw_ed25519.key ``` This dedicated file avoids conflicts with the user's existing SSH keys. Run `picoclaw onboard` to generate it automatically. `os.UserHomeDir()` is used for cross-platform home directory resolution (reads `USERPROFILE` on Windows, `HOME` on Unix/macOS). To explicitly disable SSH key usage and use passphrase-only mode: ```bash export PICOCLAW_SSH_KEY_PATH="" ``` --- ## Migration Because the only secret material is `PICOCLAW_KEY_PASSPHRASE` and the SSH private key file, migration is straightforward: 1. Copy the config file to the new machine. 2. Set `PICOCLAW_KEY_PASSPHRASE` to the same value. 3. Copy the SSH private key file to the same path (or set `PICOCLAW_SSH_KEY_PATH` to its new location). No re-encryption is needed. --- ## Security Considerations - **Passphrase strength matters in passphrase-only mode.** Without an SSH key, a weak passphrase can be brute-forced offline. Use `PICOCLAW_SSH_KEY_PATH=""` only in environments where no SSH key is available and the passphrase is sufficiently strong (≥ 32 random characters). - **The SSH key is read-only at runtime.** PicoClaw never writes to or modifies the SSH key file. - **Plaintext keys remain supported.** Existing configs without `enc://` are unaffected. - **The `enc://` format is versioned** via the HKDF `info` field (`picoclaw-credential-v1`), allowing future algorithm upgrades without breaking existing encrypted values. ================================================ FILE: docs/debug.md ================================================ # Debugging PicoClaw PicoClaw performs multiple complex interactions under the hood for every single request it receives—from routing messages and evaluating complexity, to executing tools and adapting to model failures. Being able to see exactly what is happening is crucial, not just for troubleshooting potential issues, but also for truly understanding how the agent operates. ## Starting PicoClaw in Debug Mode To get detailed information about what the agent is doing (LLM requests, tool calls, message routing), you can start the PicoClaw gateway with the debug flag: ```bash picoclaw gateway --debug # or picoclaw gateway -d ``` In this mode, the system will format the logs extensively and display previews of system prompts and tool execution results. ## Disabling Log Truncation (Full Logs) By default, PicoClaw truncates very long strings (such as the *System Prompt* or large JSON output results) in the debug logs to keep the console readable. If you need to inspect the complete output of a command or the exact payload sent to the LLM model, you can use the `--no-truncate` flag. **Note:** This flag *only* works when combined with the `--debug` mode. ```bash picoclaw gateway --debug --no-truncate ``` When this flag is active, the global truncation function is disabled. This is extremely useful for: * Verifying the exact syntax of the messages sent to the provider. * Reading the complete output of tools like `exec`, `web_fetch`, or `read_file`. * Debugging the session history saved in memory. ## Tool Call Visibility in Debug Logs When debug mode is active, the agent emits structured log entries at each stage of the tool execution lifecycle. These entries carry a `component=agent` label and use `INFO` or `DEBUG` level depending on the amount of detail: | Log message | Level | Key fields | Description | |---|---|---|---| | `LLM requested tool calls` | INFO | `tools`, `count`, `iteration` | List of tool names the model decided to call | | `Tool call: ()` | INFO | `tool`, `iteration` | The tool name and a preview of its arguments (truncated to 200 chars) | | `Sent tool result to user` | DEBUG | `tool`, `content_len` | Fired when a tool result is forwarded to the chat channel | | `TTL tick after tool execution` | DEBUG | `agent_id`, `iteration` | MCP tool-discovery TTL decrement after each tool round | | `Async tool completed, publishing result` | INFO | `tool`, `content_len`, `channel` | Only for tools that run asynchronously in the background | ### Reading a tool call log entry A typical synchronous tool call produces two consecutive lines in the console: ``` [...] [INFO] agent: LLM requested tool calls {tools=[web_search], count=1, iteration=1} [...] [INFO] agent: Tool call: web_search({"query":"picoclaw release notes"}) {tool=web_search, iteration=1} ``` The arguments preview is hard-capped at **200 characters** in the logs regardless of the `--no-truncate` flag, because it belongs to the `INFO`-level path. Use `--no-truncate` together with `--debug` to see the full `tools_json` field emitted by the `Full LLM request` DEBUG entry, which contains every tool definition sent to the model. ## Real-Time Tool Feedback in Chat (tool_feedback) Debug logs are server-side only. If you want the agent to send a visible notification directly into the chat channel every time it executes a tool—useful when sharing the bot with other users or for transparency—enable the `tool_feedback` feature in `config.json`: ```json { "agents": { "defaults": { "tool_feedback": { "enabled": true, "max_args_length": 300 } } } } ``` When `enabled` is `true`, every tool call sends a short message to the chat before the tool result is returned to the model. The message looks like: ```bash 🔧 `web_search` {"query": "picoclaw release notes"} ``` ### Options | Field | Type | Default | Description | |---|---|---|---| | `enabled` | bool | `false` | Send a chat notification for each tool call | | `max_args_length` | int | `300` | Maximum characters of the serialised arguments included in the notification | ### Environment variables Both fields can also be set via environment variables: ```bash PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED=true PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH=300 ``` > **Note:** `tool_feedback` is independent of `--debug` mode. It works in production and does not require the gateway to be started with any special flag. ================================================ FILE: docs/design/issue-783-investigation-and-fix-plan.zh.md ================================================ # Issue #783 调研与修复执行文档 ## 1. 问题澄清(已确认) - 现象:当 `agents.*.model.primary/fallbacks` 使用 `model_name` 别名(如 `step-3.5-flash`)时,fallback 链路将别名当作真实 `provider/model` 解析,导致 `provider` 可能为空、`model` 可能错误。 - 根因:`ResolveCandidates` 仅对字符串做 `ParseModelRef`,未先通过 `model_list` 将别名映射到真实 `model` 字段。 - 影响: - fallback 执行可能把别名直接发给 OpenAI-compatible provider,触发 `Unknown Model`。 - `defaults.provider` 为空时,日志出现 `provider=` 空值。 ## 2. 本次目标 - 修复 fallback 候选解析:优先通过 `model_list` 解析别名。 - 兼容旧行为:若未命中 `model_list`,继续走原有 `ParseModelRef` 兜底。 - 补充测试:覆盖别名、嵌套路径模型(如 `openrouter/stepfun/...`)、空默认 provider。 - 验证代码风格:与当前仓库风格保持一致(命名、错误处理、测试结构)。 ## 3. 联网最佳实践调研结论(已完成) - [x] 查阅 OpenAI-compatible 网关(如 OpenRouter)对 `model` 字段的推荐处理。 - [x] 查阅多 provider/fallback 设计最佳实践(候选解析、日志可观测性)。 - [x] 将外部建议映射为本仓库可执行约束。 外部参考要点(来自 OpenRouter/LiteLLM/Cloudflare AI Gateway 等官方文档): - 优先显式配置,不依赖字符串切分推断 provider。 - 对网关模型标识应保留完整路径语义,避免截断导致 Unknown Model。 - fallback 与 primary 应复用同一解析策略,避免“主路径正确、降级路径错误”。 参考链接: - OpenRouter Provider Routing: https://openrouter.ai/docs/guides/routing/provider-selection - OpenRouter Model Fallbacks: https://openrouter.ai/docs/guides/routing/model-fallbacks - OpenRouter Chat Completion API: https://openrouter.ai/docs/api-reference/chat-completion - LiteLLM Router Architecture: https://docs.litellm.ai/docs/router_architecture - Cloudflare AI Gateway Chat Completion: https://developers.cloudflare.com/ai-gateway/usage/chat-completion/ 与本仓库对应的可执行约束: - 在 fallback candidate 构建阶段先做 `model_name -> model_list.model` 映射。 - 未命中映射时保留旧解析行为,保证兼容性。 - 用新增测试锁定“别名 + 嵌套模型路径 + 空默认 provider”场景。 ## 4. 实施步骤(顺序执行) - [x] Step 1: 对齐现有代码模式,定位最小改动点(`pkg/agent` + `pkg/providers`)。 - [x] Step 2: 实现“基于 model_list 的 fallback 候选解析”。 - [x] Step 3: 增加/更新单元测试,覆盖 issue 场景。 - [x] Step 4: 代码风格一致性复核(与现有文件风格对照)。 - [x] Step 5: 运行质量门禁(LSP + `make check`)。 ## 5. 执行记录 - 状态:已完成 - 已完成改动: - `pkg/providers/fallback.go`:新增 `ResolveCandidatesWithLookup`,并保持 `ResolveCandidates` 向后兼容。 - `pkg/agent/instance.go`:在构建 fallback candidates 前,优先通过 `model_list` 解析别名,并对无协议模型补齐默认 `openai/` 前缀后再解析。 - `pkg/providers/fallback_test.go`:新增别名解析与去重测试。 - `pkg/agent/instance_test.go`:新增 agent 侧别名解析到嵌套模型路径、无协议模型解析测试。 - 风格对齐检查(完成):与 `pkg/providers/fallback_test.go`、`pkg/providers/model_ref_test.go` 现有模式一致。 - 质量验证(完成):先 `make generate`,后 `make check` 全量通过。 ================================================ FILE: docs/design/provider-refactoring-tests.md ================================================ # Provider Architecture Refactoring - Test Suite Summary This document summarizes the complete test suite designed for the Provider architecture refactoring. ## Test File Structure ``` pkg/ ├── config/ │ ├── model_config_test.go # US-001, US-002: ModelConfig struct and GetModelConfig tests │ └── migration_test.go # US-003: Backward compatibility and migration tests ├── providers/ │ ├── factory_test.go # US-004, US-005: Provider factory tests │ └── factory_provider_test.go # Factory provider integration tests ``` --- ## Test Case Checklist ### 1. `pkg/config/model_config_test.go` - Configuration Parsing Tests | Test Name | Purpose | PRD Reference | |-----------|---------|---------------| | `TestModelConfig_Parsing` | Verify ModelConfig JSON parsing | US-001 | | `TestModelConfig_ModelListInConfig` | Verify model_list parsing in Config | US-001 | | `TestModelConfig_Validation` | Verify required field validation | US-001 | | `TestConfig_GetModelConfig_Found` | Verify GetModelConfig finds model | US-002 | | `TestConfig_GetModelConfig_NotFound` | Verify GetModelConfig returns error | US-002 | | `TestConfig_GetModelConfig_EmptyModelList` | Verify empty model_list handling | US-002 | | `TestConfig_BackwardCompatibility_ProvidersToModelList` | Verify old config conversion | US-003 | | `TestConfig_DeprecationWarning` | Verify deprecation warning | US-003 | | `TestModelConfig_ProtocolExtraction` | Verify protocol prefix extraction | US-004 | | `TestConfig_ModelNameUniqueness` | Verify model_name uniqueness | US-001 | ### 2. `pkg/config/migration_test.go` - Migration Tests | Test Name | Purpose | PRD Reference | |-----------|---------|---------------| | `TestConvertProvidersToModelList_OpenAI` | OpenAI config conversion | US-003 | | `TestConvertProvidersToModelList_Anthropic` | Anthropic config conversion | US-003 | | `TestConvertProvidersToModelList_MultipleProviders` | Multiple provider conversion | US-003 | | `TestConvertProvidersToModelList_EmptyProviders` | Empty providers handling | US-003 | | `TestConvertProvidersToModelList_GitHubCopilot` | GitHub Copilot conversion | US-003 | | `TestConvertProvidersToModelList_Antigravity` | Antigravity conversion | US-003 | | `TestGenerateModelName_*` | Model name generation | US-003 | | `TestHasProvidersConfig_*` | Detect old config existence | US-003 | | `TestValidateMigration_*` | Migration validation | US-003 | | `TestMigrateConfig_DryRun` | Dry run migration | US-003 | | `TestMigrateConfig_Actual` | Actual migration | US-003 | ### 3. `pkg/providers/registry_test.go` - Load Balancing Tests | Test Name | Purpose | PRD Reference | |-----------|---------|---------------| | `TestModelRegistry_SingleConfig` | Single config returns same result | US-006 | | `TestModelRegistry_RoundRobinSelection` | 3-config round-robin selection | US-006 | | `TestModelRegistry_RoundRobinTwoConfigs` | 2-config round-robin selection | US-006 | | `TestModelRegistry_ConcurrentAccess` | Concurrent access thread safety | US-006 | | `TestModelRegistry_RaceDetection` | Data race detection | US-006 | | `TestModelRegistry_ModelNotFound` | Model not found error | US-006 | | `TestModelRegistry_EmptyRegistry` | Empty registry handling | US-006 | | `TestModelRegistry_MultipleModels` | Multiple model registration | US-006 | | `TestModelRegistry_MixedSingleAndMultiple` | Single/multiple config mix | US-006 | | `TestModelRegistry_CaseSensitiveModelNames` | Case sensitivity | US-006 | ### 4. `pkg/providers/factory/factory_test.go` - Provider Factory Tests | Test Name | Purpose | PRD Reference | |-----------|---------|---------------| | `TestCreateProviderFromConfig_OpenAI` | Create OpenAI provider | US-004 | | `TestCreateProviderFromConfig_OpenAIDefault` | Default openai protocol | US-004 | | `TestCreateProviderFromConfig_Anthropic` | Create Anthropic provider | US-004 | | `TestCreateProviderFromConfig_Antigravity` | Create Antigravity provider | US-004 | | `TestCreateProviderFromConfig_ClaudeCLI` | Create Claude CLI provider | US-004 | | `TestCreateProviderFromConfig_CodexCLI` | Create Codex CLI provider | US-004 | | `TestCreateProviderFromConfig_GitHubCopilot` | Create GitHub Copilot provider | US-004 | | `TestCreateProviderFromConfig_UnknownProtocol` | Unknown protocol error handling | US-004 | | `TestCreateProviderFromConfig_MissingAPIKey` | Missing API key error | US-004 | | `TestExtractProtocol` | Protocol prefix extraction | US-004 | | `TestCreateProvider_UsesModelList` | Create using model_list | US-005 | | `TestCreateProvider_FallbackToProviders` | Fallback to providers | US-005 | | `TestCreateProvider_PriorityModelListOverProviders` | model_list priority | US-005 | ### 5. `pkg/providers/integration_test.go` - E2E Integration Tests | Test Name | Purpose | PRD Reference | |-----------|---------|---------------| | `TestE2E_OpenAICompatibleProvider_NoCodeChange` | Zero-code provider addition | Goal | | `TestE2E_LoadBalancing_RoundRobin` | Load balancing actual effect | US-006 | | `TestE2E_BackwardCompatibility_OldProvidersConfig` | Old config compatibility | US-003 | | `TestE2E_ErrorHandling_ModelNotFound` | Model not found | FR-30 | | `TestE2E_ErrorHandling_MissingAPIKey` | Missing API key | FR-31 | | `TestE2E_ErrorHandling_InvalidAPIBase` | Invalid API base | FR-30 | | `TestE2E_ToolCalls_OpenAICompatible` | Tool call support | - | | `TestE2E_AntigravityProvider` | Antigravity provider | US-004 | | `TestE2E_ClaudeCLIProvider` | Claude CLI provider | US-004 | ### 6. Performance Tests | Test Name | Purpose | |-----------|---------| | `BenchmarkCreateProviderFromConfig` | Provider creation performance | | `BenchmarkGetModelConfig` | Model lookup performance | | `BenchmarkGetModelConfigParallel` | Concurrent lookup performance | --- ## Running Tests ```bash # Run all tests go test ./pkg/... -v # Run with data race detection go test ./pkg/... -race # Run specific package tests go test ./pkg/config -v go test ./pkg/providers -v # Run E2E tests go test ./pkg/providers -run TestE2E -v # Run performance tests go test ./pkg/providers -bench=. -benchmem ``` --- ## PRD Acceptance Criteria Mapping | PRD Acceptance Criteria | Test Cases | |------------------------|------------| | US-001: Add ModelConfig struct | `TestModelConfig_Parsing`, `TestModelConfig_Validation` | | US-001: model_name unique | `TestConfig_ModelNameUniqueness` | | US-002: GetModelConfig method | `TestConfig_GetModelConfig_*` | | US-003: Auto-convert providers | `TestConvertProvidersToModelList_*` | | US-003: Deprecation warning | `TestConfig_DeprecationWarning` | | US-003: Existing tests pass | (existing test files unchanged) | | US-004: Protocol prefix factory | `TestExtractProtocol`, `TestCreateProviderFromConfig_*` | | US-004: Default prefix openai | `TestCreateProviderFromConfig_OpenAIDefault` | | US-005: CreateProvider uses factory | `TestCreateProvider_*` | | US-006: Round-robin selection | `TestModelRegistry_RoundRobin*` | | US-006: Thread-safe atomic | `TestModelRegistry_RaceDetection` | --- ## Recommended Implementation Order 1. **Phase 1: Configuration Structure** (US-001, US-002) - Implement `ModelConfig` struct - Implement `GetModelConfig` method - Run `model_config_test.go` 2. **Phase 2: Protocol Factory** (US-004) - Implement `CreateProviderFromConfig` - Implement `ExtractProtocol` - Run `factory_test.go` 3. **Phase 3: Load Balancing** (US-006) - Implement `ModelRegistry` - Implement round-robin selection - Run `registry_test.go` (with `-race`) 4. **Phase 4: Backward Compatibility** (US-003, US-005) - Implement `ConvertProvidersToModelList` - Refactor `CreateProvider` - Run `migration_test.go` - Verify existing tests pass 5. **Phase 5: E2E Verification** - Run `integration_test.go` - Manual testing with `config.example.json` ================================================ FILE: docs/design/provider-refactoring.md ================================================ # Provider Architecture Refactoring Design > Issue: #283 > Discussion: #122 > Branch: feat/refactor-provider-by-protocol ## 1. Current Problems ### 1.1 Configuration Structure Issues **Current State**: Each Provider requires a predefined field in `ProvidersConfig` ```go type ProvidersConfig struct { Anthropic ProviderConfig `json:"anthropic"` OpenAI ProviderConfig `json:"openai"` DeepSeek ProviderConfig `json:"deepseek"` Qwen ProviderConfig `json:"qwen"` Cerebras ProviderConfig `json:"cerebras"` VolcEngine ProviderConfig `json:"volcengine"` // ... every new provider requires changes here } ``` **Problems**: - Adding a new Provider requires modifying Go code (struct definition) - `CreateProvider` function in `http_provider.go` has 200+ lines of switch-case - Most Providers are OpenAI-compatible, but code is duplicated ### 1.2 Code Bloat Trend Recent PRs demonstrate this issue: | PR | Provider | Code Changes | |----|----------|--------------| | #365 | Qwen | +17 lines to http_provider.go | | #333 | Cerebras | +17 lines to http_provider.go | | #368 | Volcengine | +18 lines to http_provider.go | Each OpenAI-compatible Provider requires: 1. Modify `config.go` to add configuration field 2. Modify `http_provider.go` to add switch case 3. Update documentation ### 1.3 Agent-Provider Coupling ```json { "agents": { "defaults": { "provider": "deepseek", // need to know provider name "model": "deepseek-chat" } } } ``` Problem: Agent needs to know both `provider` and `model`, adding complexity. --- ## 2. New Approach: model_list ### 2.1 Core Principles Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design: 1. **Model-centric**: Users care about models, not providers 2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.4`, `anthropic/claude-sonnet-4.6` 3. **Configuration-driven**: Adding new Providers only requires config changes, no code changes ### 2.2 New Configuration Structure ```json { "model_list": [ { "model_name": "deepseek-chat", "model": "openai/deepseek-chat", "api_base": "https://api.deepseek.com/v1", "api_key": "sk-xxx" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_key": "sk-xxx" }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", "api_key": "sk-xxx" }, { "model_name": "gemini-3-flash", "model": "antigravity/gemini-3-flash", "auth_method": "oauth" }, { "model_name": "my-company-llm", "model": "openai/company-model-v1", "api_base": "https://llm.company.com/v1", "api_key": "xxx" } ], "agents": { "defaults": { "model": "deepseek-chat", "max_tokens": 8192, "temperature": 0.7 } } } ``` ### 2.3 Go Struct Definition ```go type Config struct { ModelList []ModelConfig `json:"model_list"` // new Providers ProvidersConfig `json:"providers"` // old, deprecated Agents AgentsConfig `json:"agents"` Channels ChannelsConfig `json:"channels"` // ... } type ModelConfig struct { // Required ModelName string `json:"model_name"` // user-facing name (alias) Model string `json:"model"` // protocol/model, e.g., openai/gpt-5.4 // Common config APIBase string `json:"api_base,omitempty"` APIKey string `json:"api_key,omitempty"` Proxy string `json:"proxy,omitempty"` // Special provider config AuthMethod string `json:"auth_method,omitempty"` // oauth, token ConnectMode string `json:"connect_mode,omitempty"` // stdio, grpc // Optional optimizations RPM int `json:"rpm,omitempty"` // rate limit MaxTokensField string `json:"max_tokens_field,omitempty"` // max_tokens or max_completion_tokens } ``` ### 2.4 Protocol Recognition Identify protocol via prefix in `model` field: | Prefix | Protocol | Description | |--------|----------|-------------| | `openai/` | OpenAI-compatible | Most common, includes DeepSeek, Qwen, Groq, etc. | | `anthropic/` | Anthropic | Claude series specific | | `antigravity/` | Antigravity | Google Cloud Code Assist | | `gemini/` | Gemini | Google Gemini native API (if needed) | --- ## 3. Design Rationale ### 3.1 Problems Solved | Problem | Old Approach | New Approach | |---------|--------------|--------------| | Add OpenAI-compatible Provider | Change 3 code locations | Add one config entry | | Agent specifies model | Need provider + model | Only need model | | Code duplication | Each Provider duplicates logic | Share protocol implementation | | Multi-Agent support | Complex | Naturally compatible | ### 3.2 Multi-Agent Compatibility ```json { "model_list": [...], "agents": { "defaults": { "model": "deepseek-chat" }, "coder": { "model": "gpt-5.4", "system_prompt": "You are a coding assistant..." }, "translator": { "model": "claude-sonnet-4.6" } } } ``` Each Agent only needs to specify `model` (corresponds to `model_name` in `model_list`). ### 3.3 Industry Comparison **LiteLLM** (most mature open-source LLM Proxy) uses similar design: ```yaml model_list: - model_name: gpt-4o litellm_params: model: openai/gpt-5.4 api_key: xxx - model_name: my-custom litellm_params: model: openai/custom-model api_base: https://my-api.com/v1 ``` --- ## 4. Migration Plan ### 4.1 Phase 1: Compatibility Period (v1.x) Support both `providers` and `model_list`: ```go func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) { // Prefer new config if len(c.ModelList) > 0 { return c.findModelByName(modelName) } // Backward compatibility with old config if !c.Providers.IsEmpty() { logger.Warn("'providers' config is deprecated, please migrate to 'model_list'") return c.convertFromProviders(modelName) } return nil, fmt.Errorf("model %s not found", modelName) } ``` ### 4.2 Phase 2: Warning Period (late v1.x) - Print more prominent warnings at startup - Provide automatic migration script - Mark `providers` as deprecated in documentation ### 4.3 Phase 3: Removal Period (v2.0) - Completely remove `providers` support - Remove `agents.defaults.provider` field - Only support `model_list` ### 4.4 Configuration Migration Example **Old Config**: ```json { "providers": { "deepseek": { "api_key": "sk-xxx", "api_base": "https://api.deepseek.com/v1" } }, "agents": { "defaults": { "provider": "deepseek", "model": "deepseek-chat" } } } ``` **New Config**: ```json { "model_list": [ { "model_name": "deepseek-chat", "model": "openai/deepseek-chat", "api_base": "https://api.deepseek.com/v1", "api_key": "sk-xxx" } ], "agents": { "defaults": { "model": "deepseek-chat" } } } ``` --- ## 5. Implementation Checklist ### 5.1 Configuration Layer - [ ] Add `ModelConfig` struct - [ ] Add `Config.ModelList` field - [ ] Implement `GetModelConfig(modelName)` method - [ ] Implement old config compatibility conversion - [ ] Add `model_name` uniqueness validation ### 5.2 Provider Layer - [ ] Create `pkg/providers/factory/` directory - [ ] Implement `CreateProviderFromModelConfig()` - [ ] Refactor `http_provider.go` to `openai/provider.go` - [ ] Maintain backward compatibility for old `CreateProvider()` ### 5.3 Testing - [ ] New config unit tests - [ ] Old config compatibility tests - [ ] Integration tests ### 5.4 Documentation - [ ] Update README - [ ] Update config.example.json - [ ] Write migration guide --- ## 6. Risks and Mitigations | Risk | Mitigation | |------|------------| | Breaking existing configs | Compatibility period keeps old config working | | User migration cost | Provide automatic migration script | | Special Provider incompatibility | Keep `auth_method` and other extension fields | --- ## 7. References - [LiteLLM Config Documentation](https://docs.litellm.ai/docs/proxy/configs) - [One-API GitHub](https://github.com/songquanpeng/one-api) - Discussion #122: Refactor Provider Architecture ================================================ FILE: docs/docker.md ================================================ # 🐳 Docker & Quick Start Guide > Back to [README](../README.md) ## 🐳 Docker Compose You can also run PicoClaw using Docker Compose without installing anything locally. ```bash # 1. Clone this repo git clone https://github.com/sipeed/picoclaw.git cd picoclaw # 2. First run — auto-generates docker/data/config.json then exits docker compose -f docker/docker-compose.yml --profile gateway up # The container prints "First-run setup complete." and stops. # 3. Set your API keys vim docker/data/config.json # Set provider API keys, bot tokens, etc. # 4. Start docker compose -f docker/docker-compose.yml --profile gateway up -d ``` > [!TIP] > **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`. ```bash # 5. Check logs docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway # 6. Stop docker compose -f docker/docker-compose.yml --profile gateway down ``` ### Launcher Mode (Web Console) The `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat. ```bash docker compose -f docker/docker-compose.yml --profile launcher up -d ``` Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically. > [!WARNING] > The web console does not yet support authentication. Avoid exposing it to the public internet. ### Agent Mode (One-shot) ```bash # Ask a question docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?" # Interactive mode docker compose -f docker/docker-compose.yml run --rm picoclaw-agent ``` ### Update ```bash docker compose -f docker/docker-compose.yml pull docker compose -f docker/docker-compose.yml --profile gateway up -d ``` ### 🚀 Quick Start > [!TIP] > Set your API Key in `~/.picoclaw/config.json`. Get API Keys: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Web search is optional — get a free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month). **1. Initialize** ```bash picoclaw onboard ``` **2. Configure** (`~/.picoclaw/config.json`) ```json { "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "model_name": "gpt-5.4", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 } }, "model_list": [ { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", "api_key": "sk-your-api-key", "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_key": "your-api-key", "request_timeout": 300 }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", "api_key": "your-anthropic-key" } ], "tools": { "web": { "enabled": true, "fetch_limit_bytes": 10485760, "format": "plaintext", "brave": { "enabled": false, "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 }, "tavily": { "enabled": false, "api_key": "YOUR_TAVILY_API_KEY", "max_results": 5 }, "duckduckgo": { "enabled": true, "max_results": 5 }, "perplexity": { "enabled": false, "api_key": "YOUR_PERPLEXITY_API_KEY", "max_results": 5 }, "searxng": { "enabled": false, "base_url": "http://your-searxng-instance:8888", "max_results": 5 } } } } ``` > **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details. > `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s). **3. Get API Keys** * **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) * **Web Search** (optional): * [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month) * [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface * [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed) * [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month) * DuckDuckGo - Built-in fallback (no API key required) > **Note**: See `config.example.json` for a complete configuration template. **4. Chat** ```bash picoclaw agent -m "What is 2+2?" ``` That's it! You have a working AI assistant in 2 minutes. --- ================================================ FILE: docs/fr/chat-apps.md ================================================ # 💬 Configuration des Applications de Chat > Retour au [README](../../README.fr.md) ## 💬 Applications de Chat Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot ou MaixCam. > **Note** : Tous les canaux basés sur les webhooks (LINE, WeCom, etc.) sont servis sur un seul serveur HTTP Gateway partagé (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). Il n'y a pas de ports par canal à configurer. Note : Feishu utilise le mode WebSocket/SDK et n'utilise pas le serveur HTTP webhook partagé. | Canal | Configuration | | ------------ | -------------------------------------- | | **Telegram** | Facile (juste un token) | | **Discord** | Facile (bot token + intents) | | **WhatsApp** | Facile (natif : scan QR ; ou bridge URL) | | **Matrix** | Moyen (homeserver + bot access token) | | **QQ** | Facile (AppID + AppSecret) | | **DingTalk** | Moyen (identifiants de l'application) | | **LINE** | Moyen (identifiants + webhook URL) | | **WeCom AI Bot** | Moyen (Token + clé AES) | | **Feishu** | Moyen (App ID + Secret, mode WebSocket) | | **Slack** | Moyen (Bot token + App token) | | **IRC** | Moyen (serveur + configuration TLS) | | **OneBot** | Moyen (QQ via protocole OneBot) | | **MaixCam** | Facile (intégration matérielle Sipeed) | | **Pico** | Native PicoClaw protocol |
Telegram (Recommandé) **1. Créer un bot** * Ouvrez Telegram, recherchez `@BotFather` * Envoyez `/newbot`, suivez les instructions * Copiez le token **2. Configurer** ```json { "channels": { "telegram": { "enabled": true, "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } } } ``` > Obtenez votre identifiant utilisateur via `@userinfobot` sur Telegram. **3. Lancer** ```bash picoclaw gateway ``` **4. Menu de commandes Telegram (enregistré automatiquement au démarrage)** PicoClaw conserve les définitions de commandes dans un registre partagé unique. Au démarrage, Telegram enregistre automatiquement les commandes bot prises en charge (par exemple `/start`, `/help`, `/show`, `/list`) afin que le menu de commandes et le comportement à l'exécution restent synchronisés. L'enregistrement du menu de commandes Telegram reste une découverte UX locale au canal ; l'exécution générique des commandes est gérée de manière centralisée dans la boucle agent via l'exécuteur de commandes. Si l'enregistrement des commandes échoue (erreurs transitoires réseau/API), le canal démarre quand même et PicoClaw réessaie l'enregistrement en arrière-plan.
Discord **1. Créer un bot** * Allez sur * Créez une application → Bot → Add Bot * Copiez le token du bot **2. Activer les intents** * Dans les paramètres du Bot, activez **MESSAGE CONTENT INTENT** * (Optionnel) Activez **SERVER MEMBERS INTENT** si vous prévoyez d'utiliser des listes d'autorisation basées sur les données des membres **3. Obtenir votre identifiant utilisateur** * Paramètres Discord → Avancé → activez **Developer Mode** * Clic droit sur votre avatar → **Copy User ID** **4. Configurer** ```json { "channels": { "discord": { "enabled": true, "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } } } ``` **5. Inviter le bot** * OAuth2 → URL Generator * Scopes : `bot` * Bot Permissions : `Send Messages`, `Read Message History` * Ouvrez l'URL d'invitation générée et ajoutez le bot à votre serveur **Mode déclenchement en groupe (optionnel)** Par défaut, le bot répond à tous les messages dans un canal de serveur. Pour limiter les réponses aux @mentions uniquement, ajoutez : ```json { "channels": { "discord": { "group_trigger": { "mention_only": true } } } } ``` Vous pouvez également déclencher par préfixes de mots-clés (par ex. `!bot`) : ```json { "channels": { "discord": { "group_trigger": { "prefixes": ["!bot"] } } } } ``` **6. Lancer** ```bash picoclaw gateway ```
WhatsApp (natif via whatsmeow) PicoClaw peut se connecter à WhatsApp de deux manières : - **Natif (recommandé) :** En processus via [whatsmeow](https://github.com/tulir/whatsmeow). Pas de bridge séparé. Définissez `"use_native": true` et laissez `bridge_url` vide. Au premier lancement, scannez le code QR avec WhatsApp (Appareils liés). La session est stockée dans votre workspace (par ex. `workspace/whatsapp/`). Le canal natif est **optionnel** pour garder le binaire par défaut léger ; compilez avec `-tags whatsapp_native` (par ex. `make build-whatsapp-native` ou `go build -tags whatsapp_native ./cmd/...`). - **Bridge :** Connectez-vous à un bridge WebSocket externe. Définissez `bridge_url` (par ex. `ws://localhost:3001`) et gardez `use_native` à false. **Configurer (natif)** ```json { "channels": { "whatsapp": { "enabled": true, "use_native": true, "session_store_path": "", "allow_from": [] } } } ``` Si `session_store_path` est vide, la session est stockée dans `/whatsapp/`. Lancez `picoclaw gateway` ; au premier lancement, scannez le code QR affiché dans le terminal avec WhatsApp → Appareils liés.
QQ **1. Créer un bot** - Allez sur [QQ Open Platform](https://q.qq.com/#) - Créez une application → Obtenez **AppID** et **AppSecret** **2. Configurer** ```json { "channels": { "qq": { "enabled": true, "app_id": "YOUR_APP_ID", "app_secret": "YOUR_APP_SECRET", "allow_from": [] } } } ``` > Définissez `allow_from` vide pour autoriser tous les utilisateurs, ou spécifiez des numéros QQ pour restreindre l'accès. **3. Lancer** ```bash picoclaw gateway ```
DingTalk **1. Créer un bot** * Allez sur [Open Platform](https://open.dingtalk.com/) * Créez une application interne * Copiez le Client ID et le Client Secret **2. Configurer** ```json { "channels": { "dingtalk": { "enabled": true, "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "allow_from": [] } } } ``` > Définissez `allow_from` vide pour autoriser tous les utilisateurs, ou spécifiez des identifiants DingTalk pour restreindre l'accès. **3. Lancer** ```bash picoclaw gateway ```
Matrix **1. Préparer le compte bot** * Utilisez votre homeserver préféré (par ex. `https://matrix.org` ou auto-hébergé) * Créez un utilisateur bot et obtenez son access token **2. Configurer** ```json { "channels": { "matrix": { "enabled": true, "homeserver": "https://matrix.org", "user_id": "@your-bot:matrix.org", "access_token": "YOUR_MATRIX_ACCESS_TOKEN", "allow_from": [] } } } ``` **3. Lancer** ```bash picoclaw gateway ``` Pour toutes les options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), voir le [Guide de Configuration du Canal Matrix](docs/channels/matrix/README.md).
LINE **1. Créer un compte officiel LINE** - Allez sur [LINE Developers Console](https://developers.line.biz/) - Créez un provider → Créez un canal Messaging API - Copiez le **Channel Secret** et le **Channel Access Token** **2. Configurer** ```json { "channels": { "line": { "enabled": true, "channel_secret": "YOUR_CHANNEL_SECRET", "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", "webhook_path": "/webhook/line", "allow_from": [] } } } ``` > Le webhook LINE est servi sur le serveur Gateway partagé (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). **3. Configurer l'URL du Webhook** LINE nécessite HTTPS pour les webhooks. Utilisez un reverse proxy ou un tunnel : ```bash # Exemple avec ngrok (le port par défaut du gateway est 18790) ngrok http 18790 ``` Puis définissez l'URL du Webhook dans la console LINE Developers à `https://your-domain/webhook/line` et activez **Use webhook**. **4. Lancer** ```bash picoclaw gateway ``` > Dans les discussions de groupe, le bot ne répond que lorsqu'il est @mentionné. Les réponses citent le message original.
WeCom (企业微信) PicoClaw prend en charge trois types d'intégration WeCom : **Option 1 : WeCom Bot (Bot)** - Configuration plus facile, prend en charge les discussions de groupe **Option 2 : WeCom App (Application personnalisée)** - Plus de fonctionnalités, messagerie proactive, chat privé uniquement **Option 3 : WeCom AI Bot (Bot IA)** - Bot IA officiel, réponses en streaming, prend en charge les discussions de groupe et privées Voir le [Guide de Configuration WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) pour les instructions détaillées. **Configuration rapide - WeCom Bot :** **1. Créer un bot** * Allez dans la console d'administration WeCom → Discussion de groupe → Ajouter un bot de groupe * Copiez l'URL du webhook (format : `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) **2. Configurer** ```json { "channels": { "wecom": { "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", "webhook_path": "/webhook/wecom", "allow_from": [] } } } ``` > Le webhook WeCom est servi sur le serveur Gateway partagé (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). **Configuration rapide - WeCom App :** **1. Créer une application** * Allez dans la console d'administration WeCom → Gestion des applications → Créer une application * Copiez **AgentId** et **Secret** * Allez sur la page "Mon entreprise", copiez **CorpID** **2. Configurer la réception des messages** * Dans les détails de l'application, cliquez sur "Recevoir les messages" → "Configurer l'API" * Définissez l'URL à `http://your-server:18790/webhook/wecom-app` * Générez **Token** et **EncodingAESKey** **3. Configurer** ```json { "channels": { "wecom_app": { "enabled": true, "corp_id": "wwxxxxxxxxxxxxxxxx", "corp_secret": "YOUR_CORP_SECRET", "agent_id": 1000002, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-app", "allow_from": [] } } } ``` **4. Lancer** ```bash picoclaw gateway ``` > **Note** : Les callbacks webhook WeCom sont servis sur le port Gateway (par défaut 18790). Utilisez un reverse proxy pour HTTPS. **Configuration rapide - WeCom AI Bot :** **1. Créer un AI Bot** * Allez dans la console d'administration WeCom → Gestion des applications → AI Bot * Dans les paramètres du AI Bot, configurez l'URL de callback : `http://your-server:18791/webhook/wecom-aibot` * Copiez **Token** et cliquez sur "Générer aléatoirement" pour **EncodingAESKey** **2. Configurer** ```json { "channels": { "wecom_aibot": { "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-aibot", "allow_from": [], "welcome_message": "Hello! How can I help you?", "processing_message": "⏳ Processing, please wait. The results will be sent shortly." } } } ``` **3. Lancer** ```bash picoclaw gateway ``` > **Note** : WeCom AI Bot utilise le protocole streaming pull — pas de problème de timeout de réponse. Les tâches longues (>30 secondes) basculent automatiquement vers la livraison push via `response_url`.
Feishu (飞书) **1. Créer une application** * Allez sur [Feishu Open Platform](https://open.feishu.cn/) * Créez une application → Obtenez **App ID** et **App Secret** **2. Configurer** ```json { "channels": { "feishu": { "enabled": true, "app_id": "cli_xxx", "app_secret": "xxx", "encrypt_key": "", "verification_token": "", "allow_from": [] } } } ``` > Feishu utilise le mode WebSocket/SDK et ne nécessite pas de serveur webhook. **3. Lancer** ```bash picoclaw gateway ```
Slack **1. Créer une application Slack** * Allez sur [Slack API](https://api.slack.com/apps) * Créez une nouvelle application * Obtenez le **Bot Token** et l'**App Token** **2. Configurer** ```json { "channels": { "slack": { "enabled": true, "bot_token": "xoxb-your-bot-token", "app_token": "xapp-your-app-token", "allow_from": [] } } } ``` **3. Lancer** ```bash picoclaw gateway ```
IRC **1. Configurer le serveur IRC** * Préparez les informations de votre serveur IRC (adresse, port, canal) **2. Configurer** ```json { "channels": { "irc": { "enabled": true, "server": "irc.example.com:6697", "nick": "picoclaw-bot", "channel": "#your-channel", "use_tls": true, "allow_from": [] } } } ``` **3. Lancer** ```bash picoclaw gateway ```
OneBot **1. Configurer OneBot** * Installez une implémentation OneBot compatible (par ex. go-cqhttp, Lagrange) * Configurez la connexion WebSocket **2. Configurer** ```json { "channels": { "onebot": { "enabled": true, "ws_url": "ws://localhost:8080", "allow_from": [] } } } ``` > OneBot permet d'utiliser QQ via le protocole OneBot standard. **3. Lancer** ```bash picoclaw gateway ```
MaixCam **1. Préparer le matériel** * Obtenez un appareil [Sipeed MaixCam](https://wiki.sipeed.com/maixcam) **2. Configurer** ```json { "channels": { "maixcam": { "enabled": true, "allow_from": [] } } } ``` > MaixCam est une intégration matérielle Sipeed pour l'interaction IA embarquée. **3. Lancer** ```bash picoclaw gateway ```
================================================ FILE: docs/fr/configuration.md ================================================ # ⚙️ Guide de Configuration > Retour au [README](../../README.fr.md) ## ⚙️ Configuration Fichier de configuration : `~/.picoclaw/config.json` ### Variables d'Environnement Vous pouvez remplacer les chemins par défaut à l'aide de variables d'environnement. Ceci est utile pour les installations portables, les déploiements conteneurisés ou l'exécution de PicoClaw en tant que service système. Ces variables sont indépendantes et contrôlent des chemins différents. | Variable | Description | Chemin par défaut | |-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| | `PICOCLAW_CONFIG` | Remplace le chemin vers le fichier de configuration. Indique directement à PicoClaw quel `config.json` charger, en ignorant tous les autres emplacements. | `~/.picoclaw/config.json` | | `PICOCLAW_HOME` | Remplace le répertoire racine des données PicoClaw. Change l'emplacement par défaut du `workspace` et des autres répertoires de données. | `~/.picoclaw` | **Exemples :** ```bash # Run picoclaw using a specific config file # The workspace path will be read from within that config file PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway # Run picoclaw with all its data stored in /opt/picoclaw # Config will be loaded from the default ~/.picoclaw/config.json # Workspace will be created at /opt/picoclaw/workspace PICOCLAW_HOME=/opt/picoclaw picoclaw agent # Use both for a fully customized setup PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway ``` ### Structure du Workspace PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.picoclaw/workspace`) : ``` ~/.picoclaw/workspace/ ├── sessions/ # Sessions de conversation et historique ├── memory/ # Mémoire à long terme (MEMORY.md) ├── state/ # État persistant (dernier canal, etc.) ├── cron/ # Base de données des tâches planifiées ├── skills/ # Compétences personnalisées ├── AGENT.md # Guide de comportement de l'agent ├── HEARTBEAT.md # Invites de tâches périodiques (vérifiées toutes les 30 min) ├── SOUL.md # Âme de l'agent └── USER.md # Préférences utilisateur ``` > **Remarque :** Les modifications apportées à `AGENT.md`, `SOUL.md`, `USER.md` et `memory/MEMORY.md` sont détectées automatiquement au moment de l'exécution via le suivi de la date de modification (mtime). Il n'est **pas nécessaire de redémarrer le gateway** après avoir modifié ces fichiers — l'agent charge le nouveau contenu à la prochaine requête. ### Sources de Compétences Par défaut, les compétences sont chargées depuis : 1. `~/.picoclaw/workspace/skills` (workspace) 2. `~/.picoclaw/skills` (global) 3. `/skills` (builtin) Pour les configurations avancées/de test, vous pouvez remplacer la racine des compétences builtin avec : ```bash export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ``` ### Politique Unifiée d'Exécution des Commandes - Les commandes slash génériques sont exécutées via un chemin unique dans `pkg/agent/loop.go` via `commands.Executor`. - Les adaptateurs de canaux ne consomment plus les commandes génériques localement ; ils transmettent le texte entrant au chemin bus/agent. Telegram enregistre toujours automatiquement les commandes prises en charge au démarrage. - Une commande slash inconnue (par exemple `/foo`) passe au traitement LLM normal. - Une commande enregistrée mais non prise en charge sur le canal actuel (par exemple `/show` sur WhatsApp) renvoie une erreur explicite à l'utilisateur et arrête le traitement ultérieur. ### 🔒 Sandbox de Sécurité PicoClaw s'exécute dans un environnement sandboxé par défaut. L'agent ne peut accéder aux fichiers et exécuter des commandes que dans le workspace configuré. #### Configuration par Défaut ```json { "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "restrict_to_workspace": true } } } ``` | Option | Par défaut | Description | | ----------------------- | ----------------------- | ------------------------------------------------- | | `workspace` | `~/.picoclaw/workspace` | Répertoire de travail de l'agent | | `restrict_to_workspace` | `true` | Restreindre l'accès fichiers/commandes au workspace | #### Outils Protégés Lorsque `restrict_to_workspace: true`, les outils suivants sont sandboxés : | Outil | Fonction | Restriction | | ------------- | --------------------- | ---------------------------------------------- | | `read_file` | Lire des fichiers | Uniquement les fichiers dans le workspace | | `write_file` | Écrire des fichiers | Uniquement les fichiers dans le workspace | | `list_dir` | Lister les répertoires| Uniquement les répertoires dans le workspace | | `edit_file` | Modifier des fichiers | Uniquement les fichiers dans le workspace | | `append_file` | Ajouter aux fichiers | Uniquement les fichiers dans le workspace | | `exec` | Exécuter des commandes| Les chemins de commande doivent être dans le workspace | #### Protection Exec Supplémentaire Même avec `restrict_to_workspace: false`, l'outil `exec` bloque ces commandes dangereuses : * `rm -rf`, `del /f`, `rmdir /s` — Suppression en masse * `format`, `mkfs`, `diskpart` — Formatage de disque * `dd if=` — Imagerie de disque * Écriture vers `/dev/sd[a-z]` — Écritures directes sur disque * `shutdown`, `reboot`, `poweroff` — Arrêt du système * Fork bomb `:(){ :|:& };:` ### Contrôle d'Accès aux Fichiers | Clé de configuration | Type | Par défaut | Description | |----------------------|------|------------|-------------| | `tools.allow_read_paths` | string[] | `[]` | Chemins supplémentaires autorisés en lecture en dehors du workspace | | `tools.allow_write_paths` | string[] | `[]` | Chemins supplémentaires autorisés en écriture en dehors du workspace | ### Sécurité Exec | Clé de configuration | Type | Par défaut | Description | |----------------------|------|------------|-------------| | `tools.exec.allow_remote` | bool | `false` | Autoriser l'outil exec depuis les canaux distants (Telegram/Discord etc.) | | `tools.exec.enable_deny_patterns` | bool | `true` | Activer l'interception des commandes dangereuses | | `tools.exec.custom_deny_patterns` | string[] | `[]` | Patterns regex personnalisés à bloquer | | `tools.exec.custom_allow_patterns` | string[] | `[]` | Patterns regex personnalisés à autoriser | > **Note de sécurité :** La protection Symlink est activée par défaut — tous les chemins de fichiers sont résolus via `filepath.EvalSymlinks` avant la correspondance avec la liste blanche, empêchant les attaques d'évasion par symlink. #### Limitation Connue : Processus Enfants des Outils de Build Le garde de sécurité exec n'inspecte que la ligne de commande lancée directement par PicoClaw. Il n'inspecte pas récursivement les processus enfants générés par les outils de développement autorisés tels que `make`, `go run`, `cargo`, `npm run` ou les scripts de build personnalisés. Cela signifie qu'une commande de niveau supérieur peut toujours compiler ou lancer d'autres binaires après avoir passé la vérification initiale du garde. En pratique, traitez les scripts de build, les Makefiles, les scripts de packages et les binaires générés comme du code exécutable nécessitant le même niveau de revue qu'une commande shell directe. Pour les environnements à haut risque : * Examinez les scripts de build avant l'exécution. * Préférez l'approbation/revue manuelle pour les workflows de compilation et d'exécution. * Exécutez PicoClaw dans un conteneur ou une VM si vous avez besoin d'une isolation plus forte que celle fournie par le garde intégré. #### Exemples d'Erreurs ``` [ERROR] tool: Tool execution failed {tool=exec, error=Command blocked by safety guard (path outside working dir)} ``` ``` [ERROR] tool: Tool execution failed {tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} ``` #### Désactiver les Restrictions (Risque de Sécurité) Si vous avez besoin que l'agent accède à des chemins en dehors du workspace : **Méthode 1 : Fichier de configuration** ```json { "agents": { "defaults": { "restrict_to_workspace": false } } } ``` **Méthode 2 : Variable d'environnement** ```bash export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false ``` > ⚠️ **Avertissement** : Désactiver cette restriction permet à l'agent d'accéder à n'importe quel chemin sur votre système. À utiliser avec précaution dans des environnements contrôlés uniquement. #### Cohérence des Limites de Sécurité Le paramètre `restrict_to_workspace` s'applique de manière cohérente à tous les chemins d'exécution : | Chemin d'exécution | Limite de sécurité | | ------------------ | -------------------------------- | | Main Agent | `restrict_to_workspace` ✅ | | Subagent / Spawn | Hérite de la même restriction ✅ | | Heartbeat tasks | Hérite de la même restriction ✅ | Tous les chemins partagent la même restriction de workspace — il n'y a aucun moyen de contourner la limite de sécurité via les subagents ou les tâches planifiées. ### Heartbeat (Tâches Périodiques) PicoClaw peut effectuer des tâches périodiques automatiquement. Créez un fichier `HEARTBEAT.md` dans votre workspace : ```markdown # Periodic Tasks - Check my email for important messages - Review my calendar for upcoming events - Check the weather forecast ``` L'agent lira ce fichier toutes les 30 minutes (configurable) et exécutera toutes les tâches en utilisant les outils disponibles. #### Tâches Asynchrones avec Spawn Pour les tâches longues (recherche web, appels API), utilisez l'outil `spawn` pour créer un **subagent** : ```markdown # Periodic Tasks ``` ================================================ FILE: docs/fr/docker.md ================================================ # 🐳 Docker et Démarrage Rapide > Retour au [README](../../README.fr.md) ## 🐳 Docker Compose Vous pouvez également exécuter PicoClaw avec Docker Compose sans rien installer localement. ```bash # 1. Cloner ce dépôt git clone https://github.com/sipeed/picoclaw.git cd picoclaw # 2. Premier lancement — génère automatiquement docker/data/config.json puis s'arrête docker compose -f docker/docker-compose.yml --profile gateway up # Le conteneur affiche "First-run setup complete." et s'arrête. # 3. Configurer vos clés API vim docker/data/config.json # Set provider API keys, bot tokens, etc. # 4. Démarrer docker compose -f docker/docker-compose.yml --profile gateway up -d ``` > [!TIP] > **Utilisateurs Docker** : Par défaut, le Gateway écoute sur `127.0.0.1`, ce qui n'est pas accessible depuis l'hôte. Si vous devez accéder aux endpoints de santé ou exposer des ports, définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` dans votre environnement ou mettez à jour `config.json`. ```bash # 5. Vérifier les logs docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway # 6. Arrêter docker compose -f docker/docker-compose.yml --profile gateway down ``` ### Mode Launcher (Console Web) L'image `launcher` inclut les trois binaires (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) et démarre la console web par défaut, qui fournit une interface navigateur pour la configuration et le chat. ```bash docker compose -f docker/docker-compose.yml --profile launcher up -d ``` Ouvrez http://localhost:18800 dans votre navigateur. Le launcher gère automatiquement le processus gateway. > [!WARNING] > La console web ne prend pas encore en charge l'authentification. Évitez de l'exposer sur Internet public. ### Mode Agent (One-shot) ```bash # Poser une question docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?" # Mode interactif docker compose -f docker/docker-compose.yml run --rm picoclaw-agent ``` ### Mise à jour ```bash docker compose -f docker/docker-compose.yml pull docker compose -f docker/docker-compose.yml --profile gateway up -d ``` ### 🚀 Démarrage Rapide > [!TIP] > Configurez votre clé API dans `~/.picoclaw/config.json`. Obtenir des clés API : [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). La recherche web est optionnelle — obtenez gratuitement une [API Tavily](https://tavily.com) (1000 requêtes gratuites/mois) ou une [API Brave Search](https://brave.com/search/api) (2000 requêtes gratuites/mois). **1. Initialiser** ```bash picoclaw onboard ``` **2. Configurer** (`~/.picoclaw/config.json`) ```json { "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "model_name": "gpt-5.4", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 } }, "model_list": [ { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", "api_key": "sk-your-api-key", "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_key": "your-api-key", "request_timeout": 300 }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", "api_key": "your-anthropic-key" } ], "tools": { "web": { "enabled": true, "fetch_limit_bytes": 10485760, "format": "plaintext", "brave": { "enabled": false, "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 }, "tavily": { "enabled": false, "api_key": "YOUR_TAVILY_API_KEY", "max_results": 5 }, "duckduckgo": { "enabled": true, "max_results": 5 }, "perplexity": { "enabled": false, "api_key": "YOUR_PERPLEXITY_API_KEY", "max_results": 5 }, "searxng": { "enabled": false, "base_url": "http://your-searxng-instance:8888", "max_results": 5 } } } } ``` > **Nouveau** : Le format de configuration `model_list` permet l'ajout de fournisseurs sans modification de code. Voir [Configuration des Modèles](#configuration-des-modèles-model_list) pour plus de détails. > `request_timeout` est optionnel et utilise les secondes. S'il est omis ou défini à `<= 0`, PicoClaw utilise le timeout par défaut (120s). **3. Obtenir des clés API** * **Fournisseur LLM** : [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) * **Recherche Web** (optionnel) : * [Brave Search](https://brave.com/search/api) - Payant ($5/1000 requêtes, ~$5-6/mois) * [Perplexity](https://www.perplexity.ai) - Recherche alimentée par l'IA avec interface de chat * [SearXNG](https://github.com/searxng/searxng) - Métamoteur auto-hébergé (gratuit, pas de clé API nécessaire) * [Tavily](https://tavily.com) - Optimisé pour les agents IA (1000 requêtes/mois) * DuckDuckGo - Solution de repli intégrée (pas de clé API requise) > **Note** : Voir `config.example.json` pour un modèle de configuration complet. **4. Discuter** ```bash picoclaw agent -m "What is 2+2?" ``` C'est tout ! Vous avez un assistant IA fonctionnel en 2 minutes. --- ================================================ FILE: docs/fr/providers.md ================================================ # 🔌 Fournisseurs et Configuration des Modèles > Retour au [README](../../README.fr.md) ### Fournisseurs > [!NOTE] > Groq fournit la transcription vocale gratuite via Whisper. Si configuré, les messages audio de n'importe quel canal seront automatiquement transcrits au niveau de l'agent. | Provider | Purpose | Get API Key | | ------------ | --------------------------------------- | ------------------------------------------------------------ | | `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | | `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) | | `volcengine` | LLM (Volcengine direct) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | | `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | | `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | | `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | | `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | | `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | | `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) | | `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) | | `moonshot` | LLM (Kimi/Moonshot direct) | [platform.moonshot.cn](https://platform.moonshot.cn) | | `minimax` | LLM (Minimax direct) | [platform.minimaxi.com](https://platform.minimaxi.com) | | `avian` | LLM (Avian direct) | [avian.io](https://avian.io) | | `mistral` | LLM (Mistral direct) | [console.mistral.ai](https://console.mistral.ai) | | `longcat` | LLM (Longcat direct) | [longcat.ai](https://longcat.ai) | | `modelscope` | LLM (ModelScope direct) | [modelscope.cn](https://modelscope.cn) | ### Configuration des Modèles (model_list) > **Nouveauté** PicoClaw utilise désormais une approche de configuration **centrée sur le modèle**. Spécifiez simplement le format `vendor/model` (par ex. `zhipu/glm-4.7`) pour ajouter de nouveaux fournisseurs — **aucune modification de code requise !** Cette conception permet également le **support multi-agents** avec une sélection flexible de fournisseurs : - **Différents agents, différents fournisseurs** : Chaque agent peut utiliser son propre fournisseur LLM - **Modèles de repli** : Configurez des modèles principaux et de repli pour la résilience - **Répartition de charge** : Distribuez les requêtes entre plusieurs endpoints - **Configuration centralisée** : Gérez tous les fournisseurs en un seul endroit #### 📋 Tous les Vendors Supportés | Vendor | `model` Prefix | Default API Base | Protocol | API Key | | ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- | | **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | | **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | | **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | | **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | | **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) | | **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) | | **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) | | **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) | | **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | #### Configuration de Base ```json { "model_list": [ { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", "api_key": "sk-your-api-key" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_key": "sk-your-openai-key" }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", "api_key": "sk-ant-your-key" }, { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", "api_key": "your-zhipu-key" } ], "agents": { "defaults": { "model": "gpt-5.4" } } } ``` #### Exemples par Vendor **OpenAI** ```json { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_key": "sk-..." } ``` **VolcEngine (Doubao)** ```json { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", "api_key": "sk-..." } ``` **智谱 AI (GLM)** ```json { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", "api_key": "your-key" } ``` **DeepSeek** ```json { "model_name": "deepseek-chat", "model": "deepseek/deepseek-chat", "api_key": "sk-..." } ``` **Anthropic (avec clé API)** ```json { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", "api_key": "sk-ant-your-key" } ``` > Exécutez `picoclaw auth login --provider anthropic` pour coller votre token API. **API Anthropic Messages (format natif)** Pour l'accès direct à l'API Anthropic ou les endpoints personnalisés qui ne prennent en charge que le format de message natif d'Anthropic : ```json { "model_name": "claude-opus-4-6", "model": "anthropic-messages/claude-opus-4-6", "api_key": "sk-ant-your-key", "api_base": "https://api.anthropic.com" } ``` > Utilisez le protocole `anthropic-messages` lorsque : > - Vous utilisez des proxys tiers qui ne prennent en charge que l'endpoint natif `/v1/messages` d'Anthropic (pas le format compatible OpenAI `/v1/chat/completions`) > - Vous vous connectez à des services comme MiniMax, Synthetic qui nécessitent le format de message natif d'Anthropic > - Le protocole `anthropic` existant renvoie des erreurs 404 (indiquant que l'endpoint ne prend pas en charge le format compatible OpenAI) > > **Note :** Le protocole `anthropic` utilise le format compatible OpenAI (`/v1/chat/completions`), tandis que `anthropic-messages` utilise le format natif d'Anthropic (`/v1/messages`). Choisissez en fonction du format pris en charge par votre endpoint. **Ollama (local)** ```json { "model_name": "llama3", "model": "ollama/llama3" } ``` **Proxy/API Personnalisé** ```json { "model_name": "my-custom-model", "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", "api_key": "sk-...", "request_timeout": 300 } ``` **LiteLLM Proxy** ```json { "model_name": "lite-gpt4", "model": "litellm/lite-gpt4", "api_base": "http://localhost:4000/v1", "api_key": "sk-..." } ``` PicoClaw ne supprime que le préfixe externe `litellm/` avant d'envoyer la requête, donc les alias de proxy comme `litellm/lite-gpt4` envoient `lite-gpt4`, tandis que `litellm/openai/gpt-4o` envoie `openai/gpt-4o`. #### Répartition de Charge Configurez plusieurs endpoints pour le même nom de modèle — PicoClaw effectuera automatiquement un round-robin entre eux : ```json { "model_list": [ { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" } ] } ``` #### Migration depuis l'Ancienne Configuration `providers` L'ancienne configuration `providers` est **dépréciée** mais toujours prise en charge pour la compatibilité ascendante. **Ancienne configuration (dépréciée) :** ```json { "providers": { "zhipu": { "api_key": "your-key", "api_base": "https://open.bigmodel.cn/api/paas/v4" } }, "agents": { "defaults": { "provider": "zhipu", "model": "glm-4.7" } } } ``` **Nouvelle configuration (recommandée) :** ```json { "model_list": [ { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", "api_key": "your-key" } ], "agents": { "defaults": { "model": "glm-4.7" } } } ``` Pour un guide de migration détaillé, voir [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md). ### Architecture des Fournisseurs PicoClaw route les fournisseurs par famille de protocoles : - Protocole compatible OpenAI : OpenRouter, passerelles compatibles OpenAI, Groq, Zhipu et endpoints de type vLLM. - Protocole Anthropic : Comportement natif de l'API Claude. - Chemin Codex/OAuth : Route d'authentification OAuth/token OpenAI. Cela maintient le runtime léger tout en faisant des nouveaux backends compatibles OpenAI principalement une opération de configuration (`api_base` + `api_key`).
Zhipu **1. Obtenir la clé API et l'URL de base** * Obtenir la [clé API](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) **2. Configurer** ```json { "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "model": "glm-4.7", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 } }, "providers": { "zhipu": { "api_key": "Your API Key", "api_base": "https://open.bigmodel.cn/api/paas/v4" } } } ``` **3. Lancer** ```bash picoclaw agent -m "Hello" ```
Exemple de configuration complète ```json { "agents": { "defaults": { "model": "anthropic/claude-opus-4-5" } }, "session": { "dm_scope": "per-channel-peer", "backlog_limit": 20 }, "providers": { "openrouter": { "api_key": "sk-or-v1-xxx" }, "groq": { "api_key": "gsk_xxx" } }, "channels": { "telegram": { "enabled": true, "token": "123456:ABC...", "allow_from": ["123456789"] }, "discord": { "enabled": true, "token": "", "allow_from": [""] }, "whatsapp": { "enabled": false, "bridge_url": "ws://localhost:3001", "use_native": false, "session_store_path": "", "allow_from": [] }, "feishu": { "enabled": false, "app_id": "cli_xxx", "app_secret": "xxx", "encrypt_key": "", "verification_token": "", "allow_from": [] }, "qq": { "enabled": false, "app_id": "", "app_secret": "", "allow_from": [] } }, "tools": { "web": { "brave": { "enabled": false, "api_key": "BSA...", "max_results": 5 }, "duckduckgo": { "enabled": true, "max_results": 5 }, "perplexity": { "enabled": false, "api_key": "", "max_results": 5 }, "searxng": { "enabled": false, "base_url": "http://localhost:8888", "max_results": 5 } }, "cron": { "exec_timeout_minutes": 5 } }, "heartbeat": { "enabled": true, "interval": 30 } } ```
--- ## 📝 Comparaison des Clés API | Service | Pricing | Use Case | | ---------------- | ------------------------ | ------------------------------------- | | **OpenRouter** | Free: 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | | **Volcengine CodingPlan** | ¥9.9/first month | Best for Chinese users, multiple SOTA models (Doubao, DeepSeek, etc.) | | **Zhipu** | Free: 200K tokens/month | Suitable for Chinese users | | **Brave Search** | $5/1000 queries | Web search functionality | | **SearXNG** | Free (self-hosted) | Privacy-focused metasearch (70+ engines) | | **Groq** | Free tier available | Fast inference (Llama, Mixtral) | | **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | | **LongCat** | Free: up to 5M tokens/day | Fast inference | | **ModelScope** | Free: 2000 requests/day | Inference (Qwen, GLM, DeepSeek, etc.) | ---
PicoClaw Meme
================================================ FILE: docs/fr/spawn-tasks.md ================================================ # 🔄 Tâches Asynchrones et Spawn > Retour au [README](../../README.fr.md) ## Tâches Rapides (réponse directe) - Rapporter l'heure actuelle ## Tâches Longues (utiliser spawn pour l'asynchrone) - Rechercher sur le web des actualités IA et résumer - Vérifier les emails et rapporter les messages importants ``` **Comportements clés :** | Fonctionnalité | Description | | ----------------------- | --------------------------------------------------------------- | | **spawn** | Crée un subagent asynchrone, ne bloque pas le heartbeat | | **Independent context** | Le subagent a son propre contexte, pas d'historique de session | | **message tool** | Le subagent communique directement avec l'utilisateur via l'outil message | | **Non-blocking** | Après le spawn, le heartbeat continue à la tâche suivante | #### Fonctionnement de la Communication du Subagent ``` Heartbeat se déclenche ↓ L'agent lit HEARTBEAT.md ↓ Pour une tâche longue : spawn subagent ↓ ↓ Continue à la tâche suivante Le subagent travaille indépendamment ↓ ↓ Toutes les tâches terminées Le subagent utilise l'outil "message" ↓ ↓ Répond HEARTBEAT_OK L'utilisateur reçoit le résultat directement ``` Le subagent a accès aux outils (message, web_search, etc.) et peut communiquer avec l'utilisateur indépendamment sans passer par l'agent principal. **Configuration :** ```json { "heartbeat": { "enabled": true, "interval": 30 } } ``` | Option | Par défaut | Description | | ---------- | ---------- | ---------------------------------------------- | | `enabled` | `true` | Activer/désactiver le heartbeat | | `interval` | `30` | Intervalle de vérification en minutes (min: 5) | **Variables d'environnement :** * `PICOCLAW_HEARTBEAT_ENABLED=false` pour désactiver * `PICOCLAW_HEARTBEAT_INTERVAL=60` pour changer l'intervalle ================================================ FILE: docs/fr/tools_configuration.md ================================================ # 🔧 Configuration des Outils > Retour au [README](../../README.fr.md) La configuration des outils de PicoClaw se trouve dans le champ `tools` de `config.json`. ## Structure du répertoire ```json { "tools": { "web": { ... }, "mcp": { ... }, "exec": { ... }, "cron": { ... }, "skills": { ... } } } ``` ## Outils Web Les outils web sont utilisés pour la recherche et la récupération de pages web. ### Web Fetcher Paramètres généraux pour la récupération et le traitement du contenu des pages web. | Config | Type | Par défaut | Description | |---------------------|--------|---------------|-----------------------------------------------------------------------------------------------| | `enabled` | bool | true | Activer la capacité de récupération de pages web. | | `fetch_limit_bytes` | int | 10485760 | Taille maximale du contenu de la page web à récupérer, en octets (par défaut 10 Mo). | | `format` | string | "plaintext" | Format de sortie du contenu récupéré. Options : `plaintext` ou `markdown` (recommandé). | ### Brave | Config | Type | Par défaut | Description | |---------------|--------|------------|---------------------------| | `enabled` | bool | false | Activer la recherche Brave | | `api_key` | string | - | Clé API Brave Search | | `max_results` | int | 5 | Nombre maximum de résultats | ### DuckDuckGo | Config | Type | Par défaut | Description | |---------------|------|------------|--------------------------------| | `enabled` | bool | true | Activer la recherche DuckDuckGo | | `max_results` | int | 5 | Nombre maximum de résultats | ### Perplexity | Config | Type | Par défaut | Description | |---------------|--------|------------|--------------------------------| | `enabled` | bool | false | Activer la recherche Perplexity | | `api_key` | string | - | Clé API Perplexity | | `max_results` | int | 5 | Nombre maximum de résultats | ## Outil Exec L'outil exec est utilisé pour exécuter des commandes shell. | Config | Type | Par défaut | Description | |------------------------|-------|------------|------------------------------------------------| | `enabled` | bool | true | Activer l'outil exec | | `enable_deny_patterns` | bool | true | Activer le blocage par défaut des commandes dangereuses | | `custom_deny_patterns` | array | [] | Modèles de refus personnalisés (expressions régulières) | ### Désactivation de l'Outil Exec Pour désactiver complètement l'outil `exec`, définissez `enabled` à `false` : **Via le fichier de configuration :** ```json { "tools": { "exec": { "enabled": false } } } ``` **Via la variable d'environnement :** ```bash PICOCLAW_TOOLS_EXEC_ENABLED=false ``` > **Note :** Lorsqu'il est désactivé, l'agent ne pourra pas exécuter de commandes shell. Cela affecte également la capacité de l'outil Cron à exécuter des commandes shell planifiées. ### Fonctionnalité - **`enable_deny_patterns`** : Définir à `false` pour désactiver complètement les modèles de blocage par défaut des commandes dangereuses - **`custom_deny_patterns`** : Ajouter des modèles regex de refus personnalisés ; les commandes correspondantes seront bloquées ### Modèles de commandes bloquées par défaut Par défaut, PicoClaw bloque les commandes dangereuses suivantes : - Commandes de suppression : `rm -rf`, `del /f/q`, `rmdir /s` - Opérations disque : `format`, `mkfs`, `diskpart`, `dd if=`, écriture vers `/dev/sd*` - Opérations système : `shutdown`, `reboot`, `poweroff` - Substitution de commandes : `$()`, `${}`, backticks - Pipe vers shell : `| sh`, `| bash` - Élévation de privilèges : `sudo`, `chmod`, `chown` - Contrôle de processus : `pkill`, `killall`, `kill -9` - Opérations distantes : `curl | sh`, `wget | sh`, `ssh` - Gestion de paquets : `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user` - Conteneurs : `docker run`, `docker exec` - Git : `git push`, `git force` - Autres : `eval`, `source *.sh` ### Limitation architecturale connue Le garde exec ne valide que la commande de niveau supérieur envoyée à PicoClaw. Il n'inspecte **pas** récursivement les processus enfants générés par les outils de build ou les scripts après le démarrage de cette commande. Exemples de workflows pouvant contourner le garde de commande directe une fois la commande initiale autorisée : - `make run` - `go run ./cmd/...` - `cargo run` - `npm run build` Cela signifie que le garde est utile pour bloquer les commandes directes manifestement dangereuses, mais ce n'est **pas** un bac à sable complet pour les pipelines de build non vérifiés. Si votre modèle de menace inclut du code non fiable dans l'espace de travail, utilisez une isolation plus forte comme des conteneurs, des VM ou un flux d'approbation autour des commandes de build et d'exécution. ### Exemple de configuration ```json { "tools": { "exec": { "enable_deny_patterns": true, "custom_deny_patterns": [ "\\brm\\s+-r\\b", "\\bkillall\\s+python" ] } } } ``` ## Outil Cron L'outil cron est utilisé pour planifier des tâches périodiques. | Config | Type | Par défaut | Description | |------------------------|------|------------|----------------------------------------------------| | `exec_timeout_minutes` | int | 5 | Délai d'expiration en minutes, 0 signifie sans limite | ## Outil MCP L'outil MCP permet l'intégration avec des serveurs Model Context Protocol externes. ### Découverte d'outils (chargement paresseux) Lors de la connexion à plusieurs serveurs MCP, exposer simultanément des centaines d'outils peut épuiser la fenêtre de contexte du LLM et augmenter les coûts API. La fonctionnalité **Discovery** résout ce problème en gardant les outils MCP *masqués* par défaut. Au lieu de charger tous les outils, le LLM reçoit un outil de recherche léger (utilisant la correspondance par mots-clés BM25 ou les expressions régulières). Lorsque le LLM a besoin d'une capacité spécifique, il recherche dans la bibliothèque masquée. Les outils correspondants sont alors temporairement « déverrouillés » et injectés dans le contexte pour un nombre configuré de tours (`ttl`). ### Configuration globale | Config | Type | Par défaut | Description | |-------------|--------|------------|----------------------------------------------| | `enabled` | bool | false | Activer l'intégration MCP globalement | | `discovery` | object | `{}` | Configuration de la découverte d'outils (voir ci-dessous) | | `servers` | object | `{}` | Mappage du nom de serveur à la configuration du serveur | ### Configuration Discovery (`discovery`) | Config | Type | Par défaut | Description | |----------------------|------|------------|-----------------------------------------------------------------------------------------------------------------------------------| | `enabled` | bool | false | Si true, les outils MCP sont masqués et chargés à la demande via la recherche. Si false, tous les outils sont chargés | | `ttl` | int | 5 | Nombre de tours de conversation pendant lesquels un outil découvert reste déverrouillé | | `max_search_results` | int | 5 | Nombre maximum d'outils retournés par requête de recherche | | `use_bm25` | bool | true | Activer l'outil de recherche par langage naturel/mots-clés (`tool_search_tool_bm25`). **Attention** : consomme plus de ressources que la recherche regex | | `use_regex` | bool | false | Activer l'outil de recherche par motif regex (`tool_search_tool_regex`) | > **Note :** Si `discovery.enabled` est `true`, vous **devez** activer au moins un moteur de recherche (`use_bm25` ou `use_regex`), > sinon l'application ne démarrera pas. ### Configuration par serveur | Config | Type | Requis | Description | |------------|--------|----------|--------------------------------------------| | `enabled` | bool | oui | Activer ce serveur MCP | | `type` | string | non | Type de transport : `stdio`, `sse`, `http` | | `command` | string | stdio | Commande exécutable pour le transport stdio | | `args` | array | non | Arguments de commande pour le transport stdio | | `env` | object | non | Variables d'environnement pour le processus stdio | | `env_file` | string | non | Chemin vers le fichier d'environnement pour le processus stdio | | `url` | string | sse/http | URL du point de terminaison pour le transport `sse`/`http` | | `headers` | object | non | En-têtes HTTP pour le transport `sse`/`http` | ### Comportement du transport - Si `type` est omis, le transport est détecté automatiquement : - `url` est défini → `sse` - `command` est défini → `stdio` - `http` et `sse` utilisent tous deux `url` + `headers` optionnels. - `env` et `env_file` ne sont appliqués qu'aux serveurs `stdio`. ### Exemples de configuration #### 1) Serveur MCP Stdio ```json { "tools": { "mcp": { "enabled": true, "servers": { "filesystem": { "enabled": true, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-filesystem", "/tmp" ] } } } } } ``` #### 2) Serveur MCP distant SSE/HTTP ```json { "tools": { "mcp": { "enabled": true, "servers": { "remote-mcp": { "enabled": true, "type": "sse", "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer YOUR_TOKEN" } } } } } } ``` #### 3) Configuration MCP massive avec découverte d'outils activée *Dans cet exemple, le LLM ne verra que `tool_search_tool_bm25`. Il recherchera et déverrouillera dynamiquement les outils Github ou Postgres uniquement lorsque l'utilisateur le demande.* ```json { "tools": { "mcp": { "enabled": true, "discovery": { "enabled": true, "ttl": 5, "max_search_results": 5, "use_bm25": true, "use_regex": false }, "servers": { "github": { "enabled": true, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-github" ], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" } }, "postgres": { "enabled": true, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-postgres", "postgresql://user:password@localhost/dbname" ] }, "slack": { "enabled": true, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-slack" ], "env": { "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" } } } } } } ``` ## Outil Skills L'outil skills configure la découverte et l'installation de compétences via des registres comme ClawHub. ### Registres | Config | Type | Par défaut | Description | |------------------------------------|--------|----------------------|----------------------------------------------| | `registries.clawhub.enabled` | bool | true | Activer le registre ClawHub | | `registries.clawhub.base_url` | string | `https://clawhub.ai` | URL de base ClawHub | | `registries.clawhub.auth_token` | string | `""` | Jeton Bearer optionnel pour des limites de débit plus élevées | | `registries.clawhub.search_path` | string | `/api/v1/search` | Chemin de l'API de recherche | | `registries.clawhub.skills_path` | string | `/api/v1/skills` | Chemin de l'API Skills | | `registries.clawhub.download_path` | string | `/api/v1/download` | Chemin de l'API de téléchargement | ### Exemple de configuration ```json { "tools": { "skills": { "registries": { "clawhub": { "enabled": true, "base_url": "https://clawhub.ai", "auth_token": "", "search_path": "/api/v1/search", "skills_path": "/api/v1/skills", "download_path": "/api/v1/download" } } } } } ``` ## Variables d'environnement Toutes les options de configuration peuvent être remplacées via des variables d'environnement au format `PICOCLAW_TOOLS_
_` : Par exemple : - `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` - `PICOCLAW_TOOLS_EXEC_ENABLED=false` - `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` - `PICOCLAW_TOOLS_MCP_ENABLED=true` Note : La configuration de type map imbriquée (par exemple `tools.mcp.servers..*`) est configurée dans `config.json` plutôt que via des variables d'environnement. ================================================ FILE: docs/fr/troubleshooting.md ================================================ # 🐛 Dépannage > Retour au [README](../../README.fr.md) ## "model ... not found in model_list" ou OpenRouter "free is not a valid model ID" **Symptôme :** Vous voyez l'une des erreurs suivantes : - `Error creating provider: model "openrouter/free" not found in model_list` - OpenRouter retourne 400 : `"free is not a valid model ID"` **Cause :** Le champ `model` dans votre entrée `model_list` est ce qui est envoyé à l'API. Pour OpenRouter, vous devez utiliser l'identifiant de modèle **complet**, pas un raccourci. - **Incorrect :** `"model": "free"` → OpenRouter reçoit `free` et le rejette. - **Correct :** `"model": "openrouter/free"` → OpenRouter reçoit `openrouter/free` (routage automatique du niveau gratuit). **Correction :** Dans `~/.picoclaw/config.json` (ou votre chemin de configuration) : 1. **agents.defaults.model** doit correspondre à un `model_name` dans `model_list` (par ex. `"openrouter-free"`). 2. Le **model** de cette entrée doit être un identifiant de modèle OpenRouter valide, par exemple : - `"openrouter/free"` – niveau gratuit automatique - `"google/gemini-2.0-flash-exp:free"` - `"meta-llama/llama-3.1-8b-instruct:free"` Exemple : ```json { "agents": { "defaults": { "model": "openrouter-free" } }, "model_list": [ { "model_name": "openrouter-free", "model": "openrouter/free", "api_key": "sk-or-v1-YOUR_OPENROUTER_KEY", "api_base": "https://openrouter.ai/api/v1" } ] } ``` Obtenez votre clé sur [OpenRouter Keys](https://openrouter.ai/keys). ================================================ FILE: docs/it/configuration.md ================================================ # ⚙️ Guida alla Configurazione > Torna al [README](../../README.md) ## ⚙️ Configurazione File di configurazione: `~/.picoclaw/config.json` ### Variabili d'Ambiente Puoi sovrascrivere i percorsi predefiniti usando variabili d'ambiente. Questo è utile per installazioni portatili, distribuzioni containerizzate, o per eseguire picoclaw come servizio di sistema. Queste variabili sono indipendenti e controllano percorsi diversi. | Variabile | Descrizione | Percorso Predefinito | |-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| | `PICOCLAW_CONFIG` | Sovrascrive il percorso al file di configurazione. Indica direttamente a picoclaw quale `config.json` caricare, ignorando tutte le altre posizioni. | `~/.picoclaw/config.json` | | `PICOCLAW_HOME` | Sovrascrive la directory radice per i dati di picoclaw. Modifica la posizione predefinita del `workspace` e delle altre directory dati. | `~/.picoclaw` | **Esempi:** ```bash # Esegui picoclaw usando un file di configurazione specifico # Il percorso del workspace verrà letto da quel file di configurazione PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway # Esegui picoclaw con tutti i dati salvati in /opt/picoclaw # La configurazione verrà caricata dal percorso predefinito ~/.picoclaw/config.json # Il workspace verrà creato in /opt/picoclaw/workspace PICOCLAW_HOME=/opt/picoclaw picoclaw agent # Usa entrambi per un setup completamente personalizzato PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway ``` ### Struttura del Workspace PicoClaw salva i dati nel workspace configurato (predefinito: `~/.picoclaw/workspace`): ``` ~/.picoclaw/workspace/ ├── sessions/ # Sessioni di conversazione e cronologia ├── memory/ # Memoria a lungo termine (MEMORY.md) ├── state/ # Stato persistente (ultimo canale, ecc.) ├── cron/ # Database dei job pianificati ├── skills/ # Skill personalizzate ├── AGENTS.md # Guida al comportamento dell'agent ├── HEARTBEAT.md # Prompt per task periodici (controllato ogni 30 min) ├── IDENTITY.md # Identità dell'agent ├── SOUL.md # Anima dell'agent └── USER.md # Preferenze dell'utente ``` > **Nota:** Le modifiche a `AGENTS.md`, `SOUL.md`, `USER.md`, `IDENTITY.md` e `memory/MEMORY.md` vengono rilevate automaticamente a runtime tramite il tracciamento della data di modifica (mtime). **Non è necessario riavviare il gateway** dopo aver modificato questi file — l'agent caricherà il nuovo contenuto alla prossima richiesta. ### Sorgenti delle Skill Per impostazione predefinita, le skill vengono caricate da: 1. `~/.picoclaw/workspace/skills` (workspace) 2. `~/.picoclaw/skills` (globale) 3. `/skills` (builtin) Per configurazioni avanzate/di test, puoi sovrascrivere la directory radice delle skill builtin con: ```bash export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ``` ### Politica Unificata di Esecuzione dei Comandi - I comandi slash generici vengono eseguiti tramite un unico percorso in `pkg/agent/loop.go` via `commands.Executor`. - Gli adattatori dei canali non consumano più localmente i comandi generici; inoltrano il testo in entrata al percorso bus/agent. Telegram registra ancora automaticamente i comandi supportati all'avvio. - Un comando slash sconosciuto (ad esempio `/foo`) viene passato all'elaborazione LLM come se fosse un messaggio dell'utente. - Un comando registrato ma non supportato sul canale corrente (ad esempio `/show` su WhatsApp) restituisce un errore esplicito all'utente e interrompe l'elaborazione. ### 🔒 Sandbox di Sicurezza PicoClaw esegue in un ambiente sandboxed per impostazione predefinita. L'agent può accedere solo ai file ed eseguire comandi all'interno del workspace configurato. #### Configurazione Predefinita ```json { "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "restrict_to_workspace": true } } } ``` | Opzione | Predefinito | Descrizione | | ----------------------- | ----------------------- | ---------------------------------------------------- | | `workspace` | `~/.picoclaw/workspace` | Directory di lavoro dell'agent | | `restrict_to_workspace` | `true` | Limita l'accesso a file/comandi al workspace | #### Strumenti Protetti Quando `restrict_to_workspace: true`, i seguenti strumenti sono in sandbox: | Strumento | Funzione | Restrizione | | ------------- | ------------------------- | ---------------------------------------------------- | | `read_file` | Legge file | Solo file all'interno del workspace | | `write_file` | Scrive file | Solo file all'interno del workspace | | `list_dir` | Elenca directory | Solo directory all'interno del workspace | | `edit_file` | Modifica file | Solo file all'interno del workspace | | `append_file` | Aggiunge ai file | Solo file all'interno del workspace | | `exec` | Esegue comandi | I percorsi dei comandi devono essere nel workspace | #### Protezione Exec Aggiuntiva Anche con `restrict_to_workspace: false`, lo strumento `exec` blocca questi comandi pericolosi: * `rm -rf`, `del /f`, `rmdir /s` — Cancellazione di massa * `format`, `mkfs`, `diskpart` — Formattazione del disco * `dd if=` — Imaging del disco * Scrittura su `/dev/sd[a-z]` — Scritture dirette su disco * `shutdown`, `reboot`, `poweroff` — Spegnimento del sistema * Fork bomb `:(){ :|:& };:` ### Controllo Accesso ai File | Chiave di configurazione | Tipo | Predefinito | Descrizione | |--------------------------|------|-------------|-------------| | `tools.allow_read_paths` | string[] | `[]` | Percorsi aggiuntivi consentiti per la lettura al di fuori del workspace | | `tools.allow_write_paths` | string[] | `[]` | Percorsi aggiuntivi consentiti per la scrittura al di fuori del workspace | ### Sicurezza Exec | Chiave di configurazione | Tipo | Predefinito | Descrizione | |--------------------------|------|-------------|-------------| | `tools.exec.allow_remote` | bool | `false` | Consente lo strumento exec da canali remoti (Telegram/Discord ecc.) | | `tools.exec.enable_deny_patterns` | bool | `true` | Abilita l'intercettazione dei comandi pericolosi | | `tools.exec.custom_deny_patterns` | string[] | `[]` | Pattern regex personalizzati da bloccare | | `tools.exec.custom_allow_patterns` | string[] | `[]` | Pattern regex personalizzati da consentire | > **Nota di sicurezza:** La protezione dei symlink è abilitata per impostazione predefinita — tutti i percorsi file vengono risolti tramite `filepath.EvalSymlinks` prima del confronto con la whitelist, prevenendo attacchi di escape tramite symlink. #### Limitazione Nota: Processi Figlio degli Strumenti di Build Il controllo di sicurezza exec ispeziona solo la riga di comando avviata direttamente da PicoClaw. Non ispeziona ricorsivamente i processi figlio generati da strumenti di sviluppo consentiti come `make`, `go run`, `cargo`, `npm run` o script di build personalizzati. Ciò significa che un comando di primo livello può comunque compilare o avviare altri binari dopo aver superato il controllo iniziale. In pratica, tratta gli script di build, i Makefile, gli script di pacchetti e i binari generati come codice eseguibile che richiede lo stesso livello di revisione di un comando shell diretto. Per ambienti ad alto rischio: * Esamina gli script di build prima dell'esecuzione. * Preferisci l'approvazione/revisione manuale per i workflow di compilazione ed esecuzione. * Esegui PicoClaw in un container o VM se hai bisogno di un isolamento più forte di quello fornito dal controllo integrato. #### Esempi di Errore ``` [ERROR] tool: Tool execution failed {tool=exec, error=Command blocked by safety guard (path outside working dir)} ``` ``` [ERROR] tool: Tool execution failed {tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} ``` #### Disabilitare le Restrizioni (Rischio di Sicurezza) Se hai bisogno che l'agent acceda a percorsi al di fuori del workspace: **Metodo 1: File di configurazione** ```json { "agents": { "defaults": { "restrict_to_workspace": false } } } ``` **Metodo 2: Variabile d'ambiente** ```bash export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false ``` > ⚠️ **Attenzione**: Disabilitare questa restrizione consente all'agent di accedere a qualsiasi percorso sul tuo sistema. Usare con cautela solo in ambienti controllati. #### Coerenza dei Confini di Sicurezza L'impostazione `restrict_to_workspace` si applica in modo coerente a tutti i percorsi di esecuzione: | Percorso di esecuzione | Confine di sicurezza | | ---------------------- | --------------------------------- | | Main Agent | `restrict_to_workspace` ✅ | | Subagent / Spawn | Eredita la stessa restrizione ✅ | | Heartbeat tasks | Eredita la stessa restrizione ✅ | Tutti i percorsi condividono la stessa restrizione del workspace — non è possibile aggirare il confine di sicurezza tramite subagent o task pianificati. ### Heartbeat (Task Periodici) PicoClaw può eseguire task periodici automaticamente. Crea un file `HEARTBEAT.md` nel tuo workspace: ```markdown # Periodic Tasks - Check my email for important messages - Review my calendar for upcoming events - Check the weather forecast ``` L'agent leggerà questo file ogni 30 minuti (configurabile) ed eseguirà tutti i task usando gli strumenti disponibili. #### Task Asincroni con Spawn Per task di lunga durata (ricerca web, chiamate API), usa lo strumento `spawn` per creare un **subagent**: ```markdown # Periodic Tasks ``` ================================================ FILE: docs/ja/chat-apps.md ================================================ # 💬 チャットアプリ設定 > [README](../../README.ja.md) に戻る ## 💬 チャットアプリ連携 PicoClaw は複数のチャットプラットフォームをサポートしており、Agent をどこにでも接続できます。 > **注意**: すべての Webhook ベースのチャネル(LINE、WeCom など)は、共有 Gateway HTTP サーバー(`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`)上で提供されます。チャネルごとにポートを設定する必要はありません。注意:飛書(Feishu)は WebSocket/SDK モードを使用し、共有 HTTP Webhook サーバーは使用しません。 ### チャネル一覧 | チャネル | セットアップ難易度 | 特徴 | ドキュメント | | -------------------- | ------------------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | **Telegram** | ⭐ 簡単 | 推奨、音声テキスト変換対応、ロングポーリング(公開 IP 不要) | [ドキュメント](../channels/telegram/README.zh.md) | | **Discord** | ⭐ 簡単 | Socket Mode、グループ/DM 対応、Bot エコシステム充実 | [ドキュメント](../channels/discord/README.zh.md) | | **WhatsApp** | ⭐ 簡単 | ネイティブ (QR スキャン) または Bridge URL | [ドキュメント](../channels/whatsapp/README.zh.md) | | **Slack** | ⭐ 簡単 | **Socket Mode** (公開 IP 不要)、エンタープライズ対応 | [ドキュメント](../channels/slack/README.zh.md) | | **Matrix** | ⭐⭐ 中程度 | フェデレーションプロトコル、セルフホスト対応 | [ドキュメント](../channels/matrix/README.zh.md) | | **QQ** | ⭐⭐ 中程度 | 公式ボット API、中国コミュニティ向け | [ドキュメント](../channels/qq/README.zh.md) | | **DingTalk** | ⭐⭐ 中程度 | Stream モード(公開 IP 不要)、企業向け | [ドキュメント](../channels/dingtalk/README.zh.md) | | **LINE** | ⭐⭐⭐ やや難 | HTTPS Webhook が必要 | [ドキュメント](../channels/line/README.zh.md) | | **WeCom (企業微信)** | ⭐⭐⭐ やや難 | グループ Bot (Webhook)、カスタムアプリ (API)、AI Bot 対応 | [Bot](../channels/wecom/wecom_bot/README.zh.md) / [App](../channels/wecom/wecom_app/README.zh.md) / [AI Bot](../channels/wecom/wecom_aibot/README.zh.md) | | **Feishu (飛書)** | ⭐⭐⭐ やや難 | エンタープライズコラボレーション、機能豊富 | [ドキュメント](../channels/feishu/README.zh.md) | | **IRC** | ⭐⭐ 中程度 | サーバー + TLS 設定 | - | | **OneBot** | ⭐⭐ 中程度 | NapCat/Go-CQHTTP 互換、コミュニティエコシステム充実 | [ドキュメント](../channels/onebot/README.zh.md) | | **MaixCam** | ⭐ 簡単 | Sipeed AI カメラハードウェア統合チャネル | [ドキュメント](../channels/maixcam/README.zh.md) | | **Pico** | ⭐ 簡単 | PicoClaw ネイティブプロトコルチャネル | | ---
Telegram(推奨) **1. Bot を作成** * Telegram を開き、`@BotFather` を検索 * `/newbot` を送信し、プロンプトに従う * Token をコピー **2. 設定** ```json { "channels": { "telegram": { "enabled": true, "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } } } ``` > Telegram の `@userinfobot` から User ID を取得できます。 **3. 実行** ```bash picoclaw gateway ``` **4. Telegram コマンドメニュー(起動時に自動登録)** PicoClaw は統一されたコマンド定義を使用します。起動時に Telegram がサポートするコマンド(例: `/start`、`/help`、`/show`、`/list`)を Bot コマンドメニューに自動登録し、メニュー表示と実際の動作を一致させます。 Telegram 側はコマンドメニュー登録機能を保持し、汎用コマンドの実行は Agent Loop 内の commands executor で統一的に処理されます。 ネットワークや API の一時的なエラーで登録に失敗しても、チャネルの起動はブロックされません。システムがバックグラウンドで自動リトライします。
Discord **1. Bot を作成** * にアクセス * アプリケーションを作成 → Bot → Bot を追加 * Bot Token をコピー **2. Intents を有効化** * Bot 設定で **MESSAGE CONTENT INTENT** を有効化 * (オプション)メンバーデータに基づくホワイトリストが必要な場合は **SERVER MEMBERS INTENT** を有効化 **3. User ID を取得** * Discord 設定 → 詳細設定 → **開発者モード** を有効化 * アバターを右クリック → **ユーザー ID をコピー** **4. 設定** ```json { "channels": { "discord": { "enabled": true, "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } } } ``` **5. Bot を招待** * OAuth2 → URL Generator * Scopes: `bot` * Bot Permissions: `Send Messages`, `Read Message History` * 生成された招待リンクを開き、Bot をサーバーに追加 **オプション:グループトリガーモード** デフォルトでは Bot はサーバーチャネル内のすべてのメッセージに応答します。@メンション時のみ応答するには: ```json { "channels": { "discord": { "group_trigger": { "mention_only": true } } } } ``` キーワードプレフィックスでトリガーすることもできます(例: `!bot`): ```json { "channels": { "discord": { "group_trigger": { "prefixes": ["!bot"] } } } } ``` **6. 実行** ```bash picoclaw gateway ```
WhatsApp(ネイティブ whatsmeow) PicoClaw は 2 つの WhatsApp 接続方式をサポートしています: - **ネイティブ(推奨):** プロセス内で [whatsmeow](https://github.com/tulir/whatsmeow) を使用。独立した Bridge は不要です。`"use_native": true` に設定し、`bridge_url` を空にします。初回実行時に WhatsApp で QR コードをスキャン(リンクデバイス)。セッションはワークスペース配下(例: `workspace/whatsapp/`)に保存されます。ネイティブチャネルは**オプション**ビルドで、`-tags whatsapp_native` でコンパイルします(例: `make build-whatsapp-native` または `go build -tags whatsapp_native ./cmd/...`)。 - **Bridge:** 外部 WebSocket Bridge に接続。`bridge_url`(例: `ws://localhost:3001`)を設定し、`use_native` を false のままにします。 **設定(ネイティブ)** ```json { "channels": { "whatsapp": { "enabled": true, "use_native": true, "session_store_path": "", "allow_from": [] } } } ``` `session_store_path` が空の場合、セッションは `/whatsapp/` に保存されます。`picoclaw gateway` を実行し、初回実行時にターミナルに表示される QR コードをスキャンしてください(WhatsApp → リンクデバイス)。
Matrix **1. Bot アカウントを準備** * お好みの homeserver(例: `https://matrix.org` またはセルフホスト)を使用 * Bot ユーザーを作成し、access token を取得 **2. 設定** ```json { "channels": { "matrix": { "enabled": true, "homeserver": "https://matrix.org", "user_id": "@your-bot:matrix.org", "access_token": "YOUR_MATRIX_ACCESS_TOKEN", "allow_from": [] } } } ``` **3. 実行** ```bash picoclaw gateway ``` すべてのオプション(`device_id`、`join_on_invite`、`group_trigger`、`placeholder`、`reasoning_channel_id`)については [Matrix チャネル設定ガイド](../channels/matrix/README.md) を参照してください。
QQ **1. Bot を作成** - [QQ 開放プラットフォーム](https://q.qq.com/#) にアクセス - アプリケーションを作成 → **AppID** と **AppSecret** を取得 **2. 設定** ```json { "channels": { "qq": { "enabled": true, "app_id": "YOUR_APP_ID", "app_secret": "YOUR_APP_SECRET", "allow_from": [] } } } ``` > `allow_from` を空にするとすべてのユーザーを許可します。QQ 番号を指定してアクセスを制限することもできます。 **3. 実行** ```bash picoclaw gateway ```
Slack **1. Slack App を作成** * [Slack API](https://api.slack.com/apps) でアプリを作成 * **Socket Mode** を有効化 * **Bot Token** と **App-Level Token** を取得 **2. 設定** ```json { "channels": { "slack": { "enabled": true, "bot_token": "xoxb-YOUR_BOT_TOKEN", "app_token": "xapp-YOUR_APP_TOKEN", "allow_from": [] } } } ``` **3. 実行** ```bash picoclaw gateway ```
IRC **1. 設定** ```json { "channels": { "irc": { "enabled": true, "server": "irc.libera.chat:6697", "nick": "picoclaw-bot", "use_tls": true, "channels_to_join": ["#your-channel"], "allow_from": [] } } } ``` **2. 実行** ```bash picoclaw gateway ```
DingTalk **1. Bot を作成** * [開放プラットフォーム](https://open.dingtalk.com/) にアクセス * 内部アプリを作成 * Client ID と Client Secret をコピー **2. 設定** ```json { "channels": { "dingtalk": { "enabled": true, "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "allow_from": [] } } } ``` > `allow_from` を空にするとすべてのユーザーを許可します。DingTalk ユーザー ID を指定してアクセスを制限することもできます。 **3. 実行** ```bash picoclaw gateway ```
LINE **1. LINE 公式アカウントを作成** - [LINE Developers Console](https://developers.line.biz/) にアクセス - Provider を作成 → Messaging API チャネルを作成 - **Channel Secret** と **Channel Access Token** をコピー **2. 設定** ```json { "channels": { "line": { "enabled": true, "channel_secret": "YOUR_CHANNEL_SECRET", "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", "webhook_path": "/webhook/line", "allow_from": [] } } } ``` > LINE Webhook は共有 Gateway サーバー(`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`)上で提供されます。 **3. Webhook URL を設定** LINE は HTTPS Webhook が必要です。リバースプロキシまたはトンネルを使用してください: ```bash # 例:ngrok を使用(Gateway デフォルトポートは 18790) ngrok http 18790 ``` LINE Developers Console で Webhook URL を `https://your-domain/webhook/line` に設定し、**Use webhook** を有効にしてください。 **4. 実行** ```bash picoclaw gateway ``` > グループチャットでは、Bot は @メンション時のみ応答します。返信は元のメッセージを引用します。
Feishu (飛書) **1. アプリを作成** * [飛書開放プラットフォーム](https://open.feishu.cn/) にアクセス * 企業カスタムアプリを作成 * **App ID** と **App Secret** を取得 **2. 設定** ```json { "channels": { "feishu": { "enabled": true, "app_id": "cli_xxx", "app_secret": "xxx", "encrypt_key": "", "verification_token": "", "allow_from": [] } } } ``` **3. 実行** ```bash picoclaw gateway ```
WeCom (企業微信) PicoClaw は 3 種類の WeCom 統合をサポートしています: **方式 1: グループ Bot (Bot)** — セットアップ簡単、グループチャット対応 **方式 2: カスタムアプリ (App)** — より多機能、プロアクティブメッセージング、プライベートチャットのみ **方式 3: AI Bot** — 公式 AI Bot、ストリーミング返信、グループ・プライベートチャット対応 詳細なセットアップ手順は [WeCom AI Bot 設定ガイド](../channels/wecom/wecom_aibot/README.zh.md) を参照してください。 **クイックセットアップ — グループ Bot:** **1. Bot を作成** * WeCom 管理コンソール → グループチャット → グループ Bot を追加 * Webhook URL をコピー(形式:`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) **2. 設定** ```json { "channels": { "wecom": { "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", "webhook_path": "/webhook/wecom", "allow_from": [] } } } ``` > WeCom Webhook は共有 Gateway サーバー(`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`)上で提供されます。 **クイックセットアップ — カスタムアプリ:** **1. アプリを作成** * WeCom 管理コンソール → アプリ管理 → アプリを作成 * **AgentId** と **Secret** をコピー * 「マイ企業」ページで **CorpID** をコピー **2. メッセージ受信を設定** * アプリ詳細で「メッセージ受信」→「API を設定」をクリック * URL を `http://your-server:18790/webhook/wecom-app` に設定 * **Token** と **EncodingAESKey** を生成 **3. 設定** ```json { "channels": { "wecom_app": { "enabled": true, "corp_id": "wwxxxxxxxxxxxxxxxx", "corp_secret": "YOUR_CORP_SECRET", "agent_id": 1000002, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-app", "allow_from": [] } } } ``` **4. 実行** ```bash picoclaw gateway ``` > **注意**: WeCom Webhook コールバックは Gateway ポート(デフォルト 18790)で提供されます。HTTPS にはリバースプロキシを使用してください。 **クイックセットアップ — AI Bot:** **1. AI Bot を作成** * WeCom 管理コンソール → アプリ管理 → AI Bot * AI Bot 設定でコールバック URL を設定:`http://your-server:18791/webhook/wecom-aibot` * **Token** をコピーし、「ランダム生成」をクリックして **EncodingAESKey** を取得 **2. 設定** ```json { "channels": { "wecom_aibot": { "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-aibot", "allow_from": [], "welcome_message": "こんにちは!何かお手伝いできますか?", "processing_message": "⏳ Processing, please wait. The results will be sent shortly." } } } ``` **3. 実行** ```bash picoclaw gateway ``` > **注意**: WeCom AI Bot はストリーミングプルプロトコルを使用しており、返信タイムアウトの心配はありません。長時間タスク(30 秒超)は自動的に `response_url` プッシュ配信に切り替わります。
OneBot **1. 設定** NapCat / Go-CQHTTP などの OneBot 実装と互換性があります。 ```json { "channels": { "onebot": { "enabled": true, "allow_from": [] } } } ``` **2. 実行** ```bash picoclaw gateway ```
MaixCam Sipeed AI カメラハードウェア向けの統合チャネルです。 ```json { "channels": { "maixcam": { "enabled": true } } } ``` ```bash picoclaw gateway ```
================================================ FILE: docs/ja/configuration.md ================================================ # ⚙️ 設定ガイド > [README](../../README.ja.md) に戻る ## ⚙️ 設定詳細 設定ファイルパス: `~/.picoclaw/config.json` ### 環境変数 環境変数を使用してデフォルトパスを上書きできます。ポータブルインストール、コンテナ化デプロイ、または picoclaw をシステムサービスとして実行する場合に便利です。これらの変数は独立しており、異なるパスを制御します。 | 変数 | 説明 | デフォルトパス | |-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| | `PICOCLAW_CONFIG` | 設定ファイルのパスを上書きします。picoclaw がどの `config.json` を読み込むかを直接指定し、他のすべての場所を無視します。 | `~/.picoclaw/config.json` | | `PICOCLAW_HOME` | picoclaw データのルートディレクトリを上書きします。`workspace` やその他のデータディレクトリのデフォルト場所を変更します。 | `~/.picoclaw` | **例:** ```bash # 特定の設定ファイルで picoclaw を実行 # ワークスペースパスはその設定ファイル内から読み込まれます PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway # /opt/picoclaw にすべてのデータを保存して picoclaw を実行 # 設定はデフォルトの ~/.picoclaw/config.json から読み込まれます # ワークスペースは /opt/picoclaw/workspace に作成されます PICOCLAW_HOME=/opt/picoclaw picoclaw agent # 両方を使用して完全にカスタマイズ PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway ``` ### ワークスペースレイアウト PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw/workspace`)にデータを保存します: ``` ~/.picoclaw/workspace/ ├── sessions/ # 会話セッションと履歴 ├── memory/ # 長期記憶 (MEMORY.md) ├── state/ # 永続化状態 (最後のチャネルなど) ├── cron/ # スケジュールジョブデータベース ├── skills/ # カスタムスキル ├── AGENT.md # Agent 動作ガイド ├── HEARTBEAT.md # 定期タスクプロンプト (30 分ごとにチェック) ├── IDENTITY.md # Agent アイデンティティ ├── SOUL.md # Agent ソウル/性格 └── USER.md # ユーザー設定 ``` > **注意:** `AGENT.md`、`SOUL.md`、`USER.md` および `memory/MEMORY.md` への変更は、ファイル更新時刻(mtime)の追跡により実行時に自動検出されます。これらのファイルを編集した後に **gateway を再起動する必要はありません** — Agent は次のリクエスト時に最新の内容を自動的に読み込みます。 ### スキルソース デフォルトでは、スキルは以下の順序で読み込まれます: 1. `~/.picoclaw/workspace/skills`(ワークスペース) 2. `~/.picoclaw/skills`(グローバル) 3. `/skills`(ビルトイン) 高度な/テスト用セットアップでは、以下の環境変数でビルトインスキルのルートを上書きできます: ```bash export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ``` ### 統一コマンド実行ポリシー - 汎用スラッシュコマンドは `pkg/agent/loop.go` 内の `commands.Executor` を通じて統一的に実行されます。 - チャネルアダプターはローカルで汎用コマンドを消費しなくなりました。受信テキストを bus/agent パスに転送するだけです。Telegram は起動時にサポートするコマンドメニューを自動登録します。 - 未登録のスラッシュコマンド(例: `/foo`)は通常の LLM 処理にパススルーされます。 - 登録済みだが現在のチャネルでサポートされていないコマンド(例: WhatsApp での `/show`)は、明示的なユーザー向けエラーを返し、以降の処理を停止します。 ### 🔒 セキュリティサンドボックス PicoClaw はデフォルトでサンドボックス環境で実行されます。Agent は設定されたワークスペース内のファイルアクセスとコマンド実行のみが可能です。 #### デフォルト設定 ```json { "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "restrict_to_workspace": true } } } ``` | オプション | デフォルト値 | 説明 | | ----------------------- | ----------------------- | ------------------------------------- | | `workspace` | `~/.picoclaw/workspace` | Agent の作業ディレクトリ | | `restrict_to_workspace` | `true` | ファイル/コマンドアクセスをワークスペース内に制限 | #### 保護されたツール `restrict_to_workspace: true` の場合、以下のツールがサンドボックス化されます: | ツール | 機能 | 制限 | | ------------- | ---------------- | ---------------------------------- | | `read_file` | ファイル読み取り | ワークスペース内のファイルのみ | | `write_file` | ファイル書き込み | ワークスペース内のファイルのみ | | `list_dir` | ディレクトリ一覧 | ワークスペース内のディレクトリのみ | | `edit_file` | ファイル編集 | ワークスペース内のファイルのみ | | `append_file` | ファイル追記 | ワークスペース内のファイルのみ | | `exec` | コマンド実行 | コマンドパスはワークスペース内必須 | #### 追加の Exec 保護 `restrict_to_workspace: false` の場合でも、`exec` ツールは以下の危険なコマンドをブロックします: * `rm -rf`、`del /f`、`rmdir /s` — 一括削除 * `format`、`mkfs`、`diskpart` — ディスクフォーマット * `dd if=` — ディスクイメージング * `/dev/sd[a-z]` への書き込み — 直接ディスク書き込み * `shutdown`、`reboot`、`poweroff` — システムシャットダウン * Fork bomb `:(){ :|:& };:` ### ファイルアクセス制御 | 設定キー | 型 | デフォルト値 | 説明 | |----------|------|-------------|------| | `tools.allow_read_paths` | string[] | `[]` | ワークスペース外で読み取りを許可する追加パス | | `tools.allow_write_paths` | string[] | `[]` | ワークスペース外で書き込みを許可する追加パス | ### Exec セキュリティ設定 | 設定キー | 型 | デフォルト値 | 説明 | |----------|------|-------------|------| | `tools.exec.allow_remote` | bool | `false` | リモートチャネル(Telegram/Discord など)からの exec ツール実行を許可 | | `tools.exec.enable_deny_patterns` | bool | `true` | 危険なコマンドのインターセプトを有効化 | | `tools.exec.custom_deny_patterns` | string[] | `[]` | カスタムブロック正規表現パターン | | `tools.exec.custom_allow_patterns` | string[] | `[]` | カスタム許可正規表現パターン | > **セキュリティ注意:** Symlink 保護はデフォルトで有効です。すべてのファイルパスはホワイトリストマッチング前に `filepath.EvalSymlinks` で解決され、シンボリックリンクエスケープ攻撃を防止します。 #### 既知の制限:ビルドツールの子プロセス exec セキュリティガードは PicoClaw が直接起動するコマンドラインのみを検査します。`make`、`go run`、`cargo`、`npm run`、またはカスタムビルドスクリプトなどの開発ツールが生成する子プロセスは再帰的に検査しません。 つまり、トップレベルのコマンドが初期ガードチェックを通過した後、他のバイナリをコンパイルまたは起動できます。実際には、ビルドスクリプト、Makefile、パッケージスクリプト、生成されたバイナリを、直接のシェルコマンドと同等レベルの実行可能コードとしてレビューする必要があります。 高リスク環境の場合: * 実行前にビルドスクリプトをレビューしてください。 * コンパイル・実行ワークフローには承認/手動レビューを優先してください。 * ビルトインガードより強力な分離が必要な場合は、コンテナまたは VM 内で PicoClaw を実行してください。 #### エラー例 ``` [ERROR] tool: Tool execution failed {tool=exec, error=Command blocked by safety guard (path outside working dir)} ``` ``` [ERROR] tool: Tool execution failed {tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} ``` #### 制限の無効化(セキュリティリスク) Agent がワークスペース外のパスにアクセスする必要がある場合: **方法 1: 設定ファイル** ```json { "agents": { "defaults": { "restrict_to_workspace": false } } } ``` **方法 2: 環境変数** ```bash export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false ``` > ⚠️ **警告**: この制限を無効にすると、Agent がシステム上の任意のパスにアクセスできるようになります。管理された環境でのみ慎重に使用してください。 #### セキュリティ境界の一貫性 `restrict_to_workspace` 設定はすべての実行パスで一貫して適用されます: | 実行パス | セキュリティ境界 | | ---------------- | ---------------------------- | | メイン Agent | `restrict_to_workspace` ✅ | | サブ Agent / Spawn | 同じ制限を継承 ✅ | | ハートビートタスク | 同じ制限を継承 ✅ | すべてのパスは同じワークスペース制限を共有しており、サブ Agent やスケジュールタスクを通じてセキュリティ境界を回避することはできません。 ### ハートビート(定期タスク) PicoClaw は定期タスクを自動実行できます。ワークスペースに `HEARTBEAT.md` ファイルを作成してください: ```markdown # Periodic Tasks - Check my email for important messages - Review my calendar for upcoming events - Check the weather forecast ``` Agent は 30 分ごと(設定可能)にこのファイルを読み取り、利用可能なツールを使用してタスクを実行します。 #### Spawn を使用した非同期タスク 長時間実行タスク(Web 検索、API 呼び出し)には、`spawn` ツールを使用して**サブ Agent (subagent)** を作成します: ```markdown # Periodic Tasks ## Quick Tasks (respond directly) - Report current time ## Long Tasks (use spawn for async) - Search the web for AI news and summarize - Check email and report important messages ``` **主な動作:** | 特性 | 説明 | | ---------------- | -------------------------------------------- | | **spawn** | 非同期サブ Agent を作成、メインハートビートをブロックしない | | **独立コンテキスト** | サブ Agent は独自のコンテキストを持ち、セッション履歴なし | | **message tool** | サブ Agent は message ツールでユーザーと直接通信 | | **ノンブロッキング** | spawn 後、ハートビートは次のタスクに進む | **設定:** ```json { "heartbeat": { "enabled": true, "interval": 30 } } ``` | オプション | デフォルト値 | 説明 | | ---------- | ------------ | ------------------------------ | | `enabled` | `true` | ハートビートの有効/無効 | | `interval` | `30` | チェック間隔(分単位、最小: 5)| **環境変数:** - `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化 - `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔を変更 ================================================ FILE: docs/ja/docker.md ================================================ # 🐳 Docker とクイックスタート > [README](../../README.ja.md) に戻る ## 🐳 Docker Compose Docker Compose を使用して PicoClaw を実行できます。ローカルに何もインストールする必要はありません。 ```bash # 1. リポジトリをクローン git clone https://github.com/sipeed/picoclaw.git cd picoclaw # 2. 初回実行 — docker/data/config.json を自動生成して終了 docker compose -f docker/docker-compose.yml --profile gateway up # コンテナが "First-run setup complete." と表示して停止します # 3. API Key を設定 vim docker/data/config.json # provider API key、Bot Token などを設定 # 4. 起動 docker compose -f docker/docker-compose.yml --profile gateway up -d ``` > [!TIP] > **Docker ユーザー**: デフォルトでは Gateway は `127.0.0.1` でリッスンしており、コンテナ外からはアクセスできません。ヘルスチェックエンドポイントへのアクセスやポート公開が必要な場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。 ```bash # 5. ログを確認 docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway # 6. 停止 docker compose -f docker/docker-compose.yml --profile gateway down ``` ### Launcher モード (Web コンソール) `launcher` イメージには 3 つのバイナリ(`picoclaw`、`picoclaw-launcher`、`picoclaw-launcher-tui`)がすべて含まれており、デフォルトで Web コンソールを起動します。ブラウザベースの設定・チャット画面を提供します。 ```bash docker compose -f docker/docker-compose.yml --profile launcher up -d ``` ブラウザで http://localhost:18800 を開いてください。Launcher が Gateway プロセスを自動管理します。 > [!WARNING] > Web コンソールはまだ認証をサポートしていません。公開インターネットに公開しないでください。 ### Agent モード (ワンショット) ```bash # 質問する docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2は?" # インタラクティブモード docker compose -f docker/docker-compose.yml run --rm picoclaw-agent ``` ### イメージの更新 ```bash docker compose -f docker/docker-compose.yml pull docker compose -f docker/docker-compose.yml --profile gateway up -d ``` --- ## 🚀 クイックスタート > [!TIP] > `~/.picoclaw/config.json` に API Key を設定してください。API Key の取得先: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。Web 検索は**オプション**です — 無料の [Tavily API](https://tavily.com) (月 1000 回無料) または [Brave Search API](https://brave.com/search/api) (月 2000 回無料) を取得できます。 **1. 初期化** ```bash picoclaw onboard ``` **2. 設定** (`~/.picoclaw/config.json`) ```json { "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "model_name": "gpt-5.4", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 } }, "model_list": [ { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", "api_key": "sk-your-api-key", "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_key": "your-api-key", "request_timeout": 300 }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", "api_key": "your-anthropic-key" } ], "tools": { "web": { "enabled": true, "fetch_limit_bytes": 10485760, "format": "plaintext", "brave": { "enabled": false, "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 }, "tavily": { "enabled": false, "api_key": "YOUR_TAVILY_API_KEY", "max_results": 5 }, "duckduckgo": { "enabled": true, "max_results": 5 }, "perplexity": { "enabled": false, "api_key": "YOUR_PERPLEXITY_API_KEY", "max_results": 5 }, "searxng": { "enabled": false, "base_url": "http://your-searxng-instance:8888", "max_results": 5 } } } } ``` > **新機能**: `model_list` 設定形式により、コード変更なしで provider を追加できます。詳細は[モデル設定](providers.md#モデル設定-model_list)を参照してください。 > `request_timeout` はオプションで、単位は秒です。省略または `<= 0` に設定した場合、PicoClaw はデフォルトのタイムアウト(120 秒)を使用します。 **3. API Key の取得** * **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) * **Web 検索** (オプション): * [Brave Search](https://brave.com/search/api) - 有料 ($5/1000 queries, ~$5-6/month) * [Perplexity](https://www.perplexity.ai) - AI 搭載の検索・チャットインターフェース * [SearXNG](https://github.com/searxng/searxng) - セルフホスト型メタ検索エンジン(無料、API Key 不要) * [Tavily](https://tavily.com) - AI Agent 向けに最適化 (1000 requests/month) * DuckDuckGo - 組み込みフォールバック(API Key 不要) > **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。 **4. チャット** ```bash picoclaw agent -m "2+2は?" ``` 以上です!2 分で動作する AI アシスタントが手に入ります。 --- ================================================ FILE: docs/ja/providers.md ================================================ # 🔌 プロバイダーとモデル設定 > [README](../../README.ja.md) に戻る ### プロバイダー > [!NOTE] > Groq は Whisper による無料の音声文字起こしを提供しています。Groq を設定すると、任意のチャネルからの音声メッセージが Agent レベルで自動的にテキストに変換されます。 | プロバイダー | 用途 | API Key の取得 | | -------------------- | ---------------------------- | -------------------------------------------------------------------- | | `gemini` | LLM (Gemini 直接接続) | [aistudio.google.com](https://aistudio.google.com) | | `zhipu` | LLM (Zhipu 直接接続) | [bigmodel.cn](https://bigmodel.cn) | | `volcengine` | LLM (Volcengine 直接接続) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | | `openrouter` | LLM (推奨、全モデルアクセス可) | [openrouter.ai](https://openrouter.ai) | | `anthropic` | LLM (Claude 直接接続) | [console.anthropic.com](https://console.anthropic.com) | | `openai` | LLM (GPT 直接接続) | [platform.openai.com](https://platform.openai.com) | | `deepseek` | LLM (DeepSeek 直接接続) | [platform.deepseek.com](https://platform.deepseek.com) | | `qwen` | LLM (Qwen 直接接続) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `groq` | LLM + **音声文字起こし** (Whisper) | [console.groq.com](https://console.groq.com) | | `cerebras` | LLM (Cerebras 直接接続) | [cerebras.ai](https://cerebras.ai) | | `vivgrid` | LLM (Vivgrid 直接接続) | [vivgrid.com](https://vivgrid.com) | | `moonshot` | LLM (Kimi/Moonshot 直接接続) | [platform.moonshot.cn](https://platform.moonshot.cn) | | `minimax` | LLM (Minimax 直接接続) | [platform.minimaxi.com](https://platform.minimaxi.com) | | `avian` | LLM (Avian 直接接続) | [avian.io](https://avian.io) | | `mistral` | LLM (Mistral 直接接続) | [console.mistral.ai](https://console.mistral.ai) | | `longcat` | LLM (Longcat 直接接続) | [longcat.ai](https://longcat.ai) | | `modelscope` | LLM (ModelScope 直接接続) | [modelscope.cn](https://modelscope.cn) | ### モデル設定 (model_list) > **新機能!** PicoClaw は**モデル中心**の設定方式を採用しました。`ベンダー/モデル` 形式(例: `zhipu/glm-4.7`)を指定するだけで新しい provider を追加できます——**コード変更は一切不要です!** この設計は**マルチ Agent シナリオ**もサポートし、柔軟な Provider 選択を提供します: - **Agent ごとに異なる Provider**: 各 Agent が独自の LLM provider を使用可能 - **モデルフォールバック**: プライマリモデルとフォールバックモデルを設定し、信頼性を向上 - **ロードバランシング**: 複数の API エンドポイント間でリクエストを分散 - **一元管理**: すべての provider を一箇所で管理 #### 📋 サポートされている全ベンダー | ベンダー | `model` プレフィックス | デフォルト API Base | プロトコル | API Key の取得 | | ------------------- | --------------------- | --------------------------------------------------- | ---------- | ----------------------------------------------------------------- | | **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [キーを取得](https://platform.openai.com) | | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [キーを取得](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [キーを取得](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [キーを取得](https://platform.deepseek.com) | | **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [キーを取得](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [キーを取得](https://console.groq.com) | | **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [キーを取得](https://platform.moonshot.cn) | | **通義千問 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [キーを取得](https://dashscope.console.aliyun.com) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [キーを取得](https://build.nvidia.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | ローカル(キー不要) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [キーを取得](https://openrouter.ai/keys) | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | LiteLLM プロキシキー | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ローカル | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) | | **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | | **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [キーを取得](https://www.byteplus.com) | | **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [キーを取得](https://vivgrid.com) | | **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [キーを取得](https://longcat.chat/platform) | | **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [トークンを取得](https://modelscope.cn/my/tokens) | | **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuth のみ | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | #### 基本設定 ```json { "model_list": [ { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", "api_key": "sk-your-api-key" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_key": "sk-your-openai-key" }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", "api_key": "sk-ant-your-key" }, { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", "api_key": "your-zhipu-key" } ], "agents": { "defaults": { "model": "gpt-5.4" } } } ``` #### ベンダー別設定例 **OpenAI** ```json { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_key": "sk-..." } ``` **VolcEngine (Doubao)** ```json { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", "api_key": "sk-..." } ``` **智谱 AI (GLM)** ```json { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", "api_key": "your-key" } ``` **DeepSeek** ```json { "model_name": "deepseek-chat", "model": "deepseek/deepseek-chat", "api_key": "sk-..." } ``` **Anthropic (API キー使用)** ```json { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", "api_key": "sk-ant-your-key" } ``` > `picoclaw auth login --provider anthropic` を実行して API トークンを設定してください。 **Anthropic Messages API(ネイティブ形式)** Anthropic API への直接アクセスや、Anthropic のネイティブメッセージ形式のみをサポートするカスタムエンドポイント向け: ```json { "model_name": "claude-opus-4-6", "model": "anthropic-messages/claude-opus-4-6", "api_key": "sk-ant-your-key", "api_base": "https://api.anthropic.com" } ``` > `anthropic-messages` プロトコルを使用するケース: > - Anthropic のネイティブ `/v1/messages` エンドポイントのみをサポートするサードパーティプロキシを使用する場合(OpenAI 互換の `/v1/chat/completions` 非対応) > - MiniMax、Synthetic など Anthropic のネイティブメッセージ形式を必要とするサービスに接続する場合 > - 既存の `anthropic` プロトコルが 404 エラーを返す場合(エンドポイントが OpenAI 互換形式をサポートしていないことを示す) > > **注意:** `anthropic` プロトコルは OpenAI 互換形式(`/v1/chat/completions`)を使用し、`anthropic-messages` は Anthropic のネイティブ形式(`/v1/messages`)を使用します。エンドポイントがサポートする形式に応じて選択してください。 **Ollama (ローカル)** ```json { "model_name": "llama3", "model": "ollama/llama3" } ``` **カスタムプロキシ/API** ```json { "model_name": "my-custom-model", "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", "api_key": "sk-...", "request_timeout": 300 } ``` **LiteLLM Proxy** ```json { "model_name": "lite-gpt4", "model": "litellm/lite-gpt4", "api_base": "http://localhost:4000/v1", "api_key": "sk-..." } ``` PicoClaw はリクエスト送信前に外側の `litellm/` プレフィックスのみを除去するため、`litellm/lite-gpt4` は `lite-gpt4` を送信し、`litellm/openai/gpt-4o` は `openai/gpt-4o` を送信します。 #### ロードバランシング 同じモデル名に複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンで分散します: ```json { "model_list": [ { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" } ] } ``` #### レガシー `providers` 設定からの移行 旧 `providers` 設定形式は**非推奨**ですが、後方互換性のためまだサポートされています。 **旧設定(非推奨):** ```json { "providers": { "zhipu": { "api_key": "your-key", "api_base": "https://open.bigmodel.cn/api/paas/v4" } }, "agents": { "defaults": { "provider": "zhipu", "model": "glm-4.7" } } } ``` **新設定(推奨):** ```json { "model_list": [ { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", "api_key": "your-key" } ], "agents": { "defaults": { "model": "glm-4.7" } } } ``` 詳細な移行ガイドは [docs/migration/model-list-migration.md](../migration/model-list-migration.md) を参照してください。 ### Provider アーキテクチャ PicoClaw はプロトコルファミリーごとに Provider をルーティングします: - OpenAI 互換プロトコル:OpenRouter、OpenAI 互換ゲートウェイ、Groq、Zhipu、vLLM スタイルのエンドポイント。 - Anthropic プロトコル:Claude ネイティブ API 動作。 - Codex/OAuth パス:OpenAI OAuth/Token 認証ルート。 これによりランタイムを軽量に保ちつつ、新しい OpenAI 互換バックエンドの追加をほぼ設定操作(`api_base` + `api_key`)のみで実現しています。
Zhipu 設定例 **1. API key と base URL を取得** - [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) を取得 **2. 設定** ```json { "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "model": "glm-4.7", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 } }, "providers": { "zhipu": { "api_key": "Your API Key", "api_base": "https://open.bigmodel.cn/api/paas/v4" } } } ``` **3. 実行** ```bash picoclaw agent -m "こんにちは" ```
完全な設定例 ```json { "agents": { "defaults": { "model": "anthropic/claude-opus-4-5" } }, "session": { "dm_scope": "per-channel-peer", "backlog_limit": 20 }, "providers": { "openrouter": { "api_key": "sk-or-v1-xxx" }, "groq": { "api_key": "gsk_xxx" } }, "channels": { "telegram": { "enabled": true, "token": "123456:ABC...", "allow_from": ["123456789"] }, "discord": { "enabled": true, "token": "", "allow_from": [""] }, "whatsapp": { "enabled": false, "bridge_url": "ws://localhost:3001", "use_native": false, "session_store_path": "", "allow_from": [] }, "feishu": { "enabled": false, "app_id": "cli_xxx", "app_secret": "xxx", "encrypt_key": "", "verification_token": "", "allow_from": [] }, "qq": { "enabled": false, "app_id": "", "app_secret": "", "allow_from": [] } }, "tools": { "web": { "brave": { "enabled": false, "api_key": "BSA...", "max_results": 5 }, "duckduckgo": { "enabled": true, "max_results": 5 }, "perplexity": { "enabled": false, "api_key": "", "max_results": 5 }, "searxng": { "enabled": false, "base_url": "http://localhost:8888", "max_results": 5 } }, "cron": { "exec_timeout_minutes": 5 } }, "heartbeat": { "enabled": true, "interval": 30 } } ```
--- ## 📝 API Key 比較表 | サービス | Pricing | ユースケース | | ---------------- | ------------------------ | ------------------------------------- | | **OpenRouter** | Free: 200K tokens/month | マルチモデル (Claude, GPT-4 など) | | **Volcengine CodingPlan** | ¥9.9/first month | 中国ユーザー向け、複数の SOTA モデル (Doubao, DeepSeek など) | | **Zhipu** | Free: 200K tokens/month | 中国ユーザー向け | | **Brave Search** | $5/1000 queries | Web 検索機能 | | **SearXNG** | Free (self-hosted) | プライバシー重視のメタ検索 (70+ engines) | | **Groq** | Free tier available | 高速推論 (Llama, Mixtral) | | **Cerebras** | Free tier available | 高速推論 (Llama, Qwen など) | | **LongCat** | Free: up to 5M tokens/day | 高速推論 | | **ModelScope** | Free: 2000 requests/day | 推論 (Qwen, GLM, DeepSeek など) | ---
PicoClaw Meme
================================================ FILE: docs/ja/spawn-tasks.md ================================================ # 🔄 非同期タスクと Spawn > [README](../../README.ja.md) に戻る ### Spawn を使用した非同期タスク 長時間実行タスク(Web 検索、API 呼び出し)には、`spawn` ツールを使用して**サブ Agent (subagent)** を作成します: ```markdown # Periodic Tasks ## Quick Tasks (respond directly) - Report current time ## Long Tasks (use spawn for async) - Search the web for AI news and summarize - Check email and report important messages ``` **主な動作:** | 特性 | 説明 | | ---------------- | ------------------------------------------------ | | **spawn** | 非同期サブ Agent を作成、メインハートビートをブロックしない | | **独立コンテキスト** | サブ Agent は独自のコンテキストを持ち、セッション履歴なし | | **message tool** | サブ Agent は message ツールでユーザーと直接通信 | | **ノンブロッキング** | spawn 後、ハートビートは次のタスクに進む | #### サブ Agent の通信の仕組み ``` ハートビートトリガー (Heartbeat triggers) ↓ Agent が HEARTBEAT.md を読み取り ↓ 長時間タスクの場合: サブ Agent を spawn ↓ ↓ 次のタスクに進む サブ Agent が独立して作業 ↓ ↓ すべてのタスク完了 サブ Agent が "message" ツールを使用 ↓ ↓ HEARTBEAT_OK を応答 ユーザーが直接結果を受信 ``` サブ Agent はツール(message、web_search など)にアクセスでき、メイン Agent を経由せずにユーザーと独立して通信できます。 **設定:** ```json { "heartbeat": { "enabled": true, "interval": 30 } } ``` | オプション | デフォルト値 | 説明 | | ---------- | ------------ | ------------------------------ | | `enabled` | `true` | ハートビートの有効/無効 | | `interval` | `30` | チェック間隔(分単位、最小: 5)| **環境変数:** - `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化 - `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔を変更 ================================================ FILE: docs/ja/tools_configuration.md ================================================ # 🔧 ツール設定 > [README](../../README.ja.md) に戻る PicoClaw のツール設定は `config.json` の `tools` フィールドにあります。 ## ディレクトリ構造 ```json { "tools": { "web": { ... }, "mcp": { ... }, "exec": { ... }, "cron": { ... }, "skills": { ... } } } ``` ## Web ツール Web ツールはウェブ検索とフェッチに使用されます。 ### Web Fetcher ウェブページコンテンツの取得と処理に関する一般設定。 | 設定項目 | 型 | デフォルト | 説明 | |---------------------|--------|---------------|----------------------------------------------------------------------------------------| | `enabled` | bool | true | ウェブページ取得機能を有効にする。 | | `fetch_limit_bytes` | int | 10485760 | 取得するウェブページペイロードの最大サイズ(バイト単位、デフォルトは10MB)。 | | `format` | string | "plaintext" | 取得コンテンツの出力形式。オプション:`plaintext` または `markdown`(推奨)。 | ### Brave | 設定項目 | 型 | デフォルト | 説明 | |---------------|--------|------------|-----------------------| | `enabled` | bool | false | Brave 検索を有効にする | | `api_key` | string | - | Brave Search API キー | | `max_results` | int | 5 | 最大結果数 | ### DuckDuckGo | 設定項目 | 型 | デフォルト | 説明 | |---------------|------|------------|---------------------------| | `enabled` | bool | true | DuckDuckGo 検索を有効にする | | `max_results` | int | 5 | 最大結果数 | ### Perplexity | 設定項目 | 型 | デフォルト | 説明 | |---------------|--------|------------|---------------------------| | `enabled` | bool | false | Perplexity 検索を有効にする | | `api_key` | string | - | Perplexity API キー | | `max_results` | int | 5 | 最大結果数 | ## Exec ツール Exec ツールはシェルコマンドの実行に使用されます。 | 設定項目 | 型 | デフォルト | 説明 | |------------------------|-------|------------|------------------------------------| | `enabled` | bool | true | Exec ツールを有効にする | | `enable_deny_patterns` | bool | true | デフォルトの危険コマンドブロックを有効にする | | `custom_deny_patterns` | array | [] | カスタム拒否パターン(正規表現) | ### Exec ツールの無効化 `exec` ツールを完全に無効にするには、`enabled` を `false` に設定します: **設定ファイル経由:** ```json { "tools": { "exec": { "enabled": false } } } ``` **環境変数経由:** ```bash PICOCLAW_TOOLS_EXEC_ENABLED=false ``` > **注意:** 無効にすると、エージェントはシェルコマンドを実行できなくなります。これは Cron ツールがスケジュールされたシェルコマンドを実行する能力にも影響します。 ### 機能 - **`enable_deny_patterns`**:`false` に設定すると、デフォルトの危険コマンドブロックパターンを完全に無効にします - **`custom_deny_patterns`**:カスタム拒否正規表現パターンを追加します。一致するコマンドはブロックされます ### デフォルトでブロックされるコマンドパターン デフォルトで、PicoClaw は以下の危険なコマンドをブロックします: - 削除コマンド:`rm -rf`、`del /f/q`、`rmdir /s` - ディスク操作:`format`、`mkfs`、`diskpart`、`dd if=`、`/dev/sd*` への書き込み - システム操作:`shutdown`、`reboot`、`poweroff` - コマンド置換:`$()`、`${}`、バッククォート - シェルへのパイプ:`| sh`、`| bash` - 権限昇格:`sudo`、`chmod`、`chown` - プロセス制御:`pkill`、`killall`、`kill -9` - リモート操作:`curl | sh`、`wget | sh`、`ssh` - パッケージ管理:`apt`、`yum`、`dnf`、`npm install -g`、`pip install --user` - コンテナ:`docker run`、`docker exec` - Git:`git push`、`git force` - その他:`eval`、`source *.sh` ### 既知のアーキテクチャ上の制限 exec ガードは PicoClaw に送信されたトップレベルのコマンドのみを検証します。そのコマンドの実行開始後にビルドツールやスクリプトが生成する子プロセスを再帰的に検査することは**ありません**。 初期コマンドが許可された後、直接コマンドガードをバイパスできるワークフローの例: - `make run` - `go run ./cmd/...` - `cargo run` - `npm run build` これは、明らかに危険な直接コマンドのブロックには有用ですが、未レビューのビルドパイプラインに対する完全なサンドボックスでは**ありません**。脅威モデルにワークスペース内の信頼できないコードが含まれる場合は、コンテナ、VM、またはビルド・実行コマンドに対する承認フローなど、より強力な分離を使用してください。 ### 設定例 ```json { "tools": { "exec": { "enable_deny_patterns": true, "custom_deny_patterns": [ "\\brm\\s+-r\\b", "\\bkillall\\s+python" ] } } } ``` ## Cron ツール Cron ツールは定期タスクのスケジューリングに使用されます。 | 設定項目 | 型 | デフォルト | 説明 | |------------------------|-----|------------|-----------------------------------------| | `exec_timeout_minutes` | int | 5 | 実行タイムアウト(分)、0 は無制限 | ## MCP ツール MCP ツールは外部の Model Context Protocol サーバーとの統合を可能にします。 ### ツールディスカバリ(遅延読み込み) 複数の MCP サーバーに接続する場合、数百のツールを同時に公開すると LLM のコンテキストウィンドウを使い果たし、API コストが増加する可能性があります。**Discovery** 機能は、MCP ツールをデフォルトで*非表示*にすることでこの問題を解決します。 すべてのツールを読み込む代わりに、LLM には軽量な検索ツール(BM25 キーワードマッチングまたは正規表現を使用)が提供されます。LLM が特定の機能を必要とする場合、非表示のライブラリを検索します。一致するツールは一時的に「アンロック」され、設定されたターン数(`ttl`)の間コンテキストに注入されます。 ### グローバル設定 | 設定項目 | 型 | デフォルト | 説明 | |-------------|--------|------------|--------------------------------------| | `enabled` | bool | false | MCP 統合をグローバルに有効にする | | `discovery` | object | `{}` | ツールディスカバリ設定(下記参照) | | `servers` | object | `{}` | サーバー名からサーバー設定へのマップ | ### Discovery 設定(`discovery`) | 設定項目 | 型 | デフォルト | 説明 | |----------------------|------|------------|---------------------------------------------------------------------------------------------------------------| | `enabled` | bool | false | true の場合、MCP ツールは非表示になり、検索を通じてオンデマンドで読み込まれます。false の場合、すべてのツールが読み込まれます | | `ttl` | int | 5 | 発見されたツールがアンロック状態を維持する会話ターン数 | | `max_search_results` | int | 5 | 検索クエリごとに返されるツールの最大数 | | `use_bm25` | bool | true | 自然言語/キーワード検索ツール(`tool_search_tool_bm25`)を有効にする。**警告**:正規表現検索よりリソースを消費します | | `use_regex` | bool | false | 正規表現パターン検索ツール(`tool_search_tool_regex`)を有効にする | > **注意:** `discovery.enabled` が `true` の場合、少なくとも1つの検索エンジン(`use_bm25` または `use_regex`)を有効にする**必要があります**。 > そうしないとアプリケーションの起動に失敗します。 ### サーバーごとの設定 | 設定項目 | 型 | 必須 | 説明 | |------------|--------|----------|----------------------------------------| | `enabled` | bool | はい | この MCP サーバーを有効にする | | `type` | string | いいえ | トランスポートタイプ:`stdio`、`sse`、`http` | | `command` | string | stdio | stdio トランスポートの実行コマンド | | `args` | array | いいえ | stdio トランスポートのコマンド引数 | | `env` | object | いいえ | stdio プロセスの環境変数 | | `env_file` | string | いいえ | stdio プロセスの環境ファイルパス | | `url` | string | sse/http | `sse`/`http` トランスポートのエンドポイント URL | | `headers` | object | いいえ | `sse`/`http` トランスポートの HTTP ヘッダー | ### トランスポートの動作 - `type` を省略した場合、トランスポートは自動検出されます: - `url` が設定されている → `sse` - `command` が設定されている → `stdio` - `http` と `sse` はどちらも `url` + オプションの `headers` を使用します。 - `env` と `env_file` は `stdio` サーバーにのみ適用されます。 ### 設定例 #### 1) Stdio MCP サーバー ```json { "tools": { "mcp": { "enabled": true, "servers": { "filesystem": { "enabled": true, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-filesystem", "/tmp" ] } } } } } ``` #### 2) リモート SSE/HTTP MCP サーバー ```json { "tools": { "mcp": { "enabled": true, "servers": { "remote-mcp": { "enabled": true, "type": "sse", "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer YOUR_TOKEN" } } } } } } ``` #### 3) ツールディスカバリを有効にした大規模 MCP セットアップ *この例では、LLM は `tool_search_tool_bm25` のみを認識します。ユーザーからリクエストがあった場合にのみ、Github や Postgres のツールを動的に検索してアンロックします。* ```json { "tools": { "mcp": { "enabled": true, "discovery": { "enabled": true, "ttl": 5, "max_search_results": 5, "use_bm25": true, "use_regex": false }, "servers": { "github": { "enabled": true, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-github" ], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" } }, "postgres": { "enabled": true, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-postgres", "postgresql://user:password@localhost/dbname" ] }, "slack": { "enabled": true, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-slack" ], "env": { "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" } } } } } } ``` ## Skills ツール Skills ツールは ClawHub などのレジストリを通じたスキルの発見とインストールを設定します。 ### レジストリ | 設定項目 | 型 | デフォルト | 説明 | |------------------------------------|--------|----------------------|----------------------------------------------| | `registries.clawhub.enabled` | bool | true | ClawHub レジストリを有効にする | | `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub ベース URL | | `registries.clawhub.auth_token` | string | `""` | より高いレート制限のためのオプションの Bearer トークン | | `registries.clawhub.search_path` | string | `/api/v1/search` | 検索 API パス | | `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API パス | | `registries.clawhub.download_path` | string | `/api/v1/download` | ダウンロード API パス | ### 設定例 ```json { "tools": { "skills": { "registries": { "clawhub": { "enabled": true, "base_url": "https://clawhub.ai", "auth_token": "", "search_path": "/api/v1/search", "skills_path": "/api/v1/skills", "download_path": "/api/v1/download" } } } } } ``` ## 環境変数 すべての設定オプションは `PICOCLAW_TOOLS_
_` 形式の環境変数で上書きできます: 例: - `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` - `PICOCLAW_TOOLS_EXEC_ENABLED=false` - `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` - `PICOCLAW_TOOLS_MCP_ENABLED=true` 注意:ネストされたマップ形式の設定(例:`tools.mcp.servers..*`)は環境変数ではなく `config.json` で設定します。 ================================================ FILE: docs/ja/troubleshooting.md ================================================ # 🐛 トラブルシューティング > [README](../../README.ja.md) に戻る ## "model ... not found in model_list" または OpenRouter "free is not a valid model ID" **症状:** 以下のいずれかのエラーが表示されます: - `Error creating provider: model "openrouter/free" not found in model_list` - OpenRouter が 400 を返す:`"free is not a valid model ID"` **原因:** `model_list` エントリの `model` フィールドは API に送信される値です。OpenRouter では省略形ではなく、**完全な**モデル ID を使用する必要があります。 - **誤り:** `"model": "free"` → OpenRouter は `free` を受け取り、拒否します。 - **正しい:** `"model": "openrouter/free"` → OpenRouter は `openrouter/free` を受け取ります(自動無料枠ルーティング)。 **修正方法:** `~/.picoclaw/config.json`(またはお使いの設定パス)で: 1. **agents.defaults.model** は `model_list` 内の `model_name` と一致する必要があります(例:`"openrouter-free"`)。 2. そのエントリの **model** は有効な OpenRouter モデル ID である必要があります。例: - `"openrouter/free"` – 自動無料枠 - `"google/gemini-2.0-flash-exp:free"` - `"meta-llama/llama-3.1-8b-instruct:free"` 設定例: ```json { "agents": { "defaults": { "model": "openrouter-free" } }, "model_list": [ { "model_name": "openrouter-free", "model": "openrouter/free", "api_key": "sk-or-v1-YOUR_OPENROUTER_KEY", "api_base": "https://openrouter.ai/api/v1" } ] } ``` キーは [OpenRouter Keys](https://openrouter.ai/keys) で取得できます。 ================================================ FILE: docs/migration/model-list-migration.md ================================================ # Migration Guide: From `providers` to `model_list` This guide explains how to migrate from the legacy `providers` configuration to the new `model_list` format. ## Why Migrate? The new `model_list` configuration offers several advantages: - **Zero-code provider addition**: Add OpenAI-compatible providers with configuration only - **Load balancing**: Configure multiple endpoints for the same model - **Protocol-based routing**: Use prefixes like `openai/`, `anthropic/`, etc. - **Cleaner configuration**: Model-centric instead of vendor-centric ## Timeline | Version | Status | |---------|--------| | v1.x | `model_list` introduced, `providers` deprecated but functional | | v1.x+1 | Prominent deprecation warnings, migration tool available | | v2.0 | `providers` configuration removed | ## Before and After ### Before: Legacy `providers` Configuration ```json { "providers": { "openai": { "api_key": "sk-your-openai-key", "api_base": "https://api.openai.com/v1" }, "anthropic": { "api_key": "sk-ant-your-key" }, "deepseek": { "api_key": "sk-your-deepseek-key" } }, "agents": { "defaults": { "provider": "openai", "model": "gpt-5.4" } } } ``` ### After: New `model_list` Configuration ```json { "model_list": [ { "model_name": "gpt4", "model": "openai/gpt-5.4", "api_key": "sk-your-openai-key", "api_base": "https://api.openai.com/v1" }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", "api_key": "sk-ant-your-key" }, { "model_name": "deepseek", "model": "deepseek/deepseek-chat", "api_key": "sk-your-deepseek-key" } ], "agents": { "defaults": { "model": "gpt4" } } } ``` ## Protocol Prefixes The `model` field uses a protocol prefix format: `[protocol/]model-identifier` | Prefix | Description | Example | |--------|-------------|---------| | `openai/` | OpenAI API (default) | `openai/gpt-5.4` | | `anthropic/` | Anthropic API | `anthropic/claude-opus-4` | | `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` | | `gemini/` | Google Gemini API | `gemini/gemini-2.0-flash-exp` | | `claude-cli/` | Claude CLI (local) | `claude-cli/claude-sonnet-4.6` | | `codex-cli/` | Codex CLI (local) | `codex-cli/codex-4` | | `github-copilot/` | GitHub Copilot | `github-copilot/gpt-4o` | | `openrouter/` | OpenRouter | `openrouter/anthropic/claude-sonnet-4.6` | | `groq/` | Groq API | `groq/llama-3.1-70b` | | `deepseek/` | DeepSeek API | `deepseek/deepseek-chat` | | `cerebras/` | Cerebras API | `cerebras/llama-3.3-70b` | | `qwen/` | Alibaba Qwen | `qwen/qwen-max` | | `zhipu/` | Zhipu AI | `zhipu/glm-4` | | `nvidia/` | NVIDIA NIM | `nvidia/llama-3.1-nemotron-70b` | | `ollama/` | Ollama (local) | `ollama/llama3` | | `vllm/` | vLLM (local) | `vllm/my-model` | | `moonshot/` | Moonshot AI | `moonshot/moonshot-v1-8k` | | `shengsuanyun/` | ShengSuanYun | `shengsuanyun/deepseek-v3` | | `volcengine/` | Volcengine | `volcengine/doubao-pro-32k` | **Note**: If no prefix is specified, `openai/` is used as the default. ## ModelConfig Fields | Field | Required | Description | |-------|----------|-------------| | `model_name` | Yes | User-facing alias for the model | | `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.4`) | | `api_base` | No | API endpoint URL | | `api_key` | No* | API authentication key | | `proxy` | No | HTTP proxy URL | | `auth_method` | No | Authentication method: `oauth`, `token` | | `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` | | `rpm` | No | Requests per minute limit | | `max_tokens_field` | No | Field name for max tokens | | `request_timeout` | No | HTTP request timeout in seconds; `<=0` uses default `120s` | *`api_key` is required for HTTP-based protocols unless `api_base` points to a local server. ## Load Balancing Configure multiple endpoints for the same model to distribute load: ```json { "model_list": [ { "model_name": "gpt4", "model": "openai/gpt-5.4", "api_key": "sk-key1", "api_base": "https://api1.example.com/v1" }, { "model_name": "gpt4", "model": "openai/gpt-5.4", "api_key": "sk-key2", "api_base": "https://api2.example.com/v1" }, { "model_name": "gpt4", "model": "openai/gpt-5.4", "api_key": "sk-key3", "api_base": "https://api3.example.com/v1" } ] } ``` When you request model `gpt4`, requests will be distributed across all three endpoints using round-robin selection. ## Adding a New OpenAI-Compatible Provider With `model_list`, adding a new provider requires zero code changes: ```json { "model_list": [ { "model_name": "my-custom-llm", "model": "openai/my-model-v1", "api_key": "your-api-key", "api_base": "https://api.your-provider.com/v1" } ] } ``` Just specify `openai/` as the protocol (or omit it for the default), and provide your provider's API base URL. ## Backward Compatibility During the migration period, your existing `providers` configuration will continue to work: 1. If `model_list` is empty and `providers` has data, the system auto-converts internally 2. A deprecation warning is logged: `"providers config is deprecated, please migrate to model_list"` 3. All existing functionality remains unchanged ## Migration Checklist - [ ] Identify all providers you're currently using - [ ] Create `model_list` entries for each provider - [ ] Use appropriate protocol prefixes - [ ] Update `agents.defaults.model` to reference the new `model_name` - [ ] Test that all models work correctly - [ ] Remove or comment out the old `providers` section ## Troubleshooting ### Model not found error ``` model "xxx" not found in model_list or providers ``` **Solution**: Ensure the `model_name` in `model_list` matches the value in `agents.defaults.model`. ### Unknown protocol error ``` unknown protocol "xxx" in model "xxx/model-name" ``` **Solution**: Use a supported protocol prefix. See the [Protocol Prefixes](#protocol-prefixes) table above. ### Missing API key error ``` api_key or api_base is required for HTTP-based protocol "xxx" ``` **Solution**: Provide `api_key` and/or `api_base` for HTTP-based providers. ## Need Help? - [GitHub Issues](https://github.com/sipeed/picoclaw/issues) - [Discussion #122](https://github.com/sipeed/picoclaw/discussions/122): Original proposal ================================================ FILE: docs/providers.md ================================================ # 🔌 Providers & Model Configuration > Back to [README](../README.md) ### Providers > [!NOTE] > Groq provides free voice transcription via Whisper. If configured, audio messages from any channel will be automatically transcribed at the agent level. | Provider | Purpose | Get API Key | | ------------ | --------------------------------------- | ------------------------------------------------------------ | | `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | | `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) | | `volcengine` | LLM(Volcengine direct) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | | `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | | `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | | `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | | `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | | `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | | `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) | | `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) | | `nvidia` | LLM (NVIDIA NIM) | [build.nvidia.com](https://build.nvidia.com) | | `moonshot` | LLM (Kimi/Moonshot direct) | [platform.moonshot.cn](https://platform.moonshot.cn) | | `minimax` | LLM (Minimax direct) | [platform.minimaxi.com](https://platform.minimaxi.com) | | `avian` | LLM (Avian direct) | [avian.io](https://avian.io) | | `mistral` | LLM (Mistral direct) | [console.mistral.ai](https://console.mistral.ai) | | `longcat` | LLM (Longcat direct) | [longcat.ai](https://longcat.ai) | | `modelscope` | LLM (ModelScope direct) | [modelscope.cn](https://modelscope.cn) | ### Model Configuration (model_list) > **What's New?** PicoClaw now uses a **model-centric** configuration approach. Simply specify `vendor/model` format (e.g., `zhipu/glm-4.7`) to add new providers—**zero code changes required!** This design also enables **multi-agent support** with flexible provider selection: - **Different agents, different providers**: Each agent can use its own LLM provider - **Model fallbacks**: Configure primary and fallback models for resilience - **Load balancing**: Distribute requests across multiple endpoints - **Centralized configuration**: Manage all providers in one place #### 📋 All Supported Vendors | Vendor | `model` Prefix | Default API Base | Protocol | API Key | | ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- | | **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | | **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | | **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | | **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | | **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) | | **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) | | **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) | | **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) | | **Azure OpenAI** | `azure/` | `https://{resource}.openai.azure.com` | Azure | [Get Key](https://portal.azure.com) | | **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | #### Basic Configuration ```json { "model_list": [ { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", "api_key": "sk-your-api-key" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_key": "sk-your-openai-key" }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", "api_key": "sk-ant-your-key" }, { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", "api_key": "your-zhipu-key" } ], "agents": { "defaults": { "model": "gpt-5.4" } } } ``` #### Vendor-Specific Examples **OpenAI** ```json { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_key": "sk-..." } ``` **VolcEngine (Doubao)** ```json { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", "api_key": "sk-..." } ``` **智谱 AI (GLM)** ```json { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", "api_key": "your-key" } ``` **DeepSeek** ```json { "model_name": "deepseek-chat", "model": "deepseek/deepseek-chat", "api_key": "sk-..." } ``` **Anthropic (with API key)** ```json { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", "api_key": "sk-ant-your-key" } ``` > Run `picoclaw auth login --provider anthropic` to paste your API token. **Anthropic Messages API (native format)** For direct Anthropic API access or custom endpoints that only support Anthropic's native message format: ```json { "model_name": "claude-opus-4-6", "model": "anthropic-messages/claude-opus-4-6", "api_key": "sk-ant-your-key", "api_base": "https://api.anthropic.com" } ``` > Use `anthropic-messages` protocol when: > - Using third-party proxies that only support Anthropic's native `/v1/messages` endpoint (not OpenAI-compatible `/v1/chat/completions`) > - Connecting to services like MiniMax, Synthetic that require Anthropic's native message format > - The existing `anthropic` protocol returns 404 errors (indicating the endpoint doesn't support OpenAI-compatible format) > > **Note:** The `anthropic` protocol uses OpenAI-compatible format (`/v1/chat/completions`), while `anthropic-messages` uses Anthropic's native format (`/v1/messages`). Choose based on your endpoint's supported format. **Ollama (local)** ```json { "model_name": "llama3", "model": "ollama/llama3" } ``` **Custom Proxy/API** ```json { "model_name": "my-custom-model", "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", "api_key": "sk-...", "request_timeout": 300 } ``` **LiteLLM Proxy** ```json { "model_name": "lite-gpt4", "model": "litellm/lite-gpt4", "api_base": "http://localhost:4000/v1", "api_key": "sk-..." } ``` PicoClaw strips only the outer `litellm/` prefix before sending the request, so proxy aliases like `litellm/lite-gpt4` send `lite-gpt4`, while `litellm/openai/gpt-4o` sends `openai/gpt-4o`. #### Load Balancing Configure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them: ```json { "model_list": [ { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" } ] } ``` #### Migration from Legacy `providers` Config The old `providers` configuration is **deprecated** but still supported for backward compatibility. **Old Config (deprecated):** ```json { "providers": { "zhipu": { "api_key": "your-key", "api_base": "https://open.bigmodel.cn/api/paas/v4" } }, "agents": { "defaults": { "provider": "zhipu", "model": "glm-4.7" } } } ``` **New Config (recommended):** ```json { "model_list": [ { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", "api_key": "your-key" } ], "agents": { "defaults": { "model": "glm-4.7" } } } ``` For detailed migration guide, see [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md). ### Provider Architecture PicoClaw routes providers by protocol family: - OpenAI-compatible protocol: OpenRouter, OpenAI-compatible gateways, Groq, Zhipu, and vLLM-style endpoints. - Anthropic protocol: Claude-native API behavior. - Codex/OAuth path: OpenAI OAuth/token authentication route. This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`).
Zhipu **1. Get API key and base URL** * Get [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) **2. Configure** ```json { "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "model": "glm-4.7", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 } }, "providers": { "zhipu": { "api_key": "Your API Key", "api_base": "https://open.bigmodel.cn/api/paas/v4" } } } ``` **3. Run** ```bash picoclaw agent -m "Hello" ```
Full config example ```json { "agents": { "defaults": { "model": "anthropic/claude-opus-4-5" } }, "session": { "dm_scope": "per-channel-peer", "backlog_limit": 20 }, "providers": { "openrouter": { "api_key": "sk-or-v1-xxx" }, "groq": { "api_key": "gsk_xxx" } }, "channels": { "telegram": { "enabled": true, "token": "123456:ABC...", "allow_from": ["123456789"] }, "discord": { "enabled": true, "token": "", "allow_from": [""] }, "whatsapp": { "enabled": false, "bridge_url": "ws://localhost:3001", "use_native": false, "session_store_path": "", "allow_from": [] }, "feishu": { "enabled": false, "app_id": "cli_xxx", "app_secret": "xxx", "encrypt_key": "", "verification_token": "", "allow_from": [] }, "qq": { "enabled": false, "app_id": "", "app_secret": "", "allow_from": [] } }, "tools": { "web": { "brave": { "enabled": false, "api_key": "BSA...", "max_results": 5 }, "duckduckgo": { "enabled": true, "max_results": 5 }, "perplexity": { "enabled": false, "api_key": "", "max_results": 5 }, "searxng": { "enabled": false, "base_url": "http://localhost:8888", "max_results": 5 } }, "cron": { "exec_timeout_minutes": 5 } }, "heartbeat": { "enabled": true, "interval": 30 } } ```
--- ## 📝 API Key Comparison | Service | Pricing | Use Case | | ---------------- | ------------------------ | ------------------------------------- | | **OpenRouter** | Free: 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | | **Volcengine CodingPlan** | ¥9.9/first month | Best for Chinese users, multiple SOTA models (Doubao, DeepSeek, etc.) | | **Zhipu** | Free: 200K tokens/month | Suitable for Chinese users | | **Brave Search** | $5/1000 queries | Web search functionality | | **SearXNG** | Free (self-hosted) | Privacy-focused metasearch (70+ engines) | | **Groq** | Free tier available | Fast inference (Llama, Mixtral) | | **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | | **LongCat** | Free: up to 5M tokens/day | Fast inference | | **ModelScope** | Free: 2000 requests/day | Inference (Qwen, GLM, DeepSeek, etc.) | ---
PicoClaw Meme
================================================ FILE: docs/pt-br/chat-apps.md ================================================ # 💬 Configuração de Aplicativos de Chat > Voltar ao [README](../../README.pt-br.md) ## 💬 Aplicativos de Chat Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot ou MaixCam > **Nota**: Todos os canais baseados em webhook (LINE, WeCom, etc.) são servidos em um único servidor HTTP Gateway compartilhado (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). Não há portas por canal para configurar. Nota: Feishu usa o modo WebSocket/SDK e não utiliza o servidor HTTP webhook compartilhado. | Channel | Setup | | ------------ | ---------------------------------- | | **Telegram** | Easy (just a token) | | **Discord** | Easy (bot token + intents) | | **WhatsApp** | Easy (native: QR scan; or bridge URL) | | **Matrix** | Medium (homeserver + bot access token) | | **QQ** | Easy (AppID + AppSecret) | | **DingTalk** | Medium (app credentials) | | **LINE** | Medium (credentials + webhook URL) | | **WeCom AI Bot** | Medium (Token + AES key) | | **Feishu** | Medium (App ID + Secret, WebSocket mode) | | **Slack** | Medium (Bot token + App token) | | **IRC** | Medium (server + TLS config) | | **OneBot** | Medium (QQ via OneBot protocol) | | **MaixCam** | Easy (Sipeed hardware integration) | | **Pico** | Native PicoClaw protocol |
Telegram (Recomendado) **1. Criar um bot** * Abra o Telegram, pesquise `@BotFather` * Envie `/newbot`, siga as instruções * Copie o token **2. Configurar** ```json { "channels": { "telegram": { "enabled": true, "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } } } ``` > Obtenha seu ID de usuário com `@userinfobot` no Telegram. **3. Executar** ```bash picoclaw gateway ``` **4. Menu de comandos do Telegram (registrado automaticamente na inicialização)** O PicoClaw agora mantém definições de comandos em um registro compartilhado. Na inicialização, o Telegram registrará automaticamente os comandos de bot suportados (por exemplo `/start`, `/help`, `/show`, `/list`) para que o menu de comandos e o comportamento em tempo de execução permaneçam sincronizados. O registro do menu de comandos do Telegram permanece como descoberta UX local do canal; a execução genérica de comandos é tratada centralmente no loop do agente via commands executor. Se o registro de comandos falhar (erros transitórios de rede/API), o canal ainda inicia e o PicoClaw tenta novamente o registro em segundo plano.
Discord **1. Criar um bot** * Acesse * Crie um aplicativo → Bot → Add Bot * Copie o token do bot **2. Habilitar intents** * Nas configurações do Bot, habilite **MESSAGE CONTENT INTENT** * (Opcional) Habilite **SERVER MEMBERS INTENT** se planeja usar listas de permissão baseadas em dados de membros **3. Obter seu User ID** * Configurações do Discord → Avançado → habilite **Developer Mode** * Clique com o botão direito no seu avatar → **Copy User ID** **4. Configurar** ```json { "channels": { "discord": { "enabled": true, "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } } } ``` **5. Convidar o bot** * OAuth2 → URL Generator * Scopes: `bot` * Bot Permissions: `Send Messages`, `Read Message History` * Abra a URL de convite gerada e adicione o bot ao seu servidor **Opcional: Modo de ativação em grupo** Por padrão, o bot responde a todas as mensagens em um canal do servidor. Para restringir respostas apenas a @menções, adicione: ```json { "channels": { "discord": { "group_trigger": { "mention_only": true } } } } ``` Você também pode ativar por prefixos de palavras-chave (ex.: `!bot`): ```json { "channels": { "discord": { "group_trigger": { "prefixes": ["!bot"] } } } } ``` **6. Executar** ```bash picoclaw gateway ```
WhatsApp (nativo via whatsmeow) O PicoClaw pode se conectar ao WhatsApp de duas formas: - **Nativo (recomendado):** In-process usando [whatsmeow](https://github.com/tulir/whatsmeow). Sem bridge separado. Defina `"use_native": true` e deixe `bridge_url` vazio. Na primeira execução, escaneie o QR code com o WhatsApp (Dispositivos Vinculados). A sessão é armazenada no seu workspace (ex.: `workspace/whatsapp/`). O canal nativo é **opcional** para manter o binário padrão pequeno; compile com `-tags whatsapp_native` (ex.: `make build-whatsapp-native` ou `go build -tags whatsapp_native ./cmd/...`). - **Bridge:** Conecte-se a um bridge WebSocket externo. Defina `bridge_url` (ex.: `ws://localhost:3001`) e mantenha `use_native` como false. **Configurar (nativo)** ```json { "channels": { "whatsapp": { "enabled": true, "use_native": true, "session_store_path": "", "allow_from": [] } } } ``` Se `session_store_path` estiver vazio, a sessão é armazenada em `/whatsapp/`. Execute `picoclaw gateway`; na primeira execução, escaneie o QR code impresso no terminal com WhatsApp → Dispositivos Vinculados.
QQ **1. Criar um bot** - Acesse a [QQ Open Platform](https://q.qq.com/#) - Crie um aplicativo → Obtenha **AppID** e **AppSecret** **2. Configurar** ```json { "channels": { "qq": { "enabled": true, "app_id": "YOUR_APP_ID", "app_secret": "YOUR_APP_SECRET", "allow_from": [] } } } ``` > Defina `allow_from` como vazio para permitir todos os usuários, ou especifique números QQ para restringir o acesso. **3. Executar** ```bash picoclaw gateway ```
DingTalk **1. Criar um bot** * Acesse a [Open Platform](https://open.dingtalk.com/) * Crie um aplicativo interno * Copie o Client ID e o Client Secret **2. Configurar** ```json { "channels": { "dingtalk": { "enabled": true, "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "allow_from": [] } } } ``` > Defina `allow_from` como vazio para permitir todos os usuários, ou especifique IDs de usuário DingTalk para restringir o acesso. **3. Executar** ```bash picoclaw gateway ```
Matrix **1. Preparar conta do bot** * Use seu homeserver preferido (ex.: `https://matrix.org` ou auto-hospedado) * Crie um usuário bot e obtenha seu access token **2. Configurar** ```json { "channels": { "matrix": { "enabled": true, "homeserver": "https://matrix.org", "user_id": "@your-bot:matrix.org", "access_token": "YOUR_MATRIX_ACCESS_TOKEN", "allow_from": [] } } } ``` **3. Executar** ```bash picoclaw gateway ``` Para opções completas (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), veja o [Guia de Configuração do Canal Matrix](docs/channels/matrix/README.md).
LINE **1. Criar uma Conta Oficial LINE** - Acesse o [LINE Developers Console](https://developers.line.biz/) - Crie um provider → Crie um canal Messaging API - Copie o **Channel Secret** e o **Channel Access Token** **2. Configurar** ```json { "channels": { "line": { "enabled": true, "channel_secret": "YOUR_CHANNEL_SECRET", "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", "webhook_path": "/webhook/line", "allow_from": [] } } } ``` > O webhook do LINE é servido no servidor Gateway compartilhado (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). **3. Configurar URL do Webhook** O LINE requer HTTPS para webhooks. Use um proxy reverso ou túnel: ```bash # Exemplo com ngrok (porta padrão do gateway é 18790) ngrok http 18790 ``` Em seguida, defina a URL do Webhook no LINE Developers Console como `https://your-domain/webhook/line` e habilite **Use webhook**. **4. Executar** ```bash picoclaw gateway ``` > Em chats de grupo, o bot responde apenas quando @mencionado. As respostas citam a mensagem original.
WeCom (企业微信) O PicoClaw suporta três tipos de integração WeCom: **Opção 1: WeCom Bot (Bot)** - Configuração mais fácil, suporta chats de grupo **Opção 2: WeCom App (App Personalizado)** - Mais recursos, mensagens proativas, apenas chat privado **Opção 3: WeCom AI Bot (AI Bot)** - AI Bot oficial, respostas em streaming, suporta chat de grupo e privado Veja o [Guia de Configuração do WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) para instruções detalhadas de configuração. **Configuração Rápida - WeCom Bot:** **1. Criar um bot** * Acesse o Console de Administração WeCom → Chat de Grupo → Adicionar Bot de Grupo * Copie a URL do webhook (formato: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) **2. Configurar** ```json { "channels": { "wecom": { "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", "webhook_path": "/webhook/wecom", "allow_from": [] } } } ``` > O webhook do WeCom é servido no servidor Gateway compartilhado (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). **Configuração Rápida - WeCom App:** **1. Criar um aplicativo** * Acesse o Console de Administração WeCom → Gerenciamento de Apps → Criar App * Copie o **AgentId** e o **Secret** * Acesse a página "Minha Empresa", copie o **CorpID** **2. Configurar recebimento de mensagens** * Nos detalhes do App, clique em "Receber Mensagem" → "Configurar API" * Defina a URL como `http://your-server:18790/webhook/wecom-app` * Gere o **Token** e o **EncodingAESKey** **3. Configurar** ```json { "channels": { "wecom_app": { "enabled": true, "corp_id": "wwxxxxxxxxxxxxxxxx", "corp_secret": "YOUR_CORP_SECRET", "agent_id": 1000002, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-app", "allow_from": [] } } } ``` **4. Executar** ```bash picoclaw gateway ``` > **Nota**: Os callbacks de webhook do WeCom são servidos na porta do Gateway (padrão 18790). Use um proxy reverso para HTTPS. **Configuração Rápida - WeCom AI Bot:** **1. Criar um AI Bot** * Acesse o Console de Administração WeCom → Gerenciamento de Apps → AI Bot * Nas configurações do AI Bot, configure a URL de callback: `http://your-server:18791/webhook/wecom-aibot` * Copie o **Token** e clique em "Gerar Aleatoriamente" para o **EncodingAESKey** **2. Configurar** ```json { "channels": { "wecom_aibot": { "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-aibot", "allow_from": [], "welcome_message": "Hello! How can I help you?" } } } ``` **3. Executar** ```bash picoclaw gateway ``` > **Nota**: O WeCom AI Bot usa protocolo de streaming pull — sem preocupações com timeout de resposta. Tarefas longas (>30 segundos) mudam automaticamente para entrega via `response_url` push.
================================================ FILE: docs/pt-br/configuration.md ================================================ # ⚙️ Guia de Configuração > Voltar ao [README](../../README.pt-br.md) ## ⚙️ Configuração Arquivo de configuração: `~/.picoclaw/config.json` ### Variáveis de Ambiente Você pode substituir os caminhos padrão usando variáveis de ambiente. Isso é útil para instalações portáteis, implantações em contêineres ou execução do picoclaw como serviço do sistema. Essas variáveis são independentes e controlam caminhos diferentes. | Variável | Descrição | Caminho Padrão | |-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| | `PICOCLAW_CONFIG` | Substitui o caminho para o arquivo de configuração. Isso indica diretamente ao picoclaw qual `config.json` carregar, ignorando todos os outros locais. | `~/.picoclaw/config.json` | | `PICOCLAW_HOME` | Substitui o diretório raiz para dados do picoclaw. Isso altera o local padrão do `workspace` e outros diretórios de dados. | `~/.picoclaw` | **Exemplos:** ```bash # Executar picoclaw usando um arquivo de configuração específico # O caminho do workspace será lido de dentro desse arquivo de configuração PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway # Executar picoclaw com todos os dados armazenados em /opt/picoclaw # A configuração será carregada do padrão ~/.picoclaw/config.json # O workspace será criado em /opt/picoclaw/workspace PICOCLAW_HOME=/opt/picoclaw picoclaw agent # Usar ambos para uma configuração totalmente personalizada PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway ``` ### Layout do Workspace O PicoClaw armazena dados no seu workspace configurado (padrão: `~/.picoclaw/workspace`): ``` ~/.picoclaw/workspace/ ├── sessions/ # Sessões de conversa e histórico ├── memory/ # Memória de longo prazo (MEMORY.md) ├── state/ # Estado persistente (último canal, etc.) ├── cron/ # Banco de dados de tarefas agendadas ├── skills/ # Skills personalizadas ├── AGENT.md # Guia de comportamento do agente ├── HEARTBEAT.md # Prompts de tarefas periódicas (verificados a cada 30 min) ├── IDENTITY.md # Identidade do agente ├── SOUL.md # Alma do agente └── USER.md # Preferências do usuário ``` > **Nota:** Alterações em `AGENT.md`, `SOUL.md`, `USER.md` e `memory/MEMORY.md` são detectadas automaticamente em tempo de execução via rastreamento de data de modificação (mtime). **Não é necessário reiniciar o gateway** após editar esses arquivos — o agente carrega o novo conteúdo na próxima requisição. ### Fontes de Skills Por padrão, as skills são carregadas de: 1. `~/.picoclaw/workspace/skills` (workspace) 2. `~/.picoclaw/skills` (global) 3. `/skills` (builtin) Para configurações avançadas/de teste, você pode substituir o diretório raiz de skills builtin com: ```bash export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ``` ### Política Unificada de Execução de Comandos - Comandos slash genéricos são executados através de um único caminho em `pkg/agent/loop.go` via `commands.Executor`. - Os adaptadores de canal não consomem mais comandos genéricos localmente; eles encaminham o texto de entrada para o caminho bus/agent. O Telegram ainda registra automaticamente os comandos suportados na inicialização. - Comando slash desconhecido (por exemplo `/foo`) passa para o processamento normal do LLM. - Comando registrado mas não suportado no canal atual (por exemplo `/show` no WhatsApp) retorna um erro explícito ao usuário e interrompe o processamento. ### 🔒 Sandbox de Segurança O PicoClaw é executado em um ambiente sandbox por padrão. O agente só pode acessar arquivos e executar comandos dentro do workspace configurado. #### Configuração Padrão ```json { "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "restrict_to_workspace": true } } } ``` | Opção | Padrão | Descrição | | ----------------------- | ----------------------- | ----------------------------------------- | | `workspace` | `~/.picoclaw/workspace` | Diretório de trabalho do agente | | `restrict_to_workspace` | `true` | Restringir acesso a arquivos/comandos ao workspace | #### Ferramentas Protegidas Quando `restrict_to_workspace: true`, as seguintes ferramentas são isoladas: | Ferramenta | Função | Restrição | | ------------- | ---------------- | -------------------------------------- | | `read_file` | Ler arquivos | Apenas arquivos dentro do workspace | | `write_file` | Escrever arquivos| Apenas arquivos dentro do workspace | | `list_dir` | Listar diretórios| Apenas diretórios dentro do workspace | | `edit_file` | Editar arquivos | Apenas arquivos dentro do workspace | | `append_file` | Anexar a arquivos| Apenas arquivos dentro do workspace | | `exec` | Executar comandos| Caminhos de comando devem estar dentro do workspace | #### Proteção Adicional do Exec Mesmo com `restrict_to_workspace: false`, a ferramenta `exec` bloqueia estes comandos perigosos: * `rm -rf`, `del /f`, `rmdir /s` — Exclusão em massa * `format`, `mkfs`, `diskpart` — Formatação de disco * `dd if=` — Imagem de disco * Escrita em `/dev/sd[a-z]` — Escritas diretas em disco * `shutdown`, `reboot`, `poweroff` — Desligamento do sistema * Fork bomb `:(){ :|:& };:` ### Controle de Acesso a Arquivos | Config Key | Type | Default | Description | |------------|------|---------|-------------| | `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace | | `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace | ### Segurança do Exec | Config Key | Type | Default | Description | |------------|------|---------|-------------| | `tools.exec.allow_remote` | bool | `false` | Allow exec tool from remote channels (Telegram/Discord etc.) | | `tools.exec.enable_deny_patterns` | bool | `true` | Enable dangerous command interception | | `tools.exec.custom_deny_patterns` | string[] | `[]` | Custom regex patterns to block | | `tools.exec.custom_allow_patterns` | string[] | `[]` | Custom regex patterns to allow | > **Nota de Segurança:** A proteção contra symlinks é habilitada por padrão — todos os caminhos de arquivo são resolvidos através de `filepath.EvalSymlinks` antes da correspondência com a whitelist, prevenindo ataques de escape via symlink. #### Limitação Conhecida: Processos Filhos de Ferramentas de Build O guard de segurança do exec inspeciona apenas a linha de comando que o PicoClaw executa diretamente. Ele não inspeciona recursivamente processos filhos gerados por ferramentas de desenvolvimento permitidas como `make`, `go run`, `cargo`, `npm run` ou scripts de build personalizados. Isso significa que um comando de nível superior ainda pode compilar ou executar outros binários após passar pela verificação inicial do guard. Na prática, trate scripts de build, Makefiles, scripts de pacotes e binários gerados como código executável que precisa do mesmo nível de revisão que um comando shell direto. Para ambientes de maior risco: * Revise scripts de build antes da execução. * Prefira aprovação/revisão manual para fluxos de trabalho de compilação e execução. * Execute o PicoClaw dentro de um contêiner ou VM se precisar de isolamento mais forte do que o guard integrado oferece. #### Exemplos de Erro ``` [ERROR] tool: Tool execution failed {tool=exec, error=Command blocked by safety guard (path outside working dir)} ``` ``` [ERROR] tool: Tool execution failed {tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} ``` #### Desabilitando Restrições (Risco de Segurança) Se você precisar que o agente acesse caminhos fora do workspace: **Método 1: Arquivo de configuração** ```json { "agents": { "defaults": { "restrict_to_workspace": false } } } ``` **Método 2: Variável de ambiente** ```bash export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false ``` > ⚠️ **Aviso**: Desabilitar esta restrição permite que o agente acesse qualquer caminho no seu sistema. Use com cautela apenas em ambientes controlados. #### Consistência do Limite de Segurança A configuração `restrict_to_workspace` se aplica consistentemente em todos os caminhos de execução: | Caminho de Execução | Limite de Segurança | | -------------------- | ---------------------------- | | Main Agent | `restrict_to_workspace` ✅ | | Subagent / Spawn | Herda a mesma restrição ✅ | | Heartbeat tasks | Herda a mesma restrição ✅ | Todos os caminhos compartilham a mesma restrição de workspace — não há como contornar o limite de segurança através de subagentes ou tarefas agendadas. ### Heartbeat (Tarefas Periódicas) O PicoClaw pode executar tarefas periódicas automaticamente. Crie um arquivo `HEARTBEAT.md` no seu workspace: ```markdown # Tarefas Periódicas - Verificar meu e-mail para mensagens importantes - Revisar meu calendário para eventos próximos - Verificar a previsão do tempo ``` O agente lerá este arquivo a cada 30 minutos (configurável) e executará quaisquer tarefas usando as ferramentas disponíveis. #### Tarefas Assíncronas com Spawn Para tarefas de longa duração (busca na web, chamadas de API), use a ferramenta `spawn` para criar um **subagente**: ```markdown # Tarefas Periódicas ``` ================================================ FILE: docs/pt-br/docker.md ================================================ # 🐳 Docker e Início Rápido > Voltar ao [README](../../README.pt-br.md) ## 🐳 Docker Compose Você também pode executar o PicoClaw usando Docker Compose sem instalar nada localmente. ```bash # 1. Clone este repositório git clone https://github.com/sipeed/picoclaw.git cd picoclaw # 2. Primeira execução — gera automaticamente docker/data/config.json e encerra docker compose -f docker/docker-compose.yml --profile gateway up # O contêiner exibe "First-run setup complete." e para. # 3. Configure suas chaves de API vim docker/data/config.json # Set provider API keys, bot tokens, etc. # 4. Iniciar docker compose -f docker/docker-compose.yml --profile gateway up -d ``` > [!TIP] > **Usuários Docker**: Por padrão, o Gateway escuta em `127.0.0.1`, que não é acessível a partir do host. Se você precisar acessar os endpoints de saúde ou expor portas, defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` no seu ambiente ou atualize o `config.json`. ```bash # 5. Verificar logs docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway # 6. Parar docker compose -f docker/docker-compose.yml --profile gateway down ``` ### Modo Launcher (Console Web) A imagem `launcher` inclui os três binários (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) e inicia o console web por padrão, que fornece uma interface baseada em navegador para configuração e chat. ```bash docker compose -f docker/docker-compose.yml --profile launcher up -d ``` Abra http://localhost:18800 no seu navegador. O launcher gerencia o processo do gateway automaticamente. > [!WARNING] > O console web ainda não suporta autenticação. Evite expô-lo na internet pública. ### Modo Agent (One-shot) ```bash # Fazer uma pergunta docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?" # Modo interativo docker compose -f docker/docker-compose.yml run --rm picoclaw-agent ``` ### Atualização ```bash docker compose -f docker/docker-compose.yml pull docker compose -f docker/docker-compose.yml --profile gateway up -d ``` ### 🚀 Início Rápido > [!TIP] > Configure sua chave de API em `~/.picoclaw/config.json`. Obtenha chaves de API: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). A busca na web é opcional — obtenha gratuitamente uma [API Tavily](https://tavily.com) (1000 consultas gratuitas/mês) ou [API Brave Search](https://brave.com/search/api) (2000 consultas gratuitas/mês). **1. Inicializar** ```bash picoclaw onboard ``` **2. Configurar** (`~/.picoclaw/config.json`) ```json { "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "model_name": "gpt-5.4", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 } }, "model_list": [ { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", "api_key": "sk-your-api-key", "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_key": "your-api-key", "request_timeout": 300 }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", "api_key": "your-anthropic-key" } ], "tools": { "web": { "enabled": true, "fetch_limit_bytes": 10485760, "format": "plaintext", "brave": { "enabled": false, "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 }, "tavily": { "enabled": false, "api_key": "YOUR_TAVILY_API_KEY", "max_results": 5 }, "duckduckgo": { "enabled": true, "max_results": 5 }, "perplexity": { "enabled": false, "api_key": "YOUR_PERPLEXITY_API_KEY", "max_results": 5 }, "searxng": { "enabled": false, "base_url": "http://your-searxng-instance:8888", "max_results": 5 } } } } ``` > **Novo**: O formato de configuração `model_list` permite adicionar provedores sem alteração de código. Veja [Configuração de Modelos](#configuração-de-modelos-model_list) para detalhes. > `request_timeout` é opcional e usa segundos. Se omitido ou definido como `<= 0`, o PicoClaw usa o timeout padrão (120s). **3. Obter chaves de API** * **Provedor LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) * **Busca na Web** (opcional): * [Brave Search](https://brave.com/search/api) - Pago ($5/1000 consultas, ~$5-6/mês) * [Perplexity](https://www.perplexity.ai) - Busca com IA e interface de chat * [SearXNG](https://github.com/searxng/searxng) - Metabuscador auto-hospedado (gratuito, sem necessidade de chave de API) * [Tavily](https://tavily.com) - Otimizado para agentes de IA (1000 requisições/mês) * DuckDuckGo - Fallback integrado (sem necessidade de chave de API) > **Nota**: Veja `config.example.json` para um modelo de configuração completo. **4. Conversar** ```bash picoclaw agent -m "What is 2+2?" ``` Pronto! Você tem um assistente de IA funcionando em 2 minutos. --- ================================================ FILE: docs/pt-br/providers.md ================================================ # 🔌 Provedores e Configuração de Modelos > Voltar ao [README](../../README.pt-br.md) ### Provedores > [!NOTE] > O Groq fornece transcrição de voz gratuita via Whisper. Se configurado, mensagens de áudio de qualquer canal serão automaticamente transcritas no nível do agente. | Provider | Purpose | Get API Key | | ------------ | --------------------------------------- | ------------------------------------------------------------ | | `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | | `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) | | `volcengine` | LLM(Volcengine direct) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | | `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | | `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | | `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | | `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | | `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | | `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) | | `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) | | `moonshot` | LLM (Kimi/Moonshot direct) | [platform.moonshot.cn](https://platform.moonshot.cn) | | `minimax` | LLM (Minimax direct) | [platform.minimaxi.com](https://platform.minimaxi.com) | | `avian` | LLM (Avian direct) | [avian.io](https://avian.io) | | `mistral` | LLM (Mistral direct) | [console.mistral.ai](https://console.mistral.ai) | | `longcat` | LLM (Longcat direct) | [longcat.ai](https://longcat.ai) | | `modelscope` | LLM (ModelScope direct) | [modelscope.cn](https://modelscope.cn) | ### Configuração de Modelos (model_list) > **Novidade?** O PicoClaw agora usa uma abordagem de configuração **centrada no modelo**. Basta especificar o formato `vendor/model` (ex.: `zhipu/glm-4.7`) para adicionar novos provedores — **sem necessidade de alteração de código!** Este design também permite **suporte multi-agente** com seleção flexível de provedores: - **Agentes diferentes, provedores diferentes**: Cada agente pode usar seu próprio provedor LLM - **Fallback de modelos**: Configure modelos primários e de fallback para resiliência - **Balanceamento de carga**: Distribua requisições entre múltiplos endpoints - **Configuração centralizada**: Gerencie todos os provedores em um só lugar #### 📋 Todos os Vendors Suportados | Vendor | `model` Prefix | Default API Base | Protocol | API Key | | ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- | | **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | | **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | | **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | | **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | | **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) | | **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) | | **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) | | **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) | | **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | #### Configuração Básica ```json { "model_list": [ { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", "api_key": "sk-your-api-key" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_key": "sk-your-openai-key" }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", "api_key": "sk-ant-your-key" }, { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", "api_key": "your-zhipu-key" } ], "agents": { "defaults": { "model": "gpt-5.4" } } } ``` #### Exemplos por Vendor **OpenAI** ```json { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_key": "sk-..." } ``` **VolcEngine (Doubao)** ```json { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", "api_key": "sk-..." } ``` **智谱 AI (GLM)** ```json { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", "api_key": "your-key" } ``` **DeepSeek** ```json { "model_name": "deepseek-chat", "model": "deepseek/deepseek-chat", "api_key": "sk-..." } ``` **Anthropic (com chave de API)** ```json { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", "api_key": "sk-ant-your-key" } ``` > Execute `picoclaw auth login --provider anthropic` para colar seu token de API. **Anthropic Messages API (formato nativo)** Para acesso direto à API Anthropic ou endpoints personalizados que suportam apenas o formato de mensagem nativo da Anthropic: ```json { "model_name": "claude-opus-4-6", "model": "anthropic-messages/claude-opus-4-6", "api_key": "sk-ant-your-key", "api_base": "https://api.anthropic.com" } ``` > Use o protocolo `anthropic-messages` quando: > - Usar proxies de terceiros que suportam apenas o endpoint nativo `/v1/messages` da Anthropic (não o compatível com OpenAI `/v1/chat/completions`) > - Conectar a serviços como MiniMax, Synthetic que requerem o formato de mensagem nativo da Anthropic > - O protocolo `anthropic` existente retorna erros 404 (indicando que o endpoint não suporta formato compatível com OpenAI) > > **Nota:** O protocolo `anthropic` usa formato compatível com OpenAI (`/v1/chat/completions`), enquanto `anthropic-messages` usa o formato nativo da Anthropic (`/v1/messages`). Escolha com base no formato suportado pelo seu endpoint. **Ollama (local)** ```json { "model_name": "llama3", "model": "ollama/llama3" } ``` **Proxy/API Personalizado** ```json { "model_name": "my-custom-model", "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", "api_key": "sk-...", "request_timeout": 300 } ``` **LiteLLM Proxy** ```json { "model_name": "lite-gpt4", "model": "litellm/lite-gpt4", "api_base": "http://localhost:4000/v1", "api_key": "sk-..." } ``` O PicoClaw remove apenas o prefixo externo `litellm/` antes de enviar a requisição, então aliases de proxy como `litellm/lite-gpt4` enviam `lite-gpt4`, enquanto `litellm/openai/gpt-4o` envia `openai/gpt-4o`. #### Balanceamento de Carga Configure múltiplos endpoints para o mesmo nome de modelo — o PicoClaw fará automaticamente round-robin entre eles: ```json { "model_list": [ { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" } ] } ``` #### Migração da Configuração Legacy `providers` A configuração antiga `providers` está **descontinuada** mas ainda é suportada para compatibilidade retroativa. **Configuração Antiga (descontinuada):** ```json { "providers": { "zhipu": { "api_key": "your-key", "api_base": "https://open.bigmodel.cn/api/paas/v4" } }, "agents": { "defaults": { "provider": "zhipu", "model": "glm-4.7" } } } ``` **Configuração Nova (recomendada):** ```json { "model_list": [ { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", "api_key": "your-key" } ], "agents": { "defaults": { "model": "glm-4.7" } } } ``` Para guia de migração detalhado, veja [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md). ### Arquitetura de Provedores O PicoClaw roteia provedores por família de protocolo: - Protocolo compatível com OpenAI: OpenRouter, gateways compatíveis com OpenAI, Groq, Zhipu e endpoints estilo vLLM. - Protocolo Anthropic: Comportamento nativo da API Claude. - Caminho Codex/OAuth: Rota de autenticação OAuth/token da OpenAI. Isso mantém o runtime leve enquanto torna novos backends compatíveis com OpenAI basicamente uma operação de configuração (`api_base` + `api_key`).
Zhipu **1. Obter chave de API e URL base** * Obtenha a [chave de API](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) **2. Configurar** ```json { "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "model": "glm-4.7", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 } }, "providers": { "zhipu": { "api_key": "Your API Key", "api_base": "https://open.bigmodel.cn/api/paas/v4" } } } ``` **3. Executar** ```bash picoclaw agent -m "Hello" ```
Exemplo de configuração completa ```json { "agents": { "defaults": { "model": "anthropic/claude-opus-4-5" } }, "session": { "dm_scope": "per-channel-peer", "backlog_limit": 20 }, "providers": { "openrouter": { "api_key": "sk-or-v1-xxx" }, "groq": { "api_key": "gsk_xxx" } }, "channels": { "telegram": { "enabled": true, "token": "123456:ABC...", "allow_from": ["123456789"] }, "discord": { "enabled": true, "token": "", "allow_from": [""] }, "whatsapp": { "enabled": false, "bridge_url": "ws://localhost:3001", "use_native": false, "session_store_path": "", "allow_from": [] }, "feishu": { "enabled": false, "app_id": "cli_xxx", "app_secret": "xxx", "encrypt_key": "", "verification_token": "", "allow_from": [] }, "qq": { "enabled": false, "app_id": "", "app_secret": "", "allow_from": [] } }, "tools": { "web": { "brave": { "enabled": false, "api_key": "BSA...", "max_results": 5 }, "duckduckgo": { "enabled": true, "max_results": 5 }, "perplexity": { "enabled": false, "api_key": "", "max_results": 5 }, "searxng": { "enabled": false, "base_url": "http://localhost:8888", "max_results": 5 } }, "cron": { "exec_timeout_minutes": 5 } }, "heartbeat": { "enabled": true, "interval": 30 } } ```
--- ## 📝 Comparação de Chaves de API | Service | Pricing | Use Case | | ---------------- | ------------------------ | ------------------------------------- | | **OpenRouter** | Free: 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | | **Volcengine CodingPlan** | ¥9.9/first month | Best for Chinese users, multiple SOTA models (Doubao, DeepSeek, etc.) | | **Zhipu** | Free: 200K tokens/month | Suitable for Chinese users | | **Brave Search** | $5/1000 queries | Web search functionality | | **SearXNG** | Free (self-hosted) | Privacy-focused metasearch (70+ engines) | | **Groq** | Free tier available | Fast inference (Llama, Mixtral) | | **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | | **LongCat** | Free: up to 5M tokens/day | Fast inference | | **ModelScope** | Free: 2000 requests/day | Inference (Qwen, GLM, DeepSeek, etc.) | ---
PicoClaw Meme
================================================ FILE: docs/pt-br/spawn-tasks.md ================================================ # 🔄 Tarefas Assíncronas e Spawn > Voltar ao [README](../../README.pt-br.md) ## Tarefas Rápidas (resposta direta) - Informar a hora atual ## Tarefas Longas (usar spawn para assíncrono) - Pesquisar na web notícias sobre IA e resumir - Verificar e-mail e relatar mensagens importantes ``` **Comportamentos principais:** | Feature | Description | | ----------------------- | --------------------------------------------------------- | | **spawn** | Creates async subagent, doesn't block heartbeat | | **Independent context** | Subagent has its own context, no session history | | **message tool** | Subagent communicates with user directly via message tool | | **Non-blocking** | After spawning, heartbeat continues to next task | #### Como Funciona a Comunicação do Subagente ``` Heartbeat é acionado ↓ Agente lê HEARTBEAT.md ↓ Para tarefa longa: spawn subagente ↓ ↓ Continua para próxima tarefa Subagente trabalha independentemente ↓ ↓ Todas as tarefas concluídas Subagente usa ferramenta "message" ↓ ↓ Responde HEARTBEAT_OK Usuário recebe resultado diretamente ``` O subagente tem acesso a ferramentas (message, web_search, etc.) e pode se comunicar com o usuário independentemente sem passar pelo agente principal. **Configuração:** ```json { "heartbeat": { "enabled": true, "interval": 30 } } ``` | Option | Default | Description | | ---------- | ------- | ---------------------------------- | | `enabled` | `true` | Enable/disable heartbeat | | `interval` | `30` | Check interval in minutes (min: 5) | **Variáveis de ambiente:** * `PICOCLAW_HEARTBEAT_ENABLED=false` para desabilitar * `PICOCLAW_HEARTBEAT_INTERVAL=60` para alterar o intervalo ================================================ FILE: docs/pt-br/tools_configuration.md ================================================ # 🔧 Configuração de Ferramentas > Voltar ao [README](../../README.pt-br.md) A configuração de ferramentas do PicoClaw está localizada no campo `tools` do `config.json`. ## Estrutura de diretórios ```json { "tools": { "web": { ... }, "mcp": { ... }, "exec": { ... }, "cron": { ... }, "skills": { ... } } } ``` ## Ferramentas Web As ferramentas web são usadas para pesquisa e busca de páginas web. ### Web Fetcher Configurações gerais para busca e processamento de conteúdo de páginas web. | Config | Tipo | Padrão | Descrição | |---------------------|--------|---------------|-----------------------------------------------------------------------------------------------| | `enabled` | bool | true | Habilitar a capacidade de busca de páginas web. | | `fetch_limit_bytes` | int | 10485760 | Tamanho máximo do payload da página web a ser buscado, em bytes (padrão é 10MB). | | `format` | string | "plaintext" | Formato de saída do conteúdo buscado. Opções: `plaintext` ou `markdown` (recomendado). | ### Brave | Config | Tipo | Padrão | Descrição | |---------------|--------|--------|----------------------------| | `enabled` | bool | false | Habilitar pesquisa Brave | | `api_key` | string | - | Chave API do Brave Search | | `max_results` | int | 5 | Número máximo de resultados | ### DuckDuckGo | Config | Tipo | Padrão | Descrição | |---------------|------|--------|--------------------------------| | `enabled` | bool | true | Habilitar pesquisa DuckDuckGo | | `max_results` | int | 5 | Número máximo de resultados | ### Perplexity | Config | Tipo | Padrão | Descrição | |---------------|--------|--------|--------------------------------| | `enabled` | bool | false | Habilitar pesquisa Perplexity | | `api_key` | string | - | Chave API do Perplexity | | `max_results` | int | 5 | Número máximo de resultados | ## Ferramenta Exec A ferramenta exec é usada para executar comandos shell. | Config | Tipo | Padrão | Descrição | |------------------------|-------|--------|-------------------------------------------------| | `enabled` | bool | true | Habilitar a ferramenta exec | | `enable_deny_patterns` | bool | true | Habilitar bloqueio padrão de comandos perigosos | | `custom_deny_patterns` | array | [] | Padrões de negação personalizados (expressões regulares) | ### Desabilitando a Ferramenta Exec Para desabilitar completamente a ferramenta `exec`, defina `enabled` como `false`: **Via arquivo de configuração:** ```json { "tools": { "exec": { "enabled": false } } } ``` **Via variável de ambiente:** ```bash PICOCLAW_TOOLS_EXEC_ENABLED=false ``` > **Nota:** Quando desabilitada, o agent não poderá executar comandos shell. Isso também afeta a capacidade da ferramenta Cron de executar comandos shell agendados. ### Funcionalidade - **`enable_deny_patterns`**: Defina como `false` para desabilitar completamente os padrões de bloqueio de comandos perigosos padrão - **`custom_deny_patterns`**: Adicione padrões regex de negação personalizados; comandos correspondentes serão bloqueados ### Padrões de comandos bloqueados por padrão Por padrão, o PicoClaw bloqueia os seguintes comandos perigosos: - Comandos de exclusão: `rm -rf`, `del /f/q`, `rmdir /s` - Operações de disco: `format`, `mkfs`, `diskpart`, `dd if=`, escrita em `/dev/sd*` - Operações do sistema: `shutdown`, `reboot`, `poweroff` - Substituição de comandos: `$()`, `${}`, crases - Pipe para shell: `| sh`, `| bash` - Escalação de privilégios: `sudo`, `chmod`, `chown` - Controle de processos: `pkill`, `killall`, `kill -9` - Operações remotas: `curl | sh`, `wget | sh`, `ssh` - Gerenciamento de pacotes: `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user` - Contêineres: `docker run`, `docker exec` - Git: `git push`, `git force` - Outros: `eval`, `source *.sh` ### Limitação arquitetural conhecida O guarda exec apenas valida o comando de nível superior enviado ao PicoClaw. Ele **não** inspeciona recursivamente processos filhos gerados por ferramentas de build ou scripts após o início desse comando. Exemplos de fluxos de trabalho que podem contornar o guarda de comando direto uma vez que o comando inicial é permitido: - `make run` - `go run ./cmd/...` - `cargo run` - `npm run build` Isso significa que o guarda é útil para bloquear comandos diretos obviamente perigosos, mas **não** é um sandbox completo para pipelines de build não revisados. Se seu modelo de ameaça inclui código não confiável no workspace, use isolamento mais forte, como contêineres, VMs ou um fluxo de aprovação em torno de comandos de build e execução. ### Exemplo de configuração ```json { "tools": { "exec": { "enable_deny_patterns": true, "custom_deny_patterns": [ "\\brm\\s+-r\\b", "\\bkillall\\s+python" ] } } } ``` ## Ferramenta Cron A ferramenta cron é usada para agendar tarefas periódicas. | Config | Tipo | Padrão | Descrição | |------------------------|------|--------|-----------------------------------------------------| | `exec_timeout_minutes` | int | 5 | Tempo limite de execução em minutos, 0 significa sem limite | ## Ferramenta MCP A ferramenta MCP permite a integração com servidores Model Context Protocol externos. ### Descoberta de ferramentas (carregamento preguiçoso) Ao conectar a vários servidores MCP, expor centenas de ferramentas simultaneamente pode esgotar a janela de contexto do LLM e aumentar os custos de API. O recurso **Discovery** resolve isso mantendo as ferramentas MCP *ocultas* por padrão. Em vez de carregar todas as ferramentas, o LLM recebe uma ferramenta de pesquisa leve (usando correspondência de palavras-chave BM25 ou Regex). Quando o LLM precisa de uma capacidade específica, ele pesquisa a biblioteca oculta. As ferramentas correspondentes são então temporariamente "desbloqueadas" e injetadas no contexto por um número configurado de turnos (`ttl`). ### Configuração global | Config | Tipo | Padrão | Descrição | |-------------|--------|--------|----------------------------------------------| | `enabled` | bool | false | Habilitar integração MCP globalmente | | `discovery` | object | `{}` | Configuração de descoberta de ferramentas (veja abaixo) | | `servers` | object | `{}` | Mapa de nome do servidor para configuração do servidor | ### Configuração Discovery (`discovery`) | Config | Tipo | Padrão | Descrição | |----------------------|------|--------|-----------------------------------------------------------------------------------------------------------------------------------| | `enabled` | bool | false | Se true, as ferramentas MCP ficam ocultas e são carregadas sob demanda via pesquisa. Se false, todas as ferramentas são carregadas | | `ttl` | int | 5 | Número de turnos de conversa que uma ferramenta descoberta permanece desbloqueada | | `max_search_results` | int | 5 | Número máximo de ferramentas retornadas por consulta de pesquisa | | `use_bm25` | bool | true | Habilitar a ferramenta de pesquisa por linguagem natural/palavras-chave (`tool_search_tool_bm25`). **Aviso**: consome mais recursos que a pesquisa regex | | `use_regex` | bool | false | Habilitar a ferramenta de pesquisa por padrão regex (`tool_search_tool_regex`) | > **Nota:** Se `discovery.enabled` for `true`, você **deve** habilitar pelo menos um mecanismo de pesquisa (`use_bm25` ou `use_regex`), > caso contrário a aplicação falhará ao iniciar. ### Configuração por servidor | Config | Tipo | Obrigatório | Descrição | |------------|--------|-------------|--------------------------------------------| | `enabled` | bool | sim | Habilitar este servidor MCP | | `type` | string | não | Tipo de transporte: `stdio`, `sse`, `http` | | `command` | string | stdio | Comando executável para transporte stdio | | `args` | array | não | Argumentos do comando para transporte stdio | | `env` | object | não | Variáveis de ambiente para processo stdio | | `env_file` | string | não | Caminho para arquivo de ambiente para processo stdio | | `url` | string | sse/http | URL do endpoint para transporte `sse`/`http` | | `headers` | object | não | Cabeçalhos HTTP para transporte `sse`/`http` | ### Comportamento do transporte - Se `type` for omitido, o transporte é detectado automaticamente: - `url` está definido → `sse` - `command` está definido → `stdio` - `http` e `sse` ambos usam `url` + `headers` opcionais. - `env` e `env_file` são aplicados apenas a servidores `stdio`. ### Exemplos de configuração #### 1) Servidor MCP Stdio ```json { "tools": { "mcp": { "enabled": true, "servers": { "filesystem": { "enabled": true, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-filesystem", "/tmp" ] } } } } } ``` #### 2) Servidor MCP remoto SSE/HTTP ```json { "tools": { "mcp": { "enabled": true, "servers": { "remote-mcp": { "enabled": true, "type": "sse", "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer YOUR_TOKEN" } } } } } } ``` #### 3) Configuração MCP massiva com descoberta de ferramentas habilitada *Neste exemplo, o LLM verá apenas o `tool_search_tool_bm25`. Ele pesquisará e desbloqueará ferramentas do Github ou Postgres dinamicamente apenas quando solicitado pelo usuário.* ```json { "tools": { "mcp": { "enabled": true, "discovery": { "enabled": true, "ttl": 5, "max_search_results": 5, "use_bm25": true, "use_regex": false }, "servers": { "github": { "enabled": true, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-github" ], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" } }, "postgres": { "enabled": true, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-postgres", "postgresql://user:password@localhost/dbname" ] }, "slack": { "enabled": true, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-slack" ], "env": { "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" } } } } } } ``` ## Ferramenta Skills A ferramenta skills configura a descoberta e instalação de habilidades via registros como o ClawHub. ### Registros | Config | Tipo | Padrão | Descrição | |------------------------------------|--------|-----------------------|----------------------------------------------| | `registries.clawhub.enabled` | bool | true | Habilitar registro ClawHub | | `registries.clawhub.base_url` | string | `https://clawhub.ai` | URL base do ClawHub | | `registries.clawhub.auth_token` | string | `""` | Token Bearer opcional para limites de taxa mais altos | | `registries.clawhub.search_path` | string | `/api/v1/search` | Caminho da API de pesquisa | | `registries.clawhub.skills_path` | string | `/api/v1/skills` | Caminho da API de Skills | | `registries.clawhub.download_path` | string | `/api/v1/download` | Caminho da API de download | ### Exemplo de configuração ```json { "tools": { "skills": { "registries": { "clawhub": { "enabled": true, "base_url": "https://clawhub.ai", "auth_token": "", "search_path": "/api/v1/search", "skills_path": "/api/v1/skills", "download_path": "/api/v1/download" } } } } } ``` ## Variáveis de ambiente Todas as opções de configuração podem ser substituídas via variáveis de ambiente com o formato `PICOCLAW_TOOLS_
_`: Por exemplo: - `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` - `PICOCLAW_TOOLS_EXEC_ENABLED=false` - `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` - `PICOCLAW_TOOLS_MCP_ENABLED=true` Nota: Configuração de tipo mapa aninhado (por exemplo `tools.mcp.servers..*`) é configurada no `config.json` em vez de variáveis de ambiente. ================================================ FILE: docs/pt-br/troubleshooting.md ================================================ # 🐛 Solução de Problemas > Voltar ao [README](../../README.pt-br.md) ## "model ... not found in model_list" ou OpenRouter "free is not a valid model ID" **Sintoma:** Você vê um dos seguintes erros: - `Error creating provider: model "openrouter/free" not found in model_list` - OpenRouter retorna 400: `"free is not a valid model ID"` **Causa:** O campo `model` na sua entrada `model_list` é o que é enviado para a API. Para o OpenRouter, você deve usar o ID de modelo **completo**, não uma abreviação. - **Errado:** `"model": "free"` → OpenRouter recebe `free` e rejeita. - **Correto:** `"model": "openrouter/free"` → OpenRouter recebe `openrouter/free` (roteamento automático do nível gratuito). **Correção:** Em `~/.picoclaw/config.json` (ou seu caminho de configuração): 1. **agents.defaults.model** deve corresponder a um `model_name` em `model_list` (ex.: `"openrouter-free"`). 2. O **model** dessa entrada deve ser um ID de modelo OpenRouter válido, por exemplo: - `"openrouter/free"` – nível gratuito automático - `"google/gemini-2.0-flash-exp:free"` - `"meta-llama/llama-3.1-8b-instruct:free"` Exemplo: ```json { "agents": { "defaults": { "model": "openrouter-free" } }, "model_list": [ { "model_name": "openrouter-free", "model": "openrouter/free", "api_key": "sk-or-v1-YOUR_OPENROUTER_KEY", "api_base": "https://openrouter.ai/api/v1" } ] } ``` Obtenha sua chave em [OpenRouter Keys](https://openrouter.ai/keys). ================================================ FILE: docs/spawn-tasks.md ================================================ # 🔄 Spawn & Async Tasks > Back to [README](../README.md) ## Quick Tasks (respond directly) - Report current time ## Long Tasks (use spawn for async) - Search the web for AI news and summarize - Check email and report important messages ``` **Key behaviors:** | Feature | Description | | ----------------------- | --------------------------------------------------------- | | **spawn** | Creates async subagent, doesn't block heartbeat | | **Independent context** | Subagent has its own context, no session history | | **message tool** | Subagent communicates with user directly via message tool | | **Non-blocking** | After spawning, heartbeat continues to next task | #### How Subagent Communication Works ``` Heartbeat triggers ↓ Agent reads HEARTBEAT.md ↓ For long task: spawn subagent ↓ ↓ Continue to next task Subagent works independently ↓ ↓ All tasks done Subagent uses "message" tool ↓ ↓ Respond HEARTBEAT_OK User receives result directly ``` The subagent has access to tools (message, web_search, etc.) and can communicate with the user independently without going through the main agent. **Configuration:** ```json { "heartbeat": { "enabled": true, "interval": 30 } } ``` | Option | Default | Description | | ---------- | ------- | ---------------------------------- | | `enabled` | `true` | Enable/disable heartbeat | | `interval` | `30` | Check interval in minutes (min: 5) | **Environment variables:** * `PICOCLAW_HEARTBEAT_ENABLED=false` to disable * `PICOCLAW_HEARTBEAT_INTERVAL=60` to change interval ================================================ FILE: docs/tools_configuration.md ================================================ # Tools Configuration PicoClaw's tools configuration is located in the `tools` field of `config.json`. ## Directory Structure ```json { "tools": { "web": { ... }, "mcp": { ... }, "exec": { ... }, "cron": { ... }, "skills": { ... } } } ``` ## Web Tools Web tools are used for web search and fetching. ### Web Fetcher General settings for fetching and processing webpage content. | Config | Type | Default | Description | |---------------------|--------|---------------|-----------------------------------------------------------------------------------------------| | `enabled` | bool | true | Enable the webpage fetching capability. | | `fetch_limit_bytes` | int | 10485760 | Maximum size of the webpage payload to fetch, in bytes (default is 10MB). | | `format` | string | "plaintext" | Output format of the fetched content. Options: `plaintext` or `markdown` (recommended). | ### Brave | Config | Type | Default | Description | |---------------|--------|---------|---------------------------| | `enabled` | bool | false | Enable Brave search | | `api_key` | string | - | Brave Search API key | | `max_results` | int | 5 | Maximum number of results | ### DuckDuckGo | Config | Type | Default | Description | |---------------|------|---------|---------------------------| | `enabled` | bool | true | Enable DuckDuckGo search | | `max_results` | int | 5 | Maximum number of results | ### Perplexity | Config | Type | Default | Description | |---------------|--------|---------|---------------------------| | `enabled` | bool | false | Enable Perplexity search | | `api_key` | string | - | Perplexity API key | | `max_results` | int | 5 | Maximum number of results | ## Exec Tool The exec tool is used to execute shell commands. | Config | Type | Default | Description | |------------------------|-------|---------|--------------------------------------------| | `enabled` | bool | true | Enable the exec tool | | `enable_deny_patterns` | bool | true | Enable default dangerous command blocking | | `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) | ### Disabling the Exec Tool To completely disable the `exec` tool, set `enabled` to `false`: **Via config file:** ```json { "tools": { "exec": { "enabled": false } } } ``` **Via environment variable:** ```bash PICOCLAW_TOOLS_EXEC_ENABLED=false ``` > **Note:** When disabled, the agent will not be able to execute shell commands. This also affects the Cron tool's ability to run scheduled shell commands. ### Functionality - **`enable_deny_patterns`**: Set to `false` to completely disable the default dangerous command blocking patterns - **`custom_deny_patterns`**: Add custom deny regex patterns; commands matching these will be blocked ### Default Blocked Command Patterns By default, PicoClaw blocks the following dangerous commands: - Delete commands: `rm -rf`, `del /f/q`, `rmdir /s` - Disk operations: `format`, `mkfs`, `diskpart`, `dd if=`, writing to `/dev/sd*` - System operations: `shutdown`, `reboot`, `poweroff` - Command substitution: `$()`, `${}`, backticks - Pipe to shell: `| sh`, `| bash` - Privilege escalation: `sudo`, `chmod`, `chown` - Process control: `pkill`, `killall`, `kill -9` - Remote operations: `curl | sh`, `wget | sh`, `ssh` - Package management: `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user` - Containers: `docker run`, `docker exec` - Git: `git push`, `git force` - Other: `eval`, `source *.sh` ### Known Architectural Limitation The exec guard only validates the top-level command sent to PicoClaw. It does **not** recursively inspect child processes spawned by build tools or scripts after that command starts running. Examples of workflows that can bypass the direct command guard once the initial command is allowed: - `make run` - `go run ./cmd/...` - `cargo run` - `npm run build` This means the guard is useful for blocking obviously dangerous direct commands, but it is **not** a full sandbox for unreviewed build pipelines. If your threat model includes untrusted code in the workspace, use stronger isolation such as containers, VMs, or an approval flow around build-and-run commands. ### Configuration Example ```json { "tools": { "exec": { "enable_deny_patterns": true, "custom_deny_patterns": [ "\\brm\\s+-r\\b", "\\bkillall\\s+python" ] } } } ``` ## Cron Tool The cron tool is used for scheduling periodic tasks. | Config | Type | Default | Description | |------------------------|------|---------|------------------------------------------------| | `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit | ## MCP Tool The MCP tool enables integration with external Model Context Protocol servers. ### Tool Discovery (Lazy Loading) When connecting to multiple MCP servers, exposing hundreds of tools simultaneously can exhaust the LLM's context window and increase API costs. The **Discovery** feature solves this by keeping MCP tools *hidden* by default. Instead of loading all tools, the LLM is provided with a lightweight search tool (using BM25 keyword matching or Regex). When the LLM needs a specific capability, it searches the hidden library. Matching tools are then temporarily "unlocked" and injected into the context for a configured number of turns (`ttl`). ### Global Config | Config | Type | Default | Description | |-------------|--------|---------|----------------------------------------------| | `enabled` | bool | false | Enable MCP integration globally | | `discovery` | object | `{}` | Configuration for Tool Discovery (see below) | | `servers` | object | `{}` | Map of server name to server config | ### Discovery Config (`discovery`) | Config | Type | Default | Description | |----------------------|------|---------|-----------------------------------------------------------------------------------------------------------------------------------| | `enabled` | bool | false | Global default: if `true`, all MCP tools are hidden and loaded on-demand via search; if `false`, all tools are loaded into context. Individual servers can override this with the per-server `deferred` field. | | `ttl` | int | 5 | Number of conversational turns a discovered tool remains unlocked | | `max_search_results` | int | 5 | Maximum number of tools returned per search query | | `use_bm25` | bool | true | Enable the natural language/keyword search tool (`tool_search_tool_bm25`). **Warning**: consumes more resources than regex search | | `use_regex` | bool | false | Enable the regex pattern search tool (`tool_search_tool_regex`) | > **Note:** If `discovery.enabled` is `true`, you MUST enable at least one search engine (`use_bm25` or `use_regex`), > otherwise the application will fail to start. ### Per-Server Config | Config | Type | Required | Description | |------------|---------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------| | `enabled` | bool | yes | Enable this MCP server | | `deferred` | bool | no | Override deferred mode for this server only. `true` = tools are hidden and discoverable via search; `false` = tools are always visible in context. When omitted, the global `discovery.enabled` value applies. | | `type` | string | no | Transport type: `stdio`, `sse`, `http` | | `command` | string | stdio | Executable command for stdio transport | | `args` | array | no | Command arguments for stdio transport | | `env` | object | no | Environment variables for stdio process | | `env_file` | string | no | Path to environment file for stdio process | | `url` | string | sse/http | Endpoint URL for `sse`/`http` transport | | `headers` | object | no | HTTP headers for `sse`/`http` transport | ### Transport Behavior - If `type` is omitted, transport is auto-detected: - `url` is set → `sse` - `command` is set → `stdio` - `http` and `sse` both use `url` + optional `headers`. - `env` and `env_file` are only applied to `stdio` servers. ### Configuration Examples #### 1) Stdio MCP server ```json { "tools": { "mcp": { "enabled": true, "servers": { "filesystem": { "enabled": true, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-filesystem", "/tmp" ] } } } } } ``` #### 2) Remote SSE/HTTP MCP server ```json { "tools": { "mcp": { "enabled": true, "servers": { "remote-mcp": { "enabled": true, "type": "sse", "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer YOUR_TOKEN" } } } } } } ``` #### 3) Massive MCP setup with Tool Discovery enabled *In this example, the LLM will only see the `tool_search_tool_bm25`. It will search and unlock Github or Postgres tools dynamically only when requested by the user.* ```json { "tools": { "mcp": { "enabled": true, "discovery": { "enabled": true, "ttl": 5, "max_search_results": 5, "use_bm25": true, "use_regex": false }, "servers": { "github": { "enabled": true, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-github" ], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" } }, "postgres": { "enabled": true, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-postgres", "postgresql://user:password@localhost/dbname" ] }, "slack": { "enabled": true, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-slack" ], "env": { "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" } } } } } } ``` #### 4) Mixed setup: per-server deferred override *Discovery is enabled globally, but `filesystem` is pinned as always-visible while `context7` follows the global default (deferred). `aws` explicitly opts in to deferred mode even though it is the same as the global default.* ```json { "tools": { "mcp": { "enabled": true, "discovery": { "enabled": true, "ttl": 5, "max_search_results": 5, "use_bm25": true }, "servers": { "filesystem": { "enabled": true, "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"], "deferred": false }, "context7": { "enabled": true, "command": "npx", "args": ["-y", "@upstash/context7-mcp"] }, "aws": { "enabled": true, "command": "npx", "args": ["-y", "aws-mcp-server"], "deferred": true } } } } } ``` > **Tip:** `deferred` on a per-server basis is independent of `discovery.enabled`. You can keep > `discovery.enabled: false` globally (all tools visible by default) and still mark individual > high-volume servers as `"deferred": true` to avoid polluting the context with their tools. ## Skills Tool The skills tool configures skill discovery and installation via registries like ClawHub. ### Registries | Config | Type | Default | Description | |------------------------------------|--------|----------------------|----------------------------------------------| | `registries.clawhub.enabled` | bool | true | Enable ClawHub registry | | `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL | | `registries.clawhub.auth_token` | string | `""` | Optional Bearer token for higher rate limits | | `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path | | `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path | | `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path | ### Configuration Example ```json { "tools": { "skills": { "registries": { "clawhub": { "enabled": true, "base_url": "https://clawhub.ai", "auth_token": "", "search_path": "/api/v1/search", "skills_path": "/api/v1/skills", "download_path": "/api/v1/download" } } } } } ``` ## Environment Variables All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS_
_`: For example: - `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` - `PICOCLAW_TOOLS_EXEC_ENABLED=false` - `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` - `PICOCLAW_TOOLS_MCP_ENABLED=true` Note: Nested map-style config (for example `tools.mcp.servers..*`) is configured in `config.json` rather than environment variables. ================================================ FILE: docs/troubleshooting.md ================================================ # Troubleshooting ## "model ... not found in model_list" or OpenRouter "free is not a valid model ID" **Symptom:** You see either: - `Error creating provider: model "openrouter/free" not found in model_list` - OpenRouter returns 400: `"free is not a valid model ID"` **Cause:** The `model` field in your `model_list` entry is what gets sent to the API. For OpenRouter you must use the **full** model ID, not a shorthand. - **Wrong:** `"model": "free"` → OpenRouter receives `free` and rejects it. - **Right:** `"model": "openrouter/free"` → OpenRouter receives `openrouter/free` (auto free-tier routing). **Fix:** In `~/.picoclaw/config.json` (or your config path): 1. **agents.defaults.model** must match a `model_name` in `model_list` (e.g. `"openrouter-free"`). 2. That entry’s **model** must be a valid OpenRouter model ID, for example: - `"openrouter/free"` – auto free-tier - `"google/gemini-2.0-flash-exp:free"` - `"meta-llama/llama-3.1-8b-instruct:free"` Example snippet: ```json { "agents": { "defaults": { "model": "openrouter-free" } }, "model_list": [ { "model_name": "openrouter-free", "model": "openrouter/free", "api_key": "sk-or-v1-YOUR_OPENROUTER_KEY", "api_base": "https://openrouter.ai/api/v1" } ] } ``` Get your key at [OpenRouter Keys](https://openrouter.ai/keys). ================================================ FILE: docs/vi/chat-apps.md ================================================ # 💬 Cấu Hình Ứng Dụng Chat > Quay lại [README](../../README.vi.md) ## 💬 Ứng Dụng Chat Trò chuyện với picoclaw của bạn qua Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot hoặc MaixCam > **Lưu ý**: Tất cả các kênh dựa trên webhook (LINE, WeCom, v.v.) được phục vụ trên một máy chủ HTTP Gateway chung (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). Không có port riêng cho từng kênh. Lưu ý: Feishu sử dụng chế độ WebSocket/SDK và không sử dụng máy chủ HTTP webhook chung. | Channel | Setup | | ------------ | ---------------------------------- | | **Telegram** | Easy (just a token) | | **Discord** | Easy (bot token + intents) | | **WhatsApp** | Easy (native: QR scan; or bridge URL) | | **Matrix** | Medium (homeserver + bot access token) | | **QQ** | Easy (AppID + AppSecret) | | **DingTalk** | Medium (app credentials) | | **LINE** | Medium (credentials + webhook URL) | | **WeCom AI Bot** | Medium (Token + AES key) | | **Feishu** | Medium (App ID + Secret, WebSocket mode) | | **Slack** | Medium (Bot token + App token) | | **IRC** | Medium (server + TLS config) | | **OneBot** | Medium (QQ via OneBot protocol) | | **MaixCam** | Easy (Sipeed hardware integration) | | **Pico** | Native PicoClaw protocol |
Telegram (Khuyến nghị) **1. Tạo bot** * Mở Telegram, tìm `@BotFather` * Gửi `/newbot`, làm theo hướng dẫn * Sao chép token **2. Cấu hình** ```json { "channels": { "telegram": { "enabled": true, "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } } } ``` > Lấy user ID của bạn từ `@userinfobot` trên Telegram. **3. Chạy** ```bash picoclaw gateway ``` **4. Menu lệnh Telegram (tự động đăng ký khi khởi động)** PicoClaw hiện lưu trữ định nghĩa lệnh trong một registry chung. Khi khởi động, Telegram sẽ tự động đăng ký các lệnh bot được hỗ trợ (ví dụ `/start`, `/help`, `/show`, `/list`) để menu lệnh và hành vi runtime luôn đồng bộ. Đăng ký menu lệnh Telegram vẫn là UX khám phá cục bộ của kênh; thực thi lệnh chung được xử lý tập trung trong vòng lặp agent qua commands executor. Nếu đăng ký lệnh thất bại (lỗi tạm thời mạng/API), kênh vẫn khởi động và PicoClaw thử lại đăng ký trong nền.
Discord **1. Tạo bot** * Truy cập * Tạo ứng dụng → Bot → Add Bot * Sao chép bot token **2. Bật intents** * Trong cài đặt Bot, bật **MESSAGE CONTENT INTENT** * (Tùy chọn) Bật **SERVER MEMBERS INTENT** nếu bạn muốn sử dụng danh sách cho phép dựa trên dữ liệu thành viên **3. Lấy User ID** * Cài đặt Discord → Nâng cao → bật **Developer Mode** * Nhấp chuột phải vào avatar → **Copy User ID** **4. Cấu hình** ```json { "channels": { "discord": { "enabled": true, "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } } } ``` **5. Mời bot** * OAuth2 → URL Generator * Scopes: `bot` * Bot Permissions: `Send Messages`, `Read Message History` * Mở URL mời được tạo và thêm bot vào server của bạn **Tùy chọn: Chế độ kích hoạt nhóm** Mặc định bot phản hồi tất cả tin nhắn trong kênh server. Để giới hạn phản hồi chỉ khi @mention, thêm: ```json { "channels": { "discord": { "group_trigger": { "mention_only": true } } } } ``` Bạn cũng có thể kích hoạt bằng tiền tố từ khóa (ví dụ: `!bot`): ```json { "channels": { "discord": { "group_trigger": { "prefixes": ["!bot"] } } } } ``` **6. Chạy** ```bash picoclaw gateway ```
WhatsApp (native qua whatsmeow) PicoClaw có thể kết nối WhatsApp theo hai cách: - **Native (khuyến nghị):** In-process sử dụng [whatsmeow](https://github.com/tulir/whatsmeow). Không cần bridge riêng. Đặt `"use_native": true` và để trống `bridge_url`. Lần chạy đầu tiên, quét mã QR bằng WhatsApp (Thiết bị liên kết). Phiên được lưu trong workspace (ví dụ: `workspace/whatsapp/`). Kênh native là **tùy chọn** để giữ binary mặc định nhỏ; build với `-tags whatsapp_native` (ví dụ: `make build-whatsapp-native` hoặc `go build -tags whatsapp_native ./cmd/...`). - **Bridge:** Kết nối đến bridge WebSocket bên ngoài. Đặt `bridge_url` (ví dụ: `ws://localhost:3001`) và giữ `use_native` là false. **Cấu hình (native)** ```json { "channels": { "whatsapp": { "enabled": true, "use_native": true, "session_store_path": "", "allow_from": [] } } } ``` Nếu `session_store_path` trống, phiên được lưu tại `/whatsapp/`. Chạy `picoclaw gateway`; lần chạy đầu tiên, quét mã QR hiển thị trong terminal bằng WhatsApp → Thiết bị liên kết.
QQ **1. Tạo bot** - Truy cập [QQ Open Platform](https://q.qq.com/#) - Tạo ứng dụng → Lấy **AppID** và **AppSecret** **2. Cấu hình** ```json { "channels": { "qq": { "enabled": true, "app_id": "YOUR_APP_ID", "app_secret": "YOUR_APP_SECRET", "allow_from": [] } } } ``` > Đặt `allow_from` trống để cho phép tất cả người dùng, hoặc chỉ định số QQ để giới hạn truy cập. **3. Chạy** ```bash picoclaw gateway ```
DingTalk **1. Tạo bot** * Truy cập [Open Platform](https://open.dingtalk.com/) * Tạo ứng dụng nội bộ * Sao chép Client ID và Client Secret **2. Cấu hình** ```json { "channels": { "dingtalk": { "enabled": true, "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "allow_from": [] } } } ``` > Đặt `allow_from` trống để cho phép tất cả người dùng, hoặc chỉ định DingTalk user ID để giới hạn truy cập. **3. Chạy** ```bash picoclaw gateway ```
Matrix **1. Chuẩn bị tài khoản bot** * Sử dụng homeserver ưa thích (ví dụ: `https://matrix.org` hoặc tự host) * Tạo user bot và lấy access token **2. Cấu hình** ```json { "channels": { "matrix": { "enabled": true, "homeserver": "https://matrix.org", "user_id": "@your-bot:matrix.org", "access_token": "YOUR_MATRIX_ACCESS_TOKEN", "allow_from": [] } } } ``` **3. Chạy** ```bash picoclaw gateway ``` Để xem đầy đủ các tùy chọn (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), xem [Hướng Dẫn Cấu Hình Kênh Matrix](docs/channels/matrix/README.md).
LINE **1. Tạo Tài Khoản LINE Official** - Truy cập [LINE Developers Console](https://developers.line.biz/) - Tạo provider → Tạo kênh Messaging API - Sao chép **Channel Secret** và **Channel Access Token** **2. Cấu hình** ```json { "channels": { "line": { "enabled": true, "channel_secret": "YOUR_CHANNEL_SECRET", "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", "webhook_path": "/webhook/line", "allow_from": [] } } } ``` > Webhook LINE được phục vụ trên máy chủ Gateway chung (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). **3. Thiết lập Webhook URL** LINE yêu cầu HTTPS cho webhook. Sử dụng reverse proxy hoặc tunnel: ```bash # Ví dụ với ngrok (port mặc định gateway là 18790) ngrok http 18790 ``` Sau đó đặt Webhook URL trong LINE Developers Console thành `https://your-domain/webhook/line` và bật **Use webhook**. **4. Chạy** ```bash picoclaw gateway ``` > Trong chat nhóm, bot chỉ phản hồi khi được @mention. Phản hồi trích dẫn tin nhắn gốc.
WeCom (企业微信) PicoClaw hỗ trợ ba loại tích hợp WeCom: **Tùy chọn 1: WeCom Bot (Bot)** - Thiết lập dễ hơn, hỗ trợ chat nhóm **Tùy chọn 2: WeCom App (App Tùy chỉnh)** - Nhiều tính năng hơn, nhắn tin chủ động, chỉ chat riêng **Tùy chọn 3: WeCom AI Bot (AI Bot)** - AI Bot chính thức, phản hồi streaming, hỗ trợ chat nhóm & riêng Xem [Hướng Dẫn Cấu Hình WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) để biết hướng dẫn thiết lập chi tiết. **Thiết Lập Nhanh - WeCom Bot:** **1. Tạo bot** * Truy cập Console Quản Trị WeCom → Chat Nhóm → Thêm Bot Nhóm * Sao chép URL webhook (định dạng: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) **2. Cấu hình** ```json { "channels": { "wecom": { "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", "webhook_path": "/webhook/wecom", "allow_from": [] } } } ``` > Webhook WeCom được phục vụ trên máy chủ Gateway chung (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). **Thiết Lập Nhanh - WeCom App:** **1. Tạo ứng dụng** * Truy cập Console Quản Trị WeCom → Quản Lý App → Tạo App * Sao chép **AgentId** và **Secret** * Truy cập trang "Công Ty Của Tôi", sao chép **CorpID** **2. Cấu hình nhận tin nhắn** * Trong chi tiết App, nhấp "Nhận Tin Nhắn" → "Cấu Hình API" * Đặt URL thành `http://your-server:18790/webhook/wecom-app` * Tạo **Token** và **EncodingAESKey** **3. Cấu hình** ```json { "channels": { "wecom_app": { "enabled": true, "corp_id": "wwxxxxxxxxxxxxxxxx", "corp_secret": "YOUR_CORP_SECRET", "agent_id": 1000002, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-app", "allow_from": [] } } } ``` **4. Chạy** ```bash picoclaw gateway ``` > **Lưu ý**: Callback webhook WeCom được phục vụ trên port Gateway (mặc định 18790). Sử dụng reverse proxy cho HTTPS. **Thiết Lập Nhanh - WeCom AI Bot:** **1. Tạo AI Bot** * Truy cập Console Quản Trị WeCom → Quản Lý App → AI Bot * Trong cài đặt AI Bot, cấu hình callback URL: `http://your-server:18791/webhook/wecom-aibot` * Sao chép **Token** và nhấp "Tạo Ngẫu Nhiên" cho **EncodingAESKey** **2. Cấu hình** ```json { "channels": { "wecom_aibot": { "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-aibot", "allow_from": [], "welcome_message": "Hello! How can I help you?", "processing_message": "⏳ Processing, please wait. The results will be sent shortly." } } } ``` **3. Chạy** ```bash picoclaw gateway ``` > **Lưu ý**: WeCom AI Bot sử dụng giao thức streaming pull — không lo timeout phản hồi. Tác vụ dài (>30 giây) tự động chuyển sang gửi qua `response_url` push.
================================================ FILE: docs/vi/configuration.md ================================================ # ⚙️ Hướng Dẫn Cấu Hình > Quay lại [README](../../README.vi.md) ## ⚙️ Cấu Hình File cấu hình: `~/.picoclaw/config.json` ### Biến Môi Trường Bạn có thể ghi đè các đường dẫn mặc định bằng biến môi trường. Điều này hữu ích cho cài đặt portable, triển khai container, hoặc chạy picoclaw như dịch vụ hệ thống. Các biến này độc lập và kiểm soát các đường dẫn khác nhau. | Biến | Mô tả | Đường Dẫn Mặc Định | |-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| | `PICOCLAW_CONFIG` | Ghi đè đường dẫn đến file cấu hình. Chỉ định trực tiếp cho picoclaw file `config.json` nào cần tải, bỏ qua tất cả vị trí khác. | `~/.picoclaw/config.json` | | `PICOCLAW_HOME` | Ghi đè thư mục gốc cho dữ liệu picoclaw. Thay đổi vị trí mặc định của `workspace` và các thư mục dữ liệu khác. | `~/.picoclaw` | **Ví dụ:** ```bash # Chạy picoclaw với file cấu hình cụ thể # Đường dẫn workspace sẽ được đọc từ trong file cấu hình đó PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway # Chạy picoclaw với tất cả dữ liệu lưu tại /opt/picoclaw # Cấu hình sẽ được tải từ mặc định ~/.picoclaw/config.json # Workspace sẽ được tạo tại /opt/picoclaw/workspace PICOCLAW_HOME=/opt/picoclaw picoclaw agent # Sử dụng cả hai cho thiết lập tùy chỉnh hoàn toàn PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway ``` ### Bố Cục Workspace PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`): ``` ~/.picoclaw/workspace/ ├── sessions/ # Phiên hội thoại và lịch sử ├── memory/ # Bộ nhớ dài hạn (MEMORY.md) ├── state/ # Trạng thái bền vững (kênh cuối, v.v.) ├── cron/ # Cơ sở dữ liệu tác vụ lên lịch ├── skills/ # Skill tùy chỉnh ├── AGENT.md # Hướng dẫn hành vi agent ├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút) ├── IDENTITY.md # Danh tính agent ├── SOUL.md # Linh hồn agent └── USER.md # Tùy chọn người dùng ``` > **Lưu ý:** Các thay đổi đối với `AGENT.md`, `SOUL.md`, `USER.md` và `memory/MEMORY.md` được tự động phát hiện trong thời gian chạy thông qua theo dõi thời gian sửa đổi file (mtime). **Không cần khởi động lại gateway** sau khi chỉnh sửa các file này — agent sẽ tải nội dung mới vào yêu cầu tiếp theo. ### Nguồn Skill Mặc định, skill được tải từ: 1. `~/.picoclaw/workspace/skills` (workspace) 2. `~/.picoclaw/skills` (global) 3. `/skills` (builtin) Cho thiết lập nâng cao/test, bạn có thể ghi đè thư mục gốc skill builtin với: ```bash export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ``` ### Chính Sách Thực Thi Lệnh Thống Nhất - Lệnh slash chung được thực thi qua một đường dẫn duy nhất trong `pkg/agent/loop.go` qua `commands.Executor`. - Adapter kênh không còn xử lý lệnh chung cục bộ; chúng chuyển tiếp văn bản đầu vào đến đường dẫn bus/agent. Telegram vẫn tự động đăng ký lệnh được hỗ trợ khi khởi động. - Lệnh slash không xác định (ví dụ `/foo`) được chuyển sang xử lý LLM bình thường. - Lệnh đã đăng ký nhưng không được hỗ trợ trên kênh hiện tại (ví dụ `/show` trên WhatsApp) trả về lỗi rõ ràng cho người dùng và dừng xử lý tiếp. ### 🔒 Sandbox Bảo Mật PicoClaw chạy trong môi trường sandbox mặc định. Agent chỉ có thể truy cập file và thực thi lệnh trong workspace đã cấu hình. #### Cấu Hình Mặc Định ```json { "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "restrict_to_workspace": true } } } ``` | Tùy chọn | Mặc định | Mô tả | | ----------------------- | ----------------------- | ----------------------------------------- | | `workspace` | `~/.picoclaw/workspace` | Thư mục làm việc của agent | | `restrict_to_workspace` | `true` | Giới hạn truy cập file/lệnh trong workspace | #### Công Cụ Được Bảo Vệ Khi `restrict_to_workspace: true`, các công cụ sau được sandbox: | Công cụ | Chức năng | Giới hạn | | ------------- | ---------------- | -------------------------------------- | | `read_file` | Đọc file | Chỉ file trong workspace | | `write_file` | Ghi file | Chỉ file trong workspace | | `list_dir` | Liệt kê thư mục | Chỉ thư mục trong workspace | | `edit_file` | Sửa file | Chỉ file trong workspace | | `append_file` | Nối vào file | Chỉ file trong workspace | | `exec` | Thực thi lệnh | Đường dẫn lệnh phải trong workspace | #### Bảo Vệ Exec Bổ Sung Ngay cả khi `restrict_to_workspace: false`, công cụ `exec` chặn các lệnh nguy hiểm sau: * `rm -rf`, `del /f`, `rmdir /s` — Xóa hàng loạt * `format`, `mkfs`, `diskpart` — Định dạng đĩa * `dd if=` — Tạo ảnh đĩa * Ghi vào `/dev/sd[a-z]` — Ghi trực tiếp đĩa * `shutdown`, `reboot`, `poweroff` — Tắt hệ thống * Fork bomb `:(){ :|:& };:` ### Kiểm Soát Truy Cập File | Config Key | Type | Default | Description | |------------|------|---------|-------------| | `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace | | `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace | ### Bảo Mật Exec | Config Key | Type | Default | Description | |------------|------|---------|-------------| | `tools.exec.allow_remote` | bool | `false` | Allow exec tool from remote channels (Telegram/Discord etc.) | | `tools.exec.enable_deny_patterns` | bool | `true` | Enable dangerous command interception | | `tools.exec.custom_deny_patterns` | string[] | `[]` | Custom regex patterns to block | | `tools.exec.custom_allow_patterns` | string[] | `[]` | Custom regex patterns to allow | > **Lưu ý Bảo Mật:** Bảo vệ symlink được bật mặc định — tất cả đường dẫn file được giải quyết qua `filepath.EvalSymlinks` trước khi so khớp whitelist, ngăn chặn tấn công thoát qua symlink. #### Hạn Chế Đã Biết: Tiến Trình Con Từ Công Cụ Build Guard bảo mật exec chỉ kiểm tra dòng lệnh mà PicoClaw khởi chạy trực tiếp. Nó không kiểm tra đệ quy các tiến trình con được tạo bởi công cụ phát triển được phép như `make`, `go run`, `cargo`, `npm run`, hoặc script build tùy chỉnh. Điều này có nghĩa là lệnh cấp cao nhất vẫn có thể biên dịch hoặc khởi chạy binary khác sau khi vượt qua kiểm tra guard ban đầu. Trong thực tế, hãy coi script build, Makefile, script package, và binary được tạo như mã thực thi cần cùng mức độ review như lệnh shell trực tiếp. Cho môi trường rủi ro cao hơn: * Review script build trước khi thực thi. * Ưu tiên phê duyệt/review thủ công cho quy trình biên dịch và chạy. * Chạy PicoClaw trong container hoặc VM nếu bạn cần cách ly mạnh hơn guard tích hợp. #### Ví Dụ Lỗi ``` [ERROR] tool: Tool execution failed {tool=exec, error=Command blocked by safety guard (path outside working dir)} ``` ``` [ERROR] tool: Tool execution failed {tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} ``` #### Tắt Giới Hạn (Rủi Ro Bảo Mật) Nếu bạn cần agent truy cập đường dẫn ngoài workspace: **Phương pháp 1: File cấu hình** ```json { "agents": { "defaults": { "restrict_to_workspace": false } } } ``` **Phương pháp 2: Biến môi trường** ```bash export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false ``` > ⚠️ **Cảnh báo**: Tắt giới hạn này cho phép agent truy cập bất kỳ đường dẫn nào trên hệ thống. Chỉ sử dụng cẩn thận trong môi trường được kiểm soát. #### Tính Nhất Quán Ranh Giới Bảo Mật Cài đặt `restrict_to_workspace` áp dụng nhất quán trên tất cả đường dẫn thực thi: | Đường Dẫn Thực Thi | Ranh Giới Bảo Mật | | -------------------- | ---------------------------- | | Main Agent | `restrict_to_workspace` ✅ | | Subagent / Spawn | Kế thừa cùng giới hạn ✅ | | Heartbeat tasks | Kế thừa cùng giới hạn ✅ | Tất cả đường dẫn chia sẻ cùng giới hạn workspace — không có cách nào vượt qua ranh giới bảo mật qua subagent hoặc tác vụ lên lịch. ### Heartbeat (Tác Vụ Định Kỳ) PicoClaw có thể thực hiện tác vụ định kỳ tự động. Tạo file `HEARTBEAT.md` trong workspace: ```markdown # Tác Vụ Định Kỳ - Kiểm tra email cho tin nhắn quan trọng - Xem lịch cho sự kiện sắp tới - Kiểm tra dự báo thời tiết ``` Agent sẽ đọc file này mỗi 30 phút (có thể cấu hình) và thực thi các tác vụ sử dụng công cụ có sẵn. #### Tác Vụ Bất Đồng Bộ Với Spawn Cho tác vụ chạy lâu (tìm kiếm web, gọi API), sử dụng công cụ `spawn` để tạo **subagent**: ```markdown # Tác Vụ Định Kỳ ``` ================================================ FILE: docs/vi/docker.md ================================================ # 🐳 Docker và Bắt Đầu Nhanh > Quay lại [README](../../README.vi.md) ## 🐳 Docker Compose Bạn cũng có thể chạy PicoClaw bằng Docker Compose mà không cần cài đặt gì trên máy. ```bash # 1. Clone repo này git clone https://github.com/sipeed/picoclaw.git cd picoclaw # 2. Lần chạy đầu tiên — tự động tạo docker/data/config.json rồi thoát docker compose -f docker/docker-compose.yml --profile gateway up # Container hiển thị "First-run setup complete." và dừng lại. # 3. Cấu hình API key của bạn vim docker/data/config.json # Set provider API keys, bot tokens, etc. # 4. Khởi động docker compose -f docker/docker-compose.yml --profile gateway up -d ``` > [!TIP] > **Người dùng Docker**: Mặc định, Gateway lắng nghe trên `127.0.0.1`, không thể truy cập từ host. Nếu bạn cần truy cập các health endpoint hoặc mở port, hãy đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` trong môi trường hoặc cập nhật `config.json`. ```bash # 5. Kiểm tra log docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway # 6. Dừng docker compose -f docker/docker-compose.yml --profile gateway down ``` ### Chế Độ Launcher (Web Console) Image `launcher` bao gồm cả ba binary (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) và khởi động web console mặc định, cung cấp giao diện trình duyệt để cấu hình và chat. ```bash docker compose -f docker/docker-compose.yml --profile launcher up -d ``` Mở http://localhost:18800 trong trình duyệt. Launcher tự động quản lý tiến trình gateway. > [!WARNING] > Web console chưa hỗ trợ xác thực. Tránh để lộ ra internet công cộng. ### Chế Độ Agent (One-shot) ```bash # Đặt câu hỏi docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?" # Chế độ tương tác docker compose -f docker/docker-compose.yml run --rm picoclaw-agent ``` ### Cập Nhật ```bash docker compose -f docker/docker-compose.yml pull docker compose -f docker/docker-compose.yml --profile gateway up -d ``` ### 🚀 Bắt Đầu Nhanh > [!TIP] > Cấu hình API Key trong `~/.picoclaw/config.json`. Lấy API Key: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Tìm kiếm web là tùy chọn — lấy miễn phí [Tavily API](https://tavily.com) (1000 truy vấn miễn phí/tháng) hoặc [Brave Search API](https://brave.com/search/api) (2000 truy vấn miễn phí/tháng). **1. Khởi tạo** ```bash picoclaw onboard ``` **2. Cấu hình** (`~/.picoclaw/config.json`) ```json { "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "model_name": "gpt-5.4", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 } }, "model_list": [ { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", "api_key": "sk-your-api-key", "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_key": "your-api-key", "request_timeout": 300 }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", "api_key": "your-anthropic-key" } ], "tools": { "web": { "enabled": true, "fetch_limit_bytes": 10485760, "format": "plaintext", "brave": { "enabled": false, "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 }, "tavily": { "enabled": false, "api_key": "YOUR_TAVILY_API_KEY", "max_results": 5 }, "duckduckgo": { "enabled": true, "max_results": 5 }, "perplexity": { "enabled": false, "api_key": "YOUR_PERPLEXITY_API_KEY", "max_results": 5 }, "searxng": { "enabled": false, "base_url": "http://your-searxng-instance:8888", "max_results": 5 } } } } ``` > **Mới**: Định dạng cấu hình `model_list` cho phép thêm provider mà không cần thay đổi code. Xem [Cấu Hình Mô Hình](#cấu-hình-mô-hình-model_list) để biết chi tiết. > `request_timeout` là tùy chọn và tính bằng giây. Nếu bỏ qua hoặc đặt `<= 0`, PicoClaw sử dụng timeout mặc định (120s). **3. Lấy API Key** * **Nhà cung cấp LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) * **Tìm kiếm Web** (tùy chọn): * [Brave Search](https://brave.com/search/api) - Trả phí ($5/1000 truy vấn, ~$5-6/tháng) * [Perplexity](https://www.perplexity.ai) - Tìm kiếm bằng AI với giao diện chat * [SearXNG](https://github.com/searxng/searxng) - Công cụ tìm kiếm tổng hợp tự host (miễn phí, không cần API key) * [Tavily](https://tavily.com) - Tối ưu cho AI Agent (1000 yêu cầu/tháng) * DuckDuckGo - Fallback tích hợp (không cần API key) > **Lưu ý**: Xem `config.example.json` để có mẫu cấu hình đầy đủ. **4. Chat** ```bash picoclaw agent -m "What is 2+2?" ``` Vậy là xong! Bạn có một trợ lý AI hoạt động trong 2 phút. --- ================================================ FILE: docs/vi/providers.md ================================================ # 🔌 Nhà Cung Cấp và Cấu Hình Mô Hình > Quay lại [README](../../README.vi.md) ### Nhà Cung Cấp > [!NOTE] > Groq cung cấp chuyển đổi giọng nói miễn phí qua Whisper. Nếu được cấu hình, tin nhắn âm thanh từ bất kỳ kênh nào sẽ được tự động chuyển đổi ở cấp agent. | Provider | Purpose | Get API Key | | ------------ | --------------------------------------- | ------------------------------------------------------------ | | `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | | `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) | | `volcengine` | LLM(Volcengine direct) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | | `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | | `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | | `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | | `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | | `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | | `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) | | `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) | | `moonshot` | LLM (Kimi/Moonshot direct) | [platform.moonshot.cn](https://platform.moonshot.cn) | | `minimax` | LLM (Minimax direct) | [platform.minimaxi.com](https://platform.minimaxi.com) | | `avian` | LLM (Avian direct) | [avian.io](https://avian.io) | | `mistral` | LLM (Mistral direct) | [console.mistral.ai](https://console.mistral.ai) | | `longcat` | LLM (Longcat direct) | [longcat.ai](https://longcat.ai) | | `modelscope` | LLM (ModelScope direct) | [modelscope.cn](https://modelscope.cn) | ### Cấu Hình Mô Hình (model_list) > **Có gì mới?** PicoClaw hiện sử dụng cách tiếp cận cấu hình **tập trung vào mô hình**. Chỉ cần chỉ định định dạng `vendor/model` (ví dụ: `zhipu/glm-4.7`) để thêm provider mới — **không cần thay đổi code!** Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn provider linh hoạt: - **Agent khác nhau, provider khác nhau**: Mỗi agent có thể sử dụng provider LLM riêng - **Fallback mô hình**: Cấu hình mô hình chính và dự phòng cho khả năng phục hồi - **Cân bằng tải**: Phân phối yêu cầu qua nhiều endpoint - **Cấu hình tập trung**: Quản lý tất cả provider tại một nơi #### 📋 Tất Cả Vendor Được Hỗ Trợ | Vendor | `model` Prefix | Default API Base | Protocol | API Key | | ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- | | **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | | **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | | **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | | **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | | **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) | | **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) | | **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) | | **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) | | **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | #### Cấu Hình Cơ Bản ```json { "model_list": [ { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", "api_key": "sk-your-api-key" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_key": "sk-your-openai-key" }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", "api_key": "sk-ant-your-key" }, { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", "api_key": "your-zhipu-key" } ], "agents": { "defaults": { "model": "gpt-5.4" } } } ``` #### Ví Dụ Theo Vendor **OpenAI** ```json { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_key": "sk-..." } ``` **VolcEngine (Doubao)** ```json { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", "api_key": "sk-..." } ``` **智谱 AI (GLM)** ```json { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", "api_key": "your-key" } ``` **DeepSeek** ```json { "model_name": "deepseek-chat", "model": "deepseek/deepseek-chat", "api_key": "sk-..." } ``` **Anthropic (với API key)** ```json { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", "api_key": "sk-ant-your-key" } ``` > Chạy `picoclaw auth login --provider anthropic` để dán API token. **Anthropic Messages API (định dạng native)** Để truy cập trực tiếp API Anthropic hoặc endpoint tùy chỉnh chỉ hỗ trợ định dạng message native của Anthropic: ```json { "model_name": "claude-opus-4-6", "model": "anthropic-messages/claude-opus-4-6", "api_key": "sk-ant-your-key", "api_base": "https://api.anthropic.com" } ``` > Sử dụng giao thức `anthropic-messages` khi: > - Sử dụng proxy bên thứ ba chỉ hỗ trợ endpoint native `/v1/messages` của Anthropic (không tương thích OpenAI `/v1/chat/completions`) > - Kết nối đến dịch vụ như MiniMax, Synthetic yêu cầu định dạng message native của Anthropic > - Giao thức `anthropic` hiện tại trả về lỗi 404 (cho thấy endpoint không hỗ trợ định dạng tương thích OpenAI) > > **Lưu ý:** Giao thức `anthropic` sử dụng định dạng tương thích OpenAI (`/v1/chat/completions`), trong khi `anthropic-messages` sử dụng định dạng native của Anthropic (`/v1/messages`). Chọn dựa trên định dạng endpoint hỗ trợ. **Ollama (local)** ```json { "model_name": "llama3", "model": "ollama/llama3" } ``` **Proxy/API Tùy Chỉnh** ```json { "model_name": "my-custom-model", "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", "api_key": "sk-...", "request_timeout": 300 } ``` **LiteLLM Proxy** ```json { "model_name": "lite-gpt4", "model": "litellm/lite-gpt4", "api_base": "http://localhost:4000/v1", "api_key": "sk-..." } ``` PicoClaw chỉ loại bỏ tiền tố ngoài `litellm/` trước khi gửi yêu cầu, nên alias proxy như `litellm/lite-gpt4` gửi `lite-gpt4`, trong khi `litellm/openai/gpt-4o` gửi `openai/gpt-4o`. #### Cân Bằng Tải Cấu hình nhiều endpoint cho cùng tên mô hình — PicoClaw sẽ tự động round-robin giữa chúng: ```json { "model_list": [ { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" } ] } ``` #### Di Chuyển Từ Cấu Hình Legacy `providers` Cấu hình `providers` cũ đã **ngừng hỗ trợ** nhưng vẫn được hỗ trợ để tương thích ngược. **Cấu hình cũ (ngừng hỗ trợ):** ```json { "providers": { "zhipu": { "api_key": "your-key", "api_base": "https://open.bigmodel.cn/api/paas/v4" } }, "agents": { "defaults": { "provider": "zhipu", "model": "glm-4.7" } } } ``` **Cấu hình mới (khuyến nghị):** ```json { "model_list": [ { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", "api_key": "your-key" } ], "agents": { "defaults": { "model": "glm-4.7" } } } ``` Để xem hướng dẫn di chuyển chi tiết, xem [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md). ### Kiến Trúc Provider PicoClaw định tuyến provider theo họ giao thức: - Giao thức tương thích OpenAI: OpenRouter, gateway tương thích OpenAI, Groq, Zhipu, và endpoint kiểu vLLM. - Giao thức Anthropic: Hành vi API native của Claude. - Đường dẫn Codex/OAuth: Tuyến xác thực OAuth/token của OpenAI. Điều này giữ runtime nhẹ trong khi làm cho backend tương thích OpenAI mới chủ yếu là thao tác cấu hình (`api_base` + `api_key`).
Zhipu **1. Lấy API key và URL base** * Lấy [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) **2. Cấu hình** ```json { "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "model": "glm-4.7", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 } }, "providers": { "zhipu": { "api_key": "Your API Key", "api_base": "https://open.bigmodel.cn/api/paas/v4" } } } ``` **3. Chạy** ```bash picoclaw agent -m "Hello" ```
Ví dụ cấu hình đầy đủ ```json { "agents": { "defaults": { "model": "anthropic/claude-opus-4-5" } }, "session": { "dm_scope": "per-channel-peer", "backlog_limit": 20 }, "providers": { "openrouter": { "api_key": "sk-or-v1-xxx" }, "groq": { "api_key": "gsk_xxx" } }, "channels": { "telegram": { "enabled": true, "token": "123456:ABC...", "allow_from": ["123456789"] }, "discord": { "enabled": true, "token": "", "allow_from": [""] }, "whatsapp": { "enabled": false, "bridge_url": "ws://localhost:3001", "use_native": false, "session_store_path": "", "allow_from": [] }, "feishu": { "enabled": false, "app_id": "cli_xxx", "app_secret": "xxx", "encrypt_key": "", "verification_token": "", "allow_from": [] }, "qq": { "enabled": false, "app_id": "", "app_secret": "", "allow_from": [] } }, "tools": { "web": { "brave": { "enabled": false, "api_key": "BSA...", "max_results": 5 }, "duckduckgo": { "enabled": true, "max_results": 5 }, "perplexity": { "enabled": false, "api_key": "", "max_results": 5 }, "searxng": { "enabled": false, "base_url": "http://localhost:8888", "max_results": 5 } }, "cron": { "exec_timeout_minutes": 5 } }, "heartbeat": { "enabled": true, "interval": 30 } } ```
--- ## 📝 So Sánh API Key | Service | Pricing | Use Case | | ---------------- | ------------------------ | ------------------------------------- | | **OpenRouter** | Free: 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | | **Volcengine CodingPlan** | ¥9.9/first month | Best for Chinese users, multiple SOTA models (Doubao, DeepSeek, etc.) | | **Zhipu** | Free: 200K tokens/month | Suitable for Chinese users | | **Brave Search** | $5/1000 queries | Web search functionality | | **SearXNG** | Free (self-hosted) | Privacy-focused metasearch (70+ engines) | | **Groq** | Free tier available | Fast inference (Llama, Mixtral) | | **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | | **LongCat** | Free: up to 5M tokens/day | Fast inference | | **ModelScope** | Free: 2000 requests/day | Inference (Qwen, GLM, DeepSeek, etc.) | ---
PicoClaw Meme
================================================ FILE: docs/vi/spawn-tasks.md ================================================ # 🔄 Tác Vụ Bất Đồng Bộ và Spawn > Quay lại [README](../../README.vi.md) ## Tác Vụ Nhanh (phản hồi trực tiếp) - Báo cáo thời gian hiện tại ## Tác Vụ Dài (sử dụng spawn cho bất đồng bộ) - Tìm kiếm web tin tức AI và tóm tắt - Kiểm tra email và báo cáo tin nhắn quan trọng ``` **Hành vi chính:** | Feature | Description | | ----------------------- | --------------------------------------------------------- | | **spawn** | Creates async subagent, doesn't block heartbeat | | **Independent context** | Subagent has its own context, no session history | | **message tool** | Subagent communicates with user directly via message tool | | **Non-blocking** | After spawning, heartbeat continues to next task | #### Cách Giao Tiếp Subagent Hoạt Động ``` Heartbeat được kích hoạt ↓ Agent đọc HEARTBEAT.md ↓ Cho tác vụ dài: spawn subagent ↓ ↓ Tiếp tục tác vụ tiếp theo Subagent làm việc độc lập ↓ ↓ Tất cả tác vụ hoàn thành Subagent sử dụng công cụ "message" ↓ ↓ Phản hồi HEARTBEAT_OK Người dùng nhận kết quả trực tiếp ``` Subagent có quyền truy cập công cụ (message, web_search, v.v.) và có thể giao tiếp với người dùng độc lập mà không cần qua agent chính. **Cấu hình:** ```json { "heartbeat": { "enabled": true, "interval": 30 } } ``` | Option | Default | Description | | ---------- | ------- | ---------------------------------- | | `enabled` | `true` | Enable/disable heartbeat | | `interval` | `30` | Check interval in minutes (min: 5) | **Biến môi trường:** * `PICOCLAW_HEARTBEAT_ENABLED=false` để tắt * `PICOCLAW_HEARTBEAT_INTERVAL=60` để thay đổi khoảng thời gian ================================================ FILE: docs/vi/tools_configuration.md ================================================ # 🔧 Cấu Hình Công Cụ > Quay lại [README](../../README.vi.md) Cấu hình công cụ của PicoClaw nằm trong trường `tools` của `config.json`. ## Cấu trúc thư mục ```json { "tools": { "web": { ... }, "mcp": { ... }, "exec": { ... }, "cron": { ... }, "skills": { ... } } } ``` ## Công cụ Web Các công cụ web được sử dụng để tìm kiếm và tải nội dung web. ### Web Fetcher Cài đặt chung để tải và xử lý nội dung trang web. | Cấu hình | Kiểu | Mặc định | Mô tả | |----------------------|--------|---------------|-----------------------------------------------------------------------------------------------| | `enabled` | bool | true | Bật khả năng tải trang web. | | `fetch_limit_bytes` | int | 10485760 | Kích thước tối đa của payload trang web cần tải, tính bằng byte (mặc định là 10MB). | | `format` | string | "plaintext" | Định dạng đầu ra của nội dung đã tải. Tùy chọn: `plaintext` hoặc `markdown` (khuyến nghị). | ### Brave | Cấu hình | Kiểu | Mặc định | Mô tả | |----------------|--------|----------|----------------------------| | `enabled` | bool | false | Bật tìm kiếm Brave | | `api_key` | string | - | Khóa API Brave Search | | `max_results` | int | 5 | Số kết quả tối đa | ### DuckDuckGo | Cấu hình | Kiểu | Mặc định | Mô tả | |----------------|------|----------|-------------------------------| | `enabled` | bool | true | Bật tìm kiếm DuckDuckGo | | `max_results` | int | 5 | Số kết quả tối đa | ### Perplexity | Cấu hình | Kiểu | Mặc định | Mô tả | |----------------|--------|----------|-------------------------------| | `enabled` | bool | false | Bật tìm kiếm Perplexity | | `api_key` | string | - | Khóa API Perplexity | | `max_results` | int | 5 | Số kết quả tối đa | ## Công cụ Exec Công cụ exec được sử dụng để thực thi các lệnh shell. | Cấu hình | Kiểu | Mặc định | Mô tả | |--------------------------|-------|----------|------------------------------------------------| | `enabled` | bool | true | Bật công cụ exec | | `enable_deny_patterns` | bool | true | Bật chặn lệnh nguy hiểm mặc định | | `custom_deny_patterns` | array | [] | Mẫu từ chối tùy chỉnh (biểu thức chính quy) | ### Vô hiệu hóa Công cụ Exec Để hoàn toàn vô hiệu hóa công cụ `exec`, đặt `enabled` thành `false`: **Qua tệp cấu hình:** ```json { "tools": { "exec": { "enabled": false } } } ``` **Qua biến môi trường:** ```bash PICOCLAW_TOOLS_EXEC_ENABLED=false ``` > **Lưu ý:** Khi bị vô hiệu hóa, agent sẽ không thể thực thi lệnh shell. Điều này cũng ảnh hưởng đến khả năng chạy lệnh shell theo lịch của công cụ Cron. ### Chức năng - **`enable_deny_patterns`**: Đặt thành `false` để tắt hoàn toàn các mẫu chặn lệnh nguy hiểm mặc định - **`custom_deny_patterns`**: Thêm các mẫu regex từ chối tùy chỉnh; các lệnh khớp sẽ bị chặn ### Các mẫu lệnh bị chặn mặc định Theo mặc định, PicoClaw chặn các lệnh nguy hiểm sau: - Lệnh xóa: `rm -rf`, `del /f/q`, `rmdir /s` - Thao tác đĩa: `format`, `mkfs`, `diskpart`, `dd if=`, ghi vào `/dev/sd*` - Thao tác hệ thống: `shutdown`, `reboot`, `poweroff` - Thay thế lệnh: `$()`, `${}`, dấu backtick - Pipe đến shell: `| sh`, `| bash` - Leo thang đặc quyền: `sudo`, `chmod`, `chown` - Điều khiển tiến trình: `pkill`, `killall`, `kill -9` - Thao tác từ xa: `curl | sh`, `wget | sh`, `ssh` - Quản lý gói: `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user` - Container: `docker run`, `docker exec` - Git: `git push`, `git force` - Khác: `eval`, `source *.sh` ### Hạn chế kiến trúc đã biết Bộ bảo vệ exec chỉ xác thực lệnh cấp cao nhất được gửi đến PicoClaw. Nó **không** kiểm tra đệ quy các tiến trình con được tạo bởi các công cụ build hoặc script sau khi lệnh đó bắt đầu chạy. Ví dụ về các quy trình có thể bỏ qua bộ bảo vệ lệnh trực tiếp sau khi lệnh ban đầu được cho phép: - `make run` - `go run ./cmd/...` - `cargo run` - `npm run build` Điều này có nghĩa là bộ bảo vệ hữu ích để chặn các lệnh trực tiếp rõ ràng nguy hiểm, nhưng nó **không phải** là sandbox đầy đủ cho các pipeline build chưa được xem xét. Nếu mô hình mối đe dọa của bạn bao gồm mã không đáng tin cậy trong workspace, hãy sử dụng cách ly mạnh hơn như container, VM hoặc quy trình phê duyệt xung quanh các lệnh build và chạy. ### Ví dụ cấu hình ```json { "tools": { "exec": { "enable_deny_patterns": true, "custom_deny_patterns": [ "\\brm\\s+-r\\b", "\\bkillall\\s+python" ] } } } ``` ## Công cụ Cron Công cụ cron được sử dụng để lên lịch các tác vụ định kỳ. | Cấu hình | Kiểu | Mặc định | Mô tả | |--------------------------|------|----------|-----------------------------------------------------| | `exec_timeout_minutes` | int | 5 | Thời gian chờ thực thi tính bằng phút, 0 nghĩa là không giới hạn | ## Công cụ MCP Công cụ MCP cho phép tích hợp với các máy chủ Model Context Protocol bên ngoài. ### Khám phá công cụ (tải chậm) Khi kết nối với nhiều máy chủ MCP, việc hiển thị hàng trăm công cụ cùng lúc có thể làm cạn kiệt cửa sổ ngữ cảnh của LLM và tăng chi phí API. Tính năng **Discovery** giải quyết vấn đề này bằng cách giữ các công cụ MCP *ẩn* theo mặc định. Thay vì tải tất cả các công cụ, LLM được cung cấp một công cụ tìm kiếm nhẹ (sử dụng khớp từ khóa BM25 hoặc Regex). Khi LLM cần một khả năng cụ thể, nó tìm kiếm trong thư viện ẩn. Các công cụ khớp sau đó được tạm thời "mở khóa" và đưa vào ngữ cảnh trong số lượt được cấu hình (`ttl`). ### Cấu hình toàn cục | Cấu hình | Kiểu | Mặc định | Mô tả | |-------------|--------|----------|-----------------------------------------------| | `enabled` | bool | false | Bật tích hợp MCP toàn cục | | `discovery` | object | `{}` | Cấu hình khám phá công cụ (xem bên dưới) | | `servers` | object | `{}` | Ánh xạ tên máy chủ đến cấu hình máy chủ | ### Cấu hình Discovery (`discovery`) | Cấu hình | Kiểu | Mặc định | Mô tả | |----------------------|------|----------|-----------------------------------------------------------------------------------------------------------------------------------| | `enabled` | bool | false | Nếu true, các công cụ MCP bị ẩn và được tải theo yêu cầu qua tìm kiếm. Nếu false, tất cả công cụ được tải | | `ttl` | int | 5 | Số lượt hội thoại mà một công cụ đã khám phá vẫn được mở khóa | | `max_search_results` | int | 5 | Số công cụ tối đa được trả về cho mỗi truy vấn tìm kiếm | | `use_bm25` | bool | true | Bật công cụ tìm kiếm ngôn ngữ tự nhiên/từ khóa (`tool_search_tool_bm25`). **Cảnh báo**: tiêu tốn nhiều tài nguyên hơn tìm kiếm regex | | `use_regex` | bool | false | Bật công cụ tìm kiếm mẫu regex (`tool_search_tool_regex`) | > **Lưu ý:** Nếu `discovery.enabled` là `true`, bạn **phải** bật ít nhất một công cụ tìm kiếm (`use_bm25` hoặc `use_regex`), > nếu không ứng dụng sẽ không khởi động được. ### Cấu hình từng máy chủ | Cấu hình | Kiểu | Bắt buộc | Mô tả | |------------|--------|----------|--------------------------------------------| | `enabled` | bool | có | Bật máy chủ MCP này | | `type` | string | không | Loại truyền tải: `stdio`, `sse`, `http` | | `command` | string | stdio | Lệnh thực thi cho truyền tải stdio | | `args` | array | không | Đối số lệnh cho truyền tải stdio | | `env` | object | không | Biến môi trường cho tiến trình stdio | | `env_file` | string | không | Đường dẫn đến tệp môi trường cho tiến trình stdio | | `url` | string | sse/http | URL endpoint cho truyền tải `sse`/`http` | | `headers` | object | không | Header HTTP cho truyền tải `sse`/`http` | ### Hành vi truyền tải - Nếu bỏ qua `type`, truyền tải được tự động phát hiện: - `url` được đặt → `sse` - `command` được đặt → `stdio` - `http` và `sse` đều sử dụng `url` + `headers` tùy chọn. - `env` và `env_file` chỉ được áp dụng cho máy chủ `stdio`. ### Ví dụ cấu hình #### 1) Máy chủ MCP Stdio ```json { "tools": { "mcp": { "enabled": true, "servers": { "filesystem": { "enabled": true, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-filesystem", "/tmp" ] } } } } } ``` #### 2) Máy chủ MCP từ xa SSE/HTTP ```json { "tools": { "mcp": { "enabled": true, "servers": { "remote-mcp": { "enabled": true, "type": "sse", "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer YOUR_TOKEN" } } } } } } ``` #### 3) Thiết lập MCP quy mô lớn với khám phá công cụ được bật *Trong ví dụ này, LLM chỉ thấy `tool_search_tool_bm25`. Nó sẽ tìm kiếm và mở khóa động các công cụ Github hoặc Postgres chỉ khi được người dùng yêu cầu.* ```json { "tools": { "mcp": { "enabled": true, "discovery": { "enabled": true, "ttl": 5, "max_search_results": 5, "use_bm25": true, "use_regex": false }, "servers": { "github": { "enabled": true, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-github" ], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" } }, "postgres": { "enabled": true, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-postgres", "postgresql://user:password@localhost/dbname" ] }, "slack": { "enabled": true, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-slack" ], "env": { "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" } } } } } } ``` ## Công cụ Skills Công cụ skills cấu hình khám phá và cài đặt kỹ năng thông qua các registry như ClawHub. ### Registry | Cấu hình | Kiểu | Mặc định | Mô tả | |------------------------------------|--------|-----------------------|----------------------------------------------| | `registries.clawhub.enabled` | bool | true | Bật registry ClawHub | | `registries.clawhub.base_url` | string | `https://clawhub.ai` | URL cơ sở ClawHub | | `registries.clawhub.auth_token` | string | `""` | Token Bearer tùy chọn để có giới hạn tốc độ cao hơn | | `registries.clawhub.search_path` | string | `/api/v1/search` | Đường dẫn API tìm kiếm | | `registries.clawhub.skills_path` | string | `/api/v1/skills` | Đường dẫn API Skills | | `registries.clawhub.download_path` | string | `/api/v1/download` | Đường dẫn API tải xuống | ### Ví dụ cấu hình ```json { "tools": { "skills": { "registries": { "clawhub": { "enabled": true, "base_url": "https://clawhub.ai", "auth_token": "", "search_path": "/api/v1/search", "skills_path": "/api/v1/skills", "download_path": "/api/v1/download" } } } } } ``` ## Biến môi trường Tất cả các tùy chọn cấu hình có thể được ghi đè qua biến môi trường với định dạng `PICOCLAW_TOOLS_
_`: Ví dụ: - `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` - `PICOCLAW_TOOLS_EXEC_ENABLED=false` - `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` - `PICOCLAW_TOOLS_MCP_ENABLED=true` Lưu ý: Cấu hình kiểu map lồng nhau (ví dụ `tools.mcp.servers..*`) được cấu hình trong `config.json` thay vì qua biến môi trường. ================================================ FILE: docs/vi/troubleshooting.md ================================================ # 🐛 Khắc Phục Sự Cố > Quay lại [README](../../README.vi.md) ## "model ... not found in model_list" hoặc OpenRouter "free is not a valid model ID" **Triệu chứng:** Bạn thấy một trong các lỗi sau: - `Error creating provider: model "openrouter/free" not found in model_list` - OpenRouter trả về 400: `"free is not a valid model ID"` **Nguyên nhân:** Trường `model` trong mục `model_list` của bạn là giá trị được gửi đến API. Đối với OpenRouter, bạn phải sử dụng ID mô hình **đầy đủ**, không phải dạng viết tắt. - **Sai:** `"model": "free"` → OpenRouter nhận được `free` và từ chối. - **Đúng:** `"model": "openrouter/free"` → OpenRouter nhận được `openrouter/free` (định tuyến tự động tầng miễn phí). **Cách sửa:** Trong `~/.picoclaw/config.json` (hoặc đường dẫn cấu hình của bạn): 1. **agents.defaults.model** phải khớp với một `model_name` trong `model_list` (ví dụ: `"openrouter-free"`). 2. **model** của mục đó phải là ID mô hình OpenRouter hợp lệ, ví dụ: - `"openrouter/free"` – tầng miễn phí tự động - `"google/gemini-2.0-flash-exp:free"` - `"meta-llama/llama-3.1-8b-instruct:free"` Ví dụ: ```json { "agents": { "defaults": { "model": "openrouter-free" } }, "model_list": [ { "model_name": "openrouter-free", "model": "openrouter/free", "api_key": "sk-or-v1-YOUR_OPENROUTER_KEY", "api_base": "https://openrouter.ai/api/v1" } ] } ``` Lấy khóa của bạn tại [OpenRouter Keys](https://openrouter.ai/keys). ================================================ FILE: docs/zh/chat-apps.md ================================================ # 💬 聊天应用配置 > 返回 [README](../../README.zh.md) ## 💬 聊天应用集成 (Chat Apps) PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。 > **注意**: 所有 Webhook 类渠道(LINE、WeCom 等)均挂载在同一个 Gateway HTTP 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。注意:飞书(Feishu)使用 WebSocket/SDK 模式,不通过该共享 HTTP webhook 服务器接收消息。 ### 核心渠道 | 渠道 | 设置难度 | 特性说明 | 文档链接 | | -------------------- | ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](../channels/telegram/README.zh.md) | | **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](../channels/discord/README.zh.md) | | **WhatsApp** | ⭐ 简单 | 原生 (QR 扫码) 或 Bridge URL | [查看文档](../channels/whatsapp/README.zh.md) | | **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](../channels/slack/README.zh.md) | | **Matrix** | ⭐⭐ 中等 | 联邦协议,支持自建 homeserver 与公开服务器 | [查看文档](../channels/matrix/README.zh.md) | | **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](../channels/qq/README.zh.md) | | **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](../channels/dingtalk/README.zh.md) | | **LINE** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](../channels/line/README.zh.md) | | **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)、自建应用(API)和智能机器人(AI Bot) | [Bot 文档](../channels/wecom/wecom_bot/README.zh.md) / [App 文档](../channels/wecom/wecom_app/README.zh.md) / [AI Bot 文档](../channels/wecom/wecom_aibot/README.zh.md) | | **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](../channels/feishu/README.zh.md) | | **IRC** | ⭐⭐ 中等 | 服务器 + TLS 配置 | - | | **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](../channels/onebot/README.zh.md) | | **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](../channels/maixcam/README.zh.md) | | **Pico** | ⭐ 简单 | PicoClaw 原生协议通道 | | ---
Telegram(推荐) **1. 创建 Bot** * 打开 Telegram,搜索 `@BotFather` * 发送 `/newbot`,按提示操作 * 复制 Token **2. 配置** ```json { "channels": { "telegram": { "enabled": true, "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } } } ``` > 通过 Telegram 上的 `@userinfobot` 获取你的 User ID。 **3. 运行** ```bash picoclaw gateway ``` **4. Telegram 命令菜单(启动时自动注册)** PicoClaw 使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。 Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。 如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。
Discord **1. 创建 Bot** * 前往 * 创建应用 → Bot → 添加 Bot * 复制 Bot Token **2. 启用 Intents** * 在 Bot 设置中启用 **MESSAGE CONTENT INTENT** * (可选)启用 **SERVER MEMBERS INTENT**(如需基于成员数据的白名单) **3. 获取 User ID** * Discord 设置 → 高级 → 启用 **开发者模式** * 右键点击头像 → **复制用户 ID** **4. 配置** ```json { "channels": { "discord": { "enabled": true, "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"] } } } ``` **5. 邀请 Bot** * OAuth2 → URL Generator * Scopes: `bot` * Bot Permissions: `Send Messages`, `Read Message History` * 打开生成的邀请链接,将 Bot 添加到服务器 **可选:群组触发模式** 默认情况下 Bot 会回复服务器频道中的所有消息。如需仅在 @提及时回复: ```json { "channels": { "discord": { "group_trigger": { "mention_only": true } } } } ``` 也可通过关键词前缀触发(如 `!bot`): ```json { "channels": { "discord": { "group_trigger": { "prefixes": ["!bot"] } } } } ``` **6. 运行** ```bash picoclaw gateway ```
WhatsApp(原生 whatsmeow) PicoClaw 支持两种 WhatsApp 连接方式: - **原生(推荐):** 进程内使用 [whatsmeow](https://github.com/tulir/whatsmeow),无需独立 Bridge。设置 `"use_native": true` 并留空 `bridge_url`。首次运行时用 WhatsApp 扫描 QR 码(关联设备)。会话存储在工作区下(如 `workspace/whatsapp/`)。原生渠道为**可选**构建,使用 `-tags whatsapp_native` 编译(如 `make build-whatsapp-native` 或 `go build -tags whatsapp_native ./cmd/...`)。 - **Bridge:** 连接外部 WebSocket Bridge。设置 `bridge_url`(如 `ws://localhost:3001`),保持 `use_native` 为 false。 **配置(原生)** ```json { "channels": { "whatsapp": { "enabled": true, "use_native": true, "session_store_path": "", "allow_from": [] } } } ``` 如果 `session_store_path` 为空,会话存储在 `/whatsapp/`。运行 `picoclaw gateway`;首次运行时在终端扫描 QR 码(WhatsApp → 关联设备)。
Matrix **1. 准备 Bot 账号** * 使用你的 homeserver(如 `https://matrix.org` 或自建) * 创建 Bot 用户并获取 access token **2. 配置** ```json { "channels": { "matrix": { "enabled": true, "homeserver": "https://matrix.org", "user_id": "@your-bot:matrix.org", "access_token": "YOUR_MATRIX_ACCESS_TOKEN", "allow_from": [] } } } ``` **3. 运行** ```bash picoclaw gateway ``` 完整选项(`device_id`、`join_on_invite`、`group_trigger`、`placeholder`、`reasoning_channel_id`)请参考 [Matrix 渠道配置指南](../channels/matrix/README.md)。
QQ **1. 创建 Bot** - 前往 [QQ 开放平台](https://q.qq.com/#) - 创建应用 → 获取 **AppID** 和 **AppSecret** **2. 配置** ```json { "channels": { "qq": { "enabled": true, "app_id": "YOUR_APP_ID", "app_secret": "YOUR_APP_SECRET", "allow_from": [] } } } ``` > `allow_from` 留空表示允许所有用户,或指定 QQ 号限制访问。 **3. 运行** ```bash picoclaw gateway ```
Slack **1. 创建 Slack App** * 前往 [Slack API](https://api.slack.com/apps) 创建应用 * 启用 **Socket Mode** * 获取 **Bot Token** 和 **App-Level Token** **2. 配置** ```json { "channels": { "slack": { "enabled": true, "bot_token": "xoxb-YOUR_BOT_TOKEN", "app_token": "xapp-YOUR_APP_TOKEN", "allow_from": [] } } } ``` **3. 运行** ```bash picoclaw gateway ```
IRC **1. 配置** ```json { "channels": { "irc": { "enabled": true, "server": "irc.libera.chat:6697", "nick": "picoclaw-bot", "use_tls": true, "channels_to_join": ["#your-channel"], "allow_from": [] } } } ``` **2. 运行** ```bash picoclaw gateway ```
钉钉 (DingTalk) **1. 创建 Bot** * 前往 [开放平台](https://open.dingtalk.com/) * 创建内部应用 * 复制 Client ID 和 Client Secret **2. 配置** ```json { "channels": { "dingtalk": { "enabled": true, "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "allow_from": [] } } } ``` > `allow_from` 留空表示允许所有用户,或指定钉钉用户 ID 限制访问。 **3. 运行** ```bash picoclaw gateway ```
LINE **1. 创建 LINE Official Account** - 前往 [LINE Developers Console](https://developers.line.biz/) - 创建 Provider → 创建 Messaging API Channel - 复制 **Channel Secret** 和 **Channel Access Token** **2. 配置** ```json { "channels": { "line": { "enabled": true, "channel_secret": "YOUR_CHANNEL_SECRET", "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", "webhook_path": "/webhook/line", "allow_from": [] } } } ``` > LINE Webhook 挂载在共享 Gateway 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`)。 **3. 设置 Webhook URL** LINE 要求 HTTPS Webhook。使用反向代理或隧道: ```bash # 示例:使用 ngrok(Gateway 默认端口 18790) ngrok http 18790 ``` 然后在 LINE Developers Console 中将 Webhook URL 设置为 `https://your-domain/webhook/line` 并启用 **Use webhook**。 **4. 运行** ```bash picoclaw gateway ``` > 在群聊中,Bot 仅在被 @提及时回复。回复会引用原始消息。
飞书 (Feishu) **1. 创建应用** * 前往 [飞书开放平台](https://open.feishu.cn/) * 创建企业自建应用 * 获取 **App ID** 和 **App Secret** **2. 配置** ```json { "channels": { "feishu": { "enabled": true, "app_id": "cli_xxx", "app_secret": "xxx", "encrypt_key": "", "verification_token": "", "allow_from": [] } } } ``` **3. 运行** ```bash picoclaw gateway ```
企业微信 (WeCom) PicoClaw 支持三种企业微信集成方式: **方式 1: 群机器人 (Bot)** — 设置简单,支持群聊 **方式 2: 自建应用 (App)** — 功能更多,支持主动推送,仅私聊 **方式 3: 智能机器人 (AI Bot)** — 官方 AI Bot,流式回复,支持群聊和私聊 详细设置请参考 [企业微信 AI Bot 配置指南](../channels/wecom/wecom_aibot/README.zh.md)。 **快速设置 — 群机器人:** **1. 创建 Bot** * 企业微信管理后台 → 群聊 → 添加群机器人 * 复制 Webhook URL(格式:`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) **2. 配置** ```json { "channels": { "wecom": { "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", "webhook_path": "/webhook/wecom", "allow_from": [] } } } ``` > WeCom Webhook 挂载在共享 Gateway 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`)。 **快速设置 — 自建应用:** **1. 创建应用** * 企业微信管理后台 → 应用管理 → 创建应用 * 复制 **AgentId** 和 **Secret** * 前往"我的企业"页面,复制 **CorpID** **2. 配置接收消息** * 在应用详情中,点击"接收消息" → "设置 API" * 设置 URL 为 `http://your-server:18790/webhook/wecom-app` * 生成 **Token** 和 **EncodingAESKey** **3. 配置** ```json { "channels": { "wecom_app": { "enabled": true, "corp_id": "wwxxxxxxxxxxxxxxxx", "corp_secret": "YOUR_CORP_SECRET", "agent_id": 1000002, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-app", "allow_from": [] } } } ``` **4. 运行** ```bash picoclaw gateway ``` > **注意**: WeCom Webhook 回调挂载在 Gateway 端口(默认 18790)。使用反向代理配置 HTTPS。 **快速设置 — 智能机器人 (AI Bot):** **1. 创建 AI Bot** * 企业微信管理后台 → 应用管理 → AI Bot * 在 AI Bot 设置中配置回调 URL:`http://your-server:18791/webhook/wecom-aibot` * 复制 **Token** 并点击"随机生成" **EncodingAESKey** **2. 配置** ```json { "channels": { "wecom_aibot": { "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "webhook_path": "/webhook/wecom-aibot", "allow_from": [], "welcome_message": "你好!有什么可以帮你的?", "processing_message": "⏳ Processing, please wait. The results will be sent shortly." } } } ``` **3. 运行** ```bash picoclaw gateway ``` > **注意**: 企业微信 AI Bot 使用流式拉取协议,无回复超时问题。长任务(>30 秒)会自动切换到 `response_url` 推送投递。
OneBot **1. 配置** 兼容 NapCat / Go-CQHTTP 等 OneBot 实现。 ```json { "channels": { "onebot": { "enabled": true, "allow_from": [] } } } ``` **2. 运行** ```bash picoclaw gateway ```
MaixCam 专为 Sipeed AI 摄像头硬件设计的集成通道。 ```json { "channels": { "maixcam": { "enabled": true } } } ``` ```bash picoclaw gateway ```
================================================ FILE: docs/zh/configuration.md ================================================ # ⚙️ 配置指南 > 返回 [README](../../README.zh.md) ## ⚙️ 配置详解 配置文件路径: `~/.picoclaw/config.json` ### 环境变量 你可以使用环境变量覆盖默认路径。这对于便携安装、容器化部署或将 picoclaw 作为系统服务运行非常有用。这些变量是独立的,控制不同的路径。 | 变量 | 描述 | 默认路径 | |-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| | `PICOCLAW_CONFIG` | 覆盖配置文件的路径。这直接告诉 picoclaw 加载哪个 `config.json`,忽略所有其他位置。 | `~/.picoclaw/config.json` | | `PICOCLAW_HOME` | 覆盖 picoclaw 数据根目录。这会更改 `workspace` 和其他数据目录的默认位置。 | `~/.picoclaw` | **示例:** ```bash # 使用特定的配置文件运行 picoclaw # 工作区路径将从该配置文件中读取 PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway # 在 /opt/picoclaw 中存储所有数据运行 picoclaw # 配置将从默认的 ~/.picoclaw/config.json 加载 # 工作区将在 /opt/picoclaw/workspace 创建 PICOCLAW_HOME=/opt/picoclaw picoclaw agent # 同时使用两者进行完全自定义设置 PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway ``` ### 工作区布局 (Workspace Layout) PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`): ``` ~/.picoclaw/workspace/ ├── sessions/ # 对话会话和历史 ├── memory/ # 长期记忆 (MEMORY.md) ├── state/ # 持久化状态 (最后一次频道等) ├── cron/ # 定时任务数据库 ├── skills/ # 自定义技能 ├── AGENT.md # Agent 行为指南 ├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次) ├── IDENTITY.md # Agent 身份设定 ├── SOUL.md # Agent 灵魂/性格 └── USER.md # 用户偏好 ``` > **提示:** 对 `AGENT.md`、`SOUL.md`、`USER.md` 和 `memory/MEMORY.md` 的修改会通过文件修改时间(mtime)在运行时自动检测。**无需重启 gateway**,Agent 将在下一次请求时自动加载最新内容。 ### 技能来源 (Skill Sources) 默认情况下,技能会按以下顺序加载: 1. `~/.picoclaw/workspace/skills`(工作区) 2. `~/.picoclaw/skills`(全局) 3. `/skills`(内置) 在高级/测试场景下,可通过以下环境变量覆盖内置技能目录: ```bash export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ``` ### 统一命令执行策略 - 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。 - Channel 适配器不再在本地消费通用命令;它们只负责把入站文本转发到 bus/agent 路径。Telegram 仍会在启动时自动注册其支持的命令菜单。 - 未注册的斜杠命令(例如 `/foo`)会透传给 LLM 按普通输入处理。 - 已注册但当前 channel 不支持的命令(例如 WhatsApp 上的 `/show`)会返回明确的用户可见错误,并停止后续处理。 ### 🔒 安全沙箱 (Security Sandbox) PicoClaw 默认在沙箱环境中运行。Agent 只能访问配置的工作区内的文件和执行命令。 #### 默认配置 ```json { "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "restrict_to_workspace": true } } } ``` | 选项 | 默认值 | 描述 | | ----------------------- | ----------------------- | ----------------------------- | | `workspace` | `~/.picoclaw/workspace` | Agent 的工作目录 | | `restrict_to_workspace` | `true` | 限制文件/命令访问在工作区内 | #### 受保护的工具 当 `restrict_to_workspace: true` 时,以下工具会被沙箱化: | 工具 | 功能 | 限制 | | ------------- | ------------ | ------------------------------ | | `read_file` | 读取文件 | 仅限工作区内的文件 | | `write_file` | 写入文件 | 仅限工作区内的文件 | | `list_dir` | 列出目录 | 仅限工作区内的目录 | | `edit_file` | 编辑文件 | 仅限工作区内的文件 | | `append_file` | 追加文件 | 仅限工作区内的文件 | | `exec` | 执行命令 | 命令路径必须在工作区内 | #### 额外的 Exec 保护 即使 `restrict_to_workspace: false`,`exec` 工具也会阻止以下危险命令: * `rm -rf`、`del /f`、`rmdir /s` — 批量删除 * `format`、`mkfs`、`diskpart` — 磁盘格式化 * `dd if=` — 磁盘镜像 * 写入 `/dev/sd[a-z]` — 直接磁盘写入 * `shutdown`、`reboot`、`poweroff` — 系统关机 * Fork bomb `:(){ :|:& };:` ### 文件访问控制 | 配置键 | 类型 | 默认值 | 描述 | |--------|------|--------|------| | `tools.allow_read_paths` | string[] | `[]` | 允许在工作区外读取的额外路径 | | `tools.allow_write_paths` | string[] | `[]` | 允许在工作区外写入的额外路径 | ### Exec 安全配置 | 配置键 | 类型 | 默认值 | 描述 | |--------|------|--------|------| | `tools.exec.allow_remote` | bool | `false` | 允许从远程渠道(Telegram/Discord 等)执行 exec 工具 | | `tools.exec.enable_deny_patterns` | bool | `true` | 启用危险命令拦截 | | `tools.exec.custom_deny_patterns` | string[] | `[]` | 自定义阻止的正则表达式模式 | | `tools.exec.custom_allow_patterns` | string[] | `[]` | 自定义允许的正则表达式模式 | > **安全提示:** Symlink 保护默认启用——所有文件路径在白名单匹配前都会通过 `filepath.EvalSymlinks` 解析,防止符号链接逃逸攻击。 #### 已知限制:构建工具的子进程 exec 安全守卫仅检查 PicoClaw 直接启动的命令行。它不会递归检查由 `make`、`go run`、`cargo`、`npm run` 或自定义构建脚本等开发工具产生的子进程。 这意味着顶层命令通过初始守卫检查后,仍可以编译或启动其他二进制文件。实际上,应将构建脚本、Makefile、包脚本和生成的二进制文件视为与直接 shell 命令同等级别的可执行代码进行审查。 对于高风险环境: * 执行前审查构建脚本。 * 对编译并运行的工作流优先使用审批/手动审查。 * 如果需要比内置守卫更强的隔离,请在容器或虚拟机中运行 PicoClaw。 #### 错误示例 ``` [ERROR] tool: Tool execution failed {tool=exec, error=Command blocked by safety guard (path outside working dir)} ``` ``` [ERROR] tool: Tool execution failed {tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} ``` #### 禁用限制(安全风险) 如果需要 Agent 访问工作区外的路径: **方法 1: 配置文件** ```json { "agents": { "defaults": { "restrict_to_workspace": false } } } ``` **方法 2: 环境变量** ```bash export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false ``` > ⚠️ **警告**: 禁用此限制将允许 Agent 访问系统上的任何路径。仅在受控环境中谨慎使用。 #### 安全边界一致性 `restrict_to_workspace` 设置在所有执行路径中一致应用: | 执行路径 | 安全边界 | | ---------------- | ---------------------------- | | 主 Agent | `restrict_to_workspace` ✅ | | 子 Agent / Spawn | 继承相同限制 ✅ | | 心跳任务 | 继承相同限制 ✅ | 所有路径共享相同的工作区限制——无法通过子 Agent 或定时任务绕过安全边界。 ### 心跳 / 周期性任务 (Heartbeat) PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件: ```markdown # Periodic Tasks - Check my email for important messages - Review my calendar for upcoming events - Check the weather forecast ``` Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。 #### 使用 Spawn 的异步任务 对于耗时较长的任务(网络搜索、API 调用),使用 `spawn` 工具创建一个 **子 Agent (subagent)**: ```markdown # Periodic Tasks ## Quick Tasks (respond directly) - Report current time ## Long Tasks (use spawn for async) - Search the web for AI news and summarize - Check email and report important messages ``` **关键行为:** | 特性 | 描述 | | ---------------- | ---------------------------------------- | | **spawn** | 创建异步子 Agent,不阻塞主心跳进程 | | **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 | | **message tool** | 子 Agent 通过 message 工具直接与用户通信 | | **非阻塞** | spawn 后,心跳继续处理下一个任务 | **配置:** ```json { "heartbeat": { "enabled": true, "interval": 30 } } ``` | 选项 | 默认值 | 描述 | | ---------- | ------ | ---------------------------- | | `enabled` | `true` | 启用/禁用心跳 | | `interval` | `30` | 检查间隔,单位分钟 (最小: 5) | **环境变量:** - `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用 - `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔 ================================================ FILE: docs/zh/docker.md ================================================ # 🐳 Docker 与快速开始 > 返回 [README](../../README.zh.md) ## 🐳 Docker Compose 您也可以使用 Docker Compose 运行 PicoClaw,无需在本地安装任何环境。 ```bash # 1. 克隆仓库 git clone https://github.com/sipeed/picoclaw.git cd picoclaw # 2. 首次运行 — 自动生成 docker/data/config.json 后退出 docker compose -f docker/docker-compose.yml --profile gateway up # 容器打印 "First-run setup complete." 后自动停止 # 3. 填写 API Key 等配置 vim docker/data/config.json # 设置 provider API key、Bot Token 等 # 4. 正式启动 docker compose -f docker/docker-compose.yml --profile gateway up -d ``` > [!TIP] > **Docker 用户**: 默认情况下, Gateway 监听 `127.0.0.1`,该端口不会暴露到容器外。如果需要通过端口映射访问健康检查接口,请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`。 ```bash # 5. 查看日志 docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway # 6. 停止 docker compose -f docker/docker-compose.yml --profile gateway down ``` ### Launcher 模式 (Web 控制台) `launcher` 镜像包含所有三个二进制文件(`picoclaw`、`picoclaw-launcher`、`picoclaw-launcher-tui`),默认启动 Web 控制台,提供基于浏览器的配置和聊天界面。 ```bash docker compose -f docker/docker-compose.yml --profile launcher up -d ``` 在浏览器中打开 http://localhost:18800。Launcher 会自动管理 Gateway 进程。 > [!WARNING] > Web 控制台尚不支持身份验证。请勿将其暴露到公网。 ### Agent 模式 (一次性运行) ```bash # 提问 docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 等于几?" # 交互模式 docker compose -f docker/docker-compose.yml run --rm picoclaw-agent ``` ### 更新镜像 ```bash docker compose -f docker/docker-compose.yml pull docker compose -f docker/docker-compose.yml --profile gateway up -d ``` --- ## 🚀 快速开始 > [!TIP] > 在 `~/.picoclaw/config.json` 中设置您的 API Key。获取 API Key: [火山引擎 (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。网络搜索是 **可选的** — 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)。 **1. 初始化 (Initialize)** ```bash picoclaw onboard ``` **2. 配置 (Configure)** (`~/.picoclaw/config.json`) ```json { "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "model_name": "gpt-5.4", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 } }, "model_list": [ { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", "api_key": "sk-your-api-key", "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_key": "your-api-key", "request_timeout": 300 }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", "api_key": "your-anthropic-key" } ], "tools": { "web": { "enabled": true, "fetch_limit_bytes": 10485760, "format": "plaintext", "brave": { "enabled": false, "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 }, "tavily": { "enabled": false, "api_key": "YOUR_TAVILY_API_KEY", "max_results": 5 }, "duckduckgo": { "enabled": true, "max_results": 5 }, "perplexity": { "enabled": false, "api_key": "YOUR_PERPLEXITY_API_KEY", "max_results": 5 }, "searxng": { "enabled": false, "base_url": "http://your-searxng-instance:8888", "max_results": 5 } } } } ``` > **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](providers.md#模型配置-model_list)章节。 > `request_timeout` 为可选项,单位为秒。若省略或设置为 `<= 0`,PicoClaw 使用默认超时(120 秒)。 **3. 获取 API Key** * **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) * **网络搜索** (可选): * [Brave Search](https://brave.com/search/api) - 付费 ($5/1000 次查询,约 $5-6/月) * [Perplexity](https://www.perplexity.ai) - AI 驱动的搜索与聊天界面 * [SearXNG](https://github.com/searxng/searxng) - 自建元搜索引擎(免费,无需 API Key) * [Tavily](https://tavily.com) - 专为 AI Agent 优化 (1000 请求/月) * DuckDuckGo - 内置回退(无需 API Key) > **注意**: 完整的配置模板请参考 `config.example.json`。 **4. 对话 (Chat)** ```bash picoclaw agent -m "2+2 等于几?" ``` 就是这样!您在 2 分钟内就拥有了一个可工作的 AI 助手。 --- ================================================ FILE: docs/zh/providers.md ================================================ # 🔌 提供商与模型配置 > 返回 [README](../../README.zh.md) ### 提供商 (Providers) > [!NOTE] > Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,任意渠道的音频消息都将在 Agent 层面自动转录为文字。 | 提供商 | 用途 | 获取 API Key | | -------------------- | ---------------------------- | -------------------------------------------------------------------- | | `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) | | `zhipu` | LLM (智谱直连) | [bigmodel.cn](https://bigmodel.cn) | | `volcengine` | LLM (火山引擎直连) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | | `openrouter` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) | | `anthropic` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) | | `openai` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) | | `deepseek` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) | | `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) | | `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) | | `vivgrid` | LLM (Vivgrid 直连) | [vivgrid.com](https://vivgrid.com) | | `moonshot` | LLM (Kimi/Moonshot 直连) | [platform.moonshot.cn](https://platform.moonshot.cn) | | `minimax` | LLM (Minimax 直连) | [platform.minimaxi.com](https://platform.minimaxi.com) | | `avian` | LLM (Avian 直连) | [avian.io](https://avian.io) | | `mistral` | LLM (Mistral 直连) | [console.mistral.ai](https://console.mistral.ai) | | `longcat` | LLM (Longcat 直连) | [longcat.ai](https://longcat.ai) | | `modelscope` | LLM (ModelScope 直连) | [modelscope.cn](https://modelscope.cn) | ### 模型配置 (model_list) > **新功能!** PicoClaw 现在采用**以模型为中心**的配置方式。只需使用 `厂商/模型` 格式(如 `zhipu/glm-4.7`)即可添加新的 provider——**无需修改任何代码!** 该设计同时支持**多 Agent 场景**,提供灵活的 Provider 选择: - **不同 Agent 使用不同 Provider**:每个 Agent 可以使用自己的 LLM provider - **模型回退(Fallback)**:配置主模型和备用模型,提高可靠性 - **负载均衡**:在多个 API 端点之间分配请求 - **集中化配置**:在一个地方管理所有 provider #### 📋 所有支持的厂商 | 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key | | ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- | | **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) | | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) | | **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取密钥](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) | | **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理密钥 | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) | | **火山引擎(Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | | **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取密钥](https://www.byteplus.com) | | **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [获取密钥](https://vivgrid.com) | | **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) | | **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取 Token](https://modelscope.cn/my/tokens) | | **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | #### 基础配置示例 ```json { "model_list": [ { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", "api_key": "sk-your-api-key" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_key": "sk-your-openai-key" }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", "api_key": "sk-ant-your-key" }, { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", "api_key": "your-zhipu-key" } ], "agents": { "defaults": { "model": "gpt-5.4" } } } ``` #### 各厂商配置示例 **OpenAI** ```json { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_key": "sk-..." } ``` **火山引擎(Doubao)** ```json { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", "api_key": "sk-..." } ``` **智谱 AI (GLM)** ```json { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", "api_key": "your-key" } ``` **DeepSeek** ```json { "model_name": "deepseek-chat", "model": "deepseek/deepseek-chat", "api_key": "sk-..." } ``` **Anthropic (使用 OAuth)** ```json { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", "auth_method": "oauth" } ``` > 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。 **Anthropic Messages API(原生格式)** 用于直接访问 Anthropic API 或仅支持 Anthropic 原生消息格式的自定义端点: ```json { "model_name": "claude-opus-4-6", "model": "anthropic-messages/claude-opus-4-6", "api_key": "sk-ant-your-key", "api_base": "https://api.anthropic.com" } ``` > 使用 `anthropic-messages` 协议的场景: > - 使用仅支持 Anthropic 原生 `/v1/messages` 端点的第三方代理(不支持 OpenAI 兼容的 `/v1/chat/completions`) > - 连接到 MiniMax、Synthetic 等需要 Anthropic 原生消息格式的服务 > - 现有的 `anthropic` 协议返回 404 错误(说明端点不支持 OpenAI 兼容格式) > > **注意:** `anthropic` 协议使用 OpenAI 兼容格式(`/v1/chat/completions`),而 `anthropic-messages` 使用 Anthropic 原生格式(`/v1/messages`)。请根据端点支持的格式选择。 **Ollama (本地)** ```json { "model_name": "llama3", "model": "ollama/llama3" } ``` **自定义代理/API** ```json { "model_name": "my-custom-model", "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", "api_key": "sk-...", "request_timeout": 300 } ``` **LiteLLM Proxy** ```json { "model_name": "lite-gpt4", "model": "litellm/lite-gpt4", "api_base": "http://localhost:4000/v1", "api_key": "sk-..." } ``` PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/lite-gpt4` 会发送 `lite-gpt4`,而 `litellm/openai/gpt-4o` 会发送 `openai/gpt-4o`。 #### 负载均衡 为同一个模型名称配置多个端点——PicoClaw 会自动在它们之间轮询: ```json { "model_list": [ { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" } ] } ``` #### 从旧的 `providers` 配置迁移 旧的 `providers` 配置格式**已弃用**,但为向后兼容仍支持。 **旧配置(已弃用):** ```json { "providers": { "zhipu": { "api_key": "your-key", "api_base": "https://open.bigmodel.cn/api/paas/v4" } }, "agents": { "defaults": { "provider": "zhipu", "model": "glm-4.7" } } } ``` **新配置(推荐):** ```json { "model_list": [ { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", "api_key": "your-key" } ], "agents": { "defaults": { "model": "glm-4.7" } } } ``` 详细的迁移指南请参考 [docs/migration/model-list-migration.md](../migration/model-list-migration.md)。 ### Provider 架构 PicoClaw 按协议族路由 Provider: - OpenAI 兼容协议:OpenRouter、OpenAI 兼容网关、Groq、智谱、vLLM 风格端点。 - Anthropic 协议:Claude 原生 API 行为。 - Codex/OAuth 路径:OpenAI OAuth/Token 认证路由。 这使得运行时保持轻量,同时让新的 OpenAI 兼容后端基本只需配置操作(`api_base` + `api_key`)。
智谱 (Zhipu) 配置示例 **1. 获取 API key 和 base URL** - 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) **2. 配置** ```json { "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "model": "glm-4.7", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 } }, "providers": { "zhipu": { "api_key": "Your API Key", "api_base": "https://open.bigmodel.cn/api/paas/v4" } } } ``` **3. 运行** ```bash picoclaw agent -m "你好" ```
完整配置示例 ```json { "agents": { "defaults": { "model": "anthropic/claude-opus-4-5" } }, "session": { "dm_scope": "per-channel-peer", "backlog_limit": 20 }, "providers": { "openrouter": { "api_key": "sk-or-v1-xxx" }, "groq": { "api_key": "gsk_xxx" } }, "channels": { "telegram": { "enabled": true, "token": "123456:ABC...", "allow_from": ["123456789"] }, "discord": { "enabled": true, "token": "", "allow_from": [""] }, "whatsapp": { "enabled": false, "bridge_url": "ws://localhost:3001", "use_native": false, "session_store_path": "", "allow_from": [] }, "feishu": { "enabled": false, "app_id": "cli_xxx", "app_secret": "xxx", "encrypt_key": "", "verification_token": "", "allow_from": [] }, "qq": { "enabled": false, "app_id": "", "app_secret": "", "allow_from": [] } }, "tools": { "web": { "brave": { "enabled": false, "api_key": "BSA...", "max_results": 5 }, "duckduckgo": { "enabled": true, "max_results": 5 }, "perplexity": { "enabled": false, "api_key": "", "max_results": 5 }, "searxng": { "enabled": false, "base_url": "http://localhost:8888", "max_results": 5 } }, "cron": { "exec_timeout_minutes": 5 } }, "heartbeat": { "enabled": true, "interval": 30 } } ```
--- ## 📝 API Key 对比 | 服务 | 价格 | 适用场景 | | --- | --- | --- | | **OpenRouter** | 免费: 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) | | **火山引擎 CodingPlan** | ¥9.9/首月 | 最适合国内用户,多种 SOTA 模型(豆包、DeepSeek 等) | | **智谱 (Zhipu)** | 免费: 200K tokens/月 | 适合中国用户 | | **Brave Search** | $5/1000 次查询 | 网络搜索功能 | | **SearXNG** | 免费(自建) | 隐私优先的元搜索引擎(70+ 搜索引擎) | | **Groq** | 免费额度可用 | 极速推理 (Llama, Mixtral) | | **Cerebras** | 免费额度可用 | 极速推理 (Llama, Qwen 等) | | **LongCat** | 免费: 最多 5M tokens/天 | 极速推理 | | **ModelScope (魔搭)** | 免费: 2000 次请求/天 | 推理 (Qwen, GLM, DeepSeek 等) | ================================================ FILE: docs/zh/spawn-tasks.md ================================================ # 🔄 异步任务与 Spawn > 返回 [README](../../README.zh.md) ### 使用 Spawn 的异步任务 对于耗时较长的任务(网络搜索、API 调用),使用 `spawn` 工具创建一个 **子 Agent (subagent)**: ```markdown # Periodic Tasks ## Quick Tasks (respond directly) - Report current time ## Long Tasks (use spawn for async) - Search the web for AI news and summarize - Check email and report important messages ``` **关键行为:** | 特性 | 描述 | | ---------------- | ---------------------------------------- | | **spawn** | 创建异步子 Agent,不阻塞主心跳进程 | | **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 | | **message tool** | 子 Agent 通过 message 工具直接与用户通信 | | **非阻塞** | spawn 后,心跳继续处理下一个任务 | #### 子 Agent 通信原理 ``` 心跳触发 (Heartbeat triggers) ↓ Agent 读取 HEARTBEAT.md ↓ 对于长任务: spawn 子 Agent ↓ ↓ 继续下一个任务 子 Agent 独立工作 ↓ ↓ 所有任务完成 子 Agent 使用 "message" 工具 ↓ ↓ 响应 HEARTBEAT_OK 用户直接收到结果 ``` 子 Agent 可以访问工具(message, web_search 等),并且无需通过主 Agent 即可独立与用户通信。 **配置:** ```json { "heartbeat": { "enabled": true, "interval": 30 } } ``` | 选项 | 默认值 | 描述 | | ---------- | ------ | ---------------------------- | | `enabled` | `true` | 启用/禁用心跳 | | `interval` | `30` | 检查间隔,单位分钟 (最小: 5) | **环境变量:** - `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用 - `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔 ================================================ FILE: docs/zh/tools_configuration.md ================================================ # 🔧 工具配置 > 返回 [README](../../README.zh.md) PicoClaw 的工具配置位于 `config.json` 的 `tools` 字段中。 ## 目录结构 ```json { "tools": { "web": { ... }, "mcp": { ... }, "exec": { ... }, "cron": { ... }, "skills": { ... } } } ``` ## Web 工具 Web 工具用于网页搜索和抓取。 ### Web Fetcher 用于抓取和处理网页内容的通用设置。 | 配置项 | 类型 | 默认值 | 描述 | |---------------------|--------|---------------|----------------------------------------------------------------------------------------| | `enabled` | bool | true | 启用网页抓取功能。 | | `fetch_limit_bytes` | int | 10485760 | 抓取网页负载的最大大小,单位为字节(默认 10MB)。 | | `format` | string | "plaintext" | 抓取内容的输出格式。选项:`plaintext` 或 `markdown`(推荐)。 | ### Brave | 配置项 | 类型 | 默认值 | 描述 | |---------------|--------|--------|--------------------| | `enabled` | bool | false | 启用 Brave 搜索 | | `api_key` | string | - | Brave Search API 密钥 | | `max_results` | int | 5 | 最大结果数 | ### DuckDuckGo | 配置项 | 类型 | 默认值 | 描述 | |---------------|------|--------|-----------------------| | `enabled` | bool | true | 启用 DuckDuckGo 搜索 | | `max_results` | int | 5 | 最大结果数 | ### Perplexity | 配置项 | 类型 | 默认值 | 描述 | |---------------|--------|--------|-----------------------| | `enabled` | bool | false | 启用 Perplexity 搜索 | | `api_key` | string | - | Perplexity API 密钥 | | `max_results` | int | 5 | 最大结果数 | ## Exec 工具 Exec 工具用于执行 shell 命令。 | 配置项 | 类型 | 默认值 | 描述 | |------------------------|-------|--------|--------------------------------| | `enabled` | bool | true | 启用 exec 工具 | | `enable_deny_patterns` | bool | true | 启用默认的危险命令拦截 | | `custom_deny_patterns` | array | [] | 自定义拒绝模式(正则表达式) | ### 禁用 Exec 工具 要完全禁用 `exec` 工具,请将 `enabled` 设置为 `false`: **通过配置文件:** ```json { "tools": { "exec": { "enabled": false } } } ``` **通过环境变量:** ```bash PICOCLAW_TOOLS_EXEC_ENABLED=false ``` > **注意:** 禁用后,代理将无法执行 shell 命令。这也会影响 Cron 工具运行计划 shell 命令的能力。 ### 功能说明 - **`enable_deny_patterns`**:设为 `false` 可完全禁用默认的危险命令拦截模式 - **`custom_deny_patterns`**:添加自定义拒绝正则模式;匹配的命令将被拦截 ### 默认拦截的命令模式 默认情况下,PicoClaw 会拦截以下危险命令: - 删除命令:`rm -rf`、`del /f/q`、`rmdir /s` - 磁盘操作:`format`、`mkfs`、`diskpart`、`dd if=`、写入 `/dev/sd*` - 系统操作:`shutdown`、`reboot`、`poweroff` - 命令替换:`$()`、`${}`、反引号 - 管道到 shell:`| sh`、`| bash` - 权限提升:`sudo`、`chmod`、`chown` - 进程控制:`pkill`、`killall`、`kill -9` - 远程操作:`curl | sh`、`wget | sh`、`ssh` - 包管理:`apt`、`yum`、`dnf`、`npm install -g`、`pip install --user` - 容器:`docker run`、`docker exec` - Git:`git push`、`git force` - 其他:`eval`、`source *.sh` ### 已知架构限制 exec 守卫仅验证发送给 PicoClaw 的顶层命令。它**不会**递归检查该命令启动后由构建工具或脚本生成的子进程。 以下工作流在初始命令被允许后可以绕过直接命令守卫: - `make run` - `go run ./cmd/...` - `cargo run` - `npm run build` 这意味着守卫对于拦截明显危险的直接命令很有用,但它**不是**未审查构建管道的完整沙箱。如果你的威胁模型包括工作区中的不受信任代码,请使用更强的隔离措施,如容器、虚拟机或围绕构建和运行命令的审批流程。 ### 配置示例 ```json { "tools": { "exec": { "enable_deny_patterns": true, "custom_deny_patterns": [ "\\brm\\s+-r\\b", "\\bkillall\\s+python" ] } } } ``` ## Cron 工具 Cron 工具用于调度周期性任务。 | 配置项 | 类型 | 默认值 | 描述 | |------------------------|------|--------|-------------------------------------| | `exec_timeout_minutes` | int | 5 | 执行超时时间(分钟),0 表示无限制 | ## MCP 工具 MCP 工具支持与外部 Model Context Protocol 服务器集成。 ### 工具发现(延迟加载) 当连接多个 MCP 服务器时,同时暴露数百个工具可能会耗尽 LLM 的上下文窗口并增加 API 成本。**Discovery** 功能通过默认*隐藏* MCP 工具来解决此问题。 LLM 不会加载所有工具,而是获得一个轻量级搜索工具(使用 BM25 关键词匹配或正则表达式)。当 LLM 需要特定功能时,它会搜索隐藏的工具库。匹配的工具随后被临时"解锁"并注入上下文中,持续配置的轮数(`ttl`)。 ### 全局配置 | 配置项 | 类型 | 默认值 | 描述 | |-------------|--------|--------|--------------------------------------| | `enabled` | bool | false | 全局启用 MCP 集成 | | `discovery` | object | `{}` | 工具发现配置(见下文) | | `servers` | object | `{}` | 服务器名称到服务器配置的映射 | ### Discovery 配置(`discovery`) | 配置项 | 类型 | 默认值 | 描述 | |----------------------|------|--------|---------------------------------------------------------------------------------------------------------------| | `enabled` | bool | false | 如果为 true,MCP 工具将被隐藏并按需通过搜索加载。如果为 false,所有工具都会被加载 | | `ttl` | int | 5 | 已发现工具保持解锁状态的对话轮数 | | `max_search_results` | int | 5 | 每次搜索查询返回的最大工具数 | | `use_bm25` | bool | true | 启用自然语言/关键词搜索工具(`tool_search_tool_bm25`)。**警告**:比正则搜索消耗更多资源 | | `use_regex` | bool | false | 启用正则模式搜索工具(`tool_search_tool_regex`) | > **注意:** 如果 `discovery.enabled` 为 `true`,你**必须**启用至少一个搜索引擎(`use_bm25` 或 `use_regex`), > 否则应用程序将无法启动。 ### 单服务器配置 | 配置项 | 类型 | 必需 | 描述 | |------------|--------|----------|------------------------------------| | `enabled` | bool | 是 | 启用此 MCP 服务器 | | `type` | string | 否 | 传输类型:`stdio`、`sse`、`http` | | `command` | string | stdio | stdio 传输的可执行命令 | | `args` | array | 否 | stdio 传输的命令参数 | | `env` | object | 否 | stdio 进程的环境变量 | | `env_file` | string | 否 | stdio 进程的环境文件路径 | | `url` | string | sse/http | `sse`/`http` 传输的端点 URL | | `headers` | object | 否 | `sse`/`http` 传输的 HTTP 头 | ### 传输行为 - 如果省略 `type`,传输方式将自动检测: - 设置了 `url` → `sse` - 设置了 `command` → `stdio` - `http` 和 `sse` 都使用 `url` + 可选的 `headers`。 - `env` 和 `env_file` 仅应用于 `stdio` 服务器。 ### 配置示例 #### 1) Stdio MCP 服务器 ```json { "tools": { "mcp": { "enabled": true, "servers": { "filesystem": { "enabled": true, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-filesystem", "/tmp" ] } } } } } ``` #### 2) 远程 SSE/HTTP MCP 服务器 ```json { "tools": { "mcp": { "enabled": true, "servers": { "remote-mcp": { "enabled": true, "type": "sse", "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer YOUR_TOKEN" } } } } } } ``` #### 3) 启用工具发现的大规模 MCP 设置 *在此示例中,LLM 只会看到 `tool_search_tool_bm25`。它将仅在用户请求时动态搜索并解锁 Github 或 Postgres 工具。* ```json { "tools": { "mcp": { "enabled": true, "discovery": { "enabled": true, "ttl": 5, "max_search_results": 5, "use_bm25": true, "use_regex": false }, "servers": { "github": { "enabled": true, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-github" ], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" } }, "postgres": { "enabled": true, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-postgres", "postgresql://user:password@localhost/dbname" ] }, "slack": { "enabled": true, "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-slack" ], "env": { "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" } } } } } } ``` ## Skills 工具 Skills 工具配置通过 ClawHub 等注册表进行技能发现和安装。 ### 注册表 | 配置项 | 类型 | 默认值 | 描述 | |------------------------------------|--------|----------------------|--------------------------------------| | `registries.clawhub.enabled` | bool | true | 启用 ClawHub 注册表 | | `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub 基础 URL | | `registries.clawhub.auth_token` | string | `""` | 可选的 Bearer 令牌,用于更高速率限制 | | `registries.clawhub.search_path` | string | `/api/v1/search` | 搜索 API 路径 | | `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API 路径 | | `registries.clawhub.download_path` | string | `/api/v1/download` | 下载 API 路径 | ### 配置示例 ```json { "tools": { "skills": { "registries": { "clawhub": { "enabled": true, "base_url": "https://clawhub.ai", "auth_token": "", "search_path": "/api/v1/search", "skills_path": "/api/v1/skills", "download_path": "/api/v1/download" } } } } } ``` ## 环境变量 所有配置选项都可以通过格式为 `PICOCLAW_TOOLS_
_` 的环境变量覆盖: 例如: - `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` - `PICOCLAW_TOOLS_EXEC_ENABLED=false` - `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` - `PICOCLAW_TOOLS_MCP_ENABLED=true` 注意:嵌套的映射式配置(例如 `tools.mcp.servers..*`)在 `config.json` 中配置,而非通过环境变量。 ================================================ FILE: docs/zh/troubleshooting.md ================================================ # 🐛 疑难解答 > 返回 [README](../../README.zh.md) ## "model ... not found in model_list" 或 OpenRouter "free is not a valid model ID" **症状:** 你看到以下任一错误: - `Error creating provider: model "openrouter/free" not found in model_list` - OpenRouter 返回 400:`"free is not a valid model ID"` **原因:** `model_list` 条目中的 `model` 字段是发送给 API 的内容。对于 OpenRouter,你必须使用**完整的**模型 ID,而不是简写。 - **错误:** `"model": "free"` → OpenRouter 收到 `free` 并拒绝。 - **正确:** `"model": "openrouter/free"` → OpenRouter 收到 `openrouter/free`(自动免费层路由)。 **修复方法:** 在 `~/.picoclaw/config.json`(或你的配置路径)中: 1. **agents.defaults.model** 必须匹配 `model_list` 中的某个 `model_name`(例如 `"openrouter-free"`)。 2. 该条目的 **model** 必须是有效的 OpenRouter 模型 ID,例如: - `"openrouter/free"` – 自动免费层 - `"google/gemini-2.0-flash-exp:free"` - `"meta-llama/llama-3.1-8b-instruct:free"` 示例片段: ```json { "agents": { "defaults": { "model": "openrouter-free" } }, "model_list": [ { "model_name": "openrouter-free", "model": "openrouter/free", "api_key": "sk-or-v1-YOUR_OPENROUTER_KEY", "api_base": "https://openrouter.ai/api/v1" } ] } ``` 在 [OpenRouter Keys](https://openrouter.ai/keys) 获取你的密钥。 ================================================ FILE: go.mod ================================================ module github.com/sipeed/picoclaw go 1.25.8 require ( fyne.io/systray v1.12.0 github.com/adhocore/gronx v1.19.6 github.com/anthropics/anthropic-sdk-go v1.26.0 github.com/bwmarrin/discordgo v0.29.0 github.com/caarlos0/env/v11 v11.4.0 github.com/ergochat/irc-go v0.6.0 github.com/ergochat/readline v0.1.3 github.com/gdamore/tcell/v2 v2.13.8 github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/h2non/filetype v1.1.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/mdp/qrterminal/v3 v3.2.1 github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/mymmrac/telego v1.7.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 github.com/openai/openai-go/v3 v3.22.0 github.com/rivo/tview v0.42.0 github.com/rs/zerolog v1.34.0 github.com/slack-go/slack v0.17.3 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/tencent-connect/botgo v0.2.1 go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 golang.org/x/oauth2 v0.36.0 golang.org/x/term v0.41.0 golang.org/x/time v0.14.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mautrix v0.26.4 modernc.org/sqlite v1.46.1 ) require ( filippo.io/edwards25519 v1.2.0 // indirect github.com/beeper/argo-go v1.1.2 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/vektah/gqlparser/v2 v2.5.27 // indirect go.mau.fi/libsignal v0.2.1 // indirect go.mau.fi/util v0.9.7 // indirect golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/text v0.35.0 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect rsc.io/qr v0.2.0 // indirect ) require ( github.com/andybalholm/brotli v1.2.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/github/copilot-sdk/go v0.1.32 github.com/go-resty/resty/v2 v2.17.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/jsonschema-go v0.4.2 // indirect github.com/grbit/go-json v0.11.0 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect github.com/valyala/fastjson v1.6.10 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/arch v0.24.0 // indirect golang.org/x/crypto v0.49.0 golang.org/x/net v0.52.0 golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect ) ================================================ FILE: go.sum ================================================ cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM= fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc= github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc= github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/ergochat/irc-go v0.6.0 h1:Y0AGV76aeihJfCtLaQh+OyJKFiKGrYC0VTkeMZ6XW28= github.com/ergochat/irc-go v0.6.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0= github.com/ergochat/readline v0.1.3 h1:/DytGTmwdUJcLAe3k3VJgowh5vNnsdifYT6uVaf4pSo= github.com/ergochat/readline v0.1.3/go.mod h1:o3ux9QLHLm77bq7hDB21UTm6HlV2++IPDMfIfKDuOgY= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU= github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/github/copilot-sdk/go v0.1.32 h1:wc9SFWwxXhJts6vyzzboPLJqcEJGnHE8rMCAY1RrUgo= github.com/github/copilot-sdk/go v0.1.32/go.mod h1:qc2iEF7hdO8kzSvbyGvrcGhuk2fzdW4xTtT0+1EH2ts= github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q= github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc= github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc= github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek= github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk= github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= github.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo= github.com/mymmrac/telego v1.7.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8= github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU= github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys= github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 h1:rh2lKw/P/EqHa724vYH2+VVQ1YnW4u6EOXl0PMAovZE= github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g= github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tencent-connect/botgo v0.2.1 h1:+BrTt9Zh+awL28GWC4g5Na3nQaGRWb0N5IctS8WqBCk= github.com/tencent-connect/botgo v0.2.1/go.mod h1:oO1sG9ybhXNickvt+CVym5khwQ+uKhTR+IhTqEfOVsI= github.com/tidwall/gjson v1.9.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s= github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0= go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU= go.mau.fi/util v0.9.7 h1:AWGNbJfz1zRcQOKeOEYhKUG2fT+/26Gy6kyqcH8tnBg= go.mau.fi/util v0.9.7/go.mod h1:5T2f3ZWZFAGgmFwg3dGw7YK6kIsb9lryDzvynoR98pE= go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ= go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y= golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= maunium.net/go/mautrix v0.26.4 h1:enHSnkf0L2V9+VnfJfNhKSReSW6pBKS/x3Su+v+Vovs= maunium.net/go/mautrix v0.26.4/go.mod h1:YWw8NWTszsbyFAznboicBObwHPgTSLcuTbVX2kY7U2M= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= ================================================ FILE: pkg/agent/context.go ================================================ package agent import ( "errors" "fmt" "io/fs" "os" "path/filepath" "runtime" "slices" "strings" "sync" "time" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/utils" ) type ContextBuilder struct { workspace string skillsLoader *skills.SkillsLoader memory *MemoryStore toolDiscoveryBM25 bool toolDiscoveryRegex bool // Cache for system prompt to avoid rebuilding on every call. // This fixes issue #607: repeated reprocessing of the entire context. // The cache auto-invalidates when workspace source files change (mtime check). systemPromptMutex sync.RWMutex cachedSystemPrompt string cachedAt time.Time // max observed mtime across tracked paths at cache build time // existedAtCache tracks which source file paths existed the last time the // cache was built. This lets sourceFilesChanged detect files that are newly // created (didn't exist at cache time, now exist) or deleted (existed at // cache time, now gone) — both of which should trigger a cache rebuild. existedAtCache map[string]bool // skillFilesAtCache snapshots the skill tree file set and mtimes at cache // build time. This catches nested file creations/deletions/mtime changes // that may not update the top-level skill root directory mtime. skillFilesAtCache map[string]time.Time } func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuilder { cb.toolDiscoveryBM25 = useBM25 cb.toolDiscoveryRegex = useRegex return cb } func getGlobalConfigDir() string { if home := os.Getenv(config.EnvHome); home != "" { return home } home, err := os.UserHomeDir() if err != nil { return "" } return filepath.Join(home, ".picoclaw") } func NewContextBuilder(workspace string) *ContextBuilder { // builtin skills: skills directory in current project // Use the skills/ directory under the current working directory builtinSkillsDir := strings.TrimSpace(os.Getenv(config.EnvBuiltinSkills)) if builtinSkillsDir == "" { wd, _ := os.Getwd() builtinSkillsDir = filepath.Join(wd, "skills") } globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills") return &ContextBuilder{ workspace: workspace, skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir), memory: NewMemoryStore(workspace), } } func (cb *ContextBuilder) getIdentity() string { workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace)) toolDiscovery := cb.getDiscoveryRule() version := config.FormatVersion() return fmt.Sprintf( `# picoclaw 🦞 (%s) You are picoclaw, a helpful AI assistant. ## Workspace Your workspace is at: %s - Memory: %s/memory/MEMORY.md - Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md - Skills: %s/skills/{skill-name}/SKILL.md ## Important Rules 1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it. 2. **Be helpful and accurate** - When using tools, briefly explain what you're doing. 3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md 4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content. %s`, version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery) } func (cb *ContextBuilder) getDiscoveryRule() string { if !cb.toolDiscoveryBM25 && !cb.toolDiscoveryRegex { return "" } var toolNames []string if cb.toolDiscoveryBM25 { toolNames = append(toolNames, `"tool_search_tool_bm25"`) } if cb.toolDiscoveryRegex { toolNames = append(toolNames, `"tool_search_tool_regex"`) } return fmt.Sprintf( `5. **Tool Discovery** - Your visible tools are limited to save memory, but a vast hidden library exists. If you lack the right tool for a task, BEFORE giving up, you MUST search using the %s tool. Do not refuse a request unless the search returns nothing. Found tools will temporarily unlock for your next turn.`, strings.Join(toolNames, " or "), ) } func (cb *ContextBuilder) BuildSystemPrompt() string { parts := []string{} // Core identity section parts = append(parts, cb.getIdentity()) // Bootstrap files bootstrapContent := cb.LoadBootstrapFiles() if bootstrapContent != "" { parts = append(parts, bootstrapContent) } // Skills - show summary, AI can read full content with read_file tool skillsSummary := cb.skillsLoader.BuildSkillsSummary() if skillsSummary != "" { parts = append(parts, fmt.Sprintf(`# Skills The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool. %s`, skillsSummary)) } // Memory context memoryContext := cb.memory.GetMemoryContext() if memoryContext != "" { parts = append(parts, "# Memory\n\n"+memoryContext) } // Join with "---" separator return strings.Join(parts, "\n\n---\n\n") } // BuildSystemPromptWithCache returns the cached system prompt if available // and source files haven't changed, otherwise builds and caches it. // Source file changes are detected via mtime checks (cheap stat calls). func (cb *ContextBuilder) BuildSystemPromptWithCache() string { // Try read lock first — fast path when cache is valid cb.systemPromptMutex.RLock() if cb.cachedSystemPrompt != "" && !cb.sourceFilesChangedLocked() { result := cb.cachedSystemPrompt cb.systemPromptMutex.RUnlock() return result } cb.systemPromptMutex.RUnlock() // Acquire write lock for building cb.systemPromptMutex.Lock() defer cb.systemPromptMutex.Unlock() // Double-check: another goroutine may have rebuilt while we waited if cb.cachedSystemPrompt != "" && !cb.sourceFilesChangedLocked() { return cb.cachedSystemPrompt } // Snapshot the baseline (existence + max mtime) BEFORE building the prompt. // This way cachedAt reflects the pre-build state: if a file is modified // during BuildSystemPrompt, its new mtime will be > baseline.maxMtime, // so the next sourceFilesChangedLocked check will correctly trigger a // rebuild. The alternative (baseline after build) risks caching stale // content with a too-new baseline, making the staleness invisible. baseline := cb.buildCacheBaseline() prompt := cb.BuildSystemPrompt() cb.cachedSystemPrompt = prompt cb.cachedAt = baseline.maxMtime cb.existedAtCache = baseline.existed cb.skillFilesAtCache = baseline.skillFiles logger.DebugCF("agent", "System prompt cached", map[string]any{ "length": len(prompt), }) return prompt } // InvalidateCache clears the cached system prompt. // Normally not needed because the cache auto-invalidates via mtime checks, // but this is useful for tests or explicit reload commands. func (cb *ContextBuilder) InvalidateCache() { cb.systemPromptMutex.Lock() defer cb.systemPromptMutex.Unlock() cb.cachedSystemPrompt = "" cb.cachedAt = time.Time{} cb.existedAtCache = nil cb.skillFilesAtCache = nil logger.DebugCF("agent", "System prompt cache invalidated", nil) } // sourcePaths returns non-skill workspace source files tracked for cache // invalidation (bootstrap files + memory). Skill roots are handled separately // because they require both directory-level and recursive file-level checks. func (cb *ContextBuilder) sourcePaths() []string { return []string{ filepath.Join(cb.workspace, "AGENTS.md"), filepath.Join(cb.workspace, "SOUL.md"), filepath.Join(cb.workspace, "USER.md"), filepath.Join(cb.workspace, "IDENTITY.md"), filepath.Join(cb.workspace, "memory", "MEMORY.md"), } } // skillRoots returns all skill root directories that can affect // BuildSkillsSummary output (workspace/global/builtin). func (cb *ContextBuilder) skillRoots() []string { if cb.skillsLoader == nil { return []string{filepath.Join(cb.workspace, "skills")} } roots := cb.skillsLoader.SkillRoots() if len(roots) == 0 { return []string{filepath.Join(cb.workspace, "skills")} } return roots } // cacheBaseline holds the file existence snapshot and the latest observed // mtime across all tracked paths. Used as the cache reference point. type cacheBaseline struct { existed map[string]bool skillFiles map[string]time.Time maxMtime time.Time } // buildCacheBaseline records which tracked paths currently exist and computes // the latest mtime across all tracked files + skills directory contents. // Called under write lock when the cache is built. func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline { skillRoots := cb.skillRoots() // All paths whose existence we track: source files + all skill roots. allPaths := append(cb.sourcePaths(), skillRoots...) existed := make(map[string]bool, len(allPaths)) skillFiles := make(map[string]time.Time) var maxMtime time.Time for _, p := range allPaths { info, err := os.Stat(p) existed[p] = err == nil if err == nil && info.ModTime().After(maxMtime) { maxMtime = info.ModTime() } } // Walk all skill roots recursively to snapshot skill files and mtimes. // Use os.Stat (not d.Info) for consistency with sourceFilesChanged checks. for _, root := range skillRoots { _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { if walkErr == nil && !d.IsDir() { if info, err := os.Stat(path); err == nil { skillFiles[path] = info.ModTime() if info.ModTime().After(maxMtime) { maxMtime = info.ModTime() } } } return nil }) } // If no tracked files exist yet (empty workspace), maxMtime is zero. // Use a very old non-zero time so that: // 1. cachedAt.IsZero() won't trigger perpetual rebuilds. // 2. Any real file created afterwards has mtime > cachedAt, so it // will be detected by fileChangedSince (unlike time.Now() which // could race with a file whose mtime <= Now). if maxMtime.IsZero() { maxMtime = time.Unix(1, 0) } return cacheBaseline{existed: existed, skillFiles: skillFiles, maxMtime: maxMtime} } // sourceFilesChangedLocked checks whether any workspace source file has been // modified, created, or deleted since the cache was last built. // // IMPORTANT: The caller MUST hold at least a read lock on systemPromptMutex. // Go's sync.RWMutex is not reentrant, so this function must NOT acquire the // lock itself (it would deadlock when called from BuildSystemPromptWithCache // which already holds RLock or Lock). func (cb *ContextBuilder) sourceFilesChangedLocked() bool { if cb.cachedAt.IsZero() { return true } // Check tracked source files (bootstrap + memory). if slices.ContainsFunc(cb.sourcePaths(), cb.fileChangedSince) { return true } // --- Skill roots (workspace/global/builtin) --- // // For each root: // 1. Creation/deletion and root directory mtime changes are tracked by fileChangedSince. // 2. Nested file create/delete/mtime changes are tracked by the skill file snapshot. for _, root := range cb.skillRoots() { if cb.fileChangedSince(root) { return true } } if skillFilesChangedSince(cb.skillRoots(), cb.skillFilesAtCache) { return true } return false } // fileChangedSince returns true if a tracked source file has been modified, // newly created, or deleted since the cache was built. // // Four cases: // - existed at cache time, exists now -> check mtime // - existed at cache time, gone now -> changed (deleted) // - absent at cache time, exists now -> changed (created) // - absent at cache time, gone now -> no change func (cb *ContextBuilder) fileChangedSince(path string) bool { // Defensive: if existedAtCache was never initialized, treat as changed // so the cache rebuilds rather than silently serving stale data. if cb.existedAtCache == nil { return true } existedBefore := cb.existedAtCache[path] info, err := os.Stat(path) existsNow := err == nil if existedBefore != existsNow { return true // file was created or deleted } if !existsNow { return false // didn't exist before, doesn't exist now } return info.ModTime().After(cb.cachedAt) } // errWalkStop is a sentinel error used to stop filepath.WalkDir early. // Using a dedicated error (instead of fs.SkipAll) makes the early-exit // intent explicit and avoids the nilerr linter warning that would fire // if the callback returned nil when its err parameter is non-nil. var errWalkStop = errors.New("walk stop") // skillFilesChangedSince compares the current recursive skill file tree // against the cache-time snapshot. Any create/delete/mtime drift invalidates // the cache. func skillFilesChangedSince(skillRoots []string, filesAtCache map[string]time.Time) bool { // Defensive: if the snapshot was never initialized, force rebuild. if filesAtCache == nil { return true } // Check cached files still exist and keep the same mtime. for path, cachedMtime := range filesAtCache { info, err := os.Stat(path) if err != nil { // A previously tracked file disappeared (or became inaccessible): // either way, cached skill summary may now be stale. return true } if !info.ModTime().Equal(cachedMtime) { return true } } // Check no new files appeared under any skill root. changed := false for _, root := range skillRoots { if strings.TrimSpace(root) == "" { continue } err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { if walkErr != nil { // Treat unexpected walk errors as changed to avoid stale cache. if !os.IsNotExist(walkErr) { changed = true return errWalkStop } return nil } if d.IsDir() { return nil } if _, ok := filesAtCache[path]; !ok { changed = true return errWalkStop } return nil }) if changed { return true } if err != nil && !errors.Is(err, errWalkStop) && !os.IsNotExist(err) { logger.DebugCF("agent", "skills walk error", map[string]any{"error": err.Error()}) return true } } return false } func (cb *ContextBuilder) LoadBootstrapFiles() string { bootstrapFiles := []string{ "AGENTS.md", "SOUL.md", "USER.md", "IDENTITY.md", } var sb strings.Builder for _, filename := range bootstrapFiles { filePath := filepath.Join(cb.workspace, filename) if data, err := os.ReadFile(filePath); err == nil { fmt.Fprintf(&sb, "## %s\n\n%s\n\n", filename, data) } } return sb.String() } // buildDynamicContext returns a short dynamic context string with per-request info. // This changes every request (time, session) so it is NOT part of the cached prompt. // LLM-side KV cache reuse is achieved by each provider adapter's native mechanism: // - Anthropic: per-block cache_control (ephemeral) on the static SystemParts block // - OpenAI / Codex: prompt_cache_key for prefix-based caching // // See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching // See: https://platform.openai.com/docs/guides/prompt-caching func formatCurrentSenderLine(senderID, senderDisplayName string) string { senderID = strings.TrimSpace(senderID) senderDisplayName = strings.TrimSpace(senderDisplayName) switch { case senderDisplayName != "" && senderID != "": return fmt.Sprintf("Current sender: %s (ID: %s)", senderDisplayName, senderID) case senderDisplayName != "": return fmt.Sprintf("Current sender: %s", senderDisplayName) case senderID != "": return fmt.Sprintf("Current sender: %s", senderID) default: return "" } } func (cb *ContextBuilder) buildDynamicContext(channel, chatID, senderID, senderDisplayName string) string { now := time.Now().Format("2006-01-02 15:04 (Monday)") rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version()) var sb strings.Builder fmt.Fprintf(&sb, "## Current Time\n%s\n\n## Runtime\n%s", now, rt) if channel != "" && chatID != "" { fmt.Fprintf(&sb, "\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID) } if senderLine := formatCurrentSenderLine(senderID, senderDisplayName); senderLine != "" { fmt.Fprintf(&sb, "\n\n## Current Sender\n%s", senderLine) } return sb.String() } func (cb *ContextBuilder) BuildMessages( history []providers.Message, summary string, currentMessage string, media []string, channel, chatID, senderID, senderDisplayName string, ) []providers.Message { messages := []providers.Message{} // The static part (identity, bootstrap, skills, memory) is cached locally to // avoid repeated file I/O and string building on every call (fixes issue #607). // Dynamic parts (time, session, summary) are appended per request. // Everything is sent as a single system message for provider compatibility: // - Anthropic adapter extracts messages[0] (Role=="system") and maps its content // to the top-level "system" parameter in the Messages API request. A single // contiguous system block makes this extraction straightforward. // - Codex maps only the first system message to its instructions field. // - OpenAI-compat passes messages through as-is. staticPrompt := cb.BuildSystemPromptWithCache() // Build short dynamic context (time, runtime, session) — changes per request dynamicCtx := cb.buildDynamicContext(channel, chatID, senderID, senderDisplayName) // Compose a single system message: static (cached) + dynamic + optional summary. // Keeping all system content in one message ensures every provider adapter can // extract it correctly (Anthropic adapter -> top-level system param, // Codex -> instructions field). // // SystemParts carries the same content as structured blocks so that // cache-aware adapters (Anthropic) can set per-block cache_control. // The static block is marked "ephemeral" — its prefix hash is stable // across requests, enabling LLM-side KV cache reuse. stringParts := []string{staticPrompt, dynamicCtx} contentBlocks := []providers.ContentBlock{ {Type: "text", Text: staticPrompt, CacheControl: &providers.CacheControl{Type: "ephemeral"}}, {Type: "text", Text: dynamicCtx}, } if summary != "" { summaryText := fmt.Sprintf( "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+ "for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s", summary) stringParts = append(stringParts, summaryText) contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: summaryText}) } fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n") // Log system prompt summary for debugging (debug mode only). // Read cachedSystemPrompt under lock to avoid a data race with // concurrent InvalidateCache / BuildSystemPromptWithCache writes. cb.systemPromptMutex.RLock() isCached := cb.cachedSystemPrompt != "" cb.systemPromptMutex.RUnlock() logger.DebugCF("agent", "System prompt built", map[string]any{ "static_chars": len(staticPrompt), "dynamic_chars": len(dynamicCtx), "total_chars": len(fullSystemPrompt), "has_summary": summary != "", "cached": isCached, }) // Log preview of system prompt (avoid logging huge content) preview := utils.Truncate(fullSystemPrompt, 500) logger.DebugCF("agent", "System prompt preview", map[string]any{ "preview": preview, }) history = sanitizeHistoryForProvider(history) // Single system message containing all context — compatible with all providers. // SystemParts enables cache-aware adapters to set per-block cache_control; // Content is the concatenated fallback for adapters that don't read SystemParts. messages = append(messages, providers.Message{ Role: "system", Content: fullSystemPrompt, SystemParts: contentBlocks, }) // Add conversation history messages = append(messages, history...) // Add current user message if strings.TrimSpace(currentMessage) != "" { msg := providers.Message{ Role: "user", Content: currentMessage, } if len(media) > 0 { msg.Media = media } messages = append(messages, msg) } return messages } func sanitizeHistoryForProvider(history []providers.Message) []providers.Message { if len(history) == 0 { return history } sanitized := make([]providers.Message, 0, len(history)) for _, msg := range history { switch msg.Role { case "system": // Drop system messages from history. BuildMessages always // constructs its own single system message (static + dynamic + // summary); extra system messages would break providers that // only accept one (Anthropic, Codex). logger.DebugCF("agent", "Dropping system message from history", map[string]any{}) continue case "tool": if len(sanitized) == 0 { logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]any{}) continue } // Walk backwards to find the nearest assistant message, // skipping over any preceding tool messages (multi-tool-call case). foundAssistant := false for i := len(sanitized) - 1; i >= 0; i-- { if sanitized[i].Role == "tool" { continue } if sanitized[i].Role == "assistant" && len(sanitized[i].ToolCalls) > 0 { foundAssistant = true } break } if !foundAssistant { logger.DebugCF("agent", "Dropping orphaned tool message", map[string]any{}) continue } sanitized = append(sanitized, msg) case "assistant": if len(msg.ToolCalls) > 0 { if len(sanitized) == 0 { logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]any{}) continue } prev := sanitized[len(sanitized)-1] if prev.Role != "user" && prev.Role != "tool" { logger.DebugCF( "agent", "Dropping assistant tool-call turn with invalid predecessor", map[string]any{"prev_role": prev.Role}, ) continue } } sanitized = append(sanitized, msg) default: sanitized = append(sanitized, msg) } } // Second pass: ensure every assistant message with tool_calls has matching // tool result messages following it. This is required by strict providers // like DeepSeek that enforce: "An assistant message with 'tool_calls' must // be followed by tool messages responding to each 'tool_call_id'." final := make([]providers.Message, 0, len(sanitized)) for i := 0; i < len(sanitized); i++ { msg := sanitized[i] if msg.Role == "assistant" && len(msg.ToolCalls) > 0 { // Collect expected tool_call IDs expected := make(map[string]bool, len(msg.ToolCalls)) for _, tc := range msg.ToolCalls { expected[tc.ID] = false } // Check following messages for matching tool results toolMsgCount := 0 for j := i + 1; j < len(sanitized); j++ { if sanitized[j].Role != "tool" { break } toolMsgCount++ if _, exists := expected[sanitized[j].ToolCallID]; exists { expected[sanitized[j].ToolCallID] = true } } // If any tool_call_id is missing, drop this assistant message and its partial tool messages allFound := true for toolCallID, found := range expected { if !found { allFound = false logger.DebugCF( "agent", "Dropping assistant message with incomplete tool results", map[string]any{ "missing_tool_call_id": toolCallID, "expected_count": len(expected), "found_count": toolMsgCount, }, ) break } } if !allFound { // Skip this assistant message and its tool messages i += toolMsgCount continue } } final = append(final, msg) } return final } func (cb *ContextBuilder) AddToolResult( messages []providers.Message, toolCallID, toolName, result string, ) []providers.Message { messages = append(messages, providers.Message{ Role: "tool", Content: result, ToolCallID: toolCallID, }) return messages } func (cb *ContextBuilder) AddAssistantMessage( messages []providers.Message, content string, toolCalls []map[string]any, ) []providers.Message { msg := providers.Message{ Role: "assistant", Content: content, } // Always add assistant message, whether or not it has tool calls messages = append(messages, msg) return messages } // GetSkillsInfo returns information about loaded skills. func (cb *ContextBuilder) GetSkillsInfo() map[string]any { allSkills := cb.skillsLoader.ListSkills() skillNames := make([]string, 0, len(allSkills)) for _, s := range allSkills { skillNames = append(skillNames, s.Name) } return map[string]any{ "total": len(allSkills), "available": len(allSkills), "names": skillNames, } } ================================================ FILE: pkg/agent/context_cache_test.go ================================================ package agent import ( "os" "path/filepath" "strings" "sync" "testing" "time" "github.com/sipeed/picoclaw/pkg/providers" ) // setupWorkspace creates a temporary workspace with standard directories and optional files. // Returns the tmpDir path; caller should defer os.RemoveAll(tmpDir). func setupWorkspace(t *testing.T, files map[string]string) string { t.Helper() tmpDir, err := os.MkdirTemp("", "picoclaw-test-*") if err != nil { t.Fatal(err) } os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755) os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755) for name, content := range files { dir := filepath.Dir(filepath.Join(tmpDir, name)) os.MkdirAll(dir, 0o755) if err := os.WriteFile(filepath.Join(tmpDir, name), []byte(content), 0o644); err != nil { t.Fatal(err) } } return tmpDir } // TestSingleSystemMessage verifies that BuildMessages always produces exactly one // system message regardless of summary/history variations. // Fix: multiple system messages break Anthropic (top-level system param) and // Codex (only reads last system message as instructions). func TestSingleSystemMessage(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{ "IDENTITY.md": "# Identity\nTest agent.", }) defer os.RemoveAll(tmpDir) cb := NewContextBuilder(tmpDir) tests := []struct { name string history []providers.Message summary string message string }{ { name: "no summary, no history", summary: "", message: "hello", }, { name: "with summary", summary: "Previous conversation discussed X", message: "hello", }, { name: "with history and summary", history: []providers.Message{ {Role: "user", Content: "hi"}, {Role: "assistant", Content: "hello"}, }, summary: strings.Repeat("Long summary text. ", 50), message: "new message", }, { name: "system message in history is filtered", history: []providers.Message{ {Role: "system", Content: "stale system prompt from previous session"}, {Role: "user", Content: "hi"}, {Role: "assistant", Content: "hello"}, }, summary: "", message: "new message", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1", "", "") systemCount := 0 for _, m := range msgs { if m.Role == "system" { systemCount++ } } if systemCount != 1 { t.Errorf("expected exactly 1 system message, got %d", systemCount) } if msgs[0].Role != "system" { t.Errorf("first message should be system, got %s", msgs[0].Role) } if msgs[len(msgs)-1].Role != "user" { t.Errorf("last message should be user, got %s", msgs[len(msgs)-1].Role) } // System message must contain identity (static) and time (dynamic) sys := msgs[0].Content if !strings.Contains(sys, "picoclaw") { t.Error("system message missing identity") } if !strings.Contains(sys, "Current Time") { t.Error("system message missing dynamic time context") } // Summary handling if tt.summary != "" { if !strings.Contains(sys, "CONTEXT_SUMMARY:") { t.Error("summary present but CONTEXT_SUMMARY prefix missing") } if !strings.Contains(sys, tt.summary[:20]) { t.Error("summary content not found in system message") } } else { if strings.Contains(sys, "CONTEXT_SUMMARY:") { t.Error("CONTEXT_SUMMARY should not appear without summary") } } }) } } func TestBuildMessages_CurrentSenderDynamicContext(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{ "IDENTITY.md": "# Identity\nTest agent.", }) defer os.RemoveAll(tmpDir) cb := NewContextBuilder(tmpDir) tests := []struct { name string senderID string senderDisplayName string wantLine string wantSection bool }{ { name: "both id and display name", senderID: "feishu:ou_xxx", senderDisplayName: "Zhang San", wantLine: "Current sender: Zhang San (ID: feishu:ou_xxx)", wantSection: true, }, { name: "display name only", senderDisplayName: "Alice", wantLine: "Current sender: Alice", wantSection: true, }, { name: "id only", senderID: "discord:123", wantLine: "Current sender: discord:123", wantSection: true, }, { name: "no sender info", wantSection: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { msgs := cb.BuildMessages(nil, "", "hello", nil, "discord", "chat1", tt.senderID, tt.senderDisplayName) sys := msgs[0].Content if tt.wantSection { if !strings.Contains(sys, "## Current Sender") { t.Fatalf("system prompt missing Current Sender section:\n%s", sys) } if !strings.Contains(sys, tt.wantLine) { t.Fatalf("system prompt missing sender line %q:\n%s", tt.wantLine, sys) } return } if strings.Contains(sys, "## Current Sender") { t.Fatalf("system prompt should omit Current Sender section:\n%s", sys) } }) } } // TestMtimeAutoInvalidation verifies that the cache detects source file changes // via mtime without requiring explicit InvalidateCache(). // Fix: original implementation had no auto-invalidation — edits to bootstrap files, // memory, or skills were invisible until process restart. func TestMtimeAutoInvalidation(t *testing.T) { tests := []struct { name string file string // relative path inside workspace contentV1 string contentV2 string checkField string // substring to verify in rebuilt prompt }{ { name: "bootstrap file change", file: "IDENTITY.md", contentV1: "# Original Identity", contentV2: "# Updated Identity", checkField: "Updated Identity", }, { name: "memory file change", file: "memory/MEMORY.md", contentV1: "# Memory\nUser likes Go.", contentV2: "# Memory\nUser likes Rust.", checkField: "User likes Rust", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{tt.file: tt.contentV1}) defer os.RemoveAll(tmpDir) cb := NewContextBuilder(tmpDir) sp1 := cb.BuildSystemPromptWithCache() // Overwrite file and set future mtime to ensure detection. // Use 2s offset for filesystem mtime resolution safety (some FS // have 1s or coarser granularity, especially in CI containers). fullPath := filepath.Join(tmpDir, tt.file) os.WriteFile(fullPath, []byte(tt.contentV2), 0o644) future := time.Now().Add(2 * time.Second) os.Chtimes(fullPath, future, future) // Verify sourceFilesChangedLocked detects the mtime change cb.systemPromptMutex.RLock() changed := cb.sourceFilesChangedLocked() cb.systemPromptMutex.RUnlock() if !changed { t.Fatalf("sourceFilesChangedLocked() should detect %s change", tt.file) } // Should auto-rebuild without explicit InvalidateCache() sp2 := cb.BuildSystemPromptWithCache() if sp1 == sp2 { t.Errorf("cache not rebuilt after %s change", tt.file) } if !strings.Contains(sp2, tt.checkField) { t.Errorf("rebuilt prompt missing expected content %q", tt.checkField) } }) } // Skills directory mtime change t.Run("skills dir change", func(t *testing.T) { tmpDir := setupWorkspace(t, nil) defer os.RemoveAll(tmpDir) cb := NewContextBuilder(tmpDir) _ = cb.BuildSystemPromptWithCache() // populate cache // Touch skills directory (simulate new skill installed) skillsDir := filepath.Join(tmpDir, "skills") future := time.Now().Add(2 * time.Second) os.Chtimes(skillsDir, future, future) // Verify sourceFilesChangedLocked detects it (cache is rebuilt) // We confirm by checking internal state: a second call should rebuild. cb.systemPromptMutex.RLock() changed := cb.sourceFilesChangedLocked() cb.systemPromptMutex.RUnlock() if !changed { t.Error("sourceFilesChangedLocked() should detect skills dir mtime change") } }) } // TestExplicitInvalidateCache verifies that InvalidateCache() forces a rebuild // even when source files haven't changed (useful for tests and reload commands). func TestExplicitInvalidateCache(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{ "IDENTITY.md": "# Test Identity", }) defer os.RemoveAll(tmpDir) cb := NewContextBuilder(tmpDir) sp1 := cb.BuildSystemPromptWithCache() cb.InvalidateCache() sp2 := cb.BuildSystemPromptWithCache() if sp1 != sp2 { t.Error("prompt should be identical after invalidate+rebuild when files unchanged") } // Verify cachedAt was reset cb.InvalidateCache() cb.systemPromptMutex.RLock() if !cb.cachedAt.IsZero() { t.Error("cachedAt should be zero after InvalidateCache()") } cb.systemPromptMutex.RUnlock() } // TestCacheStability verifies that the static prompt is stable across repeated calls // when no files change (regression test for issue #607). func TestCacheStability(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{ "IDENTITY.md": "# Identity\nContent", "SOUL.md": "# Soul\nContent", }) defer os.RemoveAll(tmpDir) cb := NewContextBuilder(tmpDir) results := make([]string, 5) for i := range results { results[i] = cb.BuildSystemPromptWithCache() } for i := 1; i < len(results); i++ { if results[i] != results[0] { t.Errorf("cached prompt changed between call 0 and %d", i) } } // Static prompt must NOT contain per-request data if strings.Contains(results[0], "Current Time") { t.Error("static cached prompt should not contain time (added dynamically)") } } // TestNewFileCreationInvalidatesCache verifies that creating a source file that // did not exist when the cache was built triggers a cache rebuild. // This catches the "from nothing to something" edge case that the old // modifiedSince (return false on stat error) would miss. func TestNewFileCreationInvalidatesCache(t *testing.T) { tests := []struct { name string file string // relative path inside workspace content string checkField string // substring to verify in rebuilt prompt }{ { name: "new bootstrap file", file: "SOUL.md", content: "# Soul\nBe kind and helpful.", checkField: "Be kind and helpful", }, { name: "new memory file", file: "memory/MEMORY.md", content: "# Memory\nUser prefers dark mode.", checkField: "User prefers dark mode", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Start with an empty workspace (no bootstrap/memory files) tmpDir := setupWorkspace(t, nil) defer os.RemoveAll(tmpDir) cb := NewContextBuilder(tmpDir) // Populate cache — file does not exist yet sp1 := cb.BuildSystemPromptWithCache() if strings.Contains(sp1, tt.checkField) { t.Fatalf("prompt should not contain %q before file is created", tt.checkField) } // Create the file after cache was built fullPath := filepath.Join(tmpDir, tt.file) os.MkdirAll(filepath.Dir(fullPath), 0o755) if err := os.WriteFile(fullPath, []byte(tt.content), 0o644); err != nil { t.Fatal(err) } // Set future mtime to guarantee detection future := time.Now().Add(2 * time.Second) os.Chtimes(fullPath, future, future) // Cache should auto-invalidate because file went from absent -> present sp2 := cb.BuildSystemPromptWithCache() if !strings.Contains(sp2, tt.checkField) { t.Errorf("cache not invalidated on new file creation: expected %q in prompt", tt.checkField) } }) } } // TestSkillFileContentChange verifies that modifying a skill file's content // (not just the directory structure) invalidates the cache. // This is the scenario where directory mtime alone is insufficient — on most // filesystems, editing a file inside a directory does NOT update the parent // directory's mtime. func TestSkillFileContentChange(t *testing.T) { skillMD := `--- name: test-skill description: "A test skill" --- # Test Skill v1 Original content.` tmpDir := setupWorkspace(t, map[string]string{ "skills/test-skill/SKILL.md": skillMD, }) defer os.RemoveAll(tmpDir) cb := NewContextBuilder(tmpDir) // Populate cache sp1 := cb.BuildSystemPromptWithCache() _ = sp1 // cache is warm // Modify the skill file content (without touching the skills/ directory) updatedSkillMD := `--- name: test-skill description: "An updated test skill" --- # Test Skill v2 Updated content.` skillPath := filepath.Join(tmpDir, "skills", "test-skill", "SKILL.md") if err := os.WriteFile(skillPath, []byte(updatedSkillMD), 0o644); err != nil { t.Fatal(err) } // Set future mtime on the skill file only (NOT the directory) future := time.Now().Add(2 * time.Second) os.Chtimes(skillPath, future, future) // Verify that sourceFilesChangedLocked detects the content change cb.systemPromptMutex.RLock() changed := cb.sourceFilesChangedLocked() cb.systemPromptMutex.RUnlock() if !changed { t.Error("sourceFilesChangedLocked() should detect skill file content change") } // Verify cache is actually rebuilt with new content sp2 := cb.BuildSystemPromptWithCache() if sp1 == sp2 && strings.Contains(sp1, "test-skill") { // If the skill appeared in the prompt and the prompt didn't change, // the cache was not invalidated. t.Error("cache should be invalidated when skill file content changes") } } // TestGlobalSkillFileContentChange verifies that modifying a global skill // (~/.picoclaw/skills) invalidates the cached system prompt. func TestGlobalSkillFileContentChange(t *testing.T) { tmpHome := t.TempDir() t.Setenv("HOME", tmpHome) tmpDir := setupWorkspace(t, nil) defer os.RemoveAll(tmpDir) globalSkillPath := filepath.Join(tmpHome, ".picoclaw", "skills", "global-skill", "SKILL.md") if err := os.MkdirAll(filepath.Dir(globalSkillPath), 0o755); err != nil { t.Fatal(err) } v1 := `--- name: global-skill description: global-v1 --- # Global Skill v1` if err := os.WriteFile(globalSkillPath, []byte(v1), 0o644); err != nil { t.Fatal(err) } cb := NewContextBuilder(tmpDir) sp1 := cb.BuildSystemPromptWithCache() if !strings.Contains(sp1, "global-v1") { t.Fatal("expected initial prompt to contain global skill description") } v2 := `--- name: global-skill description: global-v2 --- # Global Skill v2` if err := os.WriteFile(globalSkillPath, []byte(v2), 0o644); err != nil { t.Fatal(err) } future := time.Now().Add(2 * time.Second) if err := os.Chtimes(globalSkillPath, future, future); err != nil { t.Fatalf("failed to update mtime for %s: %v", globalSkillPath, err) } cb.systemPromptMutex.RLock() changed := cb.sourceFilesChangedLocked() cb.systemPromptMutex.RUnlock() if !changed { t.Fatal("sourceFilesChangedLocked() should detect global skill file content change") } sp2 := cb.BuildSystemPromptWithCache() if !strings.Contains(sp2, "global-v2") { t.Error("rebuilt prompt should contain updated global skill description") } if sp1 == sp2 { t.Error("cache should be invalidated when global skill file content changes") } } // TestBuiltinSkillFileContentChange verifies that modifying a builtin skill // invalidates the cached system prompt. func TestBuiltinSkillFileContentChange(t *testing.T) { tmpHome := t.TempDir() t.Setenv("HOME", tmpHome) tmpDir := setupWorkspace(t, nil) defer os.RemoveAll(tmpDir) builtinRoot := t.TempDir() t.Setenv("PICOCLAW_BUILTIN_SKILLS", builtinRoot) builtinSkillPath := filepath.Join(builtinRoot, "builtin-skill", "SKILL.md") if err := os.MkdirAll(filepath.Dir(builtinSkillPath), 0o755); err != nil { t.Fatal(err) } v1 := `--- name: builtin-skill description: builtin-v1 --- # Builtin Skill v1` if err := os.WriteFile(builtinSkillPath, []byte(v1), 0o644); err != nil { t.Fatal(err) } cb := NewContextBuilder(tmpDir) sp1 := cb.BuildSystemPromptWithCache() if !strings.Contains(sp1, "builtin-v1") { t.Fatal("expected initial prompt to contain builtin skill description") } v2 := `--- name: builtin-skill description: builtin-v2 --- # Builtin Skill v2` if err := os.WriteFile(builtinSkillPath, []byte(v2), 0o644); err != nil { t.Fatal(err) } future := time.Now().Add(2 * time.Second) if err := os.Chtimes(builtinSkillPath, future, future); err != nil { t.Fatalf("failed to update mtime for %s: %v", builtinSkillPath, err) } cb.systemPromptMutex.RLock() changed := cb.sourceFilesChangedLocked() cb.systemPromptMutex.RUnlock() if !changed { t.Fatal("sourceFilesChangedLocked() should detect builtin skill file content change") } sp2 := cb.BuildSystemPromptWithCache() if !strings.Contains(sp2, "builtin-v2") { t.Error("rebuilt prompt should contain updated builtin skill description") } if sp1 == sp2 { t.Error("cache should be invalidated when builtin skill file content changes") } } // TestSkillFileDeletionInvalidatesCache verifies that deleting a nested skill // file invalidates the cached system prompt. func TestSkillFileDeletionInvalidatesCache(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{ "skills/delete-me/SKILL.md": `--- name: delete-me description: delete-me-v1 --- # Delete Me`, }) defer os.RemoveAll(tmpDir) cb := NewContextBuilder(tmpDir) sp1 := cb.BuildSystemPromptWithCache() if !strings.Contains(sp1, "delete-me-v1") { t.Fatal("expected initial prompt to contain skill description") } skillPath := filepath.Join(tmpDir, "skills", "delete-me", "SKILL.md") if err := os.Remove(skillPath); err != nil { t.Fatal(err) } cb.systemPromptMutex.RLock() changed := cb.sourceFilesChangedLocked() cb.systemPromptMutex.RUnlock() if !changed { t.Fatal("sourceFilesChangedLocked() should detect deleted skill file") } sp2 := cb.BuildSystemPromptWithCache() if strings.Contains(sp2, "delete-me-v1") { t.Error("rebuilt prompt should not contain deleted skill description") } if sp1 == sp2 { t.Error("cache should be invalidated when skill file is deleted") } } // TestConcurrentBuildSystemPromptWithCache verifies that multiple goroutines // can safely call BuildSystemPromptWithCache concurrently without producing // empty results, panics, or data races. // Run with: go test -race ./pkg/agent/ -run TestConcurrentBuildSystemPromptWithCache func TestConcurrentBuildSystemPromptWithCache(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{ "IDENTITY.md": "# Identity\nConcurrency test agent.", "SOUL.md": "# Soul\nBe helpful.", "memory/MEMORY.md": "# Memory\nUser prefers Go.", "skills/demo/SKILL.md": "---\nname: demo\ndescription: \"demo skill\"\n---\n# Demo", }) defer os.RemoveAll(tmpDir) cb := NewContextBuilder(tmpDir) const goroutines = 20 const iterations = 50 var wg sync.WaitGroup errs := make(chan string, goroutines*iterations) for g := range goroutines { wg.Add(1) go func(id int) { defer wg.Done() for i := range iterations { result := cb.BuildSystemPromptWithCache() if result == "" { errs <- "empty prompt returned" return } if !strings.Contains(result, "picoclaw") { errs <- "prompt missing identity" return } // Also exercise BuildMessages concurrently msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat", "", "") if len(msgs) < 2 { errs <- "BuildMessages returned fewer than 2 messages" return } if msgs[0].Role != "system" { errs <- "first message not system" return } // Occasionally invalidate to exercise the write path if i%10 == 0 { cb.InvalidateCache() } } }(g) } wg.Wait() close(errs) for errMsg := range errs { t.Errorf("concurrent access error: %s", errMsg) } } // BenchmarkBuildMessagesWithCache measures caching performance. // TestEmptyWorkspaceBaselineDetectsNewFiles verifies that when the cache is // built on an empty workspace (no tracked files exist), creating a file // afterwards still triggers cache invalidation. This validates the // time.Unix(1, 0) fallback for maxMtime: any real file's mtime is after epoch, // so fileChangedSince correctly detects the absent -> present transition AND // the mtime comparison succeeds even without artificially inflated Chtimes. func TestEmptyWorkspaceBaselineDetectsNewFiles(t *testing.T) { // Empty workspace: no bootstrap files, no memory, no skills content. tmpDir := setupWorkspace(t, nil) defer os.RemoveAll(tmpDir) cb := NewContextBuilder(tmpDir) // Build cache — all tracked files are absent, maxMtime falls back to epoch. sp1 := cb.BuildSystemPromptWithCache() // Create a bootstrap file with natural mtime (no Chtimes manipulation). // The file's mtime should be the current wall-clock time, which is // strictly after time.Unix(1, 0). soulPath := filepath.Join(tmpDir, "SOUL.md") if err := os.WriteFile(soulPath, []byte("# Soul\nNewly created."), 0o644); err != nil { t.Fatal(err) } // Cache should detect the new file via existedAtCache (absent -> present). cb.systemPromptMutex.RLock() changed := cb.sourceFilesChangedLocked() cb.systemPromptMutex.RUnlock() if !changed { t.Fatal("sourceFilesChangedLocked should detect newly created file on empty workspace") } sp2 := cb.BuildSystemPromptWithCache() if !strings.Contains(sp2, "Newly created") { t.Error("rebuilt prompt should contain new file content") } if sp1 == sp2 { t.Error("cache should have been invalidated after file creation") } } // BenchmarkBuildMessagesWithCache measures caching performance. func BenchmarkBuildMessagesWithCache(b *testing.B) { tmpDir, _ := os.MkdirTemp("", "picoclaw-bench-*") defer os.RemoveAll(tmpDir) os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755) os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755) for _, name := range []string{"IDENTITY.md", "SOUL.md", "USER.md"} { os.WriteFile(filepath.Join(tmpDir, name), []byte(strings.Repeat("Content.\n", 10)), 0o644) } cb := NewContextBuilder(tmpDir) history := []providers.Message{ {Role: "user", Content: "previous message"}, {Role: "assistant", Content: "previous response"}, } b.ResetTimer() for i := 0; i < b.N; i++ { _ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test", "", "") } } ================================================ FILE: pkg/agent/context_test.go ================================================ package agent import ( "testing" "github.com/sipeed/picoclaw/pkg/providers" ) func msg(role, content string) providers.Message { return providers.Message{Role: role, Content: content} } func assistantWithTools(toolIDs ...string) providers.Message { calls := make([]providers.ToolCall, len(toolIDs)) for i, id := range toolIDs { calls[i] = providers.ToolCall{ID: id, Type: "function"} } return providers.Message{Role: "assistant", ToolCalls: calls} } func toolResult(id string) providers.Message { return providers.Message{Role: "tool", Content: "result", ToolCallID: id} } func TestSanitizeHistoryForProvider_EmptyHistory(t *testing.T) { result := sanitizeHistoryForProvider(nil) if len(result) != 0 { t.Fatalf("expected empty, got %d messages", len(result)) } result = sanitizeHistoryForProvider([]providers.Message{}) if len(result) != 0 { t.Fatalf("expected empty, got %d messages", len(result)) } } func TestSanitizeHistoryForProvider_SingleToolCall(t *testing.T) { history := []providers.Message{ msg("user", "hello"), assistantWithTools("A"), toolResult("A"), msg("assistant", "done"), } result := sanitizeHistoryForProvider(history) if len(result) != 4 { t.Fatalf("expected 4 messages, got %d", len(result)) } assertRoles(t, result, "user", "assistant", "tool", "assistant") } func TestSanitizeHistoryForProvider_MultiToolCalls(t *testing.T) { history := []providers.Message{ msg("user", "do two things"), assistantWithTools("A", "B"), toolResult("A"), toolResult("B"), msg("assistant", "both done"), } result := sanitizeHistoryForProvider(history) if len(result) != 5 { t.Fatalf("expected 5 messages, got %d: %+v", len(result), roles(result)) } assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant") } func TestSanitizeHistoryForProvider_AssistantToolCallAfterPlainAssistant(t *testing.T) { history := []providers.Message{ msg("user", "hi"), msg("assistant", "thinking"), assistantWithTools("A"), toolResult("A"), } result := sanitizeHistoryForProvider(history) if len(result) != 2 { t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result)) } assertRoles(t, result, "user", "assistant") } func TestSanitizeHistoryForProvider_OrphanedLeadingTool(t *testing.T) { history := []providers.Message{ toolResult("A"), msg("user", "hello"), } result := sanitizeHistoryForProvider(history) if len(result) != 1 { t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result)) } assertRoles(t, result, "user") } func TestSanitizeHistoryForProvider_ToolAfterUserDropped(t *testing.T) { history := []providers.Message{ msg("user", "hello"), toolResult("A"), } result := sanitizeHistoryForProvider(history) if len(result) != 1 { t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result)) } assertRoles(t, result, "user") } func TestSanitizeHistoryForProvider_ToolAfterAssistantNoToolCalls(t *testing.T) { history := []providers.Message{ msg("user", "hello"), msg("assistant", "hi"), toolResult("A"), } result := sanitizeHistoryForProvider(history) if len(result) != 2 { t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result)) } assertRoles(t, result, "user", "assistant") } func TestSanitizeHistoryForProvider_AssistantToolCallAtStart(t *testing.T) { history := []providers.Message{ assistantWithTools("A"), toolResult("A"), msg("user", "hello"), } result := sanitizeHistoryForProvider(history) if len(result) != 1 { t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result)) } assertRoles(t, result, "user") } func TestSanitizeHistoryForProvider_MultiToolCallsThenNewRound(t *testing.T) { history := []providers.Message{ msg("user", "do two things"), assistantWithTools("A", "B"), toolResult("A"), toolResult("B"), msg("assistant", "done"), msg("user", "hi"), assistantWithTools("C"), toolResult("C"), msg("assistant", "done again"), } result := sanitizeHistoryForProvider(history) if len(result) != 9 { t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result)) } assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "user", "assistant", "tool", "assistant") } func TestSanitizeHistoryForProvider_ConsecutiveMultiToolRounds(t *testing.T) { history := []providers.Message{ msg("user", "start"), assistantWithTools("A", "B"), toolResult("A"), toolResult("B"), assistantWithTools("C", "D"), toolResult("C"), toolResult("D"), msg("assistant", "all done"), } result := sanitizeHistoryForProvider(history) if len(result) != 8 { t.Fatalf("expected 8 messages, got %d: %+v", len(result), roles(result)) } assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "tool", "tool", "assistant") } func TestSanitizeHistoryForProvider_PlainConversation(t *testing.T) { history := []providers.Message{ msg("user", "hello"), msg("assistant", "hi"), msg("user", "how are you"), msg("assistant", "fine"), } result := sanitizeHistoryForProvider(history) if len(result) != 4 { t.Fatalf("expected 4 messages, got %d", len(result)) } assertRoles(t, result, "user", "assistant", "user", "assistant") } func roles(msgs []providers.Message) []string { r := make([]string, len(msgs)) for i, m := range msgs { r[i] = m.Role } return r } func assertRoles(t *testing.T, msgs []providers.Message, expected ...string) { t.Helper() if len(msgs) != len(expected) { t.Fatalf("role count mismatch: got %v, want %v", roles(msgs), expected) } for i, exp := range expected { if msgs[i].Role != exp { t.Errorf("message[%d]: got role %q, want %q", i, msgs[i].Role, exp) } } } // TestSanitizeHistoryForProvider_IncompleteToolResults tests the forward validation // that ensures assistant messages with tool_calls have ALL matching tool results. // This fixes the DeepSeek error: "An assistant message with 'tool_calls' must be // followed by tool messages responding to each 'tool_call_id'." func TestSanitizeHistoryForProvider_IncompleteToolResults(t *testing.T) { // Assistant expects tool results for both A and B, but only A is present history := []providers.Message{ msg("user", "do two things"), assistantWithTools("A", "B"), toolResult("A"), // toolResult("B") is missing - this would cause DeepSeek to fail msg("user", "next question"), msg("assistant", "answer"), } result := sanitizeHistoryForProvider(history) // The assistant message with incomplete tool results should be dropped, // along with its partial tool result. The remaining messages are: // user ("do two things"), user ("next question"), assistant ("answer") if len(result) != 3 { t.Fatalf("expected 3 messages, got %d: %+v", len(result), roles(result)) } assertRoles(t, result, "user", "user", "assistant") } // TestSanitizeHistoryForProvider_MissingAllToolResults tests the case where // an assistant message has tool_calls but no tool results follow at all. func TestSanitizeHistoryForProvider_MissingAllToolResults(t *testing.T) { history := []providers.Message{ msg("user", "do something"), assistantWithTools("A"), // No tool results at all msg("user", "hello"), msg("assistant", "hi"), } result := sanitizeHistoryForProvider(history) // The assistant message with no tool results should be dropped. // Remaining: user ("do something"), user ("hello"), assistant ("hi") if len(result) != 3 { t.Fatalf("expected 3 messages, got %d: %+v", len(result), roles(result)) } assertRoles(t, result, "user", "user", "assistant") } // TestSanitizeHistoryForProvider_PartialToolResultsInMiddle tests that // incomplete tool results in the middle of a conversation are properly handled. func TestSanitizeHistoryForProvider_PartialToolResultsInMiddle(t *testing.T) { history := []providers.Message{ msg("user", "first"), assistantWithTools("A"), toolResult("A"), msg("assistant", "done"), msg("user", "second"), assistantWithTools("B", "C"), toolResult("B"), // toolResult("C") is missing msg("user", "third"), assistantWithTools("D"), toolResult("D"), msg("assistant", "all done"), } result := sanitizeHistoryForProvider(history) // First round is complete (user, assistant+tools, tool, assistant), // second round is incomplete and dropped (assistant+tools, partial tool), // third round is complete (user, assistant+tools, tool, assistant). // Remaining: user, assistant, tool, assistant, user, user, assistant, tool, assistant if len(result) != 9 { t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result)) } assertRoles(t, result, "user", "assistant", "tool", "assistant", "user", "user", "assistant", "tool", "assistant") } ================================================ FILE: pkg/agent/instance.go ================================================ package agent import ( "context" "fmt" "os" "path/filepath" "regexp" "strings" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/memory" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/tools" ) // AgentInstance represents a fully configured agent with its own workspace, // session manager, context builder, and tool registry. type AgentInstance struct { ID string Name string Model string Fallbacks []string Workspace string MaxIterations int MaxTokens int Temperature float64 ThinkingLevel ThinkingLevel ContextWindow int SummarizeMessageThreshold int SummarizeTokenPercent int Provider providers.LLMProvider Sessions session.SessionStore ContextBuilder *ContextBuilder Tools *tools.ToolRegistry Subagents *config.SubagentsConfig SkillsFilter []string Candidates []providers.FallbackCandidate // Router is non-nil when model routing is configured and the light model // was successfully resolved. It scores each incoming message and decides // whether to route to LightCandidates or stay with Candidates. Router *routing.Router // LightCandidates holds the resolved provider candidates for the light model. // Pre-computed at agent creation to avoid repeated model_list lookups at runtime. LightCandidates []providers.FallbackCandidate } // NewAgentInstance creates an agent instance from config. func NewAgentInstance( agentCfg *config.AgentConfig, defaults *config.AgentDefaults, cfg *config.Config, provider providers.LLMProvider, ) *AgentInstance { workspace := resolveAgentWorkspace(agentCfg, defaults) os.MkdirAll(workspace, 0o755) model := resolveAgentModel(agentCfg, defaults) fallbacks := resolveAgentFallbacks(agentCfg, defaults) restrict := defaults.RestrictToWorkspace readRestrict := restrict && !defaults.AllowReadOutsideWorkspace // Compile path whitelist patterns from config. allowReadPaths := buildAllowReadPatterns(cfg) allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths) toolsRegistry := tools.NewToolRegistry() if cfg.Tools.IsToolEnabled("read_file") { maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, maxReadFileSize, allowReadPaths)) } if cfg.Tools.IsToolEnabled("write_file") { toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths)) } if cfg.Tools.IsToolEnabled("list_dir") { toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths)) } if cfg.Tools.IsToolEnabled("exec") { execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg, allowReadPaths) if err != nil { logger.ErrorCF("agent", "Failed to initialize exec tool; continuing without exec", map[string]any{"error": err.Error()}) } else { toolsRegistry.Register(execTool) } } if cfg.Tools.IsToolEnabled("edit_file") { toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths)) } if cfg.Tools.IsToolEnabled("append_file") { toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths)) } sessionsDir := filepath.Join(workspace, "sessions") sessions := initSessionStore(sessionsDir) mcpDiscoveryActive := cfg.Tools.MCP.Enabled && cfg.Tools.MCP.Discovery.Enabled contextBuilder := NewContextBuilder(workspace).WithToolDiscovery( mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25, mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex, ) agentID := routing.DefaultAgentID agentName := "" var subagents *config.SubagentsConfig var skillsFilter []string if agentCfg != nil { agentID = routing.NormalizeAgentID(agentCfg.ID) agentName = agentCfg.Name subagents = agentCfg.Subagents skillsFilter = agentCfg.Skills } maxIter := defaults.MaxToolIterations if maxIter == 0 { maxIter = 20 } maxTokens := defaults.MaxTokens if maxTokens == 0 { maxTokens = 8192 } temperature := 0.7 if defaults.Temperature != nil { temperature = *defaults.Temperature } var thinkingLevelStr string if mc, err := cfg.GetModelConfig(model); err == nil { thinkingLevelStr = mc.ThinkingLevel } thinkingLevel := parseThinkingLevel(thinkingLevelStr) summarizeMessageThreshold := defaults.SummarizeMessageThreshold if summarizeMessageThreshold == 0 { summarizeMessageThreshold = 20 } summarizeTokenPercent := defaults.SummarizeTokenPercent if summarizeTokenPercent == 0 { summarizeTokenPercent = 75 } // Resolve fallback candidates candidates := resolveModelCandidates(cfg, defaults.Provider, model, fallbacks) // Model routing setup: pre-resolve light model candidates at creation time // to avoid repeated model_list lookups on every incoming message. var router *routing.Router var lightCandidates []providers.FallbackCandidate if rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != "" { resolved := resolveModelCandidates(cfg, defaults.Provider, rc.LightModel, nil) if len(resolved) > 0 { router = routing.New(routing.RouterConfig{ LightModel: rc.LightModel, Threshold: rc.Threshold, }) lightCandidates = resolved } else { logger.WarnCF("agent", "Routing light model not found; routing disabled", map[string]any{"light_model": rc.LightModel, "agent_id": agentID}) } } return &AgentInstance{ ID: agentID, Name: agentName, Model: model, Fallbacks: fallbacks, Workspace: workspace, MaxIterations: maxIter, MaxTokens: maxTokens, Temperature: temperature, ThinkingLevel: thinkingLevel, ContextWindow: maxTokens, SummarizeMessageThreshold: summarizeMessageThreshold, SummarizeTokenPercent: summarizeTokenPercent, Provider: provider, Sessions: sessions, ContextBuilder: contextBuilder, Tools: toolsRegistry, Subagents: subagents, SkillsFilter: skillsFilter, Candidates: candidates, Router: router, LightCandidates: lightCandidates, } } // resolveAgentWorkspace determines the workspace directory for an agent. func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string { if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" { return expandHome(strings.TrimSpace(agentCfg.Workspace)) } // Use the configured default workspace (respects PICOCLAW_HOME) if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || routing.NormalizeAgentID(agentCfg.ID) == "main" { return expandHome(defaults.Workspace) } // For named agents without explicit workspace, use default workspace with agent ID suffix id := routing.NormalizeAgentID(agentCfg.ID) return filepath.Join(expandHome(defaults.Workspace), "..", "workspace-"+id) } // resolveAgentModel resolves the primary model for an agent. func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string { if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" { return strings.TrimSpace(agentCfg.Model.Primary) } return defaults.GetModelName() } // resolveAgentFallbacks resolves the fallback models for an agent. func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string { if agentCfg != nil && agentCfg.Model != nil && agentCfg.Model.Fallbacks != nil { return agentCfg.Model.Fallbacks } return defaults.ModelFallbacks } func compilePatterns(patterns []string) []*regexp.Regexp { compiled := make([]*regexp.Regexp, 0, len(patterns)) for _, p := range patterns { re, err := regexp.Compile(p) if err != nil { fmt.Printf("Warning: invalid path pattern %q: %v\n", p, err) continue } compiled = append(compiled, re) } return compiled } func buildAllowReadPatterns(cfg *config.Config) []*regexp.Regexp { var configured []string if cfg != nil { configured = cfg.Tools.AllowReadPaths } compiled := compilePatterns(configured) mediaDirPattern := regexp.MustCompile(mediaTempDirPattern()) for _, pattern := range compiled { if pattern.String() == mediaDirPattern.String() { return compiled } } return append(compiled, mediaDirPattern) } func mediaTempDirPattern() string { sep := regexp.QuoteMeta(string(os.PathSeparator)) return "^" + regexp.QuoteMeta(filepath.Clean(media.TempDir())) + "(?:" + sep + "|$)" } // Close releases resources held by the agent's session store. func (a *AgentInstance) Close() error { if a.Sessions != nil { return a.Sessions.Close() } return nil } // initSessionStore creates the session persistence backend. // It uses the JSONL store by default and auto-migrates legacy JSON sessions. // Falls back to SessionManager if the JSONL store cannot be initialized or // if migration fails (which indicates the store cannot write reliably). func initSessionStore(dir string) session.SessionStore { store, err := memory.NewJSONLStore(dir) if err != nil { logger.WarnCF("agent", "Memory JSONL store init failed; falling back to json sessions", map[string]any{"error": err.Error()}) return session.NewSessionManager(dir) } if n, merr := memory.MigrateFromJSON(context.Background(), dir, store); merr != nil { // Migration failure means the store could not write data. // Fall back to SessionManager to avoid a split state where // some sessions are in JSONL and others remain in JSON. logger.WarnCF("agent", "Memory migration failed; falling back to json sessions", map[string]any{"error": merr.Error()}) store.Close() return session.NewSessionManager(dir) } else if n > 0 { logger.InfoCF("agent", "Memory migrated to JSONL", map[string]any{"sessions_migrated": n}) } return session.NewJSONLBackend(store) } func expandHome(path string) string { if path == "" { return path } if path[0] == '~' { home, _ := os.UserHomeDir() if len(path) > 1 && path[1] == '/' { return home + path[1:] } return home } return path } ================================================ FILE: pkg/agent/instance_test.go ================================================ package agent import ( "context" "os" "path/filepath" "strings" "testing" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/media" ) func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-instance-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, Model: "test-model", MaxTokens: 1234, MaxToolIterations: 5, }, }, } configuredTemp := 1.0 cfg.Agents.Defaults.Temperature = &configuredTemp provider := &mockProvider{} agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) if agent.MaxTokens != 1234 { t.Fatalf("MaxTokens = %d, want %d", agent.MaxTokens, 1234) } if agent.Temperature != 1.0 { t.Fatalf("Temperature = %f, want %f", agent.Temperature, 1.0) } } func TestNewAgentInstance_DefaultsTemperatureWhenZero(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-instance-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, Model: "test-model", MaxTokens: 1234, MaxToolIterations: 5, }, }, } configuredTemp := 0.0 cfg.Agents.Defaults.Temperature = &configuredTemp provider := &mockProvider{} agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) if agent.Temperature != 0.0 { t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.0) } } func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-instance-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, Model: "test-model", MaxTokens: 1234, MaxToolIterations: 5, }, }, } provider := &mockProvider{} agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) if agent.Temperature != 0.7 { t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7) } } func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) { tests := []struct { name string aliasName string modelName string apiBase string wantProvider string wantModel string }{ { name: "alias with provider prefix", aliasName: "step-3.5-flash", modelName: "openrouter/stepfun/step-3.5-flash:free", apiBase: "https://openrouter.ai/api/v1", wantProvider: "openrouter", wantModel: "stepfun/step-3.5-flash:free", }, { name: "alias without provider prefix", aliasName: "glm-5", modelName: "glm-5", apiBase: "https://api.z.ai/api/coding/paas/v4", wantProvider: "openai", wantModel: "glm-5", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-instance-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, Model: tt.aliasName, }, }, ModelList: []config.ModelConfig{ { ModelName: tt.aliasName, Model: tt.modelName, APIBase: tt.apiBase, }, }, } provider := &mockProvider{} agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) if len(agent.Candidates) != 1 { t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates)) } if agent.Candidates[0].Provider != tt.wantProvider { t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, tt.wantProvider) } if agent.Candidates[0].Model != tt.wantModel { t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, tt.wantModel) } }) } } func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) { workspace := t.TempDir() mediaDir := media.TempDir() if err := os.MkdirAll(mediaDir, 0o700); err != nil { t.Fatalf("MkdirAll(mediaDir) error = %v", err) } mediaFile, err := os.CreateTemp(mediaDir, "instance-tool-*.txt") if err != nil { t.Fatalf("CreateTemp(mediaDir) error = %v", err) } mediaPath := mediaFile.Name() if _, err := mediaFile.WriteString("attachment content"); err != nil { mediaFile.Close() t.Fatalf("WriteString(mediaFile) error = %v", err) } if err := mediaFile.Close(); err != nil { t.Fatalf("Close(mediaFile) error = %v", err) } t.Cleanup(func() { _ = os.Remove(mediaPath) }) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: workspace, ModelName: "test-model", RestrictToWorkspace: true, }, }, Tools: config.ToolsConfig{ ReadFile: config.ReadFileToolConfig{Enabled: true}, ListDir: config.ToolConfig{Enabled: true}, Exec: config.ExecConfig{ ToolConfig: config.ToolConfig{Enabled: true}, EnableDenyPatterns: true, AllowRemote: true, }, }, } agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{}) readTool, ok := agent.Tools.Get("read_file") if !ok { t.Fatal("read_file tool not registered") } readResult := readTool.Execute(context.Background(), map[string]any{"path": mediaPath}) if readResult.IsError { t.Fatalf("read_file should allow media temp dir, got: %s", readResult.ForLLM) } if !strings.Contains(readResult.ForLLM, "attachment content") { t.Fatalf("read_file output missing media content: %s", readResult.ForLLM) } listTool, ok := agent.Tools.Get("list_dir") if !ok { t.Fatal("list_dir tool not registered") } listResult := listTool.Execute(context.Background(), map[string]any{"path": mediaDir}) if listResult.IsError { t.Fatalf("list_dir should allow media temp dir, got: %s", listResult.ForLLM) } if !strings.Contains(listResult.ForLLM, filepath.Base(mediaPath)) { t.Fatalf("list_dir output missing media file: %s", listResult.ForLLM) } execTool, ok := agent.Tools.Get("exec") if !ok { t.Fatal("exec tool not registered") } execResult := execTool.Execute(context.Background(), map[string]any{ "command": "cat " + filepath.Base(mediaPath), "working_dir": mediaDir, }) if execResult.IsError { t.Fatalf("exec should allow media temp dir, got: %s", execResult.ForLLM) } if !strings.Contains(execResult.ForLLM, "attachment content") { t.Fatalf("exec output missing media content: %s", execResult.ForLLM) } } func TestNewAgentInstance_InvalidExecConfigDoesNotExit(t *testing.T) { workspace := t.TempDir() cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: workspace, ModelName: "test-model", }, }, Tools: config.ToolsConfig{ ReadFile: config.ReadFileToolConfig{Enabled: true}, Exec: config.ExecConfig{ ToolConfig: config.ToolConfig{Enabled: true}, EnableDenyPatterns: true, CustomDenyPatterns: []string{"[invalid-regex"}, }, }, } agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{}) if agent == nil { t.Fatal("expected agent instance, got nil") } if _, ok := agent.Tools.Get("exec"); ok { t.Fatal("exec tool should not be registered when exec config is invalid") } if _, ok := agent.Tools.Get("read_file"); !ok { t.Fatal("read_file tool should still be registered") } } ================================================ FILE: pkg/agent/loop.go ================================================ // PicoClaw - Ultra-lightweight personal AI agent // Inspired by and based on nanobot: https://github.com/HKUDS/nanobot // License: MIT // // Copyright (c) 2026 PicoClaw contributors package agent import ( "context" "encoding/json" "errors" "fmt" "path/filepath" "regexp" "strings" "sync" "sync/atomic" "time" "unicode/utf8" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/commands" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/voice" ) type AgentLoop struct { bus *bus.MessageBus cfg *config.Config registry *AgentRegistry state *state.Manager running atomic.Bool summarizing sync.Map fallback *providers.FallbackChain channelManager *channels.Manager mediaStore media.MediaStore transcriber voice.Transcriber cmdRegistry *commands.Registry mcp mcpRuntime mu sync.RWMutex reloadFunc func() error // Track active requests for safe provider cleanup activeRequests sync.WaitGroup } // processOptions configures how a message is processed type processOptions struct { SessionKey string // Session identifier for history/context Channel string // Target channel for tool execution ChatID string // Target chat ID for tool execution SenderID string // Current sender ID for dynamic context SenderDisplayName string // Current sender display name for dynamic context UserMessage string // User message content (may include prefix) Media []string // media:// refs from inbound message DefaultResponse string // Response when LLM returns empty EnableSummary bool // Whether to trigger summarization SendResponse bool // Whether to send response via bus NoHistory bool // If true, don't load session history (for heartbeat) } const ( defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json." sessionKeyAgentPrefix = "agent:" metadataKeyAccountID = "account_id" metadataKeyGuildID = "guild_id" metadataKeyTeamID = "team_id" metadataKeyParentPeerKind = "parent_peer_kind" metadataKeyParentPeerID = "parent_peer_id" ) func NewAgentLoop( cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider, ) *AgentLoop { registry := NewAgentRegistry(cfg, provider) // Register shared tools to all agents registerSharedTools(cfg, msgBus, registry, provider) // Set up shared fallback chain cooldown := providers.NewCooldownTracker() fallbackChain := providers.NewFallbackChain(cooldown) // Create state manager using default agent's workspace for channel recording defaultAgent := registry.GetDefaultAgent() var stateManager *state.Manager if defaultAgent != nil { stateManager = state.NewManager(defaultAgent.Workspace) } al := &AgentLoop{ bus: msgBus, cfg: cfg, registry: registry, state: stateManager, summarizing: sync.Map{}, fallback: fallbackChain, cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()), } return al } // registerSharedTools registers tools that are shared across all agents (web, message, spawn). func registerSharedTools( cfg *config.Config, msgBus *bus.MessageBus, registry *AgentRegistry, provider providers.LLMProvider, ) { allowReadPaths := buildAllowReadPatterns(cfg) for _, agentID := range registry.ListAgentIDs() { agent, ok := registry.GetAgent(agentID) if !ok { continue } if cfg.Tools.IsToolEnabled("web") { searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{ BraveAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Brave.APIKey, cfg.Tools.Web.Brave.APIKeys), BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, BraveEnabled: cfg.Tools.Web.Brave.Enabled, TavilyAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Tavily.APIKey, cfg.Tools.Web.Tavily.APIKeys), TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, PerplexityAPIKeys: config.MergeAPIKeys( cfg.Tools.Web.Perplexity.APIKey, cfg.Tools.Web.Perplexity.APIKeys, ), PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL, SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults, SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled, GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey, GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL, GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine, GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults, GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled, Proxy: cfg.Tools.Web.Proxy, }) if err != nil { logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()}) } else if searchTool != nil { agent.Tools.Register(searchTool) } } if cfg.Tools.IsToolEnabled("web_fetch") { fetchTool, err := tools.NewWebFetchToolWithProxy( 50000, cfg.Tools.Web.Proxy, cfg.Tools.Web.Format, cfg.Tools.Web.FetchLimitBytes, cfg.Tools.Web.PrivateHostWhitelist) if err != nil { logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) } else { agent.Tools.Register(fetchTool) } } // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms if cfg.Tools.IsToolEnabled("i2c") { agent.Tools.Register(tools.NewI2CTool()) } if cfg.Tools.IsToolEnabled("spi") { agent.Tools.Register(tools.NewSPITool()) } // Message tool if cfg.Tools.IsToolEnabled("message") { messageTool := tools.NewMessageTool() messageTool.SetSendCallback(func(channel, chatID, content string) error { pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) defer pubCancel() return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: channel, ChatID: chatID, Content: content, }) }) agent.Tools.Register(messageTool) } // Send file tool (outbound media via MediaStore — store injected later by SetMediaStore) if cfg.Tools.IsToolEnabled("send_file") { sendFileTool := tools.NewSendFileTool( agent.Workspace, cfg.Agents.Defaults.RestrictToWorkspace, cfg.Agents.Defaults.GetMaxMediaSize(), nil, allowReadPaths, ) agent.Tools.Register(sendFileTool) } // Skill discovery and installation tools skills_enabled := cfg.Tools.IsToolEnabled("skills") find_skills_enable := cfg.Tools.IsToolEnabled("find_skills") install_skills_enable := cfg.Tools.IsToolEnabled("install_skill") if skills_enabled && (find_skills_enable || install_skills_enable) { registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub), }) if find_skills_enable { searchCache := skills.NewSearchCache( cfg.Tools.Skills.SearchCache.MaxSize, time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second, ) agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache)) } if install_skills_enable { agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace)) } } // Spawn and spawn_status tools share a SubagentManager. // Construct it when either tool is enabled (both require subagent). spawnEnabled := cfg.Tools.IsToolEnabled("spawn") spawnStatusEnabled := cfg.Tools.IsToolEnabled("spawn_status") if (spawnEnabled || spawnStatusEnabled) && cfg.Tools.IsToolEnabled("subagent") { subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace) subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) // Clone the parent's tool registry so subagents can use all // tools registered so far (file, web, etc.) but NOT spawn/ // spawn_status which are added below — preventing recursive // subagent spawning. subagentManager.SetTools(agent.Tools.Clone()) if spawnEnabled { spawnTool := tools.NewSpawnTool(subagentManager) currentAgentID := agentID spawnTool.SetAllowlistChecker(func(targetAgentID string) bool { return registry.CanSpawnSubagent(currentAgentID, targetAgentID) }) agent.Tools.Register(spawnTool) } if spawnStatusEnabled { agent.Tools.Register(tools.NewSpawnStatusTool(subagentManager)) } } else if (spawnEnabled || spawnStatusEnabled) && !cfg.Tools.IsToolEnabled("subagent") { logger.WarnCF("agent", "spawn/spawn_status tools require subagent to be enabled", nil) } } } func (al *AgentLoop) Run(ctx context.Context) error { al.running.Store(true) if err := al.ensureMCPInitialized(ctx); err != nil { return err } for al.running.Load() { select { case <-ctx.Done(): return nil case msg, ok := <-al.bus.InboundChan(): if !ok { return nil } // Process message func() { defer func() { if al.channelManager != nil { al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID) } }() // TODO: Re-enable media cleanup after inbound media is properly consumed by the agent. // Currently disabled because files are deleted before the LLM can access their content. // defer func() { // if al.mediaStore != nil && msg.MediaScope != "" { // if releaseErr := al.mediaStore.ReleaseAll(msg.MediaScope); releaseErr != nil { // logger.WarnCF("agent", "Failed to release media", map[string]any{ // "scope": msg.MediaScope, // "error": releaseErr.Error(), // }) // } // } // }() response, err := al.processMessage(ctx, msg) if err != nil { response = fmt.Sprintf("Error processing message: %v", err) } if response != "" { // Check if the message tool already sent a response during this round. // If so, skip publishing to avoid duplicate messages to the user. // Use default agent's tools to check (message tool is shared). alreadySent := false defaultAgent := al.GetRegistry().GetDefaultAgent() if defaultAgent != nil { if tool, ok := defaultAgent.Tools.Get("message"); ok { if mt, ok := tool.(*tools.MessageTool); ok { alreadySent = mt.HasSentInRound() } } } if !alreadySent { al.bus.PublishOutbound(ctx, bus.OutboundMessage{ Channel: msg.Channel, ChatID: msg.ChatID, Content: response, }) logger.InfoCF("agent", "Published outbound response", map[string]any{ "channel": msg.Channel, "chat_id": msg.ChatID, "content_len": len(response), }) } else { logger.DebugCF( "agent", "Skipped outbound (message tool already sent)", map[string]any{"channel": msg.Channel}, ) } } }() default: time.Sleep(time.Microsecond * 200) } } return nil } func (al *AgentLoop) Stop() { al.running.Store(false) } // Close releases resources held by agent session stores. Call after Stop. func (al *AgentLoop) Close() { mcpManager := al.mcp.takeManager() if mcpManager != nil { if err := mcpManager.Close(); err != nil { logger.ErrorCF("agent", "Failed to close MCP manager", map[string]any{ "error": err.Error(), }) } } al.GetRegistry().Close() } func (al *AgentLoop) RegisterTool(tool tools.Tool) { registry := al.GetRegistry() for _, agentID := range registry.ListAgentIDs() { if agent, ok := registry.GetAgent(agentID); ok { agent.Tools.Register(tool) } } } func (al *AgentLoop) SetChannelManager(cm *channels.Manager) { al.channelManager = cm } // ReloadProviderAndConfig atomically swaps the provider and config with proper synchronization. // It uses a context to allow timeout control from the caller. // Returns an error if the reload fails or context is canceled. func (al *AgentLoop) ReloadProviderAndConfig( ctx context.Context, provider providers.LLMProvider, cfg *config.Config, ) error { // Validate inputs if provider == nil { return fmt.Errorf("provider cannot be nil") } if cfg == nil { return fmt.Errorf("config cannot be nil") } // Create new registry with updated config and provider // Wrap in defer/recover to handle any panics gracefully var registry *AgentRegistry var panicErr error done := make(chan struct{}, 1) go func() { defer func() { if r := recover(); r != nil { panicErr = fmt.Errorf("panic during registry creation: %v", r) logger.ErrorCF("agent", "Panic during registry creation", map[string]any{"panic": r}) } close(done) }() registry = NewAgentRegistry(cfg, provider) }() // Wait for completion or context cancellation select { case <-done: if registry == nil { if panicErr != nil { return fmt.Errorf("registry creation failed: %w", panicErr) } return fmt.Errorf("registry creation failed (nil result)") } case <-ctx.Done(): return fmt.Errorf("context canceled during registry creation: %w", ctx.Err()) } // Check context again before proceeding if err := ctx.Err(); err != nil { return fmt.Errorf("context canceled after registry creation: %w", err) } // Ensure shared tools are re-registered on the new registry registerSharedTools(cfg, al.bus, registry, provider) // Atomically swap the config and registry under write lock // This ensures readers see a consistent pair al.mu.Lock() oldRegistry := al.registry // Store new values al.cfg = cfg al.registry = registry // Also update fallback chain with new config al.fallback = providers.NewFallbackChain(providers.NewCooldownTracker()) al.mu.Unlock() // Close old provider after releasing the lock // This prevents blocking readers while closing if oldProvider, ok := extractProvider(oldRegistry); ok { if stateful, ok := oldProvider.(providers.StatefulProvider); ok { // Give in-flight requests a moment to complete // Use a reasonable timeout that balances cleanup vs resource usage select { case <-time.After(100 * time.Millisecond): stateful.Close() case <-ctx.Done(): // Context canceled, close immediately but log warning logger.WarnCF("agent", "Context canceled during provider cleanup, forcing close", map[string]any{"error": ctx.Err()}) stateful.Close() } } } logger.InfoCF("agent", "Provider and config reloaded successfully", map[string]any{ "model": cfg.Agents.Defaults.GetModelName(), }) return nil } // GetRegistry returns the current registry (thread-safe) func (al *AgentLoop) GetRegistry() *AgentRegistry { al.mu.RLock() defer al.mu.RUnlock() return al.registry } // GetConfig returns the current config (thread-safe) func (al *AgentLoop) GetConfig() *config.Config { al.mu.RLock() defer al.mu.RUnlock() return al.cfg } // SetMediaStore injects a MediaStore for media lifecycle management. func (al *AgentLoop) SetMediaStore(s media.MediaStore) { al.mediaStore = s // Propagate store to send_file tools in all agents. registry := al.GetRegistry() registry.ForEachTool("send_file", func(t tools.Tool) { if sf, ok := t.(*tools.SendFileTool); ok { sf.SetMediaStore(s) } }) } // SetTranscriber injects a voice transcriber for agent-level audio transcription. func (al *AgentLoop) SetTranscriber(t voice.Transcriber) { al.transcriber = t } // SetReloadFunc sets the callback function for triggering config reload. func (al *AgentLoop) SetReloadFunc(fn func() error) { al.reloadFunc = fn } var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`) // transcribeAudioInMessage resolves audio media refs, transcribes them, and // replaces audio annotations in msg.Content with the transcribed text. // Returns the (possibly modified) message and true if audio was transcribed. func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) (bus.InboundMessage, bool) { if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 { return msg, false } // Transcribe each audio media ref in order. var transcriptions []string for _, ref := range msg.Media { path, meta, err := al.mediaStore.ResolveWithMeta(ref) if err != nil { logger.WarnCF("voice", "Failed to resolve media ref", map[string]any{"ref": ref, "error": err}) continue } if !utils.IsAudioFile(meta.Filename, meta.ContentType) { continue } result, err := al.transcriber.Transcribe(ctx, path) if err != nil { logger.WarnCF("voice", "Transcription failed", map[string]any{"ref": ref, "error": err}) transcriptions = append(transcriptions, "") continue } transcriptions = append(transcriptions, result.Text) } if len(transcriptions) == 0 { return msg, false } al.sendTranscriptionFeedback(ctx, msg.Channel, msg.ChatID, msg.MessageID, transcriptions) // Replace audio annotations sequentially with transcriptions. idx := 0 newContent := audioAnnotationRe.ReplaceAllStringFunc(msg.Content, func(match string) string { if idx >= len(transcriptions) { return match } text := transcriptions[idx] idx++ return "[voice: " + text + "]" }) // Append any remaining transcriptions not matched by an annotation. for ; idx < len(transcriptions); idx++ { newContent += "\n[voice: " + transcriptions[idx] + "]" } msg.Content = newContent return msg, true } // sendTranscriptionFeedback sends feedback to the user with the result of // audio transcription if the option is enabled. It uses Manager.SendMessage // which executes synchronously (rate limiting, splitting, retry) so that // ordering with the subsequent placeholder is guaranteed. func (al *AgentLoop) sendTranscriptionFeedback( ctx context.Context, channel, chatID, messageID string, validTexts []string, ) { if !al.cfg.Voice.EchoTranscription { return } if al.channelManager == nil { return } var nonEmpty []string for _, t := range validTexts { if t != "" { nonEmpty = append(nonEmpty, t) } } var feedbackMsg string if len(nonEmpty) > 0 { feedbackMsg = "Transcript: " + strings.Join(nonEmpty, "\n") } else { feedbackMsg = "No voice detected in the audio" } err := al.channelManager.SendMessage(ctx, bus.OutboundMessage{ Channel: channel, ChatID: chatID, Content: feedbackMsg, ReplyToMessageID: messageID, }) if err != nil { logger.WarnCF("voice", "Failed to send transcription feedback", map[string]any{"error": err.Error()}) } } // inferMediaType determines the media type ("image", "audio", "video", "file") // from a filename and MIME content type. func inferMediaType(filename, contentType string) string { ct := strings.ToLower(contentType) fn := strings.ToLower(filename) if strings.HasPrefix(ct, "image/") { return "image" } if strings.HasPrefix(ct, "audio/") || ct == "application/ogg" { return "audio" } if strings.HasPrefix(ct, "video/") { return "video" } // Fallback: infer from extension ext := filepath.Ext(fn) switch ext { case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg": return "image" case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus": return "audio" case ".mp4", ".avi", ".mov", ".webm", ".mkv": return "video" } return "file" } // RecordLastChannel records the last active channel for this workspace. // This uses the atomic state save mechanism to prevent data loss on crash. func (al *AgentLoop) RecordLastChannel(channel string) error { if al.state == nil { return nil } return al.state.SetLastChannel(channel) } // RecordLastChatID records the last active chat ID for this workspace. // This uses the atomic state save mechanism to prevent data loss on crash. func (al *AgentLoop) RecordLastChatID(chatID string) error { if al.state == nil { return nil } return al.state.SetLastChatID(chatID) } func (al *AgentLoop) ProcessDirect( ctx context.Context, content, sessionKey string, ) (string, error) { return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct") } func (al *AgentLoop) ProcessDirectWithChannel( ctx context.Context, content, sessionKey, channel, chatID string, ) (string, error) { if err := al.ensureMCPInitialized(ctx); err != nil { return "", err } msg := bus.InboundMessage{ Channel: channel, SenderID: "cron", ChatID: chatID, Content: content, SessionKey: sessionKey, } return al.processMessage(ctx, msg) } // ProcessHeartbeat processes a heartbeat request without session history. // Each heartbeat is independent and doesn't accumulate context. func (al *AgentLoop) ProcessHeartbeat( ctx context.Context, content, channel, chatID string, ) (string, error) { agent := al.GetRegistry().GetDefaultAgent() if agent == nil { return "", fmt.Errorf("no default agent for heartbeat") } return al.runAgentLoop(ctx, agent, processOptions{ SessionKey: "heartbeat", Channel: channel, ChatID: chatID, UserMessage: content, DefaultResponse: defaultResponse, EnableSummary: false, SendResponse: false, NoHistory: true, // Don't load session history for heartbeat }) } func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { // Add message preview to log (show full content for error messages) var logContent string if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") { logContent = msg.Content // Full content for errors } else { logContent = utils.Truncate(msg.Content, 80) } logger.InfoCF( "agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent), map[string]any{ "channel": msg.Channel, "chat_id": msg.ChatID, "sender_id": msg.SenderID, "session_key": msg.SessionKey, }, ) var hadAudio bool msg, hadAudio = al.transcribeAudioInMessage(ctx, msg) // For audio messages the placeholder was deferred by the channel. // Now that transcription (and optional feedback) is done, send it. if hadAudio && al.channelManager != nil { al.channelManager.SendPlaceholder(ctx, msg.Channel, msg.ChatID) } // Route system messages to processSystemMessage if msg.Channel == "system" { return al.processSystemMessage(ctx, msg) } route, agent, routeErr := al.resolveMessageRoute(msg) if routeErr != nil { return "", routeErr } // Reset message-tool state for this round so we don't skip publishing due to a previous round. if tool, ok := agent.Tools.Get("message"); ok { if resetter, ok := tool.(interface{ ResetSentInRound() }); ok { resetter.ResetSentInRound() } } // Resolve session key from route, while preserving explicit agent-scoped keys. scopeKey := resolveScopeKey(route, msg.SessionKey) sessionKey := scopeKey logger.InfoCF("agent", "Routed message", map[string]any{ "agent_id": agent.ID, "scope_key": scopeKey, "session_key": sessionKey, "matched_by": route.MatchedBy, "route_agent": route.AgentID, "route_channel": route.Channel, }) opts := processOptions{ SessionKey: sessionKey, Channel: msg.Channel, ChatID: msg.ChatID, SenderID: msg.SenderID, SenderDisplayName: msg.Sender.DisplayName, UserMessage: msg.Content, Media: msg.Media, DefaultResponse: defaultResponse, EnableSummary: true, SendResponse: false, } // context-dependent commands check their own Runtime fields and report // "unavailable" when the required capability is nil. if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled { return response, nil } return al.runAgentLoop(ctx, agent, opts) } func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) { registry := al.GetRegistry() route := registry.ResolveRoute(routing.RouteInput{ Channel: msg.Channel, AccountID: inboundMetadata(msg, metadataKeyAccountID), Peer: extractPeer(msg), ParentPeer: extractParentPeer(msg), GuildID: inboundMetadata(msg, metadataKeyGuildID), TeamID: inboundMetadata(msg, metadataKeyTeamID), }) agent, ok := registry.GetAgent(route.AgentID) if !ok { agent = registry.GetDefaultAgent() } if agent == nil { return routing.ResolvedRoute{}, nil, fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID) } return route, agent, nil } func resolveScopeKey(route routing.ResolvedRoute, msgSessionKey string) string { if msgSessionKey != "" && strings.HasPrefix(msgSessionKey, sessionKeyAgentPrefix) { return msgSessionKey } return route.SessionKey } func (al *AgentLoop) processSystemMessage( ctx context.Context, msg bus.InboundMessage, ) (string, error) { if msg.Channel != "system" { return "", fmt.Errorf( "processSystemMessage called with non-system message channel: %s", msg.Channel, ) } logger.InfoCF("agent", "Processing system message", map[string]any{ "sender_id": msg.SenderID, "chat_id": msg.ChatID, }) // Parse origin channel from chat_id (format: "channel:chat_id") var originChannel, originChatID string if idx := strings.Index(msg.ChatID, ":"); idx > 0 { originChannel = msg.ChatID[:idx] originChatID = msg.ChatID[idx+1:] } else { originChannel = "cli" originChatID = msg.ChatID } // Extract subagent result from message content // Format: "Task 'label' completed.\n\nResult:\n" content := msg.Content if idx := strings.Index(content, "Result:\n"); idx >= 0 { content = content[idx+8:] // Extract just the result part } // Skip internal channels - only log, don't send to user if constants.IsInternalChannel(originChannel) { logger.InfoCF("agent", "Subagent completed (internal channel)", map[string]any{ "sender_id": msg.SenderID, "content_len": len(content), "channel": originChannel, }) return "", nil } // Use default agent for system messages agent := al.GetRegistry().GetDefaultAgent() if agent == nil { return "", fmt.Errorf("no default agent for system message") } // Use the origin session for context sessionKey := routing.BuildAgentMainSessionKey(agent.ID) return al.runAgentLoop(ctx, agent, processOptions{ SessionKey: sessionKey, Channel: originChannel, ChatID: originChatID, UserMessage: fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content), DefaultResponse: "Background task completed.", EnableSummary: false, SendResponse: true, }) } // runAgentLoop is the core message processing logic. func (al *AgentLoop) runAgentLoop( ctx context.Context, agent *AgentInstance, opts processOptions, ) (string, error) { // 0. Record last channel for heartbeat notifications (skip internal channels and cli) if opts.Channel != "" && opts.ChatID != "" { if !constants.IsInternalChannel(opts.Channel) { channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID) if err := al.RecordLastChannel(channelKey); err != nil { logger.WarnCF( "agent", "Failed to record last channel", map[string]any{"error": err.Error()}, ) } } } // 1. Build messages (skip history for heartbeat) var history []providers.Message var summary string if !opts.NoHistory { history = agent.Sessions.GetHistory(opts.SessionKey) summary = agent.Sessions.GetSummary(opts.SessionKey) } messages := agent.ContextBuilder.BuildMessages( history, summary, opts.UserMessage, opts.Media, opts.Channel, opts.ChatID, opts.SenderID, opts.SenderDisplayName, ) // Resolve media:// refs: images→base64 data URLs, non-images→local paths in content cfg := al.GetConfig() maxMediaSize := cfg.Agents.Defaults.GetMaxMediaSize() messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) // 2. Save user message to session agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage) // 3. Run LLM iteration loop finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts) if err != nil { return "", err } // If last tool had ForUser content and we already sent it, we might not need to send final response // This is controlled by the tool's Silent flag and ForUser content // 4. Handle empty response if finalContent == "" { finalContent = opts.DefaultResponse } // 5. Save final assistant message to session agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent) agent.Sessions.Save(opts.SessionKey) // 6. Optional: summarization if opts.EnableSummary { al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID) } // 7. Optional: send response via bus if opts.SendResponse { al.bus.PublishOutbound(ctx, bus.OutboundMessage{ Channel: opts.Channel, ChatID: opts.ChatID, Content: finalContent, }) } // 8. Log response responsePreview := utils.Truncate(finalContent, 120) logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview), map[string]any{ "agent_id": agent.ID, "session_key": opts.SessionKey, "iterations": iteration, "final_length": len(finalContent), }) return finalContent, nil } func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string) { if al.channelManager == nil { return "" } if ch, ok := al.channelManager.GetChannel(channelName); ok { return ch.ReasoningChannelID() } return "" } func (al *AgentLoop) handleReasoning( ctx context.Context, reasoningContent, channelName, channelID string, ) { if reasoningContent == "" || channelName == "" || channelID == "" { return } // Check context cancellation before attempting to publish, // since PublishOutbound's select may race between send and ctx.Done(). if ctx.Err() != nil { return } // Use a short timeout so the goroutine does not block indefinitely when // the outbound bus is full. Reasoning output is best-effort; dropping it // is acceptable to avoid goroutine accumulation. pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second) defer pubCancel() if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: channelName, ChatID: channelID, Content: reasoningContent, }); err != nil { // Treat context.DeadlineExceeded / context.Canceled as expected // (bus full under load, or parent canceled). Check the error // itself rather than ctx.Err(), because pubCtx may time out // (5 s) while the parent ctx is still active. // Also treat ErrBusClosed as expected — it occurs during normal // shutdown when the bus is closed before all goroutines finish. if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || errors.Is(err, bus.ErrBusClosed) { logger.DebugCF("agent", "Reasoning publish skipped (timeout/cancel)", map[string]any{ "channel": channelName, "error": err.Error(), }) } else { logger.WarnCF("agent", "Failed to publish reasoning (best-effort)", map[string]any{ "channel": channelName, "error": err.Error(), }) } } } // runLLMIteration executes the LLM call loop with tool handling. func (al *AgentLoop) runLLMIteration( ctx context.Context, agent *AgentInstance, messages []providers.Message, opts processOptions, ) (string, int, error) { iteration := 0 var finalContent string // Determine effective model tier for this conversation turn. // selectCandidates evaluates routing once and the decision is sticky for // all tool-follow-up iterations within the same turn so that a multi-step // tool chain doesn't switch models mid-way through. activeCandidates, activeModel := al.selectCandidates(agent, opts.UserMessage, messages) for iteration < agent.MaxIterations { iteration++ logger.DebugCF("agent", "LLM iteration", map[string]any{ "agent_id": agent.ID, "iteration": iteration, "max": agent.MaxIterations, }) // Build tool definitions providerToolDefs := agent.Tools.ToProviderDefs() // Determine whether the provider's native web search should replace // the client-side web_search tool for this request. Only enable when web // search is actually enabled and registered (so users who disabled web // access do not get provider-side search or billing). _, hasWebSearch := agent.Tools.Get("web_search") useNativeSearch := al.cfg.Tools.Web.PreferNative && isNativeSearchProvider(agent.Provider) && hasWebSearch if useNativeSearch { providerToolDefs = filterClientWebSearch(providerToolDefs) } // Log LLM request details logger.DebugCF("agent", "LLM request", map[string]any{ "agent_id": agent.ID, "iteration": iteration, "model": activeModel, "messages_count": len(messages), "tools_count": len(providerToolDefs), "native_search": useNativeSearch, "max_tokens": agent.MaxTokens, "temperature": agent.Temperature, "system_prompt_len": len(messages[0].Content), }) // Log full messages (detailed) logger.DebugCF("agent", "Full LLM request", map[string]any{ "iteration": iteration, "messages_json": formatMessagesForLog(messages), "tools_json": formatToolsForLog(providerToolDefs), }) // Call LLM with fallback chain if multiple candidates are configured. var response *providers.LLMResponse var err error llmOpts := map[string]any{ "max_tokens": agent.MaxTokens, "temperature": agent.Temperature, "prompt_cache_key": agent.ID, } if useNativeSearch { llmOpts["native_search"] = true } // parseThinkingLevel guarantees ThinkingOff for empty/unknown values, // so checking != ThinkingOff is sufficient. if agent.ThinkingLevel != ThinkingOff { if tc, ok := agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { llmOpts["thinking_level"] = string(agent.ThinkingLevel) } else { logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring", map[string]any{"agent_id": agent.ID, "thinking_level": string(agent.ThinkingLevel)}) } } callLLM := func() (*providers.LLMResponse, error) { al.activeRequests.Add(1) defer al.activeRequests.Done() if len(activeCandidates) > 1 && al.fallback != nil { fbResult, fbErr := al.fallback.Execute( ctx, activeCandidates, func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { return agent.Provider.Chat(ctx, messages, providerToolDefs, model, llmOpts) }, ) if fbErr != nil { return nil, fbErr } if fbResult.Provider != "" && len(fbResult.Attempts) > 0 { logger.InfoCF( "agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts", fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1), map[string]any{"agent_id": agent.ID, "iteration": iteration}, ) } return fbResult.Response, nil } return agent.Provider.Chat(ctx, messages, providerToolDefs, activeModel, llmOpts) } // Retry loop for context/token errors maxRetries := 2 for retry := 0; retry <= maxRetries; retry++ { response, err = callLLM() if err == nil { break } errMsg := strings.ToLower(err.Error()) // Check if this is a network/HTTP timeout — not a context window error. isTimeoutError := errors.Is(err, context.DeadlineExceeded) || strings.Contains(errMsg, "deadline exceeded") || strings.Contains(errMsg, "client.timeout") || strings.Contains(errMsg, "timed out") || strings.Contains(errMsg, "timeout exceeded") // Detect real context window / token limit errors, excluding network timeouts. isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") || strings.Contains(errMsg, "context window") || strings.Contains(errMsg, "maximum context length") || strings.Contains(errMsg, "token limit") || strings.Contains(errMsg, "too many tokens") || strings.Contains(errMsg, "max_tokens") || strings.Contains(errMsg, "invalidparameter") || strings.Contains(errMsg, "prompt is too long") || strings.Contains(errMsg, "request too large")) if isTimeoutError && retry < maxRetries { backoff := time.Duration(retry+1) * 5 * time.Second logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{ "error": err.Error(), "retry": retry, "backoff": backoff.String(), }) time.Sleep(backoff) continue } if isContextError && retry < maxRetries { logger.WarnCF( "agent", "Context window error detected, attempting compression", map[string]any{ "error": err.Error(), "retry": retry, }, ) if retry == 0 && !constants.IsInternalChannel(opts.Channel) { al.bus.PublishOutbound(ctx, bus.OutboundMessage{ Channel: opts.Channel, ChatID: opts.ChatID, Content: "Context window exceeded. Compressing history and retrying...", }) } al.forceCompression(agent, opts.SessionKey) newHistory := agent.Sessions.GetHistory(opts.SessionKey) newSummary := agent.Sessions.GetSummary(opts.SessionKey) messages = agent.ContextBuilder.BuildMessages( newHistory, newSummary, "", nil, opts.Channel, opts.ChatID, opts.SenderID, opts.SenderDisplayName, ) continue } break } if err != nil { logger.ErrorCF("agent", "LLM call failed", map[string]any{ "agent_id": agent.ID, "iteration": iteration, "model": activeModel, "error": err.Error(), }) return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err) } go al.handleReasoning( ctx, response.Reasoning, opts.Channel, al.targetReasoningChannelID(opts.Channel), ) logger.DebugCF("agent", "LLM response", map[string]any{ "agent_id": agent.ID, "iteration": iteration, "content_chars": len(response.Content), "tool_calls": len(response.ToolCalls), "reasoning": response.Reasoning, "target_channel": al.targetReasoningChannelID(opts.Channel), "channel": opts.Channel, }) // Check if no tool calls - then check reasoning content if any if len(response.ToolCalls) == 0 { finalContent = response.Content if finalContent == "" && response.ReasoningContent != "" { finalContent = response.ReasoningContent } logger.InfoCF("agent", "LLM response without tool calls (direct answer)", map[string]any{ "agent_id": agent.ID, "iteration": iteration, "content_chars": len(finalContent), }) break } normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls)) for _, tc := range response.ToolCalls { normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc)) } // Log tool calls toolNames := make([]string, 0, len(normalizedToolCalls)) for _, tc := range normalizedToolCalls { toolNames = append(toolNames, tc.Name) } logger.InfoCF("agent", "LLM requested tool calls", map[string]any{ "agent_id": agent.ID, "tools": toolNames, "count": len(normalizedToolCalls), "iteration": iteration, }) // Build assistant message with tool calls assistantMsg := providers.Message{ Role: "assistant", Content: response.Content, ReasoningContent: response.ReasoningContent, } for _, tc := range normalizedToolCalls { argumentsJSON, _ := json.Marshal(tc.Arguments) // Copy ExtraContent to ensure thought_signature is persisted for Gemini 3 extraContent := tc.ExtraContent thoughtSignature := "" if tc.Function != nil { thoughtSignature = tc.Function.ThoughtSignature } assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ ID: tc.ID, Type: "function", Name: tc.Name, Function: &providers.FunctionCall{ Name: tc.Name, Arguments: string(argumentsJSON), ThoughtSignature: thoughtSignature, }, ExtraContent: extraContent, ThoughtSignature: thoughtSignature, }) } messages = append(messages, assistantMsg) // Save assistant message with tool calls to session agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg) // Execute tool calls in parallel type indexedAgentResult struct { result *tools.ToolResult tc providers.ToolCall } agentResults := make([]indexedAgentResult, len(normalizedToolCalls)) var wg sync.WaitGroup for i, tc := range normalizedToolCalls { agentResults[i].tc = tc wg.Add(1) go func(idx int, tc providers.ToolCall) { defer wg.Done() argsJSON, _ := json.Marshal(tc.Arguments) argsPreview := utils.Truncate(string(argsJSON), 200) logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview), map[string]any{ "agent_id": agent.ID, "tool": tc.Name, "iteration": iteration, }) // Send tool feedback to chat channel if enabled if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && opts.Channel != "" { feedbackPreview := utils.Truncate( string(argsJSON), al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), ) feedbackMsg := fmt.Sprintf("\U0001f527 `%s`\n```\n%s\n```", tc.Name, feedbackPreview) fbCtx, fbCancel := context.WithTimeout(ctx, 3*time.Second) _ = al.bus.PublishOutbound(fbCtx, bus.OutboundMessage{ Channel: opts.Channel, ChatID: opts.ChatID, Content: feedbackMsg, }) fbCancel() } // Create async callback for tools that implement AsyncExecutor. // When the background work completes, this publishes the result // as an inbound system message so processSystemMessage routes it // back to the user via the normal agent loop. asyncCallback := func(_ context.Context, result *tools.ToolResult) { // Send ForUser content directly to the user (immediate feedback), // mirroring the synchronous tool execution path. if !result.Silent && result.ForUser != "" { outCtx, outCancel := context.WithTimeout(context.Background(), 5*time.Second) defer outCancel() _ = al.bus.PublishOutbound(outCtx, bus.OutboundMessage{ Channel: opts.Channel, ChatID: opts.ChatID, Content: result.ForUser, }) } // Determine content for the agent loop (ForLLM or error). content := result.ForLLM if content == "" && result.Err != nil { content = result.Err.Error() } if content == "" { return } logger.InfoCF("agent", "Async tool completed, publishing result", map[string]any{ "tool": tc.Name, "content_len": len(content), "channel": opts.Channel, }) pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) defer pubCancel() _ = al.bus.PublishInbound(pubCtx, bus.InboundMessage{ Channel: "system", SenderID: fmt.Sprintf("async:%s", tc.Name), ChatID: fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID), Content: content, }) } toolResult := agent.Tools.ExecuteWithContext( ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback, ) agentResults[idx].result = toolResult }(i, tc) } wg.Wait() // Process results in original order (send to user, save to session) for _, r := range agentResults { // Send ForUser content to user immediately if not Silent if !r.result.Silent && r.result.ForUser != "" && opts.SendResponse { al.bus.PublishOutbound(ctx, bus.OutboundMessage{ Channel: opts.Channel, ChatID: opts.ChatID, Content: r.result.ForUser, }) logger.DebugCF("agent", "Sent tool result to user", map[string]any{ "tool": r.tc.Name, "content_len": len(r.result.ForUser), }) } // If tool returned media refs, publish them as outbound media if len(r.result.Media) > 0 { parts := make([]bus.MediaPart, 0, len(r.result.Media)) for _, ref := range r.result.Media { part := bus.MediaPart{Ref: ref} if al.mediaStore != nil { if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { part.Filename = meta.Filename part.ContentType = meta.ContentType part.Type = inferMediaType(meta.Filename, meta.ContentType) } } parts = append(parts, part) } al.bus.PublishOutboundMedia(ctx, bus.OutboundMediaMessage{ Channel: opts.Channel, ChatID: opts.ChatID, Parts: parts, }) } // Determine content for LLM based on tool result contentForLLM := r.result.ForLLM if contentForLLM == "" && r.result.Err != nil { contentForLLM = r.result.Err.Error() } toolResultMsg := providers.Message{ Role: "tool", Content: contentForLLM, ToolCallID: r.tc.ID, } messages = append(messages, toolResultMsg) // Save tool result message to session agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg) } // Tick down TTL of discovered tools after processing tool results. // Only reached when tool calls were made (the loop continues); // the break on no-tool-call responses skips this. // NOTE: This is safe because processMessage is sequential per agent. // If per-agent concurrency is added, TTL consistency between // ToProviderDefs and Get must be re-evaluated. agent.Tools.TickTTL() logger.DebugCF("agent", "TTL tick after tool execution", map[string]any{ "agent_id": agent.ID, "iteration": iteration, }) } return finalContent, iteration, nil } // selectCandidates returns the model candidates and resolved model name to use // for a conversation turn. When model routing is configured and the incoming // message scores below the complexity threshold, it returns the light model // candidates instead of the primary ones. // // The returned (candidates, model) pair is used for all LLM calls within one // turn — tool follow-up iterations use the same tier as the initial call so // that a multi-step tool chain doesn't switch models mid-way. func (al *AgentLoop) selectCandidates( agent *AgentInstance, userMsg string, history []providers.Message, ) (candidates []providers.FallbackCandidate, model string) { if agent.Router == nil || len(agent.LightCandidates) == 0 { return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model) } _, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model) if !usedLight { logger.DebugCF("agent", "Model routing: primary model selected", map[string]any{ "agent_id": agent.ID, "score": score, "threshold": agent.Router.Threshold(), }) return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model) } logger.InfoCF("agent", "Model routing: light model selected", map[string]any{ "agent_id": agent.ID, "light_model": agent.Router.LightModel(), "score": score, "threshold": agent.Router.Threshold(), }) return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel()) } // maybeSummarize triggers summarization if the session history exceeds thresholds. func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) { newHistory := agent.Sessions.GetHistory(sessionKey) tokenEstimate := al.estimateTokens(newHistory) threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100 if len(newHistory) > agent.SummarizeMessageThreshold || tokenEstimate > threshold { summarizeKey := agent.ID + ":" + sessionKey if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading { go func() { defer al.summarizing.Delete(summarizeKey) logger.Debug("Memory threshold reached. Optimizing conversation history...") al.summarizeSession(agent, sessionKey) }() } } } // forceCompression aggressively reduces context when the limit is hit. // It drops the oldest 50% of messages (keeping system prompt and last user message). func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) { history := agent.Sessions.GetHistory(sessionKey) if len(history) <= 4 { return } // Keep system prompt (usually [0]) and the very last message (user's trigger) // We want to drop the oldest half of the *conversation* // Assuming [0] is system, [1:] is conversation conversation := history[1 : len(history)-1] if len(conversation) == 0 { return } // Helper to find the mid-point of the conversation mid := len(conversation) / 2 // New history structure: // 1. System Prompt (with compression note appended) // 2. Second half of conversation // 3. Last message droppedCount := mid keptConversation := conversation[mid:] newHistory := make([]providers.Message, 0, 1+len(keptConversation)+1) // Append compression note to the original system prompt instead of adding a new system message // This avoids having two consecutive system messages which some APIs (like Zhipu) reject compressionNote := fmt.Sprintf( "\n\n[System Note: Emergency compression dropped %d oldest messages due to context limit]", droppedCount, ) enhancedSystemPrompt := history[0] enhancedSystemPrompt.Content = enhancedSystemPrompt.Content + compressionNote newHistory = append(newHistory, enhancedSystemPrompt) newHistory = append(newHistory, keptConversation...) newHistory = append(newHistory, history[len(history)-1]) // Last message // Update session agent.Sessions.SetHistory(sessionKey, newHistory) agent.Sessions.Save(sessionKey) logger.WarnCF("agent", "Forced compression executed", map[string]any{ "session_key": sessionKey, "dropped_msgs": droppedCount, "new_count": len(newHistory), }) } // GetStartupInfo returns information about loaded tools and skills for logging. func (al *AgentLoop) GetStartupInfo() map[string]any { info := make(map[string]any) registry := al.GetRegistry() agent := registry.GetDefaultAgent() if agent == nil { return info } // Tools info toolsList := agent.Tools.List() info["tools"] = map[string]any{ "count": len(toolsList), "names": toolsList, } // Skills info info["skills"] = agent.ContextBuilder.GetSkillsInfo() // Agents info info["agents"] = map[string]any{ "count": len(registry.ListAgentIDs()), "ids": registry.ListAgentIDs(), } return info } // formatMessagesForLog formats messages for logging func formatMessagesForLog(messages []providers.Message) string { if len(messages) == 0 { return "[]" } var sb strings.Builder sb.WriteString("[\n") for i, msg := range messages { fmt.Fprintf(&sb, " [%d] Role: %s\n", i, msg.Role) if len(msg.ToolCalls) > 0 { sb.WriteString(" ToolCalls:\n") for _, tc := range msg.ToolCalls { fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name) if tc.Function != nil { fmt.Fprintf( &sb, " Arguments: %s\n", utils.Truncate(tc.Function.Arguments, 200), ) } } } if msg.Content != "" { content := utils.Truncate(msg.Content, 200) fmt.Fprintf(&sb, " Content: %s\n", content) } if msg.ToolCallID != "" { fmt.Fprintf(&sb, " ToolCallID: %s\n", msg.ToolCallID) } sb.WriteString("\n") } sb.WriteString("]") return sb.String() } // formatToolsForLog formats tool definitions for logging func formatToolsForLog(toolDefs []providers.ToolDefinition) string { if len(toolDefs) == 0 { return "[]" } var sb strings.Builder sb.WriteString("[\n") for i, tool := range toolDefs { fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name) fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description) if len(tool.Function.Parameters) > 0 { fmt.Fprintf( &sb, " Parameters: %s\n", utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200), ) } } sb.WriteString("]") return sb.String() } // summarizeSession summarizes the conversation history for a session. func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() history := agent.Sessions.GetHistory(sessionKey) summary := agent.Sessions.GetSummary(sessionKey) // Keep last 4 messages for continuity if len(history) <= 4 { return } toSummarize := history[:len(history)-4] // Oversized Message Guard maxMessageTokens := agent.ContextWindow / 2 validMessages := make([]providers.Message, 0) omitted := false for _, m := range toSummarize { if m.Role != "user" && m.Role != "assistant" { continue } msgTokens := len(m.Content) / 2 if msgTokens > maxMessageTokens { omitted = true continue } validMessages = append(validMessages, m) } if len(validMessages) == 0 { return } const ( maxSummarizationMessages = 10 llmMaxRetries = 3 llmTemperature = 0.3 fallbackMaxContentLength = 200 ) // Multi-Part Summarization var finalSummary string if len(validMessages) > maxSummarizationMessages { mid := len(validMessages) / 2 mid = al.findNearestUserMessage(validMessages, mid) part1 := validMessages[:mid] part2 := validMessages[mid:] s1, _ := al.summarizeBatch(ctx, agent, part1, "") s2, _ := al.summarizeBatch(ctx, agent, part2, "") mergePrompt := fmt.Sprintf( "Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", s1, s2, ) resp, err := al.retryLLMCall(ctx, agent, mergePrompt, llmMaxRetries) if err == nil && resp.Content != "" { finalSummary = resp.Content } else { finalSummary = s1 + " " + s2 } } else { finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary) } if omitted && finalSummary != "" { finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]" } if finalSummary != "" { agent.Sessions.SetSummary(sessionKey, finalSummary) agent.Sessions.TruncateHistory(sessionKey, 4) agent.Sessions.Save(sessionKey) } } // findNearestUserMessage finds the nearest user message to the given index. // It searches backward first, then forward if no user message is found. func (al *AgentLoop) findNearestUserMessage(messages []providers.Message, mid int) int { originalMid := mid for mid > 0 && messages[mid].Role != "user" { mid-- } if messages[mid].Role == "user" { return mid } mid = originalMid for mid < len(messages) && messages[mid].Role != "user" { mid++ } if mid < len(messages) { return mid } return originalMid } // retryLLMCall calls the LLM with retry logic. func (al *AgentLoop) retryLLMCall( ctx context.Context, agent *AgentInstance, prompt string, maxRetries int, ) (*providers.LLMResponse, error) { const ( llmTemperature = 0.3 ) var resp *providers.LLMResponse var err error for attempt := 0; attempt < maxRetries; attempt++ { al.activeRequests.Add(1) resp, err = func() (*providers.LLMResponse, error) { defer al.activeRequests.Done() return agent.Provider.Chat( ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, agent.Model, map[string]any{ "max_tokens": agent.MaxTokens, "temperature": llmTemperature, "prompt_cache_key": agent.ID, }, ) }() if err == nil && resp != nil && resp.Content != "" { return resp, nil } if attempt < maxRetries-1 { time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond) } } return resp, err } // summarizeBatch summarizes a batch of messages. func (al *AgentLoop) summarizeBatch( ctx context.Context, agent *AgentInstance, batch []providers.Message, existingSummary string, ) (string, error) { const ( llmMaxRetries = 3 llmTemperature = 0.3 fallbackMinContentLength = 200 fallbackMaxContentPercent = 10 ) var sb strings.Builder sb.WriteString( "Provide a concise summary of this conversation segment, preserving core context and key points.\n", ) if existingSummary != "" { sb.WriteString("Existing context: ") sb.WriteString(existingSummary) sb.WriteString("\n") } sb.WriteString("\nCONVERSATION:\n") for _, m := range batch { fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content) } prompt := sb.String() response, err := al.retryLLMCall(ctx, agent, prompt, llmMaxRetries) if err == nil && response.Content != "" { return strings.TrimSpace(response.Content), nil } var fallback strings.Builder fallback.WriteString("Conversation summary: ") for i, m := range batch { if i > 0 { fallback.WriteString(" | ") } content := strings.TrimSpace(m.Content) runes := []rune(content) if len(runes) == 0 { fallback.WriteString(fmt.Sprintf("%s: ", m.Role)) continue } keepLength := len(runes) * fallbackMaxContentPercent / 100 if keepLength < fallbackMinContentLength { keepLength = fallbackMinContentLength } if keepLength > len(runes) { keepLength = len(runes) } content = string(runes[:keepLength]) if keepLength < len(runes) { content += "..." } fallback.WriteString(fmt.Sprintf("%s: %s", m.Role, content)) } return fallback.String(), nil } // estimateTokens estimates the number of tokens in a message list. // Uses a safe heuristic of 2.5 characters per token to account for CJK and other // overheads better than the previous 3 chars/token. func (al *AgentLoop) estimateTokens(messages []providers.Message) int { totalChars := 0 for _, m := range messages { totalChars += utf8.RuneCountInString(m.Content) } // 2.5 chars per token = totalChars * 2 / 5 return totalChars * 2 / 5 } func (al *AgentLoop) handleCommand( ctx context.Context, msg bus.InboundMessage, agent *AgentInstance, opts *processOptions, ) (string, bool) { if !commands.HasCommandPrefix(msg.Content) { return "", false } if al.cmdRegistry == nil { return "", false } rt := al.buildCommandsRuntime(agent, opts) executor := commands.NewExecutor(al.cmdRegistry, rt) var commandReply string result := executor.Execute(ctx, commands.Request{ Channel: msg.Channel, ChatID: msg.ChatID, SenderID: msg.SenderID, Text: msg.Content, Reply: func(text string) error { commandReply = text return nil }, }) switch result.Outcome { case commands.OutcomeHandled: if result.Err != nil { return mapCommandError(result), true } if commandReply != "" { return commandReply, true } return "", true default: // OutcomePassthrough — let the message fall through to LLM return "", false } } func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOptions) *commands.Runtime { registry := al.GetRegistry() cfg := al.GetConfig() rt := &commands.Runtime{ Config: cfg, ListAgentIDs: registry.ListAgentIDs, ListDefinitions: al.cmdRegistry.Definitions, GetEnabledChannels: func() []string { if al.channelManager == nil { return nil } return al.channelManager.GetEnabledChannels() }, SwitchChannel: func(value string) error { if al.channelManager == nil { return fmt.Errorf("channel manager not initialized") } if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" { return fmt.Errorf("channel '%s' not found or not enabled", value) } return nil }, } rt.ReloadConfig = func() error { if al.reloadFunc == nil { return fmt.Errorf("reload not configured") } return al.reloadFunc() } if agent != nil { rt.GetModelInfo = func() (string, string) { return agent.Model, resolvedCandidateProvider(agent.Candidates, cfg.Agents.Defaults.Provider) } rt.SwitchModel = func(value string) (string, error) { value = strings.TrimSpace(value) modelCfg, err := resolvedModelConfig(cfg, value, agent.Workspace) if err != nil { return "", err } nextProvider, _, err := providers.CreateProviderFromConfig(modelCfg) if err != nil { return "", fmt.Errorf("failed to initialize model %q: %w", value, err) } nextCandidates := resolveModelCandidates(cfg, cfg.Agents.Defaults.Provider, modelCfg.Model, agent.Fallbacks) if len(nextCandidates) == 0 { return "", fmt.Errorf("model %q did not resolve to any provider candidates", value) } oldModel := agent.Model oldProvider := agent.Provider agent.Model = value agent.Provider = nextProvider agent.Candidates = nextCandidates agent.ThinkingLevel = parseThinkingLevel(modelCfg.ThinkingLevel) if oldProvider != nil && oldProvider != nextProvider { if stateful, ok := oldProvider.(providers.StatefulProvider); ok { stateful.Close() } } return oldModel, nil } rt.ClearHistory = func() error { if opts == nil { return fmt.Errorf("process options not available") } if agent.Sessions == nil { return fmt.Errorf("sessions not initialized for agent") } agent.Sessions.SetHistory(opts.SessionKey, make([]providers.Message, 0)) agent.Sessions.SetSummary(opts.SessionKey, "") agent.Sessions.Save(opts.SessionKey) return nil } } return rt } func mapCommandError(result commands.ExecuteResult) string { if result.Command == "" { return fmt.Sprintf("Failed to execute command: %v", result.Err) } return fmt.Sprintf("Failed to execute /%s: %v", result.Command, result.Err) } // extractPeer extracts the routing peer from the inbound message's structured Peer field. func extractPeer(msg bus.InboundMessage) *routing.RoutePeer { if msg.Peer.Kind == "" { return nil } peerID := msg.Peer.ID if peerID == "" { if msg.Peer.Kind == "direct" { peerID = msg.SenderID } else { peerID = msg.ChatID } } return &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID} } func inboundMetadata(msg bus.InboundMessage, key string) string { if msg.Metadata == nil { return "" } return msg.Metadata[key] } // extractParentPeer extracts the parent peer (reply-to) from inbound message metadata. func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer { parentKind := inboundMetadata(msg, metadataKeyParentPeerKind) parentID := inboundMetadata(msg, metadataKeyParentPeerID) if parentKind == "" || parentID == "" { return nil } return &routing.RoutePeer{Kind: parentKind, ID: parentID} } // isNativeSearchProvider reports whether the given LLM provider implements // NativeSearchCapable and returns true for SupportsNativeSearch. func isNativeSearchProvider(p providers.LLMProvider) bool { if ns, ok := p.(providers.NativeSearchCapable); ok { return ns.SupportsNativeSearch() } return false } // filterClientWebSearch returns a copy of tools with the client-side // web_search tool removed. Used when native provider search is preferred. func filterClientWebSearch(tools []providers.ToolDefinition) []providers.ToolDefinition { result := make([]providers.ToolDefinition, 0, len(tools)) for _, t := range tools { if strings.EqualFold(t.Function.Name, "web_search") { continue } result = append(result, t) } return result } // Helper to extract provider from registry for cleanup func extractProvider(registry *AgentRegistry) (providers.LLMProvider, bool) { if registry == nil { return nil, false } // Get any agent to access the provider defaultAgent := registry.GetDefaultAgent() if defaultAgent == nil { return nil, false } return defaultAgent.Provider, true } ================================================ FILE: pkg/agent/loop_mcp.go ================================================ // PicoClaw - Ultra-lightweight personal AI agent // Inspired by and based on nanobot: https://github.com/HKUDS/nanobot // License: MIT // // Copyright (c) 2026 PicoClaw contributors package agent import ( "context" "fmt" "sync" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/mcp" "github.com/sipeed/picoclaw/pkg/tools" ) type mcpRuntime struct { initOnce sync.Once mu sync.Mutex manager *mcp.Manager initErr error } func (r *mcpRuntime) setManager(manager *mcp.Manager) { r.mu.Lock() r.manager = manager r.initErr = nil r.mu.Unlock() } func (r *mcpRuntime) setInitErr(err error) { r.mu.Lock() r.initErr = err r.mu.Unlock() } func (r *mcpRuntime) getInitErr() error { r.mu.Lock() defer r.mu.Unlock() return r.initErr } func (r *mcpRuntime) takeManager() *mcp.Manager { r.mu.Lock() defer r.mu.Unlock() manager := r.manager r.manager = nil return manager } func (r *mcpRuntime) hasManager() bool { r.mu.Lock() defer r.mu.Unlock() return r.manager != nil } // ensureMCPInitialized loads MCP servers/tools once so both Run() and direct // agent mode share the same initialization path. func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { if !al.cfg.Tools.IsToolEnabled("mcp") { return nil } if al.cfg.Tools.MCP.Servers == nil || len(al.cfg.Tools.MCP.Servers) == 0 { logger.WarnCF("agent", "MCP is enabled but no servers are configured, skipping MCP initialization", nil) return nil } findValidServer := false for _, serverCfg := range al.cfg.Tools.MCP.Servers { if serverCfg.Enabled { findValidServer = true } } if !findValidServer { logger.WarnCF("agent", "MCP is enabled but no valid servers are configured, skipping MCP initialization", nil) return nil } al.mcp.initOnce.Do(func() { mcpManager := mcp.NewManager() defaultAgent := al.registry.GetDefaultAgent() workspacePath := al.cfg.WorkspacePath() if defaultAgent != nil && defaultAgent.Workspace != "" { workspacePath = defaultAgent.Workspace } if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil { logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available", map[string]any{ "error": err.Error(), }) if closeErr := mcpManager.Close(); closeErr != nil { logger.ErrorCF("agent", "Failed to close MCP manager", map[string]any{ "error": closeErr.Error(), }) } return } // Register MCP tools for all agents servers := mcpManager.GetServers() uniqueTools := 0 totalRegistrations := 0 agentIDs := al.registry.ListAgentIDs() agentCount := len(agentIDs) for serverName, conn := range servers { uniqueTools += len(conn.Tools) // Determine whether this server's tools should be deferred (hidden). // Per-server "deferred" field takes precedence over the global Discovery.Enabled. serverCfg := al.cfg.Tools.MCP.Servers[serverName] registerAsHidden := serverIsDeferred(al.cfg.Tools.MCP.Discovery.Enabled, serverCfg) for _, tool := range conn.Tools { for _, agentID := range agentIDs { agent, ok := al.registry.GetAgent(agentID) if !ok { continue } mcpTool := tools.NewMCPTool(mcpManager, serverName, tool) if registerAsHidden { agent.Tools.RegisterHidden(mcpTool) } else { agent.Tools.Register(mcpTool) } totalRegistrations++ logger.DebugCF("agent", "Registered MCP tool", map[string]any{ "agent_id": agentID, "server": serverName, "tool": tool.Name, "name": mcpTool.Name(), "deferred": registerAsHidden, }) } } } logger.InfoCF("agent", "MCP tools registered successfully", map[string]any{ "server_count": len(servers), "unique_tools": uniqueTools, "total_registrations": totalRegistrations, "agent_count": agentCount, }) // Initializes Discovery Tools only if enabled by configuration if al.cfg.Tools.MCP.Enabled && al.cfg.Tools.MCP.Discovery.Enabled { useBM25 := al.cfg.Tools.MCP.Discovery.UseBM25 useRegex := al.cfg.Tools.MCP.Discovery.UseRegex // Fail fast: If discovery is enabled but no search method is turned on if !useBM25 && !useRegex { al.mcp.setInitErr(fmt.Errorf( "tool discovery is enabled but neither 'use_bm25' nor 'use_regex' is set to true in the configuration", )) if closeErr := mcpManager.Close(); closeErr != nil { logger.ErrorCF("agent", "Failed to close MCP manager", map[string]any{ "error": closeErr.Error(), }) } return } ttl := al.cfg.Tools.MCP.Discovery.TTL if ttl <= 0 { ttl = 5 // Default value } maxSearchResults := al.cfg.Tools.MCP.Discovery.MaxSearchResults if maxSearchResults <= 0 { maxSearchResults = 5 // Default value } logger.InfoCF("agent", "Initializing tool discovery", map[string]any{ "bm25": useBM25, "regex": useRegex, "ttl": ttl, "max_results": maxSearchResults, }) for _, agentID := range agentIDs { agent, ok := al.registry.GetAgent(agentID) if !ok { continue } if useRegex { agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults)) } if useBM25 { agent.Tools.Register(tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults)) } } } al.mcp.setManager(mcpManager) }) return al.mcp.getInitErr() } // serverIsDeferred reports whether an MCP server's tools should be registered // as hidden (deferred/discovery mode). // // The per-server Deferred field takes precedence over the global discoveryEnabled // default. When Deferred is nil, discoveryEnabled is used as the fallback. func serverIsDeferred(discoveryEnabled bool, serverCfg config.MCPServerConfig) bool { if !discoveryEnabled { return false } if serverCfg.Deferred != nil { return *serverCfg.Deferred } return true } ================================================ FILE: pkg/agent/loop_mcp_test.go ================================================ // PicoClaw - Ultra-lightweight personal AI agent // Inspired by and based on nanobot: https://github.com/HKUDS/nanobot // License: MIT // // Copyright (c) 2026 PicoClaw contributors package agent import ( "testing" "github.com/sipeed/picoclaw/pkg/config" ) func boolPtr(b bool) *bool { return &b } func TestServerIsDeferred(t *testing.T) { tests := []struct { name string discoveryEnabled bool serverDeferred *bool want bool }{ // --- global false always wins: per-server deferred is ignored --- { name: "global false: per-server deferred=true is ignored", discoveryEnabled: false, serverDeferred: boolPtr(true), want: false, }, { name: "global false: per-server deferred=false stays false", discoveryEnabled: false, serverDeferred: boolPtr(false), want: false, }, // --- global true: per-server override applies --- { name: "global true: per-server deferred=false opts out", discoveryEnabled: true, serverDeferred: boolPtr(false), want: false, }, { name: "global true: per-server deferred=true stays true", discoveryEnabled: true, serverDeferred: boolPtr(true), want: true, }, // --- no per-server override: fall back to global --- { name: "no per-server field, global discovery enabled", discoveryEnabled: true, serverDeferred: nil, want: true, }, { name: "no per-server field, global discovery disabled", discoveryEnabled: false, serverDeferred: nil, want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { serverCfg := config.MCPServerConfig{Deferred: tt.serverDeferred} got := serverIsDeferred(tt.discoveryEnabled, serverCfg) if got != tt.want { t.Errorf("serverIsDeferred(discoveryEnabled=%v, deferred=%v) = %v, want %v", tt.discoveryEnabled, tt.serverDeferred, got, tt.want) } }) } } ================================================ FILE: pkg/agent/loop_media.go ================================================ // PicoClaw - Ultra-lightweight personal AI agent // Inspired by and based on nanobot: https://github.com/HKUDS/nanobot // License: MIT // // Copyright (c) 2026 PicoClaw contributors package agent import ( "bytes" "encoding/base64" "io" "os" "strings" "github.com/h2non/filetype" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/providers" ) // resolveMediaRefs resolves media:// refs in messages. // Images are base64-encoded into the Media array for multimodal LLMs. // Non-image files (documents, audio, video) have their local path injected // into Content so the agent can access them via file tools like read_file. // Returns a new slice; original messages are not mutated. func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message { if store == nil { return messages } result := make([]providers.Message, len(messages)) copy(result, messages) for i, m := range result { if len(m.Media) == 0 { continue } resolved := make([]string, 0, len(m.Media)) var pathTags []string for _, ref := range m.Media { if !strings.HasPrefix(ref, "media://") { resolved = append(resolved, ref) continue } localPath, meta, err := store.ResolveWithMeta(ref) if err != nil { logger.WarnCF("agent", "Failed to resolve media ref", map[string]any{ "ref": ref, "error": err.Error(), }) continue } info, err := os.Stat(localPath) if err != nil { logger.WarnCF("agent", "Failed to stat media file", map[string]any{ "path": localPath, "error": err.Error(), }) continue } mime := detectMIME(localPath, meta) if strings.HasPrefix(mime, "image/") { dataURL := encodeImageToDataURL(localPath, mime, info, maxSize) if dataURL != "" { resolved = append(resolved, dataURL) } continue } pathTags = append(pathTags, buildPathTag(mime, localPath)) } result[i].Media = resolved if len(pathTags) > 0 { result[i].Content = injectPathTags(result[i].Content, pathTags) } } return result } // detectMIME determines the MIME type from metadata or magic-bytes detection. // Returns empty string if detection fails. func detectMIME(localPath string, meta media.MediaMeta) string { if meta.ContentType != "" { return meta.ContentType } kind, err := filetype.MatchFile(localPath) if err != nil || kind == filetype.Unknown { return "" } return kind.MIME.Value } // encodeImageToDataURL base64-encodes an image file into a data URL. // Returns empty string if the file exceeds maxSize or encoding fails. func encodeImageToDataURL(localPath, mime string, info os.FileInfo, maxSize int) string { if info.Size() > int64(maxSize) { logger.WarnCF("agent", "Media file too large, skipping", map[string]any{ "path": localPath, "size": info.Size(), "max_size": maxSize, }) return "" } f, err := os.Open(localPath) if err != nil { logger.WarnCF("agent", "Failed to open media file", map[string]any{ "path": localPath, "error": err.Error(), }) return "" } defer f.Close() prefix := "data:" + mime + ";base64," encodedLen := base64.StdEncoding.EncodedLen(int(info.Size())) var buf bytes.Buffer buf.Grow(len(prefix) + encodedLen) buf.WriteString(prefix) encoder := base64.NewEncoder(base64.StdEncoding, &buf) if _, err := io.Copy(encoder, f); err != nil { logger.WarnCF("agent", "Failed to encode media file", map[string]any{ "path": localPath, "error": err.Error(), }) return "" } encoder.Close() return buf.String() } // buildPathTag creates a structured tag exposing the local file path. // Tag type is derived from MIME: [audio:/path], [video:/path], or [file:/path]. func buildPathTag(mime, localPath string) string { switch { case strings.HasPrefix(mime, "audio/"): return "[audio:" + localPath + "]" case strings.HasPrefix(mime, "video/"): return "[video:" + localPath + "]" default: return "[file:" + localPath + "]" } } // injectPathTags replaces generic media tags in content with path-bearing versions, // or appends if no matching generic tag is found. func injectPathTags(content string, tags []string) string { for _, tag := range tags { var generic string switch { case strings.HasPrefix(tag, "[audio:"): generic = "[audio]" case strings.HasPrefix(tag, "[video:"): generic = "[video]" case strings.HasPrefix(tag, "[file:"): generic = "[file]" } if generic != "" && strings.Contains(content, generic) { content = strings.Replace(content, generic, tag, 1) } else if content == "" { content = tag } else { content += " " + tag } } return content } ================================================ FILE: pkg/agent/loop_test.go ================================================ package agent import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "slices" "strings" "testing" "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/tools" ) type fakeChannel struct{ id string } func (f *fakeChannel) Name() string { return "fake" } func (f *fakeChannel) Start(ctx context.Context) error { return nil } func (f *fakeChannel) Stop(ctx context.Context) error { return nil } func (f *fakeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return nil } func (f *fakeChannel) IsRunning() bool { return true } func (f *fakeChannel) IsAllowed(string) bool { return true } func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true } func (f *fakeChannel) ReasoningChannelID() string { return f.id } type recordingProvider struct { lastMessages []providers.Message } func (r *recordingProvider) Chat( ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]any, ) (*providers.LLMResponse, error) { r.lastMessages = append([]providers.Message(nil), messages...) return &providers.LLMResponse{ Content: "Mock response", ToolCalls: []providers.ToolCall{}, }, nil } func (r *recordingProvider) GetDefaultModel() string { return "mock-model" } func newTestAgentLoop( t *testing.T, ) (al *AgentLoop, cfg *config.Config, msgBus *bus.MessageBus, provider *mockProvider, cleanup func()) { t.Helper() tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } cfg = &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, Model: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, }, } msgBus = bus.NewMessageBus() provider = &mockProvider{} al = NewAgentLoop(cfg, msgBus, provider) return al, cfg, msgBus, provider, func() { os.RemoveAll(tmpDir) } } func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, Model: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, }, } msgBus := bus.NewMessageBus() provider := &recordingProvider{} al := NewAgentLoop(cfg, msgBus, provider) response, err := al.processMessage(context.Background(), bus.InboundMessage{ Channel: "discord", SenderID: "discord:123", Sender: bus.SenderInfo{ DisplayName: "Alice", }, ChatID: "group-1", Content: "hello", }) if err != nil { t.Fatalf("processMessage() error = %v", err) } if response != "Mock response" { t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") } if len(provider.lastMessages) == 0 { t.Fatal("provider did not receive any messages") } systemPrompt := provider.lastMessages[0].Content wantSender := "## Current Sender\nCurrent sender: Alice (ID: discord:123)" if !strings.Contains(systemPrompt, wantSender) { t.Fatalf("system prompt missing sender context %q:\n%s", wantSender, systemPrompt) } lastMessage := provider.lastMessages[len(provider.lastMessages)-1] if lastMessage.Role != "user" || lastMessage.Content != "hello" { t.Fatalf("last provider message = %+v, want unchanged user message", lastMessage) } } func TestRecordLastChannel(t *testing.T) { al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t) defer cleanup() testChannel := "test-channel" if err := al.RecordLastChannel(testChannel); err != nil { t.Fatalf("RecordLastChannel failed: %v", err) } if got := al.state.GetLastChannel(); got != testChannel { t.Errorf("Expected channel '%s', got '%s'", testChannel, got) } al2 := NewAgentLoop(cfg, msgBus, provider) if got := al2.state.GetLastChannel(); got != testChannel { t.Errorf("Expected persistent channel '%s', got '%s'", testChannel, got) } } func TestRecordLastChatID(t *testing.T) { al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t) defer cleanup() testChatID := "test-chat-id-123" if err := al.RecordLastChatID(testChatID); err != nil { t.Fatalf("RecordLastChatID failed: %v", err) } if got := al.state.GetLastChatID(); got != testChatID { t.Errorf("Expected chat ID '%s', got '%s'", testChatID, got) } al2 := NewAgentLoop(cfg, msgBus, provider) if got := al2.state.GetLastChatID(); got != testChatID { t.Errorf("Expected persistent chat ID '%s', got '%s'", testChatID, got) } } func TestNewAgentLoop_StateInitialized(t *testing.T) { // Create temp workspace tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) // Create test config cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, Model: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, }, } // Create agent loop msgBus := bus.NewMessageBus() provider := &mockProvider{} al := NewAgentLoop(cfg, msgBus, provider) // Verify state manager is initialized if al.state == nil { t.Error("Expected state manager to be initialized") } // Verify state directory was created stateDir := filepath.Join(tmpDir, "state") if _, err := os.Stat(stateDir); os.IsNotExist(err) { t.Error("Expected state directory to exist") } } // TestToolRegistry_ToolRegistration verifies tools can be registered and retrieved func TestToolRegistry_ToolRegistration(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, Model: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, }, } msgBus := bus.NewMessageBus() provider := &mockProvider{} al := NewAgentLoop(cfg, msgBus, provider) // Register a custom tool customTool := &mockCustomTool{} al.RegisterTool(customTool) // Verify tool is registered by checking it doesn't panic on GetStartupInfo // (actual tool retrieval is tested in tools package tests) info := al.GetStartupInfo() toolsInfo := info["tools"].(map[string]any) toolsList := toolsInfo["names"].([]string) // Check that our custom tool name is in the list found := slices.Contains(toolsList, "mock_custom") if !found { t.Error("Expected custom tool to be registered") } } // TestToolContext_Updates verifies tool context helpers work correctly func TestToolContext_Updates(t *testing.T) { ctx := tools.WithToolContext(context.Background(), "telegram", "chat-42") if got := tools.ToolChannel(ctx); got != "telegram" { t.Errorf("expected channel 'telegram', got %q", got) } if got := tools.ToolChatID(ctx); got != "chat-42" { t.Errorf("expected chatID 'chat-42', got %q", got) } // Empty context returns empty strings if got := tools.ToolChannel(context.Background()); got != "" { t.Errorf("expected empty channel from bare context, got %q", got) } } // TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved func TestToolRegistry_GetDefinitions(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, Model: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, }, } msgBus := bus.NewMessageBus() provider := &mockProvider{} al := NewAgentLoop(cfg, msgBus, provider) // Register a test tool and verify it shows up in startup info testTool := &mockCustomTool{} al.RegisterTool(testTool) info := al.GetStartupInfo() toolsInfo := info["tools"].(map[string]any) toolsList := toolsInfo["names"].([]string) // Check that our custom tool name is in the list found := slices.Contains(toolsList, "mock_custom") if !found { t.Error("Expected custom tool to be registered") } } // TestAgentLoop_GetStartupInfo verifies startup info contains tools func TestAgentLoop_GetStartupInfo(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) cfg := config.DefaultConfig() cfg.Agents.Defaults.Workspace = tmpDir cfg.Agents.Defaults.Model = "test-model" cfg.Agents.Defaults.MaxTokens = 4096 cfg.Agents.Defaults.MaxToolIterations = 10 msgBus := bus.NewMessageBus() provider := &mockProvider{} al := NewAgentLoop(cfg, msgBus, provider) info := al.GetStartupInfo() // Verify tools info exists toolsInfo, ok := info["tools"] if !ok { t.Fatal("Expected 'tools' key in startup info") } toolsMap, ok := toolsInfo.(map[string]any) if !ok { t.Fatal("Expected 'tools' to be a map") } count, ok := toolsMap["count"] if !ok { t.Fatal("Expected 'count' in tools info") } // Should have default tools registered if count.(int) == 0 { t.Error("Expected at least some tools to be registered") } } // TestAgentLoop_Stop verifies Stop() sets running to false func TestAgentLoop_Stop(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, Model: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, }, } msgBus := bus.NewMessageBus() provider := &mockProvider{} al := NewAgentLoop(cfg, msgBus, provider) // Note: running is only set to true when Run() is called // We can't test that without starting the event loop // Instead, verify the Stop method can be called safely al.Stop() // Verify running is false (initial state or after Stop) if al.running.Load() { t.Error("Expected agent to be stopped (or never started)") } } // Mock implementations for testing type simpleMockProvider struct { response string } func (m *simpleMockProvider) Chat( ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]any, ) (*providers.LLMResponse, error) { return &providers.LLMResponse{ Content: m.response, ToolCalls: []providers.ToolCall{}, }, nil } func (m *simpleMockProvider) GetDefaultModel() string { return "mock-model" } type countingMockProvider struct { response string calls int } func (m *countingMockProvider) Chat( ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]any, ) (*providers.LLMResponse, error) { m.calls++ return &providers.LLMResponse{ Content: m.response, ToolCalls: []providers.ToolCall{}, }, nil } func (m *countingMockProvider) GetDefaultModel() string { return "counting-mock-model" } // mockCustomTool is a simple mock tool for registration testing type mockCustomTool struct{} func (m *mockCustomTool) Name() string { return "mock_custom" } func (m *mockCustomTool) Description() string { return "Mock custom tool for testing" } func (m *mockCustomTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{}, } } func (m *mockCustomTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { return tools.SilentResult("Custom tool executed") } // testHelper executes a message and returns the response type testHelper struct { al *AgentLoop } func newChatCompletionTestServer( t *testing.T, label string, response string, calls *int, model *string, ) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/chat/completions" { t.Fatalf("%s server path = %q, want /chat/completions", label, r.URL.Path) } *calls = *calls + 1 defer r.Body.Close() var req struct { Model string `json:"model"` } decodeErr := json.NewDecoder(r.Body).Decode(&req) if decodeErr != nil { t.Fatalf("decode %s request: %v", label, decodeErr) } *model = req.Model w.Header().Set("Content-Type", "application/json") encodeErr := json.NewEncoder(w).Encode(map[string]any{ "choices": []map[string]any{ { "message": map[string]any{"content": response}, "finish_reason": "stop", }, }, }) if encodeErr != nil { t.Fatalf("encode %s response: %v", label, encodeErr) } })) } func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, msg bus.InboundMessage) string { // Use a short timeout to avoid hanging timeoutCtx, cancel := context.WithTimeout(ctx, responseTimeout) defer cancel() response, err := h.al.processMessage(timeoutCtx, msg) if err != nil { tb.Fatalf("processMessage failed: %v", err) } return response } const responseTimeout = 3 * time.Second func TestProcessMessage_UsesRouteSessionKey(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, Model: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, }, } msgBus := bus.NewMessageBus() provider := &simpleMockProvider{response: "ok"} al := NewAgentLoop(cfg, msgBus, provider) msg := bus.InboundMessage{ Channel: "telegram", SenderID: "user1", ChatID: "chat1", Content: "hello", Peer: bus.Peer{ Kind: "direct", ID: "user1", }, } route := al.registry.ResolveRoute(routing.RouteInput{ Channel: msg.Channel, Peer: extractPeer(msg), }) sessionKey := route.SessionKey defaultAgent := al.registry.GetDefaultAgent() if defaultAgent == nil { t.Fatal("No default agent found") } helper := testHelper{al: al} _ = helper.executeAndGetResponse(t, context.Background(), msg) history := defaultAgent.Sessions.GetHistory(sessionKey) if len(history) != 2 { t.Fatalf("expected session history len=2, got %d", len(history)) } if history[0].Role != "user" || history[0].Content != "hello" { t.Fatalf("unexpected first message in session: %+v", history[0]) } } func TestProcessMessage_CommandOutcomes(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, Model: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, }, Session: config.SessionConfig{ DMScope: "per-channel-peer", }, } msgBus := bus.NewMessageBus() provider := &countingMockProvider{response: "LLM reply"} al := NewAgentLoop(cfg, msgBus, provider) helper := testHelper{al: al} baseMsg := bus.InboundMessage{ Channel: "whatsapp", SenderID: "user1", ChatID: "chat1", Peer: bus.Peer{ Kind: "direct", ID: "user1", }, } showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ Channel: baseMsg.Channel, SenderID: baseMsg.SenderID, ChatID: baseMsg.ChatID, Content: "/show channel", Peer: baseMsg.Peer, }) if showResp != "Current Channel: whatsapp" { t.Fatalf("unexpected /show reply: %q", showResp) } if provider.calls != 0 { t.Fatalf("LLM should not be called for handled command, calls=%d", provider.calls) } fooResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ Channel: baseMsg.Channel, SenderID: baseMsg.SenderID, ChatID: baseMsg.ChatID, Content: "/foo", Peer: baseMsg.Peer, }) if fooResp != "LLM reply" { t.Fatalf("unexpected /foo reply: %q", fooResp) } if provider.calls != 1 { t.Fatalf("LLM should be called exactly once after /foo passthrough, calls=%d", provider.calls) } newResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ Channel: baseMsg.Channel, SenderID: baseMsg.SenderID, ChatID: baseMsg.ChatID, Content: "/new", Peer: baseMsg.Peer, }) if newResp != "LLM reply" { t.Fatalf("unexpected /new reply: %q", newResp) } if provider.calls != 2 { t.Fatalf("LLM should be called for passthrough /new command, calls=%d", provider.calls) } } func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, Provider: "openai", Model: "local", MaxTokens: 4096, MaxToolIterations: 10, }, }, ModelList: []config.ModelConfig{ { ModelName: "local", Model: "openai/local-model", APIKey: "test-key", APIBase: "https://local.example.invalid/v1", }, { ModelName: "deepseek", Model: "openrouter/deepseek/deepseek-v3.2", APIKey: "test-key", APIBase: "https://openrouter.ai/api/v1", }, }, } msgBus := bus.NewMessageBus() provider := &countingMockProvider{response: "LLM reply"} al := NewAgentLoop(cfg, msgBus, provider) helper := testHelper{al: al} switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ Channel: "telegram", SenderID: "user1", ChatID: "chat1", Content: "/switch model to deepseek", Peer: bus.Peer{ Kind: "direct", ID: "user1", }, }) if !strings.Contains(switchResp, "Switched model from local to deepseek") { t.Fatalf("unexpected /switch reply: %q", switchResp) } showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ Channel: "telegram", SenderID: "user1", ChatID: "chat1", Content: "/show model", Peer: bus.Peer{ Kind: "direct", ID: "user1", }, }) if !strings.Contains(showResp, "Current Model: deepseek (Provider: openrouter)") { t.Fatalf("unexpected /show model reply after switch: %q", showResp) } if provider.calls != 0 { t.Fatalf("LLM should not be called for /switch and /show, calls=%d", provider.calls) } } func TestProcessMessage_SwitchModelRejectsUnknownAlias(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, Provider: "openai", Model: "local", MaxTokens: 4096, MaxToolIterations: 10, }, }, ModelList: []config.ModelConfig{ { ModelName: "local", Model: "openai/local-model", APIKey: "test-key", APIBase: "https://local.example.invalid/v1", }, }, } msgBus := bus.NewMessageBus() provider := &countingMockProvider{response: "LLM reply"} al := NewAgentLoop(cfg, msgBus, provider) helper := testHelper{al: al} switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ Channel: "telegram", SenderID: "user1", ChatID: "chat1", Content: "/switch model to missing", Peer: bus.Peer{ Kind: "direct", ID: "user1", }, }) if switchResp != `model "missing" not found in model_list or providers` { t.Fatalf("unexpected /switch error reply: %q", switchResp) } showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ Channel: "telegram", SenderID: "user1", ChatID: "chat1", Content: "/show model", Peer: bus.Peer{ Kind: "direct", ID: "user1", }, }) if !strings.Contains(showResp, "Current Model: local (Provider: openai)") { t.Fatalf("unexpected /show model reply after rejected switch: %q", showResp) } if provider.calls != 0 { t.Fatalf("LLM should not be called for rejected /switch and /show, calls=%d", provider.calls) } } func TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) localCalls := 0 localModel := "" localServer := newChatCompletionTestServer(t, "local", "local reply", &localCalls, &localModel) defer localServer.Close() remoteCalls := 0 remoteModel := "" remoteServer := newChatCompletionTestServer(t, "remote", "remote reply", &remoteCalls, &remoteModel) defer remoteServer.Close() cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, Provider: "openai", Model: "local", MaxTokens: 4096, MaxToolIterations: 10, }, }, ModelList: []config.ModelConfig{ { ModelName: "local", Model: "openai/Qwen3.5-35B-A3B", APIKey: "local-key", APIBase: localServer.URL, }, { ModelName: "deepseek", Model: "openrouter/deepseek/deepseek-v3.2", APIKey: "remote-key", APIBase: remoteServer.URL, }, }, } msgBus := bus.NewMessageBus() provider, _, err := providers.CreateProvider(cfg) if err != nil { t.Fatalf("CreateProvider() error = %v", err) } al := NewAgentLoop(cfg, msgBus, provider) helper := testHelper{al: al} firstResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ Channel: "telegram", SenderID: "user1", ChatID: "chat1", Content: "hello before switch", Peer: bus.Peer{ Kind: "direct", ID: "user1", }, }) if firstResp != "local reply" { t.Fatalf("unexpected response before switch: %q", firstResp) } if localCalls != 1 { t.Fatalf("local calls before switch = %d, want 1", localCalls) } if remoteCalls != 0 { t.Fatalf("remote calls before switch = %d, want 0", remoteCalls) } if localModel != "Qwen3.5-35B-A3B" { t.Fatalf("local model before switch = %q, want %q", localModel, "Qwen3.5-35B-A3B") } switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ Channel: "telegram", SenderID: "user1", ChatID: "chat1", Content: "/switch model to deepseek", Peer: bus.Peer{ Kind: "direct", ID: "user1", }, }) if !strings.Contains(switchResp, "Switched model from local to deepseek") { t.Fatalf("unexpected /switch reply: %q", switchResp) } secondResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ Channel: "telegram", SenderID: "user1", ChatID: "chat1", Content: "hello after switch", Peer: bus.Peer{ Kind: "direct", ID: "user1", }, }) if secondResp != "remote reply" { t.Fatalf("unexpected response after switch: %q", secondResp) } if localCalls != 1 { t.Fatalf("local calls after switch = %d, want 1", localCalls) } if remoteCalls != 1 { t.Fatalf("remote calls after switch = %d, want 1", remoteCalls) } if remoteModel != "deepseek-v3.2" { t.Fatalf( "remote model after switch = %q, want %q", remoteModel, "deepseek-v3.2", ) } } // TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, Model: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, }, } msgBus := bus.NewMessageBus() provider := &simpleMockProvider{response: "File operation complete"} al := NewAgentLoop(cfg, msgBus, provider) helper := testHelper{al: al} // ReadFileTool returns SilentResult, which should not send user message ctx := context.Background() msg := bus.InboundMessage{ Channel: "test", SenderID: "user1", ChatID: "chat1", Content: "read test.txt", SessionKey: "test-session", } response := helper.executeAndGetResponse(t, ctx, msg) // Silent tool should return the LLM's response directly if response != "File operation complete" { t.Errorf("Expected 'File operation complete', got: %s", response) } } // TestToolResult_UserFacingToolDoesSendMessage verifies user-facing tools trigger outbound func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, Model: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, }, } msgBus := bus.NewMessageBus() provider := &simpleMockProvider{response: "Command output: hello world"} al := NewAgentLoop(cfg, msgBus, provider) helper := testHelper{al: al} // ExecTool returns UserResult, which should send user message ctx := context.Background() msg := bus.InboundMessage{ Channel: "test", SenderID: "user1", ChatID: "chat1", Content: "run hello", SessionKey: "test-session", } response := helper.executeAndGetResponse(t, ctx, msg) // User-facing tool should include the output in final response if response != "Command output: hello world" { t.Errorf("Expected 'Command output: hello world', got: %s", response) } } // failFirstMockProvider fails on the first N calls with a specific error type failFirstMockProvider struct { failures int currentCall int failError error successResp string } func (m *failFirstMockProvider) Chat( ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]any, ) (*providers.LLMResponse, error) { m.currentCall++ if m.currentCall <= m.failures { return nil, m.failError } return &providers.LLMResponse{ Content: m.successResp, ToolCalls: []providers.ToolCall{}, }, nil } func (m *failFirstMockProvider) GetDefaultModel() string { return "mock-fail-model" } // TestAgentLoop_ContextExhaustionRetry verify that the agent retries on context errors func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, Model: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, }, } msgBus := bus.NewMessageBus() // Create a provider that fails once with a context error contextErr := fmt.Errorf("InvalidParameter: Total tokens of image and text exceed max message tokens") provider := &failFirstMockProvider{ failures: 1, failError: contextErr, successResp: "Recovered from context error", } al := NewAgentLoop(cfg, msgBus, provider) // Inject some history to simulate a full context sessionKey := "test-session-context" // Create dummy history history := []providers.Message{ {Role: "system", Content: "System prompt"}, {Role: "user", Content: "Old message 1"}, {Role: "assistant", Content: "Old response 1"}, {Role: "user", Content: "Old message 2"}, {Role: "assistant", Content: "Old response 2"}, {Role: "user", Content: "Trigger message"}, } defaultAgent := al.registry.GetDefaultAgent() if defaultAgent == nil { t.Fatal("No default agent found") } defaultAgent.Sessions.SetHistory(sessionKey, history) // Call ProcessDirectWithChannel // Note: ProcessDirectWithChannel calls processMessage which will execute runLLMIteration response, err := al.ProcessDirectWithChannel( context.Background(), "Trigger message", sessionKey, "test", "test-chat", ) if err != nil { t.Fatalf("Expected success after retry, got error: %v", err) } if response != "Recovered from context error" { t.Errorf("Expected 'Recovered from context error', got '%s'", response) } // We expect 2 calls: 1st failed, 2nd succeeded if provider.currentCall != 2 { t.Errorf("Expected 2 calls (1 fail + 1 success), got %d", provider.currentCall) } // Check final history length finalHistory := defaultAgent.Sessions.GetHistory(sessionKey) // We verify that the history has been modified (compressed) // Original length: 6 // Expected behavior: compression drops ~50% of history (mid slice) // We can assert that the length is NOT what it would be without compression. // Without compression: 6 + 1 (new user msg) + 1 (assistant msg) = 8 if len(finalHistory) >= 8 { t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory)) } } // TestProcessDirectWithChannel_TriggersMCPInitialization verifies that // ProcessDirectWithChannel triggers MCP initialization when MCP is enabled. // Note: Manager is only initialized when at least one MCP server is configured // and successfully connected. func TestProcessDirectWithChannel_TriggersMCPInitialization(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) // Test with MCP enabled but no servers - should not initialize manager cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, Model: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, }, Tools: config.ToolsConfig{ MCP: config.MCPConfig{ ToolConfig: config.ToolConfig{ Enabled: true, }, // No servers configured - manager should not be initialized }, }, } msgBus := bus.NewMessageBus() provider := &mockProvider{} al := NewAgentLoop(cfg, msgBus, provider) defer al.Close() if al.mcp.hasManager() { t.Fatal("expected MCP manager to be nil before first direct processing") } _, err = al.ProcessDirectWithChannel( context.Background(), "hello", "session-1", "cli", "direct", ) if err != nil { t.Fatalf("ProcessDirectWithChannel failed: %v", err) } // Manager should not be initialized when no servers are configured if al.mcp.hasManager() { t.Fatal("expected MCP manager to be nil when no servers are configured") } } func TestTargetReasoningChannelID_AllChannels(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, Model: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, }, } al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{}) chManager, err := channels.NewManager(&config.Config{}, bus.NewMessageBus(), nil) if err != nil { t.Fatalf("Failed to create channel manager: %v", err) } for name, id := range map[string]string{ "whatsapp": "rid-whatsapp", "telegram": "rid-telegram", "feishu": "rid-feishu", "discord": "rid-discord", "maixcam": "rid-maixcam", "qq": "rid-qq", "dingtalk": "rid-dingtalk", "slack": "rid-slack", "line": "rid-line", "onebot": "rid-onebot", "wecom": "rid-wecom", "wecom_app": "rid-wecom-app", } { chManager.RegisterChannel(name, &fakeChannel{id: id}) } al.SetChannelManager(chManager) tests := []struct { channel string wantID string }{ {channel: "whatsapp", wantID: "rid-whatsapp"}, {channel: "telegram", wantID: "rid-telegram"}, {channel: "feishu", wantID: "rid-feishu"}, {channel: "discord", wantID: "rid-discord"}, {channel: "maixcam", wantID: "rid-maixcam"}, {channel: "qq", wantID: "rid-qq"}, {channel: "dingtalk", wantID: "rid-dingtalk"}, {channel: "slack", wantID: "rid-slack"}, {channel: "line", wantID: "rid-line"}, {channel: "onebot", wantID: "rid-onebot"}, {channel: "wecom", wantID: "rid-wecom"}, {channel: "wecom_app", wantID: "rid-wecom-app"}, {channel: "unknown", wantID: ""}, } for _, tt := range tests { t.Run(tt.channel, func(t *testing.T) { got := al.targetReasoningChannelID(tt.channel) if got != tt.wantID { t.Fatalf("targetReasoningChannelID(%q) = %q, want %q", tt.channel, got, tt.wantID) } }) } } func TestHandleReasoning(t *testing.T) { newLoop := func(t *testing.T) (*AgentLoop, *bus.MessageBus) { t.Helper() tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } t.Cleanup(func() { _ = os.RemoveAll(tmpDir) }) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, Model: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, }, } msgBus := bus.NewMessageBus() return NewAgentLoop(cfg, msgBus, &mockProvider{}), msgBus } t.Run("skips when any required field is empty", func(t *testing.T) { al, msgBus := newLoop(t) al.handleReasoning(context.Background(), "reasoning", "telegram", "") ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() for { select { case msg, ok := <-msgBus.OutboundChan(): if !ok { t.Fatalf("expected no outbound message, got %+v", msg) } if msg.Content == "reasoning" { t.Fatalf("expected no message for empty chatID, got %+v", msg) } return case <-ctx.Done(): t.Log("expected an outbound message, got none within timeout") return default: // Continue to check for message time.Sleep(5 * time.Millisecond) // Avoid busy loop } } }) t.Run("publishes one message for non telegram", func(t *testing.T) { al, msgBus := newLoop(t) al.handleReasoning(context.Background(), "hello reasoning", "slack", "channel-1") msg, ok := <-msgBus.OutboundChan() if !ok { t.Fatal("expected an outbound message") } if msg.Channel != "slack" || msg.ChatID != "channel-1" || msg.Content != "hello reasoning" { t.Fatalf("unexpected outbound message: %+v", msg) } }) t.Run("publishes one message for telegram", func(t *testing.T) { al, msgBus := newLoop(t) reasoning := "hello telegram reasoning" al.handleReasoning(context.Background(), reasoning, "telegram", "tg-chat") ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() for { select { case <-ctx.Done(): t.Fatal("expected an outbound message, got none within timeout") return case msg, ok := <-msgBus.OutboundChan(): if !ok { t.Fatal("expected outbound message") } if msg.Channel != "telegram" { t.Fatalf("expected telegram channel message, got %+v", msg) } if msg.ChatID != "tg-chat" { t.Fatalf("expected chatID tg-chat, got %+v", msg) } if msg.Content != reasoning { t.Fatalf("content mismatch: got %q want %q", msg.Content, reasoning) } return } } }) t.Run("expired ctx", func(t *testing.T) { al, msgBus := newLoop(t) reasoning := "hello telegram reasoning" al.handleReasoning(context.Background(), reasoning, "telegram", "tg-chat") consumeCtx, consumeCancel := context.WithTimeout(context.Background(), 2*time.Second) defer consumeCancel() for { select { case msg, ok := <-msgBus.OutboundChan(): if !ok { t.Fatalf("expected no outbound message, but received: %+v", msg) } t.Logf("Received unexpected outbound message: %+v", msg) return case <-consumeCtx.Done(): t.Fatalf("failed: no message received within timeout") return } } }) t.Run("returns promptly when bus is full", func(t *testing.T) { al, msgBus := newLoop(t) // Fill the outbound bus buffer until a publish would block. // Use a short timeout to detect when the buffer is full, // rather than hardcoding the buffer size. for i := 0; ; i++ { fillCtx, fillCancel := context.WithTimeout(context.Background(), 50*time.Millisecond) err := msgBus.PublishOutbound(fillCtx, bus.OutboundMessage{ Channel: "filler", ChatID: "filler", Content: fmt.Sprintf("filler-%d", i), }) fillCancel() if err != nil { // Buffer is full (timed out trying to send). break } } // Use a short-deadline parent context to bound the test. ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) defer cancel() start := time.Now() al.handleReasoning(ctx, "should timeout", "slack", "channel-full") elapsed := time.Since(start) // handleReasoning uses a 5s internal timeout, but the parent ctx // expires in 500ms. It should return within ~500ms, not 5s. if elapsed > 2*time.Second { t.Fatalf("handleReasoning blocked too long (%v); expected prompt return", elapsed) } // Drain the bus and verify the reasoning message was NOT published // (it should have been dropped due to timeout). timeer := time.After(1 * time.Second) for { select { case <-timeer: t.Logf( "no reasoning message received after draining bus for 1s, as expected,length=%d", len(msgBus.OutboundChan()), ) return case msg, ok := <-msgBus.OutboundChan(): if !ok { break } if msg.Content == "should timeout" { t.Fatal("expected reasoning message to be dropped when bus is full, but it was published") } } } }) } func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir() // Create a minimal valid PNG (8-byte header is enough for filetype detection) pngPath := filepath.Join(dir, "test.png") // PNG magic: 0x89 P N G \r \n 0x1A \n + minimal IHDR pngHeader := []byte{ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature 0x00, 0x00, 0x00, 0x0D, // IHDR length 0x49, 0x48, 0x44, 0x52, // "IHDR" 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, // 1x1 RGB 0x00, 0x00, 0x00, // no interlace 0x90, 0x77, 0x53, 0xDE, // CRC } if err := os.WriteFile(pngPath, pngHeader, 0o644); err != nil { t.Fatal(err) } ref, err := store.Store(pngPath, media.MediaMeta{}, "test") if err != nil { t.Fatal(err) } messages := []providers.Message{ {Role: "user", Content: "describe this", Media: []string{ref}}, } result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) if len(result[0].Media) != 1 { t.Fatalf("expected 1 resolved media, got %d", len(result[0].Media)) } if !strings.HasPrefix(result[0].Media[0], "data:image/png;base64,") { t.Fatalf("expected data:image/png;base64, prefix, got %q", result[0].Media[0][:40]) } } func TestResolveMediaRefs_SkipsOversizedFile(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir() bigPath := filepath.Join(dir, "big.png") // Write PNG header + padding to exceed limit data := make([]byte, 1024+1) // 1KB + 1 byte copy(data, []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) if err := os.WriteFile(bigPath, data, 0o644); err != nil { t.Fatal(err) } ref, _ := store.Store(bigPath, media.MediaMeta{}, "test") messages := []providers.Message{ {Role: "user", Content: "hi", Media: []string{ref}}, } // Use a tiny limit (1KB) so the file is oversized result := resolveMediaRefs(messages, store, 1024) if len(result[0].Media) != 0 { t.Fatalf("expected 0 media (oversized), got %d", len(result[0].Media)) } } func TestResolveMediaRefs_UnknownTypeInjectsPath(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir() txtPath := filepath.Join(dir, "readme.txt") if err := os.WriteFile(txtPath, []byte("hello world"), 0o644); err != nil { t.Fatal(err) } ref, _ := store.Store(txtPath, media.MediaMeta{}, "test") messages := []providers.Message{ {Role: "user", Content: "hi", Media: []string{ref}}, } result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) if len(result[0].Media) != 0 { t.Fatalf("expected 0 media entries, got %d", len(result[0].Media)) } expected := "hi [file:" + txtPath + "]" if result[0].Content != expected { t.Fatalf("expected content %q, got %q", expected, result[0].Content) } } func TestResolveMediaRefs_PassesThroughNonMediaRefs(t *testing.T) { messages := []providers.Message{ {Role: "user", Content: "hi", Media: []string{"https://example.com/img.png"}}, } result := resolveMediaRefs(messages, nil, config.DefaultMaxMediaSize) if len(result[0].Media) != 1 || result[0].Media[0] != "https://example.com/img.png" { t.Fatalf("expected passthrough of non-media:// URL, got %v", result[0].Media) } } func TestResolveMediaRefs_DoesNotMutateOriginal(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir() pngPath := filepath.Join(dir, "test.png") pngHeader := []byte{ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, } os.WriteFile(pngPath, pngHeader, 0o644) ref, _ := store.Store(pngPath, media.MediaMeta{}, "test") original := []providers.Message{ {Role: "user", Content: "hi", Media: []string{ref}}, } originalRef := original[0].Media[0] resolveMediaRefs(original, store, config.DefaultMaxMediaSize) if original[0].Media[0] != originalRef { t.Fatal("resolveMediaRefs mutated original message slice") } } func TestResolveMediaRefs_UsesMetaContentType(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir() // File with JPEG content but stored with explicit content type jpegPath := filepath.Join(dir, "photo") jpegHeader := []byte{0xFF, 0xD8, 0xFF, 0xE0} // JPEG magic bytes os.WriteFile(jpegPath, jpegHeader, 0o644) ref, _ := store.Store(jpegPath, media.MediaMeta{ContentType: "image/jpeg"}, "test") messages := []providers.Message{ {Role: "user", Content: "hi", Media: []string{ref}}, } result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) if len(result[0].Media) != 1 { t.Fatalf("expected 1 media, got %d", len(result[0].Media)) } if !strings.HasPrefix(result[0].Media[0], "data:image/jpeg;base64,") { t.Fatalf("expected jpeg prefix, got %q", result[0].Media[0][:30]) } } func TestResolveMediaRefs_PDFInjectsFilePath(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir() pdfPath := filepath.Join(dir, "report.pdf") // PDF magic bytes os.WriteFile(pdfPath, []byte("%PDF-1.4 test content"), 0o644) ref, _ := store.Store(pdfPath, media.MediaMeta{ContentType: "application/pdf"}, "test") messages := []providers.Message{ {Role: "user", Content: "report.pdf [file]", Media: []string{ref}}, } result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) if len(result[0].Media) != 0 { t.Fatalf("expected 0 media (non-image), got %d", len(result[0].Media)) } expected := "report.pdf [file:" + pdfPath + "]" if result[0].Content != expected { t.Fatalf("expected content %q, got %q", expected, result[0].Content) } } func TestResolveMediaRefs_AudioInjectsAudioPath(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir() oggPath := filepath.Join(dir, "voice.ogg") os.WriteFile(oggPath, []byte("fake audio"), 0o644) ref, _ := store.Store(oggPath, media.MediaMeta{ContentType: "audio/ogg"}, "test") messages := []providers.Message{ {Role: "user", Content: "voice.ogg [audio]", Media: []string{ref}}, } result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) if len(result[0].Media) != 0 { t.Fatalf("expected 0 media, got %d", len(result[0].Media)) } expected := "voice.ogg [audio:" + oggPath + "]" if result[0].Content != expected { t.Fatalf("expected content %q, got %q", expected, result[0].Content) } } func TestResolveMediaRefs_VideoInjectsVideoPath(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir() mp4Path := filepath.Join(dir, "clip.mp4") os.WriteFile(mp4Path, []byte("fake video"), 0o644) ref, _ := store.Store(mp4Path, media.MediaMeta{ContentType: "video/mp4"}, "test") messages := []providers.Message{ {Role: "user", Content: "clip.mp4 [video]", Media: []string{ref}}, } result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) if len(result[0].Media) != 0 { t.Fatalf("expected 0 media, got %d", len(result[0].Media)) } expected := "clip.mp4 [video:" + mp4Path + "]" if result[0].Content != expected { t.Fatalf("expected content %q, got %q", expected, result[0].Content) } } func TestResolveMediaRefs_NoGenericTagAppendsPath(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir() csvPath := filepath.Join(dir, "data.csv") os.WriteFile(csvPath, []byte("a,b,c"), 0o644) ref, _ := store.Store(csvPath, media.MediaMeta{ContentType: "text/csv"}, "test") messages := []providers.Message{ {Role: "user", Content: "here is my data", Media: []string{ref}}, } result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) expected := "here is my data [file:" + csvPath + "]" if result[0].Content != expected { t.Fatalf("expected content %q, got %q", expected, result[0].Content) } } func TestResolveMediaRefs_EmptyContentGetsPathTag(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir() docPath := filepath.Join(dir, "doc.docx") os.WriteFile(docPath, []byte("fake docx"), 0o644) docxMIME := "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ref, _ := store.Store(docPath, media.MediaMeta{ContentType: docxMIME}, "test") messages := []providers.Message{ {Role: "user", Content: "", Media: []string{ref}}, } result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) expected := "[file:" + docPath + "]" if result[0].Content != expected { t.Fatalf("expected content %q, got %q", expected, result[0].Content) } } func TestResolveMediaRefs_MixedImageAndFile(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir() pngPath := filepath.Join(dir, "photo.png") pngHeader := []byte{ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, } os.WriteFile(pngPath, pngHeader, 0o644) imgRef, _ := store.Store(pngPath, media.MediaMeta{}, "test") pdfPath := filepath.Join(dir, "report.pdf") os.WriteFile(pdfPath, []byte("%PDF-1.4 test"), 0o644) fileRef, _ := store.Store(pdfPath, media.MediaMeta{ContentType: "application/pdf"}, "test") messages := []providers.Message{ {Role: "user", Content: "check these [file]", Media: []string{imgRef, fileRef}}, } result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) if len(result[0].Media) != 1 { t.Fatalf("expected 1 media (image only), got %d", len(result[0].Media)) } if !strings.HasPrefix(result[0].Media[0], "data:image/png;base64,") { t.Fatal("expected image to be base64 encoded") } expectedContent := "check these [file:" + pdfPath + "]" if result[0].Content != expectedContent { t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content) } } // --- Native search helper tests --- type nativeSearchProvider struct { supported bool } func (p *nativeSearchProvider) Chat( ctx context.Context, msgs []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]any, ) (*providers.LLMResponse, error) { return &providers.LLMResponse{Content: "ok"}, nil } func (p *nativeSearchProvider) GetDefaultModel() string { return "test-model" } func (p *nativeSearchProvider) SupportsNativeSearch() bool { return p.supported } type plainProvider struct{} func (p *plainProvider) Chat( ctx context.Context, msgs []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]any, ) (*providers.LLMResponse, error) { return &providers.LLMResponse{Content: "ok"}, nil } func (p *plainProvider) GetDefaultModel() string { return "test-model" } func TestIsNativeSearchProvider_Supported(t *testing.T) { if !isNativeSearchProvider(&nativeSearchProvider{supported: true}) { t.Fatal("expected true for provider that supports native search") } } func TestIsNativeSearchProvider_NotSupported(t *testing.T) { if isNativeSearchProvider(&nativeSearchProvider{supported: false}) { t.Fatal("expected false for provider that does not support native search") } } func TestIsNativeSearchProvider_NoInterface(t *testing.T) { if isNativeSearchProvider(&plainProvider{}) { t.Fatal("expected false for provider that does not implement NativeSearchCapable") } } func TestFilterClientWebSearch_RemovesWebSearch(t *testing.T) { defs := []providers.ToolDefinition{ {Type: "function", Function: providers.ToolFunctionDefinition{Name: "web_search"}}, {Type: "function", Function: providers.ToolFunctionDefinition{Name: "read_file"}}, {Type: "function", Function: providers.ToolFunctionDefinition{Name: "exec"}}, } result := filterClientWebSearch(defs) if len(result) != 2 { t.Fatalf("len(result) = %d, want 2", len(result)) } for _, td := range result { if td.Function.Name == "web_search" { t.Fatal("web_search should be filtered out") } } } func TestFilterClientWebSearch_NoWebSearch(t *testing.T) { defs := []providers.ToolDefinition{ {Type: "function", Function: providers.ToolFunctionDefinition{Name: "read_file"}}, {Type: "function", Function: providers.ToolFunctionDefinition{Name: "exec"}}, } result := filterClientWebSearch(defs) if len(result) != 2 { t.Fatalf("len(result) = %d, want 2", len(result)) } } func TestFilterClientWebSearch_EmptyInput(t *testing.T) { result := filterClientWebSearch(nil) if len(result) != 0 { t.Fatalf("len(result) = %d, want 0", len(result)) } } ================================================ FILE: pkg/agent/memory.go ================================================ // PicoClaw - Ultra-lightweight personal AI agent // Inspired by and based on nanobot: https://github.com/HKUDS/nanobot // License: MIT // // Copyright (c) 2026 PicoClaw contributors package agent import ( "fmt" "os" "path/filepath" "strings" "time" "github.com/sipeed/picoclaw/pkg/fileutil" ) // MemoryStore manages persistent memory for the agent. // - Long-term memory: memory/MEMORY.md // - Daily notes: memory/YYYYMM/YYYYMMDD.md type MemoryStore struct { workspace string memoryDir string memoryFile string } // NewMemoryStore creates a new MemoryStore with the given workspace path. // It ensures the memory directory exists. func NewMemoryStore(workspace string) *MemoryStore { memoryDir := filepath.Join(workspace, "memory") memoryFile := filepath.Join(memoryDir, "MEMORY.md") // Ensure memory directory exists os.MkdirAll(memoryDir, 0o755) return &MemoryStore{ workspace: workspace, memoryDir: memoryDir, memoryFile: memoryFile, } } // getTodayFile returns the path to today's daily note file (memory/YYYYMM/YYYYMMDD.md). func (ms *MemoryStore) getTodayFile() string { today := time.Now().Format("20060102") // YYYYMMDD monthDir := today[:6] // YYYYMM filePath := filepath.Join(ms.memoryDir, monthDir, today+".md") return filePath } // ReadLongTerm reads the long-term memory (MEMORY.md). // Returns empty string if the file doesn't exist. func (ms *MemoryStore) ReadLongTerm() string { if data, err := os.ReadFile(ms.memoryFile); err == nil { return string(data) } return "" } // WriteLongTerm writes content to the long-term memory file (MEMORY.md). func (ms *MemoryStore) WriteLongTerm(content string) error { // Use unified atomic write utility with explicit sync for flash storage reliability. // Using 0o600 (owner read/write only) for secure default permissions. return fileutil.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600) } // ReadToday reads today's daily note. // Returns empty string if the file doesn't exist. func (ms *MemoryStore) ReadToday() string { todayFile := ms.getTodayFile() if data, err := os.ReadFile(todayFile); err == nil { return string(data) } return "" } // AppendToday appends content to today's daily note. // If the file doesn't exist, it creates a new file with a date header. func (ms *MemoryStore) AppendToday(content string) error { todayFile := ms.getTodayFile() // Ensure month directory exists monthDir := filepath.Dir(todayFile) if err := os.MkdirAll(monthDir, 0o755); err != nil { return err } var existingContent string if data, err := os.ReadFile(todayFile); err == nil { existingContent = string(data) } var newContent string if existingContent == "" { // Add header for new day header := fmt.Sprintf("# %s\n\n", time.Now().Format("2006-01-02")) newContent = header + content } else { // Append to existing content newContent = existingContent + "\n" + content } // Use unified atomic write utility with explicit sync for flash storage reliability. return fileutil.WriteFileAtomic(todayFile, []byte(newContent), 0o600) } // GetRecentDailyNotes returns daily notes from the last N days. // Contents are joined with "---" separator. func (ms *MemoryStore) GetRecentDailyNotes(days int) string { var sb strings.Builder first := true for i := range days { date := time.Now().AddDate(0, 0, -i) dateStr := date.Format("20060102") // YYYYMMDD monthDir := dateStr[:6] // YYYYMM filePath := filepath.Join(ms.memoryDir, monthDir, dateStr+".md") if data, err := os.ReadFile(filePath); err == nil { if !first { sb.WriteString("\n\n---\n\n") } sb.Write(data) first = false } } return sb.String() } // GetMemoryContext returns formatted memory context for the agent prompt. // Includes long-term memory and recent daily notes. func (ms *MemoryStore) GetMemoryContext() string { longTerm := ms.ReadLongTerm() recentNotes := ms.GetRecentDailyNotes(3) if longTerm == "" && recentNotes == "" { return "" } var sb strings.Builder if longTerm != "" { sb.WriteString("## Long-term Memory\n\n") sb.WriteString(longTerm) } if recentNotes != "" { if longTerm != "" { sb.WriteString("\n\n---\n\n") } sb.WriteString("## Recent Daily Notes\n\n") sb.WriteString(recentNotes) } return sb.String() } ================================================ FILE: pkg/agent/mock_provider_test.go ================================================ package agent import ( "context" "github.com/sipeed/picoclaw/pkg/providers" ) type mockProvider struct{} func (m *mockProvider) Chat( ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]any, ) (*providers.LLMResponse, error) { return &providers.LLMResponse{ Content: "Mock response", ToolCalls: []providers.ToolCall{}, }, nil } func (m *mockProvider) GetDefaultModel() string { return "mock-model" } ================================================ FILE: pkg/agent/model_resolution.go ================================================ package agent import ( "fmt" "strings" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" ) func buildModelListResolver(cfg *config.Config) func(raw string) (string, bool) { ensureProtocol := func(model string) string { model = strings.TrimSpace(model) if model == "" { return "" } if strings.Contains(model, "/") { return model } return "openai/" + model } return func(raw string) (string, bool) { raw = strings.TrimSpace(raw) if raw == "" || cfg == nil { return "", false } if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" { return ensureProtocol(mc.Model), true } for i := range cfg.ModelList { fullModel := strings.TrimSpace(cfg.ModelList[i].Model) if fullModel == "" { continue } if fullModel == raw { return ensureProtocol(fullModel), true } _, modelID := providers.ExtractProtocol(fullModel) if modelID == raw { return ensureProtocol(fullModel), true } } return "", false } } func resolveModelCandidates( cfg *config.Config, defaultProvider string, primary string, fallbacks []string, ) []providers.FallbackCandidate { return providers.ResolveCandidatesWithLookup( providers.ModelConfig{ Primary: primary, Fallbacks: fallbacks, }, defaultProvider, buildModelListResolver(cfg), ) } func resolvedCandidateModel(candidates []providers.FallbackCandidate, fallback string) string { if len(candidates) > 0 && strings.TrimSpace(candidates[0].Model) != "" { return candidates[0].Model } return fallback } func resolvedCandidateProvider(candidates []providers.FallbackCandidate, fallback string) string { if len(candidates) > 0 && strings.TrimSpace(candidates[0].Provider) != "" { return candidates[0].Provider } return fallback } func resolvedModelConfig(cfg *config.Config, modelName, workspace string) (*config.ModelConfig, error) { if cfg == nil { return nil, fmt.Errorf("config is nil") } modelCfg, err := cfg.GetModelConfig(strings.TrimSpace(modelName)) if err != nil { return nil, err } clone := *modelCfg if clone.Workspace == "" { clone.Workspace = workspace } return &clone, nil } ================================================ FILE: pkg/agent/registry.go ================================================ package agent import ( "sync" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/tools" ) // AgentRegistry manages multiple agent instances and routes messages to them. type AgentRegistry struct { agents map[string]*AgentInstance resolver *routing.RouteResolver mu sync.RWMutex } // NewAgentRegistry creates a registry from config, instantiating all agents. func NewAgentRegistry( cfg *config.Config, provider providers.LLMProvider, ) *AgentRegistry { registry := &AgentRegistry{ agents: make(map[string]*AgentInstance), resolver: routing.NewRouteResolver(cfg), } agentConfigs := cfg.Agents.List if len(agentConfigs) == 0 { implicitAgent := &config.AgentConfig{ ID: "main", Default: true, } instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider) registry.agents["main"] = instance logger.InfoCF("agent", "Created implicit main agent (no agents.list configured)", nil) } else { for i := range agentConfigs { ac := &agentConfigs[i] id := routing.NormalizeAgentID(ac.ID) instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider) registry.agents[id] = instance logger.InfoCF("agent", "Registered agent", map[string]any{ "agent_id": id, "name": ac.Name, "workspace": instance.Workspace, "model": instance.Model, }) } } return registry } // GetAgent returns the agent instance for a given ID. func (r *AgentRegistry) GetAgent(agentID string) (*AgentInstance, bool) { r.mu.RLock() defer r.mu.RUnlock() id := routing.NormalizeAgentID(agentID) agent, ok := r.agents[id] return agent, ok } // ResolveRoute determines which agent handles the message. func (r *AgentRegistry) ResolveRoute(input routing.RouteInput) routing.ResolvedRoute { return r.resolver.ResolveRoute(input) } // ListAgentIDs returns all registered agent IDs. func (r *AgentRegistry) ListAgentIDs() []string { r.mu.RLock() defer r.mu.RUnlock() ids := make([]string, 0, len(r.agents)) for id := range r.agents { ids = append(ids, id) } return ids } // CanSpawnSubagent checks if parentAgentID is allowed to spawn targetAgentID. func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bool { parent, ok := r.GetAgent(parentAgentID) if !ok { return false } if parent.Subagents == nil || parent.Subagents.AllowAgents == nil { return false } targetNorm := routing.NormalizeAgentID(targetAgentID) for _, allowed := range parent.Subagents.AllowAgents { if allowed == "*" { return true } if routing.NormalizeAgentID(allowed) == targetNorm { return true } } return false } // ForEachTool calls fn for every tool registered under the given name // across all agents. This is useful for propagating dependencies (e.g. // MediaStore) to tools after registry construction. func (r *AgentRegistry) ForEachTool(name string, fn func(tools.Tool)) { r.mu.RLock() defer r.mu.RUnlock() for _, agent := range r.agents { if t, ok := agent.Tools.Get(name); ok { fn(t) } } } // Close releases resources held by all registered agents. func (r *AgentRegistry) Close() { r.mu.RLock() defer r.mu.RUnlock() for _, agent := range r.agents { if err := agent.Close(); err != nil { logger.WarnCF("agent", "Failed to close agent", map[string]any{"agent_id": agent.ID, "error": err.Error()}) } } } // GetDefaultAgent returns the default agent instance. func (r *AgentRegistry) GetDefaultAgent() *AgentInstance { r.mu.RLock() defer r.mu.RUnlock() if agent, ok := r.agents["main"]; ok { return agent } for _, agent := range r.agents { return agent } return nil } ================================================ FILE: pkg/agent/registry_test.go ================================================ package agent import ( "context" "testing" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" ) type mockRegistryProvider struct{} func (m *mockRegistryProvider) Chat( ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]any, ) (*providers.LLMResponse, error) { return &providers.LLMResponse{Content: "mock", FinishReason: "stop"}, nil } func (m *mockRegistryProvider) GetDefaultModel() string { return "mock-model" } func testCfg(agents []config.AgentConfig) *config.Config { return &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: "/tmp/picoclaw-test-registry", Model: "gpt-4", MaxTokens: 8192, MaxToolIterations: 10, }, List: agents, }, } } func TestNewAgentRegistry_ImplicitMain(t *testing.T) { cfg := testCfg(nil) registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) ids := registry.ListAgentIDs() if len(ids) != 1 || ids[0] != "main" { t.Errorf("expected implicit main agent, got %v", ids) } agent, ok := registry.GetAgent("main") if !ok || agent == nil { t.Fatal("expected to find 'main' agent") } if agent.ID != "main" { t.Errorf("agent.ID = %q, want 'main'", agent.ID) } } func TestNewAgentRegistry_ExplicitAgents(t *testing.T) { cfg := testCfg([]config.AgentConfig{ {ID: "sales", Default: true, Name: "Sales Bot"}, {ID: "support", Name: "Support Bot"}, }) registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) ids := registry.ListAgentIDs() if len(ids) != 2 { t.Fatalf("expected 2 agents, got %d: %v", len(ids), ids) } sales, ok := registry.GetAgent("sales") if !ok || sales == nil { t.Fatal("expected to find 'sales' agent") } if sales.Name != "Sales Bot" { t.Errorf("sales.Name = %q, want 'Sales Bot'", sales.Name) } support, ok := registry.GetAgent("support") if !ok || support == nil { t.Fatal("expected to find 'support' agent") } } func TestAgentRegistry_GetAgent_Normalize(t *testing.T) { cfg := testCfg([]config.AgentConfig{ {ID: "my-agent", Default: true}, }) registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) agent, ok := registry.GetAgent("My-Agent") if !ok || agent == nil { t.Fatal("expected to find agent with normalized ID") } if agent.ID != "my-agent" { t.Errorf("agent.ID = %q, want 'my-agent'", agent.ID) } } func TestAgentRegistry_GetDefaultAgent(t *testing.T) { cfg := testCfg([]config.AgentConfig{ {ID: "alpha"}, {ID: "beta", Default: true}, }) registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) // GetDefaultAgent first checks for "main", then returns any agent := registry.GetDefaultAgent() if agent == nil { t.Fatal("expected a default agent") } } func TestAgentRegistry_CanSpawnSubagent(t *testing.T) { cfg := testCfg([]config.AgentConfig{ { ID: "parent", Default: true, Subagents: &config.SubagentsConfig{ AllowAgents: []string{"child1", "child2"}, }, }, {ID: "child1"}, {ID: "child2"}, {ID: "restricted"}, }) registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) if !registry.CanSpawnSubagent("parent", "child1") { t.Error("expected parent to be allowed to spawn child1") } if !registry.CanSpawnSubagent("parent", "child2") { t.Error("expected parent to be allowed to spawn child2") } if registry.CanSpawnSubagent("parent", "restricted") { t.Error("expected parent to NOT be allowed to spawn restricted") } if registry.CanSpawnSubagent("child1", "child2") { t.Error("expected child1 to NOT be allowed to spawn (no subagents config)") } } func TestAgentRegistry_CanSpawnSubagent_Wildcard(t *testing.T) { cfg := testCfg([]config.AgentConfig{ { ID: "admin", Default: true, Subagents: &config.SubagentsConfig{ AllowAgents: []string{"*"}, }, }, {ID: "any-agent"}, }) registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) if !registry.CanSpawnSubagent("admin", "any-agent") { t.Error("expected wildcard to allow spawning any agent") } if !registry.CanSpawnSubagent("admin", "nonexistent") { t.Error("expected wildcard to allow spawning even nonexistent agents") } } func TestAgentInstance_Model(t *testing.T) { model := &config.AgentModelConfig{Primary: "claude-opus"} cfg := testCfg([]config.AgentConfig{ {ID: "custom", Default: true, Model: model}, }) registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) agent, _ := registry.GetAgent("custom") if agent.Model != "claude-opus" { t.Errorf("agent.Model = %q, want 'claude-opus'", agent.Model) } } func TestAgentInstance_FallbackInheritance(t *testing.T) { cfg := testCfg([]config.AgentConfig{ {ID: "inherit", Default: true}, }) cfg.Agents.Defaults.ModelFallbacks = []string{"openai/gpt-4o-mini", "anthropic/haiku"} registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) agent, _ := registry.GetAgent("inherit") if len(agent.Fallbacks) != 2 { t.Errorf("expected 2 fallbacks inherited from defaults, got %d", len(agent.Fallbacks)) } } func TestAgentInstance_FallbackExplicitEmpty(t *testing.T) { model := &config.AgentModelConfig{ Primary: "gpt-4", Fallbacks: []string{}, // explicitly empty = disable } cfg := testCfg([]config.AgentConfig{ {ID: "no-fallback", Default: true, Model: model}, }) cfg.Agents.Defaults.ModelFallbacks = []string{"should-not-inherit"} registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) agent, _ := registry.GetAgent("no-fallback") if len(agent.Fallbacks) != 0 { t.Errorf("expected 0 fallbacks (explicit empty), got %d: %v", len(agent.Fallbacks), agent.Fallbacks) } } ================================================ FILE: pkg/agent/thinking.go ================================================ package agent import "strings" // ThinkingLevel controls how the provider sends thinking parameters. // // - "adaptive": sends {thinking: {type: "adaptive"}} + output_config.effort (Claude 4.6+) // - "low"/"medium"/"high"/"xhigh": sends {thinking: {type: "enabled", budget_tokens: N}} (all models) // - "off": disables thinking type ThinkingLevel string const ( ThinkingOff ThinkingLevel = "off" ThinkingLow ThinkingLevel = "low" ThinkingMedium ThinkingLevel = "medium" ThinkingHigh ThinkingLevel = "high" ThinkingXHigh ThinkingLevel = "xhigh" ThinkingAdaptive ThinkingLevel = "adaptive" ) // parseThinkingLevel normalizes a config string to a ThinkingLevel. // Case-insensitive and whitespace-tolerant for user-facing config values. // Returns ThinkingOff for unknown or empty values. func parseThinkingLevel(level string) ThinkingLevel { switch strings.ToLower(strings.TrimSpace(level)) { case "adaptive": return ThinkingAdaptive case "low": return ThinkingLow case "medium": return ThinkingMedium case "high": return ThinkingHigh case "xhigh": return ThinkingXHigh default: return ThinkingOff } } ================================================ FILE: pkg/agent/thinking_test.go ================================================ package agent import "testing" func TestParseThinkingLevel(t *testing.T) { tests := []struct { name string input string want ThinkingLevel }{ {"off", "off", ThinkingOff}, {"empty", "", ThinkingOff}, {"low", "low", ThinkingLow}, {"medium", "medium", ThinkingMedium}, {"high", "high", ThinkingHigh}, {"xhigh", "xhigh", ThinkingXHigh}, {"adaptive", "adaptive", ThinkingAdaptive}, {"unknown", "unknown", ThinkingOff}, // Case-insensitive and whitespace-tolerant {"upper_Medium", "Medium", ThinkingMedium}, {"upper_HIGH", "HIGH", ThinkingHigh}, {"mixed_Adaptive", "Adaptive", ThinkingAdaptive}, {"leading_space", " high", ThinkingHigh}, {"trailing_space", "low ", ThinkingLow}, {"both_spaces", " medium ", ThinkingMedium}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := parseThinkingLevel(tt.input); got != tt.want { t.Errorf("parseThinkingLevel(%q) = %q, want %q", tt.input, got, tt.want) } }) } } ================================================ FILE: pkg/auth/anthropic_usage.go ================================================ package auth import ( "encoding/json" "fmt" "io" "net/http" "time" ) const ( anthropicBetaHeader = "oauth-2025-04-20" anthropicAPIVersion = "2023-06-01" ) // anthropicUsageURL is the endpoint for fetching OAuth usage stats. // It is a var (not const) to allow overriding in tests. var anthropicUsageURL = "https://api.anthropic.com/api/oauth/usage" func setAnthropicUsageURL(url string) { anthropicUsageURL = url } type AnthropicUsage struct { FiveHourUtilization float64 SevenDayUtilization float64 } func FetchAnthropicUsage(token string) (*AnthropicUsage, error) { req, err := http.NewRequest("GET", anthropicUsageURL, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Anthropic-Version", anthropicAPIVersion) req.Header.Set("Anthropic-Beta", anthropicBetaHeader) client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading usage response: %w", err) } if resp.StatusCode != http.StatusOK { if resp.StatusCode == http.StatusForbidden { return nil, fmt.Errorf("insufficient scope: usage endpoint requires oauth scope") } return nil, fmt.Errorf("usage request failed (%d): %s", resp.StatusCode, string(body)) } var result struct { FiveHour struct { Utilization float64 `json:"utilization"` } `json:"five_hour"` SevenDay struct { Utilization float64 `json:"utilization"` } `json:"seven_day"` } if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("parsing usage response: %w", err) } return &AnthropicUsage{ FiveHourUtilization: result.FiveHour.Utilization, SevenDayUtilization: result.SevenDay.Utilization, }, nil } ================================================ FILE: pkg/auth/anthropic_usage_test.go ================================================ package auth import ( "net/http" "net/http/httptest" "strings" "testing" ) func TestFetchAnthropicUsage_Success(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Authorization"); got != "Bearer test-token" { t.Errorf("Authorization = %q, want %q", got, "Bearer test-token") } if got := r.Header.Get("Anthropic-Beta"); got != anthropicBetaHeader { t.Errorf("Anthropic-Beta = %q, want %q", got, anthropicBetaHeader) } w.WriteHeader(http.StatusOK) w.Write([]byte(`{"five_hour":{"utilization":0.42},"seven_day":{"utilization":0.85}}`)) })) defer srv.Close() // Temporarily override the URL by using the test server origURL := anthropicUsageURL defer func() { setAnthropicUsageURL(origURL) }() setAnthropicUsageURL(srv.URL) usage, err := FetchAnthropicUsage("test-token") if err != nil { t.Fatalf("unexpected error: %v", err) } if usage.FiveHourUtilization != 0.42 { t.Errorf("FiveHourUtilization = %v, want 0.42", usage.FiveHourUtilization) } if usage.SevenDayUtilization != 0.85 { t.Errorf("SevenDayUtilization = %v, want 0.85", usage.SevenDayUtilization) } } func TestFetchAnthropicUsage_Forbidden(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) w.Write([]byte(`{"error":"forbidden"}`)) })) defer srv.Close() origURL := anthropicUsageURL defer func() { setAnthropicUsageURL(origURL) }() setAnthropicUsageURL(srv.URL) _, err := FetchAnthropicUsage("test-token") if err == nil { t.Fatal("expected error for 403, got nil") } if !strings.Contains(err.Error(), "insufficient scope") { t.Errorf("expected 'insufficient scope' error, got %q", err.Error()) } } func TestFetchAnthropicUsage_ServerError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`internal error`)) })) defer srv.Close() origURL := anthropicUsageURL defer func() { setAnthropicUsageURL(origURL) }() setAnthropicUsageURL(srv.URL) _, err := FetchAnthropicUsage("test-token") if err == nil { t.Fatal("expected error for 500, got nil") } if !strings.Contains(err.Error(), "500") { t.Errorf("expected error containing '500', got %q", err.Error()) } } func TestFetchAnthropicUsage_MalformedJSON(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte(`not json`)) })) defer srv.Close() origURL := anthropicUsageURL defer func() { setAnthropicUsageURL(origURL) }() setAnthropicUsageURL(srv.URL) _, err := FetchAnthropicUsage("test-token") if err == nil { t.Fatal("expected error for malformed JSON, got nil") } if !strings.Contains(err.Error(), "parsing usage response") { t.Errorf("expected 'parsing usage response' error, got %q", err.Error()) } } ================================================ FILE: pkg/auth/oauth.go ================================================ package auth import ( "bufio" "context" "crypto/rand" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "io" "net" "net/http" "net/url" "os" "os/exec" "runtime" "strconv" "strings" "time" ) type OAuthProviderConfig struct { Issuer string ClientID string ClientSecret string // Required for Google OAuth (confidential client) TokenURL string // Override token endpoint (Google uses a different URL than issuer) Scopes string Originator string Port int } func OpenAIOAuthConfig() OAuthProviderConfig { return OAuthProviderConfig{ Issuer: "https://auth.openai.com", ClientID: "app_EMoamEEZ73f0CkXaXp7hrann", Scopes: "openid profile email offline_access", Originator: "codex_cli_rs", Port: 1455, } } // GoogleAntigravityOAuthConfig returns the OAuth configuration for Google Cloud Code Assist (Antigravity). // Client credentials are the same ones used by OpenCode/pi-ai for Cloud Code Assist access. func GoogleAntigravityOAuthConfig() OAuthProviderConfig { // These are the same client credentials used by the OpenCode antigravity plugin. clientID := decodeBase64( "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==", ) clientSecret := decodeBase64("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=") return OAuthProviderConfig{ Issuer: "https://accounts.google.com/o/oauth2/v2", TokenURL: "https://oauth2.googleapis.com/token", ClientID: clientID, ClientSecret: clientSecret, Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/cclog https://www.googleapis.com/auth/experimentsandconfigs", Port: 51121, } } func decodeBase64(s string) string { data, err := base64.StdEncoding.DecodeString(s) if err != nil { return s } return string(data) } // GenerateState generates a random state string for OAuth CSRF protection. func GenerateState() (string, error) { buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf), nil } func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { pkce, err := GeneratePKCE() if err != nil { return nil, fmt.Errorf("generating PKCE: %w", err) } state, err := GenerateState() if err != nil { return nil, fmt.Errorf("generating state: %w", err) } redirectURI := fmt.Sprintf("http://localhost:%d/auth/callback", cfg.Port) authURL := buildAuthorizeURL(cfg, pkce, state, redirectURI) resultCh := make(chan callbackResult, 1) mux := http.NewServeMux() mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("state") != state { resultCh <- callbackResult{err: fmt.Errorf("state mismatch")} http.Error(w, "State mismatch", http.StatusBadRequest) return } code := r.URL.Query().Get("code") if code == "" { errMsg := r.URL.Query().Get("error") resultCh <- callbackResult{err: fmt.Errorf("no code received: %s", errMsg)} http.Error(w, "No authorization code received", http.StatusBadRequest) return } w.Header().Set("Content-Type", "text/html") fmt.Fprint(w, "

Authentication successful!

You can close this window.

") resultCh <- callbackResult{code: code} }) listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.Port)) if err != nil { return nil, fmt.Errorf("starting callback server on port %d: %w", cfg.Port, err) } server := &http.Server{Handler: mux} go server.Serve(listener) defer func() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() server.Shutdown(ctx) }() fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL) if err := OpenBrowser(authURL); err != nil { fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL) } fmt.Printf( "Wait! If you are in a headless environment (like Coolify/VPS) and cannot reach localhost:%d,\n", cfg.Port, ) fmt.Println( "please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.", ) fmt.Println("Waiting for authentication (browser or manual paste)...") // Start manual input in a goroutine manualCh := make(chan string) go func() { reader := bufio.NewReader(os.Stdin) input, _ := reader.ReadString('\n') manualCh <- strings.TrimSpace(input) }() select { case result := <-resultCh: if result.err != nil { return nil, result.err } return ExchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI) case manualInput := <-manualCh: if manualInput == "" { return nil, fmt.Errorf("manual input canceled") } // Extract code from URL if it's a full URL code := manualInput if strings.Contains(manualInput, "?") { u, err := url.Parse(manualInput) if err == nil { code = u.Query().Get("code") } } if code == "" { return nil, fmt.Errorf("could not find authorization code in input") } return ExchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI) case <-time.After(5 * time.Minute): return nil, fmt.Errorf("authentication timed out after 5 minutes") } } type callbackResult struct { code string err error } type deviceCodeResponse struct { DeviceAuthID string UserCode string Interval int } // DeviceCodeInfo holds the device code information returned by the OAuth provider. type DeviceCodeInfo struct { DeviceAuthID string `json:"device_auth_id"` UserCode string `json:"user_code"` VerifyURL string `json:"verify_url"` Interval int `json:"interval"` } // RequestDeviceCode requests a device code from the OAuth provider. // Returns the info needed for the user to authenticate in a browser. func RequestDeviceCode(cfg OAuthProviderConfig) (*DeviceCodeInfo, error) { reqBody, _ := json.Marshal(map[string]string{ "client_id": cfg.ClientID, }) resp, err := http.Post( cfg.Issuer+"/api/accounts/deviceauth/usercode", "application/json", strings.NewReader(string(reqBody)), ) if err != nil { return nil, fmt.Errorf("requesting device code: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading device code response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("device code request failed: %s", string(body)) } deviceResp, err := parseDeviceCodeResponse(body) if err != nil { return nil, fmt.Errorf("parsing device code response: %w", err) } if deviceResp.Interval < 1 { deviceResp.Interval = 5 } return &DeviceCodeInfo{ DeviceAuthID: deviceResp.DeviceAuthID, UserCode: deviceResp.UserCode, VerifyURL: cfg.Issuer + "/codex/device", Interval: deviceResp.Interval, }, nil } // PollDeviceCodeOnce makes a single poll attempt to check if the user has authenticated. // Returns (credential, nil) on success, (nil, nil) if still pending, or (nil, err) on failure. func PollDeviceCodeOnce(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*AuthCredential, error) { return pollDeviceCode(cfg, deviceAuthID, userCode) } func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) { var raw struct { DeviceAuthID string `json:"device_auth_id"` UserCode string `json:"user_code"` Interval json.RawMessage `json:"interval"` } if err := json.Unmarshal(body, &raw); err != nil { return deviceCodeResponse{}, err } interval, err := parseFlexibleInt(raw.Interval) if err != nil { return deviceCodeResponse{}, err } return deviceCodeResponse{ DeviceAuthID: raw.DeviceAuthID, UserCode: raw.UserCode, Interval: interval, }, nil } func parseFlexibleInt(raw json.RawMessage) (int, error) { if len(raw) == 0 || string(raw) == "null" { return 0, nil } var interval int if err := json.Unmarshal(raw, &interval); err == nil { return interval, nil } var intervalStr string if err := json.Unmarshal(raw, &intervalStr); err == nil { intervalStr = strings.TrimSpace(intervalStr) if intervalStr == "" { return 0, nil } return strconv.Atoi(intervalStr) } return 0, fmt.Errorf("invalid integer value: %s", string(raw)) } func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) { reqBody, _ := json.Marshal(map[string]string{ "client_id": cfg.ClientID, }) resp, err := http.Post( cfg.Issuer+"/api/accounts/deviceauth/usercode", "application/json", strings.NewReader(string(reqBody)), ) if err != nil { return nil, fmt.Errorf("requesting device code: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading device code response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("device code request failed: %s", string(body)) } deviceResp, err := parseDeviceCodeResponse(body) if err != nil { return nil, fmt.Errorf("parsing device code response: %w", err) } if deviceResp.Interval < 1 { deviceResp.Interval = 5 } fmt.Printf( "\nTo authenticate, open this URL in your browser:\n\n %s/codex/device\n\nThen enter this code: %s\n\nWaiting for authentication...\n", cfg.Issuer, deviceResp.UserCode, ) deadline := time.After(15 * time.Minute) ticker := time.NewTicker(time.Duration(deviceResp.Interval) * time.Second) defer ticker.Stop() for { select { case <-deadline: return nil, fmt.Errorf("device code authentication timed out after 15 minutes") case <-ticker.C: cred, err := pollDeviceCode(cfg, deviceResp.DeviceAuthID, deviceResp.UserCode) if err != nil { continue } if cred != nil { return cred, nil } } } } func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*AuthCredential, error) { reqBody, _ := json.Marshal(map[string]string{ "device_auth_id": deviceAuthID, "user_code": userCode, }) resp, err := http.Post( cfg.Issuer+"/api/accounts/deviceauth/token", "application/json", strings.NewReader(string(reqBody)), ) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("pending") } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading device token response: %w", err) } var tokenResp struct { AuthorizationCode string `json:"authorization_code"` CodeChallenge string `json:"code_challenge"` CodeVerifier string `json:"code_verifier"` } if err := json.Unmarshal(body, &tokenResp); err != nil { return nil, err } redirectURI := cfg.Issuer + "/deviceauth/callback" return ExchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI) } func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCredential, error) { if cred.RefreshToken == "" { return nil, fmt.Errorf("no refresh token available") } data := url.Values{ "client_id": {cfg.ClientID}, "grant_type": {"refresh_token"}, "refresh_token": {cred.RefreshToken}, "scope": {"openid profile email"}, } if cfg.ClientSecret != "" { data.Set("client_secret", cfg.ClientSecret) } tokenURL := cfg.Issuer + "/oauth/token" if cfg.TokenURL != "" { tokenURL = cfg.TokenURL } resp, err := http.PostForm(tokenURL, data) if err != nil { return nil, fmt.Errorf("refreshing token: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading token refresh response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("token refresh failed: %s", string(body)) } refreshed, err := parseTokenResponse(body, cred.Provider) if err != nil { return nil, err } if refreshed.RefreshToken == "" { refreshed.RefreshToken = cred.RefreshToken } if refreshed.AccountID == "" { refreshed.AccountID = cred.AccountID } if cred.Email != "" && refreshed.Email == "" { refreshed.Email = cred.Email } if cred.ProjectID != "" && refreshed.ProjectID == "" { refreshed.ProjectID = cred.ProjectID } return refreshed, nil } func BuildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string { return buildAuthorizeURL(cfg, pkce, state, redirectURI) } func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string { params := url.Values{ "response_type": {"code"}, "client_id": {cfg.ClientID}, "redirect_uri": {redirectURI}, "scope": {cfg.Scopes}, "code_challenge": {pkce.CodeChallenge}, "code_challenge_method": {"S256"}, "state": {state}, } isGoogle := strings.Contains(strings.ToLower(cfg.Issuer), "accounts.google.com") if isGoogle { // Google OAuth requires these for refresh token support params.Set("access_type", "offline") params.Set("prompt", "consent") } else { // OpenAI-specific parameters params.Set("id_token_add_organizations", "true") params.Set("codex_cli_simplified_flow", "true") if strings.Contains(strings.ToLower(cfg.Issuer), "auth.openai.com") { params.Set("originator", "picoclaw") } if cfg.Originator != "" { params.Set("originator", cfg.Originator) } } // Google uses /auth path, OpenAI uses /oauth/authorize if isGoogle { return cfg.Issuer + "/auth?" + params.Encode() } return cfg.Issuer + "/oauth/authorize?" + params.Encode() } // ExchangeCodeForTokens exchanges an authorization code for tokens. func ExchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) { data := url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {redirectURI}, "client_id": {cfg.ClientID}, "code_verifier": {codeVerifier}, } if cfg.ClientSecret != "" { data.Set("client_secret", cfg.ClientSecret) } tokenURL := cfg.Issuer + "/oauth/token" if cfg.TokenURL != "" { tokenURL = cfg.TokenURL } // Determine provider name from config provider := "openai" if cfg.TokenURL != "" && strings.Contains(cfg.TokenURL, "googleapis.com") { provider = "google-antigravity" } resp, err := http.PostForm(tokenURL, data) if err != nil { return nil, fmt.Errorf("exchanging code for tokens: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading token exchange response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("token exchange failed: %s", string(body)) } return parseTokenResponse(body, provider) } func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) { var tokenResp struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int `json:"expires_in"` IDToken string `json:"id_token"` } if err := json.Unmarshal(body, &tokenResp); err != nil { return nil, fmt.Errorf("parsing token response: %w", err) } if tokenResp.AccessToken == "" { return nil, fmt.Errorf("no access token in response") } var expiresAt time.Time if tokenResp.ExpiresIn > 0 { expiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) } cred := &AuthCredential{ AccessToken: tokenResp.AccessToken, RefreshToken: tokenResp.RefreshToken, ExpiresAt: expiresAt, Provider: provider, AuthMethod: "oauth", } if accountID := extractAccountID(tokenResp.IDToken); accountID != "" { cred.AccountID = accountID } else if accountID := extractAccountID(tokenResp.AccessToken); accountID != "" { cred.AccountID = accountID } else if accountID := extractAccountID(tokenResp.IDToken); accountID != "" { // Recent OpenAI OAuth responses may only include chatgpt_account_id in id_token claims. cred.AccountID = accountID } return cred, nil } func extractAccountID(token string) string { claims, err := parseJWTClaims(token) if err != nil { return "" } if accountID, ok := claims["chatgpt_account_id"].(string); ok && accountID != "" { return accountID } if accountID, ok := claims["https://api.openai.com/auth.chatgpt_account_id"].(string); ok && accountID != "" { return accountID } if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]any); ok { if accountID, ok := authClaim["chatgpt_account_id"].(string); ok && accountID != "" { return accountID } } if orgs, ok := claims["organizations"].([]any); ok { for _, org := range orgs { if orgMap, ok := org.(map[string]any); ok { if accountID, ok := orgMap["id"].(string); ok && accountID != "" { return accountID } } } } return "" } func parseJWTClaims(token string) (map[string]any, error) { parts := strings.Split(token, ".") if len(parts) < 2 { return nil, fmt.Errorf("token is not a JWT") } payload := parts[1] switch len(payload) % 4 { case 2: payload += "==" case 3: payload += "=" } decoded, err := base64URLDecode(payload) if err != nil { return nil, err } var claims map[string]any if err := json.Unmarshal(decoded, &claims); err != nil { return nil, err } return claims, nil } func base64URLDecode(s string) ([]byte, error) { s = strings.NewReplacer("-", "+", "_", "/").Replace(s) return base64.StdEncoding.DecodeString(s) } // OpenBrowser opens the given URL in the user's default browser. func OpenBrowser(url string) error { switch runtime.GOOS { case "darwin": return exec.Command("open", url).Start() case "linux": return exec.Command("xdg-open", url).Start() case "windows": return exec.Command("cmd", "/c", "start", url).Start() default: return fmt.Errorf("unsupported platform: %s", runtime.GOOS) } } ================================================ FILE: pkg/auth/oauth_test.go ================================================ package auth import ( "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "testing" ) func makeJWTForClaims(t *testing.T, claims map[string]any) string { t.Helper() header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) payloadJSON, err := json.Marshal(claims) if err != nil { t.Fatalf("marshal claims: %v", err) } payload := base64.RawURLEncoding.EncodeToString(payloadJSON) return header + "." + payload + ".sig" } func TestBuildAuthorizeURL(t *testing.T) { cfg := OAuthProviderConfig{ Issuer: "https://auth.example.com", ClientID: "test-client-id", Scopes: "openid profile", Originator: "codex_cli_rs", Port: 1455, } pkce := PKCECodes{ CodeVerifier: "test-verifier", CodeChallenge: "test-challenge", } u := BuildAuthorizeURL(cfg, pkce, "test-state", "http://localhost:1455/auth/callback") if !strings.HasPrefix(u, "https://auth.example.com/oauth/authorize?") { t.Errorf("URL does not start with expected prefix: %s", u) } if !strings.Contains(u, "client_id=test-client-id") { t.Error("URL missing client_id") } if !strings.Contains(u, "code_challenge=test-challenge") { t.Error("URL missing code_challenge") } if !strings.Contains(u, "code_challenge_method=S256") { t.Error("URL missing code_challenge_method") } if !strings.Contains(u, "state=test-state") { t.Error("URL missing state") } if !strings.Contains(u, "response_type=code") { t.Error("URL missing response_type") } if !strings.Contains(u, "id_token_add_organizations=true") { t.Error("URL missing id_token_add_organizations") } if !strings.Contains(u, "codex_cli_simplified_flow=true") { t.Error("URL missing codex_cli_simplified_flow") } if !strings.Contains(u, "originator=codex_cli_rs") { t.Error("URL missing originator") } } func TestBuildAuthorizeURLOpenAIExtras(t *testing.T) { cfg := OpenAIOAuthConfig() pkce := PKCECodes{CodeVerifier: "test-verifier", CodeChallenge: "test-challenge"} u := BuildAuthorizeURL(cfg, pkce, "test-state", "http://localhost:1455/auth/callback") parsed, err := url.Parse(u) if err != nil { t.Fatalf("url.Parse() error: %v", err) } q := parsed.Query() if q.Get("id_token_add_organizations") != "true" { t.Errorf("id_token_add_organizations = %q, want true", q.Get("id_token_add_organizations")) } if q.Get("codex_cli_simplified_flow") != "true" { t.Errorf("codex_cli_simplified_flow = %q, want true", q.Get("codex_cli_simplified_flow")) } if q.Get("originator") != "codex_cli_rs" { t.Errorf("originator = %q, want codex_cli_rs", q.Get("originator")) } } func TestParseTokenResponse(t *testing.T) { resp := map[string]any{ "access_token": "test-access-token", "refresh_token": "test-refresh-token", "expires_in": 3600, "id_token": "test-id-token", } body, _ := json.Marshal(resp) cred, err := parseTokenResponse(body, "openai") if err != nil { t.Fatalf("parseTokenResponse() error: %v", err) } if cred.AccessToken != "test-access-token" { t.Errorf("AccessToken = %q, want %q", cred.AccessToken, "test-access-token") } if cred.RefreshToken != "test-refresh-token" { t.Errorf("RefreshToken = %q, want %q", cred.RefreshToken, "test-refresh-token") } if cred.Provider != "openai" { t.Errorf("Provider = %q, want %q", cred.Provider, "openai") } if cred.AuthMethod != "oauth" { t.Errorf("AuthMethod = %q, want %q", cred.AuthMethod, "oauth") } if cred.ExpiresAt.IsZero() { t.Error("ExpiresAt should not be zero") } } func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) { idToken := makeJWTForClaims(t, map[string]any{"chatgpt_account_id": "acc-id-from-id-token"}) resp := map[string]any{ "access_token": "opaque-access-token", "refresh_token": "test-refresh-token", "expires_in": 3600, "id_token": idToken, } body, _ := json.Marshal(resp) cred, err := parseTokenResponse(body, "openai") if err != nil { t.Fatalf("parseTokenResponse() error: %v", err) } if cred.AccountID != "acc-id-from-id-token" { t.Errorf("AccountID = %q, want %q", cred.AccountID, "acc-id-from-id-token") } } func TestExtractAccountIDFromOrganizationsFallback(t *testing.T) { token := makeJWTForClaims(t, map[string]any{ "organizations": []any{ map[string]any{"id": "org_from_orgs"}, }, }) if got := extractAccountID(token); got != "org_from_orgs" { t.Errorf("extractAccountID() = %q, want %q", got, "org_from_orgs") } } func TestParseTokenResponseNoAccessToken(t *testing.T) { body := []byte(`{"refresh_token": "test"}`) _, err := parseTokenResponse(body, "openai") if err == nil { t.Error("expected error for missing access_token") } } func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) { idToken := makeJWTWithAccountID("acc-from-id") resp := map[string]any{ "access_token": "not-a-jwt", "refresh_token": "test-refresh-token", "expires_in": 3600, "id_token": idToken, } body, _ := json.Marshal(resp) cred, err := parseTokenResponse(body, "openai") if err != nil { t.Fatalf("parseTokenResponse() error: %v", err) } if cred.AccountID != "acc-from-id" { t.Errorf("AccountID = %q, want %q", cred.AccountID, "acc-from-id") } } func makeJWTWithAccountID(accountID string) string { header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) payload := base64.RawURLEncoding.EncodeToString( []byte(`{"https://api.openai.com/auth":{"chatgpt_account_id":"` + accountID + `"}}`), ) return header + "." + payload + ".sig" } func TestExchangeCodeForTokens(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/oauth/token" { http.Error(w, "not found", http.StatusNotFound) return } if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } r.ParseForm() if r.FormValue("grant_type") != "authorization_code" { http.Error(w, "invalid grant_type", http.StatusBadRequest) return } resp := map[string]any{ "access_token": "mock-access-token", "refresh_token": "mock-refresh-token", "expires_in": 3600, } json.NewEncoder(w).Encode(resp) })) defer server.Close() cfg := OAuthProviderConfig{ Issuer: server.URL, ClientID: "test-client", Scopes: "openid", Port: 1455, } cred, err := ExchangeCodeForTokens(cfg, "test-code", "test-verifier", "http://localhost:1455/auth/callback") if err != nil { t.Fatalf("ExchangeCodeForTokens() error: %v", err) } if cred.AccessToken != "mock-access-token" { t.Errorf("AccessToken = %q, want %q", cred.AccessToken, "mock-access-token") } } func TestRefreshAccessToken(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/oauth/token" { http.Error(w, "not found", http.StatusNotFound) return } r.ParseForm() if r.FormValue("grant_type") != "refresh_token" { http.Error(w, "invalid grant_type", http.StatusBadRequest) return } resp := map[string]any{ "access_token": "refreshed-access-token", "refresh_token": "refreshed-refresh-token", "expires_in": 3600, } json.NewEncoder(w).Encode(resp) })) defer server.Close() cfg := OAuthProviderConfig{ Issuer: server.URL, ClientID: "test-client", } cred := &AuthCredential{ AccessToken: "old-token", RefreshToken: "old-refresh-token", Provider: "openai", AuthMethod: "oauth", } refreshed, err := RefreshAccessToken(cred, cfg) if err != nil { t.Fatalf("RefreshAccessToken() error: %v", err) } if refreshed.AccessToken != "refreshed-access-token" { t.Errorf("AccessToken = %q, want %q", refreshed.AccessToken, "refreshed-access-token") } if refreshed.RefreshToken != "refreshed-refresh-token" { t.Errorf("RefreshToken = %q, want %q", refreshed.RefreshToken, "refreshed-refresh-token") } } func TestRefreshAccessTokenNoRefreshToken(t *testing.T) { cfg := OpenAIOAuthConfig() cred := &AuthCredential{ AccessToken: "old-token", Provider: "openai", AuthMethod: "oauth", } _, err := RefreshAccessToken(cred, cfg) if err == nil { t.Error("expected error for missing refresh token") } } func TestRefreshAccessTokenPreservesRefreshAndAccountID(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := map[string]any{ "access_token": "new-access-token-only", "expires_in": 3600, } json.NewEncoder(w).Encode(resp) })) defer server.Close() cfg := OAuthProviderConfig{Issuer: server.URL, ClientID: "test-client"} cred := &AuthCredential{ AccessToken: "old-access", RefreshToken: "existing-refresh", AccountID: "acc_existing", Provider: "openai", AuthMethod: "oauth", } refreshed, err := RefreshAccessToken(cred, cfg) if err != nil { t.Fatalf("RefreshAccessToken() error: %v", err) } if refreshed.RefreshToken != "existing-refresh" { t.Errorf("RefreshToken = %q, want %q", refreshed.RefreshToken, "existing-refresh") } if refreshed.AccountID != "acc_existing" { t.Errorf("AccountID = %q, want %q", refreshed.AccountID, "acc_existing") } } func TestOpenAIOAuthConfig(t *testing.T) { cfg := OpenAIOAuthConfig() if cfg.Issuer != "https://auth.openai.com" { t.Errorf("Issuer = %q, want %q", cfg.Issuer, "https://auth.openai.com") } if cfg.ClientID == "" { t.Error("ClientID is empty") } if cfg.Port != 1455 { t.Errorf("Port = %d, want 1455", cfg.Port) } } func TestParseDeviceCodeResponseIntervalAsNumber(t *testing.T) { body := []byte(`{"device_auth_id":"abc","user_code":"DEF-1234","interval":5}`) resp, err := parseDeviceCodeResponse(body) if err != nil { t.Fatalf("parseDeviceCodeResponse() error: %v", err) } if resp.DeviceAuthID != "abc" { t.Errorf("DeviceAuthID = %q, want %q", resp.DeviceAuthID, "abc") } if resp.UserCode != "DEF-1234" { t.Errorf("UserCode = %q, want %q", resp.UserCode, "DEF-1234") } if resp.Interval != 5 { t.Errorf("Interval = %d, want %d", resp.Interval, 5) } } func TestParseDeviceCodeResponseIntervalAsString(t *testing.T) { body := []byte(`{"device_auth_id":"abc","user_code":"DEF-1234","interval":"5"}`) resp, err := parseDeviceCodeResponse(body) if err != nil { t.Fatalf("parseDeviceCodeResponse() error: %v", err) } if resp.Interval != 5 { t.Errorf("Interval = %d, want %d", resp.Interval, 5) } } func TestParseDeviceCodeResponseInvalidInterval(t *testing.T) { body := []byte(`{"device_auth_id":"abc","user_code":"DEF-1234","interval":"abc"}`) if _, err := parseDeviceCodeResponse(body); err == nil { t.Fatal("expected error for invalid interval") } } ================================================ FILE: pkg/auth/pkce.go ================================================ package auth import ( "crypto/rand" "crypto/sha256" "encoding/base64" ) type PKCECodes struct { CodeVerifier string CodeChallenge string } func GeneratePKCE() (PKCECodes, error) { buf := make([]byte, 64) if _, err := rand.Read(buf); err != nil { return PKCECodes{}, err } verifier := base64.RawURLEncoding.EncodeToString(buf) hash := sha256.Sum256([]byte(verifier)) challenge := base64.RawURLEncoding.EncodeToString(hash[:]) return PKCECodes{ CodeVerifier: verifier, CodeChallenge: challenge, }, nil } ================================================ FILE: pkg/auth/pkce_test.go ================================================ package auth import ( "crypto/sha256" "encoding/base64" "testing" ) func TestGeneratePKCE(t *testing.T) { codes, err := GeneratePKCE() if err != nil { t.Fatalf("GeneratePKCE() error: %v", err) } if codes.CodeVerifier == "" { t.Fatal("CodeVerifier is empty") } if codes.CodeChallenge == "" { t.Fatal("CodeChallenge is empty") } verifierBytes, err := base64.RawURLEncoding.DecodeString(codes.CodeVerifier) if err != nil { t.Fatalf("CodeVerifier is not valid base64url: %v", err) } if len(verifierBytes) != 64 { t.Errorf("CodeVerifier decoded length = %d, want 64", len(verifierBytes)) } hash := sha256.Sum256([]byte(codes.CodeVerifier)) expectedChallenge := base64.RawURLEncoding.EncodeToString(hash[:]) if codes.CodeChallenge != expectedChallenge { t.Errorf("CodeChallenge = %q, want SHA256 of verifier = %q", codes.CodeChallenge, expectedChallenge) } } func TestGeneratePKCEUniqueness(t *testing.T) { codes1, err := GeneratePKCE() if err != nil { t.Fatalf("GeneratePKCE() error: %v", err) } codes2, err := GeneratePKCE() if err != nil { t.Fatalf("GeneratePKCE() error: %v", err) } if codes1.CodeVerifier == codes2.CodeVerifier { t.Error("two GeneratePKCE() calls produced identical verifiers") } } ================================================ FILE: pkg/auth/store.go ================================================ package auth import ( "encoding/json" "os" "path/filepath" "time" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/fileutil" ) type AuthCredential struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token,omitempty"` AccountID string `json:"account_id,omitempty"` ExpiresAt time.Time `json:"expires_at,omitempty"` Provider string `json:"provider"` AuthMethod string `json:"auth_method"` Email string `json:"email,omitempty"` ProjectID string `json:"project_id,omitempty"` } type AuthStore struct { Credentials map[string]*AuthCredential `json:"credentials"` } func (c *AuthCredential) IsExpired() bool { if c.ExpiresAt.IsZero() { return false } return time.Now().After(c.ExpiresAt) } func (c *AuthCredential) NeedsRefresh() bool { if c.ExpiresAt.IsZero() { return false } return time.Now().Add(5 * time.Minute).After(c.ExpiresAt) } func authFilePath() string { if home := os.Getenv(config.EnvHome); home != "" { return filepath.Join(home, "auth.json") } home, _ := os.UserHomeDir() return filepath.Join(home, ".picoclaw", "auth.json") } func LoadStore() (*AuthStore, error) { path := authFilePath() data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return &AuthStore{Credentials: make(map[string]*AuthCredential)}, nil } return nil, err } var store AuthStore if err := json.Unmarshal(data, &store); err != nil { return nil, err } if store.Credentials == nil { store.Credentials = make(map[string]*AuthCredential) } return &store, nil } func SaveStore(store *AuthStore) error { path := authFilePath() data, err := json.MarshalIndent(store, "", " ") if err != nil { return err } // Use unified atomic write utility with explicit sync for flash storage reliability. return fileutil.WriteFileAtomic(path, data, 0o600) } func GetCredential(provider string) (*AuthCredential, error) { store, err := LoadStore() if err != nil { return nil, err } cred, ok := store.Credentials[provider] if !ok { return nil, nil } return cred, nil } func SetCredential(provider string, cred *AuthCredential) error { store, err := LoadStore() if err != nil { return err } store.Credentials[provider] = cred return SaveStore(store) } func DeleteCredential(provider string) error { store, err := LoadStore() if err != nil { return err } delete(store.Credentials, provider) return SaveStore(store) } func DeleteAllCredentials() error { path := authFilePath() if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return err } return nil } ================================================ FILE: pkg/auth/store_test.go ================================================ package auth import ( "os" "path/filepath" "testing" "time" ) func TestAuthCredentialIsExpired(t *testing.T) { tests := []struct { name string expiresAt time.Time want bool }{ {"zero time", time.Time{}, false}, {"future", time.Now().Add(time.Hour), false}, {"past", time.Now().Add(-time.Hour), true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &AuthCredential{ExpiresAt: tt.expiresAt} if got := c.IsExpired(); got != tt.want { t.Errorf("IsExpired() = %v, want %v", got, tt.want) } }) } } func TestAuthCredentialNeedsRefresh(t *testing.T) { tests := []struct { name string expiresAt time.Time want bool }{ {"zero time", time.Time{}, false}, {"far future", time.Now().Add(time.Hour), false}, {"within 5 min", time.Now().Add(3 * time.Minute), true}, {"already expired", time.Now().Add(-time.Minute), true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &AuthCredential{ExpiresAt: tt.expiresAt} if got := c.NeedsRefresh(); got != tt.want { t.Errorf("NeedsRefresh() = %v, want %v", got, tt.want) } }) } } func TestStoreRoundtrip(t *testing.T) { tmpDir := t.TempDir() origHome := os.Getenv("HOME") t.Setenv("HOME", tmpDir) defer os.Setenv("HOME", origHome) cred := &AuthCredential{ AccessToken: "test-access-token", RefreshToken: "test-refresh-token", AccountID: "acct-123", ExpiresAt: time.Now().Add(time.Hour).Truncate(time.Second), Provider: "openai", AuthMethod: "oauth", } if err := SetCredential("openai", cred); err != nil { t.Fatalf("SetCredential() error: %v", err) } loaded, err := GetCredential("openai") if err != nil { t.Fatalf("GetCredential() error: %v", err) } if loaded == nil { t.Fatal("GetCredential() returned nil") } if loaded.AccessToken != cred.AccessToken { t.Errorf("AccessToken = %q, want %q", loaded.AccessToken, cred.AccessToken) } if loaded.RefreshToken != cred.RefreshToken { t.Errorf("RefreshToken = %q, want %q", loaded.RefreshToken, cred.RefreshToken) } if loaded.Provider != cred.Provider { t.Errorf("Provider = %q, want %q", loaded.Provider, cred.Provider) } } func TestStoreFilePermissions(t *testing.T) { tmpDir := t.TempDir() origHome := os.Getenv("HOME") t.Setenv("HOME", tmpDir) defer os.Setenv("HOME", origHome) cred := &AuthCredential{ AccessToken: "secret-token", Provider: "openai", AuthMethod: "oauth", } if err := SetCredential("openai", cred); err != nil { t.Fatalf("SetCredential() error: %v", err) } path := filepath.Join(tmpDir, ".picoclaw", "auth.json") info, err := os.Stat(path) if err != nil { t.Fatalf("Stat() error: %v", err) } perm := info.Mode().Perm() if perm != 0o600 { t.Errorf("file permissions = %o, want 0600", perm) } } func TestStoreMultiProvider(t *testing.T) { tmpDir := t.TempDir() origHome := os.Getenv("HOME") t.Setenv("HOME", tmpDir) defer os.Setenv("HOME", origHome) openaiCred := &AuthCredential{AccessToken: "openai-token", Provider: "openai", AuthMethod: "oauth"} anthropicCred := &AuthCredential{AccessToken: "anthropic-token", Provider: "anthropic", AuthMethod: "token"} if err := SetCredential("openai", openaiCred); err != nil { t.Fatalf("SetCredential(openai) error: %v", err) } if err := SetCredential("anthropic", anthropicCred); err != nil { t.Fatalf("SetCredential(anthropic) error: %v", err) } loaded, err := GetCredential("openai") if err != nil { t.Fatalf("GetCredential(openai) error: %v", err) } if loaded.AccessToken != "openai-token" { t.Errorf("openai token = %q, want %q", loaded.AccessToken, "openai-token") } loaded, err = GetCredential("anthropic") if err != nil { t.Fatalf("GetCredential(anthropic) error: %v", err) } if loaded.AccessToken != "anthropic-token" { t.Errorf("anthropic token = %q, want %q", loaded.AccessToken, "anthropic-token") } } func TestDeleteCredential(t *testing.T) { tmpDir := t.TempDir() origHome := os.Getenv("HOME") t.Setenv("HOME", tmpDir) defer os.Setenv("HOME", origHome) cred := &AuthCredential{AccessToken: "to-delete", Provider: "openai", AuthMethod: "oauth"} if err := SetCredential("openai", cred); err != nil { t.Fatalf("SetCredential() error: %v", err) } if err := DeleteCredential("openai"); err != nil { t.Fatalf("DeleteCredential() error: %v", err) } loaded, err := GetCredential("openai") if err != nil { t.Fatalf("GetCredential() error: %v", err) } if loaded != nil { t.Error("expected nil after delete") } } func TestLoadStoreEmpty(t *testing.T) { tmpDir := t.TempDir() origHome := os.Getenv("HOME") t.Setenv("HOME", tmpDir) defer os.Setenv("HOME", origHome) store, err := LoadStore() if err != nil { t.Fatalf("LoadStore() error: %v", err) } if store == nil { t.Fatal("LoadStore() returned nil") } if len(store.Credentials) != 0 { t.Errorf("expected empty credentials, got %d", len(store.Credentials)) } } ================================================ FILE: pkg/auth/token.go ================================================ package auth import ( "bufio" "fmt" "io" "strings" ) func LoginPasteToken(provider string, r io.Reader) (*AuthCredential, error) { fmt.Printf("Paste your API key or session token from %s:\n", providerDisplayName(provider)) fmt.Print("> ") scanner := bufio.NewScanner(r) if !scanner.Scan() { if err := scanner.Err(); err != nil { return nil, fmt.Errorf("reading token: %w", err) } return nil, fmt.Errorf("no input received") } token := strings.TrimSpace(scanner.Text()) if token == "" { return nil, fmt.Errorf("token cannot be empty") } return &AuthCredential{ AccessToken: token, Provider: provider, AuthMethod: "token", }, nil } func LoginSetupToken(r io.Reader) (*AuthCredential, error) { fmt.Println("Paste your setup token from `claude setup-token`:") fmt.Print("> ") scanner := bufio.NewScanner(r) if !scanner.Scan() { if err := scanner.Err(); err != nil { return nil, fmt.Errorf("reading token: %w", err) } return nil, fmt.Errorf("no input received") } token := strings.TrimSpace(scanner.Text()) if !strings.HasPrefix(token, "sk-ant-oat01-") { return nil, fmt.Errorf("invalid setup token: expected prefix sk-ant-oat01-") } if len(token) < 80 { return nil, fmt.Errorf("invalid setup token: too short (expected at least 80 characters)") } return &AuthCredential{ AccessToken: token, Provider: "anthropic", AuthMethod: "oauth", }, nil } func providerDisplayName(provider string) string { switch provider { case "anthropic": return "console.anthropic.com" case "openai": return "platform.openai.com" default: return provider } } ================================================ FILE: pkg/auth/token_test.go ================================================ package auth import ( "strings" "testing" ) func TestLoginSetupToken(t *testing.T) { // A valid token: correct prefix + at least 80 chars validToken := "sk-ant-oat01-" + strings.Repeat("a", 80) tests := []struct { name string input string wantErr string }{ {"valid token", validToken, ""}, {"empty input", "", "expected prefix sk-ant-oat01-"}, {"wrong prefix", "sk-ant-api-" + strings.Repeat("a", 80), "expected prefix sk-ant-oat01-"}, {"too short", "sk-ant-oat01-short", "too short"}, {"whitespace only", " ", "expected prefix sk-ant-oat01-"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := strings.NewReader(tt.input + "\n") cred, err := LoginSetupToken(r) if tt.wantErr != "" { if err == nil { t.Fatalf("expected error containing %q, got nil", tt.wantErr) } if !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) } return } if err != nil { t.Fatalf("unexpected error: %v", err) } if cred.AccessToken != validToken { t.Errorf("AccessToken = %q, want %q", cred.AccessToken, validToken) } if cred.Provider != "anthropic" { t.Errorf("Provider = %q, want %q", cred.Provider, "anthropic") } if cred.AuthMethod != "oauth" { t.Errorf("AuthMethod = %q, want %q", cred.AuthMethod, "oauth") } }) } } func TestLoginSetupToken_EmptyReader(t *testing.T) { r := strings.NewReader("") _, err := LoginSetupToken(r) if err == nil { t.Fatal("expected error for empty reader, got nil") } } ================================================ FILE: pkg/bus/bus.go ================================================ package bus import ( "context" "errors" "sync" "sync/atomic" "github.com/sipeed/picoclaw/pkg/logger" ) // ErrBusClosed is returned when publishing to a closed MessageBus. var ErrBusClosed = errors.New("message bus closed") const defaultBusBufferSize = 64 type MessageBus struct { inbound chan InboundMessage outbound chan OutboundMessage outboundMedia chan OutboundMediaMessage closeOnce sync.Once done chan struct{} closed atomic.Bool wg sync.WaitGroup } func NewMessageBus() *MessageBus { return &MessageBus{ inbound: make(chan InboundMessage, defaultBusBufferSize), outbound: make(chan OutboundMessage, defaultBusBufferSize), outboundMedia: make(chan OutboundMediaMessage, defaultBusBufferSize), done: make(chan struct{}), } } func publish[T any](ctx context.Context, mb *MessageBus, ch chan T, msg T) error { // check bus closed before acquiring wg, to avoid unnecessary wg.Add and potential deadlock if mb.closed.Load() { return ErrBusClosed } // check again,before sending message, to avoid sending to closed channel select { case <-ctx.Done(): return ctx.Err() case <-mb.done: return ErrBusClosed default: } mb.wg.Add(1) defer mb.wg.Done() select { case ch <- msg: return nil case <-ctx.Done(): return ctx.Err() case <-mb.done: return ErrBusClosed } } func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error { return publish(ctx, mb, mb.inbound, msg) } func (mb *MessageBus) InboundChan() <-chan InboundMessage { return mb.inbound } func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error { return publish(ctx, mb, mb.outbound, msg) } func (mb *MessageBus) OutboundChan() <-chan OutboundMessage { return mb.outbound } func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error { return publish(ctx, mb, mb.outboundMedia, msg) } func (mb *MessageBus) OutboundMediaChan() <-chan OutboundMediaMessage { return mb.outboundMedia } func (mb *MessageBus) Close() { mb.closeOnce.Do(func() { // notify all blocked publishers to exit close(mb.done) // because every publisher will check mb.closed before acquiring wg // so we can be sure that new publishers will not be added new messages after this point mb.closed.Store(true) // wait for all ongoing Publish calls to finish, ensuring all messages have been sent to channels or exited mb.wg.Wait() // close channels safely close(mb.inbound) close(mb.outbound) close(mb.outboundMedia) // clean up any remaining messages in channels drained := 0 for range mb.inbound { drained++ } for range mb.outbound { drained++ } for range mb.outboundMedia { drained++ } if drained > 0 { logger.DebugCF("bus", "Drained buffered messages during close", map[string]any{ "count": drained, }) } }) } ================================================ FILE: pkg/bus/bus_test.go ================================================ package bus import ( "context" "sync" "testing" "time" ) func TestPublishConsume(t *testing.T) { mb := NewMessageBus() defer mb.Close() ctx := context.Background() msg := InboundMessage{ Channel: "test", SenderID: "user1", ChatID: "chat1", Content: "hello", } if err := mb.PublishInbound(ctx, msg); err != nil { t.Fatalf("PublishInbound failed: %v", err) } got, ok := <-mb.InboundChan() if !ok { t.Fatal("ConsumeInbound returned ok=false") } if got.Content != "hello" { t.Fatalf("expected content 'hello', got %q", got.Content) } if got.Channel != "test" { t.Fatalf("expected channel 'test', got %q", got.Channel) } } func TestPublishOutboundSubscribe(t *testing.T) { mb := NewMessageBus() defer mb.Close() ctx := context.Background() msg := OutboundMessage{ Channel: "telegram", ChatID: "123", Content: "world", } if err := mb.PublishOutbound(ctx, msg); err != nil { t.Fatalf("PublishOutbound failed: %v", err) } got, ok := <-mb.OutboundChan() if !ok { t.Fatal("SubscribeOutbound returned ok=false") } if got.Content != "world" { t.Fatalf("expected content 'world', got %q", got.Content) } } func TestPublishInbound_ContextCancel(t *testing.T) { mb := NewMessageBus() defer mb.Close() // Fill the buffer ctx := context.Background() for i := range defaultBusBufferSize { if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil { t.Fatalf("fill failed at %d: %v", i, err) } } // Now buffer is full; publish with a canceled context cancelCtx, cancel := context.WithCancel(context.Background()) cancel() err := mb.PublishInbound(cancelCtx, InboundMessage{Content: "overflow"}) if err == nil { t.Fatal("expected error from canceled context, got nil") } if err != context.Canceled { t.Fatalf("expected context.Canceled, got %v", err) } } func TestPublishInbound_BusClosed(t *testing.T) { mb := NewMessageBus() mb.Close() err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"}) if err != ErrBusClosed { t.Fatalf("expected ErrBusClosed, got %v", err) } } func TestPublishOutbound_BusClosed(t *testing.T) { mb := NewMessageBus() mb.Close() err := mb.PublishOutbound(context.Background(), OutboundMessage{Content: "test"}) if err != ErrBusClosed { t.Fatalf("expected ErrBusClosed, got %v", err) } } func TestConsumeInbound_ContextCancel(t *testing.T) { mb := NewMessageBus() defer mb.Close() for i := range defaultBusBufferSize { if err := mb.PublishInbound(context.Background(), InboundMessage{Content: "fill"}); err != nil { t.Fatalf("fill failed at %d: %v", i, err) } } ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() mb.PublishInbound(ctx, InboundMessage{Content: "ContextCancel"}) select { case <-ctx.Done(): t.Log("context canceled, as expected") case msg, ok := <-mb.InboundChan(): if !ok { t.Fatal("expected ok=false when context is canceled") } if msg.Content == "ContextCancel" { t.Fatalf("expected content 'ContextCancel', got %q", msg.Content) } } } func TestConsumeInbound_BusClosed(t *testing.T) { mb := NewMessageBus() timer := time.AfterFunc(100*time.Millisecond, func() { mb.Close() }) select { case <-timer.C: t.Log("context canceled, as expected") case _, ok := <-mb.InboundChan(): if ok { t.Fatal("expected ok=false when context is canceled") } } } func TestSubscribeOutbound_BusClosed(t *testing.T) { mb := NewMessageBus() mb.Close() _, ok := <-mb.OutboundChan() if ok { t.Fatal("expected ok=false when bus is closed") } } func TestConcurrentPublishClose(t *testing.T) { mb := NewMessageBus() ctx := context.Background() const numGoroutines = 100 var wg sync.WaitGroup wg.Add(numGoroutines + 1) // Spawn many goroutines trying to publish for range numGoroutines { go func() { defer wg.Done() // Use a short timeout context so we don't block forever after close publishCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) defer cancel() // Errors are expected; we just must not panic or deadlock _ = mb.PublishInbound(publishCtx, InboundMessage{Content: "concurrent"}) }() } // Close from another goroutine go func() { defer wg.Done() time.Sleep(5 * time.Millisecond) mb.Close() }() // Must complete without deadlock done := make(chan struct{}) go func() { wg.Wait() close(done) }() select { case <-done: // success case <-time.After(5 * time.Second): t.Fatal("test timed out - possible deadlock") } } func TestPublishInbound_FullBuffer(t *testing.T) { mb := NewMessageBus() defer mb.Close() ctx := context.Background() // Fill the buffer for i := range defaultBusBufferSize { if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil { t.Fatalf("fill failed at %d: %v", i, err) } } // Buffer is full; publish with short timeout timeoutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) defer cancel() err := mb.PublishInbound(timeoutCtx, InboundMessage{Content: "overflow"}) if err == nil { t.Fatal("expected error when buffer is full and context times out") } if err != context.DeadlineExceeded { t.Fatalf("expected context.DeadlineExceeded, got %v", err) } } func TestCloseIdempotent(t *testing.T) { mb := NewMessageBus() // Multiple Close calls must not panic mb.Close() mb.Close() mb.Close() // After close, publish should return ErrBusClosed err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"}) if err != ErrBusClosed { t.Fatalf("expected ErrBusClosed after multiple closes, got %v", err) } } ================================================ FILE: pkg/bus/types.go ================================================ package bus // Peer identifies the routing peer for a message (direct, group, channel, etc.) type Peer struct { Kind string `json:"kind"` // "direct" | "group" | "channel" | "" ID string `json:"id"` } // SenderInfo provides structured sender identity information. type SenderInfo struct { Platform string `json:"platform,omitempty"` // "telegram", "discord", "slack", ... PlatformID string `json:"platform_id,omitempty"` // raw platform ID, e.g. "123456" CanonicalID string `json:"canonical_id,omitempty"` // "platform:id" format Username string `json:"username,omitempty"` // username (e.g. @alice) DisplayName string `json:"display_name,omitempty"` // display name } type InboundMessage struct { Channel string `json:"channel"` SenderID string `json:"sender_id"` Sender SenderInfo `json:"sender"` ChatID string `json:"chat_id"` Content string `json:"content"` Media []string `json:"media,omitempty"` Peer Peer `json:"peer"` // routing peer MessageID string `json:"message_id,omitempty"` // platform message ID MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope SessionKey string `json:"session_key"` Metadata map[string]string `json:"metadata,omitempty"` } type OutboundMessage struct { Channel string `json:"channel"` ChatID string `json:"chat_id"` Content string `json:"content"` ReplyToMessageID string `json:"reply_to_message_id,omitempty"` } // MediaPart describes a single media attachment to send. type MediaPart struct { Type string `json:"type"` // "image" | "audio" | "video" | "file" Ref string `json:"ref"` // media store ref, e.g. "media://abc123" Caption string `json:"caption,omitempty"` // optional caption text Filename string `json:"filename,omitempty"` // original filename hint ContentType string `json:"content_type,omitempty"` // MIME type hint } // OutboundMediaMessage carries media attachments from Agent to channels via the bus. type OutboundMediaMessage struct { Channel string `json:"channel"` ChatID string `json:"chat_id"` Parts []MediaPart `json:"parts"` } ================================================ FILE: pkg/channels/README.md ================================================ # PicoClaw Channel System: Complete Development Guide > **Scope**: `pkg/channels/`, `pkg/bus/`, `pkg/media/`, `pkg/identity/`, `cmd/picoclaw/internal/gateway/` --- ## Table of Contents - [Part 1: Architecture Overview](#part-1-architecture-overview) - [Part 2: Migration Guide — From main Branch to Refactored Branch](#part-2-migration-guide--from-main-branch-to-refactored-branch) - [Part 3: New Channel Development Guide — Implementing a Channel from Scratch](#part-3-new-channel-development-guide--implementing-a-channel-from-scratch) - [Part 4: Core Subsystem Details](#part-4-core-subsystem-details) - [Part 5: Key Design Decisions and Conventions](#part-5-key-design-decisions-and-conventions) - [Appendix: Complete File Listing and Interface Quick Reference](#appendix-complete-file-listing-and-interface-quick-reference) --- ## Part 1: Architecture Overview ### 1.1 Before and After Comparison **Before Refactor (main branch)**: ``` pkg/channels/ ├── telegram.go # Each channel directly in the channels package ├── discord.go ├── slack.go ├── manager.go # Manager directly references each channel type ├── ... ``` - All channel implementations lived at the top level of `pkg/channels/` - Manager constructed each channel via `switch` or `if-else` chains - Routing info like Peer and MessageID was buried in `Metadata map[string]string` - No rate limiting or retry on message sending - No unified media file lifecycle management - Each channel ran its own HTTP server - Group chat trigger filtering logic was scattered across channels **After Refactor (refactor/channel-system branch)**: ``` pkg/channels/ ├── base.go # BaseChannel shared abstraction layer ├── interfaces.go # Optional capability interfaces (TypingCapable, MessageEditor, ReactionCapable, PlaceholderCapable, PlaceholderRecorder) ├── README.md # English documentation ├── README.zh.md # Chinese documentation ├── media.go # MediaSender optional interface ├── webhook.go # WebhookHandler, HealthChecker optional interfaces ├── errors.go # Sentinel errors (ErrNotRunning, ErrRateLimit, ErrTemporary, ErrSendFailed) ├── errutil.go # Error classification helpers ├── registry.go # Factory registry (RegisterFactory / getFactory) ├── manager.go # Unified orchestration: Worker queues, rate limiting, retries, Typing/Placeholder, shared HTTP ├── split.go # Smart long-message splitting (preserves code block integrity) ├── telegram/ # Each channel in its own sub-package │ ├── init.go # Factory registration │ ├── telegram.go # Implementation │ └── telegram_commands.go ├── discord/ │ ├── init.go │ └── discord.go ├── slack/ line/ onebot/ dingtalk/ feishu/ wecom/ qq/ whatsapp/ whatsapp_native/ maixcam/ pico/ │ └── ... pkg/bus/ ├── bus.go # MessageBus (buffer 64, safe close + drain) ├── types.go # Structured message types (Peer, SenderInfo, MediaPart, InboundMessage, OutboundMessage, OutboundMediaMessage) pkg/media/ ├── store.go # MediaStore interface + FileMediaStore implementation (two-phase release, TTL cleanup) pkg/identity/ ├── identity.go # Unified user identity: canonical "platform:id" format + backward-compatible matching ``` ### 1.2 Message Flow Overview ``` ┌────────────┐ InboundMessage ┌───────────┐ LLM + Tools ┌────────────┐ │ Telegram │──┐ │ │ │ │ │ Discord │──┤ PublishInbound() │ │ PublishOutbound() │ │ │ Slack │──┼──────────────────────▶ │ MessageBus │ ◀─────────────────── │ AgentLoop │ │ LINE │──┤ (buffered chan, 64) │ │ (buffered chan, 64) │ │ │ ... │──┘ │ │ │ │ └────────────┘ └─────┬─────┘ └────────────┘ │ SubscribeOutbound() │ SubscribeOutboundMedia() ▼ ┌───────────────────┐ │ Manager │ │ ├── dispatchOutbound() Route to Worker queues │ ├── dispatchOutboundMedia() │ ├── runWorker() Message split + sendWithRetry() │ ├── runMediaWorker() sendMediaWithRetry() │ ├── preSend() Stop Typing + Undo Reaction + Edit Placeholder │ └── runTTLJanitor() Clean up expired Typing/Placeholder └────────┬──────────┘ │ channel.Send() / SendMedia() │ ▼ ┌────────────────┐ │ Platform APIs │ └────────────────┘ ``` ### 1.3 Key Design Principles | Principle | Description | |-----------|-------------| | **Sub-package Isolation** | Each channel is a standalone Go sub-package, depending on `BaseChannel` and interfaces from the `channels` parent package | | **Factory Registration** | Sub-packages self-register via `init()`, Manager looks up factories by name, eliminating import coupling | | **Capability Discovery** | Optional capabilities are declared via interfaces (`MediaSender`, `TypingCapable`, `ReactionCapable`, `PlaceholderCapable`, `MessageEditor`, `WebhookHandler`, `HealthChecker`), discovered by Manager via runtime type assertions | | **Structured Messages** | Peer, MessageID, and SenderInfo promoted from Metadata to first-class fields on InboundMessage | | **Error Classification** | Channels return sentinel errors (`ErrRateLimit`, `ErrTemporary`, etc.), Manager uses these to determine retry strategy | | **Centralized Orchestration** | Rate limiting, message splitting, retries, and Typing/Reaction/Placeholder management are all handled by Manager and BaseChannel; channels only need to implement Send | --- ## Part 2: Migration Guide — From main Branch to Refactored Branch ### 2.1 If You Have Unmerged Channel Changes #### Step 1: Identify which files you modified On the main branch, channel files were directly in `pkg/channels/` top level, e.g.: - `pkg/channels/telegram.go` - `pkg/channels/discord.go` After refactoring, these files have been removed and code moved to corresponding sub-packages: - `pkg/channels/telegram/telegram.go` - `pkg/channels/discord/discord.go` #### Step 2: Understand the structural change mapping | main branch file | Refactored branch location | Changes | |---|---|---| | `pkg/channels/telegram.go` | `pkg/channels/telegram/telegram.go` + `init.go` | Package name changed from `channels` to `telegram` | | `pkg/channels/discord.go` | `pkg/channels/discord/discord.go` + `init.go` | Same as above | | `pkg/channels/manager.go` | `pkg/channels/manager.go` | Extensively rewritten | | _(did not exist)_ | `pkg/channels/base.go` | New shared abstraction layer | | _(did not exist)_ | `pkg/channels/registry.go` | New factory registry | | _(did not exist)_ | `pkg/channels/errors.go` + `errutil.go` | New error classification system | | _(did not exist)_ | `pkg/channels/interfaces.go` | New optional capability interfaces | | _(did not exist)_ | `pkg/channels/media.go` | New MediaSender interface | | _(did not exist)_ | `pkg/channels/webhook.go` | New WebhookHandler/HealthChecker | | _(did not exist)_ | `pkg/channels/whatsapp_native/` | New WhatsApp native mode (whatsmeow) | | _(did not exist)_ | `pkg/channels/split.go` | New message splitting (migrated from utils) | | _(did not exist)_ | `pkg/bus/types.go` | New structured message types | | _(did not exist)_ | `pkg/media/store.go` | New media file lifecycle management | | _(did not exist)_ | `pkg/identity/identity.go` | New unified user identity | #### Step 3: Migrate your channel code Using Telegram as an example, the main changes are: **3a. Package declaration and imports** ```go // Old code (main branch) package channels import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" ) // New code (refactored branch) package telegram import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" // Reference parent package "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" // New "github.com/sipeed/picoclaw/pkg/media" // New (if media support needed) ) ``` **3b. Struct embeds BaseChannel** ```go // Old code: directly held bus, config, etc. fields type TelegramChannel struct { bus *bus.MessageBus config *config.Config running bool allowList []string // ... } // New code: embed BaseChannel, which provides bus, running, allowList, etc. type TelegramChannel struct { *channels.BaseChannel // Embed shared abstraction bot *telego.Bot config *config.Config // ... only channel-specific fields } ``` **3c. Constructor** ```go // Old code: direct assignment func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) { return &TelegramChannel{ bus: bus, config: cfg, allowList: cfg.Channels.Telegram.AllowFrom, // ... }, nil } // New code: use NewBaseChannel + functional options func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) { base := channels.NewBaseChannel( "telegram", // Name cfg.Channels.Telegram, // Raw config (any type) bus, // Message bus cfg.Channels.Telegram.AllowFrom, // Allow list channels.WithMaxMessageLength(4096), // Platform message length limit channels.WithGroupTrigger(cfg.Channels.Telegram.GroupTrigger), // Group trigger config channels.WithReasoningChannelID(cfg.Channels.Telegram.ReasoningChannelID), // Reasoning chain routing ) return &TelegramChannel{ BaseChannel: base, bot: bot, config: cfg, }, nil } ``` **3d. Start/Stop lifecycle** ```go // New code: use SetRunning atomic operation func (c *TelegramChannel) Start(ctx context.Context) error { // ... initialize bot, webhook, etc. c.SetRunning(true) // Must be called after ready go bh.Start() return nil } func (c *TelegramChannel) Stop(ctx context.Context) error { c.SetRunning(false) // Must be called before cleanup // ... stop bot handler, cancel context return nil } ``` **3e. Send method error returns** ```go // Old code: returns plain error func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.running { return fmt.Errorf("not running") } // ... if err != nil { return err } } // New code: must return sentinel errors for Manager to determine retry strategy func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning // ← Manager will not retry } // ... if err != nil { // Use ClassifySendError to wrap error based on HTTP status code return channels.ClassifySendError(statusCode, err) // Or manually wrap: // return fmt.Errorf("%w: %v", channels.ErrTemporary, err) // return fmt.Errorf("%w: %v", channels.ErrRateLimit, err) // return fmt.Errorf("%w: %v", channels.ErrSendFailed, err) } return nil } ``` **3f. Message reception (Inbound)** ```go // Old code: directly construct InboundMessage and publish msg := bus.InboundMessage{ Channel: "telegram", SenderID: senderID, ChatID: chatID, Content: content, Metadata: map[string]string{ "peer_kind": "group", // Routing info buried in metadata "peer_id": chatID, "message_id": msgID, }, } c.bus.PublishInbound(ctx, msg) // New code: use BaseChannel.HandleMessage with structured fields sender := bus.SenderInfo{ Platform: "telegram", PlatformID: strconv.FormatInt(from.ID, 10), CanonicalID: identity.BuildCanonicalID("telegram", strconv.FormatInt(from.ID, 10)), Username: from.Username, DisplayName: from.FirstName, } peer := bus.Peer{ Kind: "group", // or "direct" ID: chatID, } // HandleMessage internally calls IsAllowedSender for permission checks, builds MediaScope, and publishes to bus c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, sender) ``` **3g. Add factory registration (required)** Create `init.go` for your channel: ```go // pkg/channels/telegram/init.go package telegram import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) func init() { channels.RegisterFactory("telegram", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { return NewTelegramChannel(cfg, b) }) } ``` **3h. Import sub-package in Gateway** ```go // cmd/picoclaw/internal/gateway/helpers.go import ( _ "github.com/sipeed/picoclaw/pkg/channels/telegram" // Triggers init() registration _ "github.com/sipeed/picoclaw/pkg/channels/discord" _ "github.com/sipeed/picoclaw/pkg/channels/your_new_channel" // New addition ) ``` #### Step 4: Migrate bus message usage If your code directly reads routing fields from `InboundMessage.Metadata`: ```go // Old code peerKind := msg.Metadata["peer_kind"] peerID := msg.Metadata["peer_id"] msgID := msg.Metadata["message_id"] // New code peerKind := msg.Peer.Kind // First-class field peerID := msg.Peer.ID // First-class field msgID := msg.MessageID // First-class field sender := msg.Sender // bus.SenderInfo struct scope := msg.MediaScope // Media lifecycle scope ``` #### Step 5: Migrate allow-list checks ```go // Old code if !c.isAllowed(senderID) { return } // New code: prefer structured check if !c.IsAllowedSender(sender) { return } // Or fall back to string check: if !c.IsAllowed(senderID) { return } ``` `BaseChannel.HandleMessage` already handles this logic internally — no need to duplicate the check in your channel. ### 2.2 If You Have Manager Modifications The Manager has been completely rewritten. Your modifications will need to account for the new architecture: | Old Manager Responsibility | New Manager Responsibility | |---|---| | Directly construct channels (switch/if-else) | Look up and construct via factory registry | | Directly call channel.Send | Per-channel Worker queues + rate limiting + retries | | No message splitting | Automatic splitting based on MaxMessageLength | | Each channel runs its own HTTP server | Unified shared HTTP server | | No Typing/Placeholder management | Unified preSend handles Typing stop + Reaction undo + Placeholder edit; inbound-side BaseChannel.HandleMessage auto-orchestrates Typing/Reaction/Placeholder | | No TTL cleanup | runTTLJanitor periodically cleans up expired Typing/Reaction/Placeholder entries | ### 2.3 If You Have Agent Loop Modifications Main changes to the Agent Loop: 1. **MediaStore injection**: `agentLoop.SetMediaStore(mediaStore)` — Agent resolves media references produced by tools via MediaStore 2. **ChannelManager injection**: `agentLoop.SetChannelManager(channelManager)` — Agent can query channel state 3. **OutboundMediaMessage**: Agent now sends media messages via `bus.PublishOutboundMedia()` instead of embedding them in text replies 4. **extractPeer**: Routing uses `msg.Peer` structured fields instead of Metadata lookups --- ## Part 3: New Channel Development Guide — Implementing a Channel from Scratch ### 3.1 Minimum Implementation Checklist To add a new chat platform (e.g., `matrix`), you need to: 1. ✅ Create sub-package directory `pkg/channels/matrix/` 2. ✅ Create `init.go` — factory registration 3. ✅ Create `matrix.go` — channel implementation 4. ✅ Add blank import in Gateway helpers 5. ✅ Add config check in Manager.initChannels() 6. ✅ Add config struct in `pkg/config/` ### 3.2 Complete Template #### `pkg/channels/matrix/init.go` ```go package matrix import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) func init() { channels.RegisterFactory("matrix", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { return NewMatrixChannel(cfg, b) }) } ``` #### `pkg/channels/matrix/matrix.go` ```go package matrix import ( "context" "fmt" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" ) // MatrixChannel implements channels.Channel for the Matrix protocol. type MatrixChannel struct { *channels.BaseChannel // Must embed config *config.Config ctx context.Context cancel context.CancelFunc // ... Matrix SDK client, etc. } func NewMatrixChannel(cfg *config.Config, msgBus *bus.MessageBus) (*MatrixChannel, error) { matrixCfg := cfg.Channels.Matrix // Assumes this field exists in config base := channels.NewBaseChannel( "matrix", // Channel name (globally unique) matrixCfg, // Raw config msgBus, // Message bus matrixCfg.AllowFrom, // Allow list channels.WithMaxMessageLength(65536), // Matrix message length limit channels.WithGroupTrigger(matrixCfg.GroupTrigger), channels.WithReasoningChannelID(matrixCfg.ReasoningChannelID), // Reasoning chain routing (optional) ) return &MatrixChannel{ BaseChannel: base, config: cfg, }, nil } // ========== Required Channel Interface Methods ========== func (c *MatrixChannel) Start(ctx context.Context) error { c.ctx, c.cancel = context.WithCancel(ctx) // 1. Initialize Matrix client // 2. Start listening for messages // 3. Mark as running c.SetRunning(true) logger.InfoC("matrix", "Matrix channel started") return nil } func (c *MatrixChannel) Stop(ctx context.Context) error { c.SetRunning(false) if c.cancel != nil { c.cancel() } logger.InfoC("matrix", "Matrix channel stopped") return nil } func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { // 1. Check running state if !c.IsRunning() { return channels.ErrNotRunning } // 2. Send message to Matrix err := c.sendToMatrix(ctx, msg.ChatID, msg.Content) if err != nil { // 3. Must use error classification wrapping // If you have an HTTP status code: // return channels.ClassifySendError(statusCode, err) // If it's a network error: // return channels.ClassifyNetError(err) // If manual classification is needed: return fmt.Errorf("%w: %v", channels.ErrTemporary, err) } return nil } // ========== Incoming Message Handling ========== func (c *MatrixChannel) handleIncoming(roomID, senderID, displayName, content string, msgID string) { // 1. Construct structured sender identity sender := bus.SenderInfo{ Platform: "matrix", PlatformID: senderID, CanonicalID: identity.BuildCanonicalID("matrix", senderID), Username: senderID, DisplayName: displayName, } // 2. Determine Peer type (direct vs group) peer := bus.Peer{ Kind: "group", // or "direct" ID: roomID, } // 3. Group chat filtering (if applicable) isGroup := peer.Kind == "group" if isGroup { isMentioned := false // Detect @mentions based on platform specifics shouldRespond, cleanContent := c.ShouldRespondInGroup(isMentioned, content) if !shouldRespond { return } content = cleanContent } // 4. Handle media attachments (if any) var mediaRefs []string store := c.GetMediaStore() if store != nil { // Download attachment locally → store.Store() → get ref // mediaRefs = append(mediaRefs, ref) } // 5. Call HandleMessage to publish to bus // HandleMessage internally will: // - Check IsAllowedSender/IsAllowed // - Build MediaScope // - Publish InboundMessage c.HandleMessage( c.ctx, peer, msgID, // Platform message ID senderID, // Raw sender ID roomID, // Chat/room ID content, // Message content mediaRefs, // Media reference list nil, // Extra metadata (usually nil) sender, // SenderInfo (variadic parameter) ) } // ========== Internal Methods ========== func (c *MatrixChannel) sendToMatrix(ctx context.Context, roomID, content string) error { // Actual Matrix SDK call return nil } ``` ### 3.3 Optional Capability Interfaces Depending on platform capabilities, your channel can optionally implement the following interfaces: #### MediaSender — Send Media Attachments ```go // If the platform supports sending images/files/audio/video func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } store := c.GetMediaStore() if store == nil { return fmt.Errorf("no media store: %w", channels.ErrSendFailed) } for _, part := range msg.Parts { localPath, err := store.Resolve(part.Ref) if err != nil { logger.ErrorCF("matrix", "Failed to resolve media", map[string]any{ "ref": part.Ref, "error": err.Error(), }) continue } // Call the appropriate API based on part.Type ("image"|"audio"|"video"|"file") switch part.Type { case "image": // Upload image to Matrix default: // Upload file to Matrix } } return nil } ``` #### TypingCapable — Typing Indicator ```go // If the platform supports "typing..." indicators func (c *MatrixChannel) StartTyping(ctx context.Context, chatID string) (stop func(), err error) { // Call Matrix API to send typing indicator // The returned stop function must be idempotent stopped := false return func() { if !stopped { stopped = true // Call Matrix API to stop typing } }, nil } ``` #### ReactionCapable — Message Reaction Indicator ```go // If the platform supports adding emoji reactions to inbound messages (e.g., Slack's 👀, OneBot's emoji 289) func (c *MatrixChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error) { // Call Matrix API to add reaction to message // The returned undo function removes the reaction, must be idempotent err = c.addReaction(chatID, messageID, "eyes") if err != nil { return func() {}, err } return func() { c.removeReaction(chatID, messageID, "eyes") }, nil } ``` #### MessageEditor — Message Editing ```go // If the platform supports editing sent messages (used for Placeholder replacement) func (c *MatrixChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error { // Call Matrix API to edit message return nil } ``` #### PlaceholderCapable — Placeholder Messages ```go // If the platform supports sending placeholder messages (e.g. "Thinking... 💭"), // and the channel also implements MessageEditor, then Manager's preSend will // automatically edit the placeholder into the final response on outbound. // SendPlaceholder checks PlaceholderConfig.Enabled internally; // returning ("", nil) means skip. func (c *MatrixChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { cfg := c.config.Channels.Matrix.Placeholder if !cfg.Enabled { return "", nil } text := cfg.Text if text == "" { text = "Thinking... 💭" } // Call Matrix API to send placeholder message msg, err := c.sendText(ctx, chatID, text) if err != nil { return "", err } return msg.ID, nil } ``` #### WebhookHandler — HTTP Webhook Reception ```go // If the channel receives messages via webhook (rather than long-polling/WebSocket) func (c *MatrixChannel) WebhookPath() string { return "/webhook/matrix" // Path will be registered on the shared HTTP server } func (c *MatrixChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Handle webhook request } ``` #### HealthChecker — Health Check Endpoint ```go func (c *MatrixChannel) HealthPath() string { return "/health/matrix" } func (c *MatrixChannel) HealthHandler(w http.ResponseWriter, r *http.Request) { if c.IsRunning() { w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) } else { w.WriteHeader(http.StatusServiceUnavailable) } } ``` ### 3.4 Inbound-side Typing/Reaction/Placeholder Auto-orchestration `BaseChannel.HandleMessage` automatically detects whether the channel implements `TypingCapable`, `ReactionCapable`, and/or `PlaceholderCapable` **before** publishing the inbound message, and triggers the corresponding indicators. The three pipelines are completely independent and do not interfere with each other: ```go // Automatically executed inside BaseChannel.HandleMessage (no manual calls needed): if c.owner != nil && c.placeholderRecorder != nil { // Typing — independent pipeline if tc, ok := c.owner.(TypingCapable); ok { if stop, err := tc.StartTyping(ctx, chatID); err == nil { c.placeholderRecorder.RecordTypingStop(c.name, chatID, stop) } } // Reaction — independent pipeline if rc, ok := c.owner.(ReactionCapable); ok && messageID != "" { if undo, err := rc.ReactToMessage(ctx, chatID, messageID); err == nil { c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo) } } // Placeholder — independent pipeline if pc, ok := c.owner.(PlaceholderCapable); ok { if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" { c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID) } } } ``` **This means**: - Channels implementing `TypingCapable` (Telegram, Discord, LINE, Pico) do not need to manually call `StartTyping` + `RecordTypingStop` in `handleMessage` - Channels implementing `ReactionCapable` (Slack, OneBot) do not need to manually call `AddReaction` + `RecordTypingStop` in `handleMessage` - Channels implementing `PlaceholderCapable` (Telegram, Discord, Pico) do not need to manually send placeholder messages and call `RecordPlaceholder` in `handleMessage` - Channels only need to implement the corresponding interface; `HandleMessage` handles orchestration automatically - Channels that don't implement these interfaces are unaffected (type assertions will fail and be skipped) - `PlaceholderCapable`'s `SendPlaceholder` method internally decides whether to send based on the configured `PlaceholderConfig.Enabled`; returning `("", nil)` skips registration **Owner Injection**: Manager automatically calls `SetOwner(ch)` in `initChannel` to inject the concrete channel into BaseChannel — no manual setup required from developers. When the Agent finishes processing a message, Manager's `preSend` automatically: 1. Calls the recorded `stop()` to stop Typing 2. Calls the recorded `undo()` to undo Reaction 3. If there is a Placeholder and the channel implements `MessageEditor`, attempts to edit the Placeholder with the final reply (skipping Send) ### 3.5 Register Configuration and Gateway Integration #### Add configuration in `pkg/config/config.go` ```go type ChannelsConfig struct { // ... existing channels Matrix MatrixChannelConfig `json:"matrix"` } type MatrixChannelConfig struct { Enabled bool `json:"enabled"` HomeServer string `json:"home_server"` Token string `json:"token"` AllowFrom []string `json:"allow_from"` GroupTrigger GroupTriggerConfig `json:"group_trigger"` Placeholder PlaceholderConfig `json:"placeholder"` ReasoningChannelID string `json:"reasoning_channel_id"` } ``` #### Add entry in Manager.initChannels() ```go // In the initChannels() method of pkg/channels/manager.go if m.config.Channels.Matrix.Enabled && m.config.Channels.Matrix.Token != "" { m.initChannel("matrix", "Matrix") } ``` > **Note**: If your channel has multiple modes (like WhatsApp Bridge vs Native), branch in initChannels based on config: > ```go > if cfg.UseNative { > m.initChannel("whatsapp_native", "WhatsApp Native") > } else { > m.initChannel("whatsapp", "WhatsApp") > } > ``` #### Add blank import in Gateway ```go // cmd/picoclaw/internal/gateway/helpers.go import ( _ "github.com/sipeed/picoclaw/pkg/channels/matrix" ) ``` --- ## Part 4: Core Subsystem Details ### 4.1 MessageBus **Files**: `pkg/bus/bus.go`, `pkg/bus/types.go` ```go type MessageBus struct { inbound chan InboundMessage // buffer = 64 outbound chan OutboundMessage // buffer = 64 outboundMedia chan OutboundMediaMessage // buffer = 64 done chan struct{} // Close signal closed atomic.Bool // Prevents double-close } ``` **Key Behaviors**: | Method | Behavior | |--------|----------| | `PublishInbound(ctx, msg)` | Check closed → send to inbound channel → block/timeout/close | | `ConsumeInbound(ctx)` | Read from inbound → block/close/cancel | | `PublishOutbound(ctx, msg)` | Send to outbound channel | | `SubscribeOutbound(ctx)` | Read from outbound (called by Manager dispatcher) | | `PublishOutboundMedia(ctx, msg)` | Send to outboundMedia channel | | `SubscribeOutboundMedia(ctx)` | Read from outboundMedia (called by Manager media dispatcher) | | `Close()` | CAS close → close(done) → drain all channels (**does not close the channels themselves** to avoid concurrent send-on-closed panic) | **Design Notes**: - Buffer size increased from 16 to 64 to reduce blocking under burst load - `Close()` does not close the underlying channels (only closes the `done` signal channel), because there may be concurrent `Publish` goroutines - Drain loop ensures buffered messages are not silently dropped ### 4.2 Structured Message Types **File**: `pkg/bus/types.go` ```go // Routing peer type Peer struct { Kind string `json:"kind"` // "direct" | "group" | "channel" | "" ID string `json:"id"` } // Sender identity information type SenderInfo struct { Platform string `json:"platform,omitempty"` // "telegram", "discord", ... PlatformID string `json:"platform_id,omitempty"` // Platform-native ID CanonicalID string `json:"canonical_id,omitempty"` // "platform:id" canonical format Username string `json:"username,omitempty"` DisplayName string `json:"display_name,omitempty"` } // Inbound message type InboundMessage struct { Channel string // Source channel name SenderID string // Sender ID (prefer CanonicalID) Sender SenderInfo // Structured sender info ChatID string // Chat/room ID Content string // Message text Media []string // Media reference list (media://...) Peer Peer // Routing peer (first-class field) MessageID string // Platform message ID (first-class field) MediaScope string // Media lifecycle scope SessionKey string // Session key Metadata map[string]string // Only for channel-specific extensions } // Outbound text message type OutboundMessage struct { Channel string ChatID string Content string } // Outbound media message type OutboundMediaMessage struct { Channel string ChatID string Parts []MediaPart } // Media part type MediaPart struct { Type string // "image" | "audio" | "video" | "file" Ref string // "media://uuid" Caption string Filename string ContentType string } ``` ### 4.3 BaseChannel **File**: `pkg/channels/base.go` BaseChannel is the shared abstraction layer for all channels, providing the following capabilities: | Method/Feature | Description | |---|---| | `Name() string` | Channel name | | `IsRunning() bool` | Atomically read running state | | `SetRunning(bool)` | Atomically set running state | | `MaxMessageLength() int` | Message length limit (rune count), 0 = unlimited | | `ReasoningChannelID() string` | Reasoning chain routing target channel ID (empty = no routing) | | `IsAllowed(senderID string) bool` | Legacy allow-list check (supports `"id\|username"` and `"@username"` formats) | | `IsAllowedSender(sender SenderInfo) bool` | New allow-list check (delegates to `identity.MatchAllowed`) | | `ShouldRespondInGroup(isMentioned, content) (bool, string)` | Unified group chat trigger filtering logic | | `HandleMessage(...)` | Unified inbound message handling: permission check → build MediaScope → auto-trigger Typing/Reaction/Placeholder → publish to Bus | | `SetMediaStore(s) / GetMediaStore()` | MediaStore injected by Manager | | `SetPlaceholderRecorder(r) / GetPlaceholderRecorder()` | PlaceholderRecorder injected by Manager | | `SetOwner(ch)` | Concrete channel reference injected by Manager (used for Typing/Reaction/Placeholder type assertions in HandleMessage) | **Functional Options**: ```go channels.WithMaxMessageLength(4096) // Set platform message length limit channels.WithGroupTrigger(groupTriggerCfg) // Set group trigger configuration channels.WithReasoningChannelID(id) // Set reasoning chain routing target channel ``` ### 4.4 Factory Registry **File**: `pkg/channels/registry.go` ```go type ChannelFactory func(cfg *config.Config, bus *bus.MessageBus) (Channel, error) func RegisterFactory(name string, f ChannelFactory) // Called in sub-package init() func getFactory(name string) (ChannelFactory, bool) // Called internally by Manager ``` The factory registry is protected by `sync.RWMutex` and registrations occur during `init()` phase (completed at process startup). Manager looks up factories by name in `initChannel()` and calls them. ### 4.5 Error Classification and Retries **Files**: `pkg/channels/errors.go`, `pkg/channels/errutil.go` #### Sentinel Errors ```go var ( ErrNotRunning = errors.New("channel not running") // Permanent: do not retry ErrRateLimit = errors.New("rate limited") // Fixed delay: retry after 1s ErrTemporary = errors.New("temporary failure") // Exponential backoff: 500ms * 2^attempt, max 8s ErrSendFailed = errors.New("send failed") // Permanent: do not retry ) ``` #### Error Classification Helpers ```go // Automatically classify based on HTTP status code func ClassifySendError(statusCode int, rawErr error) error { // 429 → ErrRateLimit // 5xx → ErrTemporary // 4xx → ErrSendFailed } // Wrap network errors as temporary func ClassifyNetError(err error) error { // → ErrTemporary } ``` #### Manager Retry Strategy (`sendWithRetry`) ``` Max retries: 3 Rate limit delay: 1 second Base backoff: 500 milliseconds Max backoff: 8 seconds Retry logic: ErrNotRunning → Fail immediately, no retry ErrSendFailed → Fail immediately, no retry ErrRateLimit → Wait 1s → retry ErrTemporary → Wait 500ms * 2^attempt (max 8s) → retry Other unknown → Wait 500ms * 2^attempt (max 8s) → retry ``` ### 4.6 Manager Orchestration **File**: `pkg/channels/manager.go` #### Per-channel Worker Architecture ```go type channelWorker struct { ch Channel // Channel instance queue chan bus.OutboundMessage // Outbound text queue (buffered 16) mediaQueue chan bus.OutboundMediaMessage // Outbound media queue (buffered 16) done chan struct{} // Text worker completion signal mediaDone chan struct{} // Media worker completion signal limiter *rate.Limiter // Per-channel rate limiter } ``` #### Per-channel Rate Limit Configuration ```go var channelRateConfig = map[string]float64{ "telegram": 20, // 20 msg/s "discord": 1, // 1 msg/s "slack": 1, // 1 msg/s "line": 10, // 10 msg/s } // Default: 10 msg/s // burst = max(1, ceil(rate/2)) ``` #### Lifecycle Management ``` StartAll: 1. Iterate registered channels → channel.Start(ctx) 2. Create channelWorker for each successfully started channel 3. Start goroutines: - runWorker (per-channel outbound text) - runMediaWorker (per-channel outbound media) - dispatchOutbound (route from bus to worker queues) - dispatchOutboundMedia (route from bus to media worker queues) - runTTLJanitor (every 10s clean up expired typing/reaction/placeholder) 4. Start shared HTTP server (if configured) StopAll: 1. Shut down shared HTTP server (5s timeout) 2. Cancel dispatcher context 3. Close text worker queues → wait for drain to complete 4. Close media worker queues → wait for drain to complete 5. Stop each channel (channel.Stop) ``` #### Typing/Reaction/Placeholder Management ```go // Manager implements PlaceholderRecorder interface func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) // Inbound side: BaseChannel.HandleMessage auto-orchestrates // BaseChannel.HandleMessage, before PublishInbound, auto-triggers via owner type assertions: // - TypingCapable.StartTyping → RecordTypingStop // - ReactionCapable.ReactToMessage → RecordReactionUndo // - PlaceholderCapable.SendPlaceholder → RecordPlaceholder // All three are independent and do not interfere with each other. Channels don't need to call these manually. // Outbound side: pre-send processing func (m *Manager) preSend(ctx, name, msg, ch) bool { key := name + ":" + msg.ChatID // 1. Stop Typing (call stored stop function) // 2. Undo Reaction (call stored undo function) // 3. Attempt to edit Placeholder (if channel implements MessageEditor) // Success → return true (skip Send) // Failure → return false (proceed with Send) } ``` Manager storage is fully separated; three pipelines do not interfere: ```go Manager { typingStops sync.Map // "channel:chatID" → typingEntry ← manages TypingCapable reactionUndos sync.Map // "channel:chatID" → reactionEntry ← manages ReactionCapable placeholders sync.Map // "channel:chatID" → placeholderEntry } ``` TTL Cleanup: - Typing stop functions: 5-minute TTL (auto-calls stop and deletes on expiry) - Reaction undo functions: 5-minute TTL (auto-calls undo and deletes on expiry) - Placeholder IDs: 10-minute TTL (deletes on expiry) - Cleanup interval: 10 seconds ### 4.7 Message Splitting **File**: `pkg/channels/split.go` `SplitMessage(content string, maxLen int) []string` Smart splitting strategy: 1. Calculate effective split point = maxLen - 10% buffer (to reserve space for code block closure) 2. Prefer splitting at newlines 3. Otherwise split at spaces/tabs 4. Detect unclosed code blocks (` ``` `) 5. If a code block is unclosed: - Attempt to extend to maxLen to include the closing fence - If the code block is too long, inject close/reopen fences (`\n```\n` + header) - Last resort: split before the code block starts ### 4.8 MediaStore **File**: `pkg/media/store.go` ```go type MediaStore interface { Store(localPath string, meta MediaMeta, scope string) (ref string, err error) Resolve(ref string) (localPath string, err error) ResolveWithMeta(ref string) (localPath string, meta MediaMeta, err error) ReleaseAll(scope string) error } ``` **FileMediaStore Implementation**: - Pure in-memory mapping, no file copy/move - Reference format: `media://` - Scope format: `channel:chatID:messageID` (generated by `BuildMediaScope`) - **Two-phase operation**: - Phase 1 (holding lock): collect and delete entries from map - Phase 2 (no lock): delete files from disk - Purpose: minimize lock contention - **TTL Cleanup**: `NewFileMediaStoreWithCleanup` → `Start()` launches background cleanup goroutine - Cleanup interval and max TTL are controlled by configuration ### 4.9 Identity **File**: `pkg/identity/identity.go` ```go // Build canonical ID func BuildCanonicalID(platform, platformID string) string // → "telegram:123456" // Parse canonical ID func ParseCanonicalID(canonical string) (platform, id string, ok bool) // Match against allow list (backward-compatible) func MatchAllowed(sender bus.SenderInfo, allowed string) bool ``` `MatchAllowed` supported allow-list formats: | Format | Matching | |--------|----------| | `"123456"` | Matches `sender.PlatformID` | | `"@alice"` | Matches `sender.Username` | | `"123456\|alice"` | Matches PlatformID or Username (legacy format compatibility) | | `"telegram:123456"` | Exact match on `sender.CanonicalID` (new format) | ### 4.10 Shared HTTP Server **File**: `pkg/channels/manager.go`'s `SetupHTTPServer` Manager creates a single `http.Server` and auto-discovers and registers: - Channels implementing `WebhookHandler` → mounted at `wh.WebhookPath()` - Channels implementing `HealthChecker` → mounted at `hc.HealthPath()` - Global health endpoint registered by `health.Server.RegisterOnMux` Timeout configuration: ReadTimeout = 30s, WriteTimeout = 30s --- ## Part 5: Key Design Decisions and Conventions ### 5.1 Mandatory Conventions 1. **Error classification is a contract**: A channel's `Send` method **must** return sentinel errors (or wrap them). Manager's retry strategy relies entirely on `errors.Is` checks. Returning unclassified errors will cause Manager to treat them as "unknown errors" (exponential backoff retry). 2. **SetRunning is a lifecycle signal**: **Must** call `c.SetRunning(true)` after successful `Start`, and **must** call `c.SetRunning(false)` at the beginning of `Stop`. **Must** check `c.IsRunning()` in `Send` and return `ErrNotRunning`. 3. **HandleMessage includes permission checks**: Do not perform your own permission checks before calling `HandleMessage` (unless you need platform-specific preprocessing before the check). `HandleMessage` already calls `IsAllowedSender`/`IsAllowed` internally. 4. **Message splitting is handled by Manager**: A channel's `Send` method does not need to handle long message splitting. Manager automatically splits based on `MaxMessageLength()` before calling `Send`. Channels only need to declare the limit via `WithMaxMessageLength`. 5. **Typing/Reaction/Placeholder is handled by BaseChannel + Manager automatically**: A channel's `Send` method does not need to manage Typing stop, Reaction undo, or Placeholder editing. `BaseChannel.HandleMessage` auto-triggers `TypingCapable`, `ReactionCapable`, and `PlaceholderCapable` on the inbound side (via `owner` type assertions); Manager's `preSend` auto-stops Typing, undoes Reaction, and edits Placeholder on the outbound side. Channels only need to implement the corresponding interfaces. 6. **Factory registration belongs in init()**: Each sub-package must have an `init.go` file calling `channels.RegisterFactory`. Gateway must trigger registration via blank imports (`_ "pkg/channels/xxx"`). ### 5.2 Metadata Field Usage Conventions **Do NOT put the following information in Metadata anymore**: - `peer_kind` / `peer_id` → Use `InboundMessage.Peer` - `message_id` → Use `InboundMessage.MessageID` - `sender_platform` / `sender_username` → Use `InboundMessage.Sender` **Metadata should only be used for**: - Channel-specific extension information (e.g., Telegram's `reply_to_message_id`) - Temporary information that doesn't fit into structured fields ### 5.3 Concurrency Safety Conventions - `BaseChannel.running`: Uses `atomic.Bool`, thread-safe - `Manager.channels` / `Manager.workers`: Protected by `sync.RWMutex` - `Manager.placeholders` / `Manager.typingStops` / `Manager.reactionUndos`: Uses `sync.Map` - `MessageBus.closed`: Uses `atomic.Bool` - `FileMediaStore`: Uses `sync.RWMutex`, two-phase operation to minimize lock-hold time - Channel Worker queue: Go channel, inherently concurrent-safe ### 5.4 Testing Conventions Existing test files: - `pkg/channels/base_test.go` — BaseChannel unit tests - `pkg/channels/manager_test.go` — Manager unit tests - `pkg/channels/split_test.go` — Message splitting tests - `pkg/channels/errors_test.go` — Error type tests - `pkg/channels/errutil_test.go` — Error classification tests To add tests for a new channel: ```bash go test ./pkg/channels/matrix/ -v # Sub-package tests go test ./pkg/channels/ -run TestSpecific -v # Framework tests make test # Full test suite ``` --- ## Appendix: Complete File Listing and Interface Quick Reference ### A.1 Framework Layer Files | File | Responsibility | |------|---------------| | `pkg/channels/base.go` | BaseChannel struct, Channel interface, MessageLengthProvider, BaseChannelOption, HandleMessage | | `pkg/channels/interfaces.go` | TypingCapable, MessageEditor, ReactionCapable, PlaceholderCapable, PlaceholderRecorder interfaces | | `pkg/channels/media.go` | MediaSender interface | | `pkg/channels/webhook.go` | WebhookHandler, HealthChecker interfaces | | `pkg/channels/errors.go` | ErrNotRunning, ErrRateLimit, ErrTemporary, ErrSendFailed sentinels | | `pkg/channels/errutil.go` | ClassifySendError, ClassifyNetError helpers | | `pkg/channels/registry.go` | RegisterFactory, getFactory factory registry | | `pkg/channels/manager.go` | Manager: Worker queues, rate limiting, retries, preSend, shared HTTP, TTL janitor | | `pkg/channels/split.go` | SplitMessage long-message splitting | | `pkg/bus/bus.go` | MessageBus implementation | | `pkg/bus/types.go` | Peer, SenderInfo, InboundMessage, OutboundMessage, OutboundMediaMessage, MediaPart | | `pkg/media/store.go` | MediaStore interface, FileMediaStore implementation | | `pkg/identity/identity.go` | BuildCanonicalID, ParseCanonicalID, MatchAllowed | ### A.2 Channel Sub-packages | Sub-package | Registered Name | Optional Interfaces | |-------------|----------------|-------------------| | `pkg/channels/telegram/` | `"telegram"` | TypingCapable, PlaceholderCapable, MessageEditor, MediaSender | | `pkg/channels/discord/` | `"discord"` | TypingCapable, PlaceholderCapable, MessageEditor, MediaSender | | `pkg/channels/slack/` | `"slack"` | ReactionCapable, MediaSender | | `pkg/channels/line/` | `"line"` | TypingCapable, MediaSender, WebhookHandler | | `pkg/channels/onebot/` | `"onebot"` | ReactionCapable, MediaSender | | `pkg/channels/dingtalk/` | `"dingtalk"` | — | | `pkg/channels/feishu/` | `"feishu"` | — (architecture-specific build tags: `feishu_32.go` / `feishu_64.go`) | | `pkg/channels/wecom/` | `"wecom"` | WebhookHandler, HealthChecker | | `pkg/channels/wecom/` | `"wecom_app"` | MediaSender, WebhookHandler, HealthChecker | | `pkg/channels/qq/` | `"qq"` | — | | `pkg/channels/whatsapp/` | `"whatsapp"` | — (Bridge mode) | | `pkg/channels/whatsapp_native/` | `"whatsapp_native"` | — (Native whatsmeow mode) | | `pkg/channels/maixcam/` | `"maixcam"` | — | | `pkg/channels/pico/` | `"pico"` | TypingCapable, PlaceholderCapable, MessageEditor, WebhookHandler | ### A.3 Interface Quick Reference ```go // ===== Required ===== type Channel interface { Name() string Start(ctx context.Context) error Stop(ctx context.Context) error Send(ctx context.Context, msg bus.OutboundMessage) error IsRunning() bool IsAllowed(senderID string) bool IsAllowedSender(sender bus.SenderInfo) bool ReasoningChannelID() string } // ===== Optional ===== type MediaSender interface { SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error } type TypingCapable interface { StartTyping(ctx context.Context, chatID string) (stop func(), err error) } type ReactionCapable interface { ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error) } type PlaceholderCapable interface { SendPlaceholder(ctx context.Context, chatID string) (messageID string, err error) } type MessageEditor interface { EditMessage(ctx context.Context, chatID, messageID, content string) error } type WebhookHandler interface { WebhookPath() string http.Handler } type HealthChecker interface { HealthPath() string HealthHandler(w http.ResponseWriter, r *http.Request) } type MessageLengthProvider interface { MaxMessageLength() int } // ===== Injected by Manager ===== type PlaceholderRecorder interface { RecordPlaceholder(channel, chatID, placeholderID string) RecordTypingStop(channel, chatID string, stop func()) RecordReactionUndo(channel, chatID string, undo func()) } ``` ### A.4 Gateway Startup Sequence (Complete Bootstrap Flow) ```go // 1. Create core components msgBus := bus.NewMessageBus() provider := providers.CreateProvider(cfg) agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) // 2. Create media store (with TTL cleanup) mediaStore := media.NewFileMediaStoreWithCleanup(cleanerConfig) mediaStore.Start() // 3. Create Channel Manager (triggers initChannels → factory lookup → construct → inject MediaStore/PlaceholderRecorder/Owner) channelManager := channels.NewManager(cfg, msgBus, mediaStore) // 4. Inject references agentLoop.SetChannelManager(channelManager) agentLoop.SetMediaStore(mediaStore) // 5. Configure shared HTTP server channelManager.SetupHTTPServer(addr, healthServer) // 6. Start channelManager.StartAll(ctx) // Start channels + workers + dispatchers + HTTP server go agentLoop.Run(ctx) // Start Agent message loop // 7. Shutdown (signal-triggered) cancel() // Cancel context msgBus.Close() // Signal close + drain channelManager.StopAll(shutdownCtx) // Stop HTTP + workers + channels mediaStore.Stop() // Stop TTL cleanup agentLoop.Stop() // Stop Agent ``` ### A.5 Per-channel Rate Limit Reference | Channel | Rate (msg/s) | Burst | |---------|-------------|-------| | telegram | 20 | 10 | | discord | 1 | 1 | | slack | 1 | 1 | | line | 10 | 5 | | _others_ | 10 (default) | 5 | ### A.6 Known Limitations and Caveats 1. **Media cleanup temporarily disabled**: The `ReleaseAll` call in the Agent loop is commented out (`refactor(loop): disable media cleanup to prevent premature file deletion`) because session boundaries are not yet clearly defined. TTL cleanup remains active. 2. **Feishu architecture-specific compilation**: The Feishu channel uses build tags to distinguish 32-bit and 64-bit architectures (`feishu_32.go` / `feishu_64.go`). Feishu uses the SDK's WebSocket mode (not HTTP webhook), so it does not implement `WebhookHandler`. 3. **WeCom has two factories**: `"wecom"` (Bot mode, webhook only) and `"wecom_app"` (App mode, supports MediaSender) are registered separately. Both implement `WebhookHandler` and `HealthChecker`. 4. **Pico Protocol**: `pkg/channels/pico/` implements a custom PicoClaw native protocol channel that receives messages via WebSocket webhook (`/pico/ws`). 5. **WhatsApp has two modes**: `"whatsapp"` (Bridge mode, communicates via external bridge URL) and `"whatsapp_native"` (native whatsmeow mode, connects directly to WhatsApp). Manager selects which to initialize based on `WhatsAppConfig.UseNative`. 6. **DingTalk uses Stream mode**: DingTalk uses the SDK's Stream/WebSocket mode (not HTTP webhook), so it does not implement `WebhookHandler`. 7. **PlaceholderConfig vs implementation**: `PlaceholderConfig` appears in 6 channel configs (Telegram, Discord, Slack, LINE, OneBot, Pico), but only channels that implement both `PlaceholderCapable` + `MessageEditor` (Telegram, Discord, Pico) can actually use placeholder message editing. The rest are reserved fields. 8. **ReasoningChannelID**: Most channel configs include a `reasoning_channel_id` field to route LLM reasoning/thinking output to a designated channel (WhatsApp, Telegram, Feishu, Discord, MaixCam, QQ, DingTalk, Slack, LINE, OneBot, WeCom, WeComApp). Note: `PicoConfig` does not currently expose this field. `BaseChannel` exposes this via the `WithReasoningChannelID` option and `ReasoningChannelID()` method. ================================================ FILE: pkg/channels/README.zh.md ================================================ # PicoClaw Channel System:完整开发指南 > **影响范围**: `pkg/channels/`, `pkg/bus/`, `pkg/media/`, `pkg/identity/`, `cmd/picoclaw/internal/gateway/` --- ## 目录 - [第一部分:架构总览](#第一部分架构总览) - [第二部分:迁移指南——从 main 分支迁移到重构分支](#第二部分迁移指南从-main-分支迁移到重构分支) - [第三部分:新 Channel 开发指南——从零实现一个新 Channel](#第三部分新-channel-开发指南从零实现一个新-channel) - [第四部分:核心子系统详解](#第四部分核心子系统详解) - [第五部分:关键设计决策与约定](#第五部分关键设计决策与约定) - [附录:完整文件清单与接口速查表](#附录完整文件清单与接口速查表) --- ## 第一部分:架构总览 ### 1.1 重构前后对比 **重构前(main 分支)**: ``` pkg/channels/ ├── telegram.go # 每个 channel 直接放在 channels 包内 ├── discord.go ├── slack.go ├── manager.go # Manager 直接引用各 channel 类型 ├── ... ``` - Channel 实现全部在 `pkg/channels/` 包的顶层 - Manager 通过 `switch` 或 `if-else` 链条直接构造各 channel - Peer、MessageID 等路由信息埋在 `Metadata map[string]string` 中 - 消息发送没有速率限制和重试 - 没有统一的媒体文件生命周期管理 - 各 channel 各自启动 HTTP 服务器 - 群聊触发过滤逻辑分散在各 channel 中 **重构后(refactor/channel-system 分支)**: ``` pkg/channels/ ├── base.go # BaseChannel 共享抽象层 ├── interfaces.go # 可选能力接口(TypingCapable, MessageEditor, ReactionCapable, PlaceholderCapable, PlaceholderRecorder) ├── README.md # 英文文档 ├── README.zh.md # 中文文档 ├── media.go # MediaSender 可选接口 ├── webhook.go # WebhookHandler, HealthChecker 可选接口 ├── errors.go # 错误哨兵值(ErrNotRunning, ErrRateLimit, ErrTemporary, ErrSendFailed) ├── errutil.go # 错误分类帮助函数 ├── registry.go # 工厂注册表(RegisterFactory / getFactory) ├── manager.go # 统一编排:Worker 队列、速率限制、重试、Typing/Placeholder、共享 HTTP ├── split.go # 长消息智能分割(保留代码块完整性) ├── telegram/ # 每个 channel 独立子包 │ ├── init.go # 工厂注册 │ ├── telegram.go # 实现 │ └── telegram_commands.go ├── discord/ │ ├── init.go │ └── discord.go ├── slack/ line/ onebot/ dingtalk/ feishu/ wecom/ qq/ whatsapp/ whatsapp_native/ maixcam/ pico/ │ └── ... pkg/bus/ ├── bus.go # MessageBus(缓冲区 64,安全关闭+排水) ├── types.go # 结构化消息类型(Peer, SenderInfo, MediaPart, InboundMessage, OutboundMessage, OutboundMediaMessage) pkg/media/ ├── store.go # MediaStore 接口 + FileMediaStore 实现(两阶段释放,TTL 清理) pkg/identity/ ├── identity.go # 统一用户身份:规范 "platform:id" 格式 + 向后兼容匹配 ``` ### 1.2 消息流转全景图 ``` ┌────────────┐ InboundMessage ┌───────────┐ LLM + Tools ┌────────────┐ │ Telegram │──┐ │ │ │ │ │ Discord │──┤ PublishInbound() │ │ PublishOutbound() │ │ │ Slack │──┼──────────────────────▶ │ MessageBus │ ◀─────────────────── │ AgentLoop │ │ LINE │──┤ (buffered chan, 64) │ │ (buffered chan, 64) │ │ │ ... │──┘ │ │ │ │ └────────────┘ └─────┬─────┘ └────────────┘ │ SubscribeOutbound() │ SubscribeOutboundMedia() ▼ ┌───────────────────┐ │ Manager │ │ ├── dispatchOutbound() 路由到 Worker 队列 │ ├── dispatchOutboundMedia() │ ├── runWorker() 消息分割 + sendWithRetry() │ ├── runMediaWorker() sendMediaWithRetry() │ ├── preSend() 停止 Typing + 撤销 Reaction + 编辑 Placeholder │ └── runTTLJanitor() 清理过期 Typing/Placeholder └────────┬──────────┘ │ channel.Send() / SendMedia() │ ▼ ┌────────────────┐ │ 各平台 API/SDK │ └────────────────┘ ``` ### 1.3 关键设计原则 | 原则 | 说明 | |------|------| | **子包隔离** | 每个 channel 一个独立 Go 子包,依赖 `channels` 父包提供的 `BaseChannel` 和接口 | | **工厂注册** | 各子包通过 `init()` 自注册,Manager 通过名字查找工厂,消除 import 耦合 | | **能力发现** | 可选能力通过接口(`MediaSender`, `TypingCapable`, `ReactionCapable`, `PlaceholderCapable`, `MessageEditor`, `WebhookHandler`, `HealthChecker`)声明,Manager 运行时类型断言发现 | | **结构化消息** | Peer、MessageID、SenderInfo 从 Metadata 提升为 InboundMessage 的一等字段 | | **错误分类** | Channel 返回哨兵错误(`ErrRateLimit`, `ErrTemporary` 等),Manager 据此决定重试策略 | | **集中编排** | 速率限制、消息分割、重试、Typing/Reaction/Placeholder 全部由 Manager 和 BaseChannel 统一处理,Channel 只负责 Send | --- ## 第二部分:迁移指南——从 main 分支迁移到重构分支 ### 2.1 如果你有未合并的 Channel 修改 #### 步骤 1:确认你修改了哪些文件 在 main 分支上,Channel 文件直接位于 `pkg/channels/` 顶层,例如: - `pkg/channels/telegram.go` - `pkg/channels/discord.go` 重构后,这些文件已被删除,代码移动到了对应子包: - `pkg/channels/telegram/telegram.go` - `pkg/channels/discord/discord.go` #### 步骤 2:理解结构变化映射 | main 分支文件 | 重构分支位置 | 变化 | |---|---|---| | `pkg/channels/telegram.go` | `pkg/channels/telegram/telegram.go` + `init.go` | 包名从 `channels` 变为 `telegram` | | `pkg/channels/discord.go` | `pkg/channels/discord/discord.go` + `init.go` | 同上 | | `pkg/channels/manager.go` | `pkg/channels/manager.go` | 大幅重写 | | _(不存在)_ | `pkg/channels/base.go` | 新增共享抽象层 | | _(不存在)_ | `pkg/channels/registry.go` | 新增工厂注册表 | | _(不存在)_ | `pkg/channels/errors.go` + `errutil.go` | 新增错误分类体系 | | _(不存在)_ | `pkg/channels/interfaces.go` | 新增可选能力接口 | | _(不存在)_ | `pkg/channels/media.go` | 新增 MediaSender 接口 | | _(不存在)_ | `pkg/channels/webhook.go` | 新增 WebhookHandler/HealthChecker | | _(不存在)_ | `pkg/channels/whatsapp_native/` | 新增 WhatsApp 原生模式(whatsmeow) | | _(不存在)_ | `pkg/channels/split.go` | 新增消息分割(从 utils 迁入) | | _(不存在)_ | `pkg/bus/types.go` | 新增结构化消息类型 | | _(不存在)_ | `pkg/media/store.go` | 新增媒体文件生命周期管理 | | _(不存在)_ | `pkg/identity/identity.go` | 新增统一用户身份 | #### 步骤 3:迁移你的 Channel 代码 以 Telegram 为例,主要改动项: **3a. 包声明和导入** ```go // 旧代码(main 分支) package channels import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" ) // 新代码(重构分支) package telegram import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" // 引用父包 "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" // 新增 "github.com/sipeed/picoclaw/pkg/media" // 新增(如需媒体) ) ``` **3b. 结构体嵌入 BaseChannel** ```go // 旧代码:直接持有 bus、config 等字段 type TelegramChannel struct { bus *bus.MessageBus config *config.Config running bool allowList []string // ... } // 新代码:嵌入 BaseChannel,它提供 bus、running、allowList 等 type TelegramChannel struct { *channels.BaseChannel // 嵌入共享抽象 bot *telego.Bot config *config.Config // ... 只保留 channel 特有字段 } ``` **3c. 构造函数** ```go // 旧代码:直接赋值 func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) { return &TelegramChannel{ bus: bus, config: cfg, allowList: cfg.Channels.Telegram.AllowFrom, // ... }, nil } // 新代码:使用 NewBaseChannel + 功能选项 func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) { base := channels.NewBaseChannel( "telegram", // 名称 cfg.Channels.Telegram, // 原始配置(any 类型) bus, // 消息总线 cfg.Channels.Telegram.AllowFrom, // 允许列表 channels.WithMaxMessageLength(4096), // 平台消息长度上限 channels.WithGroupTrigger(cfg.Channels.Telegram.GroupTrigger), // 群聊触发配置 channels.WithReasoningChannelID(cfg.Channels.Telegram.ReasoningChannelID), // 思维链路由 ) return &TelegramChannel{ BaseChannel: base, bot: bot, config: cfg, }, nil } ``` **3d. Start/Stop 生命周期** ```go // 新代码:使用 SetRunning 原子操作 func (c *TelegramChannel) Start(ctx context.Context) error { // ... 初始化 bot、webhook 等 c.SetRunning(true) // 必须在就绪后调用 go bh.Start() return nil } func (c *TelegramChannel) Stop(ctx context.Context) error { c.SetRunning(false) // 必须在清理前调用 // ... 停止 bot handler、取消 context return nil } ``` **3e. Send 方法的错误返回** ```go // 旧代码:返回普通 error func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.running { return fmt.Errorf("not running") } // ... if err != nil { return err } } // 新代码:必须返回哨兵错误,供 Manager 判断重试策略 func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning // ← Manager 不会重试 } // ... if err != nil { // 使用 ClassifySendError 根据 HTTP 状态码包装错误 return channels.ClassifySendError(statusCode, err) // 或手动包装: // return fmt.Errorf("%w: %v", channels.ErrTemporary, err) // return fmt.Errorf("%w: %v", channels.ErrRateLimit, err) // return fmt.Errorf("%w: %v", channels.ErrSendFailed, err) } return nil } ``` **3f. 消息接收(Inbound)** ```go // 旧代码:直接构造 InboundMessage 并发布 msg := bus.InboundMessage{ Channel: "telegram", SenderID: senderID, ChatID: chatID, Content: content, Metadata: map[string]string{ "peer_kind": "group", // 路由信息埋在 metadata "peer_id": chatID, "message_id": msgID, }, } c.bus.PublishInbound(ctx, msg) // 新代码:使用 BaseChannel.HandleMessage,传入结构化字段 sender := bus.SenderInfo{ Platform: "telegram", PlatformID: strconv.FormatInt(from.ID, 10), CanonicalID: identity.BuildCanonicalID("telegram", strconv.FormatInt(from.ID, 10)), Username: from.Username, DisplayName: from.FirstName, } peer := bus.Peer{ Kind: "group", // 或 "direct" ID: chatID, } // HandleMessage 内部调用 IsAllowedSender 检查权限,构建 MediaScope,发布到 bus c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, sender) ``` **3g. 添加工厂注册(必需)** 为你的 channel 创建 `init.go`: ```go // pkg/channels/telegram/init.go package telegram import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) func init() { channels.RegisterFactory("telegram", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { return NewTelegramChannel(cfg, b) }) } ``` **3h. 在 Gateway 中导入子包** ```go // cmd/picoclaw/internal/gateway/helpers.go import ( _ "github.com/sipeed/picoclaw/pkg/channels/telegram" // 触发 init() 注册 _ "github.com/sipeed/picoclaw/pkg/channels/discord" _ "github.com/sipeed/picoclaw/pkg/channels/your_new_channel" // 新增 ) ``` #### 步骤 4:迁移 Bus 消息使用方式 如果你的代码直接读取 `InboundMessage.Metadata` 中的路由字段: ```go // 旧代码 peerKind := msg.Metadata["peer_kind"] peerID := msg.Metadata["peer_id"] msgID := msg.Metadata["message_id"] // 新代码 peerKind := msg.Peer.Kind // 一等字段 peerID := msg.Peer.ID // 一等字段 msgID := msg.MessageID // 一等字段 sender := msg.Sender // bus.SenderInfo 结构体 scope := msg.MediaScope // 媒体生命周期作用域 ``` #### 步骤 5:迁移允许列表检查 ```go // 旧代码 if !c.isAllowed(senderID) { return } // 新代码:优先使用结构化检查 if !c.IsAllowedSender(sender) { return } // 或回退到字符串检查: if !c.IsAllowed(senderID) { return } ``` `BaseChannel.HandleMessage` 方法内部已经处理了这个逻辑,无需在 channel 中重复检查。 ### 2.2 如果你有 Manager 的修改 Manager 已被完全重写。你的修改需要理解新架构: | 旧 Manager 职责 | 新 Manager 职责 | |---|---| | 直接构造 channel(switch/if-else) | 通过工厂注册表查找并构造 | | 直接调用 channel.Send | 通过 per-channel Worker 队列 + 速率限制 + 重试 | | 无消息分割 | 自动根据 MaxMessageLength 分割长消息 | | 各 channel 自建 HTTP 服务器 | 统一共享 HTTP 服务器 | | 无 Typing/Placeholder 管理 | 统一 preSend 处理 Typing 停止 + Reaction 撤销 + Placeholder 编辑;入站侧 BaseChannel.HandleMessage 自动编排 Typing/Reaction/Placeholder | | 无 TTL 清理 | runTTLJanitor 定期清理过期 Typing/Reaction/Placeholder 条目 | ### 2.3 如果你有 Agent Loop 的修改 Agent Loop 的主要变化: 1. **MediaStore 注入**:`agentLoop.SetMediaStore(mediaStore)` — Agent 通过 MediaStore 解析工具产生的媒体引用 2. **ChannelManager 注入**:`agentLoop.SetChannelManager(channelManager)` — Agent 可查询 channel 状态 3. **OutboundMediaMessage**:Agent 现在通过 `bus.PublishOutboundMedia()` 发送媒体消息,而非嵌入文本回复 4. **extractPeer**:路由使用 `msg.Peer` 结构化字段而非 Metadata 查找 --- ## 第三部分:新 Channel 开发指南——从零实现一个新 Channel ### 3.1 最小实现清单 要添加一个新的聊天平台(例如 `matrix`),你需要: 1. ✅ 创建子包目录 `pkg/channels/matrix/` 2. ✅ 创建 `init.go` — 工厂注册 3. ✅ 创建 `matrix.go` — Channel 实现 4. ✅ 在 Gateway helpers 中添加 blank import 5. ✅ 在 Manager.initChannels() 中添加配置检查 6. ✅ 在 `pkg/config/` 中添加配置结构体 ### 3.2 完整模板 #### `pkg/channels/matrix/init.go` ```go package matrix import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) func init() { channels.RegisterFactory("matrix", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { return NewMatrixChannel(cfg, b) }) } ``` #### `pkg/channels/matrix/matrix.go` ```go package matrix import ( "context" "fmt" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" ) // MatrixChannel implements channels.Channel for the Matrix protocol. type MatrixChannel struct { *channels.BaseChannel // 必须嵌入 config *config.Config ctx context.Context cancel context.CancelFunc // ... Matrix SDK 客户端等 } func NewMatrixChannel(cfg *config.Config, msgBus *bus.MessageBus) (*MatrixChannel, error) { matrixCfg := cfg.Channels.Matrix // 假设配置中有此字段 base := channels.NewBaseChannel( "matrix", // channel 名称(全局唯一) matrixCfg, // 原始配置 msgBus, // 消息总线 matrixCfg.AllowFrom, // 允许列表 channels.WithMaxMessageLength(65536), // Matrix 消息长度限制 channels.WithGroupTrigger(matrixCfg.GroupTrigger), channels.WithReasoningChannelID(matrixCfg.ReasoningChannelID), // 思维链路由(可选) ) return &MatrixChannel{ BaseChannel: base, config: cfg, }, nil } // ========== 必须实现的 Channel 接口方法 ========== func (c *MatrixChannel) Start(ctx context.Context) error { c.ctx, c.cancel = context.WithCancel(ctx) // 1. 初始化 Matrix 客户端 // 2. 开始监听消息 // 3. 标记为运行中 c.SetRunning(true) logger.InfoC("matrix", "Matrix channel started") return nil } func (c *MatrixChannel) Stop(ctx context.Context) error { c.SetRunning(false) if c.cancel != nil { c.cancel() } logger.InfoC("matrix", "Matrix channel stopped") return nil } func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { // 1. 检查运行状态 if !c.IsRunning() { return channels.ErrNotRunning } // 2. 发送消息到 Matrix err := c.sendToMatrix(ctx, msg.ChatID, msg.Content) if err != nil { // 3. 必须使用错误分类包装 // 如果你有 HTTP 状态码: // return channels.ClassifySendError(statusCode, err) // 如果是网络错误: // return channels.ClassifyNetError(err) // 如果需要手动分类: return fmt.Errorf("%w: %v", channels.ErrTemporary, err) } return nil } // ========== 消息接收处理 ========== func (c *MatrixChannel) handleIncoming(roomID, senderID, displayName, content string, msgID string) { // 1. 构造结构化发送者身份 sender := bus.SenderInfo{ Platform: "matrix", PlatformID: senderID, CanonicalID: identity.BuildCanonicalID("matrix", senderID), Username: senderID, DisplayName: displayName, } // 2. 确定 Peer 类型(直聊 vs 群聊) peer := bus.Peer{ Kind: "group", // 或 "direct" ID: roomID, } // 3. 群聊过滤(如适用) isGroup := peer.Kind == "group" if isGroup { isMentioned := false // 根据平台特性检测 @提及 shouldRespond, cleanContent := c.ShouldRespondInGroup(isMentioned, content) if !shouldRespond { return } content = cleanContent } // 4. 处理媒体附件(如有) var mediaRefs []string store := c.GetMediaStore() if store != nil { // 下载附件到本地 → store.Store() → 获取 ref // mediaRefs = append(mediaRefs, ref) } // 5. 调用 HandleMessage 发布到 bus // HandleMessage 内部会: // - 检查 IsAllowedSender/IsAllowed // - 构建 MediaScope // - 发布 InboundMessage c.HandleMessage( c.ctx, peer, msgID, // 平台消息 ID senderID, // 原始发送者 ID roomID, // 聊天/房间 ID content, // 消息内容 mediaRefs, // 媒体引用列表 nil, // 额外 metadata(通常 nil) sender, // SenderInfo(variadic 参数) ) } // ========== 内部方法 ========== func (c *MatrixChannel) sendToMatrix(ctx context.Context, roomID, content string) error { // 实际的 Matrix SDK 调用 return nil } ``` ### 3.3 可选能力接口 根据平台能力,你的 Channel 可以选择性实现以下接口: #### MediaSender — 发送媒体附件 ```go // 如果平台支持发送图片/文件/音频/视频 func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } store := c.GetMediaStore() if store == nil { return fmt.Errorf("no media store: %w", channels.ErrSendFailed) } for _, part := range msg.Parts { localPath, err := store.Resolve(part.Ref) if err != nil { logger.ErrorCF("matrix", "Failed to resolve media", map[string]any{ "ref": part.Ref, "error": err.Error(), }) continue } // 根据 part.Type ("image"|"audio"|"video"|"file") 调用对应 API switch part.Type { case "image": // 上传图片到 Matrix default: // 上传文件到 Matrix } } return nil } ``` #### TypingCapable — Typing 指示器 ```go // 如果平台支持 "正在输入..." 提示 func (c *MatrixChannel) StartTyping(ctx context.Context, chatID string) (stop func(), err error) { // 调用 Matrix API 发送 typing 指示器 // 返回的 stop 函数必须是幂等的 stopped := false return func() { if !stopped { stopped = true // 调用 Matrix API 停止 typing } }, nil } ``` #### ReactionCapable — 消息反应指示器 ```go // 如果平台支持对入站消息添加 emoji 反应(如 Slack 的 👀、OneBot 的表情 289) func (c *MatrixChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error) { // 调用 Matrix API 添加反应到消息 // 返回的 undo 函数移除反应,必须是幂等的 err = c.addReaction(chatID, messageID, "eyes") if err != nil { return func() {}, err } return func() { c.removeReaction(chatID, messageID, "eyes") }, nil } ``` #### MessageEditor — 消息编辑 ```go // 如果平台支持编辑已发送的消息(用于 Placeholder 替换) func (c *MatrixChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error { // 调用 Matrix API 编辑消息 return nil } ``` #### PlaceholderCapable — 占位消息 ```go // 如果平台支持发送占位消息(如 "Thinking... 💭"),并且实现了 MessageEditor, // 则 Manager 的 preSend 会在出站时自动将占位消息编辑为最终回复。 // SendPlaceholder 内部根据 PlaceholderConfig.Enabled 决定是否发送; // 返回 ("", nil) 表示跳过。 func (c *MatrixChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { cfg := c.config.Channels.Matrix.Placeholder if !cfg.Enabled { return "", nil } text := cfg.Text if text == "" { text = "Thinking... 💭" } // 调用 Matrix API 发送占位消息 msg, err := c.sendText(ctx, chatID, text) if err != nil { return "", err } return msg.ID, nil } ``` #### WebhookHandler — HTTP Webhook 接收 ```go // 如果 channel 通过 webhook 接收消息(而非长轮询/WebSocket) func (c *MatrixChannel) WebhookPath() string { return "/webhook/matrix" // 路径会被注册到共享 HTTP 服务器 } func (c *MatrixChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { // 处理 webhook 请求 } ``` #### HealthChecker — 健康检查端点 ```go func (c *MatrixChannel) HealthPath() string { return "/health/matrix" } func (c *MatrixChannel) HealthHandler(w http.ResponseWriter, r *http.Request) { if c.IsRunning() { w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) } else { w.WriteHeader(http.StatusServiceUnavailable) } } ``` ### 3.4 入站侧 Typing/Reaction/Placeholder 自动编排 `BaseChannel.HandleMessage` 在发布入站消息**之前**,自动检测 channel 是否实现了 `TypingCapable`、`ReactionCapable` 和/或 `PlaceholderCapable`,并触发相应的指示器。三条管道完全独立,互不干扰: ```go // BaseChannel.HandleMessage 内部自动执行(无需 channel 手动调用): if c.owner != nil && c.placeholderRecorder != nil { // Typing — 独立管道 if tc, ok := c.owner.(TypingCapable); ok { if stop, err := tc.StartTyping(ctx, chatID); err == nil { c.placeholderRecorder.RecordTypingStop(c.name, chatID, stop) } } // Reaction — 独立管道 if rc, ok := c.owner.(ReactionCapable); ok && messageID != "" { if undo, err := rc.ReactToMessage(ctx, chatID, messageID); err == nil { c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo) } } // Placeholder — 独立管道 if pc, ok := c.owner.(PlaceholderCapable); ok { if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" { c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID) } } } ``` **这意味着**: - 实现 `TypingCapable` 的 channel(Telegram、Discord、LINE、Pico)无需在 `handleMessage` 中手动调用 `StartTyping` + `RecordTypingStop` - 实现 `ReactionCapable` 的 channel(Slack、OneBot)无需在 `handleMessage` 中手动调用 `AddReaction` + `RecordTypingStop` - 实现 `PlaceholderCapable` 的 channel(Telegram、Discord、Pico)无需在 `handleMessage` 中手动发送占位消息并调用 `RecordPlaceholder` - Channel 只需实现对应接口,`HandleMessage` 会自动完成编排 - 不实现这些接口的 channel 不受影响(类型断言会失败,跳过) - `PlaceholderCapable` 的 `SendPlaceholder` 方法内部根据配置的 `PlaceholderConfig.Enabled` 决定是否发送;返回 `("", nil)` 时跳过注册 **Owner 注入**:Manager 在 `initChannel` 中自动调用 `SetOwner(ch)` 将具体 channel 注入 BaseChannel,无需开发者手动设置。 当 Agent 处理完消息后,Manager 的 `preSend` 会自动: 1. 调用已记录的 `stop()` 停止 Typing 2. 调用已记录的 `undo()` 撤销 Reaction 3. 如果有 Placeholder,且 channel 实现了 `MessageEditor`,尝试编辑 Placeholder 为最终回复(跳过 Send) ### 3.5 注册配置和 Gateway 接入 #### 在 `pkg/config/config.go` 中添加配置 ```go type ChannelsConfig struct { // ... 现有 channels Matrix MatrixChannelConfig `json:"matrix"` } type MatrixChannelConfig struct { Enabled bool `json:"enabled"` HomeServer string `json:"home_server"` Token string `json:"token"` AllowFrom []string `json:"allow_from"` GroupTrigger GroupTriggerConfig `json:"group_trigger"` Placeholder PlaceholderConfig `json:"placeholder"` ReasoningChannelID string `json:"reasoning_channel_id"` } ``` #### 在 Manager.initChannels() 中添加入口 ```go // pkg/channels/manager.go 的 initChannels() 方法中 if m.config.Channels.Matrix.Enabled && m.config.Channels.Matrix.Token != "" { m.initChannel("matrix", "Matrix") } ``` > **注意**:如果你的 channel 有多种模式(如 WhatsApp Bridge vs Native),需要在 initChannels 中根据配置分支: > ```go > if cfg.UseNative { > m.initChannel("whatsapp_native", "WhatsApp Native") > } else { > m.initChannel("whatsapp", "WhatsApp") > } > ``` #### 在 Gateway 中添加 blank import ```go // cmd/picoclaw/internal/gateway/helpers.go import ( _ "github.com/sipeed/picoclaw/pkg/channels/matrix" ) ``` --- ## 第四部分:核心子系统详解 ### 4.1 MessageBus **文件**:`pkg/bus/bus.go`、`pkg/bus/types.go` ```go type MessageBus struct { inbound chan InboundMessage // 缓冲区 = 64 outbound chan OutboundMessage // 缓冲区 = 64 outboundMedia chan OutboundMediaMessage // 缓冲区 = 64 done chan struct{} // 关闭信号 closed atomic.Bool // 防止重复关闭 } ``` **关键行为**: | 方法 | 行为 | |------|------| | `PublishInbound(ctx, msg)` | 检查 closed → 发送到 inbound channel → 阻塞/超时/关闭 | | `ConsumeInbound(ctx)` | 从 inbound 读取 → 阻塞/关闭/取消 | | `PublishOutbound(ctx, msg)` | 发送到 outbound channel | | `SubscribeOutbound(ctx)` | 从 outbound 读取(Manager dispatcher 调用) | | `PublishOutboundMedia(ctx, msg)` | 发送到 outboundMedia channel | | `SubscribeOutboundMedia(ctx)` | 从 outboundMedia 读取(Manager media dispatcher 调用) | | `Close()` | CAS 关闭 → close(done) → 排水所有 channel(**不关闭 channel 本身**,避免并发 send-on-closed panic) | **设计要点**: - 缓冲区从 16 增至 64,减少突发负载下的阻塞 - `Close()` 不关闭底层 channel(只关闭 `done` 信号通道),因为可能有正在并发 `Publish` 的 goroutine - 排水循环确保 buffered 消息不被静默丢弃 ### 4.2 结构化消息类型 **文件**:`pkg/bus/types.go` ```go // 路由对等体 type Peer struct { Kind string `json:"kind"` // "direct" | "group" | "channel" | "" ID string `json:"id"` } // 发送者身份信息 type SenderInfo struct { Platform string `json:"platform,omitempty"` // "telegram", "discord", ... PlatformID string `json:"platform_id,omitempty"` // 平台原始 ID CanonicalID string `json:"canonical_id,omitempty"` // "platform:id" 规范格式 Username string `json:"username,omitempty"` DisplayName string `json:"display_name,omitempty"` } // 入站消息 type InboundMessage struct { Channel string // 来源 channel 名称 SenderID string // 发送者 ID(优先使用 CanonicalID) Sender SenderInfo // 结构化发送者信息 ChatID string // 聊天/房间 ID Content string // 消息文本 Media []string // 媒体引用列表(media://...) Peer Peer // 路由对等体(一等字段) MessageID string // 平台消息 ID(一等字段) MediaScope string // 媒体生命周期作用域 SessionKey string // 会话键 Metadata map[string]string // 仅用于 channel 特有扩展 } // 出站文本消息 type OutboundMessage struct { Channel string ChatID string Content string } // 出站媒体消息 type OutboundMediaMessage struct { Channel string ChatID string Parts []MediaPart } // 媒体片段 type MediaPart struct { Type string // "image" | "audio" | "video" | "file" Ref string // "media://uuid" Caption string Filename string ContentType string } ``` ### 4.3 BaseChannel **文件**:`pkg/channels/base.go` BaseChannel 是所有 channel 的共享抽象层,提供以下能力: | 方法/特性 | 说明 | |---|---| | `Name() string` | Channel 名称 | | `IsRunning() bool` | 原子读取运行状态 | | `SetRunning(bool)` | 原子设置运行状态 | | `MaxMessageLength() int` | 消息长度限制(rune 计数),0 = 无限制 | | `ReasoningChannelID() string` | 思维链路由目标 channel ID(空 = 不路由) | | `IsAllowed(senderID string) bool` | 旧格式允许列表检查(支持 `"id\|username"` 和 `"@username"` 格式) | | `IsAllowedSender(sender SenderInfo) bool` | 新格式允许列表检查(委托给 `identity.MatchAllowed`) | | `ShouldRespondInGroup(isMentioned, content) (bool, string)` | 统一群聊触发过滤逻辑 | | `HandleMessage(...)` | 统一入站消息处理:权限检查 → 构建 MediaScope → 自动触发 Typing/Reaction/Placeholder → 发布到 Bus | | `SetMediaStore(s) / GetMediaStore()` | Manager 注入的媒体存储 | | `SetPlaceholderRecorder(r) / GetPlaceholderRecorder()` | Manager 注入的占位符记录器 | | `SetOwner(ch) ` | Manager 注入的具体 channel 引用(用于 HandleMessage 内部的 Typing/Reaction/Placeholder 类型断言) | **功能选项**: ```go channels.WithMaxMessageLength(4096) // 设置平台消息长度限制 channels.WithGroupTrigger(groupTriggerCfg) // 设置群聊触发配置 channels.WithReasoningChannelID(id) // 设置思维链路由目标 channel ``` ### 4.4 工厂注册表 **文件**:`pkg/channels/registry.go` ```go type ChannelFactory func(cfg *config.Config, bus *bus.MessageBus) (Channel, error) func RegisterFactory(name string, f ChannelFactory) // 子包 init() 中调用 func getFactory(name string) (ChannelFactory, bool) // Manager 内部调用 ``` 工厂注册表使用 `sync.RWMutex` 保护,在 `init()` 阶段注册(进程启动时完成)。Manager 在 `initChannel()` 中通过名字查找工厂并调用它。 ### 4.5 错误分类与重试 **文件**:`pkg/channels/errors.go`、`pkg/channels/errutil.go` #### 哨兵错误 ```go var ( ErrNotRunning = errors.New("channel not running") // 永久:不重试 ErrRateLimit = errors.New("rate limited") // 固定延迟:1s 后重试 ErrTemporary = errors.New("temporary failure") // 指数退避:500ms * 2^attempt,最大 8s ErrSendFailed = errors.New("send failed") // 永久:不重试 ) ``` #### 错误分类帮助函数 ```go // 根据 HTTP 状态码自动分类 func ClassifySendError(statusCode int, rawErr error) error { // 429 → ErrRateLimit // 5xx → ErrTemporary // 4xx → ErrSendFailed } // 网络错误统一包装为临时错误 func ClassifyNetError(err error) error { // → ErrTemporary } ``` #### Manager 重试策略(`sendWithRetry`) ``` 最大重试次数: 3 速率限制延迟: 1 秒 基础退避: 500 毫秒 最大退避: 8 秒 重试逻辑: ErrNotRunning → 立即失败,不重试 ErrSendFailed → 立即失败,不重试 ErrRateLimit → 等待 1s → 重试 ErrTemporary → 等待 500ms * 2^attempt(最大 8s) → 重试 其他未知错误 → 等待 500ms * 2^attempt(最大 8s) → 重试 ``` ### 4.6 Manager 编排 **文件**:`pkg/channels/manager.go` #### Per-channel Worker 架构 ```go type channelWorker struct { ch Channel // channel 实例 queue chan bus.OutboundMessage // 出站文本队列(缓冲 16) mediaQueue chan bus.OutboundMediaMessage // 出站媒体队列(缓冲 16) done chan struct{} // 文本 worker 完成信号 mediaDone chan struct{} // 媒体 worker 完成信号 limiter *rate.Limiter // per-channel 速率限制器 } ``` #### Per-channel 速率限制配置 ```go var channelRateConfig = map[string]float64{ "telegram": 20, // 20 msg/s "discord": 1, // 1 msg/s "slack": 1, // 1 msg/s "line": 10, // 10 msg/s } // 默认: 10 msg/s // burst = max(1, ceil(rate/2)) ``` #### 生命周期管理 ``` StartAll: 1. 遍历已注册 channels → channel.Start(ctx) 2. 为每个启动成功的 channel 创建 channelWorker 3. 启动 goroutines: - runWorker (per-channel 出站文本) - runMediaWorker (per-channel 出站媒体) - dispatchOutbound (从 bus 路由到 worker 队列) - dispatchOutboundMedia (从 bus 路由到 media worker 队列) - runTTLJanitor (每 10s 清理过期 typing/reaction/placeholder) 4. 启动共享 HTTP 服务器(如已配置) StopAll: 1. 关闭共享 HTTP 服务器(5s 超时) 2. 取消 dispatcher context 3. 关闭 text worker 队列 → 等待排水完成 4. 关闭 media worker 队列 → 等待排水完成 5. 停止每个 channel(channel.Stop) ``` #### Typing/Reaction/Placeholder 管理 ```go // Manager 实现 PlaceholderRecorder 接口 func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) // 入站侧:BaseChannel.HandleMessage 自动编排 // BaseChannel.HandleMessage 在 PublishInbound 之前,通过 owner 类型断言自动触发: // - TypingCapable.StartTyping → RecordTypingStop // - ReactionCapable.ReactToMessage → RecordReactionUndo // - PlaceholderCapable.SendPlaceholder → RecordPlaceholder // 三者独立,互不干扰。Channel 无需手动调用。 // 出站侧:发送前处理 func (m *Manager) preSend(ctx, name, msg, ch) bool { key := name + ":" + msg.ChatID // 1. 停止 Typing(调用存储的 stop 函数) // 2. 撤销 Reaction(调用存储的 undo 函数) // 3. 尝试编辑 Placeholder(如果 channel 实现了 MessageEditor) // 成功 → return true(跳过 Send) // 失败 → return false(继续 Send) } ``` Manager 存储完全分离,三条管道互不干扰: ```go Manager { typingStops sync.Map // "channel:chatID" → typingEntry ← 管 TypingCapable reactionUndos sync.Map // "channel:chatID" → reactionEntry ← 管 ReactionCapable placeholders sync.Map // "channel:chatID" → placeholderEntry } ``` TTL 清理: - Typing 停止函数:5 分钟 TTL(到期后自动调用 stop 并删除) - Reaction 撤销函数:5 分钟 TTL(到期后自动调用 undo 并删除) - Placeholder ID:10 分钟 TTL(到期后删除) - 清理间隔:10 秒 ### 4.7 消息分割 **文件**:`pkg/channels/split.go` `SplitMessage(content string, maxLen int) []string` 智能分割策略: 1. 计算有效分割点 = maxLen - 10% 缓冲区(为代码块闭合留空间) 2. 优先在换行符处分割 3. 其次在空格/制表符处分割 4. 检测未闭合的代码块(` ``` `) 5. 如果代码块未闭合: - 尝试扩展到 maxLen 以包含闭合围栏 - 如果代码块太长,注入闭合/重开围栏(`\n```\n` + header) - 最后手段:在代码块开始前分割 ### 4.8 MediaStore **文件**:`pkg/media/store.go` ```go type MediaStore interface { Store(localPath string, meta MediaMeta, scope string) (ref string, err error) Resolve(ref string) (localPath string, err error) ResolveWithMeta(ref string) (localPath string, meta MediaMeta, err error) ReleaseAll(scope string) error } ``` **FileMediaStore 实现**: - 纯内存映射,不复制/移动文件 - 引用格式:`media://` - Scope 格式:`channel:chatID:messageID`(由 `BuildMediaScope` 生成) - **两阶段操作**: - Phase 1(持锁):从 map 中收集并删除条目 - Phase 2(无锁):从磁盘删除文件 - 目的:最小化锁争用 - **TTL 清理**:`NewFileMediaStoreWithCleanup` → `Start()` 启动后台清理协程 - 清理间隔和最大存活时间由配置控制 ### 4.9 Identity **文件**:`pkg/identity/identity.go` ```go // 构建规范 ID func BuildCanonicalID(platform, platformID string) string // → "telegram:123456" // 解析规范 ID func ParseCanonicalID(canonical string) (platform, id string, ok bool) // 匹配允许列表(向后兼容) func MatchAllowed(sender bus.SenderInfo, allowed string) bool ``` `MatchAllowed` 支持的允许列表格式: | 格式 | 匹配方式 | |------|----------| | `"123456"` | 匹配 `sender.PlatformID` | | `"@alice"` | 匹配 `sender.Username` | | `"123456\|alice"` | 匹配 PlatformID 或 Username(旧格式兼容) | | `"telegram:123456"` | 精确匹配 `sender.CanonicalID`(新格式) | ### 4.10 共享 HTTP 服务器 **文件**:`pkg/channels/manager.go` 的 `SetupHTTPServer` Manager 创建单一 `http.Server`,自动发现和注册: - 实现 `WebhookHandler` 的 channel → 挂载到 `wh.WebhookPath()` - 实现 `HealthChecker` 的 channel → 挂载到 `hc.HealthPath()` - Health 全局端点由 `health.Server.RegisterOnMux` 注册 超时配置:ReadTimeout = 30s, WriteTimeout = 30s --- ## 第五部分:关键设计决策与约定 ### 5.1 必须遵守的约定 1. **错误分类是合约**:Channel 的 `Send` 方法**必须**返回哨兵错误(或包装它们)。Manager 的重试策略完全依赖 `errors.Is` 检查。如果返回未分类的错误,Manager 会按"未知错误"处理(指数退避重试)。 2. **SetRunning 是生命周期信号**:`Start` 成功后**必须**调用 `c.SetRunning(true)`,`Stop` 开始时**必须**调用 `c.SetRunning(false)`。`Send` 中**必须**检查 `c.IsRunning()` 并返回 `ErrNotRunning`。 3. **HandleMessage 包含权限检查**:不要在调用 `HandleMessage` 之前自行进行权限检查(除非你需要在检查前做平台特定的预处理)。`HandleMessage` 内部已经调用 `IsAllowedSender`/`IsAllowed`。 4. **消息分割由 Manager 处理**:Channel 的 `Send` 方法不需要处理长消息分割。Manager 会在调用 `Send` 之前根据 `MaxMessageLength()` 自动分割。Channel 只需通过 `WithMaxMessageLength` 声明限制。 5. **Typing/Reaction/Placeholder 由 BaseChannel + Manager 自动处理**:Channel 的 `Send` 方法不需要管理 Typing 停止、Reaction 撤销或 Placeholder 编辑。`BaseChannel.HandleMessage` 在入站侧自动触发 `TypingCapable`、`ReactionCapable` 和 `PlaceholderCapable`(通过 `owner` 类型断言);Manager 的 `preSend` 在出站侧自动停止 Typing、撤销 Reaction、编辑 Placeholder。Channel 只需实现对应接口即可。 6. **工厂注册在 init() 中**:每个子包必须有 `init.go` 文件调用 `channels.RegisterFactory`。Gateway 必须通过 blank import(`_ "pkg/channels/xxx"`)触发注册。 ### 5.2 Metadata 字段使用约定 **不要再把以下信息放入 Metadata**: - `peer_kind` / `peer_id` → 使用 `InboundMessage.Peer` - `message_id` → 使用 `InboundMessage.MessageID` - `sender_platform` / `sender_username` → 使用 `InboundMessage.Sender` **Metadata 仅用于**: - Channel 特有的扩展信息(如 Telegram 的 `reply_to_message_id`) - 不适合放入结构化字段的临时信息 ### 5.3 并发安全约定 - `BaseChannel.running`:使用 `atomic.Bool`,线程安全 - `Manager.channels` / `Manager.workers`:使用 `sync.RWMutex` 保护 - `Manager.placeholders` / `Manager.typingStops` / `Manager.reactionUndos`:使用 `sync.Map` - `MessageBus.closed`:使用 `atomic.Bool` - `FileMediaStore`:使用 `sync.RWMutex`,两阶段操作减少持锁时间 - Channel Worker queue:Go channel,天然并发安全 ### 5.4 测试约定 已有测试文件: - `pkg/channels/base_test.go` — BaseChannel 单元测试 - `pkg/channels/manager_test.go` — Manager 单元测试 - `pkg/channels/split_test.go` — 消息分割测试 - `pkg/channels/errors_test.go` — 错误类型测试 - `pkg/channels/errutil_test.go` — 错误分类测试 为新 channel 添加测试时: ```bash go test ./pkg/channels/matrix/ -v # 子包测试 go test ./pkg/channels/ -run TestSpecific -v # 框架测试 make test # 全量测试 ``` --- ## 附录:完整文件清单与接口速查表 ### A.1 框架层文件 | 文件 | 职责 | |------|------| | `pkg/channels/base.go` | BaseChannel 结构体、Channel 接口、MessageLengthProvider、BaseChannelOption、HandleMessage | | `pkg/channels/interfaces.go` | TypingCapable、MessageEditor、ReactionCapable、PlaceholderCapable、PlaceholderRecorder 接口 | | `pkg/channels/media.go` | MediaSender 接口 | | `pkg/channels/webhook.go` | WebhookHandler、HealthChecker 接口 | | `pkg/channels/errors.go` | ErrNotRunning、ErrRateLimit、ErrTemporary、ErrSendFailed 哨兵 | | `pkg/channels/errutil.go` | ClassifySendError、ClassifyNetError 帮助函数 | | `pkg/channels/registry.go` | RegisterFactory、getFactory 工厂注册表 | | `pkg/channels/manager.go` | Manager:Worker 队列、速率限制、重试、preSend、共享 HTTP、TTL janitor | | `pkg/channels/split.go` | SplitMessage 长消息分割 | | `pkg/bus/bus.go` | MessageBus 实现 | | `pkg/bus/types.go` | Peer、SenderInfo、InboundMessage、OutboundMessage、OutboundMediaMessage、MediaPart | | `pkg/media/store.go` | MediaStore 接口、FileMediaStore 实现 | | `pkg/identity/identity.go` | BuildCanonicalID、ParseCanonicalID、MatchAllowed | ### A.2 Channel 子包 | 子包 | 注册名 | 可选接口 | |------|--------|----------| | `pkg/channels/telegram/` | `"telegram"` | TypingCapable, PlaceholderCapable, MessageEditor, MediaSender | | `pkg/channels/discord/` | `"discord"` | TypingCapable, PlaceholderCapable, MessageEditor, MediaSender | | `pkg/channels/slack/` | `"slack"` | ReactionCapable, MediaSender | | `pkg/channels/line/` | `"line"` | TypingCapable, MediaSender, WebhookHandler | | `pkg/channels/onebot/` | `"onebot"` | ReactionCapable, MediaSender | | `pkg/channels/dingtalk/` | `"dingtalk"` | — | | `pkg/channels/feishu/` | `"feishu"` | — (架构特定 build tags: `feishu_32.go` / `feishu_64.go`) | | `pkg/channels/wecom/` | `"wecom"` | WebhookHandler, HealthChecker | | `pkg/channels/wecom/` | `"wecom_app"` | MediaSender, WebhookHandler, HealthChecker | | `pkg/channels/qq/` | `"qq"` | — | | `pkg/channels/whatsapp/` | `"whatsapp"` | — (Bridge 模式) | | `pkg/channels/whatsapp_native/` | `"whatsapp_native"` | — (原生 whatsmeow 模式) | | `pkg/channels/maixcam/` | `"maixcam"` | — | | `pkg/channels/pico/` | `"pico"` | TypingCapable, PlaceholderCapable, MessageEditor, WebhookHandler | ### A.3 接口速查表 ```go // ===== 必须实现 ===== type Channel interface { Name() string Start(ctx context.Context) error Stop(ctx context.Context) error Send(ctx context.Context, msg bus.OutboundMessage) error IsRunning() bool IsAllowed(senderID string) bool IsAllowedSender(sender bus.SenderInfo) bool ReasoningChannelID() string } // ===== 可选实现 ===== type MediaSender interface { SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error } type TypingCapable interface { StartTyping(ctx context.Context, chatID string) (stop func(), err error) } type ReactionCapable interface { ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error) } type PlaceholderCapable interface { SendPlaceholder(ctx context.Context, chatID string) (messageID string, err error) } type MessageEditor interface { EditMessage(ctx context.Context, chatID, messageID, content string) error } type WebhookHandler interface { WebhookPath() string http.Handler } type HealthChecker interface { HealthPath() string HealthHandler(w http.ResponseWriter, r *http.Request) } type MessageLengthProvider interface { MaxMessageLength() int } // ===== 由 Manager 注入 ===== type PlaceholderRecorder interface { RecordPlaceholder(channel, chatID, placeholderID string) RecordTypingStop(channel, chatID string, stop func()) RecordReactionUndo(channel, chatID string, undo func()) } ``` ### A.4 Gateway 启动序列(完整引导流程) ```go // 1. 创建核心组件 msgBus := bus.NewMessageBus() provider := providers.CreateProvider(cfg) agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) // 2. 创建媒体存储(带 TTL 清理) mediaStore := media.NewFileMediaStoreWithCleanup(cleanerConfig) mediaStore.Start() // 3. 创建 Channel Manager(触发 initChannels → 工厂查找 → 构造 → 注入 MediaStore/PlaceholderRecorder/Owner) channelManager := channels.NewManager(cfg, msgBus, mediaStore) // 4. 注入引用 agentLoop.SetChannelManager(channelManager) agentLoop.SetMediaStore(mediaStore) // 5. 配置共享 HTTP 服务器 channelManager.SetupHTTPServer(addr, healthServer) // 6. 启动 channelManager.StartAll(ctx) // 启动 channels + workers + dispatchers + HTTP server go agentLoop.Run(ctx) // 启动 Agent 消息循环 // 7. 关闭(信号触发) cancel() // 取消 context msgBus.Close() // 信号关闭 + 排水 channelManager.StopAll(shutdownCtx) // 停止 HTTP + workers + channels mediaStore.Stop() // 停止 TTL 清理 agentLoop.Stop() // 停止 Agent ``` ### A.5 Per-channel 速率限制参考 | Channel | 速率 (msg/s) | Burst | |---------|-------------|-------| | telegram | 20 | 10 | | discord | 1 | 1 | | slack | 1 | 1 | | line | 10 | 5 | | _其他_ | 10 (默认) | 5 | ### A.6 已知限制和注意事项 1. **媒体清理暂时禁用**:Agent loop 中的 `ReleaseAll` 调用被注释掉了(`refactor(loop): disable media cleanup to prevent premature file deletion`),因为会话边界尚未明确定义。TTL 清理仍然有效。 2. **Feishu 架构特定编译**:Feishu channel 使用 build tags 区分 32 位和 64 位架构(`feishu_32.go` / `feishu_64.go`)。Feishu 使用 SDK 的 WebSocket 模式(非 HTTP webhook),因此不实现 `WebhookHandler`。 3. **WeCom 有两个工厂**:`"wecom"`(Bot 模式,纯 webhook)和 `"wecom_app"`(应用模式,支持 MediaSender)分别注册。两者都实现了 `WebhookHandler` 和 `HealthChecker`。 4. **Pico Protocol**:`pkg/channels/pico/` 实现了一个自定义的 PicoClaw 原生协议 channel,通过 WebSocket webhook (`/pico/ws`) 接收消息。 5. **WhatsApp 有两种模式**:`"whatsapp"`(Bridge 模式,通过外部 bridge URL 通信)和 `"whatsapp_native"`(原生 whatsmeow 模式,直接连接 WhatsApp)。Manager 根据 `WhatsAppConfig.UseNative` 决定初始化哪个。 6. **DingTalk 使用 Stream 模式**:DingTalk 使用 SDK 的 Stream/WebSocket 模式(非 HTTP webhook),因此不实现 `WebhookHandler`。 7. **PlaceholderConfig 的配置与实现**:`PlaceholderConfig` 出现在 6 个 channel config 中(Telegram、Discord、Slack、LINE、OneBot、Pico),但只有实现了 `PlaceholderCapable` + `MessageEditor` 的 channel(Telegram、Discord、Pico)能真正使用占位消息编辑功能。其余 channel 的 `PlaceholderConfig` 为预留字段。 8. **ReasoningChannelID**:大多数 channel config 都包含 `reasoning_channel_id` 字段,用于将 LLM 的思维链(reasoning/thinking)路由到指定 channel(WhatsApp、Telegram、Feishu、Discord、MaixCam、QQ、DingTalk、Slack、LINE、OneBot、WeCom、WeComApp)。注意:`PicoConfig` 目前不包含该字段。`BaseChannel` 通过 `WithReasoningChannelID` 选项和 `ReasoningChannelID()` 方法暴露此配置。 ================================================ FILE: pkg/channels/base.go ================================================ package channels import ( "context" "crypto/rand" "encoding/binary" "encoding/hex" "regexp" "strconv" "strings" "sync/atomic" "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" ) var ( uniqueIDCounter uint64 uniqueIDPrefix string ) func init() { // One-time read from crypto/rand for a unique prefix (single syscall). var b [8]byte if _, err := rand.Read(b[:]); err != nil { // fallback to time-based prefix binary.BigEndian.PutUint64(b[:], uint64(time.Now().UnixNano())) } uniqueIDPrefix = hex.EncodeToString(b[:]) } // audioAnnotationRe matches audio/voice annotations injected by channels (e.g. [voice], [audio: file.ogg]). var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`) // uniqueID generates a process-unique ID using a random prefix and an atomic counter. // This ID is intended for internal correlation (e.g. media scope keys) and is NOT // cryptographically secure — it must not be used in contexts where unpredictability matters. func uniqueID() string { n := atomic.AddUint64(&uniqueIDCounter, 1) return uniqueIDPrefix + strconv.FormatUint(n, 16) } type Channel interface { Name() string Start(ctx context.Context) error Stop(ctx context.Context) error Send(ctx context.Context, msg bus.OutboundMessage) error IsRunning() bool IsAllowed(senderID string) bool IsAllowedSender(sender bus.SenderInfo) bool ReasoningChannelID() string } // BaseChannelOption is a functional option for configuring a BaseChannel. type BaseChannelOption func(*BaseChannel) // WithMaxMessageLength sets the maximum message length (in runes) for a channel. // Messages exceeding this limit will be automatically split by the Manager. // A value of 0 means no limit. func WithMaxMessageLength(n int) BaseChannelOption { return func(c *BaseChannel) { c.maxMessageLength = n } } // WithGroupTrigger sets the group trigger configuration for a channel. func WithGroupTrigger(gt config.GroupTriggerConfig) BaseChannelOption { return func(c *BaseChannel) { c.groupTrigger = gt } } // WithReasoningChannelID sets the reasoning channel ID where thoughts should be sent. func WithReasoningChannelID(id string) BaseChannelOption { return func(c *BaseChannel) { c.reasoningChannelID = id } } // MessageLengthProvider is an opt-in interface that channels implement // to advertise their maximum message length. The Manager uses this via // type assertion to decide whether to split outbound messages. type MessageLengthProvider interface { MaxMessageLength() int } type BaseChannel struct { config any bus *bus.MessageBus running atomic.Bool name string allowList []string maxMessageLength int groupTrigger config.GroupTriggerConfig mediaStore media.MediaStore placeholderRecorder PlaceholderRecorder owner Channel // the concrete channel that embeds this BaseChannel reasoningChannelID string } func NewBaseChannel( name string, config any, bus *bus.MessageBus, allowList []string, opts ...BaseChannelOption, ) *BaseChannel { bc := &BaseChannel{ config: config, bus: bus, name: name, allowList: allowList, } for _, opt := range opts { opt(bc) } return bc } // MaxMessageLength returns the maximum message length (in runes) for this channel. // A value of 0 means no limit. func (c *BaseChannel) MaxMessageLength() int { return c.maxMessageLength } // ShouldRespondInGroup determines whether the bot should respond in a group chat. // Each channel is responsible for: // 1. Detecting isMentioned (platform-specific) // 2. Stripping bot mention from content (platform-specific) // 3. Calling this method to get the group response decision // // Logic: // - If isMentioned → always respond // - If mention_only configured and not mentioned → ignore // - If prefixes configured → respond if content starts with any prefix (strip it) // - If prefixes configured but no match and not mentioned → ignore // - Otherwise (no group_trigger configured) → respond to all (permissive default) func (c *BaseChannel) ShouldRespondInGroup(isMentioned bool, content string) (bool, string) { gt := c.groupTrigger // Mentioned → always respond if isMentioned { return true, strings.TrimSpace(content) } // mention_only → require mention if gt.MentionOnly { return false, content } // Prefix matching if len(gt.Prefixes) > 0 { for _, prefix := range gt.Prefixes { if prefix != "" && strings.HasPrefix(content, prefix) { return true, strings.TrimSpace(strings.TrimPrefix(content, prefix)) } } // Prefixes configured but none matched and not mentioned → ignore return false, content } // No group_trigger configured → permissive (respond to all) return true, strings.TrimSpace(content) } func (c *BaseChannel) Name() string { return c.name } func (c *BaseChannel) ReasoningChannelID() string { return c.reasoningChannelID } func (c *BaseChannel) IsRunning() bool { return c.running.Load() } func (c *BaseChannel) IsAllowed(senderID string) bool { if len(c.allowList) == 0 { return true } // Extract parts from compound senderID like "123456|username" idPart := senderID userPart := "" if idx := strings.Index(senderID, "|"); idx > 0 { idPart = senderID[:idx] userPart = senderID[idx+1:] } for _, allowed := range c.allowList { // Strip leading "@" from allowed value for username matching trimmed := strings.TrimPrefix(allowed, "@") allowedID := trimmed allowedUser := "" if idx := strings.Index(trimmed, "|"); idx > 0 { allowedID = trimmed[:idx] allowedUser = trimmed[idx+1:] } // Support either side using "id|username" compound form. // This keeps backward compatibility with legacy Telegram allowlist entries. if senderID == allowed || idPart == allowed || senderID == trimmed || idPart == trimmed || idPart == allowedID || (allowedUser != "" && senderID == allowedUser) || (userPart != "" && (userPart == allowed || userPart == trimmed || userPart == allowedUser)) { return true } } return false } // IsAllowedSender checks whether a structured SenderInfo is permitted by the allow-list. // It delegates to identity.MatchAllowed for each entry, providing unified matching // across all legacy formats and the new canonical "platform:id" format. func (c *BaseChannel) IsAllowedSender(sender bus.SenderInfo) bool { if len(c.allowList) == 0 { return true } for _, allowed := range c.allowList { if identity.MatchAllowed(sender, allowed) { return true } } return false } func (c *BaseChannel) HandleMessage( ctx context.Context, peer bus.Peer, messageID, senderID, chatID, content string, media []string, metadata map[string]string, senderOpts ...bus.SenderInfo, ) { // Use SenderInfo-based allow check when available, else fall back to string var sender bus.SenderInfo if len(senderOpts) > 0 { sender = senderOpts[0] } if sender.CanonicalID != "" || sender.PlatformID != "" { if !c.IsAllowedSender(sender) { return } } else { if !c.IsAllowed(senderID) { return } } // Set SenderID to canonical if available, otherwise keep the raw senderID resolvedSenderID := senderID if sender.CanonicalID != "" { resolvedSenderID = sender.CanonicalID } scope := BuildMediaScope(c.name, chatID, messageID) msg := bus.InboundMessage{ Channel: c.name, SenderID: resolvedSenderID, Sender: sender, ChatID: chatID, Content: content, Media: media, Peer: peer, MessageID: messageID, MediaScope: scope, Metadata: metadata, } // Auto-trigger typing indicator, message reaction, and placeholder before publishing. // Each capability is independent — all three may fire for the same message. if c.owner != nil && c.placeholderRecorder != nil { // Typing — independent pipeline if tc, ok := c.owner.(TypingCapable); ok { if stop, err := tc.StartTyping(ctx, chatID); err == nil { c.placeholderRecorder.RecordTypingStop(c.name, chatID, stop) } } // Reaction — independent pipeline if rc, ok := c.owner.(ReactionCapable); ok && messageID != "" { if undo, err := rc.ReactToMessage(ctx, chatID, messageID); err == nil { c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo) } } // Placeholder — independent pipeline. // Skip when the message contains audio: the agent will send the // placeholder after transcription completes, so the user sees // "Thinking…" only once the voice has been processed. if !audioAnnotationRe.MatchString(content) { if pc, ok := c.owner.(PlaceholderCapable); ok { if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" { c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID) } } } } if err := c.bus.PublishInbound(ctx, msg); err != nil { logger.ErrorCF("channels", "Failed to publish inbound message", map[string]any{ "channel": c.name, "chat_id": chatID, "error": err.Error(), }) } } func (c *BaseChannel) SetRunning(running bool) { c.running.Store(running) } // SetMediaStore injects a MediaStore into the channel. func (c *BaseChannel) SetMediaStore(s media.MediaStore) { c.mediaStore = s } // GetMediaStore returns the injected MediaStore (may be nil). func (c *BaseChannel) GetMediaStore() media.MediaStore { return c.mediaStore } // SetPlaceholderRecorder injects a PlaceholderRecorder into the channel. func (c *BaseChannel) SetPlaceholderRecorder(r PlaceholderRecorder) { c.placeholderRecorder = r } // GetPlaceholderRecorder returns the injected PlaceholderRecorder (may be nil). func (c *BaseChannel) GetPlaceholderRecorder() PlaceholderRecorder { return c.placeholderRecorder } // SetOwner injects the concrete channel that embeds this BaseChannel. // This allows HandleMessage to auto-trigger TypingCapable / ReactionCapable / PlaceholderCapable. func (c *BaseChannel) SetOwner(ch Channel) { c.owner = ch } // BuildMediaScope constructs a scope key for media lifecycle tracking. func BuildMediaScope(channel, chatID, messageID string) string { id := messageID if id == "" { id = uniqueID() } return channel + ":" + chatID + ":" + id } ================================================ FILE: pkg/channels/base_test.go ================================================ package channels import ( "testing" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" ) func TestBaseChannelIsAllowed(t *testing.T) { tests := []struct { name string allowList []string senderID string want bool }{ { name: "empty allowlist allows all", allowList: nil, senderID: "anyone", want: true, }, { name: "compound sender matches numeric allowlist", allowList: []string{"123456"}, senderID: "123456|alice", want: true, }, { name: "compound sender matches username allowlist", allowList: []string{"@alice"}, senderID: "123456|alice", want: true, }, { name: "numeric sender matches legacy compound allowlist", allowList: []string{"123456|alice"}, senderID: "123456", want: true, }, { name: "non matching sender is denied", allowList: []string{"123456"}, senderID: "654321|bob", want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ch := NewBaseChannel("test", nil, nil, tt.allowList) if got := ch.IsAllowed(tt.senderID); got != tt.want { t.Fatalf("IsAllowed(%q) = %v, want %v", tt.senderID, got, tt.want) } }) } } func TestShouldRespondInGroup(t *testing.T) { tests := []struct { name string gt config.GroupTriggerConfig isMentioned bool content string wantRespond bool wantContent string }{ { name: "no config - permissive default", gt: config.GroupTriggerConfig{}, isMentioned: false, content: "hello world", wantRespond: true, wantContent: "hello world", }, { name: "no config - mentioned", gt: config.GroupTriggerConfig{}, isMentioned: true, content: "hello world", wantRespond: true, wantContent: "hello world", }, { name: "mention_only - not mentioned", gt: config.GroupTriggerConfig{MentionOnly: true}, isMentioned: false, content: "hello world", wantRespond: false, wantContent: "hello world", }, { name: "mention_only - mentioned", gt: config.GroupTriggerConfig{MentionOnly: true}, isMentioned: true, content: "hello world", wantRespond: true, wantContent: "hello world", }, { name: "prefix match", gt: config.GroupTriggerConfig{Prefixes: []string{"/ask"}}, isMentioned: false, content: "/ask hello", wantRespond: true, wantContent: "hello", }, { name: "prefix no match - not mentioned", gt: config.GroupTriggerConfig{Prefixes: []string{"/ask"}}, isMentioned: false, content: "hello world", wantRespond: false, wantContent: "hello world", }, { name: "prefix no match - but mentioned", gt: config.GroupTriggerConfig{Prefixes: []string{"/ask"}}, isMentioned: true, content: "hello world", wantRespond: true, wantContent: "hello world", }, { name: "multiple prefixes - second matches", gt: config.GroupTriggerConfig{Prefixes: []string{"/ask", "/bot"}}, isMentioned: false, content: "/bot help me", wantRespond: true, wantContent: "help me", }, { name: "mention_only with prefixes - mentioned overrides", gt: config.GroupTriggerConfig{MentionOnly: true, Prefixes: []string{"/ask"}}, isMentioned: true, content: "hello", wantRespond: true, wantContent: "hello", }, { name: "mention_only with prefixes - not mentioned, no prefix", gt: config.GroupTriggerConfig{MentionOnly: true, Prefixes: []string{"/ask"}}, isMentioned: false, content: "hello", wantRespond: false, wantContent: "hello", }, { name: "empty prefix in list is skipped", gt: config.GroupTriggerConfig{Prefixes: []string{"", "/ask"}}, isMentioned: false, content: "/ask test", wantRespond: true, wantContent: "test", }, { name: "prefix strips leading whitespace after prefix", gt: config.GroupTriggerConfig{Prefixes: []string{"/ask "}}, isMentioned: false, content: "/ask hello", wantRespond: true, wantContent: "hello", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ch := NewBaseChannel("test", nil, nil, nil, WithGroupTrigger(tt.gt)) gotRespond, gotContent := ch.ShouldRespondInGroup(tt.isMentioned, tt.content) if gotRespond != tt.wantRespond { t.Errorf("ShouldRespondInGroup() respond = %v, want %v", gotRespond, tt.wantRespond) } if gotContent != tt.wantContent { t.Errorf("ShouldRespondInGroup() content = %q, want %q", gotContent, tt.wantContent) } }) } } func TestIsAllowedSender(t *testing.T) { tests := []struct { name string allowList []string sender bus.SenderInfo want bool }{ { name: "empty allowlist allows all", allowList: nil, sender: bus.SenderInfo{PlatformID: "anyone"}, want: true, }, { name: "numeric ID matches PlatformID", allowList: []string{"123456"}, sender: bus.SenderInfo{ Platform: "telegram", PlatformID: "123456", CanonicalID: "telegram:123456", }, want: true, }, { name: "canonical format matches", allowList: []string{"telegram:123456"}, sender: bus.SenderInfo{ Platform: "telegram", PlatformID: "123456", CanonicalID: "telegram:123456", }, want: true, }, { name: "canonical format wrong platform", allowList: []string{"discord:123456"}, sender: bus.SenderInfo{ Platform: "telegram", PlatformID: "123456", CanonicalID: "telegram:123456", }, want: false, }, { name: "@username matches", allowList: []string{"@alice"}, sender: bus.SenderInfo{ Platform: "telegram", PlatformID: "123456", CanonicalID: "telegram:123456", Username: "alice", }, want: true, }, { name: "compound id|username matches by ID", allowList: []string{"123456|alice"}, sender: bus.SenderInfo{ Platform: "telegram", PlatformID: "123456", CanonicalID: "telegram:123456", Username: "alice", }, want: true, }, { name: "non matching sender denied", allowList: []string{"654321"}, sender: bus.SenderInfo{ Platform: "telegram", PlatformID: "123456", CanonicalID: "telegram:123456", }, want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ch := NewBaseChannel("test", nil, nil, tt.allowList) if got := ch.IsAllowedSender(tt.sender); got != tt.want { t.Fatalf("IsAllowedSender(%+v) = %v, want %v", tt.sender, got, tt.want) } }) } } ================================================ FILE: pkg/channels/dingtalk/dingtalk.go ================================================ // PicoClaw - Ultra-lightweight personal AI agent // DingTalk channel implementation using Stream Mode package dingtalk import ( "context" "fmt" "sync" "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" "github.com/open-dingtalk/dingtalk-stream-sdk-go/client" dinglog "github.com/open-dingtalk/dingtalk-stream-sdk-go/logger" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) // DingTalkChannel implements the Channel interface for DingTalk (钉钉) // It uses WebSocket for receiving messages via stream mode and API for sending type DingTalkChannel struct { *channels.BaseChannel config config.DingTalkConfig clientID string clientSecret string streamClient *client.StreamClient ctx context.Context cancel context.CancelFunc // Map to store session webhooks for each chat sessionWebhooks sync.Map // chatID -> sessionWebhook } // NewDingTalkChannel creates a new DingTalk channel instance func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (*DingTalkChannel, error) { if cfg.ClientID == "" || cfg.ClientSecret == "" { return nil, fmt.Errorf("dingtalk client_id and client_secret are required") } // Set the logger for the Stream SDK dinglog.SetLogger(logger.NewLogger("dingtalk")) base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(20000), channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) return &DingTalkChannel{ BaseChannel: base, config: cfg, clientID: cfg.ClientID, clientSecret: cfg.ClientSecret, }, nil } // Start initializes the DingTalk channel with Stream Mode func (c *DingTalkChannel) Start(ctx context.Context) error { logger.InfoC("dingtalk", "Starting DingTalk channel (Stream Mode)...") c.ctx, c.cancel = context.WithCancel(ctx) // Create credential config cred := client.NewAppCredentialConfig(c.clientID, c.clientSecret) // Create the stream client with options c.streamClient = client.NewStreamClient( client.WithAppCredential(cred), client.WithAutoReconnect(true), ) // Register chatbot callback handler (IChatBotMessageHandler is a function type) c.streamClient.RegisterChatBotCallbackRouter(c.onChatBotMessageReceived) // Start the stream client if err := c.streamClient.Start(c.ctx); err != nil { return fmt.Errorf("failed to start stream client: %w", err) } c.SetRunning(true) logger.InfoC("dingtalk", "DingTalk channel started (Stream Mode)") return nil } // Stop gracefully stops the DingTalk channel func (c *DingTalkChannel) Stop(ctx context.Context) error { logger.InfoC("dingtalk", "Stopping DingTalk channel...") if c.cancel != nil { c.cancel() } if c.streamClient != nil { c.streamClient.Close() } c.SetRunning(false) logger.InfoC("dingtalk", "DingTalk channel stopped") return nil } // Send sends a message to DingTalk via the chatbot reply API func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } // Get session webhook from storage sessionWebhookRaw, ok := c.sessionWebhooks.Load(msg.ChatID) if !ok { return fmt.Errorf("no session_webhook found for chat %s, cannot send message", msg.ChatID) } sessionWebhook, ok := sessionWebhookRaw.(string) if !ok { return fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID) } logger.DebugCF("dingtalk", "Sending message", map[string]any{ "chat_id": msg.ChatID, "preview": utils.Truncate(msg.Content, 100), }) // Use the session webhook to send the reply return c.SendDirectReply(ctx, sessionWebhook, msg.Content) } // onChatBotMessageReceived implements the IChatBotMessageHandler function signature // This is called by the Stream SDK when a new message arrives // IChatBotMessageHandler is: func(c context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) func (c *DingTalkChannel) onChatBotMessageReceived( ctx context.Context, data *chatbot.BotCallbackDataModel, ) ([]byte, error) { // Extract message content from Text field content := data.Text.Content if content == "" { // Try to extract from Content interface{} if Text is empty if contentMap, ok := data.Content.(map[string]any); ok { if textContent, ok := contentMap["content"].(string); ok { content = textContent } } } if content == "" { return nil, nil // Ignore empty messages } senderID := data.SenderStaffId senderNick := data.SenderNick chatID := senderID if data.ConversationType != "1" { // For group chats chatID = data.ConversationId } // Store the session webhook for this chat so we can reply later c.sessionWebhooks.Store(chatID, data.SessionWebhook) metadata := map[string]string{ "sender_name": senderNick, "conversation_id": data.ConversationId, "conversation_type": data.ConversationType, "platform": "dingtalk", "session_webhook": data.SessionWebhook, } var peer bus.Peer if data.ConversationType == "1" { peer = bus.Peer{Kind: "direct", ID: senderID} } else { peer = bus.Peer{Kind: "group", ID: data.ConversationId} // In group chats, apply unified group trigger filtering respond, cleaned := c.ShouldRespondInGroup(false, content) if !respond { return nil, nil } content = cleaned } logger.DebugCF("dingtalk", "Received message", map[string]any{ "sender_nick": senderNick, "sender_id": senderID, "preview": utils.Truncate(content, 50), }) // Build sender info sender := bus.SenderInfo{ Platform: "dingtalk", PlatformID: senderID, CanonicalID: identity.BuildCanonicalID("dingtalk", senderID), DisplayName: senderNick, } if !c.IsAllowedSender(sender) { return nil, nil } // Handle the message through the base channel c.HandleMessage(ctx, peer, "", senderID, chatID, content, nil, metadata, sender) // Return nil to indicate we've handled the message asynchronously // The response will be sent through the message bus return nil, nil } // SendDirectReply sends a direct reply using the session webhook func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, content string) error { replier := chatbot.NewChatbotReplier() // Convert string content to []byte for the API contentBytes := []byte(content) titleBytes := []byte("PicoClaw") // Send markdown formatted reply err := replier.SimpleReplyMarkdown( ctx, sessionWebhook, titleBytes, contentBytes, ) if err != nil { return fmt.Errorf("dingtalk send: %w", channels.ErrTemporary) } return nil } ================================================ FILE: pkg/channels/dingtalk/init.go ================================================ package dingtalk import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) func init() { channels.RegisterFactory("dingtalk", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { return NewDingTalkChannel(cfg.Channels.DingTalk, b) }) } ================================================ FILE: pkg/channels/discord/discord.go ================================================ package discord import ( "context" "fmt" "net/http" "net/url" "os" "regexp" "strings" "sync" "time" "github.com/bwmarrin/discordgo" "github.com/gorilla/websocket" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" ) const ( sendTimeout = 10 * time.Second ) var ( // Pre-compiled regexes for resolveDiscordRefs (avoid re-compiling per call) channelRefRe = regexp.MustCompile(`<#(\d+)>`) msgLinkRe = regexp.MustCompile(`https://(?:discord\.com|discordapp\.com)/channels/(\d+)/(\d+)/(\d+)`) ) type DiscordChannel struct { *channels.BaseChannel session *discordgo.Session config config.DiscordConfig ctx context.Context cancel context.CancelFunc typingMu sync.Mutex typingStop map[string]chan struct{} // chatID → stop signal botUserID string // stored for mention checking } func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { discordgo.Logger = logger.NewLogger("discord"). WithLevels(map[int]logger.LogLevel{ discordgo.LogError: logger.ERROR, discordgo.LogWarning: logger.WARN, discordgo.LogInformational: logger.INFO, discordgo.LogDebug: logger.DEBUG, }).Log session, err := discordgo.New("Bot " + cfg.Token) if err != nil { return nil, fmt.Errorf("failed to create discord session: %w", err) } if err := applyDiscordProxy(session, cfg.Proxy); err != nil { return nil, err } base := channels.NewBaseChannel("discord", cfg, bus, cfg.AllowFrom, channels.WithMaxMessageLength(2000), channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) return &DiscordChannel{ BaseChannel: base, session: session, config: cfg, ctx: context.Background(), typingStop: make(map[string]chan struct{}), }, nil } func (c *DiscordChannel) Start(ctx context.Context) error { logger.InfoC("discord", "Starting Discord bot") c.ctx, c.cancel = context.WithCancel(ctx) // Get bot user ID before opening session to avoid race condition botUser, err := c.session.User("@me") if err != nil { return fmt.Errorf("failed to get bot user: %w", err) } c.botUserID = botUser.ID c.session.AddHandler(c.handleMessage) if err := c.session.Open(); err != nil { return fmt.Errorf("failed to open discord session: %w", err) } c.SetRunning(true) logger.InfoCF("discord", "Discord bot connected", map[string]any{ "username": botUser.Username, "user_id": botUser.ID, }) return nil } func (c *DiscordChannel) Stop(ctx context.Context) error { logger.InfoC("discord", "Stopping Discord bot") c.SetRunning(false) // Stop all typing goroutines before closing session c.typingMu.Lock() for chatID, stop := range c.typingStop { close(stop) delete(c.typingStop, chatID) } c.typingMu.Unlock() // Cancel our context so typing goroutines using c.ctx.Done() exit if c.cancel != nil { c.cancel() } if err := c.session.Close(); err != nil { return fmt.Errorf("failed to close discord session: %w", err) } return nil } func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } channelID := msg.ChatID if channelID == "" { return fmt.Errorf("channel ID is empty") } if len([]rune(msg.Content)) == 0 { return nil } return c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID) } // SendMedia implements the channels.MediaSender interface. func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } channelID := msg.ChatID if channelID == "" { return fmt.Errorf("channel ID is empty") } store := c.GetMediaStore() if store == nil { return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } // Collect all files into a single ChannelMessageSendComplex call files := make([]*discordgo.File, 0, len(msg.Parts)) var caption string for _, part := range msg.Parts { localPath, err := store.Resolve(part.Ref) if err != nil { logger.ErrorCF("discord", "Failed to resolve media ref", map[string]any{ "ref": part.Ref, "error": err.Error(), }) continue } file, err := os.Open(localPath) if err != nil { logger.ErrorCF("discord", "Failed to open media file", map[string]any{ "path": localPath, "error": err.Error(), }) continue } // Note: discordgo reads from the Reader and we can't close it before send filename := part.Filename if filename == "" { filename = "file" } files = append(files, &discordgo.File{ Name: filename, ContentType: part.ContentType, Reader: file, }) if part.Caption != "" && caption == "" { caption = part.Caption } } if len(files) == 0 { return nil } sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) defer cancel() done := make(chan error, 1) go func() { _, err := c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ Content: caption, Files: files, }) done <- err }() select { case err := <-done: // Close all file readers for _, f := range files { if closer, ok := f.Reader.(*os.File); ok { closer.Close() } } if err != nil { return fmt.Errorf("discord send media: %w", channels.ErrTemporary) } return nil case <-sendCtx.Done(): // Close all file readers for _, f := range files { if closer, ok := f.Reader.(*os.File); ok { closer.Close() } } return sendCtx.Err() } } // EditMessage implements channels.MessageEditor. func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { _, err := c.session.ChannelMessageEdit(chatID, messageID, content) return err } // SendPlaceholder implements channels.PlaceholderCapable. // It sends a placeholder message that will later be edited to the actual // response via EditMessage (channels.MessageEditor). func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { if !c.config.Placeholder.Enabled { return "", nil } text := c.config.Placeholder.Text if text == "" { text = "Thinking... 💭" } msg, err := c.session.ChannelMessageSend(chatID, text) if err != nil { return "", err } return msg.ID, nil } func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) error { // Use the passed ctx for timeout control sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) defer cancel() done := make(chan error, 1) go func() { var err error // If we have an ID, we send the message as "Reply" if replyToID != "" { _, err = c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ Content: content, Reference: &discordgo.MessageReference{ MessageID: replyToID, ChannelID: channelID, }, }) } else { // Otherwise, we send a normal message _, err = c.session.ChannelMessageSend(channelID, content) } done <- err }() select { case err := <-done: if err != nil { return fmt.Errorf("discord send: %w", channels.ErrTemporary) } return nil case <-sendCtx.Done(): return sendCtx.Err() } } // appendContent safely appends content to existing text func appendContent(content, suffix string) string { if content == "" { return suffix } return content + "\n" + suffix } func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.MessageCreate) { if m == nil || m.Author == nil { return } if m.Author.ID == s.State.User.ID { return } // Check allowlist first to avoid downloading attachments for rejected users sender := bus.SenderInfo{ Platform: "discord", PlatformID: m.Author.ID, CanonicalID: identity.BuildCanonicalID("discord", m.Author.ID), Username: m.Author.Username, } // Build display name displayName := m.Author.Username if m.Author.Discriminator != "" && m.Author.Discriminator != "0" { displayName += "#" + m.Author.Discriminator } sender.DisplayName = displayName if !c.IsAllowedSender(sender) { logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{ "user_id": m.Author.ID, }) return } content := m.Content // In guild (group) channels, apply unified group trigger filtering // DMs (GuildID is empty) always get a response if m.GuildID != "" { isMentioned := false for _, mention := range m.Mentions { if mention.ID == c.botUserID { isMentioned = true break } } content = c.stripBotMention(content) respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) if !respond { logger.DebugCF("discord", "Group message ignored by group trigger", map[string]any{ "user_id": m.Author.ID, }) return } content = cleaned } else { // DMs: just strip bot mention without filtering content = c.stripBotMention(content) } // Resolve Discord refs in main content before concatenation to avoid // double-expanding links that appear in the referenced message. content = c.resolveDiscordRefs(s, content, m.GuildID) // Prepend referenced (quoted) message content if this is a reply if m.MessageReference != nil && m.ReferencedMessage != nil { refContent := m.ReferencedMessage.Content if refContent != "" { refAuthor := "unknown" if m.ReferencedMessage.Author != nil { refAuthor = m.ReferencedMessage.Author.Username } refContent = c.resolveDiscordRefs(s, refContent, m.GuildID) content = fmt.Sprintf("[quoted message from %s]: %s\n\n%s", refAuthor, refContent, content) } } senderID := m.Author.ID mediaPaths := make([]string, 0, len(m.Attachments)) scope := channels.BuildMediaScope("discord", m.ChannelID, m.ID) // Helper to register a local file with the media store storeMedia := func(localPath, filename string) string { if store := c.GetMediaStore(); store != nil { ref, err := store.Store(localPath, media.MediaMeta{ Filename: filename, Source: "discord", }, scope) if err == nil { return ref } } return localPath // fallback } for _, attachment := range m.Attachments { isAudio := utils.IsAudioFile(attachment.Filename, attachment.ContentType) if isAudio { localPath := c.downloadAttachment(attachment.URL, attachment.Filename) if localPath != "" { mediaPaths = append(mediaPaths, storeMedia(localPath, attachment.Filename)) content = appendContent(content, fmt.Sprintf("[audio: %s]", attachment.Filename)) } else { logger.WarnCF("discord", "Failed to download audio attachment", map[string]any{ "url": attachment.URL, "filename": attachment.Filename, }) mediaPaths = append(mediaPaths, attachment.URL) content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL)) } } else { mediaPaths = append(mediaPaths, attachment.URL) content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL)) } } if content == "" && len(mediaPaths) == 0 { return } if content == "" { content = "[media only]" } logger.DebugCF("discord", "Received message", map[string]any{ "sender_name": sender.DisplayName, "sender_id": senderID, "preview": utils.Truncate(content, 50), }) peerKind := "channel" peerID := m.ChannelID if m.GuildID == "" { peerKind = "direct" peerID = senderID } peer := bus.Peer{Kind: peerKind, ID: peerID} metadata := map[string]string{ "user_id": senderID, "username": m.Author.Username, "display_name": sender.DisplayName, "guild_id": m.GuildID, "channel_id": m.ChannelID, "is_dm": fmt.Sprintf("%t", m.GuildID == ""), } c.HandleMessage(c.ctx, peer, m.ID, senderID, m.ChannelID, content, mediaPaths, metadata, sender) } // startTyping starts a continuous typing indicator loop for the given chatID. // It stops any existing typing loop for that chatID before starting a new one. func (c *DiscordChannel) startTyping(chatID string) { c.typingMu.Lock() // Stop existing loop for this chatID if any if stop, ok := c.typingStop[chatID]; ok { close(stop) } stop := make(chan struct{}) c.typingStop[chatID] = stop c.typingMu.Unlock() go func() { if err := c.session.ChannelTyping(chatID); err != nil { logger.DebugCF("discord", "ChannelTyping error", map[string]any{"chatID": chatID, "err": err}) } ticker := time.NewTicker(8 * time.Second) defer ticker.Stop() timeout := time.After(5 * time.Minute) for { select { case <-stop: return case <-timeout: return case <-c.ctx.Done(): return case <-ticker.C: if err := c.session.ChannelTyping(chatID); err != nil { logger.DebugCF("discord", "ChannelTyping error", map[string]any{"chatID": chatID, "err": err}) } } } }() } // stopTyping stops the typing indicator loop for the given chatID. func (c *DiscordChannel) stopTyping(chatID string) { c.typingMu.Lock() defer c.typingMu.Unlock() if stop, ok := c.typingStop[chatID]; ok { close(stop) delete(c.typingStop, chatID) } } // StartTyping implements channels.TypingCapable. // It starts a continuous typing indicator and returns an idempotent stop function. func (c *DiscordChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { c.startTyping(chatID) return func() { c.stopTyping(chatID) }, nil } func (c *DiscordChannel) downloadAttachment(url, filename string) string { return utils.DownloadFile(url, filename, utils.DownloadOptions{ LoggerPrefix: "discord", ProxyURL: c.config.Proxy, }) } func applyDiscordProxy(session *discordgo.Session, proxyAddr string) error { var proxyFunc func(*http.Request) (*url.URL, error) if proxyAddr != "" { proxyURL, err := url.Parse(proxyAddr) if err != nil { return fmt.Errorf("invalid discord proxy URL %q: %w", proxyAddr, err) } proxyFunc = http.ProxyURL(proxyURL) } else if os.Getenv("HTTP_PROXY") != "" || os.Getenv("HTTPS_PROXY") != "" { proxyFunc = http.ProxyFromEnvironment } if proxyFunc == nil { return nil } transport := &http.Transport{Proxy: proxyFunc} session.Client = &http.Client{ Timeout: sendTimeout, Transport: transport, } if session.Dialer != nil { dialerCopy := *session.Dialer dialerCopy.Proxy = proxyFunc session.Dialer = &dialerCopy } else { session.Dialer = &websocket.Dialer{Proxy: proxyFunc} } return nil } // resolveDiscordRefs resolves channel references (<#id> → #channel-name) and // expands Discord message links to show the linked message content. // Only links pointing to the same guild are expanded to prevent cross-guild leakage. func (c *DiscordChannel) resolveDiscordRefs(s *discordgo.Session, text string, guildID string) string { // 1. Resolve channel references: <#id> → #channel-name text = channelRefRe.ReplaceAllStringFunc(text, func(match string) string { parts := channelRefRe.FindStringSubmatch(match) if len(parts) < 2 { return match } // Prefer session state cache to avoid API calls if ch, err := s.State.Channel(parts[1]); err == nil { return "#" + ch.Name } if ch, err := s.Channel(parts[1]); err == nil { return "#" + ch.Name } return match }) // 2. Expand Discord message links (max 3, same guild only) matches := msgLinkRe.FindAllStringSubmatch(text, 3) for _, m := range matches { if len(m) < 4 { continue } linkGuildID, channelID, messageID := m[1], m[2], m[3] // Security: only expand links from the same guild if linkGuildID != guildID { continue } msg, err := s.ChannelMessage(channelID, messageID) if err != nil || msg == nil || msg.Content == "" { continue } author := "unknown" if msg.Author != nil { author = msg.Author.Username } text += fmt.Sprintf("\n[linked message from %s]: %s", author, msg.Content) } return text } // stripBotMention removes the bot mention from the message content. // Discord mentions have the format <@USER_ID> or <@!USER_ID> (with nickname). func (c *DiscordChannel) stripBotMention(text string) string { if c.botUserID == "" { return text } // Remove both regular mention <@USER_ID> and nickname mention <@!USER_ID> text = strings.ReplaceAll(text, fmt.Sprintf("<@%s>", c.botUserID), "") text = strings.ReplaceAll(text, fmt.Sprintf("<@!%s>", c.botUserID), "") return strings.TrimSpace(text) } ================================================ FILE: pkg/channels/discord/discord_resolve_test.go ================================================ package discord import ( "testing" ) func TestChannelRefRegex(t *testing.T) { tests := []struct { name string input string wantID string wantOK bool }{ {"basic channel ref", "<#123456789>", "123456789", true}, {"long id", "<#9876543210123456>", "9876543210123456", true}, {"no match plain text", "hello world", "", false}, {"no match partial", "<#>", "", false}, {"no match letters", "<#abc>", "", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { matches := channelRefRe.FindStringSubmatch(tt.input) if tt.wantOK { if len(matches) < 2 || matches[1] != tt.wantID { t.Errorf("channelRefRe(%q) = %v, want ID %q", tt.input, matches, tt.wantID) } } else { if len(matches) >= 2 { t.Errorf("channelRefRe(%q) should not match, got %v", tt.input, matches) } } }) } } func TestMsgLinkRegex(t *testing.T) { tests := []struct { name string input string wantGuild string wantChan string wantMsg string wantOK bool }{ { "discord.com link", "https://discord.com/channels/111/222/333", "111", "222", "333", true, }, { "discordapp.com link", "https://discordapp.com/channels/111/222/333", "111", "222", "333", true, }, { "real world ids", "check this https://discord.com/channels/9000000000000001/9000000000000002/9000000000000003 please", "9000000000000001", "9000000000000002", "9000000000000003", true, }, {"no match http", "http://discord.com/channels/1/2/3", "", "", "", false}, {"no match missing segment", "https://discord.com/channels/1/2", "", "", "", false}, {"no match plain text", "hello world", "", "", "", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { matches := msgLinkRe.FindStringSubmatch(tt.input) if tt.wantOK { if len(matches) < 4 { t.Fatalf("msgLinkRe(%q) didn't match, want guild=%s chan=%s msg=%s", tt.input, tt.wantGuild, tt.wantChan, tt.wantMsg) } if matches[1] != tt.wantGuild || matches[2] != tt.wantChan || matches[3] != tt.wantMsg { t.Errorf("msgLinkRe(%q) = guild=%s chan=%s msg=%s, want %s/%s/%s", tt.input, matches[1], matches[2], matches[3], tt.wantGuild, tt.wantChan, tt.wantMsg) } } else { if len(matches) >= 4 { t.Errorf("msgLinkRe(%q) should not match, got %v", tt.input, matches) } } }) } } func TestMsgLinkRegex_MultipleMatches(t *testing.T) { input := "see https://discord.com/channels/1/2/3 and https://discord.com/channels/4/5/6 and https://discord.com/channels/7/8/9 and https://discord.com/channels/10/11/12" matches := msgLinkRe.FindAllStringSubmatch(input, 3) if len(matches) != 3 { t.Fatalf("expected 3 matches (capped), got %d", len(matches)) } // Verify the 3rd match is 7/8/9 (not 10/11/12) if matches[2][1] != "7" || matches[2][2] != "8" || matches[2][3] != "9" { t.Errorf("3rd match = %v, want guild=7 chan=8 msg=9", matches[2]) } } ================================================ FILE: pkg/channels/discord/discord_test.go ================================================ package discord import ( "net/http" "net/url" "testing" "github.com/bwmarrin/discordgo" ) func TestApplyDiscordProxy_CustomProxy(t *testing.T) { session, err := discordgo.New("Bot test-token") if err != nil { t.Fatalf("discordgo.New() error: %v", err) } if err = applyDiscordProxy(session, "http://127.0.0.1:7890"); err != nil { t.Fatalf("applyDiscordProxy() error: %v", err) } req, err := http.NewRequest("GET", "https://discord.com/api/v10/gateway", nil) if err != nil { t.Fatalf("http.NewRequest() error: %v", err) } restProxy := session.Client.Transport.(*http.Transport).Proxy restProxyURL, err := restProxy(req) if err != nil { t.Fatalf("rest proxy func error: %v", err) } if got, want := restProxyURL.String(), "http://127.0.0.1:7890"; got != want { t.Fatalf("REST proxy = %q, want %q", got, want) } wsProxyURL, err := session.Dialer.Proxy(req) if err != nil { t.Fatalf("ws proxy func error: %v", err) } if got, want := wsProxyURL.String(), "http://127.0.0.1:7890"; got != want { t.Fatalf("WS proxy = %q, want %q", got, want) } } func TestApplyDiscordProxy_FromEnvironment(t *testing.T) { t.Setenv("HTTP_PROXY", "http://127.0.0.1:8888") t.Setenv("http_proxy", "http://127.0.0.1:8888") t.Setenv("HTTPS_PROXY", "http://127.0.0.1:8888") t.Setenv("https_proxy", "http://127.0.0.1:8888") t.Setenv("ALL_PROXY", "") t.Setenv("all_proxy", "") t.Setenv("NO_PROXY", "") t.Setenv("no_proxy", "") session, err := discordgo.New("Bot test-token") if err != nil { t.Fatalf("discordgo.New() error: %v", err) } if err = applyDiscordProxy(session, ""); err != nil { t.Fatalf("applyDiscordProxy() error: %v", err) } req, err := http.NewRequest("GET", "https://discord.com/api/v10/gateway", nil) if err != nil { t.Fatalf("http.NewRequest() error: %v", err) } gotURL, err := session.Dialer.Proxy(req) if err != nil { t.Fatalf("ws proxy func error: %v", err) } wantURL, err := url.Parse("http://127.0.0.1:8888") if err != nil { t.Fatalf("url.Parse() error: %v", err) } if gotURL.String() != wantURL.String() { t.Fatalf("WS proxy = %q, want %q", gotURL.String(), wantURL.String()) } } func TestApplyDiscordProxy_InvalidProxyURL(t *testing.T) { session, err := discordgo.New("Bot test-token") if err != nil { t.Fatalf("discordgo.New() error: %v", err) } if err = applyDiscordProxy(session, "://bad-proxy"); err == nil { t.Fatal("applyDiscordProxy() expected error for invalid proxy URL, got nil") } } ================================================ FILE: pkg/channels/discord/init.go ================================================ package discord import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) func init() { channels.RegisterFactory("discord", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { return NewDiscordChannel(cfg.Channels.Discord, b) }) } ================================================ FILE: pkg/channels/errors.go ================================================ package channels import "errors" var ( // ErrNotRunning indicates the channel is not running. // Manager will not retry. ErrNotRunning = errors.New("channel not running") // ErrRateLimit indicates the platform returned a rate-limit response (e.g. HTTP 429). // Manager will wait a fixed delay and retry. ErrRateLimit = errors.New("rate limited") // ErrTemporary indicates a transient failure (e.g. network timeout, 5xx). // Manager will use exponential backoff and retry. ErrTemporary = errors.New("temporary failure") // ErrSendFailed indicates a permanent failure (e.g. invalid chat ID, 4xx non-429). // Manager will not retry. ErrSendFailed = errors.New("send failed") ) ================================================ FILE: pkg/channels/errors_test.go ================================================ package channels import ( "errors" "fmt" "testing" ) func TestErrorsIs(t *testing.T) { wrapped := fmt.Errorf("telegram API: %w", ErrRateLimit) if !errors.Is(wrapped, ErrRateLimit) { t.Error("wrapped ErrRateLimit should match") } if errors.Is(wrapped, ErrTemporary) { t.Error("wrapped ErrRateLimit should not match ErrTemporary") } } func TestErrorsIsAllTypes(t *testing.T) { sentinels := []error{ErrNotRunning, ErrRateLimit, ErrTemporary, ErrSendFailed} for _, sentinel := range sentinels { wrapped := fmt.Errorf("context: %w", sentinel) if !errors.Is(wrapped, sentinel) { t.Errorf("wrapped %v should match itself", sentinel) } // Verify it doesn't match other sentinel errors for _, other := range sentinels { if other == sentinel { continue } if errors.Is(wrapped, other) { t.Errorf("wrapped %v should not match %v", sentinel, other) } } } } func TestErrorMessages(t *testing.T) { tests := []struct { err error want string }{ {ErrNotRunning, "channel not running"}, {ErrRateLimit, "rate limited"}, {ErrTemporary, "temporary failure"}, {ErrSendFailed, "send failed"}, } for _, tt := range tests { if got := tt.err.Error(); got != tt.want { t.Errorf("error message = %q, want %q", got, tt.want) } } } ================================================ FILE: pkg/channels/errutil.go ================================================ package channels import ( "fmt" "net/http" ) // ClassifySendError wraps a raw error with the appropriate sentinel based on // an HTTP status code. Channels that perform HTTP API calls should use this // in their Send path. func ClassifySendError(statusCode int, rawErr error) error { switch { case statusCode == http.StatusTooManyRequests: return fmt.Errorf("%w: %v", ErrRateLimit, rawErr) case statusCode >= 500: return fmt.Errorf("%w: %v", ErrTemporary, rawErr) case statusCode >= 400: return fmt.Errorf("%w: %v", ErrSendFailed, rawErr) default: return rawErr } } // ClassifyNetError wraps a network/timeout error as ErrTemporary. func ClassifyNetError(err error) error { if err == nil { return nil } return fmt.Errorf("%w: %v", ErrTemporary, err) } ================================================ FILE: pkg/channels/errutil_test.go ================================================ package channels import ( "errors" "fmt" "testing" ) func TestClassifySendError(t *testing.T) { raw := fmt.Errorf("some API error") tests := []struct { name string statusCode int wantIs error wantNil bool }{ {"429 -> ErrRateLimit", 429, ErrRateLimit, false}, {"500 -> ErrTemporary", 500, ErrTemporary, false}, {"502 -> ErrTemporary", 502, ErrTemporary, false}, {"503 -> ErrTemporary", 503, ErrTemporary, false}, {"400 -> ErrSendFailed", 400, ErrSendFailed, false}, {"403 -> ErrSendFailed", 403, ErrSendFailed, false}, {"404 -> ErrSendFailed", 404, ErrSendFailed, false}, {"200 -> raw error", 200, nil, false}, {"201 -> raw error", 201, nil, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := ClassifySendError(tt.statusCode, raw) if err == nil { t.Fatal("expected non-nil error") } if tt.wantIs != nil { if !errors.Is(err, tt.wantIs) { t.Errorf("errors.Is(err, %v) = false, want true; err = %v", tt.wantIs, err) } } else { // Should return the raw error unchanged if err != raw { t.Errorf("expected raw error to be returned unchanged for status %d, got %v", tt.statusCode, err) } } }) } } func TestClassifySendErrorNoFalsePositive(t *testing.T) { raw := fmt.Errorf("some error") // 429 should NOT match ErrTemporary or ErrSendFailed err := ClassifySendError(429, raw) if errors.Is(err, ErrTemporary) { t.Error("429 should not match ErrTemporary") } if errors.Is(err, ErrSendFailed) { t.Error("429 should not match ErrSendFailed") } // 500 should NOT match ErrRateLimit or ErrSendFailed err = ClassifySendError(500, raw) if errors.Is(err, ErrRateLimit) { t.Error("500 should not match ErrRateLimit") } if errors.Is(err, ErrSendFailed) { t.Error("500 should not match ErrSendFailed") } // 400 should NOT match ErrRateLimit or ErrTemporary err = ClassifySendError(400, raw) if errors.Is(err, ErrRateLimit) { t.Error("400 should not match ErrRateLimit") } if errors.Is(err, ErrTemporary) { t.Error("400 should not match ErrTemporary") } } func TestClassifyNetError(t *testing.T) { t.Run("nil error returns nil", func(t *testing.T) { if err := ClassifyNetError(nil); err != nil { t.Errorf("expected nil, got %v", err) } }) t.Run("non-nil error wraps as ErrTemporary", func(t *testing.T) { raw := fmt.Errorf("connection refused") err := ClassifyNetError(raw) if err == nil { t.Fatal("expected non-nil error") } if !errors.Is(err, ErrTemporary) { t.Errorf("errors.Is(err, ErrTemporary) = false, want true; err = %v", err) } }) } ================================================ FILE: pkg/channels/feishu/common.go ================================================ package feishu import ( "encoding/json" "regexp" "strings" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" ) // mentionPlaceholderRegex matches @_user_N placeholders inserted by Feishu for mentions. var mentionPlaceholderRegex = regexp.MustCompile(`@_user_\d+`) // stringValue safely dereferences a *string pointer. func stringValue(v *string) string { if v == nil { return "" } return *v } // buildMarkdownCard builds a Feishu Interactive Card JSON 2.0 string with markdown content. // JSON 2.0 cards support full CommonMark standard markdown syntax. func buildMarkdownCard(content string) (string, error) { card := map[string]any{ "schema": "2.0", "body": map[string]any{ "elements": []map[string]any{ { "tag": "markdown", "content": content, }, }, }, } data, err := json.Marshal(card) if err != nil { return "", err } return string(data), nil } // extractJSONStringField unmarshals content as JSON and returns the value of the given string field. // Returns "" if the content is invalid JSON or the field is missing/empty. func extractJSONStringField(content, field string) string { var m map[string]json.RawMessage if err := json.Unmarshal([]byte(content), &m); err != nil { return "" } raw, ok := m[field] if !ok { return "" } var s string if err := json.Unmarshal(raw, &s); err != nil { return "" } return s } // extractImageKey extracts the image_key from a Feishu image message content JSON. // Format: {"image_key": "img_xxx"} func extractImageKey(content string) string { return extractJSONStringField(content, "image_key") } // extractFileKey extracts the file_key from a Feishu file/audio message content JSON. // Format: {"file_key": "file_xxx", "file_name": "...", ...} func extractFileKey(content string) string { return extractJSONStringField(content, "file_key") } // extractFileName extracts the file_name from a Feishu file message content JSON. func extractFileName(content string) string { return extractJSONStringField(content, "file_name") } // stripMentionPlaceholders removes @_user_N placeholders from the text content. // These are inserted by Feishu when users @mention someone in a message. func stripMentionPlaceholders(content string, mentions []*larkim.MentionEvent) string { if len(mentions) == 0 { return content } for _, m := range mentions { if m.Key != nil && *m.Key != "" { content = strings.ReplaceAll(content, *m.Key, "") } } // Also clean up any remaining @_user_N patterns content = mentionPlaceholderRegex.ReplaceAllString(content, "") return strings.TrimSpace(content) } // extractCardImageKeys recursively extracts all image keys from a Feishu interactive card. // Image keys are used to download images from Feishu API. // Returns two slices: Feishu-hosted keys and external URLs. func extractCardImageKeys(rawContent string) (feishuKeys []string, externalURLs []string) { if rawContent == "" { return nil, nil } var card map[string]any if err := json.Unmarshal([]byte(rawContent), &card); err != nil { return nil, nil } extractImageKeysRecursive(card, &feishuKeys, &externalURLs) return feishuKeys, externalURLs } // isExternalURL returns true if the string is an external HTTP/HTTPS URL. func isExternalURL(s string) bool { return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") } // extractImageKeysRecursive traverses card structure to find all image keys. // Collects both Feishu-hosted keys and external URLs separately. func extractImageKeysRecursive(v any, feishuKeys, externalURLs *[]string) { switch val := v.(type) { case map[string]any: // Check if this is an img element if tag, ok := val["tag"].(string); ok { switch tag { case "img": // Try img_key first (always Feishu-hosted) if imgKey, ok := val["img_key"].(string); ok && imgKey != "" { *feishuKeys = append(*feishuKeys, imgKey) } // Check src - could be Feishu key or external URL if src, ok := val["src"].(string); ok && src != "" { if isExternalURL(src) { *externalURLs = append(*externalURLs, src) } else { *feishuKeys = append(*feishuKeys, src) } } case "icon": // Icon elements use icon_key if iconKey, ok := val["icon_key"].(string); ok && iconKey != "" { *feishuKeys = append(*feishuKeys, iconKey) } } } // Recurse into all nested structures for _, child := range val { extractImageKeysRecursive(child, feishuKeys, externalURLs) } case []any: for _, item := range val { extractImageKeysRecursive(item, feishuKeys, externalURLs) } } } ================================================ FILE: pkg/channels/feishu/common_test.go ================================================ package feishu import ( "encoding/json" "testing" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" ) func TestExtractJSONStringField(t *testing.T) { tests := []struct { name string content string field string want string }{ { name: "valid field", content: `{"image_key": "img_v2_xxx"}`, field: "image_key", want: "img_v2_xxx", }, { name: "missing field", content: `{"image_key": "img_v2_xxx"}`, field: "file_key", want: "", }, { name: "invalid JSON", content: `not json at all`, field: "image_key", want: "", }, { name: "empty content", content: "", field: "image_key", want: "", }, { name: "non-string field value", content: `{"count": 42}`, field: "count", want: "", }, { name: "empty string value", content: `{"image_key": ""}`, field: "image_key", want: "", }, { name: "multiple fields", content: `{"file_key": "file_xxx", "file_name": "test.pdf"}`, field: "file_name", want: "test.pdf", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := extractJSONStringField(tt.content, tt.field) if got != tt.want { t.Errorf("extractJSONStringField(%q, %q) = %q, want %q", tt.content, tt.field, got, tt.want) } }) } } func TestExtractImageKey(t *testing.T) { tests := []struct { name string content string want string }{ { name: "normal", content: `{"image_key": "img_v2_abc123"}`, want: "img_v2_abc123", }, { name: "missing key", content: `{"file_key": "file_xxx"}`, want: "", }, { name: "malformed JSON", content: `{broken`, want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := extractImageKey(tt.content) if got != tt.want { t.Errorf("extractImageKey(%q) = %q, want %q", tt.content, got, tt.want) } }) } } func TestExtractFileKey(t *testing.T) { tests := []struct { name string content string want string }{ { name: "normal", content: `{"file_key": "file_v2_abc123", "file_name": "test.doc"}`, want: "file_v2_abc123", }, { name: "missing key", content: `{"image_key": "img_xxx"}`, want: "", }, { name: "malformed JSON", content: `not json`, want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := extractFileKey(tt.content) if got != tt.want { t.Errorf("extractFileKey(%q) = %q, want %q", tt.content, got, tt.want) } }) } } func TestExtractFileName(t *testing.T) { tests := []struct { name string content string want string }{ { name: "normal", content: `{"file_key": "file_xxx", "file_name": "report.pdf"}`, want: "report.pdf", }, { name: "missing name", content: `{"file_key": "file_xxx"}`, want: "", }, { name: "malformed JSON", content: `{bad`, want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := extractFileName(tt.content) if got != tt.want { t.Errorf("extractFileName(%q) = %q, want %q", tt.content, got, tt.want) } }) } } func TestBuildMarkdownCard(t *testing.T) { tests := []struct { name string content string }{ { name: "normal content", content: "Hello **world**", }, { name: "empty content", content: "", }, { name: "special characters", content: `Code: "foo" & 'baz'`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := buildMarkdownCard(tt.content) if err != nil { t.Fatalf("buildMarkdownCard(%q) unexpected error: %v", tt.content, err) } // Verify valid JSON var parsed map[string]any if err := json.Unmarshal([]byte(result), &parsed); err != nil { t.Fatalf("buildMarkdownCard(%q) produced invalid JSON: %v", tt.content, err) } // Verify schema if parsed["schema"] != "2.0" { t.Errorf("schema = %v, want %q", parsed["schema"], "2.0") } // Verify body.elements[0].content == input body, ok := parsed["body"].(map[string]any) if !ok { t.Fatal("missing body in card JSON") } elements, ok := body["elements"].([]any) if !ok || len(elements) == 0 { t.Fatal("missing or empty elements in card JSON") } elem, ok := elements[0].(map[string]any) if !ok { t.Fatal("first element is not an object") } if elem["tag"] != "markdown" { t.Errorf("tag = %v, want %q", elem["tag"], "markdown") } if elem["content"] != tt.content { t.Errorf("content = %v, want %q", elem["content"], tt.content) } }) } } func TestStripMentionPlaceholders(t *testing.T) { strPtr := func(s string) *string { return &s } tests := []struct { name string content string mentions []*larkim.MentionEvent want string }{ { name: "no mentions", content: "Hello world", mentions: nil, want: "Hello world", }, { name: "single mention", content: "@_user_1 hello", mentions: []*larkim.MentionEvent{ {Key: strPtr("@_user_1")}, }, want: "hello", }, { name: "multiple mentions", content: "@_user_1 @_user_2 hey", mentions: []*larkim.MentionEvent{ {Key: strPtr("@_user_1")}, {Key: strPtr("@_user_2")}, }, want: "hey", }, { name: "empty content", content: "", mentions: []*larkim.MentionEvent{{Key: strPtr("@_user_1")}}, want: "", }, { name: "empty mentions slice", content: "@_user_1 test", mentions: []*larkim.MentionEvent{}, want: "@_user_1 test", }, { name: "mention with nil key", content: "@_user_1 test", mentions: []*larkim.MentionEvent{ {Key: nil}, }, want: "test", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := stripMentionPlaceholders(tt.content, tt.mentions) if got != tt.want { t.Errorf("stripMentionPlaceholders(%q, ...) = %q, want %q", tt.content, got, tt.want) } }) } } func TestExtractCardImageKeys(t *testing.T) { tests := []struct { name string content string wantFeishuKeys []string wantExternalURLs []string }{ { name: "empty content", content: "", wantFeishuKeys: nil, wantExternalURLs: nil, }, { name: "invalid JSON", content: "not json", wantFeishuKeys: nil, wantExternalURLs: nil, }, { name: "card with no images", content: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"text"}]}}`, wantFeishuKeys: nil, wantExternalURLs: nil, }, { name: "single image with img_key", content: `{"elements":[{"tag":"img","img_key":"img_abc123"}]}`, wantFeishuKeys: []string{"img_abc123"}, wantExternalURLs: nil, }, { name: "single image with src as Feishu key", content: `{"elements":[{"tag":"img","src":"img_xyz789"}]}`, wantFeishuKeys: []string{"img_xyz789"}, wantExternalURLs: nil, }, { name: "multiple images", content: `{"elements":[{"tag":"img","img_key":"img_1"},{"tag":"div","text":{"content":"text"}},{"tag":"img","img_key":"img_2"}]}`, wantFeishuKeys: []string{"img_1", "img_2"}, wantExternalURLs: nil, }, { name: "nested image in columns", content: `{"elements":[{"tag":"div","columns":[{"tag":"img","img_key":"img_col1"},{"tag":"img","img_key":"img_col2"}]}]}`, wantFeishuKeys: []string{"img_col1", "img_col2"}, wantExternalURLs: nil, }, { name: "image in action", content: `{"elements":[{"tag":"action","actions":[{"tag":"img","img_key":"img_action"}]}]}`, wantFeishuKeys: []string{"img_action"}, wantExternalURLs: nil, }, { name: "icon element", content: `{"elements":[{"tag":"icon","icon_key":"icon_123"}]}`, wantFeishuKeys: []string{"icon_123"}, wantExternalURLs: nil, }, { name: "complex card with text and images", content: `{"header":{"title":{"content":"Title"}},"elements":[{"tag":"div","text":{"content":"Description"}},{"tag":"img","img_key":"img_main"}]}`, wantFeishuKeys: []string{"img_main"}, wantExternalURLs: nil, }, { name: "external URL in src", content: `{"elements":[{"tag":"img","src":"https://example.com/image.png"}]}`, wantFeishuKeys: nil, wantExternalURLs: []string{"https://example.com/image.png"}, }, { name: "mixed Feishu keys and external URLs", content: `{"elements":[{"tag":"img","img_key":"img_feishu"},{"tag":"img","src":"https://cdn.example.com/external.jpg"},{"tag":"img","src":"img_another"}]}`, wantFeishuKeys: []string{"img_feishu", "img_another"}, wantExternalURLs: []string{"https://cdn.example.com/external.jpg"}, }, { name: "multiple external URLs", content: `{"elements":[{"tag":"img","src":"https://a.com/1.png"},{"tag":"img","src":"http://b.com/2.jpg"}]}`, wantFeishuKeys: nil, wantExternalURLs: []string{"https://a.com/1.png", "http://b.com/2.jpg"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotFeishuKeys, gotExternalURLs := extractCardImageKeys(tt.content) // Compare Feishu keys if len(gotFeishuKeys) != len(tt.wantFeishuKeys) { t.Errorf("extractCardImageKeys() feishuKeys = %v, want %v", gotFeishuKeys, tt.wantFeishuKeys) return } for i, v := range gotFeishuKeys { if v != tt.wantFeishuKeys[i] { t.Errorf("extractCardImageKeys() feishuKeys[%d] = %q, want %q", i, v, tt.wantFeishuKeys[i]) } } // Compare external URLs if len(gotExternalURLs) != len(tt.wantExternalURLs) { t.Errorf("extractCardImageKeys() externalURLs = %v, want %v", gotExternalURLs, tt.wantExternalURLs) return } for i, v := range gotExternalURLs { if v != tt.wantExternalURLs[i] { t.Errorf("extractCardImageKeys() externalURLs[%d] = %q, want %q", i, v, tt.wantExternalURLs[i]) } } }) } } ================================================ FILE: pkg/channels/feishu/feishu_32.go ================================================ //go:build !amd64 && !arm64 && !riscv64 && !mips64 && !ppc64 package feishu import ( "context" "errors" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) // FeishuChannel is a stub implementation for 32-bit architectures type FeishuChannel struct { *channels.BaseChannel } var errUnsupported = errors.New("feishu channel is not supported on 32-bit architectures") // NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) { return nil, errors.New( "feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config", ) } // Start is a stub method to satisfy the Channel interface func (c *FeishuChannel) Start(ctx context.Context) error { return errUnsupported } // Stop is a stub method to satisfy the Channel interface func (c *FeishuChannel) Stop(ctx context.Context) error { return errUnsupported } // Send is a stub method to satisfy the Channel interface func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return errUnsupported } // EditMessage is a stub method to satisfy MessageEditor func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error { return errUnsupported } // SendPlaceholder is a stub method to satisfy PlaceholderCapable func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { return "", errUnsupported } // ReactToMessage is a stub method to satisfy ReactionCapable func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) { return func() {}, errUnsupported } // SendMedia is a stub method to satisfy MediaSender func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { return errUnsupported } ================================================ FILE: pkg/channels/feishu/feishu_64.go ================================================ //go:build amd64 || arm64 || riscv64 || mips64 || ppc64 package feishu import ( "context" "encoding/json" "fmt" "io" "math/rand" "net/http" "os" "path/filepath" "strings" "sync" "sync/atomic" lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" larkdispatcher "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" larkws "github.com/larksuite/oapi-sdk-go/v3/ws" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" ) // errCodeTenantTokenInvalid is the Feishu API error code for an expired/revoked // tenant_access_token. The Lark SDK's built-in retry does not clear its cache // on this error, so we do it ourselves. const errCodeTenantTokenInvalid = 99991663 type FeishuChannel struct { *channels.BaseChannel config config.FeishuConfig client *lark.Client wsClient *larkws.Client tokenCache *tokenCache // custom cache that supports invalidation botOpenID atomic.Value // stores string; populated lazily for @mention detection mu sync.Mutex cancel context.CancelFunc } func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) { base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom, channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) tc := newTokenCache() opts := []lark.ClientOptionFunc{lark.WithTokenCache(tc)} if cfg.IsLark { opts = append(opts, lark.WithOpenBaseUrl(lark.LarkBaseUrl)) } ch := &FeishuChannel{ BaseChannel: base, config: cfg, tokenCache: tc, client: lark.NewClient(cfg.AppID, cfg.AppSecret, opts...), } ch.SetOwner(ch) return ch, nil } func (c *FeishuChannel) Start(ctx context.Context) error { if c.config.AppID == "" || c.config.AppSecret == "" { return fmt.Errorf("feishu app_id or app_secret is empty") } // Fetch bot open_id via API for reliable @mention detection. if err := c.fetchBotOpenID(ctx); err != nil { logger.ErrorCF("feishu", "Failed to fetch bot open_id, @mention detection may not work", map[string]any{ "error": err.Error(), }) } dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken, c.config.EncryptKey). OnP2MessageReceiveV1(c.handleMessageReceive) runCtx, cancel := context.WithCancel(ctx) c.mu.Lock() c.cancel = cancel domain := lark.FeishuBaseUrl if c.config.IsLark { domain = lark.LarkBaseUrl } c.wsClient = larkws.NewClient( c.config.AppID, c.config.AppSecret, larkws.WithEventHandler(dispatcher), larkws.WithDomain(domain), ) wsClient := c.wsClient c.mu.Unlock() c.SetRunning(true) logger.InfoC("feishu", "Feishu channel started (websocket mode)") go func() { if err := wsClient.Start(runCtx); err != nil { logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]any{ "error": err.Error(), }) } }() return nil } func (c *FeishuChannel) Stop(ctx context.Context) error { c.mu.Lock() if c.cancel != nil { c.cancel() c.cancel = nil } c.wsClient = nil c.mu.Unlock() c.SetRunning(false) logger.InfoC("feishu", "Feishu channel stopped") return nil } // Send sends a message using Interactive Card format for markdown rendering. // Falls back to plain text message if card sending fails (e.g., table limit exceeded). func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } if msg.ChatID == "" { return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) } // Build interactive card with markdown content cardContent, err := buildMarkdownCard(msg.Content) if err != nil { // If card build fails, fall back to plain text return c.sendText(ctx, msg.ChatID, msg.Content) } // First attempt: try sending as interactive card err = c.sendCard(ctx, msg.ChatID, cardContent) if err == nil { return nil } // Check if error is due to card table limit (error code 11310) // See: https://open.feishu.cn/document/server-docs/im-api/message-content-description/create_json errMsg := err.Error() isCardLimitError := strings.Contains(errMsg, "11310") if isCardLimitError { logger.WarnCF("feishu", "Card send failed (table limit), falling back to text message", map[string]any{ "chat_id": msg.ChatID, "error": errMsg, }) // Second attempt: fall back to plain text message textErr := c.sendText(ctx, msg.ChatID, msg.Content) if textErr == nil { return nil } // If text also fails, return the text error return textErr } // For other errors, return the original card error return err } // EditMessage implements channels.MessageEditor. // Uses Message.Patch to update an interactive card message. func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error { cardContent, err := buildMarkdownCard(content) if err != nil { return fmt.Errorf("feishu edit: card build failed: %w", err) } req := larkim.NewPatchMessageReqBuilder(). MessageId(messageID). Body(larkim.NewPatchMessageReqBodyBuilder().Content(cardContent).Build()). Build() resp, err := c.client.Im.V1.Message.Patch(ctx, req) if err != nil { return fmt.Errorf("feishu edit: %w", err) } if !resp.Success() { c.invalidateTokenOnAuthError(resp.Code) return fmt.Errorf("feishu edit api error (code=%d msg=%s)", resp.Code, resp.Msg) } return nil } // SendPlaceholder implements channels.PlaceholderCapable. // Sends an interactive card with placeholder text and returns its message ID. func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { if !c.config.Placeholder.Enabled { logger.DebugCF("feishu", "Placeholder disabled, skipping", map[string]any{ "chat_id": chatID, }) return "", nil } text := c.config.Placeholder.Text if text == "" { text = "Thinking..." } cardContent, err := buildMarkdownCard(text) if err != nil { return "", fmt.Errorf("feishu placeholder: card build failed: %w", err) } req := larkim.NewCreateMessageReqBuilder(). ReceiveIdType(larkim.ReceiveIdTypeChatId). Body(larkim.NewCreateMessageReqBodyBuilder(). ReceiveId(chatID). MsgType(larkim.MsgTypeInteractive). Content(cardContent). Build()). Build() resp, err := c.client.Im.V1.Message.Create(ctx, req) if err != nil { return "", fmt.Errorf("feishu placeholder send: %w", err) } if !resp.Success() { c.invalidateTokenOnAuthError(resp.Code) return "", fmt.Errorf("feishu placeholder api error (code=%d msg=%s)", resp.Code, resp.Msg) } if resp.Data != nil && resp.Data.MessageId != nil { return *resp.Data.MessageId, nil } return "", nil } // ReactToMessage implements channels.ReactionCapable. // Adds a reaction (randomly chosen from config) and returns an undo function to remove it. func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) { // Get emoji list from config emojiList := c.config.RandomReactionEmoji var chosenEmoji string if len(emojiList) == 0 { // Default to "Pin" if no config chosenEmoji = "Pin" } else { idx := rand.Intn(len(emojiList)) chosenEmoji = emojiList[idx] } req := larkim.NewCreateMessageReactionReqBuilder(). MessageId(messageID). Body(larkim.NewCreateMessageReactionReqBodyBuilder(). ReactionType(larkim.NewEmojiBuilder().EmojiType(chosenEmoji).Build()). Build()). Build() resp, err := c.client.Im.V1.MessageReaction.Create(ctx, req) if err != nil { logger.ErrorCF("feishu", "Failed to add reaction", map[string]any{ "emoji": chosenEmoji, "message_id": messageID, "error": err.Error(), }) return func() {}, fmt.Errorf("feishu react: %w", err) } if !resp.Success() { c.invalidateTokenOnAuthError(resp.Code) logger.ErrorCF("feishu", "Reaction API error", map[string]any{ "emoji": chosenEmoji, "message_id": messageID, "code": resp.Code, "msg": resp.Msg, }) return func() {}, fmt.Errorf("feishu react api error (code=%d msg=%s)", resp.Code, resp.Msg) } var reactionID string if resp.Data != nil && resp.Data.ReactionId != nil { reactionID = *resp.Data.ReactionId } if reactionID == "" { return func() {}, nil } var undone atomic.Bool undo := func() { if !undone.CompareAndSwap(false, true) { return } delReq := larkim.NewDeleteMessageReactionReqBuilder(). MessageId(messageID). ReactionId(reactionID). Build() _, _ = c.client.Im.V1.MessageReaction.Delete(context.Background(), delReq) } return undo, nil } // SendMedia implements channels.MediaSender. // Uploads images/files via Feishu API then sends as messages. func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } if msg.ChatID == "" { return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) } store := c.GetMediaStore() if store == nil { return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } for _, part := range msg.Parts { if err := c.sendMediaPart(ctx, msg.ChatID, part, store); err != nil { return err } } return nil } // sendMediaPart resolves and sends a single media part. func (c *FeishuChannel) sendMediaPart( ctx context.Context, chatID string, part bus.MediaPart, store media.MediaStore, ) error { localPath, err := store.Resolve(part.Ref) if err != nil { logger.ErrorCF("feishu", "Failed to resolve media ref", map[string]any{ "ref": part.Ref, "error": err.Error(), }) return nil // skip this part } file, err := os.Open(localPath) if err != nil { logger.ErrorCF("feishu", "Failed to open media file", map[string]any{ "path": localPath, "error": err.Error(), }) return nil // skip this part } defer file.Close() switch part.Type { case "image": err = c.sendImage(ctx, chatID, file) default: filename := part.Filename if filename == "" { filename = "file" } err = c.sendFile(ctx, chatID, file, filename, part.Type) } if err != nil { logger.ErrorCF("feishu", "Failed to send media", map[string]any{ "type": part.Type, "error": err.Error(), }) return fmt.Errorf("feishu send media: %w", channels.ErrTemporary) } return nil } // --- Inbound message handling --- func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.P2MessageReceiveV1) error { if event == nil || event.Event == nil || event.Event.Message == nil { return nil } message := event.Event.Message sender := event.Event.Sender chatID := stringValue(message.ChatId) if chatID == "" { return nil } senderID := extractFeishuSenderID(sender) if senderID == "" { senderID = "unknown" } messageType := stringValue(message.MessageType) messageID := stringValue(message.MessageId) rawContent := stringValue(message.Content) // Check allowlist early to avoid downloading media for rejected senders. // BaseChannel.HandleMessage will check again, but this avoids wasted network I/O. senderInfo := bus.SenderInfo{ Platform: "feishu", PlatformID: senderID, CanonicalID: identity.BuildCanonicalID("feishu", senderID), } if !c.IsAllowedSender(senderInfo) { return nil } // Extract content based on message type content := extractContent(messageType, rawContent) // Handle media messages (download and store) var mediaRefs []string if store := c.GetMediaStore(); store != nil && messageID != "" { mediaRefs = c.downloadInboundMedia(ctx, chatID, messageID, messageType, rawContent, store) } // For interactive cards, pass external image URLs via media refs. // Keep content as valid raw JSON for downstream parsing. if messageType == larkim.MsgTypeInteractive { _, externalURLs := extractCardImageKeys(rawContent) if len(externalURLs) > 0 { mediaRefs = append(mediaRefs, externalURLs...) } } // Append media tags to content (like Telegram does) content = appendMediaTags(content, messageType, mediaRefs) if content == "" { content = "[empty message]" } metadata := map[string]string{} if messageID != "" { metadata["message_id"] = messageID } if messageType != "" { metadata["message_type"] = messageType } chatType := stringValue(message.ChatType) if chatType != "" { metadata["chat_type"] = chatType } if sender != nil && sender.TenantKey != nil { metadata["tenant_key"] = *sender.TenantKey } var peer bus.Peer if chatType == "p2p" { peer = bus.Peer{Kind: "direct", ID: senderID} } else { peer = bus.Peer{Kind: "group", ID: chatID} // Check if bot was mentioned isMentioned := c.isBotMentioned(message) // Strip mention placeholders from content before group trigger check if len(message.Mentions) > 0 { content = stripMentionPlaceholders(content, message.Mentions) } // In group chats, apply unified group trigger filtering respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) if !respond { return nil } content = cleaned } logger.InfoCF("feishu", "Feishu message received", map[string]any{ "sender_id": senderID, "chat_id": chatID, "message_id": messageID, "preview": utils.Truncate(content, 80), }) c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, senderInfo) return nil } // --- Internal helpers --- // fetchBotOpenID calls the Feishu bot info API to retrieve and store the bot's open_id. func (c *FeishuChannel) fetchBotOpenID(ctx context.Context) error { resp, err := c.client.Do(ctx, &larkcore.ApiReq{ HttpMethod: http.MethodGet, ApiPath: "/open-apis/bot/v3/info", SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}, }) if err != nil { return fmt.Errorf("bot info request: %w", err) } var result struct { Code int `json:"code"` Bot struct { OpenID string `json:"open_id"` } `json:"bot"` } if err := json.Unmarshal(resp.RawBody, &result); err != nil { return fmt.Errorf("bot info parse: %w", err) } if result.Code != 0 { c.invalidateTokenOnAuthError(result.Code) return fmt.Errorf("bot info api error (code=%d)", result.Code) } if result.Bot.OpenID == "" { return fmt.Errorf("bot info: empty open_id") } c.botOpenID.Store(result.Bot.OpenID) logger.InfoCF("feishu", "Fetched bot open_id from API", map[string]any{ "open_id": result.Bot.OpenID, }) return nil } // isBotMentioned checks if the bot was @mentioned in the message. func (c *FeishuChannel) isBotMentioned(message *larkim.EventMessage) bool { if message.Mentions == nil { return false } knownID, _ := c.botOpenID.Load().(string) if knownID == "" { logger.DebugCF("feishu", "Bot open_id unknown, cannot detect @mention", nil) return false } for _, m := range message.Mentions { if m.Id == nil { continue } if m.Id.OpenId != nil && *m.Id.OpenId == knownID { return true } } return false } // extractContent extracts text content from different message types. func extractContent(messageType, rawContent string) string { if rawContent == "" { return "" } switch messageType { case larkim.MsgTypeText: var textPayload struct { Text string `json:"text"` } if err := json.Unmarshal([]byte(rawContent), &textPayload); err == nil { return textPayload.Text } return rawContent case larkim.MsgTypePost: // Pass raw JSON to LLM — structured rich text is more informative than flattened plain text return rawContent case larkim.MsgTypeInteractive: // Pass raw JSON to LLM — structured card is more informative than flattened text return rawContent case larkim.MsgTypeImage: // Image messages don't have text content return "" case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia: // File/audio/video messages may have a filename name := extractFileName(rawContent) if name != "" { return name } return "" default: return rawContent } } // downloadInboundMedia downloads media from inbound messages and stores in MediaStore. func (c *FeishuChannel) downloadInboundMedia( ctx context.Context, chatID, messageID, messageType, rawContent string, store media.MediaStore, ) []string { var refs []string scope := channels.BuildMediaScope("feishu", chatID, messageID) switch messageType { case larkim.MsgTypeImage: imageKey := extractImageKey(rawContent) if imageKey == "" { return nil } ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope) if ref != "" { refs = append(refs, ref) } case larkim.MsgTypeInteractive: // Extract and download images embedded in interactive cards feishuKeys, _ := extractCardImageKeys(rawContent) // Download Feishu-hosted images via API for _, imageKey := range feishuKeys { ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope) if ref != "" { refs = append(refs, ref) } } // External URLs are passed directly to LLM, not downloaded case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia: fileKey := extractFileKey(rawContent) if fileKey == "" { return nil } // Derive a fallback extension from the message type. var ext string switch messageType { case larkim.MsgTypeAudio: ext = ".ogg" case larkim.MsgTypeMedia: ext = ".mp4" default: ext = "" // generic file — rely on resp.FileName } ref := c.downloadResource(ctx, messageID, fileKey, "file", ext, store, scope) if ref != "" { refs = append(refs, ref) } } return refs } // downloadResource downloads a message resource (image/file) from Feishu, // writes it to the project media directory, and stores the reference in MediaStore. // fallbackExt (e.g. ".jpg") is appended when the resolved filename has no extension. func (c *FeishuChannel) downloadResource( ctx context.Context, messageID, fileKey, resourceType, fallbackExt string, store media.MediaStore, scope string, ) string { req := larkim.NewGetMessageResourceReqBuilder(). MessageId(messageID). FileKey(fileKey). Type(resourceType). Build() resp, err := c.client.Im.V1.MessageResource.Get(ctx, req) if err != nil { logger.ErrorCF("feishu", "Failed to download resource", map[string]any{ "message_id": messageID, "file_key": fileKey, "error": err.Error(), }) return "" } if !resp.Success() { c.invalidateTokenOnAuthError(resp.Code) logger.ErrorCF("feishu", "Resource download api error", map[string]any{ "code": resp.Code, "msg": resp.Msg, }) return "" } if resp.File == nil { return "" } // Safely close the underlying reader if it implements io.Closer (e.g. HTTP response body). if closer, ok := resp.File.(io.Closer); ok { defer closer.Close() } filename := resp.FileName if filename == "" { filename = fileKey } // If filename still has no extension, append the fallback (like Telegram's ext parameter). if filepath.Ext(filename) == "" && fallbackExt != "" { filename += fallbackExt } // Write to the shared picoclaw_media directory using a unique name to avoid collisions. mediaDir := media.TempDir() if mkdirErr := os.MkdirAll(mediaDir, 0o700); mkdirErr != nil { logger.ErrorCF("feishu", "Failed to create media directory", map[string]any{ "error": mkdirErr.Error(), }) return "" } ext := filepath.Ext(filename) localPath := filepath.Join(mediaDir, utils.SanitizeFilename(messageID+"-"+fileKey+ext)) out, err := os.Create(localPath) if err != nil { logger.ErrorCF("feishu", "Failed to create local file for resource", map[string]any{ "error": err.Error(), }) return "" } if _, copyErr := io.Copy(out, resp.File); copyErr != nil { out.Close() os.Remove(localPath) logger.ErrorCF("feishu", "Failed to write resource to file", map[string]any{ "error": copyErr.Error(), }) return "" } out.Close() ref, err := store.Store(localPath, media.MediaMeta{ Filename: filename, Source: "feishu", }, scope) if err != nil { logger.ErrorCF("feishu", "Failed to store downloaded resource", map[string]any{ "file_key": fileKey, "error": err.Error(), }) os.Remove(localPath) return "" } return ref } // appendMediaTags appends media type tags to content (like Telegram's "[image: photo]"). // For interactive cards, media tags are not appended because content is raw JSON // and appending would produce invalid JSON format. func appendMediaTags(content, messageType string, mediaRefs []string) string { if len(mediaRefs) == 0 { return content } // Don't append tags to JSON content (interactive cards) - would produce invalid JSON if messageType == larkim.MsgTypeInteractive { return content } var tag string switch messageType { case larkim.MsgTypeImage: tag = "[image: photo]" case larkim.MsgTypeAudio: tag = "[audio]" case larkim.MsgTypeMedia: tag = "[video]" case larkim.MsgTypeFile: tag = "[file]" default: tag = "[attachment]" } if content == "" { return tag } return content + " " + tag } // sendCard sends an interactive card message to a chat. func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) error { req := larkim.NewCreateMessageReqBuilder(). ReceiveIdType(larkim.ReceiveIdTypeChatId). Body(larkim.NewCreateMessageReqBodyBuilder(). ReceiveId(chatID). MsgType(larkim.MsgTypeInteractive). Content(cardContent). Build()). Build() resp, err := c.client.Im.V1.Message.Create(ctx, req) if err != nil { return fmt.Errorf("feishu send card: %w", channels.ErrTemporary) } if !resp.Success() { c.invalidateTokenOnAuthError(resp.Code) return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) } logger.DebugCF("feishu", "Feishu card message sent", map[string]any{ "chat_id": chatID, }) return nil } // sendText sends a plain text message to a chat (fallback when card fails). func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) error { content, _ := json.Marshal(map[string]string{"text": text}) req := larkim.NewCreateMessageReqBuilder(). ReceiveIdType(larkim.ReceiveIdTypeChatId). Body(larkim.NewCreateMessageReqBodyBuilder(). ReceiveId(chatID). MsgType(larkim.MsgTypeText). Content(string(content)). Build()). Build() resp, err := c.client.Im.V1.Message.Create(ctx, req) if err != nil { return fmt.Errorf("feishu send text: %w", channels.ErrTemporary) } if !resp.Success() { return fmt.Errorf("feishu text api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) } logger.DebugCF("feishu", "Feishu text message sent (fallback)", map[string]any{ "chat_id": chatID, }) return nil } // sendImage uploads an image and sends it as a message. func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.File) error { // Upload image to get image_key uploadReq := larkim.NewCreateImageReqBuilder(). Body(larkim.NewCreateImageReqBodyBuilder(). ImageType("message"). Image(file). Build()). Build() uploadResp, err := c.client.Im.V1.Image.Create(ctx, uploadReq) if err != nil { return fmt.Errorf("feishu image upload: %w", err) } if !uploadResp.Success() { c.invalidateTokenOnAuthError(uploadResp.Code) return fmt.Errorf("feishu image upload api error (code=%d msg=%s)", uploadResp.Code, uploadResp.Msg) } if uploadResp.Data == nil || uploadResp.Data.ImageKey == nil { return fmt.Errorf("feishu image upload: no image_key returned") } imageKey := *uploadResp.Data.ImageKey // Send image message content, _ := json.Marshal(map[string]string{"image_key": imageKey}) req := larkim.NewCreateMessageReqBuilder(). ReceiveIdType(larkim.ReceiveIdTypeChatId). Body(larkim.NewCreateMessageReqBodyBuilder(). ReceiveId(chatID). MsgType(larkim.MsgTypeImage). Content(string(content)). Build()). Build() resp, err := c.client.Im.V1.Message.Create(ctx, req) if err != nil { return fmt.Errorf("feishu image send: %w", err) } if !resp.Success() { c.invalidateTokenOnAuthError(resp.Code) return fmt.Errorf("feishu image send api error (code=%d msg=%s)", resp.Code, resp.Msg) } return nil } // sendFile uploads a file and sends it as a message. func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.File, filename, fileType string) error { // Map part type to Feishu file type feishuFileType := "stream" switch fileType { case "audio": feishuFileType = "opus" case "video": feishuFileType = "mp4" } // Upload file to get file_key uploadReq := larkim.NewCreateFileReqBuilder(). Body(larkim.NewCreateFileReqBodyBuilder(). FileType(feishuFileType). FileName(filename). File(file). Build()). Build() uploadResp, err := c.client.Im.V1.File.Create(ctx, uploadReq) if err != nil { return fmt.Errorf("feishu file upload: %w", err) } if !uploadResp.Success() { c.invalidateTokenOnAuthError(uploadResp.Code) return fmt.Errorf("feishu file upload api error (code=%d msg=%s)", uploadResp.Code, uploadResp.Msg) } if uploadResp.Data == nil || uploadResp.Data.FileKey == nil { return fmt.Errorf("feishu file upload: no file_key returned") } fileKey := *uploadResp.Data.FileKey // Send file message content, _ := json.Marshal(map[string]string{"file_key": fileKey}) req := larkim.NewCreateMessageReqBuilder(). ReceiveIdType(larkim.ReceiveIdTypeChatId). Body(larkim.NewCreateMessageReqBodyBuilder(). ReceiveId(chatID). MsgType(larkim.MsgTypeFile). Content(string(content)). Build()). Build() resp, err := c.client.Im.V1.Message.Create(ctx, req) if err != nil { return fmt.Errorf("feishu file send: %w", err) } if !resp.Success() { c.invalidateTokenOnAuthError(resp.Code) return fmt.Errorf("feishu file send api error (code=%d msg=%s)", resp.Code, resp.Msg) } return nil } func extractFeishuSenderID(sender *larkim.EventSender) string { if sender == nil || sender.SenderId == nil { return "" } if sender.SenderId.UserId != nil && *sender.SenderId.UserId != "" { return *sender.SenderId.UserId } if sender.SenderId.OpenId != nil && *sender.SenderId.OpenId != "" { return *sender.SenderId.OpenId } if sender.SenderId.UnionId != nil && *sender.SenderId.UnionId != "" { return *sender.SenderId.UnionId } return "" } // invalidateTokenOnAuthError clears the cached tenant_access_token when the // Feishu API reports it as invalid (99991663), so the next request fetches a // fresh one. The Lark SDK's built-in retry does not clear the cache, causing // all API calls to fail until the token naturally expires (~2 hours). func (c *FeishuChannel) invalidateTokenOnAuthError(code int) { if code == errCodeTenantTokenInvalid { c.tokenCache.InvalidateAll() logger.WarnCF("feishu", "Invalidated cached token due to auth error", nil) } } ================================================ FILE: pkg/channels/feishu/feishu_64_test.go ================================================ //go:build amd64 || arm64 || riscv64 || mips64 || ppc64 package feishu import ( "testing" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" ) func TestExtractContent(t *testing.T) { tests := []struct { name string messageType string rawContent string want string }{ { name: "text message", messageType: "text", rawContent: `{"text": "hello world"}`, want: "hello world", }, { name: "text message invalid JSON", messageType: "text", rawContent: `not json`, want: "not json", }, { name: "post message returns raw JSON", messageType: "post", rawContent: `{"title": "test post"}`, want: `{"title": "test post"}`, }, { name: "image message returns empty", messageType: "image", rawContent: `{"image_key": "img_xxx"}`, want: "", }, { name: "file message with filename", messageType: "file", rawContent: `{"file_key": "file_xxx", "file_name": "report.pdf"}`, want: "report.pdf", }, { name: "file message without filename", messageType: "file", rawContent: `{"file_key": "file_xxx"}`, want: "", }, { name: "audio message with filename", messageType: "audio", rawContent: `{"file_key": "file_xxx", "file_name": "recording.ogg"}`, want: "recording.ogg", }, { name: "media message with filename", messageType: "media", rawContent: `{"file_key": "file_xxx", "file_name": "video.mp4"}`, want: "video.mp4", }, { name: "unknown message type returns raw", messageType: "sticker", rawContent: `{"sticker_id": "sticker_xxx"}`, want: `{"sticker_id": "sticker_xxx"}`, }, { name: "empty raw content", messageType: "text", rawContent: "", want: "", }, { name: "interactive card returns raw JSON", messageType: "interactive", rawContent: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"Hello from card"}]}}`, want: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"Hello from card"}]}}`, }, { name: "interactive card with complex structure returns raw JSON", messageType: "interactive", rawContent: `{"header":{"title":{"tag":"plain_text","content":"Title"}},"elements":[{"tag":"div","text":{"tag":"lark_md","content":"Card content"}}]}`, want: `{"header":{"title":{"tag":"plain_text","content":"Title"}},"elements":[{"tag":"div","text":{"tag":"lark_md","content":"Card content"}}]}`, }, { name: "interactive card invalid JSON returns as-is", messageType: "interactive", rawContent: `not valid json`, want: `not valid json`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := extractContent(tt.messageType, tt.rawContent) if got != tt.want { t.Errorf("extractContent(%q, %q) = %q, want %q", tt.messageType, tt.rawContent, got, tt.want) } }) } } func TestAppendMediaTags(t *testing.T) { tests := []struct { name string content string messageType string mediaRefs []string want string }{ { name: "no refs returns content unchanged", content: "hello", messageType: "image", mediaRefs: nil, want: "hello", }, { name: "empty refs returns content unchanged", content: "hello", messageType: "image", mediaRefs: []string{}, want: "hello", }, { name: "image with content", content: "check this", messageType: "image", mediaRefs: []string{"ref1"}, want: "check this [image: photo]", }, { name: "image empty content", content: "", messageType: "image", mediaRefs: []string{"ref1"}, want: "[image: photo]", }, { name: "audio", content: "listen", messageType: "audio", mediaRefs: []string{"ref1"}, want: "listen [audio]", }, { name: "media/video", content: "watch", messageType: "media", mediaRefs: []string{"ref1"}, want: "watch [video]", }, { name: "file", content: "report.pdf", messageType: "file", mediaRefs: []string{"ref1"}, want: "report.pdf [file]", }, { name: "unknown type", content: "something", messageType: "sticker", mediaRefs: []string{"ref1"}, want: "something [attachment]", }, { name: "interactive card with images returns content unchanged", content: `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`, messageType: "interactive", mediaRefs: []string{"ref1"}, want: `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := appendMediaTags(tt.content, tt.messageType, tt.mediaRefs) if got != tt.want { t.Errorf( "appendMediaTags(%q, %q, %v) = %q, want %q", tt.content, tt.messageType, tt.mediaRefs, got, tt.want, ) } }) } } func TestExtractFeishuSenderID(t *testing.T) { strPtr := func(s string) *string { return &s } tests := []struct { name string sender *larkim.EventSender want string }{ { name: "nil sender", sender: nil, want: "", }, { name: "nil sender ID", sender: &larkim.EventSender{SenderId: nil}, want: "", }, { name: "userId preferred", sender: &larkim.EventSender{ SenderId: &larkim.UserId{ UserId: strPtr("u_abc123"), OpenId: strPtr("ou_def456"), UnionId: strPtr("on_ghi789"), }, }, want: "u_abc123", }, { name: "openId fallback", sender: &larkim.EventSender{ SenderId: &larkim.UserId{ UserId: strPtr(""), OpenId: strPtr("ou_def456"), UnionId: strPtr("on_ghi789"), }, }, want: "ou_def456", }, { name: "unionId fallback", sender: &larkim.EventSender{ SenderId: &larkim.UserId{ UserId: strPtr(""), OpenId: strPtr(""), UnionId: strPtr("on_ghi789"), }, }, want: "on_ghi789", }, { name: "all empty strings", sender: &larkim.EventSender{ SenderId: &larkim.UserId{ UserId: strPtr(""), OpenId: strPtr(""), UnionId: strPtr(""), }, }, want: "", }, { name: "nil userId pointer falls through", sender: &larkim.EventSender{ SenderId: &larkim.UserId{ UserId: nil, OpenId: strPtr("ou_def456"), UnionId: nil, }, }, want: "ou_def456", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := extractFeishuSenderID(tt.sender) if got != tt.want { t.Errorf("extractFeishuSenderID() = %q, want %q", got, tt.want) } }) } } ================================================ FILE: pkg/channels/feishu/init.go ================================================ package feishu import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) func init() { channels.RegisterFactory("feishu", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { return NewFeishuChannel(cfg.Channels.Feishu, b) }) } ================================================ FILE: pkg/channels/feishu/token_cache.go ================================================ package feishu import ( "context" "sync" "time" ) // tokenCache implements larkcore.Cache with an extra InvalidateAll method. // This works around a bug in the Lark SDK v3 where the built-in token retry // loop does not clear stale tokens from cache on auth errors. type tokenCache struct { mu sync.RWMutex store map[string]*tokenEntry } type tokenEntry struct { value string expireAt time.Time } func newTokenCache() *tokenCache { return &tokenCache{store: make(map[string]*tokenEntry)} } func (c *tokenCache) Set(_ context.Context, key, value string, ttl time.Duration) error { c.mu.Lock() defer c.mu.Unlock() c.store[key] = &tokenEntry{value: value, expireAt: time.Now().Add(ttl)} return nil } func (c *tokenCache) Get(_ context.Context, key string) (string, error) { c.mu.Lock() defer c.mu.Unlock() e, ok := c.store[key] if !ok { return "", nil } if e.expireAt.Before(time.Now()) { delete(c.store, key) return "", nil } return e.value, nil } // InvalidateAll removes all cached tokens, forcing fresh acquisition. func (c *tokenCache) InvalidateAll() { c.mu.Lock() defer c.mu.Unlock() clear(c.store) } ================================================ FILE: pkg/channels/interfaces.go ================================================ package channels import ( "context" "github.com/sipeed/picoclaw/pkg/commands" ) // TypingCapable — channels that can show a typing/thinking indicator. // StartTyping begins the indicator and returns a stop function. // The stop function MUST be idempotent and safe to call multiple times. type TypingCapable interface { StartTyping(ctx context.Context, chatID string) (stop func(), err error) } // MessageEditor — channels that can edit an existing message. // messageID is always string; channels convert platform-specific types internally. type MessageEditor interface { EditMessage(ctx context.Context, chatID string, messageID string, content string) error } // ReactionCapable — channels that can add a reaction (e.g. 👀) to an inbound message. // ReactToMessage adds a reaction and returns an undo function to remove it. // The undo function MUST be idempotent and safe to call multiple times. type ReactionCapable interface { ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error) } // PlaceholderCapable — channels that can send a placeholder message // (e.g. "Thinking... 💭") that will later be edited to the actual response. // The channel MUST also implement MessageEditor for the placeholder to be useful. // SendPlaceholder returns the platform message ID of the placeholder so that // Manager.preSend can later edit it via MessageEditor.EditMessage. type PlaceholderCapable interface { SendPlaceholder(ctx context.Context, chatID string) (messageID string, err error) } // PlaceholderRecorder is injected into channels by Manager. // Channels call these methods on inbound to register typing/placeholder state. // Manager uses the registered state on outbound to stop typing and edit placeholders. type PlaceholderRecorder interface { RecordPlaceholder(channel, chatID, placeholderID string) RecordTypingStop(channel, chatID string, stop func()) RecordReactionUndo(channel, chatID string, undo func()) } // CommandRegistrarCapable is implemented by channels that can register // command menus with their upstream platform (e.g. Telegram BotCommand). // Channels that do not support platform-level command menus can ignore it. type CommandRegistrarCapable interface { RegisterCommands(ctx context.Context, defs []commands.Definition) error } ================================================ FILE: pkg/channels/interfaces_command_test.go ================================================ package channels import ( "context" "testing" "github.com/sipeed/picoclaw/pkg/commands" ) type mockRegistrar struct{} func (mockRegistrar) RegisterCommands(context.Context, []commands.Definition) error { return nil } func TestCommandRegistrarCapable_Compiles(t *testing.T) { var _ CommandRegistrarCapable = mockRegistrar{} } ================================================ FILE: pkg/channels/irc/handler.go ================================================ package irc import ( "fmt" "strings" "time" "unicode" "github.com/ergochat/irc-go/ircevent" "github.com/ergochat/irc-go/ircmsg" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" ) // onConnect is called after a successful connection (and on reconnect). func (c *IRCChannel) onConnect(conn *ircevent.Connection) { // NickServ auth (only if SASL is not configured) if c.config.NickServPassword != "" && c.config.SASLUser == "" { conn.Privmsg("NickServ", "IDENTIFY "+c.config.NickServPassword) } // Join configured channels for _, ch := range c.config.Channels { conn.Join(ch) logger.InfoCF("irc", "Joined IRC channel", map[string]any{ "channel": ch, }) } } // onPrivmsg handles incoming PRIVMSG events. func (c *IRCChannel) onPrivmsg(conn *ircevent.Connection, e ircmsg.Message) { if len(e.Params) < 2 { return } nick := e.Nick() currentNick := conn.CurrentNick() // Ignore own messages if strings.EqualFold(nick, currentNick) { return } target := e.Params[0] // channel name or bot's nick content := e.Params[1] // message text // Determine if this is a DM or channel message isDM := !strings.HasPrefix(target, "#") && !strings.HasPrefix(target, "&") var chatID string var peer bus.Peer if isDM { chatID = nick peer = bus.Peer{Kind: "direct", ID: nick} } else { chatID = target peer = bus.Peer{Kind: "group", ID: target} } sender := bus.SenderInfo{ Platform: "irc", PlatformID: nick, CanonicalID: identity.BuildCanonicalID("irc", nick), Username: nick, DisplayName: nick, } if !c.IsAllowedSender(sender) { return } // For channel messages, check group trigger (mention detection) if !isDM { isMentioned := isBotMentioned(content, currentNick) if isMentioned { content = stripBotMention(content, currentNick) } respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) if !respond { return } content = cleaned } if strings.TrimSpace(content) == "" { return } messageID := fmt.Sprintf("%s-%d", nick, time.Now().UnixNano()) metadata := map[string]string{ "platform": "irc", "server": c.config.Server, } if !isDM { metadata["channel"] = target } c.HandleMessage(c.ctx, peer, messageID, nick, chatID, content, nil, metadata, sender) } // nickMentionedAt returns the byte index where botNick is mentioned in content // with word-boundary checks, or -1 if not found. Also checks for "nick:" / // "nick," prefix convention. func nickMentionedAt(content, botNick string) int { lower := strings.ToLower(content) lowerNick := strings.ToLower(botNick) // "nick:" or "nick," at start (most common IRC convention) if strings.HasPrefix(lower, lowerNick+":") || strings.HasPrefix(lower, lowerNick+",") { return 0 } // Word-boundary match anywhere in the message idx := strings.Index(lower, lowerNick) if idx < 0 { return -1 } runes := []rune(lower) nickRunes := []rune(lowerNick) endIdx := idx + len(string(nickRunes)) before := idx == 0 || !unicode.IsLetter(runes[idx-1]) && !unicode.IsDigit(runes[idx-1]) after := endIdx >= len(lower) || !unicode.IsLetter(rune(lower[endIdx])) && !unicode.IsDigit(rune(lower[endIdx])) if before && after { return idx } return -1 } // isBotMentioned checks if the bot's nick appears in the message. func isBotMentioned(content, botNick string) bool { return nickMentionedAt(content, botNick) >= 0 } // stripBotMention removes "nick: " or "nick, " prefix from content. func stripBotMention(content, botNick string) string { idx := nickMentionedAt(content, botNick) if idx != 0 { return content } lowerNick := strings.ToLower(botNick) lower := strings.ToLower(content) for _, sep := range []string{":", ","} { prefix := lowerNick + sep if strings.HasPrefix(lower, prefix) { return strings.TrimSpace(content[len(prefix):]) } } return content } ================================================ FILE: pkg/channels/irc/init.go ================================================ package irc import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) func init() { channels.RegisterFactory("irc", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { if !cfg.Channels.IRC.Enabled { return nil, nil } return NewIRCChannel(cfg.Channels.IRC, b) }) } ================================================ FILE: pkg/channels/irc/irc.go ================================================ package irc import ( "context" "crypto/tls" "fmt" "strings" "github.com/ergochat/irc-go/ircevent" "github.com/ergochat/irc-go/ircmsg" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" ) // IRCChannel implements the Channel interface for IRC servers. type IRCChannel struct { *channels.BaseChannel config config.IRCConfig conn *ircevent.Connection ctx context.Context cancel context.CancelFunc } // NewIRCChannel creates a new IRC channel. func NewIRCChannel(cfg config.IRCConfig, messageBus *bus.MessageBus) (*IRCChannel, error) { if cfg.Server == "" { return nil, fmt.Errorf("irc server is required") } if cfg.Nick == "" { return nil, fmt.Errorf("irc nick is required") } base := channels.NewBaseChannel("irc", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(400), channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) return &IRCChannel{ BaseChannel: base, config: cfg, }, nil } // Start connects to the IRC server and begins listening. func (c *IRCChannel) Start(ctx context.Context) error { logger.InfoC("irc", "Starting IRC channel") c.ctx, c.cancel = context.WithCancel(ctx) user := c.config.User if user == "" { user = c.config.Nick } realName := c.config.RealName if realName == "" { realName = c.config.Nick } caps := []string(c.config.RequestCaps) if len(caps) == 0 { caps = []string{"server-time", "message-tags"} } conn := &ircevent.Connection{ Server: c.config.Server, Nick: c.config.Nick, User: user, RealName: realName, Password: c.config.Password, UseTLS: c.config.TLS, RequestCaps: caps, QuitMessage: "Goodbye", Debug: false, Log: nil, } if c.config.TLS { conn.TLSConfig = &tls.Config{ ServerName: extractHost(c.config.Server), } } // SASL auth (takes priority over NickServ) if c.config.SASLUser != "" && c.config.SASLPassword != "" { conn.SASLLogin = c.config.SASLUser conn.SASLPassword = c.config.SASLPassword } // Register event handlers conn.AddConnectCallback(func(e ircmsg.Message) { c.onConnect(conn) }) conn.AddCallback("PRIVMSG", func(e ircmsg.Message) { c.onPrivmsg(conn, e) }) if err := conn.Connect(); err != nil { return fmt.Errorf("irc connect failed: %w", err) } c.conn = conn // ircevent.Connection.Loop() handles reconnection internally. go conn.Loop() c.SetRunning(true) logger.InfoCF("irc", "IRC channel started", map[string]any{ "server": c.config.Server, "nick": c.config.Nick, }) return nil } // Stop disconnects from the IRC server. func (c *IRCChannel) Stop(ctx context.Context) error { logger.InfoC("irc", "Stopping IRC channel") c.SetRunning(false) if c.conn != nil { c.conn.Quit() } if c.cancel != nil { c.cancel() } logger.InfoC("irc", "IRC channel stopped") return nil } // Send sends a message to an IRC channel or user. func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } target := msg.ChatID if target == "" { return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) } if strings.TrimSpace(msg.Content) == "" { return nil } // Send each line separately (IRC is line-oriented) lines := strings.Split(msg.Content, "\n") for _, line := range lines { line = strings.TrimRight(line, "\r") if line == "" { continue } c.conn.Privmsg(target, line) } logger.DebugCF("irc", "Message sent", map[string]any{ "target": target, "lines": len(lines), }) return nil } // StartTyping implements channels.TypingCapable using IRCv3 +typing client tag. // Requires typing.enabled in config and server support for message-tags capability. func (c *IRCChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { noop := func() {} if !c.config.Typing.Enabled || !c.IsRunning() || c.conn == nil { return noop, nil } // Check if server supports message-tags (required for TAGMSG) if _, ok := c.conn.AcknowledgedCaps()["message-tags"]; !ok { return noop, nil } c.conn.SendWithTags(map[string]string{"+typing": "active"}, "TAGMSG", chatID) return func() { if c.IsRunning() && c.conn != nil { c.conn.SendWithTags(map[string]string{"+typing": "done"}, "TAGMSG", chatID) } }, nil } // extractHost returns the hostname portion of a host:port string. func extractHost(server string) string { host, _, found := strings.Cut(server, ":") if found { return host } return server } ================================================ FILE: pkg/channels/irc/irc_test.go ================================================ package irc import ( "testing" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" ) func TestNewIRCChannel(t *testing.T) { msgBus := bus.NewMessageBus() t.Run("missing server", func(t *testing.T) { cfg := config.IRCConfig{Nick: "bot"} _, err := NewIRCChannel(cfg, msgBus) if err == nil { t.Error("expected error for missing server, got nil") } }) t.Run("missing nick", func(t *testing.T) { cfg := config.IRCConfig{Server: "irc.example.com:6667"} _, err := NewIRCChannel(cfg, msgBus) if err == nil { t.Error("expected error for missing nick, got nil") } }) t.Run("valid config", func(t *testing.T) { cfg := config.IRCConfig{ Server: "irc.example.com:6667", Nick: "testbot", Channels: []string{"#test"}, } ch, err := NewIRCChannel(cfg, msgBus) if err != nil { t.Fatalf("unexpected error: %v", err) } if ch.Name() != "irc" { t.Errorf("Name() = %q, want %q", ch.Name(), "irc") } if ch.IsRunning() { t.Error("new channel should not be running") } }) } func TestExtractHost(t *testing.T) { tests := []struct { server string want string }{ {"irc.libera.chat:6697", "irc.libera.chat"}, {"localhost:6667", "localhost"}, {"irc.example.com", "irc.example.com"}, {"", ""}, } for _, tt := range tests { t.Run(tt.server, func(t *testing.T) { got := extractHost(tt.server) if got != tt.want { t.Errorf("extractHost(%q) = %q, want %q", tt.server, got, tt.want) } }) } } func TestNickMentionedAt(t *testing.T) { tests := []struct { name string content string nick string want int }{ {"colon prefix", "bot: hello", "bot", 0}, {"comma prefix", "bot, hello", "bot", 0}, {"case insensitive", "BOT: hello", "bot", 0}, {"word boundary mid", "hey bot what's up", "bot", 4}, {"no mention", "hello world", "bot", -1}, {"substring mismatch", "robotics are cool", "bot", -1}, {"nick at end", "hello bot", "bot", 6}, {"empty content", "", "bot", -1}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := nickMentionedAt(tt.content, tt.nick) if got != tt.want { t.Errorf("nickMentionedAt(%q, %q) = %d, want %d", tt.content, tt.nick, got, tt.want) } }) } } func TestIsBotMentioned(t *testing.T) { tests := []struct { name string content string nick string want bool }{ {"colon prefix", "bot: hello", "bot", true}, {"comma prefix", "bot, hello", "bot", true}, {"case insensitive", "BOT: hello", "bot", true}, {"word boundary mid", "hey bot what's up", "bot", true}, {"no mention", "hello world", "bot", false}, {"substring mismatch", "robotics are cool", "bot", false}, {"nick at end", "hello bot", "bot", true}, {"empty content", "", "bot", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := isBotMentioned(tt.content, tt.nick) if got != tt.want { t.Errorf("isBotMentioned(%q, %q) = %v, want %v", tt.content, tt.nick, got, tt.want) } }) } } func TestStripBotMention(t *testing.T) { tests := []struct { name string content string nick string want string }{ {"colon prefix", "bot: hello there", "bot", "hello there"}, {"comma prefix", "bot, help me", "bot", "help me"}, {"case insensitive", "BOT: hello", "bot", "hello"}, {"no prefix match", "hello bot", "bot", "hello bot"}, {"only prefix", "bot:", "bot", ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := stripBotMention(tt.content, tt.nick) if got != tt.want { t.Errorf("stripBotMention(%q, %q) = %q, want %q", tt.content, tt.nick, got, tt.want) } }) } } ================================================ FILE: pkg/channels/line/init.go ================================================ package line import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) func init() { channels.RegisterFactory("line", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { return NewLINEChannel(cfg.Channels.LINE, b) }) } ================================================ FILE: pkg/channels/line/line.go ================================================ package line import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "strings" "sync" "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" ) const ( lineAPIBase = "https://api.line.me/v2/bot" lineDataAPIBase = "https://api-data.line.me/v2/bot" lineReplyEndpoint = lineAPIBase + "/message/reply" linePushEndpoint = lineAPIBase + "/message/push" lineContentEndpoint = lineDataAPIBase + "/message/%s/content" lineBotInfoEndpoint = lineAPIBase + "/info" lineLoadingEndpoint = lineAPIBase + "/chat/loading/start" lineReplyTokenMaxAge = 25 * time.Second // Limit request body to prevent memory exhaustion (DoS). // LINE webhook payloads are typically a few KB; 1 MiB is generous. maxWebhookBodySize = 1 << 20 // 1 MiB ) type replyTokenEntry struct { token string timestamp time.Time } // LINEChannel implements the Channel interface for LINE Official Account // using the LINE Messaging API with HTTP webhook for receiving messages // and REST API for sending messages. type LINEChannel struct { *channels.BaseChannel config config.LINEConfig infoClient *http.Client // for bot info lookups (short timeout) apiClient *http.Client // for messaging API calls botUserID string // Bot's user ID botBasicID string // Bot's basic ID (e.g. @216ru...) botDisplayName string // Bot's display name for text-based mention detection replyTokens sync.Map // chatID -> replyTokenEntry quoteTokens sync.Map // chatID -> quoteToken (string) ctx context.Context cancel context.CancelFunc } // NewLINEChannel creates a new LINE channel instance. func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINEChannel, error) { if cfg.ChannelSecret == "" || cfg.ChannelAccessToken == "" { return nil, fmt.Errorf("line channel_secret and channel_access_token are required") } base := channels.NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(5000), channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) return &LINEChannel{ BaseChannel: base, config: cfg, infoClient: &http.Client{Timeout: 10 * time.Second}, apiClient: &http.Client{Timeout: 30 * time.Second}, }, nil } // Start initializes the LINE channel. func (c *LINEChannel) Start(ctx context.Context) error { logger.InfoC("line", "Starting LINE channel (Webhook Mode)") c.ctx, c.cancel = context.WithCancel(ctx) // Fetch bot profile to get bot's userId for mention detection if err := c.fetchBotInfo(); err != nil { logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]any{ "error": err.Error(), }) } else { logger.InfoCF("line", "Bot info fetched", map[string]any{ "bot_user_id": c.botUserID, "basic_id": c.botBasicID, "display_name": c.botDisplayName, }) } c.SetRunning(true) logger.InfoC("line", "LINE channel started (Webhook Mode)") return nil } // fetchBotInfo retrieves the bot's userId, basicId, and displayName from the LINE API. func (c *LINEChannel) fetchBotInfo() error { req, err := http.NewRequest(http.MethodGet, lineBotInfoEndpoint, nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken) resp, err := c.infoClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("bot info API returned status %d", resp.StatusCode) } var info struct { UserID string `json:"userId"` BasicID string `json:"basicId"` DisplayName string `json:"displayName"` } if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { return err } c.botUserID = info.UserID c.botBasicID = info.BasicID c.botDisplayName = info.DisplayName return nil } // Stop gracefully stops the LINE channel. func (c *LINEChannel) Stop(ctx context.Context) error { logger.InfoC("line", "Stopping LINE channel") if c.cancel != nil { c.cancel() } c.SetRunning(false) logger.InfoC("line", "LINE channel stopped") return nil } // WebhookPath returns the path for registering on the shared HTTP server. func (c *LINEChannel) WebhookPath() string { if c.config.WebhookPath != "" { return c.config.WebhookPath } return "/webhook/line" } // ServeHTTP implements http.Handler for the shared HTTP server. func (c *LINEChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { c.webhookHandler(w, r) } // webhookHandler handles incoming LINE webhook requests. func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1)) if err != nil { logger.ErrorCF("line", "Failed to read request body", map[string]any{ "error": err.Error(), }) http.Error(w, "Bad request", http.StatusBadRequest) return } if int64(len(body)) > maxWebhookBodySize { logger.WarnC("line", "Webhook request body too large, rejected") http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge) return } signature := r.Header.Get("X-Line-Signature") if !c.verifySignature(body, signature) { logger.WarnC("line", "Invalid webhook signature") http.Error(w, "Forbidden", http.StatusForbidden) return } var payload struct { Events []lineEvent `json:"events"` } if err := json.Unmarshal(body, &payload); err != nil { logger.ErrorCF("line", "Failed to parse webhook payload", map[string]any{ "error": err.Error(), }) http.Error(w, "Bad request", http.StatusBadRequest) return } // Return 200 immediately, process events asynchronously w.WriteHeader(http.StatusOK) for _, event := range payload.Events { go c.processEvent(event) } } // verifySignature validates the X-Line-Signature using HMAC-SHA256. func (c *LINEChannel) verifySignature(body []byte, signature string) bool { if signature == "" { return false } mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret)) mac.Write(body) expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(signature)) } // LINE webhook event types type lineEvent struct { Type string `json:"type"` ReplyToken string `json:"replyToken"` Source lineSource `json:"source"` Message json.RawMessage `json:"message"` Timestamp int64 `json:"timestamp"` } type lineSource struct { Type string `json:"type"` // "user", "group", "room" UserID string `json:"userId"` GroupID string `json:"groupId"` RoomID string `json:"roomId"` } type lineMessage struct { ID string `json:"id"` Type string `json:"type"` // "text", "image", "video", "audio", "file", "sticker" Text string `json:"text"` QuoteToken string `json:"quoteToken"` Mention *struct { Mentionees []lineMentionee `json:"mentionees"` } `json:"mention"` ContentProvider struct { Type string `json:"type"` } `json:"contentProvider"` } type lineMentionee struct { Index int `json:"index"` Length int `json:"length"` Type string `json:"type"` // "user", "all" UserID string `json:"userId"` } func (c *LINEChannel) processEvent(event lineEvent) { if event.Type != "message" { logger.DebugCF("line", "Ignoring non-message event", map[string]any{ "type": event.Type, }) return } senderID := event.Source.UserID chatID := c.resolveChatID(event.Source) isGroup := event.Source.Type == "group" || event.Source.Type == "room" var msg lineMessage if err := json.Unmarshal(event.Message, &msg); err != nil { logger.ErrorCF("line", "Failed to parse message", map[string]any{ "error": err.Error(), }) return } // Store reply token for later use if event.ReplyToken != "" { c.replyTokens.Store(chatID, replyTokenEntry{ token: event.ReplyToken, timestamp: time.Now(), }) } // Store quote token for quoting the original message in reply if msg.QuoteToken != "" { c.quoteTokens.Store(chatID, msg.QuoteToken) } var content string var mediaPaths []string scope := channels.BuildMediaScope("line", chatID, msg.ID) // Helper to register a local file with the media store storeMedia := func(localPath, filename string) string { if store := c.GetMediaStore(); store != nil { ref, err := store.Store(localPath, media.MediaMeta{ Filename: filename, Source: "line", }, scope) if err == nil { return ref } } return localPath // fallback } switch msg.Type { case "text": content = msg.Text // Strip bot mention from text in group chats if isGroup { content = c.stripBotMention(content, msg) } case "image": localPath := c.downloadContent(msg.ID, "image.jpg") if localPath != "" { mediaPaths = append(mediaPaths, storeMedia(localPath, "image.jpg")) content = "[image]" } case "audio": localPath := c.downloadContent(msg.ID, "audio.m4a") if localPath != "" { mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.m4a")) content = "[audio]" } case "video": localPath := c.downloadContent(msg.ID, "video.mp4") if localPath != "" { mediaPaths = append(mediaPaths, storeMedia(localPath, "video.mp4")) content = "[video]" } case "file": content = "[file]" case "sticker": content = "[sticker]" default: content = fmt.Sprintf("[%s]", msg.Type) } if strings.TrimSpace(content) == "" { return } // In group chats, apply unified group trigger filtering if isGroup { isMentioned := c.isBotMentioned(msg) respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) if !respond { logger.DebugCF("line", "Ignoring group message by group trigger", map[string]any{ "chat_id": chatID, }) return } content = cleaned } metadata := map[string]string{ "platform": "line", "source_type": event.Source.Type, } var peer bus.Peer if isGroup { peer = bus.Peer{Kind: "group", ID: chatID} } else { peer = bus.Peer{Kind: "direct", ID: senderID} } logger.DebugCF("line", "Received message", map[string]any{ "sender_id": senderID, "chat_id": chatID, "message_type": msg.Type, "is_group": isGroup, "preview": utils.Truncate(content, 50), }) sender := bus.SenderInfo{ Platform: "line", PlatformID: senderID, CanonicalID: identity.BuildCanonicalID("line", senderID), } if !c.IsAllowedSender(sender) { return } c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, mediaPaths, metadata, sender) } // isBotMentioned checks if the bot is mentioned in the message. // It first checks the mention metadata (userId match), then falls back // to text-based detection using the bot's display name, since LINE may // not include userId in mentionees for Official Accounts. func (c *LINEChannel) isBotMentioned(msg lineMessage) bool { // Check mention metadata if msg.Mention != nil { for _, m := range msg.Mention.Mentionees { if m.Type == "all" { return true } if c.botUserID != "" && m.UserID == c.botUserID { return true } } // Mention metadata exists with mentionees but bot not matched by userId. // The bot IS likely mentioned (LINE includes mention struct when bot is @-ed), // so check if any mentionee overlaps with bot display name in text. if c.botDisplayName != "" { for _, m := range msg.Mention.Mentionees { if m.Index >= 0 && m.Length > 0 { runes := []rune(msg.Text) end := m.Index + m.Length if end <= len(runes) { mentionText := string(runes[m.Index:end]) if strings.Contains(mentionText, c.botDisplayName) { return true } } } } } } // Fallback: text-based detection with display name if c.botDisplayName != "" && strings.Contains(msg.Text, "@"+c.botDisplayName) { return true } return false } // stripBotMention removes the @BotName mention text from the message. func (c *LINEChannel) stripBotMention(text string, msg lineMessage) string { stripped := false // Try to strip using mention metadata indices if msg.Mention != nil { runes := []rune(text) for i := len(msg.Mention.Mentionees) - 1; i >= 0; i-- { m := msg.Mention.Mentionees[i] // Strip if userId matches OR if the mention text contains the bot display name shouldStrip := false if c.botUserID != "" && m.UserID == c.botUserID { shouldStrip = true } else if c.botDisplayName != "" && m.Index >= 0 && m.Length > 0 { end := m.Index + m.Length if end <= len(runes) { mentionText := string(runes[m.Index:end]) if strings.Contains(mentionText, c.botDisplayName) { shouldStrip = true } } } if shouldStrip { start := m.Index end := m.Index + m.Length if start >= 0 && end <= len(runes) { runes = append(runes[:start], runes[end:]...) stripped = true } } } if stripped { return strings.TrimSpace(string(runes)) } } // Fallback: strip @DisplayName from text if c.botDisplayName != "" { text = strings.ReplaceAll(text, "@"+c.botDisplayName, "") } return strings.TrimSpace(text) } // resolveChatID determines the chat ID from the event source. // For group/room messages, use the group/room ID; for 1:1, use the user ID. func (c *LINEChannel) resolveChatID(source lineSource) string { switch source.Type { case "group": return source.GroupID case "room": return source.RoomID default: return source.UserID } } // Send sends a message to LINE. It first tries the Reply API (free) // using a cached reply token, then falls back to the Push API. func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } // Load and consume quote token for this chat var quoteToken string if qt, ok := c.quoteTokens.LoadAndDelete(msg.ChatID); ok { quoteToken = qt.(string) } // Try reply token first (free, valid for ~25 seconds) if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok { tokenEntry := entry.(replyTokenEntry) if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge { if err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil { logger.DebugCF("line", "Message sent via Reply API", map[string]any{ "chat_id": msg.ChatID, "quoted": quoteToken != "", }) return nil } logger.DebugC("line", "Reply API failed, falling back to Push API") } } // Fall back to Push API return c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken) } // SendMedia implements the channels.MediaSender interface. // LINE requires media to be accessible via public URL; since we only have local files, // we fall back to sending a text message with the filename/caption. // For full support, an external file hosting service would be needed. func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } store := c.GetMediaStore() if store == nil { return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } // LINE Messaging API requires publicly accessible URLs for media messages. // Since we only have local file paths, send caption text as fallback. for _, part := range msg.Parts { caption := part.Caption if caption == "" { caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename) } if err := c.sendPush(ctx, msg.ChatID, caption, ""); err != nil { return err } } return nil } // buildTextMessage creates a text message object, optionally with quoteToken. func buildTextMessage(content, quoteToken string) map[string]string { msg := map[string]string{ "type": "text", "text": content, } if quoteToken != "" { msg["quoteToken"] = quoteToken } return msg } // sendReply sends a message using the LINE Reply API. func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteToken string) error { payload := map[string]any{ "replyToken": replyToken, "messages": []map[string]string{buildTextMessage(content, quoteToken)}, } return c.callAPI(ctx, lineReplyEndpoint, payload) } // sendPush sends a message using the LINE Push API. func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken string) error { payload := map[string]any{ "to": to, "messages": []map[string]string{buildTextMessage(content, quoteToken)}, } return c.callAPI(ctx, linePushEndpoint, payload) } // StartTyping implements channels.TypingCapable using LINE's loading animation. // // NOTE: The LINE loading animation API only works for 1:1 chats. // Group/room chat IDs (starting with "C" or "R") are detected automatically; // for these, a no-op stop function is returned without calling the API. func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { if chatID == "" { return func() {}, nil } // Group/room chats: LINE loading animation is 1:1 only. if strings.HasPrefix(chatID, "C") || strings.HasPrefix(chatID, "R") { return func() {}, nil } typingCtx, cancel := context.WithCancel(ctx) var once sync.Once stop := func() { once.Do(cancel) } // Send immediately, then refresh periodically for long-running tasks. if err := c.sendLoading(typingCtx, chatID); err != nil { stop() return stop, err } ticker := time.NewTicker(50 * time.Second) go func() { defer ticker.Stop() for { select { case <-typingCtx.Done(): return case <-ticker.C: if err := c.sendLoading(typingCtx, chatID); err != nil { logger.DebugCF("line", "Failed to refresh loading indicator", map[string]any{ "error": err.Error(), }) } } } }() return stop, nil } // sendLoading sends a loading animation indicator to the chat. func (c *LINEChannel) sendLoading(ctx context.Context, chatID string) error { payload := map[string]any{ "chatId": chatID, "loadingSeconds": 60, } return c.callAPI(ctx, lineLoadingEndpoint, payload) } // callAPI makes an authenticated POST request to the LINE API. func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) error { body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("failed to marshal payload: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken) resp, err := c.apiClient.Do(req) if err != nil { return channels.ClassifyNetError(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, err := io.ReadAll(resp.Body) if err != nil { return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading LINE API error response: %w", err)) } return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("LINE API error: %s", string(respBody))) } return nil } // downloadContent downloads media content from the LINE API. func (c *LINEChannel) downloadContent(messageID, filename string) string { url := fmt.Sprintf(lineContentEndpoint, messageID) return utils.DownloadFile(url, filename, utils.DownloadOptions{ LoggerPrefix: "line", ExtraHeaders: map[string]string{ "Authorization": "Bearer " + c.config.ChannelAccessToken, }, }) } ================================================ FILE: pkg/channels/line/line_test.go ================================================ package line import ( "bytes" "net/http" "net/http/httptest" "strings" "testing" ) func TestWebhookRejectsOversizedBody(t *testing.T) { ch := &LINEChannel{} oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1) req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized)) rec := httptest.NewRecorder() ch.webhookHandler(rec, req) if rec.Code != http.StatusRequestEntityTooLarge { t.Errorf("expected status %d, got %d", http.StatusRequestEntityTooLarge, rec.Code) } } func TestWebhookAcceptsMaxBodySize(t *testing.T) { ch := &LINEChannel{} body := bytes.Repeat([]byte("A"), maxWebhookBodySize) req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) rec := httptest.NewRecorder() ch.webhookHandler(rec, req) // Missing signature should be rejected, but the body size should not trigger 413. if rec.Code != http.StatusForbidden { t.Errorf("expected status %d, got %d", http.StatusForbidden, rec.Code) } } func TestWebhookRejectsOversizedBodyBeforeSignatureCheck(t *testing.T) { ch := &LINEChannel{} oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1) req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized)) req.Header.Set("X-Line-Signature", "invalidsignature") rec := httptest.NewRecorder() ch.webhookHandler(rec, req) if rec.Code != http.StatusRequestEntityTooLarge { t.Errorf("expected status %d, got %d", http.StatusRequestEntityTooLarge, rec.Code) } } func TestWebhookRejectsNonPostMethod(t *testing.T) { ch := &LINEChannel{} req := httptest.NewRequest(http.MethodGet, "/webhook", nil) rec := httptest.NewRecorder() ch.webhookHandler(rec, req) if rec.Code != http.StatusMethodNotAllowed { t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) } } func TestWebhookRejectsInvalidSignature(t *testing.T) { ch := &LINEChannel{} body := `{"events":[]}` req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body)) req.Header.Set("X-Line-Signature", "invalidsignature") rec := httptest.NewRecorder() ch.webhookHandler(rec, req) if rec.Code != http.StatusForbidden { t.Errorf("expected status %d, got %d", http.StatusForbidden, rec.Code) } } ================================================ FILE: pkg/channels/maixcam/init.go ================================================ package maixcam import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) func init() { channels.RegisterFactory("maixcam", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { return NewMaixCamChannel(cfg.Channels.MaixCam, b) }) } ================================================ FILE: pkg/channels/maixcam/maixcam.go ================================================ package maixcam import ( "context" "encoding/json" "fmt" "net" "sync" "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" ) type MaixCamChannel struct { *channels.BaseChannel config config.MaixCamConfig listener net.Listener ctx context.Context cancel context.CancelFunc clients map[net.Conn]bool clientsMux sync.RWMutex } type MaixCamMessage struct { Type string `json:"type"` Tips string `json:"tips"` Timestamp float64 `json:"timestamp"` Data map[string]any `json:"data"` } func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) { base := channels.NewBaseChannel( "maixcam", cfg, bus, cfg.AllowFrom, channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) return &MaixCamChannel{ BaseChannel: base, config: cfg, clients: make(map[net.Conn]bool), }, nil } func (c *MaixCamChannel) Start(ctx context.Context) error { logger.InfoC("maixcam", "Starting MaixCam channel server") c.ctx, c.cancel = context.WithCancel(ctx) addr := fmt.Sprintf("%s:%d", c.config.Host, c.config.Port) listener, err := net.Listen("tcp", addr) if err != nil { c.cancel() return fmt.Errorf("failed to listen on %s: %w", addr, err) } c.listener = listener c.SetRunning(true) logger.InfoCF("maixcam", "MaixCam server listening", map[string]any{ "host": c.config.Host, "port": c.config.Port, }) go c.acceptConnections() return nil } func (c *MaixCamChannel) acceptConnections() { logger.DebugC("maixcam", "Starting connection acceptor") for { select { case <-c.ctx.Done(): logger.InfoC("maixcam", "Stopping connection acceptor") return default: conn, err := c.listener.Accept() if err != nil { if c.IsRunning() { logger.ErrorCF("maixcam", "Failed to accept connection", map[string]any{ "error": err.Error(), }) } return } logger.InfoCF("maixcam", "New connection from MaixCam device", map[string]any{ "remote_addr": conn.RemoteAddr().String(), }) c.clientsMux.Lock() c.clients[conn] = true c.clientsMux.Unlock() go c.handleConnection(conn) } } } func (c *MaixCamChannel) handleConnection(conn net.Conn) { logger.DebugC("maixcam", "Handling MaixCam connection") defer func() { conn.Close() c.clientsMux.Lock() delete(c.clients, conn) c.clientsMux.Unlock() logger.DebugC("maixcam", "Connection closed") }() decoder := json.NewDecoder(conn) for { select { case <-c.ctx.Done(): return default: var msg MaixCamMessage if err := decoder.Decode(&msg); err != nil { if err.Error() != "EOF" { logger.ErrorCF("maixcam", "Failed to decode message", map[string]any{ "error": err.Error(), }) } return } c.processMessage(msg, conn) } } } func (c *MaixCamChannel) processMessage(msg MaixCamMessage, conn net.Conn) { switch msg.Type { case "person_detected": c.handlePersonDetection(msg) case "heartbeat": logger.DebugC("maixcam", "Received heartbeat") case "status": c.handleStatusUpdate(msg) default: logger.WarnCF("maixcam", "Unknown message type", map[string]any{ "type": msg.Type, }) } } func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) { logger.InfoCF("maixcam", "", map[string]any{ "timestamp": msg.Timestamp, "data": msg.Data, }) senderID := "maixcam" chatID := "default" classInfo, ok := msg.Data["class_name"].(string) if !ok { classInfo = "person" } score, _ := msg.Data["score"].(float64) x, _ := msg.Data["x"].(float64) y, _ := msg.Data["y"].(float64) w, _ := msg.Data["w"].(float64) h, _ := msg.Data["h"].(float64) content := fmt.Sprintf("📷 Person detected!\nClass: %s\nConfidence: %.2f%%\nPosition: (%.0f, %.0f)\nSize: %.0fx%.0f", classInfo, score*100, x, y, w, h) metadata := map[string]string{ "timestamp": fmt.Sprintf("%.0f", msg.Timestamp), "class_id": fmt.Sprintf("%.0f", msg.Data["class_id"]), "score": fmt.Sprintf("%.2f", score), "x": fmt.Sprintf("%.0f", x), "y": fmt.Sprintf("%.0f", y), "w": fmt.Sprintf("%.0f", w), "h": fmt.Sprintf("%.0f", h), } sender := bus.SenderInfo{ Platform: "maixcam", PlatformID: "maixcam", CanonicalID: identity.BuildCanonicalID("maixcam", "maixcam"), } if !c.IsAllowedSender(sender) { return } c.HandleMessage( c.ctx, bus.Peer{Kind: "channel", ID: "default"}, "", senderID, chatID, content, []string{}, metadata, sender, ) } func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) { logger.InfoCF("maixcam", "Status update from MaixCam", map[string]any{ "status": msg.Data, }) } func (c *MaixCamChannel) Stop(ctx context.Context) error { logger.InfoC("maixcam", "Stopping MaixCam channel") c.SetRunning(false) // Cancel context first to signal goroutines to exit if c.cancel != nil { c.cancel() } if c.listener != nil { c.listener.Close() } c.clientsMux.Lock() defer c.clientsMux.Unlock() for conn := range c.clients { conn.Close() } c.clients = make(map[net.Conn]bool) logger.InfoC("maixcam", "MaixCam channel stopped") return nil } func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } // Check ctx before entering write path select { case <-ctx.Done(): return ctx.Err() default: } c.clientsMux.RLock() defer c.clientsMux.RUnlock() if len(c.clients) == 0 { logger.WarnC("maixcam", "No MaixCam devices connected") return fmt.Errorf("no connected MaixCam devices") } response := map[string]any{ "type": "command", "timestamp": float64(0), "message": msg.Content, "chat_id": msg.ChatID, } data, err := json.Marshal(response) if err != nil { return fmt.Errorf("failed to marshal response: %w", err) } var sendErr error for conn := range c.clients { _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if _, err := conn.Write(data); err != nil { logger.ErrorCF("maixcam", "Failed to send to client", map[string]any{ "client": conn.RemoteAddr().String(), "error": err.Error(), }) sendErr = fmt.Errorf("maixcam send: %w", channels.ErrTemporary) } _ = conn.SetWriteDeadline(time.Time{}) } return sendErr } ================================================ FILE: pkg/channels/manager.go ================================================ // PicoClaw - Ultra-lightweight personal AI agent // Inspired by and based on nanobot: https://github.com/HKUDS/nanobot // License: MIT // // Copyright (c) 2026 PicoClaw contributors package channels import ( "context" "errors" "fmt" "math" "net/http" "sync" "time" "golang.org/x/time/rate" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" ) const ( defaultChannelQueueSize = 16 defaultRateLimit = 10 // default 10 msg/s maxRetries = 3 rateLimitDelay = 1 * time.Second baseBackoff = 500 * time.Millisecond maxBackoff = 8 * time.Second janitorInterval = 10 * time.Second typingStopTTL = 5 * time.Minute placeholderTTL = 10 * time.Minute ) // typingEntry wraps a typing stop function with a creation timestamp for TTL eviction. type typingEntry struct { stop func() createdAt time.Time } // reactionEntry wraps a reaction undo function with a creation timestamp for TTL eviction. type reactionEntry struct { undo func() createdAt time.Time } // placeholderEntry wraps a placeholder ID with a creation timestamp for TTL eviction. type placeholderEntry struct { id string createdAt time.Time } // channelRateConfig maps channel name to per-second rate limit. var channelRateConfig = map[string]float64{ "telegram": 20, "discord": 1, "slack": 1, "matrix": 2, "line": 10, "qq": 5, "irc": 2, } type channelWorker struct { ch Channel queue chan bus.OutboundMessage mediaQueue chan bus.OutboundMediaMessage done chan struct{} mediaDone chan struct{} limiter *rate.Limiter } type Manager struct { channels map[string]Channel workers map[string]*channelWorker bus *bus.MessageBus config *config.Config mediaStore media.MediaStore dispatchTask *asyncTask mux *http.ServeMux httpServer *http.Server mu sync.RWMutex placeholders sync.Map // "channel:chatID" → placeholderID (string) typingStops sync.Map // "channel:chatID" → func() reactionUndos sync.Map // "channel:chatID" → reactionEntry channelHashes map[string]string // channel name → config hash } type asyncTask struct { cancel context.CancelFunc } // RecordPlaceholder registers a placeholder message for later editing. // Implements PlaceholderRecorder. func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) { key := channel + ":" + chatID m.placeholders.Store(key, placeholderEntry{id: placeholderID, createdAt: time.Now()}) } // SendPlaceholder sends a "Thinking…" placeholder for the given channel/chatID // and records it for later editing. Returns true if a placeholder was sent. func (m *Manager) SendPlaceholder(ctx context.Context, channel, chatID string) bool { m.mu.RLock() ch, ok := m.channels[channel] m.mu.RUnlock() if !ok { return false } pc, ok := ch.(PlaceholderCapable) if !ok { return false } phID, err := pc.SendPlaceholder(ctx, chatID) if err != nil || phID == "" { return false } m.RecordPlaceholder(channel, chatID, phID) return true } // RecordTypingStop registers a typing stop function for later invocation. // Implements PlaceholderRecorder. func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) { key := channel + ":" + chatID entry := typingEntry{stop: stop, createdAt: time.Now()} if previous, loaded := m.typingStops.Swap(key, entry); loaded { if oldEntry, ok := previous.(typingEntry); ok && oldEntry.stop != nil { oldEntry.stop() } } } // InvokeTypingStop invokes the registered typing stop function for the given channel and chatID. // It is safe to call even when no typing indicator is active (no-op). // Used by the agent loop to stop typing when processing completes (success, error, or panic), // regardless of whether an outbound message is published. func (m *Manager) InvokeTypingStop(channel, chatID string) { key := channel + ":" + chatID if v, loaded := m.typingStops.LoadAndDelete(key); loaded { if entry, ok := v.(typingEntry); ok { entry.stop() } } } // RecordReactionUndo registers a reaction undo function for later invocation. // Implements PlaceholderRecorder. func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) { key := channel + ":" + chatID m.reactionUndos.Store(key, reactionEntry{undo: undo, createdAt: time.Now()}) } // preSend handles typing stop, reaction undo, and placeholder editing before sending a message. // Returns true if the message was edited into a placeholder (skip Send). func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) bool { key := name + ":" + msg.ChatID // 1. Stop typing if v, loaded := m.typingStops.LoadAndDelete(key); loaded { if entry, ok := v.(typingEntry); ok { entry.stop() // idempotent, safe } } // 2. Undo reaction if v, loaded := m.reactionUndos.LoadAndDelete(key); loaded { if entry, ok := v.(reactionEntry); ok { entry.undo() // idempotent, safe } } // 3. Try editing placeholder if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if entry, ok := v.(placeholderEntry); ok && entry.id != "" { if editor, ok := ch.(MessageEditor); ok { if err := editor.EditMessage(ctx, msg.ChatID, entry.id, msg.Content); err == nil { return true // edited successfully, skip Send } // edit failed → fall through to normal Send } } } return false } func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.MediaStore) (*Manager, error) { m := &Manager{ channels: make(map[string]Channel), workers: make(map[string]*channelWorker), bus: messageBus, config: cfg, mediaStore: store, channelHashes: make(map[string]string), } if err := m.initChannels(&cfg.Channels); err != nil { return nil, err } // Store initial config hashes for all channels m.channelHashes = toChannelHashes(cfg) return m, nil } // initChannel is a helper that looks up a factory by name and creates the channel. func (m *Manager) initChannel(name, displayName string) { f, ok := getFactory(name) if !ok { logger.WarnCF("channels", "Factory not registered", map[string]any{ "channel": displayName, }) return } logger.DebugCF("channels", "Attempting to initialize channel", map[string]any{ "channel": displayName, }) ch, err := f(m.config, m.bus) if err != nil { logger.ErrorCF("channels", "Failed to initialize channel", map[string]any{ "channel": displayName, "error": err.Error(), }) } else { // Inject MediaStore if channel supports it if m.mediaStore != nil { if setter, ok := ch.(interface{ SetMediaStore(s media.MediaStore) }); ok { setter.SetMediaStore(m.mediaStore) } } // Inject PlaceholderRecorder if channel supports it if setter, ok := ch.(interface{ SetPlaceholderRecorder(r PlaceholderRecorder) }); ok { setter.SetPlaceholderRecorder(m) } // Inject owner reference so BaseChannel.HandleMessage can auto-trigger typing/reaction if setter, ok := ch.(interface{ SetOwner(ch Channel) }); ok { setter.SetOwner(ch) } m.channels[name] = ch logger.InfoCF("channels", "Channel enabled successfully", map[string]any{ "channel": displayName, }) } } func (m *Manager) initChannels(channels *config.ChannelsConfig) error { logger.InfoC("channels", "Initializing channel manager") if channels.Telegram.Enabled && channels.Telegram.Token != "" { m.initChannel("telegram", "Telegram") } if channels.WhatsApp.Enabled { waCfg := channels.WhatsApp if waCfg.UseNative { m.initChannel("whatsapp_native", "WhatsApp Native") } else if waCfg.BridgeURL != "" { m.initChannel("whatsapp", "WhatsApp") } } if channels.Feishu.Enabled { m.initChannel("feishu", "Feishu") } if channels.Discord.Enabled && channels.Discord.Token != "" { m.initChannel("discord", "Discord") } if channels.MaixCam.Enabled { m.initChannel("maixcam", "MaixCam") } if channels.QQ.Enabled { m.initChannel("qq", "QQ") } if channels.DingTalk.Enabled && channels.DingTalk.ClientID != "" { m.initChannel("dingtalk", "DingTalk") } if channels.Slack.Enabled && channels.Slack.BotToken != "" { m.initChannel("slack", "Slack") } if channels.Matrix.Enabled && m.config.Channels.Matrix.Homeserver != "" && m.config.Channels.Matrix.UserID != "" && m.config.Channels.Matrix.AccessToken != "" { m.initChannel("matrix", "Matrix") } if channels.LINE.Enabled && channels.LINE.ChannelAccessToken != "" { m.initChannel("line", "LINE") } if channels.OneBot.Enabled && channels.OneBot.WSUrl != "" { m.initChannel("onebot", "OneBot") } if channels.WeCom.Enabled && channels.WeCom.Token != "" { m.initChannel("wecom", "WeCom") } if m.config.Channels.WeComAIBot.Enabled && ((m.config.Channels.WeComAIBot.BotID != "" && m.config.Channels.WeComAIBot.Secret != "") || m.config.Channels.WeComAIBot.Token != "") { m.initChannel("wecom_aibot", "WeCom AI Bot") } if channels.WeComApp.Enabled && channels.WeComApp.CorpID != "" { m.initChannel("wecom_app", "WeCom App") } if channels.Pico.Enabled && channels.Pico.Token != "" { m.initChannel("pico", "Pico") } if channels.IRC.Enabled && channels.IRC.Server != "" { m.initChannel("irc", "IRC") } logger.InfoCF("channels", "Channel initialization completed", map[string]any{ "enabled_channels": len(m.channels), }) return nil } // SetupHTTPServer creates a shared HTTP server with the given listen address. // It registers health endpoints from the health server and discovers channels // that implement WebhookHandler and/or HealthChecker to register their handlers. func (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server) { m.mux = http.NewServeMux() // Register health endpoints if healthServer != nil { healthServer.RegisterOnMux(m.mux) } // Discover and register webhook handlers and health checkers for name, ch := range m.channels { if wh, ok := ch.(WebhookHandler); ok { m.mux.Handle(wh.WebhookPath(), wh) logger.InfoCF("channels", "Webhook handler registered", map[string]any{ "channel": name, "path": wh.WebhookPath(), }) } if hc, ok := ch.(HealthChecker); ok { m.mux.HandleFunc(hc.HealthPath(), hc.HealthHandler) logger.InfoCF("channels", "Health endpoint registered", map[string]any{ "channel": name, "path": hc.HealthPath(), }) } } m.httpServer = &http.Server{ Addr: addr, Handler: m.mux, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, } } func (m *Manager) StartAll(ctx context.Context) error { m.mu.Lock() defer m.mu.Unlock() if len(m.channels) == 0 { logger.WarnC("channels", "No channels enabled") } logger.InfoC("channels", "Starting all channels") dispatchCtx, cancel := context.WithCancel(ctx) m.dispatchTask = &asyncTask{cancel: cancel} for name, channel := range m.channels { logger.InfoCF("channels", "Starting channel", map[string]any{ "channel": name, }) if err := channel.Start(ctx); err != nil { logger.ErrorCF("channels", "Failed to start channel", map[string]any{ "channel": name, "error": err.Error(), }) continue } // Lazily create worker only after channel starts successfully w := newChannelWorker(name, channel) m.workers[name] = w go m.runWorker(dispatchCtx, name, w) go m.runMediaWorker(dispatchCtx, name, w) } // Start the dispatcher that reads from the bus and routes to workers go m.dispatchOutbound(dispatchCtx) go m.dispatchOutboundMedia(dispatchCtx) // Start the TTL janitor that cleans up stale typing/placeholder entries go m.runTTLJanitor(dispatchCtx) // Start shared HTTP server if configured if m.httpServer != nil { go func() { logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{ "addr": m.httpServer.Addr, }) if err := m.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { logger.FatalCF("channels", "Shared HTTP server error", map[string]any{ "error": err.Error(), }) } }() } logger.InfoC("channels", "All channels started") return nil } func (m *Manager) StopAll(ctx context.Context) error { m.mu.Lock() defer m.mu.Unlock() logger.InfoC("channels", "Stopping all channels") // Shutdown shared HTTP server first if m.httpServer != nil { shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if err := m.httpServer.Shutdown(shutdownCtx); err != nil { logger.ErrorCF("channels", "Shared HTTP server shutdown error", map[string]any{ "error": err.Error(), }) } m.httpServer = nil } // Cancel dispatcher if m.dispatchTask != nil { m.dispatchTask.cancel() m.dispatchTask = nil } // Close all worker queues and wait for them to drain for _, w := range m.workers { if w != nil { close(w.queue) } } for _, w := range m.workers { if w != nil { <-w.done } } // Close all media worker queues and wait for them to drain for _, w := range m.workers { if w != nil { close(w.mediaQueue) } } for _, w := range m.workers { if w != nil { <-w.mediaDone } } // Stop all channels for name, channel := range m.channels { logger.InfoCF("channels", "Stopping channel", map[string]any{ "channel": name, }) if err := channel.Stop(ctx); err != nil { logger.ErrorCF("channels", "Error stopping channel", map[string]any{ "channel": name, "error": err.Error(), }) } } logger.InfoC("channels", "All channels stopped") return nil } // newChannelWorker creates a channelWorker with a rate limiter configured // for the given channel name. func newChannelWorker(name string, ch Channel) *channelWorker { rateVal := float64(defaultRateLimit) if r, ok := channelRateConfig[name]; ok { rateVal = r } burst := int(math.Max(1, math.Ceil(rateVal/2))) return &channelWorker{ ch: ch, queue: make(chan bus.OutboundMessage, defaultChannelQueueSize), mediaQueue: make(chan bus.OutboundMediaMessage, defaultChannelQueueSize), done: make(chan struct{}), mediaDone: make(chan struct{}), limiter: rate.NewLimiter(rate.Limit(rateVal), burst), } } // runWorker processes outbound messages for a single channel, splitting // messages that exceed the channel's maximum message length. func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) { defer close(w.done) for { select { case msg, ok := <-w.queue: if !ok { return } maxLen := 0 if mlp, ok := w.ch.(MessageLengthProvider); ok { maxLen = mlp.MaxMessageLength() } if maxLen > 0 && len([]rune(msg.Content)) > maxLen { chunks := SplitMessage(msg.Content, maxLen) for _, chunk := range chunks { chunkMsg := msg chunkMsg.Content = chunk m.sendWithRetry(ctx, name, w, chunkMsg) } } else { m.sendWithRetry(ctx, name, w, msg) } case <-ctx.Done(): return } } } // sendWithRetry sends a message through the channel with rate limiting and // retry logic. It classifies errors to determine the retry strategy: // - ErrNotRunning / ErrSendFailed: permanent, no retry // - ErrRateLimit: fixed delay retry // - ErrTemporary / unknown: exponential backoff retry func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMessage) { // Rate limit: wait for token if err := w.limiter.Wait(ctx); err != nil { // ctx canceled, shutting down return } // Pre-send: stop typing and try to edit placeholder if m.preSend(ctx, name, msg, w.ch) { return // placeholder was edited successfully, skip Send } var lastErr error for attempt := 0; attempt <= maxRetries; attempt++ { lastErr = w.ch.Send(ctx, msg) if lastErr == nil { return } // Permanent failures — don't retry if errors.Is(lastErr, ErrNotRunning) || errors.Is(lastErr, ErrSendFailed) { break } // Last attempt exhausted — don't sleep if attempt == maxRetries { break } // Rate limit error — fixed delay if errors.Is(lastErr, ErrRateLimit) { select { case <-time.After(rateLimitDelay): continue case <-ctx.Done(): return } } // ErrTemporary or unknown error — exponential backoff backoff := min(time.Duration(float64(baseBackoff)*math.Pow(2, float64(attempt))), maxBackoff) select { case <-time.After(backoff): case <-ctx.Done(): return } } // All retries exhausted or permanent failure logger.ErrorCF("channels", "Send failed", map[string]any{ "channel": name, "chat_id": msg.ChatID, "error": lastErr.Error(), "retries": maxRetries, }) } func dispatchLoop[M any]( ctx context.Context, m *Manager, ch <-chan M, getChannel func(M) string, enqueue func(context.Context, *channelWorker, M) bool, startMsg, stopMsg, unknownMsg, noWorkerMsg string, ) { logger.InfoC("channels", startMsg) for { select { case <-ctx.Done(): logger.InfoC("channels", stopMsg) return case msg, ok := <-ch: if !ok { logger.InfoC("channels", stopMsg) return } channel := getChannel(msg) // Silently skip internal channels if constants.IsInternalChannel(channel) { continue } m.mu.RLock() _, exists := m.channels[channel] w, wExists := m.workers[channel] m.mu.RUnlock() if !exists { logger.WarnCF("channels", unknownMsg, map[string]any{"channel": channel}) continue } if wExists && w != nil { if !enqueue(ctx, w, msg) { return } } else if exists { logger.WarnCF("channels", noWorkerMsg, map[string]any{"channel": channel}) } } } } func (m *Manager) dispatchOutbound(ctx context.Context) { dispatchLoop( ctx, m, m.bus.OutboundChan(), func(msg bus.OutboundMessage) string { return msg.Channel }, func(ctx context.Context, w *channelWorker, msg bus.OutboundMessage) bool { select { case w.queue <- msg: return true case <-ctx.Done(): return false } }, "Outbound dispatcher started", "Outbound dispatcher stopped", "Unknown channel for outbound message", "Channel has no active worker, skipping message", ) } func (m *Manager) dispatchOutboundMedia(ctx context.Context) { dispatchLoop( ctx, m, m.bus.OutboundMediaChan(), func(msg bus.OutboundMediaMessage) string { return msg.Channel }, func(ctx context.Context, w *channelWorker, msg bus.OutboundMediaMessage) bool { select { case w.mediaQueue <- msg: return true case <-ctx.Done(): return false } }, "Outbound media dispatcher started", "Outbound media dispatcher stopped", "Unknown channel for outbound media message", "Channel has no active worker, skipping media message", ) } // runMediaWorker processes outbound media messages for a single channel. func (m *Manager) runMediaWorker(ctx context.Context, name string, w *channelWorker) { defer close(w.mediaDone) for { select { case msg, ok := <-w.mediaQueue: if !ok { return } m.sendMediaWithRetry(ctx, name, w, msg) case <-ctx.Done(): return } } } // sendMediaWithRetry sends a media message through the channel with rate limiting and // retry logic. If the channel does not implement MediaSender, it silently skips. func (m *Manager) sendMediaWithRetry(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMediaMessage) { ms, ok := w.ch.(MediaSender) if !ok { logger.DebugCF("channels", "Channel does not support MediaSender, skipping media", map[string]any{ "channel": name, }) return } // Rate limit: wait for token if err := w.limiter.Wait(ctx); err != nil { return } var lastErr error for attempt := 0; attempt <= maxRetries; attempt++ { lastErr = ms.SendMedia(ctx, msg) if lastErr == nil { return } // Permanent failures — don't retry if errors.Is(lastErr, ErrNotRunning) || errors.Is(lastErr, ErrSendFailed) { break } // Last attempt exhausted — don't sleep if attempt == maxRetries { break } // Rate limit error — fixed delay if errors.Is(lastErr, ErrRateLimit) { select { case <-time.After(rateLimitDelay): continue case <-ctx.Done(): return } } // ErrTemporary or unknown error — exponential backoff backoff := min(time.Duration(float64(baseBackoff)*math.Pow(2, float64(attempt))), maxBackoff) select { case <-time.After(backoff): case <-ctx.Done(): return } } // All retries exhausted or permanent failure logger.ErrorCF("channels", "SendMedia failed", map[string]any{ "channel": name, "chat_id": msg.ChatID, "error": lastErr.Error(), "retries": maxRetries, }) } // runTTLJanitor periodically scans the typingStops and placeholders maps // and evicts entries that have exceeded their TTL. This prevents memory // accumulation when outbound paths fail to trigger preSend (e.g. LLM errors). func (m *Manager) runTTLJanitor(ctx context.Context) { ticker := time.NewTicker(janitorInterval) defer ticker.Stop() for { select { case <-ctx.Done(): return case now := <-ticker.C: m.typingStops.Range(func(key, value any) bool { if entry, ok := value.(typingEntry); ok { if now.Sub(entry.createdAt) > typingStopTTL { if _, loaded := m.typingStops.LoadAndDelete(key); loaded { entry.stop() // idempotent, safe } } } return true }) m.reactionUndos.Range(func(key, value any) bool { if entry, ok := value.(reactionEntry); ok { if now.Sub(entry.createdAt) > typingStopTTL { if _, loaded := m.reactionUndos.LoadAndDelete(key); loaded { entry.undo() // idempotent, safe } } } return true }) m.placeholders.Range(func(key, value any) bool { if entry, ok := value.(placeholderEntry); ok { if now.Sub(entry.createdAt) > placeholderTTL { m.placeholders.Delete(key) } } return true }) } } } func (m *Manager) GetChannel(name string) (Channel, bool) { m.mu.RLock() defer m.mu.RUnlock() channel, ok := m.channels[name] return channel, ok } func (m *Manager) GetStatus() map[string]any { m.mu.RLock() defer m.mu.RUnlock() status := make(map[string]any) for name, channel := range m.channels { status[name] = map[string]any{ "enabled": true, "running": channel.IsRunning(), } } return status } func (m *Manager) GetEnabledChannels() []string { m.mu.RLock() defer m.mu.RUnlock() names := make([]string, 0, len(m.channels)) for name := range m.channels { names = append(names, name) } return names } // Reload updates the config reference without restarting channels. // This is used when channel config hasn't changed but other parts of the config have. func (m *Manager) Reload(ctx context.Context, cfg *config.Config) error { m.mu.Lock() defer m.mu.Unlock() list := toChannelHashes(cfg) added, removed := compareChannels(m.channelHashes, list) for _, name := range removed { // Stop all channels channel := m.channels[name] logger.InfoCF("channels", "Stopping channel", map[string]any{ "channel": name, }) if err := channel.Stop(ctx); err != nil { logger.ErrorCF("channels", "Error stopping channel", map[string]any{ "channel": name, "error": err.Error(), }) } go func() { m.UnregisterChannel(name) }() } dispatchCtx, cancel := context.WithCancel(ctx) m.dispatchTask = &asyncTask{cancel: cancel} cc, err := toChannelConfig(cfg, added) if err != nil { logger.ErrorC("channels", fmt.Sprintf("toChannelConfig error: %v", err)) return err } err = m.initChannels(cc) if err != nil { logger.ErrorC("channels", fmt.Sprintf("initChannels error: %v", err)) return err } for _, name := range added { channel := m.channels[name] logger.InfoCF("channels", "Starting channel", map[string]any{ "channel": name, }) if err := channel.Start(ctx); err != nil { logger.ErrorCF("channels", "Failed to start channel", map[string]any{ "channel": name, "error": err.Error(), }) continue } // Lazily create worker only after channel starts successfully w := newChannelWorker(name, channel) m.workers[name] = w go m.runWorker(dispatchCtx, name, w) go m.runMediaWorker(dispatchCtx, name, w) go func() { m.RegisterChannel(name, channel) }() } m.config = cfg m.channelHashes = toChannelHashes(cfg) return nil } func (m *Manager) RegisterChannel(name string, channel Channel) { m.mu.Lock() defer m.mu.Unlock() m.channels[name] = channel } func (m *Manager) UnregisterChannel(name string) { m.mu.Lock() defer m.mu.Unlock() if w, ok := m.workers[name]; ok && w != nil { close(w.queue) <-w.done close(w.mediaQueue) <-w.mediaDone } delete(m.workers, name) delete(m.channels, name) } // SendMessage sends an outbound message synchronously through the channel // worker's rate limiter and retry logic. It blocks until the message is // delivered (or all retries are exhausted), which preserves ordering when // a subsequent operation depends on the message having been sent. func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) error { m.mu.RLock() _, exists := m.channels[msg.Channel] w, wExists := m.workers[msg.Channel] m.mu.RUnlock() if !exists { return fmt.Errorf("channel %s not found", msg.Channel) } if !wExists || w == nil { return fmt.Errorf("channel %s has no active worker", msg.Channel) } maxLen := 0 if mlp, ok := w.ch.(MessageLengthProvider); ok { maxLen = mlp.MaxMessageLength() } if maxLen > 0 && len([]rune(msg.Content)) > maxLen { for _, chunk := range SplitMessage(msg.Content, maxLen) { chunkMsg := msg chunkMsg.Content = chunk m.sendWithRetry(ctx, msg.Channel, w, chunkMsg) } } else { m.sendWithRetry(ctx, msg.Channel, w, msg) } return nil } func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error { m.mu.RLock() _, exists := m.channels[channelName] w, wExists := m.workers[channelName] m.mu.RUnlock() if !exists { return fmt.Errorf("channel %s not found", channelName) } msg := bus.OutboundMessage{ Channel: channelName, ChatID: chatID, Content: content, } if wExists && w != nil { select { case w.queue <- msg: return nil case <-ctx.Done(): return ctx.Err() } } // Fallback: direct send (should not happen) channel, _ := m.channels[channelName] return channel.Send(ctx, msg) } ================================================ FILE: pkg/channels/manager_channel.go ================================================ package channels import ( "crypto/md5" "encoding/hex" "encoding/json" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" ) func toChannelHashes(cfg *config.Config) map[string]string { result := make(map[string]string) ch := cfg.Channels // should not be error marshal, _ := json.Marshal(ch) var channelConfig map[string]map[string]any _ = json.Unmarshal(marshal, &channelConfig) for key, value := range channelConfig { if !value["enabled"].(bool) { continue } valueBytes, _ := json.Marshal(value) hash := md5.Sum(valueBytes) result[key] = hex.EncodeToString(hash[:]) } return result } func compareChannels(old, news map[string]string) (added, removed []string) { for key, newHash := range news { if oldHash, ok := old[key]; ok { if newHash != oldHash { removed = append(removed, key) added = append(added, key) } } else { added = append(added, key) } } for key := range old { if _, ok := news[key]; !ok { removed = append(removed, key) } } return added, removed } func toChannelConfig(cfg *config.Config, list []string) (*config.ChannelsConfig, error) { result := &config.ChannelsConfig{} ch := cfg.Channels // should not be error marshal, _ := json.Marshal(ch) var channelConfig map[string]map[string]any _ = json.Unmarshal(marshal, &channelConfig) temp := make(map[string]map[string]any, 0) for key, value := range channelConfig { found := false for _, s := range list { if key == s { found = true break } } if !found || !value["enabled"].(bool) { continue } temp[key] = value } marshal, err := json.Marshal(temp) if err != nil { logger.Errorf("marshal error: %v", err) return nil, err } err = json.Unmarshal(marshal, result) if err != nil { logger.Errorf("unmarshal error: %v", err) return nil, err } return result, nil } ================================================ FILE: pkg/channels/manager_channel_test.go ================================================ package channels import ( "testing" "github.com/stretchr/testify/assert" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" ) func TestToChannelHashes(t *testing.T) { logger.SetLevel(logger.DEBUG) cfg := config.DefaultConfig() results := toChannelHashes(cfg) assert.Equal(t, 0, len(results)) logger.Debugf("results: %v", results) cfg2 := config.DefaultConfig() cfg2.Channels.DingTalk.Enabled = true results2 := toChannelHashes(cfg2) assert.Equal(t, 1, len(results2)) logger.Debugf("results2: %v", results2) added, removed := compareChannels(results, results2) assert.EqualValues(t, []string{"dingtalk"}, added) assert.EqualValues(t, []string(nil), removed) cfg3 := config.DefaultConfig() cfg3.Channels.Telegram.Enabled = true results3 := toChannelHashes(cfg3) assert.Equal(t, 1, len(results3)) logger.Debugf("results3: %v", results3) added, removed = compareChannels(results2, results3) assert.EqualValues(t, []string{"dingtalk"}, removed) assert.EqualValues(t, []string{"telegram"}, added) cfg3.Channels.Telegram.Token = "114314" results4 := toChannelHashes(cfg3) assert.Equal(t, 1, len(results4)) logger.Debugf("results4: %v", results4) added, removed = compareChannels(results3, results4) assert.EqualValues(t, []string{"telegram"}, removed) assert.EqualValues(t, []string{"telegram"}, added) cc, err := toChannelConfig(cfg3, added) assert.NoError(t, err) logger.Debugf("cc: %#v", cc.Telegram) assert.Equal(t, "114314", cc.Telegram.Token) assert.Equal(t, true, cc.Telegram.Enabled) cc, err = toChannelConfig(cfg2, added) assert.NoError(t, err) logger.Debugf("cc: %#v", cc.Telegram) assert.Equal(t, "", cc.Telegram.Token) assert.Equal(t, false, cc.Telegram.Enabled) } ================================================ FILE: pkg/channels/manager_test.go ================================================ package channels import ( "context" "errors" "fmt" "sync" "sync/atomic" "testing" "time" "golang.org/x/time/rate" "github.com/sipeed/picoclaw/pkg/bus" ) // mockChannel is a test double that delegates Send to a configurable function. type mockChannel struct { BaseChannel sendFn func(ctx context.Context, msg bus.OutboundMessage) error sentMessages []bus.OutboundMessage placeholdersSent int editedMessages int lastPlaceholderID string } func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { m.sentMessages = append(m.sentMessages, msg) return m.sendFn(ctx, msg) } func (m *mockChannel) Start(ctx context.Context) error { return nil } func (m *mockChannel) Stop(ctx context.Context) error { return nil } func (m *mockChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { m.placeholdersSent++ m.lastPlaceholderID = "mock-ph-123" return m.lastPlaceholderID, nil } func (m *mockChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error { m.editedMessages++ return nil } // newTestManager creates a minimal Manager suitable for unit tests. func newTestManager() *Manager { return &Manager{ channels: make(map[string]Channel), workers: make(map[string]*channelWorker), } } func TestSendWithRetry_Success(t *testing.T) { m := newTestManager() var callCount int ch := &mockChannel{ sendFn: func(_ context.Context, _ bus.OutboundMessage) error { callCount++ return nil }, } w := &channelWorker{ ch: ch, limiter: rate.NewLimiter(rate.Inf, 1), } ctx := context.Background() msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} m.sendWithRetry(ctx, "test", w, msg) if callCount != 1 { t.Fatalf("expected 1 Send call, got %d", callCount) } } func TestSendWithRetry_TemporaryThenSuccess(t *testing.T) { m := newTestManager() var callCount int ch := &mockChannel{ sendFn: func(_ context.Context, _ bus.OutboundMessage) error { callCount++ if callCount <= 2 { return fmt.Errorf("network error: %w", ErrTemporary) } return nil }, } w := &channelWorker{ ch: ch, limiter: rate.NewLimiter(rate.Inf, 1), } ctx := context.Background() msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} m.sendWithRetry(ctx, "test", w, msg) if callCount != 3 { t.Fatalf("expected 3 Send calls (2 failures + 1 success), got %d", callCount) } } func TestSendWithRetry_PermanentFailure(t *testing.T) { m := newTestManager() var callCount int ch := &mockChannel{ sendFn: func(_ context.Context, _ bus.OutboundMessage) error { callCount++ return fmt.Errorf("bad chat ID: %w", ErrSendFailed) }, } w := &channelWorker{ ch: ch, limiter: rate.NewLimiter(rate.Inf, 1), } ctx := context.Background() msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} m.sendWithRetry(ctx, "test", w, msg) if callCount != 1 { t.Fatalf("expected 1 Send call (no retry for permanent failure), got %d", callCount) } } func TestSendWithRetry_NotRunning(t *testing.T) { m := newTestManager() var callCount int ch := &mockChannel{ sendFn: func(_ context.Context, _ bus.OutboundMessage) error { callCount++ return ErrNotRunning }, } w := &channelWorker{ ch: ch, limiter: rate.NewLimiter(rate.Inf, 1), } ctx := context.Background() msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} m.sendWithRetry(ctx, "test", w, msg) if callCount != 1 { t.Fatalf("expected 1 Send call (no retry for ErrNotRunning), got %d", callCount) } } func TestSendWithRetry_RateLimitRetry(t *testing.T) { m := newTestManager() var callCount int ch := &mockChannel{ sendFn: func(_ context.Context, _ bus.OutboundMessage) error { callCount++ if callCount == 1 { return fmt.Errorf("429: %w", ErrRateLimit) } return nil }, } w := &channelWorker{ ch: ch, limiter: rate.NewLimiter(rate.Inf, 1), } ctx := context.Background() msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} start := time.Now() m.sendWithRetry(ctx, "test", w, msg) elapsed := time.Since(start) if callCount != 2 { t.Fatalf("expected 2 Send calls (1 rate limit + 1 success), got %d", callCount) } // Should have waited at least rateLimitDelay (1s) but allow some slack if elapsed < 900*time.Millisecond { t.Fatalf("expected at least ~1s delay for rate limit retry, got %v", elapsed) } } func TestSendWithRetry_MaxRetriesExhausted(t *testing.T) { m := newTestManager() var callCount int ch := &mockChannel{ sendFn: func(_ context.Context, _ bus.OutboundMessage) error { callCount++ return fmt.Errorf("timeout: %w", ErrTemporary) }, } w := &channelWorker{ ch: ch, limiter: rate.NewLimiter(rate.Inf, 1), } ctx := context.Background() msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} m.sendWithRetry(ctx, "test", w, msg) expected := maxRetries + 1 // initial attempt + maxRetries retries if callCount != expected { t.Fatalf("expected %d Send calls, got %d", expected, callCount) } } func TestSendWithRetry_UnknownError(t *testing.T) { m := newTestManager() var callCount int ch := &mockChannel{ sendFn: func(_ context.Context, _ bus.OutboundMessage) error { callCount++ if callCount == 1 { return errors.New("random unexpected error") } return nil }, } w := &channelWorker{ ch: ch, limiter: rate.NewLimiter(rate.Inf, 1), } ctx := context.Background() msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} m.sendWithRetry(ctx, "test", w, msg) if callCount != 2 { t.Fatalf("expected 2 Send calls (unknown error treated as temporary), got %d", callCount) } } func TestSendWithRetry_ContextCancelled(t *testing.T) { m := newTestManager() var callCount int ch := &mockChannel{ sendFn: func(_ context.Context, _ bus.OutboundMessage) error { callCount++ return fmt.Errorf("timeout: %w", ErrTemporary) }, } w := &channelWorker{ ch: ch, limiter: rate.NewLimiter(rate.Inf, 1), } ctx, cancel := context.WithCancel(context.Background()) msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} // Cancel context after first Send attempt returns ch.sendFn = func(_ context.Context, _ bus.OutboundMessage) error { callCount++ cancel() return fmt.Errorf("timeout: %w", ErrTemporary) } m.sendWithRetry(ctx, "test", w, msg) // Should have called Send once, then noticed ctx canceled during backoff if callCount != 1 { t.Fatalf("expected 1 Send call before context cancellation, got %d", callCount) } } func TestWorkerRateLimiter(t *testing.T) { m := newTestManager() var mu sync.Mutex var sendTimes []time.Time ch := &mockChannel{ sendFn: func(_ context.Context, _ bus.OutboundMessage) error { mu.Lock() sendTimes = append(sendTimes, time.Now()) mu.Unlock() return nil }, } // Create a worker with a low rate: 2 msg/s, burst 1 w := &channelWorker{ ch: ch, queue: make(chan bus.OutboundMessage, 10), done: make(chan struct{}), limiter: rate.NewLimiter(2, 1), } ctx := t.Context() go m.runWorker(ctx, "test", w) // Enqueue 4 messages for i := range 4 { w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: fmt.Sprintf("msg%d", i)} } // Wait enough time for all messages to be sent (4 msgs at 2/s = ~2s, give extra margin) time.Sleep(3 * time.Second) mu.Lock() times := make([]time.Time, len(sendTimes)) copy(times, sendTimes) mu.Unlock() if len(times) != 4 { t.Fatalf("expected 4 sends, got %d", len(times)) } // Verify rate limiting: total duration should be at least 1s // (first message immediate, then ~500ms between each subsequent one at 2/s) totalDuration := times[len(times)-1].Sub(times[0]) if totalDuration < 1*time.Second { t.Fatalf("expected total duration >= 1s for 4 msgs at 2/s rate, got %v", totalDuration) } } func TestNewChannelWorker_DefaultRate(t *testing.T) { ch := &mockChannel{} w := newChannelWorker("unknown_channel", ch) if w.limiter == nil { t.Fatal("expected limiter to be non-nil") } if w.limiter.Limit() != rate.Limit(defaultRateLimit) { t.Fatalf("expected rate limit %v, got %v", rate.Limit(defaultRateLimit), w.limiter.Limit()) } } func TestNewChannelWorker_ConfiguredRate(t *testing.T) { ch := &mockChannel{} for name, expectedRate := range channelRateConfig { w := newChannelWorker(name, ch) if w.limiter.Limit() != rate.Limit(expectedRate) { t.Fatalf("channel %s: expected rate %v, got %v", name, expectedRate, w.limiter.Limit()) } } } func TestRunWorker_MessageSplitting(t *testing.T) { m := newTestManager() var mu sync.Mutex var received []string ch := &mockChannelWithLength{ mockChannel: mockChannel{ sendFn: func(_ context.Context, msg bus.OutboundMessage) error { mu.Lock() received = append(received, msg.Content) mu.Unlock() return nil }, }, maxLen: 5, } w := &channelWorker{ ch: ch, queue: make(chan bus.OutboundMessage, 10), done: make(chan struct{}), limiter: rate.NewLimiter(rate.Inf, 1), } ctx := t.Context() go m.runWorker(ctx, "test", w) // Send a message that should be split w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello world"} time.Sleep(100 * time.Millisecond) mu.Lock() count := len(received) mu.Unlock() if count < 2 { t.Fatalf("expected message to be split into at least 2 chunks, got %d", count) } } // mockChannelWithLength implements MessageLengthProvider. type mockChannelWithLength struct { mockChannel maxLen int } func (m *mockChannelWithLength) MaxMessageLength() int { return m.maxLen } func TestSendWithRetry_ExponentialBackoff(t *testing.T) { m := newTestManager() var callTimes []time.Time var callCount atomic.Int32 ch := &mockChannel{ sendFn: func(_ context.Context, _ bus.OutboundMessage) error { callTimes = append(callTimes, time.Now()) callCount.Add(1) return fmt.Errorf("timeout: %w", ErrTemporary) }, } w := &channelWorker{ ch: ch, limiter: rate.NewLimiter(rate.Inf, 1), } ctx := context.Background() msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} start := time.Now() m.sendWithRetry(ctx, "test", w, msg) totalElapsed := time.Since(start) // With maxRetries=3: attempts at 0, ~500ms, ~1.5s, ~3.5s // Total backoff: 500ms + 1s + 2s = 3.5s // Allow some margin if totalElapsed < 3*time.Second { t.Fatalf("expected total elapsed >= 3s for exponential backoff, got %v", totalElapsed) } if int(callCount.Load()) != maxRetries+1 { t.Fatalf("expected %d calls, got %d", maxRetries+1, callCount.Load()) } } // --- Phase 10: preSend orchestration tests --- // mockMessageEditor is a channel that supports MessageEditor. type mockMessageEditor struct { mockChannel editFn func(ctx context.Context, chatID, messageID, content string) error } func (m *mockMessageEditor) EditMessage(ctx context.Context, chatID, messageID, content string) error { return m.editFn(ctx, chatID, messageID, content) } func TestPreSend_PlaceholderEditSuccess(t *testing.T) { m := newTestManager() var sendCalled bool var editCalled bool ch := &mockMessageEditor{ mockChannel: mockChannel{ sendFn: func(_ context.Context, _ bus.OutboundMessage) error { sendCalled = true return nil }, }, editFn: func(_ context.Context, chatID, messageID, content string) error { editCalled = true if chatID != "123" { t.Fatalf("expected chatID 123, got %s", chatID) } if messageID != "456" { t.Fatalf("expected messageID 456, got %s", messageID) } if content != "hello" { t.Fatalf("expected content 'hello', got %s", content) } return nil }, } // Register placeholder m.RecordPlaceholder("test", "123", "456") msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} edited := m.preSend(context.Background(), "test", msg, ch) if !edited { t.Fatal("expected preSend to return true (placeholder edited)") } if !editCalled { t.Fatal("expected EditMessage to be called") } if sendCalled { t.Fatal("expected Send to NOT be called when placeholder edited") } } func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) { m := newTestManager() ch := &mockMessageEditor{ mockChannel: mockChannel{ sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, }, editFn: func(_ context.Context, _, _, _ string) error { return fmt.Errorf("edit failed") }, } m.RecordPlaceholder("test", "123", "456") msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} edited := m.preSend(context.Background(), "test", msg, ch) if edited { t.Fatal("expected preSend to return false when edit fails") } } func TestInvokeTypingStop_CallsRegisteredStop(t *testing.T) { m := newTestManager() var stopCalled bool m.RecordTypingStop("telegram", "chat123", func() { stopCalled = true }) m.InvokeTypingStop("telegram", "chat123") if !stopCalled { t.Fatal("expected typing stop func to be called") } } func TestInvokeTypingStop_NoOpWhenNoEntry(t *testing.T) { m := newTestManager() // Should not panic m.InvokeTypingStop("telegram", "nonexistent") } func TestInvokeTypingStop_Idempotent(t *testing.T) { m := newTestManager() var callCount int m.RecordTypingStop("telegram", "chat123", func() { callCount++ }) m.InvokeTypingStop("telegram", "chat123") m.InvokeTypingStop("telegram", "chat123") // Second call: entry already removed, no-op if callCount != 1 { t.Fatalf("expected stop to be called once, got %d", callCount) } } func TestPreSend_TypingStopCalled(t *testing.T) { m := newTestManager() var stopCalled bool ch := &mockChannel{ sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, } m.RecordTypingStop("test", "123", func() { stopCalled = true }) msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} m.preSend(context.Background(), "test", msg, ch) if !stopCalled { t.Fatal("expected typing stop func to be called") } } func TestPreSend_NoRegisteredState(t *testing.T) { m := newTestManager() ch := &mockChannel{ sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, } msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} edited := m.preSend(context.Background(), "test", msg, ch) if edited { t.Fatal("expected preSend to return false with no registered state") } } func TestPreSend_TypingAndPlaceholder(t *testing.T) { m := newTestManager() var stopCalled bool var editCalled bool ch := &mockMessageEditor{ mockChannel: mockChannel{ sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, }, editFn: func(_ context.Context, _, _, _ string) error { editCalled = true return nil }, } m.RecordTypingStop("test", "123", func() { stopCalled = true }) m.RecordPlaceholder("test", "123", "456") msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} edited := m.preSend(context.Background(), "test", msg, ch) if !stopCalled { t.Fatal("expected typing stop to be called") } if !editCalled { t.Fatal("expected EditMessage to be called") } if !edited { t.Fatal("expected preSend to return true") } } func TestRecordPlaceholder_ConcurrentSafe(t *testing.T) { m := newTestManager() var wg sync.WaitGroup for i := range 100 { wg.Add(1) go func(i int) { defer wg.Done() chatID := fmt.Sprintf("chat_%d", i%10) m.RecordPlaceholder("test", chatID, fmt.Sprintf("msg_%d", i)) }(i) } wg.Wait() } func TestRecordTypingStop_ConcurrentSafe(t *testing.T) { m := newTestManager() var wg sync.WaitGroup for i := range 100 { wg.Add(1) go func(i int) { defer wg.Done() chatID := fmt.Sprintf("chat_%d", i%10) m.RecordTypingStop("test", chatID, func() {}) }(i) } wg.Wait() } func TestRecordTypingStop_ReplacesExistingStop(t *testing.T) { m := newTestManager() var oldStopCalls int var newStopCalls int m.RecordTypingStop("test", "123", func() { oldStopCalls++ }) m.RecordTypingStop("test", "123", func() { newStopCalls++ }) if oldStopCalls != 1 { t.Fatalf("expected previous typing stop to be called once when replaced, got %d", oldStopCalls) } if newStopCalls != 0 { t.Fatalf("expected replacement typing stop to stay active until preSend, got %d calls", newStopCalls) } msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} m.preSend(context.Background(), "test", msg, &mockChannel{}) if newStopCalls != 1 { t.Fatalf("expected replacement typing stop to be called by preSend, got %d", newStopCalls) } if oldStopCalls != 1 { t.Fatalf("expected previous typing stop to not be called again, got %d", oldStopCalls) } } func TestSendWithRetry_PreSendEditsPlaceholder(t *testing.T) { m := newTestManager() var sendCalled bool ch := &mockMessageEditor{ mockChannel: mockChannel{ sendFn: func(_ context.Context, _ bus.OutboundMessage) error { sendCalled = true return nil }, }, editFn: func(_ context.Context, _, _, _ string) error { return nil // edit succeeds }, } m.RecordPlaceholder("test", "123", "456") w := &channelWorker{ ch: ch, limiter: rate.NewLimiter(rate.Inf, 1), } msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} m.sendWithRetry(context.Background(), "test", w, msg) if sendCalled { t.Fatal("expected Send to NOT be called when placeholder was edited") } } // --- Dispatcher exit tests (Step 1) --- func TestDispatcherExitsOnCancel(t *testing.T) { mb := bus.NewMessageBus() defer mb.Close() m := &Manager{ channels: make(map[string]Channel), workers: make(map[string]*channelWorker), bus: mb, } ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { m.dispatchOutbound(ctx) close(done) }() // Cancel context and verify the dispatcher exits quickly cancel() select { case <-done: // success case <-time.After(2 * time.Second): t.Fatal("dispatchOutbound did not exit within 2s after context cancel") } } func TestDispatcherMediaExitsOnCancel(t *testing.T) { mb := bus.NewMessageBus() defer mb.Close() m := &Manager{ channels: make(map[string]Channel), workers: make(map[string]*channelWorker), bus: mb, } ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { m.dispatchOutboundMedia(ctx) close(done) }() cancel() select { case <-done: // success case <-time.After(2 * time.Second): t.Fatal("dispatchOutboundMedia did not exit within 2s after context cancel") } } // --- TTL Janitor tests (Step 2) --- func TestTypingStopJanitorEviction(t *testing.T) { m := newTestManager() var stopCalled atomic.Bool // Store a typing entry with a creation time far in the past m.typingStops.Store("test:123", typingEntry{ stop: func() { stopCalled.Store(true) }, createdAt: time.Now().Add(-10 * time.Minute), // well past typingStopTTL }) // Run janitor with a short-lived context ctx, cancel := context.WithCancel(context.Background()) // Manually trigger the janitor logic once by simulating a tick go func() { // Override janitor to run immediately now := time.Now() m.typingStops.Range(func(key, value any) bool { if entry, ok := value.(typingEntry); ok { if now.Sub(entry.createdAt) > typingStopTTL { if _, loaded := m.typingStops.LoadAndDelete(key); loaded { entry.stop() } } } return true }) cancel() }() <-ctx.Done() if !stopCalled.Load() { t.Fatal("expected typing stop function to be called by janitor eviction") } // Verify entry was deleted if _, loaded := m.typingStops.Load("test:123"); loaded { t.Fatal("expected typing entry to be deleted after eviction") } } func TestPlaceholderJanitorEviction(t *testing.T) { m := newTestManager() // Store a placeholder entry with a creation time far in the past m.placeholders.Store("test:456", placeholderEntry{ id: "msg_old", createdAt: time.Now().Add(-20 * time.Minute), // well past placeholderTTL }) // Simulate janitor logic now := time.Now() m.placeholders.Range(func(key, value any) bool { if entry, ok := value.(placeholderEntry); ok { if now.Sub(entry.createdAt) > placeholderTTL { m.placeholders.Delete(key) } } return true }) // Verify entry was deleted if _, loaded := m.placeholders.Load("test:456"); loaded { t.Fatal("expected placeholder entry to be deleted after eviction") } } func TestPreSendStillWorksWithWrappedTypes(t *testing.T) { m := newTestManager() var stopCalled bool var editCalled bool ch := &mockMessageEditor{ mockChannel: mockChannel{ sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, }, editFn: func(_ context.Context, chatID, messageID, content string) error { editCalled = true if messageID != "ph_id" { t.Fatalf("expected messageID ph_id, got %s", messageID) } return nil }, } // Use the new wrapped types via the public API m.RecordTypingStop("test", "chat1", func() { stopCalled = true }) m.RecordPlaceholder("test", "chat1", "ph_id") msg := bus.OutboundMessage{Channel: "test", ChatID: "chat1", Content: "response"} edited := m.preSend(context.Background(), "test", msg, ch) if !stopCalled { t.Fatal("expected typing stop to be called via wrapped type") } if !editCalled { t.Fatal("expected EditMessage to be called via wrapped type") } if !edited { t.Fatal("expected preSend to return true") } } // --- Lazy worker creation tests (Step 6) --- func TestLazyWorkerCreation(t *testing.T) { m := newTestManager() ch := &mockChannel{ sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, } // RegisterChannel should NOT create a worker m.RegisterChannel("lazy", ch) m.mu.RLock() _, chExists := m.channels["lazy"] _, wExists := m.workers["lazy"] m.mu.RUnlock() if !chExists { t.Fatal("expected channel to be registered") } if wExists { t.Fatal("expected worker to NOT be created by RegisterChannel (lazy creation)") } } // --- FastID uniqueness test (Step 5) --- func TestBuildMediaScope_FastIDUniqueness(t *testing.T) { seen := make(map[string]bool) for range 1000 { scope := BuildMediaScope("test", "chat1", "") if seen[scope] { t.Fatalf("duplicate scope generated: %s", scope) } seen[scope] = true } // Verify format: "channel:chatID:id" scope := BuildMediaScope("telegram", "42", "") parts := 0 for _, c := range scope { if c == ':' { parts++ } } if parts != 2 { t.Fatalf("expected scope to have 2 colons (channel:chatID:id), got: %s", scope) } } func TestBuildMediaScope_WithMessageID(t *testing.T) { scope := BuildMediaScope("discord", "chat99", "msg123") expected := "discord:chat99:msg123" if scope != expected { t.Fatalf("expected %s, got %s", expected, scope) } } func TestManager_PlaceholderConsumedByResponse(t *testing.T) { mgr := &Manager{ channels: make(map[string]Channel), workers: make(map[string]*channelWorker), placeholders: sync.Map{}, } mockCh := &mockChannel{ sendFn: func(ctx context.Context, msg bus.OutboundMessage) error { return nil }, } worker := newChannelWorker("mock", mockCh) mgr.channels["mock"] = mockCh mgr.workers["mock"] = worker ctx := context.Background() key := "mock:chat-1" // Simulate a placeholder recorded by base.go HandleMessage mgr.RecordPlaceholder("mock", "chat-1", "ph-123") if _, ok := mgr.placeholders.Load(key); !ok { t.Fatal("expected placeholder to be recorded") } // Transcription feedback arrives first — it should consume the placeholder // and be delivered via EditMessage, not Send. msgTranscript := bus.OutboundMessage{ Channel: "mock", ChatID: "chat-1", Content: "Transcript: hello", } mgr.sendWithRetry(ctx, "mock", worker, msgTranscript) if mockCh.editedMessages != 1 { t.Errorf("expected 1 edited message (placeholder consumed by transcript), got %d", mockCh.editedMessages) } if len(mockCh.sentMessages) != 0 { t.Errorf("expected 0 normal messages (transcript used edit), got %d", len(mockCh.sentMessages)) } // Placeholder should be gone now if _, ok := mgr.placeholders.Load(key); ok { t.Error("expected placeholder to be removed after being consumed") } // Final LLM response arrives — no placeholder left, so it goes through Send msgFinal := bus.OutboundMessage{ Channel: "mock", ChatID: "chat-1", Content: "Final Answer", } mgr.sendWithRetry(ctx, "mock", worker, msgFinal) if len(mockCh.sentMessages) != 1 { t.Errorf("expected 1 normal message sent, got %d", len(mockCh.sentMessages)) } } func TestSendMessage_Synchronous(t *testing.T) { m := newTestManager() var received []bus.OutboundMessage ch := &mockChannel{ sendFn: func(_ context.Context, msg bus.OutboundMessage) error { received = append(received, msg) return nil }, } w := &channelWorker{ ch: ch, limiter: rate.NewLimiter(rate.Inf, 1), } m.channels["test"] = ch m.workers["test"] = w msg := bus.OutboundMessage{ Channel: "test", ChatID: "123", Content: "hello world", ReplyToMessageID: "msg-456", } err := m.SendMessage(context.Background(), msg) if err != nil { t.Fatalf("expected no error, got %v", err) } // SendMessage is synchronous — message should already be delivered if len(received) != 1 { t.Fatalf("expected 1 message sent, got %d", len(received)) } if received[0].ReplyToMessageID != "msg-456" { t.Fatalf("expected ReplyToMessageID msg-456, got %s", received[0].ReplyToMessageID) } if received[0].Content != "hello world" { t.Fatalf("expected content 'hello world', got %s", received[0].Content) } } func TestSendMessage_UnknownChannel(t *testing.T) { m := newTestManager() msg := bus.OutboundMessage{ Channel: "nonexistent", ChatID: "123", Content: "hello", } err := m.SendMessage(context.Background(), msg) if err == nil { t.Fatal("expected error for unknown channel") } } func TestSendMessage_NoWorker(t *testing.T) { m := newTestManager() ch := &mockChannel{ sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, } m.channels["test"] = ch // No worker registered msg := bus.OutboundMessage{ Channel: "test", ChatID: "123", Content: "hello", } err := m.SendMessage(context.Background(), msg) if err == nil { t.Fatal("expected error when no worker exists") } } func TestSendMessage_WithRetry(t *testing.T) { m := newTestManager() var callCount int ch := &mockChannel{ sendFn: func(_ context.Context, _ bus.OutboundMessage) error { callCount++ if callCount == 1 { return fmt.Errorf("transient: %w", ErrTemporary) } return nil }, } w := &channelWorker{ ch: ch, limiter: rate.NewLimiter(rate.Inf, 1), } m.channels["test"] = ch m.workers["test"] = w msg := bus.OutboundMessage{ Channel: "test", ChatID: "123", Content: "retry me", } err := m.SendMessage(context.Background(), msg) if err != nil { t.Fatalf("expected no error, got %v", err) } if callCount != 2 { t.Fatalf("expected 2 Send calls (1 failure + 1 success), got %d", callCount) } } func TestSendMessage_WithSplitting(t *testing.T) { m := newTestManager() var received []string ch := &mockChannelWithLength{ mockChannel: mockChannel{ sendFn: func(_ context.Context, msg bus.OutboundMessage) error { received = append(received, msg.Content) return nil }, }, maxLen: 5, } w := &channelWorker{ ch: ch, limiter: rate.NewLimiter(rate.Inf, 1), } m.channels["test"] = ch m.workers["test"] = w msg := bus.OutboundMessage{ Channel: "test", ChatID: "123", Content: "hello world", } err := m.SendMessage(context.Background(), msg) if err != nil { t.Fatalf("expected no error, got %v", err) } if len(received) < 2 { t.Fatalf("expected message to be split into at least 2 chunks, got %d", len(received)) } } func TestSendMessage_PreservesOrdering(t *testing.T) { m := newTestManager() var order []string ch := &mockChannel{ sendFn: func(_ context.Context, msg bus.OutboundMessage) error { order = append(order, msg.Content) return nil }, } w := &channelWorker{ ch: ch, limiter: rate.NewLimiter(rate.Inf, 1), } m.channels["test"] = ch m.workers["test"] = w // Send two messages sequentially — they must arrive in order _ = m.SendMessage(context.Background(), bus.OutboundMessage{ Channel: "test", ChatID: "1", Content: "first", }) _ = m.SendMessage(context.Background(), bus.OutboundMessage{ Channel: "test", ChatID: "1", Content: "second", }) if len(order) != 2 { t.Fatalf("expected 2 messages, got %d", len(order)) } if order[0] != "first" || order[1] != "second" { t.Fatalf("expected [first, second], got %v", order) } } func TestManager_SendPlaceholder(t *testing.T) { mgr := &Manager{ channels: make(map[string]Channel), workers: make(map[string]*channelWorker), placeholders: sync.Map{}, } mockCh := &mockChannel{ sendFn: func(ctx context.Context, msg bus.OutboundMessage) error { return nil }, } mgr.channels["mock"] = mockCh ctx := context.Background() // SendPlaceholder should send a placeholder and record it ok := mgr.SendPlaceholder(ctx, "mock", "chat-1") if !ok { t.Fatal("expected SendPlaceholder to succeed") } if mockCh.placeholdersSent != 1 { t.Errorf("expected 1 placeholder sent, got %d", mockCh.placeholdersSent) } key := "mock:chat-1" if _, loaded := mgr.placeholders.Load(key); !loaded { t.Error("expected placeholder to be recorded in manager") } // SendPlaceholder on unknown channel should return false ok = mgr.SendPlaceholder(ctx, "unknown", "chat-1") if ok { t.Error("expected SendPlaceholder to fail for unknown channel") } } ================================================ FILE: pkg/channels/matrix/init.go ================================================ package matrix import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) func init() { channels.RegisterFactory("matrix", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { return NewMatrixChannel(cfg.Channels.Matrix, b) }) } ================================================ FILE: pkg/channels/matrix/matrix.go ================================================ package matrix import ( "context" "fmt" "html" "io" "mime" "net/url" "os" "path/filepath" "regexp" "strings" "sync" "time" "github.com/gomarkdown/markdown" mdhtml "github.com/gomarkdown/markdown/html" "github.com/gomarkdown/markdown/parser" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" ) const ( typingRefreshInterval = 20 * time.Second typingServerTTL = 30 * time.Second roomKindCacheTTL = 5 * time.Minute roomKindCacheCleanupPeriod = 1 * time.Minute roomKindCacheMaxEntries = 2048 ) var matrixMentionHrefRegexp = regexp.MustCompile(`(?i)]+href=["']([^"']+)["']`) type roomKindCacheEntry struct { isGroup bool expiresAt time.Time touchedAt time.Time } type roomKindCache struct { mu sync.Mutex entries map[string]roomKindCacheEntry maxEntries int ttl time.Duration } func newRoomKindCache(maxEntries int, ttl time.Duration) *roomKindCache { if maxEntries <= 0 { maxEntries = roomKindCacheMaxEntries } if ttl <= 0 { ttl = roomKindCacheTTL } return &roomKindCache{ entries: make(map[string]roomKindCacheEntry), maxEntries: maxEntries, ttl: ttl, } } func (c *roomKindCache) get(roomID string, now time.Time) (bool, bool) { c.mu.Lock() defer c.mu.Unlock() entry, ok := c.entries[roomID] if !ok { return false, false } if !entry.expiresAt.After(now) { delete(c.entries, roomID) return false, false } return entry.isGroup, true } func (c *roomKindCache) set(roomID string, isGroup bool, now time.Time) { c.mu.Lock() defer c.mu.Unlock() if entry, ok := c.entries[roomID]; ok { entry.isGroup = isGroup entry.expiresAt = now.Add(c.ttl) entry.touchedAt = now c.entries[roomID] = entry return } c.cleanupExpiredLocked(now) for len(c.entries) >= c.maxEntries { if !c.evictOldestLocked() { break } } c.entries[roomID] = roomKindCacheEntry{ isGroup: isGroup, expiresAt: now.Add(c.ttl), touchedAt: now, } } func (c *roomKindCache) cleanupExpired(now time.Time) int { c.mu.Lock() defer c.mu.Unlock() return c.cleanupExpiredLocked(now) } func (c *roomKindCache) cleanupExpiredLocked(now time.Time) int { removed := 0 for roomID, entry := range c.entries { if !entry.expiresAt.After(now) { delete(c.entries, roomID) removed++ } } return removed } func (c *roomKindCache) evictOldestLocked() bool { if len(c.entries) == 0 { return false } var ( oldestRoomID string oldestAt time.Time ) for roomID, entry := range c.entries { if oldestRoomID == "" || entry.touchedAt.Before(oldestAt) { oldestRoomID = roomID oldestAt = entry.touchedAt } } delete(c.entries, oldestRoomID) return true } type typingSession struct { stopCh chan struct{} once sync.Once } func newTypingSession() *typingSession { return &typingSession{ stopCh: make(chan struct{}), } } func (s *typingSession) stop() { s.once.Do(func() { close(s.stopCh) }) } // MatrixChannel implements the Channel interface for Matrix. type MatrixChannel struct { *channels.BaseChannel client *mautrix.Client config config.MatrixConfig syncer *mautrix.DefaultSyncer ctx context.Context cancel context.CancelFunc startTime time.Time typingMu sync.Mutex typingSessions map[string]*typingSession // roomID -> session roomKindCache *roomKindCache localpartMentionR *regexp.Regexp } func NewMatrixChannel(cfg config.MatrixConfig, messageBus *bus.MessageBus) (*MatrixChannel, error) { homeserver := strings.TrimSpace(cfg.Homeserver) userID := strings.TrimSpace(cfg.UserID) accessToken := strings.TrimSpace(cfg.AccessToken) if homeserver == "" { return nil, fmt.Errorf("matrix homeserver is required") } if userID == "" { return nil, fmt.Errorf("matrix user_id is required") } if accessToken == "" { return nil, fmt.Errorf("matrix access_token is required") } client, err := mautrix.NewClient(homeserver, id.UserID(userID), accessToken) if err != nil { return nil, fmt.Errorf("create matrix client: %w", err) } if cfg.DeviceID != "" { client.DeviceID = id.DeviceID(cfg.DeviceID) } syncer, ok := client.Syncer.(*mautrix.DefaultSyncer) if !ok { return nil, fmt.Errorf("matrix syncer is not *mautrix.DefaultSyncer") } base := channels.NewBaseChannel( "matrix", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(65536), channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) return &MatrixChannel{ BaseChannel: base, client: client, config: cfg, syncer: syncer, typingSessions: make(map[string]*typingSession), startTime: time.Now(), roomKindCache: newRoomKindCache(roomKindCacheMaxEntries, roomKindCacheTTL), localpartMentionR: localpartMentionRegexp(matrixLocalpart(client.UserID)), typingMu: sync.Mutex{}, }, nil } func (c *MatrixChannel) Start(ctx context.Context) error { logger.InfoC("matrix", "Starting Matrix channel") c.ctx, c.cancel = context.WithCancel(ctx) c.startTime = time.Now() c.syncer.OnEventType(event.EventMessage, c.handleMessageEvent) c.syncer.OnEventType(event.StateMember, c.handleMemberEvent) c.SetRunning(true) go c.runRoomKindCacheJanitor(c.ctx) go func() { if err := c.client.SyncWithContext(c.ctx); err != nil && c.ctx.Err() == nil { logger.ErrorCF("matrix", "Matrix sync stopped unexpectedly", map[string]any{ "error": err.Error(), }) } }() logger.InfoC("matrix", "Matrix channel started") return nil } func (c *MatrixChannel) Stop(ctx context.Context) error { logger.InfoC("matrix", "Stopping Matrix channel") c.SetRunning(false) if c.cancel != nil { c.cancel() } c.stopTypingSessions(ctx) logger.InfoC("matrix", "Matrix channel stopped") return nil } func markdownToHTML(md string) string { p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs) renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdhtml.CommonFlags}) return strings.TrimSpace(string(markdown.ToHTML([]byte(md), p, renderer))) } func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } roomID := id.RoomID(strings.TrimSpace(msg.ChatID)) if roomID == "" { return fmt.Errorf("matrix room ID is empty: %w", channels.ErrSendFailed) } content := strings.TrimSpace(msg.Content) if content == "" { return nil } _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, c.messageContent(content)) if err != nil { return fmt.Errorf("matrix send: %w", channels.ErrTemporary) } return nil } func (c *MatrixChannel) messageContent(text string) *event.MessageEventContent { mc := &event.MessageEventContent{MsgType: event.MsgText, Body: text} if c.config.MessageFormat != "plain" { mc.Format = event.FormatHTML mc.FormattedBody = markdownToHTML(text) } return mc } // SendMedia implements channels.MediaSender. func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } sendCtx := ctx if sendCtx == nil { sendCtx = context.Background() } roomID := id.RoomID(strings.TrimSpace(msg.ChatID)) if roomID == "" { return fmt.Errorf("matrix room ID is empty: %w", channels.ErrSendFailed) } store := c.GetMediaStore() if store == nil { return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } for _, part := range msg.Parts { if err := sendCtx.Err(); err != nil { return err } localPath, meta, err := store.ResolveWithMeta(part.Ref) if err != nil { logger.ErrorCF("matrix", "Failed to resolve media ref", map[string]any{ "ref": part.Ref, "error": err.Error(), }) continue } fileInfo, err := os.Stat(localPath) if err != nil { logger.ErrorCF("matrix", "Failed to stat media file", map[string]any{ "path": localPath, "error": err.Error(), }) continue } file, err := os.Open(localPath) if err != nil { logger.ErrorCF("matrix", "Failed to open media file", map[string]any{ "path": localPath, "error": err.Error(), }) continue } filename := strings.TrimSpace(part.Filename) if filename == "" { filename = strings.TrimSpace(meta.Filename) } if filename == "" { filename = filepath.Base(localPath) } if filename == "" { filename = "file" } contentType := strings.TrimSpace(part.ContentType) if contentType == "" { contentType = strings.TrimSpace(meta.ContentType) } if contentType == "" { contentType = mime.TypeByExtension(strings.ToLower(filepath.Ext(filename))) } if contentType == "" { contentType = "application/octet-stream" } uploadResp, err := c.client.UploadMedia(sendCtx, mautrix.ReqUploadMedia{ Content: file, ContentLength: fileInfo.Size(), ContentType: contentType, FileName: filename, }) file.Close() if err != nil { logger.ErrorCF("matrix", "Failed to upload media", map[string]any{ "path": localPath, "type": part.Type, "error": err.Error(), }) return fmt.Errorf("matrix upload media: %w", channels.ErrTemporary) } msgType := matrixOutboundMsgType(part.Type, filename, contentType) content := matrixOutboundContent( part.Caption, filename, msgType, contentType, fileInfo.Size(), uploadResp.ContentURI.CUString(), ) if _, err := c.client.SendMessageEvent(sendCtx, roomID, event.EventMessage, content); err != nil { logger.ErrorCF("matrix", "Failed to send media message", map[string]any{ "room_id": roomID.String(), "type": msgType, "error": err.Error(), }) return fmt.Errorf("matrix send media: %w", channels.ErrTemporary) } } return nil } // StartTyping implements channels.TypingCapable. func (c *MatrixChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { if !c.IsRunning() { return func() {}, nil } roomID := id.RoomID(strings.TrimSpace(chatID)) if roomID == "" { return func() {}, fmt.Errorf("matrix room ID is empty") } session := newTypingSession() c.typingMu.Lock() if prev := c.typingSessions[chatID]; prev != nil { prev.stop() } c.typingSessions[chatID] = session c.typingMu.Unlock() parent := c.baseContext() go c.typingLoop(parent, roomID, session) var once sync.Once stop := func() { once.Do(func() { session.stop() c.typingMu.Lock() if current := c.typingSessions[chatID]; current == session { delete(c.typingSessions, chatID) } c.typingMu.Unlock() _, _ = c.client.UserTyping(context.Background(), roomID, false, 0) }) } return stop, nil } // SendPlaceholder implements channels.PlaceholderCapable. func (c *MatrixChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { if !c.config.Placeholder.Enabled { return "", nil } roomID := id.RoomID(strings.TrimSpace(chatID)) if roomID == "" { return "", fmt.Errorf("matrix room ID is empty") } text := strings.TrimSpace(c.config.Placeholder.Text) if text == "" { text = "Thinking... 💭" } resp, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{ MsgType: event.MsgNotice, Body: text, }) if err != nil { return "", err } return resp.EventID.String(), nil } // EditMessage implements channels.MessageEditor. func (c *MatrixChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { roomID := id.RoomID(strings.TrimSpace(chatID)) if roomID == "" { return fmt.Errorf("matrix room ID is empty") } if strings.TrimSpace(messageID) == "" { return fmt.Errorf("matrix message ID is empty") } editContent := c.messageContent(content) editContent.SetEdit(id.EventID(messageID)) _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, editContent) return err } func (c *MatrixChannel) handleMemberEvent(ctx context.Context, evt *event.Event) { if !c.config.JoinOnInvite { return } if evt == nil { return } member := evt.Content.AsMember() if member.Membership != event.MembershipInvite { return } if evt.GetStateKey() != c.client.UserID.String() { return } _, err := c.client.JoinRoomByID(c.baseContext(), evt.RoomID) if err != nil { logger.WarnCF("matrix", "Failed to auto-join invited room", map[string]any{ "room_id": evt.RoomID.String(), "error": err.Error(), }) return } logger.InfoCF("matrix", "Joined room after invite", map[string]any{ "room_id": evt.RoomID.String(), }) } func (c *MatrixChannel) handleMessageEvent(ctx context.Context, evt *event.Event) { if evt == nil { return } // Ignore our own messages. if evt.Sender == c.client.UserID { return } // Ignore historical events on first sync. if time.UnixMilli(evt.Timestamp).Before(c.startTime) { return } msgEvt := evt.Content.AsMessage() if msgEvt == nil { return } // Ignore edits. if msgEvt.RelatesTo != nil && msgEvt.RelatesTo.GetReplaceID() != "" { return } roomID := evt.RoomID.String() scope := channels.BuildMediaScope("matrix", roomID, evt.ID.String()) content, mediaPaths, ok := c.extractInboundContent(ctx, msgEvt, scope) if !ok { return } content = strings.TrimSpace(content) if content == "" && len(mediaPaths) == 0 { return } senderID := evt.Sender.String() sender := bus.SenderInfo{ Platform: "matrix", PlatformID: senderID, CanonicalID: identity.BuildCanonicalID("matrix", senderID), Username: senderID, DisplayName: senderID, } if !c.IsAllowedSender(sender) { logger.DebugCF("matrix", "Message rejected by allowlist", map[string]any{ "sender_id": senderID, }) return } isGroup := c.isGroupRoom(ctx, evt.RoomID) if isGroup { isMentioned := c.isBotMentioned(msgEvt) if isMentioned { content = c.stripSelfMention(content) } respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) if !respond { logger.DebugCF("matrix", "Ignoring group message by trigger rules", map[string]any{ "room_id": roomID, "is_mentioned": isMentioned, "mention_only": c.config.GroupTrigger.MentionOnly, "prefixes": c.config.GroupTrigger.Prefixes, }) return } content = cleaned } else { content = c.stripSelfMention(content) } content = strings.TrimSpace(content) if content == "" { return } peerKind := "direct" peerID := senderID if isGroup { peerKind = "group" peerID = roomID } metadata := map[string]string{ "room_id": roomID, "timestamp": fmt.Sprintf("%d", evt.Timestamp), "is_group": fmt.Sprintf("%t", isGroup), "sender_raw": senderID, } if replyTo := msgEvt.GetRelatesTo().GetReplyTo(); replyTo != "" { metadata["reply_to_msg_id"] = replyTo.String() } c.HandleMessage( c.baseContext(), bus.Peer{Kind: peerKind, ID: peerID}, evt.ID.String(), senderID, roomID, content, mediaPaths, metadata, sender, ) } func (c *MatrixChannel) extractInboundContent( ctx context.Context, msgEvt *event.MessageEventContent, scope string, ) (string, []string, bool) { switch msgEvt.MsgType { case event.MsgText, event.MsgNotice: return msgEvt.Body, nil, true case event.MsgImage, event.MsgAudio, event.MsgVideo, event.MsgFile: return c.extractInboundMedia(ctx, msgEvt, scope) default: logger.DebugCF("matrix", "Ignoring unsupported matrix msgtype", map[string]any{ "msgtype": msgEvt.MsgType, }) return "", nil, false } } func (c *MatrixChannel) extractInboundMedia( ctx context.Context, msgEvt *event.MessageEventContent, scope string, ) (string, []string, bool) { mediaKind := matrixMediaKind(msgEvt.MsgType) label := matrixMediaLabel(msgEvt, mediaKind) content := fmt.Sprintf("[%s: %s]", mediaKind, label) if caption := strings.TrimSpace(msgEvt.GetCaption()); caption != "" { content = caption + "\n" + content } localPath, err := c.downloadMedia(ctx, msgEvt, mediaKind) if err != nil { logger.WarnCF("matrix", "Failed to download media; forwarding as text-only marker", map[string]any{ "msgtype": msgEvt.MsgType, "error": err.Error(), }) return content, nil, true } filename := matrixMediaFilename(label, mediaKind, matrixContentType(msgEvt)) ref := c.storeMedia(localPath, media.MediaMeta{ Filename: filename, ContentType: matrixContentType(msgEvt), Source: "matrix", }, scope) return content, []string{ref}, true } func (c *MatrixChannel) storeMedia(localPath string, meta media.MediaMeta, scope string) string { if store := c.GetMediaStore(); store != nil { ref, err := store.Store(localPath, meta, scope) if err == nil { return ref } logger.WarnCF("matrix", "Failed to store media in MediaStore, falling back to local path", map[string]any{ "path": localPath, "error": err.Error(), }) } return localPath } func (c *MatrixChannel) downloadMedia( ctx context.Context, msgEvt *event.MessageEventContent, mediaKind string, ) (string, error) { uri := matrixMediaURI(msgEvt) if uri == "" { return "", fmt.Errorf("empty matrix media URL") } parsed := uri.ParseOrIgnore() if parsed.IsEmpty() { return "", fmt.Errorf("invalid matrix media URL: %s", uri) } dlCtx := c.baseContext() if ctx != nil { dlCtx = ctx } reqCtx, cancel := context.WithTimeout(dlCtx, 20*time.Second) defer cancel() resp, err := c.client.Download(reqCtx, parsed) if err != nil { return "", err } defer resp.Body.Close() reader := resp.Body readerClose := func() error { return nil } // Encrypted attachments put URL in msgEvt.File and require client-side decryption. if msgEvt != nil && msgEvt.File != nil && msgEvt.URL == "" { if err = msgEvt.File.PrepareForDecryption(); err != nil { return "", fmt.Errorf("decrypt matrix media: %w", err) } decryptReader := msgEvt.File.DecryptStream(resp.Body) reader = decryptReader readerClose = decryptReader.Close } label := matrixMediaLabel(msgEvt, mediaKind) ext := matrixMediaExt(label, matrixContentType(msgEvt), mediaKind) mediaDir, err := matrixMediaTempDir() if err != nil { return "", fmt.Errorf("create matrix media directory: %w", err) } tmp, err := os.CreateTemp(mediaDir, "matrix-media-*"+ext) if err != nil { return "", err } tmpPath := tmp.Name() cleanup := true defer func() { _ = tmp.Close() if cleanup { _ = os.Remove(tmpPath) } }() _, err = io.Copy(tmp, reader) if err != nil { return "", err } if err = readerClose(); err != nil { return "", fmt.Errorf("decrypt matrix media: %w", err) } if err = tmp.Close(); err != nil { return "", err } cleanup = false return tmpPath, nil } func matrixContentType(msgEvt *event.MessageEventContent) string { if msgEvt != nil && msgEvt.Info != nil { return strings.TrimSpace(msgEvt.Info.MimeType) } return "" } func matrixMediaURI(msgEvt *event.MessageEventContent) id.ContentURIString { if msgEvt == nil { return "" } if msgEvt.URL != "" { return msgEvt.URL } if msgEvt.File != nil { return msgEvt.File.URL } return "" } func matrixMediaKind(msgType event.MessageType) string { switch msgType { case event.MsgAudio: return "audio" case event.MsgVideo: return "video" case event.MsgFile: return "file" default: return "image" } } func matrixOutboundMsgType(partType, filename, contentType string) event.MessageType { switch strings.ToLower(strings.TrimSpace(partType)) { case "image": return event.MsgImage case "audio", "voice": return event.MsgAudio case "video": return event.MsgVideo case "file", "document": return event.MsgFile } ct := strings.ToLower(strings.TrimSpace(contentType)) switch { case strings.HasPrefix(ct, "image/"): return event.MsgImage case strings.HasPrefix(ct, "audio/"), ct == "application/ogg", ct == "application/x-ogg": return event.MsgAudio case strings.HasPrefix(ct, "video/"): return event.MsgVideo } switch strings.ToLower(strings.TrimSpace(filepath.Ext(filename))) { case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg": return event.MsgImage case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus": return event.MsgAudio case ".mp4", ".avi", ".mov", ".webm", ".mkv": return event.MsgVideo default: return event.MsgFile } } func matrixOutboundContent( caption, filename string, msgType event.MessageType, contentType string, size int64, uri id.ContentURIString, ) *event.MessageEventContent { body := strings.TrimSpace(caption) if body == "" { body = filename } if body == "" { body = matrixMediaKind(msgType) } info := &event.FileInfo{MimeType: strings.TrimSpace(contentType)} if size > 0 && size <= int64(int(^uint(0)>>1)) { info.Size = int(size) } content := &event.MessageEventContent{ MsgType: msgType, Body: body, URL: uri, FileName: filename, Info: info, } return content } func matrixMediaLabel(msgEvt *event.MessageEventContent, fallback string) string { if msgEvt == nil { return fallback } if v := strings.TrimSpace(msgEvt.FileName); v != "" { return v } if v := strings.TrimSpace(msgEvt.Body); v != "" { return v } return fallback } func matrixMediaFilename(label, mediaKind, contentType string) string { filename := strings.TrimSpace(label) if filename == "" { filename = mediaKind } if filepath.Ext(filename) == "" { filename += matrixMediaExt("", contentType, mediaKind) } return filename } func matrixMediaExt(filename, contentType, mediaKind string) string { if ext := strings.TrimSpace(filepath.Ext(filename)); ext != "" { return ext } if contentType != "" { if exts, err := mime.ExtensionsByType(contentType); err == nil && len(exts) > 0 { return exts[0] } } switch mediaKind { case "audio": return ".ogg" case "video": return ".mp4" case "file": return ".bin" default: return ".jpg" } } func (c *MatrixChannel) isGroupRoom(ctx context.Context, roomID id.RoomID) bool { now := time.Now() if isGroup, ok := c.roomKindCache.get(roomID.String(), now); ok { return isGroup } qctx := c.baseContext() if ctx != nil { qctx = ctx } reqCtx, cancel := context.WithTimeout(qctx, 5*time.Second) defer cancel() resp, err := c.client.JoinedMembers(reqCtx, roomID) if err != nil { logger.DebugCF("matrix", "Failed to query room members; assume direct", map[string]any{ "room_id": roomID.String(), "error": err.Error(), }) return false } isGroup := len(resp.Joined) > 2 c.roomKindCache.set(roomID.String(), isGroup, now) return isGroup } func (c *MatrixChannel) isBotMentioned(msgEvt *event.MessageEventContent) bool { if msgEvt == nil { return false } if msgEvt.Mentions != nil && msgEvt.Mentions.Has(c.client.UserID) { return true } userID := c.client.UserID.String() if userID != "" && strings.Contains(msgEvt.Body, userID) { return true } if mentionsUserInFormattedBody(msgEvt.FormattedBody, c.client.UserID) { return true } mentionR := c.localpartMentionR if mentionR == nil { mentionR = localpartMentionRegexp(matrixLocalpart(c.client.UserID)) } if mentionR == nil { return false } // Matrix users are addressed as MXID "@localpart:server", but many clients // emit plain-text mentions as "@localpart". Both forms are handled here. return mentionR.MatchString(msgEvt.Body) || mentionR.MatchString(msgEvt.FormattedBody) } func mentionsUserInFormattedBody(formattedBody string, userID id.UserID) bool { target := strings.ToLower(strings.TrimSpace(userID.String())) if target == "" { return false } formattedBody = strings.TrimSpace(formattedBody) if formattedBody == "" { return false } if strings.Contains(strings.ToLower(formattedBody), target) { return true } matches := matrixMentionHrefRegexp.FindAllStringSubmatch(formattedBody, -1) for _, match := range matches { if len(match) < 2 { continue } decoded := decodeMatrixMentionHref(match[1]) if strings.Contains(strings.ToLower(decoded), target) { return true } u, err := url.Parse(decoded) if err != nil { continue } if strings.Contains(strings.ToLower(u.Path), target) || strings.Contains(strings.ToLower(u.Fragment), target) { return true } if strings.Contains(strings.ToLower(decodeMatrixMentionHref(u.Fragment)), target) { return true } } return false } func decodeMatrixMentionHref(v string) string { decoded := html.UnescapeString(strings.TrimSpace(v)) if decoded == "" { return "" } for i := 0; i < 2; i++ { next, err := url.QueryUnescape(decoded) if err != nil || next == decoded { break } decoded = next } return decoded } func (c *MatrixChannel) typingLoop(ctx context.Context, roomID id.RoomID, session *typingSession) { sendTyping := func() { _, err := c.client.UserTyping(ctx, roomID, true, typingServerTTL) if err != nil { logger.DebugCF("matrix", "Failed to send typing status", map[string]any{ "room_id": roomID.String(), "error": err.Error(), }) } } sendTyping() ticker := time.NewTicker(typingRefreshInterval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-session.stopCh: return case <-ticker.C: sendTyping() } } } func (c *MatrixChannel) stopTypingSessions(ctx context.Context) { c.typingMu.Lock() sessions := c.typingSessions c.typingSessions = make(map[string]*typingSession) c.typingMu.Unlock() stopCtx := ctx if stopCtx == nil { stopCtx = context.Background() } for roomID, session := range sessions { session.stop() _, _ = c.client.UserTyping(stopCtx, id.RoomID(roomID), false, 0) } } func (c *MatrixChannel) baseContext() context.Context { if c.ctx != nil { return c.ctx } return context.Background() } func (c *MatrixChannel) runRoomKindCacheJanitor(ctx context.Context) { ticker := time.NewTicker(roomKindCacheCleanupPeriod) defer ticker.Stop() for { select { case <-ctx.Done(): return case now := <-ticker.C: c.roomKindCache.cleanupExpired(now) } } } func (c *MatrixChannel) stripSelfMention(text string) string { return stripUserMentionWithRegexp(text, c.client.UserID, c.localpartMentionR) } func matrixMediaTempDir() (string, error) { mediaDir := media.TempDir() if err := os.MkdirAll(mediaDir, 0o700); err != nil { return "", err } return mediaDir, nil } func matrixLocalpart(userID id.UserID) string { s := strings.TrimPrefix(userID.String(), "@") localpart, _, _ := strings.Cut(s, ":") return strings.TrimSpace(localpart) } func localpartMentionRegexp(localpart string) *regexp.Regexp { localpart = strings.TrimSpace(localpart) if localpart == "" { return nil } // Match Matrix mentions in plain text while avoiding false positives: // "@picoclaw" and "@picoclaw:matrix.org" should match, // "test@example.com" and "hellopicoclawworld" should not. pattern := `(?i)(^|[^[:alnum:]_])@` + regexp.QuoteMeta(localpart) + `(?::[A-Za-z0-9._:-]+)?([^[:alnum:]_]|$)` return regexp.MustCompile(pattern) } func stripUserMention(text string, userID id.UserID) string { return stripUserMentionWithRegexp(text, userID, localpartMentionRegexp(matrixLocalpart(userID))) } func stripUserMentionWithRegexp(text string, userID id.UserID, mentionR *regexp.Regexp) string { cleaned := strings.ReplaceAll(text, userID.String(), "") if mentionR != nil { cleaned = mentionR.ReplaceAllString(cleaned, "$1$2") } cleaned = strings.TrimSpace(cleaned) cleaned = strings.TrimLeft(cleaned, ",:; ") return strings.TrimSpace(cleaned) } ================================================ FILE: pkg/channels/matrix/matrix_test.go ================================================ package matrix import ( "context" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/media" ) func TestMatrixLocalpartMentionRegexp(t *testing.T) { re := localpartMentionRegexp("picoclaw") cases := []struct { text string want bool }{ {text: "@picoclaw hello", want: true}, {text: "hi @picoclaw:matrix.org", want: true}, { text: "\u6b22\u8fce\u4e00\u4e0bpicoclaw\u5c0f\u9f99\u867e", want: false, // historical false-positive case in PR #356 }, {text: "mail test@example.com", want: false}, } for _, tc := range cases { if got := re.MatchString(tc.text); got != tc.want { t.Fatalf("text=%q match=%v want=%v", tc.text, got, tc.want) } } } func TestStripUserMention(t *testing.T) { userID := id.UserID("@picoclaw:matrix.org") cases := []struct { in string want string }{ {in: "@picoclaw:matrix.org hello", want: "hello"}, {in: "@picoclaw, hello", want: "hello"}, {in: "no mention here", want: "no mention here"}, } for _, tc := range cases { if got := stripUserMention(tc.in, userID); got != tc.want { t.Fatalf("stripUserMention(%q)=%q want=%q", tc.in, got, tc.want) } } } func TestIsBotMentioned(t *testing.T) { ch := &MatrixChannel{ client: &mautrix.Client{ UserID: id.UserID("@picoclaw:matrix.org"), }, } cases := []struct { name string msg event.MessageEventContent want bool }{ { name: "mentions field", msg: event.MessageEventContent{ Body: "hello", Mentions: &event.Mentions{ UserIDs: []id.UserID{id.UserID("@picoclaw:matrix.org")}, }, }, want: true, }, { name: "full user id in body", msg: event.MessageEventContent{ Body: "@picoclaw:matrix.org hello", }, want: true, }, { name: "localpart with at sign", msg: event.MessageEventContent{ Body: "@picoclaw hello", }, want: true, }, { name: "localpart without at sign should not match", msg: event.MessageEventContent{ Body: "\u6b22\u8fce\u4e00\u4e0bpicoclaw\u5c0f\u9f99\u867e", }, want: false, }, { name: "formatted mention href matrix.to plain", msg: event.MessageEventContent{ Body: "hello bot", FormattedBody: `PicoClaw hello`, }, want: true, }, { name: "formatted mention href matrix.to encoded", msg: event.MessageEventContent{ Body: "hello bot", FormattedBody: `PicoClaw hello`, }, want: true, }, } for _, tc := range cases { if got := ch.isBotMentioned(&tc.msg); got != tc.want { t.Fatalf("%s: got=%v want=%v", tc.name, got, tc.want) } } } func TestRoomKindCache_ExpiresEntries(t *testing.T) { cache := newRoomKindCache(4, 5*time.Second) now := time.Unix(100, 0) cache.set("!room:matrix.org", true, now) if got, ok := cache.get("!room:matrix.org", now.Add(2*time.Second)); !ok || !got { t.Fatalf("expected cached group room before ttl, got ok=%v group=%v", ok, got) } if _, ok := cache.get("!room:matrix.org", now.Add(6*time.Second)); ok { t.Fatal("expected cache miss after ttl expiry") } } func TestRoomKindCache_EvictsOldestWhenFull(t *testing.T) { cache := newRoomKindCache(2, time.Minute) now := time.Unix(200, 0) cache.set("!room1:matrix.org", false, now) cache.set("!room2:matrix.org", false, now.Add(1*time.Second)) cache.set("!room3:matrix.org", true, now.Add(2*time.Second)) if _, ok := cache.get("!room1:matrix.org", now.Add(2*time.Second)); ok { t.Fatal("expected oldest cache entry to be evicted") } if got, ok := cache.get("!room2:matrix.org", now.Add(2*time.Second)); !ok || got { t.Fatalf("expected room2 to remain and be direct, got ok=%v group=%v", ok, got) } if got, ok := cache.get("!room3:matrix.org", now.Add(2*time.Second)); !ok || !got { t.Fatalf("expected room3 to remain and be group, got ok=%v group=%v", ok, got) } } func TestMatrixMediaTempDir(t *testing.T) { dir, err := matrixMediaTempDir() if err != nil { t.Fatalf("matrixMediaTempDir failed: %v", err) } if filepath.Base(dir) != media.TempDirName { t.Fatalf("unexpected media dir base: %q", filepath.Base(dir)) } info, err := os.Stat(dir) if err != nil { t.Fatalf("media dir not created: %v", err) } if !info.IsDir() { t.Fatalf("expected directory, got mode=%v", info.Mode()) } } func TestMatrixMediaExt(t *testing.T) { if got := matrixMediaExt("photo.png", "", "image"); got != ".png" { t.Fatalf("filename extension mismatch: got=%q", got) } if got := matrixMediaExt("", "image/webp", "image"); got != ".webp" { t.Fatalf("content-type extension mismatch: got=%q", got) } if got := matrixMediaExt("", "", "image"); got != ".jpg" { t.Fatalf("default image extension mismatch: got=%q", got) } if got := matrixMediaExt("", "", "audio"); got != ".ogg" { t.Fatalf("default audio extension mismatch: got=%q", got) } if got := matrixMediaExt("", "", "video"); got != ".mp4" { t.Fatalf("default video extension mismatch: got=%q", got) } if got := matrixMediaExt("", "", "file"); got != ".bin" { t.Fatalf("default file extension mismatch: got=%q", got) } } func TestDownloadMedia_WritesResponseToTempFile(t *testing.T) { const wantBody = "matrix-media-payload" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasSuffix(r.URL.Path, "/_matrix/client/v1/media/download/matrix.test/abc123") { t.Fatalf("unexpected download path: %s", r.URL.Path) } w.Header().Set("Content-Type", "image/png") _, _ = w.Write([]byte(wantBody)) })) defer server.Close() client, err := mautrix.NewClient(server.URL, id.UserID("@picoclaw:matrix.test"), "") if err != nil { t.Fatalf("NewClient: %v", err) } ch := &MatrixChannel{client: client} msg := &event.MessageEventContent{ MsgType: event.MsgImage, Body: "image.png", URL: id.ContentURIString("mxc://matrix.test/abc123"), Info: &event.FileInfo{MimeType: "image/png"}, } path, err := ch.downloadMedia(context.Background(), msg, "image") if err != nil { t.Fatalf("downloadMedia: %v", err) } defer os.Remove(path) if ext := filepath.Ext(path); ext != ".png" { t.Fatalf("temp file extension=%q want=.png", ext) } got, err := os.ReadFile(path) if err != nil { t.Fatalf("ReadFile: %v", err) } if string(got) != wantBody { t.Fatalf("file contents=%q want=%q", string(got), wantBody) } } func TestExtractInboundContent_ImageNoURLFallback(t *testing.T) { ch := &MatrixChannel{} msg := &event.MessageEventContent{ MsgType: event.MsgImage, Body: "test.png", } content, mediaRefs, ok := ch.extractInboundContent(context.Background(), msg, "matrix:room:event") if !ok { t.Fatal("expected ok for image fallback") } if content != "[image: test.png]" { t.Fatalf("unexpected content: %q", content) } if len(mediaRefs) != 0 { t.Fatalf("expected no media refs, got %d", len(mediaRefs)) } } func TestExtractInboundContent_AudioNoURLFallback(t *testing.T) { ch := &MatrixChannel{} msg := &event.MessageEventContent{ MsgType: event.MsgAudio, FileName: "voice.ogg", Body: "please transcribe", } content, mediaRefs, ok := ch.extractInboundContent(context.Background(), msg, "matrix:room:event") if !ok { t.Fatal("expected ok for audio fallback") } if content != "please transcribe\n[audio: voice.ogg]" { t.Fatalf("unexpected content: %q", content) } if len(mediaRefs) != 0 { t.Fatalf("expected no media refs, got %d", len(mediaRefs)) } } func TestMatrixOutboundMsgType(t *testing.T) { cases := []struct { name string partType string filename string contentType string want event.MessageType }{ {name: "explicit image", partType: "image", want: event.MsgImage}, {name: "explicit audio", partType: "audio", want: event.MsgAudio}, {name: "mime fallback video", contentType: "video/mp4", want: event.MsgVideo}, {name: "extension fallback audio", filename: "voice.ogg", want: event.MsgAudio}, {name: "unknown defaults file", filename: "report.txt", want: event.MsgFile}, } for _, tc := range cases { if got := matrixOutboundMsgType(tc.partType, tc.filename, tc.contentType); got != tc.want { t.Fatalf("%s: got=%q want=%q", tc.name, got, tc.want) } } } func TestMatrixOutboundContent(t *testing.T) { content := matrixOutboundContent( "please review", "voice.ogg", event.MsgAudio, "audio/ogg", 1234, id.ContentURIString("mxc://matrix.org/abc"), ) if content.Body != "please review" { t.Fatalf("unexpected body: %q", content.Body) } if content.FileName != "voice.ogg" { t.Fatalf("unexpected filename: %q", content.FileName) } if content.Info == nil || content.Info.MimeType != "audio/ogg" { t.Fatalf("unexpected content type: %+v", content.Info) } if content.Info == nil || content.Info.Size != 1234 { t.Fatalf("unexpected size: %+v", content.Info) } noCaption := matrixOutboundContent( "", "image.png", event.MsgImage, "image/png", 0, id.ContentURIString("mxc://matrix.org/def"), ) if noCaption.Body != "image.png" { t.Fatalf("unexpected fallback body: %q", noCaption.Body) } } func TestMarkdownToHTML(t *testing.T) { tests := []struct { name string input string contains string }{ {"bold", "**hello**", "hello"}, {"italic", "_world_", "world"}, {"header", "### Title", ""}, {"inline code", "`x`", "x"}, {"plain text", "just text", "just text"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := markdownToHTML(tt.input) if !strings.Contains(got, tt.contains) { t.Fatalf("markdownToHTML(%q) = %q, want it to contain %q", tt.input, got, tt.contains) } }) } } func TestMessageContent(t *testing.T) { richtext := &MatrixChannel{config: config.MatrixConfig{MessageFormat: "richtext"}} plain := &MatrixChannel{config: config.MatrixConfig{MessageFormat: "plain"}} defaultt := &MatrixChannel{config: config.MatrixConfig{}} for _, c := range []*MatrixChannel{richtext, defaultt} { mc := c.messageContent("**hi**") if mc.Format != event.FormatHTML { t.Errorf("format %q: expected FormatHTML, got %q", c.config.MessageFormat, mc.Format) } if !strings.Contains(mc.FormattedBody, "hi") { t.Errorf("format %q: FormattedBody %q missing ", c.config.MessageFormat, mc.FormattedBody) } if mc.Body != "**hi**" { t.Errorf("format %q: Body should remain plain, got %q", c.config.MessageFormat, mc.Body) } } mc := plain.messageContent("**hi**") if mc.Format != "" || mc.FormattedBody != "" { t.Errorf("plain: expected no formatting, got format=%q formattedBody=%q", mc.Format, mc.FormattedBody) } } ================================================ FILE: pkg/channels/media.go ================================================ package channels import ( "context" "github.com/sipeed/picoclaw/pkg/bus" ) // MediaSender is an optional interface for channels that can send // media attachments (images, files, audio, video). // Manager discovers channels implementing this interface via type // assertion and routes OutboundMediaMessage to them. type MediaSender interface { SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error } ================================================ FILE: pkg/channels/onebot/init.go ================================================ package onebot import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) func init() { channels.RegisterFactory("onebot", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { return NewOneBotChannel(cfg.Channels.OneBot, b) }) } ================================================ FILE: pkg/channels/onebot/onebot.go ================================================ package onebot import ( "context" "encoding/json" "fmt" "strconv" "strings" "sync" "sync/atomic" "time" "github.com/gorilla/websocket" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" ) type OneBotChannel struct { *channels.BaseChannel config config.OneBotConfig conn *websocket.Conn ctx context.Context cancel context.CancelFunc dedup map[string]struct{} dedupRing []string dedupIdx int mu sync.Mutex writeMu sync.Mutex echoCounter int64 selfID int64 pending map[string]chan json.RawMessage pendingMu sync.Mutex lastMessageID sync.Map } type oneBotRawEvent struct { PostType string `json:"post_type"` MessageType string `json:"message_type"` SubType string `json:"sub_type"` MessageID json.RawMessage `json:"message_id"` UserID json.RawMessage `json:"user_id"` GroupID json.RawMessage `json:"group_id"` RawMessage string `json:"raw_message"` Message json.RawMessage `json:"message"` Sender json.RawMessage `json:"sender"` SelfID json.RawMessage `json:"self_id"` Time json.RawMessage `json:"time"` MetaEventType string `json:"meta_event_type"` NoticeType string `json:"notice_type"` Echo string `json:"echo"` RetCode json.RawMessage `json:"retcode"` Status json.RawMessage `json:"status"` Data json.RawMessage `json:"data"` } type BotStatus struct { Online bool `json:"online"` Good bool `json:"good"` } func isAPIResponse(raw json.RawMessage) bool { if len(raw) == 0 { return false } var s string if json.Unmarshal(raw, &s) == nil { return s == "ok" || s == "failed" } var bs BotStatus if json.Unmarshal(raw, &bs) == nil { return bs.Online || bs.Good } return false } type oneBotSender struct { UserID json.RawMessage `json:"user_id"` Nickname string `json:"nickname"` Card string `json:"card"` } type oneBotAPIRequest struct { Action string `json:"action"` Params any `json:"params"` Echo string `json:"echo,omitempty"` } type oneBotMessageSegment struct { Type string `json:"type"` Data map[string]any `json:"data"` } func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) { base := channels.NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom, channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) const dedupSize = 1024 return &OneBotChannel{ BaseChannel: base, config: cfg, dedup: make(map[string]struct{}, dedupSize), dedupRing: make([]string, dedupSize), dedupIdx: 0, pending: make(map[string]chan json.RawMessage), }, nil } func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool) { go func() { _, err := c.sendAPIRequest("set_msg_emoji_like", map[string]any{ "message_id": messageID, "emoji_id": emojiID, "set": set, }, 5*time.Second) if err != nil { logger.DebugCF("onebot", "Failed to set emoji like", map[string]any{ "message_id": messageID, "error": err.Error(), }) } }() } // ReactToMessage implements channels.ReactionCapable. // It adds an emoji reaction (ID 289) to group messages and returns an undo function. // Private messages return a no-op since reactions are only meaningful in groups. func (c *OneBotChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) { // Only react in group chats if !strings.HasPrefix(chatID, "group:") { return func() {}, nil } c.setMsgEmojiLike(messageID, 289, true) return func() { c.setMsgEmojiLike(messageID, 289, false) }, nil } func (c *OneBotChannel) Start(ctx context.Context) error { if c.config.WSUrl == "" { return fmt.Errorf("OneBot ws_url not configured") } logger.InfoCF("onebot", "Starting OneBot channel", map[string]any{ "ws_url": c.config.WSUrl, }) c.ctx, c.cancel = context.WithCancel(ctx) if err := c.connect(); err != nil { logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]any{ "error": err.Error(), }) } else { go c.listen() c.fetchSelfID() } if c.config.ReconnectInterval > 0 { go c.reconnectLoop() } else { if c.conn == nil { return fmt.Errorf("failed to connect to OneBot and reconnect is disabled") } } c.SetRunning(true) logger.InfoC("onebot", "OneBot channel started successfully") return nil } func (c *OneBotChannel) connect() error { dialer := websocket.DefaultDialer dialer.HandshakeTimeout = 10 * time.Second header := make(map[string][]string) if c.config.AccessToken != "" { header["Authorization"] = []string{"Bearer " + c.config.AccessToken} } conn, resp, err := dialer.Dial(c.config.WSUrl, header) if resp != nil { resp.Body.Close() } if err != nil { return err } conn.SetPongHandler(func(appData string) error { _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second)) return nil }) _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second)) c.mu.Lock() c.conn = conn c.mu.Unlock() go c.pinger(conn) logger.InfoC("onebot", "WebSocket connected") return nil } func (c *OneBotChannel) pinger(conn *websocket.Conn) { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for { select { case <-c.ctx.Done(): return case <-ticker.C: c.writeMu.Lock() err := conn.WriteMessage(websocket.PingMessage, nil) c.writeMu.Unlock() if err != nil { logger.DebugCF("onebot", "Ping write failed, stopping pinger", map[string]any{ "error": err.Error(), }) return } } } } func (c *OneBotChannel) fetchSelfID() { resp, err := c.sendAPIRequest("get_login_info", nil, 5*time.Second) if err != nil { logger.WarnCF("onebot", "Failed to get_login_info", map[string]any{ "error": err.Error(), }) return } type loginInfo struct { UserID json.RawMessage `json:"user_id"` Nickname string `json:"nickname"` } for _, extract := range []func() (*loginInfo, error){ func() (*loginInfo, error) { var w struct { Data loginInfo `json:"data"` } err := json.Unmarshal(resp, &w) return &w.Data, err }, func() (*loginInfo, error) { var f loginInfo err := json.Unmarshal(resp, &f) return &f, err }, } { info, err := extract() if err != nil || len(info.UserID) == 0 { continue } if uid, err := parseJSONInt64(info.UserID); err == nil && uid > 0 { atomic.StoreInt64(&c.selfID, uid) logger.InfoCF("onebot", "Bot self ID retrieved", map[string]any{ "self_id": uid, "nickname": info.Nickname, }) return } } logger.WarnCF("onebot", "Could not parse self ID from get_login_info response", map[string]any{ "response": string(resp), }) } func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.Duration) (json.RawMessage, error) { c.mu.Lock() conn := c.conn c.mu.Unlock() if conn == nil { return nil, fmt.Errorf("WebSocket not connected") } echo := fmt.Sprintf("api_%d_%d", time.Now().UnixNano(), atomic.AddInt64(&c.echoCounter, 1)) ch := make(chan json.RawMessage, 1) c.pendingMu.Lock() c.pending[echo] = ch c.pendingMu.Unlock() defer func() { c.pendingMu.Lock() delete(c.pending, echo) c.pendingMu.Unlock() }() req := oneBotAPIRequest{ Action: action, Params: params, Echo: echo, } data, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("failed to marshal API request: %w", err) } c.writeMu.Lock() _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) err = conn.WriteMessage(websocket.TextMessage, data) _ = conn.SetWriteDeadline(time.Time{}) c.writeMu.Unlock() if err != nil { return nil, fmt.Errorf("failed to write API request: %w", err) } select { case resp := <-ch: if resp == nil { return nil, fmt.Errorf("API request %s: channel stopped", action) } return resp, nil case <-time.After(timeout): return nil, fmt.Errorf("API request %s timed out after %v", action, timeout) case <-c.ctx.Done(): return nil, fmt.Errorf("context canceled") } } func (c *OneBotChannel) reconnectLoop() { interval := max(time.Duration(c.config.ReconnectInterval)*time.Second, 5*time.Second) for { select { case <-c.ctx.Done(): return case <-time.After(interval): c.mu.Lock() conn := c.conn c.mu.Unlock() if conn == nil { logger.InfoC("onebot", "Attempting to reconnect...") if err := c.connect(); err != nil { logger.ErrorCF("onebot", "Reconnect failed", map[string]any{ "error": err.Error(), }) } else { go c.listen() c.fetchSelfID() } } } } } func (c *OneBotChannel) Stop(ctx context.Context) error { logger.InfoC("onebot", "Stopping OneBot channel") c.SetRunning(false) if c.cancel != nil { c.cancel() } c.pendingMu.Lock() for echo, ch := range c.pending { select { case ch <- nil: // non-blocking wake for blocked sendAPIRequest goroutines default: } delete(c.pending, echo) } c.pendingMu.Unlock() c.mu.Lock() if c.conn != nil { c.conn.Close() c.conn = nil } c.mu.Unlock() return nil } func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } // Check ctx before entering write path select { case <-ctx.Done(): return ctx.Err() default: } c.mu.Lock() conn := c.conn c.mu.Unlock() if conn == nil { return fmt.Errorf("OneBot WebSocket not connected") } action, params, err := c.buildSendRequest(msg) if err != nil { return err } echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1)) req := oneBotAPIRequest{ Action: action, Params: params, Echo: echo, } data, err := json.Marshal(req) if err != nil { return fmt.Errorf("failed to marshal OneBot request: %w", err) } c.writeMu.Lock() _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) err = conn.WriteMessage(websocket.TextMessage, data) _ = conn.SetWriteDeadline(time.Time{}) c.writeMu.Unlock() if err != nil { logger.ErrorCF("onebot", "Failed to send message", map[string]any{ "error": err.Error(), }) return fmt.Errorf("onebot send: %w", channels.ErrTemporary) } return nil } // SendMedia implements the channels.MediaSender interface. func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } select { case <-ctx.Done(): return ctx.Err() default: } c.mu.Lock() conn := c.conn c.mu.Unlock() if conn == nil { return fmt.Errorf("OneBot WebSocket not connected") } store := c.GetMediaStore() if store == nil { return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } // Build media segments var segments []oneBotMessageSegment for _, part := range msg.Parts { localPath, err := store.Resolve(part.Ref) if err != nil { logger.ErrorCF("onebot", "Failed to resolve media ref", map[string]any{ "ref": part.Ref, "error": err.Error(), }) continue } var segType string switch part.Type { case "image": segType = "image" case "video": segType = "video" case "audio": segType = "record" default: segType = "file" } segments = append(segments, oneBotMessageSegment{ Type: segType, Data: map[string]any{"file": "file://" + localPath}, }) if part.Caption != "" { segments = append(segments, oneBotMessageSegment{ Type: "text", Data: map[string]any{"text": part.Caption}, }) } } if len(segments) == 0 { return nil } chatID := msg.ChatID var action, idKey string var rawID string if rest, ok := strings.CutPrefix(chatID, "group:"); ok { action, idKey, rawID = "send_group_msg", "group_id", rest } else if rest, ok := strings.CutPrefix(chatID, "private:"); ok { action, idKey, rawID = "send_private_msg", "user_id", rest } else { action, idKey, rawID = "send_private_msg", "user_id", chatID } id, err := strconv.ParseInt(rawID, 10, 64) if err != nil { return fmt.Errorf("invalid %s in chatID: %s: %w", idKey, chatID, channels.ErrSendFailed) } echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1)) req := oneBotAPIRequest{ Action: action, Params: map[string]any{idKey: id, "message": segments}, Echo: echo, } data, err := json.Marshal(req) if err != nil { return fmt.Errorf("failed to marshal OneBot request: %w", err) } c.writeMu.Lock() _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) err = conn.WriteMessage(websocket.TextMessage, data) _ = conn.SetWriteDeadline(time.Time{}) c.writeMu.Unlock() if err != nil { logger.ErrorCF("onebot", "Failed to send media message", map[string]any{ "error": err.Error(), }) return fmt.Errorf("onebot send media: %w", channels.ErrTemporary) } return nil } func (c *OneBotChannel) buildMessageSegments(chatID, content string) []oneBotMessageSegment { var segments []oneBotMessageSegment if lastMsgID, ok := c.lastMessageID.Load(chatID); ok { if msgID, ok := lastMsgID.(string); ok && msgID != "" { segments = append(segments, oneBotMessageSegment{ Type: "reply", Data: map[string]any{"id": msgID}, }) } } segments = append(segments, oneBotMessageSegment{ Type: "text", Data: map[string]any{"text": content}, }) return segments } func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, any, error) { chatID := msg.ChatID segments := c.buildMessageSegments(chatID, msg.Content) var action, idKey string var rawID string if rest, ok := strings.CutPrefix(chatID, "group:"); ok { action, idKey, rawID = "send_group_msg", "group_id", rest } else if rest, ok := strings.CutPrefix(chatID, "private:"); ok { action, idKey, rawID = "send_private_msg", "user_id", rest } else { action, idKey, rawID = "send_private_msg", "user_id", chatID } id, err := strconv.ParseInt(rawID, 10, 64) if err != nil { return "", nil, fmt.Errorf("invalid %s in chatID: %s", idKey, chatID) } return action, map[string]any{idKey: id, "message": segments}, nil } func (c *OneBotChannel) listen() { c.mu.Lock() conn := c.conn c.mu.Unlock() if conn == nil { logger.WarnC("onebot", "WebSocket connection is nil, listener exiting") return } for { select { case <-c.ctx.Done(): return default: _, message, err := conn.ReadMessage() if err != nil { logger.ErrorCF("onebot", "WebSocket read error", map[string]any{ "error": err.Error(), }) c.mu.Lock() if c.conn == conn { c.conn.Close() c.conn = nil } c.mu.Unlock() return } _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second)) var raw oneBotRawEvent if err := json.Unmarshal(message, &raw); err != nil { logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]any{ "error": err.Error(), "payload": string(message), }) continue } logger.DebugCF("onebot", "WebSocket event", map[string]any{ "length": len(message), "post_type": raw.PostType, "sub_type": raw.SubType, }) if raw.Echo != "" { c.pendingMu.Lock() ch, ok := c.pending[raw.Echo] c.pendingMu.Unlock() if ok { select { case ch <- message: default: } } else { logger.DebugCF("onebot", "Received API response (no waiter)", map[string]any{ "echo": raw.Echo, "status": string(raw.Status), }) } continue } if isAPIResponse(raw.Status) { logger.DebugCF("onebot", "Received API response without echo, skipping", map[string]any{ "status": string(raw.Status), }) continue } c.handleRawEvent(&raw) } } } func parseJSONInt64(raw json.RawMessage) (int64, error) { if len(raw) == 0 { return 0, nil } var n int64 if err := json.Unmarshal(raw, &n); err == nil { return n, nil } var s string if err := json.Unmarshal(raw, &s); err == nil { return strconv.ParseInt(s, 10, 64) } return 0, fmt.Errorf("cannot parse as int64: %s", string(raw)) } func parseJSONString(raw json.RawMessage) string { if len(raw) == 0 { return "" } var s string if err := json.Unmarshal(raw, &s); err == nil { return s } return string(raw) } type parseMessageResult struct { Text string IsBotMentioned bool Media []string ReplyTo string } func (c *OneBotChannel) parseMessageSegments( raw json.RawMessage, selfID int64, store media.MediaStore, scope string, ) parseMessageResult { if len(raw) == 0 { return parseMessageResult{} } var s string if err := json.Unmarshal(raw, &s); err == nil { mentioned := false if selfID > 0 { cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID) if strings.Contains(s, cqAt) { mentioned = true s = strings.ReplaceAll(s, cqAt, "") s = strings.TrimSpace(s) } } return parseMessageResult{Text: s, IsBotMentioned: mentioned} } var segments []map[string]any if err := json.Unmarshal(raw, &segments); err != nil { return parseMessageResult{} } var textParts []string mentioned := false selfIDStr := strconv.FormatInt(selfID, 10) var mediaRefs []string var replyTo string // Helper to register a local file with the media store storeFile := func(localPath, filename string) string { if store != nil { ref, err := store.Store(localPath, media.MediaMeta{ Filename: filename, Source: "onebot", }, scope) if err == nil { return ref } } return localPath // fallback } for _, seg := range segments { segType, _ := seg["type"].(string) data, _ := seg["data"].(map[string]any) switch segType { case "text": if data != nil { if t, ok := data["text"].(string); ok { textParts = append(textParts, t) } } case "at": if data != nil && selfID > 0 { qqVal := fmt.Sprintf("%v", data["qq"]) if qqVal == selfIDStr || qqVal == "all" { mentioned = true } } case "image", "video", "file": if data != nil { url, _ := data["url"].(string) if url != "" { defaults := map[string]string{"image": "image.jpg", "video": "video.mp4", "file": "file"} filename := defaults[segType] if f, ok := data["file"].(string); ok && f != "" { filename = f } else if n, ok := data["name"].(string); ok && n != "" { filename = n } localPath := utils.DownloadFile(url, filename, utils.DownloadOptions{ LoggerPrefix: "onebot", }) if localPath != "" { mediaRefs = append(mediaRefs, storeFile(localPath, filename)) textParts = append(textParts, fmt.Sprintf("[%s]", segType)) } } } case "record": if data != nil { url, _ := data["url"].(string) if url != "" { localPath := utils.DownloadFile(url, "voice.amr", utils.DownloadOptions{ LoggerPrefix: "onebot", }) if localPath != "" { textParts = append(textParts, "[voice]") mediaRefs = append(mediaRefs, storeFile(localPath, "voice.amr")) } } } case "reply": if data != nil { if id, ok := data["id"]; ok { replyTo = fmt.Sprintf("%v", id) } } case "face": if data != nil { faceID, _ := data["id"] textParts = append(textParts, fmt.Sprintf("[face:%v]", faceID)) } case "forward": textParts = append(textParts, "[forward message]") default: } } return parseMessageResult{ Text: strings.TrimSpace(strings.Join(textParts, "")), IsBotMentioned: mentioned, Media: mediaRefs, ReplyTo: replyTo, } } func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) { switch raw.PostType { case "message": if userID, err := parseJSONInt64(raw.UserID); err == nil && userID > 0 { // Build minimal sender for allowlist check sender := bus.SenderInfo{ Platform: "onebot", PlatformID: strconv.FormatInt(userID, 10), CanonicalID: identity.BuildCanonicalID("onebot", strconv.FormatInt(userID, 10)), } if !c.IsAllowedSender(sender) { logger.DebugCF("onebot", "Message rejected by allowlist", map[string]any{ "user_id": userID, }) return } } c.handleMessage(raw) case "message_sent": logger.DebugCF("onebot", "Bot sent message event", map[string]any{ "message_type": raw.MessageType, "message_id": parseJSONString(raw.MessageID), }) case "meta_event": c.handleMetaEvent(raw) case "notice": c.handleNoticeEvent(raw) case "request": logger.DebugCF("onebot", "Request event received", map[string]any{ "sub_type": raw.SubType, }) case "": logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]any{ "echo": raw.Echo, "status": raw.Status, }) default: logger.DebugCF("onebot", "Unknown post_type", map[string]any{ "post_type": raw.PostType, }) } } func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) { if raw.MetaEventType == "lifecycle" { logger.InfoCF("onebot", "Lifecycle event", map[string]any{"sub_type": raw.SubType}) } else if raw.MetaEventType != "heartbeat" { logger.DebugCF("onebot", "Meta event: "+raw.MetaEventType, nil) } } func (c *OneBotChannel) handleNoticeEvent(raw *oneBotRawEvent) { fields := map[string]any{ "notice_type": raw.NoticeType, "sub_type": raw.SubType, "group_id": parseJSONString(raw.GroupID), "user_id": parseJSONString(raw.UserID), "message_id": parseJSONString(raw.MessageID), } switch raw.NoticeType { case "group_recall", "group_increase", "group_decrease", "friend_add", "group_admin", "group_ban": logger.InfoCF("onebot", "Notice: "+raw.NoticeType, fields) default: logger.DebugCF("onebot", "Notice: "+raw.NoticeType, fields) } } func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { // Parse fields from raw event userID, err := parseJSONInt64(raw.UserID) if err != nil { logger.WarnCF("onebot", "Failed to parse user_id", map[string]any{ "error": err.Error(), "raw": string(raw.UserID), }) return } groupID, _ := parseJSONInt64(raw.GroupID) selfID, _ := parseJSONInt64(raw.SelfID) messageID := parseJSONString(raw.MessageID) if selfID == 0 { selfID = atomic.LoadInt64(&c.selfID) } // Compute scope for media store before parsing (parsing may download files) var chatIDForScope string switch raw.MessageType { case "group": chatIDForScope = "group:" + strconv.FormatInt(groupID, 10) default: chatIDForScope = "private:" + strconv.FormatInt(userID, 10) } scope := channels.BuildMediaScope("onebot", chatIDForScope, messageID) parsed := c.parseMessageSegments(raw.Message, selfID, c.GetMediaStore(), scope) isBotMentioned := parsed.IsBotMentioned content := raw.RawMessage if content == "" { content = parsed.Text } else if selfID > 0 { cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID) if strings.Contains(content, cqAt) { isBotMentioned = true content = strings.ReplaceAll(content, cqAt, "") content = strings.TrimSpace(content) } } if parsed.Text != "" && content != parsed.Text && (len(parsed.Media) > 0 || parsed.ReplyTo != "") { content = parsed.Text } var sender oneBotSender if len(raw.Sender) > 0 { if err := json.Unmarshal(raw.Sender, &sender); err != nil { logger.WarnCF("onebot", "Failed to parse sender", map[string]any{ "error": err.Error(), "sender": string(raw.Sender), }) } } if c.isDuplicate(messageID) { logger.DebugCF("onebot", "Duplicate message, skipping", map[string]any{ "message_id": messageID, }) return } if content == "" { logger.DebugCF("onebot", "Received empty message, ignoring", map[string]any{ "message_id": messageID, }) return } senderID := strconv.FormatInt(userID, 10) var chatID string var peer bus.Peer metadata := map[string]string{} if parsed.ReplyTo != "" { metadata["reply_to_message_id"] = parsed.ReplyTo } switch raw.MessageType { case "private": chatID = "private:" + senderID peer = bus.Peer{Kind: "direct", ID: senderID} case "group": groupIDStr := strconv.FormatInt(groupID, 10) chatID = "group:" + groupIDStr peer = bus.Peer{Kind: "group", ID: groupIDStr} metadata["group_id"] = groupIDStr senderUserID, _ := parseJSONInt64(sender.UserID) if senderUserID > 0 { metadata["sender_user_id"] = strconv.FormatInt(senderUserID, 10) } if sender.Card != "" { metadata["sender_name"] = sender.Card } else if sender.Nickname != "" { metadata["sender_name"] = sender.Nickname } respond, strippedContent := c.ShouldRespondInGroup(isBotMentioned, content) if !respond { logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]any{ "sender": senderID, "group": groupIDStr, "is_mentioned": isBotMentioned, "content": truncate(content, 100), }) return } content = strippedContent default: logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]any{ "type": raw.MessageType, "message_id": messageID, "user_id": userID, }) return } logger.InfoCF("onebot", "Received "+raw.MessageType+" message", map[string]any{ "sender": senderID, "chat_id": chatID, "message_id": messageID, "length": len(content), "content": truncate(content, 100), "media_count": len(parsed.Media), }) if sender.Nickname != "" { metadata["nickname"] = sender.Nickname } c.lastMessageID.Store(chatID, messageID) senderInfo := bus.SenderInfo{ Platform: "onebot", PlatformID: senderID, CanonicalID: identity.BuildCanonicalID("onebot", senderID), DisplayName: sender.Nickname, } if !c.IsAllowedSender(senderInfo) { logger.DebugCF("onebot", "Message rejected by allowlist (senderInfo)", map[string]any{ "sender": senderID, }) return } c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, parsed.Media, metadata, senderInfo) } func (c *OneBotChannel) isDuplicate(messageID string) bool { if messageID == "" || messageID == "0" { return false } c.mu.Lock() defer c.mu.Unlock() if _, exists := c.dedup[messageID]; exists { return true } if old := c.dedupRing[c.dedupIdx]; old != "" { delete(c.dedup, old) } c.dedupRing[c.dedupIdx] = messageID c.dedup[messageID] = struct{}{} c.dedupIdx = (c.dedupIdx + 1) % len(c.dedupRing) return false } func truncate(s string, n int) string { runes := []rune(s) if len(runes) <= n { return s } return string(runes[:n]) + "..." } ================================================ FILE: pkg/channels/pico/init.go ================================================ package pico import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) func init() { channels.RegisterFactory("pico", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { return NewPicoChannel(cfg.Channels.Pico, b) }) } ================================================ FILE: pkg/channels/pico/pico.go ================================================ package pico import ( "context" "encoding/json" "fmt" "net/http" "strings" "sync" "sync/atomic" "time" "github.com/google/uuid" "github.com/gorilla/websocket" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" ) // picoConn represents a single WebSocket connection. type picoConn struct { id string conn *websocket.Conn sessionID string writeMu sync.Mutex closed atomic.Bool } // writeJSON sends a JSON message to the connection with write locking. func (pc *picoConn) writeJSON(v any) error { if pc.closed.Load() { return fmt.Errorf("connection closed") } pc.writeMu.Lock() defer pc.writeMu.Unlock() return pc.conn.WriteJSON(v) } // close closes the connection. func (pc *picoConn) close() { if pc.closed.CompareAndSwap(false, true) { pc.conn.Close() } } // PicoChannel implements the native Pico Protocol WebSocket channel. // It serves as the reference implementation for all optional capability interfaces. type PicoChannel struct { *channels.BaseChannel config config.PicoConfig upgrader websocket.Upgrader connections sync.Map // connID → *picoConn connCount atomic.Int32 ctx context.Context cancel context.CancelFunc } // NewPicoChannel creates a new Pico Protocol channel. func NewPicoChannel(cfg config.PicoConfig, messageBus *bus.MessageBus) (*PicoChannel, error) { if cfg.Token == "" { return nil, fmt.Errorf("pico token is required") } base := channels.NewBaseChannel("pico", cfg, messageBus, cfg.AllowFrom) allowOrigins := cfg.AllowOrigins checkOrigin := func(r *http.Request) bool { if len(allowOrigins) == 0 { return true // allow all if not configured } origin := r.Header.Get("Origin") for _, allowed := range allowOrigins { if allowed == "*" || allowed == origin { return true } } return false } return &PicoChannel{ BaseChannel: base, config: cfg, upgrader: websocket.Upgrader{ CheckOrigin: checkOrigin, ReadBufferSize: 1024, WriteBufferSize: 1024, }, }, nil } // Start implements Channel. func (c *PicoChannel) Start(ctx context.Context) error { logger.InfoC("pico", "Starting Pico Protocol channel") c.ctx, c.cancel = context.WithCancel(ctx) c.SetRunning(true) logger.InfoC("pico", "Pico Protocol channel started") return nil } // Stop implements Channel. func (c *PicoChannel) Stop(ctx context.Context) error { logger.InfoC("pico", "Stopping Pico Protocol channel") c.SetRunning(false) // Close all connections c.connections.Range(func(key, value any) bool { if pc, ok := value.(*picoConn); ok { pc.close() } c.connections.Delete(key) return true }) if c.cancel != nil { c.cancel() } logger.InfoC("pico", "Pico Protocol channel stopped") return nil } // WebhookPath implements channels.WebhookHandler. func (c *PicoChannel) WebhookPath() string { return "/pico/" } // ServeHTTP implements http.Handler for the shared HTTP server. func (c *PicoChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/pico") switch { case path == "/ws" || path == "/ws/": c.handleWebSocket(w, r) default: http.NotFound(w, r) } } // Send implements Channel — sends a message to the appropriate WebSocket connection. func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } outMsg := newMessage(TypeMessageCreate, map[string]any{ "content": msg.Content, }) return c.broadcastToSession(msg.ChatID, outMsg) } // EditMessage implements channels.MessageEditor. func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { outMsg := newMessage(TypeMessageUpdate, map[string]any{ "message_id": messageID, "content": content, }) return c.broadcastToSession(chatID, outMsg) } // StartTyping implements channels.TypingCapable. func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { startMsg := newMessage(TypeTypingStart, nil) if err := c.broadcastToSession(chatID, startMsg); err != nil { return func() {}, err } return func() { stopMsg := newMessage(TypeTypingStop, nil) c.broadcastToSession(chatID, stopMsg) }, nil } // SendPlaceholder implements channels.PlaceholderCapable. // It sends a placeholder message via the Pico Protocol that will later be // edited to the actual response via EditMessage (channels.MessageEditor). func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { if !c.config.Placeholder.Enabled { return "", nil } text := c.config.Placeholder.Text if text == "" { text = "Thinking... 💭" } msgID := uuid.New().String() outMsg := newMessage(TypeMessageCreate, map[string]any{ "content": text, "message_id": msgID, }) if err := c.broadcastToSession(chatID, outMsg); err != nil { return "", err } return msgID, nil } // broadcastToSession sends a message to all connections with a matching session. func (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error { // chatID format: "pico:" sessionID := strings.TrimPrefix(chatID, "pico:") msg.SessionID = sessionID var sent bool c.connections.Range(func(key, value any) bool { pc, ok := value.(*picoConn) if !ok { return true } if pc.sessionID == sessionID { if err := pc.writeJSON(msg); err != nil { logger.DebugCF("pico", "Write to connection failed", map[string]any{ "conn_id": pc.id, "error": err.Error(), }) } else { sent = true } } return true }) if !sent { return fmt.Errorf("no active connections for session %s: %w", sessionID, channels.ErrSendFailed) } return nil } // handleWebSocket upgrades the HTTP connection and manages the WebSocket lifecycle. func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) { if !c.IsRunning() { http.Error(w, "channel not running", http.StatusServiceUnavailable) return } // Authenticate if !c.authenticate(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } // Check connection limit maxConns := c.config.MaxConnections if maxConns <= 0 { maxConns = 100 } if int(c.connCount.Load()) >= maxConns { http.Error(w, "too many connections", http.StatusServiceUnavailable) return } // Echo the matched subprotocol back so the browser accepts the upgrade. var responseHeader http.Header if proto := c.matchedSubprotocol(r); proto != "" { responseHeader = http.Header{"Sec-WebSocket-Protocol": {proto}} } conn, err := c.upgrader.Upgrade(w, r, responseHeader) if err != nil { logger.ErrorCF("pico", "WebSocket upgrade failed", map[string]any{ "error": err.Error(), }) return } // Determine session ID from query param or generate one sessionID := r.URL.Query().Get("session_id") if sessionID == "" { sessionID = uuid.New().String() } pc := &picoConn{ id: uuid.New().String(), conn: conn, sessionID: sessionID, } c.connections.Store(pc.id, pc) c.connCount.Add(1) logger.InfoCF("pico", "WebSocket client connected", map[string]any{ "conn_id": pc.id, "session_id": sessionID, }) go c.readLoop(pc) } // authenticate checks the request for a valid token: // 1. Authorization: Bearer header // 2. Sec-WebSocket-Protocol "token." (for browsers that can't set headers) // 3. Query parameter "token" (only when AllowTokenQuery is on) func (c *PicoChannel) authenticate(r *http.Request) bool { token := c.config.Token if token == "" { return false } // Check Authorization header auth := r.Header.Get("Authorization") if after, ok := strings.CutPrefix(auth, "Bearer "); ok { if after == token { return true } } // Check Sec-WebSocket-Protocol subprotocol ("token.") if c.matchedSubprotocol(r) != "" { return true } // Check query parameter only when explicitly allowed if c.config.AllowTokenQuery { if r.URL.Query().Get("token") == token { return true } } return false } // matchedSubprotocol returns the "token." subprotocol that matches // the configured token, or "" if none do. func (c *PicoChannel) matchedSubprotocol(r *http.Request) string { token := c.config.Token for _, proto := range websocket.Subprotocols(r) { if after, ok := strings.CutPrefix(proto, "token."); ok && after == token { return proto } } return "" } // readLoop reads messages from a WebSocket connection. func (c *PicoChannel) readLoop(pc *picoConn) { defer func() { pc.close() c.connections.Delete(pc.id) c.connCount.Add(-1) logger.InfoCF("pico", "WebSocket client disconnected", map[string]any{ "conn_id": pc.id, "session_id": pc.sessionID, }) }() readTimeout := time.Duration(c.config.ReadTimeout) * time.Second if readTimeout <= 0 { readTimeout = 60 * time.Second } _ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout)) pc.conn.SetPongHandler(func(appData string) error { _ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout)) return nil }) // Start ping ticker pingInterval := time.Duration(c.config.PingInterval) * time.Second if pingInterval <= 0 { pingInterval = 30 * time.Second } go c.pingLoop(pc, pingInterval) for { select { case <-c.ctx.Done(): return default: } _, rawMsg, err := pc.conn.ReadMessage() if err != nil { if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { logger.DebugCF("pico", "WebSocket read error", map[string]any{ "conn_id": pc.id, "error": err.Error(), }) } return } _ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout)) var msg PicoMessage if err := json.Unmarshal(rawMsg, &msg); err != nil { errMsg := newError("invalid_message", "failed to parse message") pc.writeJSON(errMsg) continue } c.handleMessage(pc, msg) } } // pingLoop sends periodic ping frames to keep the connection alive. func (c *PicoChannel) pingLoop(pc *picoConn, interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-c.ctx.Done(): return case <-ticker.C: if pc.closed.Load() { return } pc.writeMu.Lock() err := pc.conn.WriteMessage(websocket.PingMessage, nil) pc.writeMu.Unlock() if err != nil { return } } } } // handleMessage processes an inbound Pico Protocol message. func (c *PicoChannel) handleMessage(pc *picoConn, msg PicoMessage) { switch msg.Type { case TypePing: pong := newMessage(TypePong, nil) pong.ID = msg.ID pc.writeJSON(pong) case TypeMessageSend: c.handleMessageSend(pc, msg) default: errMsg := newError("unknown_type", fmt.Sprintf("unknown message type: %s", msg.Type)) pc.writeJSON(errMsg) } } // handleMessageSend processes an inbound message.send from a client. func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) { content, _ := msg.Payload["content"].(string) if strings.TrimSpace(content) == "" { errMsg := newError("empty_content", "message content is empty") pc.writeJSON(errMsg) return } sessionID := msg.SessionID if sessionID == "" { sessionID = pc.sessionID } chatID := "pico:" + sessionID senderID := "pico-user" peer := bus.Peer{Kind: "direct", ID: "pico:" + sessionID} metadata := map[string]string{ "platform": "pico", "session_id": sessionID, "conn_id": pc.id, } logger.DebugCF("pico", "Received message", map[string]any{ "session_id": sessionID, "preview": truncate(content, 50), }) sender := bus.SenderInfo{ Platform: "pico", PlatformID: senderID, CanonicalID: identity.BuildCanonicalID("pico", senderID), } if !c.IsAllowedSender(sender) { return } c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, nil, metadata, sender) } // truncate truncates a string to maxLen runes. func truncate(s string, maxLen int) string { runes := []rune(s) if len(runes) <= maxLen { return s } return string(runes[:maxLen]) + "..." } ================================================ FILE: pkg/channels/pico/protocol.go ================================================ package pico import "time" // Protocol message types. const ( // TypeMessageSend is sent from client to server. TypeMessageSend = "message.send" TypeMediaSend = "media.send" TypePing = "ping" // TypeMessageCreate is sent from server to client. TypeMessageCreate = "message.create" TypeMessageUpdate = "message.update" TypeMediaCreate = "media.create" TypeTypingStart = "typing.start" TypeTypingStop = "typing.stop" TypeError = "error" TypePong = "pong" ) // PicoMessage is the wire format for all Pico Protocol messages. type PicoMessage struct { Type string `json:"type"` ID string `json:"id,omitempty"` SessionID string `json:"session_id,omitempty"` Timestamp int64 `json:"timestamp,omitempty"` Payload map[string]any `json:"payload,omitempty"` } // newMessage creates a PicoMessage with the given type and payload. func newMessage(msgType string, payload map[string]any) PicoMessage { return PicoMessage{ Type: msgType, Timestamp: time.Now().UnixMilli(), Payload: payload, } } // newError creates an error PicoMessage. func newError(code, message string) PicoMessage { return newMessage(TypeError, map[string]any{ "code": code, "message": message, }) } ================================================ FILE: pkg/channels/qq/botgo_logger.go ================================================ package qq import ( "fmt" "strings" "github.com/sipeed/picoclaw/pkg/logger" ) // botGoLogger preserves useful SDK info logs while demoting noisy heartbeat // traffic to DEBUG so long-running QQ sessions do not spam the console. type botGoLogger struct { *logger.Logger } func newBotGoLogger(component string) *botGoLogger { return &botGoLogger{Logger: logger.NewLogger(component)} } func (b *botGoLogger) Info(v ...any) { message := fmt.Sprint(v...) if shouldDemoteBotGoInfo(message) { b.Logger.Debug(message) return } b.Logger.Info(message) } func (b *botGoLogger) Infof(format string, v ...any) { message := fmt.Sprintf(format, v...) if shouldDemoteBotGoInfo(message) { b.Logger.Debug(message) return } b.Logger.Info(message) } func shouldDemoteBotGoInfo(message string) bool { return strings.Contains(message, " write Heartbeat message") || strings.Contains(message, " receive HeartbeatAck message") } ================================================ FILE: pkg/channels/qq/init.go ================================================ package qq import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) func init() { channels.RegisterFactory("qq", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { return NewQQChannel(cfg.Channels.QQ, b) }) } ================================================ FILE: pkg/channels/qq/qq.go ================================================ package qq import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "net/http" "net/url" "os" "path" "path/filepath" "regexp" "strings" "sync" "sync/atomic" "time" "github.com/tencent-connect/botgo" "github.com/tencent-connect/botgo/constant" "github.com/tencent-connect/botgo/dto" "github.com/tencent-connect/botgo/event" "github.com/tencent-connect/botgo/openapi/options" "github.com/tencent-connect/botgo/token" "golang.org/x/oauth2" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" ) const ( dedupTTL = 5 * time.Minute dedupInterval = 60 * time.Second dedupMaxSize = 10000 // hard cap on dedup map entries typingResend = 8 * time.Second typingSeconds = 10 bytesPerMiB = 1024 * 1024 ) type qqAPI interface { WS(ctx context.Context, params map[string]string, body string) (*dto.WebsocketAP, error) PostGroupMessage( ctx context.Context, groupID string, msg dto.APIMessage, opt ...options.Option, ) (*dto.Message, error) PostC2CMessage( ctx context.Context, userID string, msg dto.APIMessage, opt ...options.Option, ) (*dto.Message, error) Transport(ctx context.Context, method, url string, body any) ([]byte, error) } type QQChannel struct { *channels.BaseChannel config config.QQConfig api qqAPI tokenSource oauth2.TokenSource ctx context.Context cancel context.CancelFunc sessionManager botgo.SessionManager downloadFn func(urlStr, filename string) string // Chat routing: track whether a chatID is group or direct. chatType sync.Map // chatID → "group" | "direct" // Passive reply: store last inbound message ID per chat. lastMsgID sync.Map // chatID → string // msg_seq: per-chat atomic counter for multi-part replies. msgSeqCounters sync.Map // chatID → *atomic.Uint64 // Time-based dedup replacing the unbounded map. dedup map[string]time.Time muDedup sync.Mutex // done is closed on Stop to shut down the dedup janitor. done chan struct{} stopOnce sync.Once } func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) { base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(cfg.MaxMessageLength), channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) return &QQChannel{ BaseChannel: base, config: cfg, dedup: make(map[string]time.Time), done: make(chan struct{}), }, nil } func (c *QQChannel) Start(ctx context.Context) error { if c.config.AppID == "" || c.config.AppSecret == "" { return fmt.Errorf("QQ app_id and app_secret not configured") } botgo.SetLogger(newBotGoLogger("botgo")) logger.InfoC("qq", "Starting QQ bot (WebSocket mode)") // Reinitialize shutdown signal for clean restart. c.done = make(chan struct{}) c.stopOnce = sync.Once{} // create token source credentials := &token.QQBotCredentials{ AppID: c.config.AppID, AppSecret: c.config.AppSecret, } c.tokenSource = token.NewQQBotTokenSource(credentials) // create child context c.ctx, c.cancel = context.WithCancel(ctx) // start auto-refresh token goroutine if err := token.StartRefreshAccessToken(c.ctx, c.tokenSource); err != nil { return fmt.Errorf("failed to start token refresh: %w", err) } // initialize OpenAPI client c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second) // register event handlers intent := event.RegisterHandlers( c.handleC2CMessage(), c.handleGroupATMessage(), ) // get WebSocket endpoint wsInfo, err := c.api.WS(c.ctx, nil, "") if err != nil { return fmt.Errorf("failed to get websocket info: %w", err) } logger.InfoCF("qq", "Got WebSocket info", map[string]any{ "shards": wsInfo.Shards, }) // create and save sessionManager c.sessionManager = botgo.NewSessionManager() // start WebSocket connection in goroutine to avoid blocking go func() { if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil { logger.ErrorCF("qq", "WebSocket session error", map[string]any{ "error": err.Error(), }) c.SetRunning(false) } }() // start dedup janitor goroutine go c.dedupJanitor() // Pre-register reasoning_channel_id as group chat if configured, // so outbound-only destinations are routed correctly. if c.config.ReasoningChannelID != "" { c.chatType.Store(c.config.ReasoningChannelID, "group") } c.SetRunning(true) logger.InfoC("qq", "QQ bot started successfully") return nil } func (c *QQChannel) Stop(ctx context.Context) error { logger.InfoC("qq", "Stopping QQ bot") c.SetRunning(false) // Signal the dedup janitor to stop (idempotent). c.stopOnce.Do(func() { close(c.done) }) if c.cancel != nil { c.cancel() } return nil } // getChatKind returns the chat type for a given chatID ("group" or "direct"). // Unknown chatIDs default to "group" and log a warning, since QQ group IDs are // more common as outbound-only destinations (e.g. reasoning_channel_id). func (c *QQChannel) getChatKind(chatID string) string { if v, ok := c.chatType.Load(chatID); ok { if k, ok := v.(string); ok { return k } } logger.DebugCF("qq", "Unknown chat type for chatID, defaulting to group", map[string]any{ "chat_id": chatID, }) return "group" } func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } chatKind := c.getChatKind(msg.ChatID) // Build message with content. msgToCreate := &dto.MessageToCreate{ Content: msg.Content, MsgType: dto.TextMsg, } // Use Markdown message type if enabled in config. if c.config.SendMarkdown { msgToCreate.MsgType = dto.MarkdownMsg msgToCreate.Markdown = &dto.Markdown{ Content: msg.Content, } // Clear plain content to avoid sending duplicate text. msgToCreate.Content = "" } c.applyPassiveReplyMetadata(msg.ChatID, msgToCreate) // Sanitize URLs in group messages to avoid QQ's URL blacklist rejection. if chatKind == "group" { if msgToCreate.Content != "" { msgToCreate.Content = sanitizeURLs(msgToCreate.Content) } if msgToCreate.Markdown != nil && msgToCreate.Markdown.Content != "" { msgToCreate.Markdown.Content = sanitizeURLs(msgToCreate.Markdown.Content) } } // Route to group or C2C. var err error if chatKind == "group" { _, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate) } else { _, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) } if err != nil { logger.ErrorCF("qq", "Failed to send message", map[string]any{ "chat_id": msg.ChatID, "chat_kind": chatKind, "error": err.Error(), }) return fmt.Errorf("qq send: %w", channels.ErrTemporary) } return nil } // StartTyping implements channels.TypingCapable. // It sends an InputNotify (msg_type=6) immediately and re-sends every 8 seconds. // The returned stop function is idempotent and cancels the goroutine. func (c *QQChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { // We need a stored msg_id for passive InputNotify; skip if none available. v, ok := c.lastMsgID.Load(chatID) if !ok { return func() {}, nil } msgID, ok := v.(string) if !ok || msgID == "" { return func() {}, nil } chatKind := c.getChatKind(chatID) sendTyping := func(sendCtx context.Context) { typingMsg := &dto.MessageToCreate{ MsgType: dto.InputNotifyMsg, MsgID: msgID, InputNotify: &dto.InputNotify{ InputType: 1, InputSecond: typingSeconds, }, } var err error if chatKind == "group" { _, err = c.api.PostGroupMessage(sendCtx, chatID, typingMsg) } else { _, err = c.api.PostC2CMessage(sendCtx, chatID, typingMsg) } if err != nil { logger.DebugCF("qq", "Failed to send typing indicator", map[string]any{ "chat_id": chatID, "error": err.Error(), }) } } // Send immediately. sendTyping(c.ctx) typingCtx, cancel := context.WithCancel(c.ctx) go func() { ticker := time.NewTicker(typingResend) defer ticker.Stop() for { select { case <-typingCtx.Done(): return case <-ticker.C: sendTyping(typingCtx) } } }() return cancel, nil } // SendMedia implements the channels.MediaSender interface. // QQ group/C2C media sending is a two-step flow: // 1. Upload media to /files using a remote URL or base64-encoded local bytes. // 2. Send a msg_type=7 message using the returned file_info. func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } chatKind := c.getChatKind(msg.ChatID) for _, part := range msg.Parts { fileInfo, err := c.uploadMedia(ctx, chatKind, msg.ChatID, part) if err != nil { logger.ErrorCF("qq", "Failed to upload media", map[string]any{ "type": part.Type, "chat_id": msg.ChatID, "error": err.Error(), }) if errors.Is(err, channels.ErrSendFailed) { return err } return fmt.Errorf("qq send media: %w", channels.ErrTemporary) } if err := c.sendUploadedMedia(ctx, chatKind, msg.ChatID, part, fileInfo); err != nil { logger.ErrorCF("qq", "Failed to send media", map[string]any{ "type": part.Type, "chat_id": msg.ChatID, "error": err.Error(), }) return fmt.Errorf("qq send media: %w", channels.ErrTemporary) } } return nil } type qqMediaUpload struct { FileType uint64 `json:"file_type"` URL string `json:"url,omitempty"` FileData string `json:"file_data,omitempty"` SrvSendMsg bool `json:"srv_send_msg,omitempty"` } func (c *QQChannel) uploadMedia( ctx context.Context, chatKind, chatID string, part bus.MediaPart, ) ([]byte, error) { payload, err := c.buildMediaUpload(part) if err != nil { return nil, err } body, err := c.api.Transport(ctx, http.MethodPost, c.mediaUploadURL(chatKind, chatID), payload) if err != nil { return nil, err } var uploaded dto.Message if err := json.Unmarshal(body, &uploaded); err != nil { return nil, fmt.Errorf("qq decode media upload response: %w", err) } if len(uploaded.FileInfo) == 0 { return nil, fmt.Errorf("qq upload media: missing file_info") } return uploaded.FileInfo, nil } func (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error) { payload := &qqMediaUpload{ FileType: qqFileType(part.Type), } mediaRef := part.Ref if isHTTPURL(mediaRef) { payload.URL = mediaRef return payload, nil } store := c.GetMediaStore() if store == nil { return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } resolved, err := store.Resolve(part.Ref) if err != nil { return nil, fmt.Errorf("qq resolve media ref %q: %v: %w", part.Ref, err, channels.ErrSendFailed) } if isHTTPURL(resolved) { payload.URL = resolved return payload, nil } if limitBytes := c.maxBase64FileSizeBytes(); limitBytes > 0 { info, statErr := os.Stat(resolved) if statErr != nil { return nil, fmt.Errorf("qq stat local media %q: %v: %w", resolved, statErr, channels.ErrSendFailed) } if info.Size() > limitBytes { return nil, fmt.Errorf( "qq local media %q exceeds max_base64_file_size_mib (%d > %d bytes): %w", resolved, info.Size(), limitBytes, channels.ErrSendFailed, ) } } data, err := os.ReadFile(resolved) if err != nil { return nil, fmt.Errorf("qq read local media %q: %v: %w", resolved, err, channels.ErrSendFailed) } payload.FileData = base64.StdEncoding.EncodeToString(data) return payload, nil } func (c *QQChannel) sendUploadedMedia( ctx context.Context, chatKind, chatID string, part bus.MediaPart, fileInfo []byte, ) error { msg := &dto.MessageToCreate{ Content: part.Caption, MsgType: dto.RichMediaMsg, Media: &dto.MediaInfo{ FileInfo: fileInfo, }, } c.applyPassiveReplyMetadata(chatID, msg) if chatKind == "group" && msg.Content != "" { msg.Content = sanitizeURLs(msg.Content) } if chatKind == "group" { _, err := c.api.PostGroupMessage(ctx, chatID, msg) return err } _, err := c.api.PostC2CMessage(ctx, chatID, msg) return err } func (c *QQChannel) applyPassiveReplyMetadata(chatID string, msg *dto.MessageToCreate) { if v, ok := c.lastMsgID.Load(chatID); ok { if msgID, ok := v.(string); ok && msgID != "" { msg.MsgID = msgID // Increment msg_seq atomically for multi-part replies. if counterVal, ok := c.msgSeqCounters.Load(chatID); ok { if counter, ok := counterVal.(*atomic.Uint64); ok { seq := counter.Add(1) msg.MsgSeq = uint32(seq) } } } } } func (c *QQChannel) mediaUploadURL(chatKind, chatID string) string { base := constant.APIDomain if chatKind == "group" { return fmt.Sprintf("%s/v2/groups/%s/files", base, chatID) } return fmt.Sprintf("%s/v2/users/%s/files", base, chatID) } func qqFileType(partType string) uint64 { switch partType { case "image": return 1 case "video": return 2 case "audio": return 3 default: return 4 } } func (c *QQChannel) maxBase64FileSizeBytes() int64 { if c.config.MaxBase64FileSizeMiB <= 0 { return 0 } return c.config.MaxBase64FileSizeMiB * bytesPerMiB } // handleC2CMessage handles QQ private messages. func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error { // deduplication check if c.isDuplicate(data.ID) { return nil } // extract user info var senderID string if data.Author != nil && data.Author.ID != "" { senderID = data.Author.ID } else { logger.WarnC("qq", "Received message with no sender ID") return nil } sender := bus.SenderInfo{ Platform: "qq", PlatformID: data.Author.ID, CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID), } if !c.IsAllowedSender(sender) { return nil } content := strings.TrimSpace(data.Content) mediaPaths, attachmentNotes := c.extractInboundAttachments(senderID, data.ID, data.Attachments) for _, note := range attachmentNotes { content = appendContent(content, note) } if content == "" && len(mediaPaths) == 0 { logger.DebugC("qq", "Received empty C2C message with no attachments, ignoring") return nil } logger.InfoCF("qq", "Received C2C message", map[string]any{ "sender": senderID, "length": len(content), "media_count": len(mediaPaths), }) // Store chat routing context. c.chatType.Store(senderID, "direct") c.lastMsgID.Store(senderID, data.ID) // Reset msg_seq counter for new inbound message. c.msgSeqCounters.Store(senderID, new(atomic.Uint64)) metadata := map[string]string{ "account_id": senderID, } c.HandleMessage(c.ctx, bus.Peer{Kind: "direct", ID: senderID}, data.ID, senderID, senderID, content, mediaPaths, metadata, sender, ) return nil } } // handleGroupATMessage handles QQ group @ messages. func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error { // deduplication check if c.isDuplicate(data.ID) { return nil } // extract user info var senderID string if data.Author != nil && data.Author.ID != "" { senderID = data.Author.ID } else { logger.WarnC("qq", "Received group message with no sender ID") return nil } sender := bus.SenderInfo{ Platform: "qq", PlatformID: data.Author.ID, CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID), } if !c.IsAllowedSender(sender) { return nil } content := strings.TrimSpace(data.Content) mediaPaths, attachmentNotes := c.extractInboundAttachments(data.GroupID, data.ID, data.Attachments) for _, note := range attachmentNotes { content = appendContent(content, note) } // GroupAT event means bot is always mentioned; apply group trigger filtering. respond, cleaned := c.ShouldRespondInGroup(true, content) if !respond { return nil } content = cleaned if content == "" && len(mediaPaths) == 0 { logger.DebugC("qq", "Received empty group message with no attachments, ignoring") return nil } logger.InfoCF("qq", "Received group AT message", map[string]any{ "sender": senderID, "group": data.GroupID, "length": len(content), "media_count": len(mediaPaths), }) // Store chat routing context using GroupID as chatID. c.chatType.Store(data.GroupID, "group") c.lastMsgID.Store(data.GroupID, data.ID) // Reset msg_seq counter for new inbound message. c.msgSeqCounters.Store(data.GroupID, new(atomic.Uint64)) metadata := map[string]string{ "account_id": senderID, "group_id": data.GroupID, } c.HandleMessage(c.ctx, bus.Peer{Kind: "group", ID: data.GroupID}, data.ID, senderID, data.GroupID, content, mediaPaths, metadata, sender, ) return nil } } func (c *QQChannel) extractInboundAttachments( chatID, messageID string, attachments []*dto.MessageAttachment, ) ([]string, []string) { if len(attachments) == 0 { return nil, nil } scope := channels.BuildMediaScope("qq", chatID, messageID) mediaPaths := make([]string, 0, len(attachments)) notes := make([]string, 0, len(attachments)) storeMedia := func(localPath string, attachment *dto.MessageAttachment) string { if store := c.GetMediaStore(); store != nil { ref, err := store.Store(localPath, media.MediaMeta{ Filename: qqAttachmentFilename(attachment), ContentType: attachment.ContentType, Source: "qq", }, scope) if err == nil { return ref } } return localPath } for _, attachment := range attachments { if attachment == nil { continue } filename := qqAttachmentFilename(attachment) if localPath := c.downloadAttachment(attachment.URL, filename); localPath != "" { mediaPaths = append(mediaPaths, storeMedia(localPath, attachment)) } else if attachment.URL != "" { mediaPaths = append(mediaPaths, attachment.URL) } notes = append(notes, qqAttachmentNote(attachment)) } return mediaPaths, notes } func (c *QQChannel) downloadAttachment(urlStr, filename string) string { if urlStr == "" { return "" } if c.downloadFn != nil { return c.downloadFn(urlStr, filename) } return utils.DownloadFile(urlStr, filename, utils.DownloadOptions{ LoggerPrefix: "qq", ExtraHeaders: c.downloadHeaders(), }) } func (c *QQChannel) downloadHeaders() map[string]string { headers := map[string]string{} if c.config.AppID != "" { headers["X-Union-Appid"] = c.config.AppID } if c.tokenSource != nil { if tk, err := c.tokenSource.Token(); err == nil && tk.AccessToken != "" { auth := strings.TrimSpace(tk.TokenType + " " + tk.AccessToken) if auth != "" { headers["Authorization"] = auth } } } if len(headers) == 0 { return nil } return headers } func qqAttachmentFilename(attachment *dto.MessageAttachment) string { if attachment == nil { return "attachment" } if attachment.FileName != "" { return attachment.FileName } if attachment.URL != "" { if parsed, err := url.Parse(attachment.URL); err == nil { if base := path.Base(parsed.Path); base != "" && base != "." && base != "/" { return base } } } switch qqAttachmentKind(attachment) { case "image": return "image" case "audio": return "audio" case "video": return "video" default: return "attachment" } } func qqAttachmentKind(attachment *dto.MessageAttachment) string { if attachment == nil { return "file" } contentType := strings.ToLower(attachment.ContentType) filename := strings.ToLower(attachment.FileName) switch { case strings.HasPrefix(contentType, "image/"): return "image" case strings.HasPrefix(contentType, "video/"): return "video" case strings.HasPrefix(contentType, "audio/"), contentType == "application/ogg", contentType == "application/x-ogg": return "audio" } switch filepath.Ext(filename) { case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg": return "image" case ".mp4", ".avi", ".mov", ".webm", ".mkv": return "video" case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus", ".silk": return "audio" default: return "file" } } func qqAttachmentNote(attachment *dto.MessageAttachment) string { filename := qqAttachmentFilename(attachment) switch qqAttachmentKind(attachment) { case "image": return fmt.Sprintf("[image: %s]", filename) case "audio": return fmt.Sprintf("[audio: %s]", filename) case "video": return fmt.Sprintf("[video: %s]", filename) default: return fmt.Sprintf("[file: %s]", filename) } } // isDuplicate checks whether a message has been seen within the TTL window. // It also enforces a hard cap on map size by evicting oldest entries. func (c *QQChannel) isDuplicate(messageID string) bool { c.muDedup.Lock() defer c.muDedup.Unlock() if ts, exists := c.dedup[messageID]; exists && time.Since(ts) < dedupTTL { return true } // Enforce hard cap: evict oldest entries when at capacity. if len(c.dedup) >= dedupMaxSize { var oldestID string var oldestTS time.Time for id, ts := range c.dedup { if oldestID == "" || ts.Before(oldestTS) { oldestID = id oldestTS = ts } } if oldestID != "" { delete(c.dedup, oldestID) } } c.dedup[messageID] = time.Now() return false } // dedupJanitor periodically evicts expired entries from the dedup map. func (c *QQChannel) dedupJanitor() { ticker := time.NewTicker(dedupInterval) defer ticker.Stop() for { select { case <-c.done: return case <-ticker.C: // Collect expired keys under read-like scan. c.muDedup.Lock() now := time.Now() var expired []string for id, ts := range c.dedup { if now.Sub(ts) >= dedupTTL { expired = append(expired, id) } } for _, id := range expired { delete(c.dedup, id) } c.muDedup.Unlock() } } } // isHTTPURL returns true if s starts with http:// or https://. func isHTTPURL(s string) bool { return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") } func appendContent(content, suffix string) string { if suffix == "" { return content } if content == "" { return suffix } return content + "\n" + suffix } // urlPattern matches URLs with explicit http(s):// scheme. // Only scheme-prefixed URLs are matched to avoid false positives on bare text // like version numbers (e.g., "1.2.3") or domain-like fragments. var urlPattern = regexp.MustCompile( `(?i)` + `https?://` + // required scheme `(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+` + // domain parts `[a-zA-Z]{2,}` + // TLD `(?:[/?#]\S*)?`, // optional path/query/fragment ) // sanitizeURLs replaces dots in URL domains with "。" (fullwidth period) // to prevent QQ's URL blacklist from rejecting the message. func sanitizeURLs(text string) string { return urlPattern.ReplaceAllStringFunc(text, func(match string) string { // Split into scheme + rest (scheme is always present). idx := strings.Index(match, "://") scheme := match[:idx+3] rest := match[idx+3:] // Find where the domain ends (first / ? or #). domainEnd := len(rest) for i, ch := range rest { if ch == '/' || ch == '?' || ch == '#' { domainEnd = i break } } domain := rest[:domainEnd] path := rest[domainEnd:] // Replace dots in domain only. domain = strings.ReplaceAll(domain, ".", "。") return scheme + domain + path }) } ================================================ FILE: pkg/channels/qq/qq_test.go ================================================ package qq import ( "context" "encoding/base64" "encoding/json" "errors" "os" "strings" "sync/atomic" "testing" "time" "github.com/tencent-connect/botgo/dto" "github.com/tencent-connect/botgo/openapi/options" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/media" ) func TestHandleC2CMessage_IncludesAccountIDMetadata(t *testing.T) { messageBus := bus.NewMessageBus() ch := &QQChannel{ BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), dedup: make(map[string]time.Time), done: make(chan struct{}), ctx: context.Background(), } err := ch.handleC2CMessage()(nil, &dto.WSC2CMessageData{ ID: "msg-1", Content: "hello", Author: &dto.User{ ID: "7750283E123456", }, }) if err != nil { t.Fatalf("handleC2CMessage() error = %v", err) } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() for { select { case <-ctx.Done(): t.Fatal("timeout waiting for inbound message") return case inbound, ok := <-messageBus.InboundChan(): if !ok { t.Fatal("expected inbound message") } if inbound.Metadata["account_id"] != "7750283E123456" { t.Fatalf("account_id metadata = %q, want %q", inbound.Metadata["account_id"], "7750283E123456") } return } } } func TestHandleC2CMessage_AttachmentOnlyPublishesMedia(t *testing.T) { messageBus := bus.NewMessageBus() store := media.NewFileMediaStore() localPath := writeTempFile(t, t.TempDir(), "image.png", []byte("fake-image")) ch := &QQChannel{ BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), dedup: make(map[string]time.Time), done: make(chan struct{}), ctx: context.Background(), downloadFn: func(urlStr, filename string) string { if filename != "image.png" { t.Fatalf("download filename = %q, want image.png", filename) } return localPath }, } ch.SetMediaStore(store) err := ch.handleC2CMessage()(nil, &dto.WSC2CMessageData{ ID: "msg-attachment", Content: "", Author: &dto.User{ ID: "7750283E123456", }, Attachments: []*dto.MessageAttachment{{ URL: "https://example.com/image.png", FileName: "image.png", ContentType: "image/png", }}, }) if err != nil { t.Fatalf("handleC2CMessage() error = %v", err) } inbound := waitInboundMessage(t, messageBus) if inbound.Content != "[image: image.png]" { t.Fatalf("inbound.Content = %q", inbound.Content) } if len(inbound.Media) != 1 { t.Fatalf("len(inbound.Media) = %d, want 1", len(inbound.Media)) } if !strings.HasPrefix(inbound.Media[0], "media://") { t.Fatalf("inbound.Media[0] = %q, want media:// ref", inbound.Media[0]) } _, meta, err := store.ResolveWithMeta(inbound.Media[0]) if err != nil { t.Fatalf("ResolveWithMeta() error = %v", err) } if meta.Filename != "image.png" { t.Fatalf("meta.Filename = %q, want image.png", meta.Filename) } if meta.ContentType != "image/png" { t.Fatalf("meta.ContentType = %q, want image/png", meta.ContentType) } } func TestHandleGroupATMessage_AttachmentOnlyPublishesMedia(t *testing.T) { messageBus := bus.NewMessageBus() store := media.NewFileMediaStore() localPath := writeTempFile(t, t.TempDir(), "report.pdf", []byte("fake-pdf")) ch := &QQChannel{ BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), dedup: make(map[string]time.Time), done: make(chan struct{}), ctx: context.Background(), downloadFn: func(urlStr, filename string) string { if filename != "report.pdf" { t.Fatalf("download filename = %q, want report.pdf", filename) } return localPath }, } ch.SetMediaStore(store) err := ch.handleGroupATMessage()(nil, &dto.WSGroupATMessageData{ ID: "group-attachment", GroupID: "group-1", Content: "", Author: &dto.User{ ID: "7750283E123456", }, Attachments: []*dto.MessageAttachment{{ URL: "https://example.com/report.pdf", FileName: "report.pdf", ContentType: "application/pdf", }}, }) if err != nil { t.Fatalf("handleGroupATMessage() error = %v", err) } inbound := waitInboundMessage(t, messageBus) if inbound.Content != "[file: report.pdf]" { t.Fatalf("inbound.Content = %q", inbound.Content) } if len(inbound.Media) != 1 { t.Fatalf("len(inbound.Media) = %d, want 1", len(inbound.Media)) } if !strings.HasPrefix(inbound.Media[0], "media://") { t.Fatalf("inbound.Media[0] = %q, want media:// ref", inbound.Media[0]) } if inbound.Peer.Kind != "group" || inbound.Peer.ID != "group-1" { t.Fatalf("inbound.Peer = %+v, want group/group-1", inbound.Peer) } } func TestSendMedia_UploadsLocalFileAsBase64(t *testing.T) { messageBus := bus.NewMessageBus() store := media.NewFileMediaStore() tmpFile, err := os.CreateTemp(t.TempDir(), "qq-media-*.png") if err != nil { t.Fatalf("CreateTemp() error = %v", err) } defer tmpFile.Close() content := []byte("local-image-data") if _, writeErr := tmpFile.Write(content); writeErr != nil { t.Fatalf("Write() error = %v", writeErr) } ref, err := store.Store(tmpFile.Name(), media.MediaMeta{ Filename: "reply.png", ContentType: "image/png", }, "qq:test") if err != nil { t.Fatalf("Store() error = %v", err) } api := &fakeQQAPI{ transportResp: mustJSON(t, dto.Message{FileInfo: []byte("uploaded-file-info")}), } ch := &QQChannel{ BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), api: api, dedup: make(map[string]time.Time), done: make(chan struct{}), ctx: context.Background(), } ch.SetRunning(true) ch.SetMediaStore(store) ch.chatType.Store("group-1", "group") ch.lastMsgID.Store("group-1", "msg-1") ch.msgSeqCounters.Store("group-1", new(atomic.Uint64)) err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "group-1", Parts: []bus.MediaPart{{ Type: "image", Ref: ref, Caption: "see https://example.com/image", }}, }) if err != nil { t.Fatalf("SendMedia() error = %v", err) } if len(api.transportCalls) != 1 { t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls)) } upload := api.transportCalls[0] if upload.method != "POST" { t.Fatalf("upload method = %q, want POST", upload.method) } if upload.url != "https://api.sgroup.qq.com/v2/groups/group-1/files" { t.Fatalf("upload url = %q", upload.url) } if upload.body.URL != "" { t.Fatalf("upload URL = %q, want empty", upload.body.URL) } wantBase64 := base64.StdEncoding.EncodeToString(content) if upload.body.FileData != wantBase64 { t.Fatalf("upload file_data = %q, want %q", upload.body.FileData, wantBase64) } if upload.body.FileType != 1 { t.Fatalf("upload file_type = %d, want 1", upload.body.FileType) } if len(api.groupMessages) != 1 { t.Fatalf("groupMessages = %d, want 1", len(api.groupMessages)) } msg, ok := api.groupMessages[0].(*dto.MessageToCreate) if !ok { t.Fatalf("groupMessages[0] type = %T, want *dto.MessageToCreate", api.groupMessages[0]) } if msg.MsgType != dto.RichMediaMsg { t.Fatalf("msg.MsgType = %d, want %d", msg.MsgType, dto.RichMediaMsg) } if msg.MsgID != "msg-1" { t.Fatalf("msg.MsgID = %q, want msg-1", msg.MsgID) } if msg.MsgSeq != 1 { t.Fatalf("msg.MsgSeq = %d, want 1", msg.MsgSeq) } if msg.Content != "see https://example。com/image" { t.Fatalf("msg.Content = %q", msg.Content) } if msg.Media == nil || string(msg.Media.FileInfo) != "uploaded-file-info" { t.Fatalf("msg.Media.FileInfo = %q, want uploaded-file-info", string(msg.Media.FileInfo)) } } func TestSendMedia_UsesRemoteURLUploadForC2C(t *testing.T) { messageBus := bus.NewMessageBus() api := &fakeQQAPI{ transportResp: mustJSON(t, dto.Message{FileInfo: []byte("remote-file-info")}), } ch := &QQChannel{ BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), api: api, dedup: make(map[string]time.Time), done: make(chan struct{}), ctx: context.Background(), } ch.SetRunning(true) ch.chatType.Store("user-1", "direct") err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "user-1", Parts: []bus.MediaPart{{ Type: "file", Ref: "https://cdn.example.com/report.pdf", }}, }) if err != nil { t.Fatalf("SendMedia() error = %v", err) } if len(api.transportCalls) != 1 { t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls)) } upload := api.transportCalls[0] if upload.url != "https://api.sgroup.qq.com/v2/users/user-1/files" { t.Fatalf("upload url = %q", upload.url) } if upload.body.URL != "https://cdn.example.com/report.pdf" { t.Fatalf("upload URL = %q", upload.body.URL) } if upload.body.FileData != "" { t.Fatalf("upload file_data = %q, want empty", upload.body.FileData) } if upload.body.FileType != 4 { t.Fatalf("upload file_type = %d, want 4", upload.body.FileType) } if len(api.c2cMessages) != 1 { t.Fatalf("c2cMessages = %d, want 1", len(api.c2cMessages)) } msg, ok := api.c2cMessages[0].(*dto.MessageToCreate) if !ok { t.Fatalf("c2cMessages[0] type = %T, want *dto.MessageToCreate", api.c2cMessages[0]) } if msg.MsgType != dto.RichMediaMsg { t.Fatalf("msg.MsgType = %d, want %d", msg.MsgType, dto.RichMediaMsg) } if msg.Media == nil || string(msg.Media.FileInfo) != "remote-file-info" { t.Fatalf("msg.Media.FileInfo = %q, want remote-file-info", string(msg.Media.FileInfo)) } } func TestSendMedia_ReturnsSendFailedWithoutMediaStore(t *testing.T) { messageBus := bus.NewMessageBus() ch := &QQChannel{ BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), api: &fakeQQAPI{}, dedup: make(map[string]time.Time), done: make(chan struct{}), ctx: context.Background(), } ch.SetRunning(true) ch.chatType.Store("group-1", "group") err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "group-1", Parts: []bus.MediaPart{{ Type: "image", Ref: "media://missing", }}, }) if !errors.Is(err, channels.ErrSendFailed) { t.Fatalf("SendMedia() error = %v, want ErrSendFailed", err) } } func TestSendMedia_ReturnsSendFailedWhenLocalFileExceedsBase64MiBLimit(t *testing.T) { messageBus := bus.NewMessageBus() store := media.NewFileMediaStore() tmpFile, err := os.CreateTemp(t.TempDir(), "qq-media-too-large-*.bin") if err != nil { t.Fatalf("CreateTemp() error = %v", err) } defer tmpFile.Close() content := make([]byte, bytesPerMiB+1) if _, writeErr := tmpFile.Write(content); writeErr != nil { t.Fatalf("Write() error = %v", writeErr) } ref, err := store.Store(tmpFile.Name(), media.MediaMeta{ Filename: "large.bin", ContentType: "application/octet-stream", }, "qq:test") if err != nil { t.Fatalf("Store() error = %v", err) } api := &fakeQQAPI{} ch := &QQChannel{ BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), config: config.QQConfig{ MaxBase64FileSizeMiB: 1, }, api: api, dedup: make(map[string]time.Time), done: make(chan struct{}), ctx: context.Background(), } ch.SetRunning(true) ch.SetMediaStore(store) ch.chatType.Store("group-1", "group") err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "group-1", Parts: []bus.MediaPart{{ Type: "file", Ref: ref, }}, }) if !errors.Is(err, channels.ErrSendFailed) { t.Fatalf("SendMedia() error = %v, want ErrSendFailed", err) } if len(api.transportCalls) != 0 { t.Fatalf("transportCalls = %d, want 0", len(api.transportCalls)) } } type fakeQQAPI struct { transportResp []byte transportErr error groupErr error c2cErr error transportCalls []fakeTransportCall groupMessages []dto.APIMessage c2cMessages []dto.APIMessage } type fakeTransportCall struct { method string url string body qqMediaUpload } func (f *fakeQQAPI) WS( context.Context, map[string]string, string, ) (*dto.WebsocketAP, error) { return nil, nil } func (f *fakeQQAPI) PostGroupMessage( _ context.Context, _ string, msg dto.APIMessage, _ ...options.Option, ) (*dto.Message, error) { f.groupMessages = append(f.groupMessages, msg) return &dto.Message{}, f.groupErr } func (f *fakeQQAPI) PostC2CMessage( _ context.Context, _ string, msg dto.APIMessage, _ ...options.Option, ) (*dto.Message, error) { f.c2cMessages = append(f.c2cMessages, msg) return &dto.Message{}, f.c2cErr } func (f *fakeQQAPI) Transport(_ context.Context, method, url string, body any) ([]byte, error) { upload, ok := body.(*qqMediaUpload) if !ok { return nil, errors.New("unexpected transport body type") } f.transportCalls = append(f.transportCalls, fakeTransportCall{ method: method, url: url, body: *upload, }) return f.transportResp, f.transportErr } func mustJSON(t *testing.T, v any) []byte { t.Helper() b, err := json.Marshal(v) if err != nil { t.Fatalf("json.Marshal() error = %v", err) } return b } func waitInboundMessage(t *testing.T, messageBus *bus.MessageBus) bus.InboundMessage { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() for { select { case <-ctx.Done(): t.Fatal("timeout waiting for inbound message") case inbound, ok := <-messageBus.InboundChan(): if !ok { t.Fatal("expected inbound message") } return inbound } } } func writeTempFile(t *testing.T, dir, name string, content []byte) string { t.Helper() path := dir + "/" + name if err := os.WriteFile(path, content, 0o600); err != nil { t.Fatalf("WriteFile() error = %v", err) } return path } ================================================ FILE: pkg/channels/registry.go ================================================ package channels import ( "sync" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" ) // ChannelFactory is a constructor function that creates a Channel from config and message bus. // Each channel subpackage registers one or more factories via init(). type ChannelFactory func(cfg *config.Config, bus *bus.MessageBus) (Channel, error) var ( factoriesMu sync.RWMutex factories = map[string]ChannelFactory{} ) // RegisterFactory registers a named channel factory. Called from subpackage init() functions. func RegisterFactory(name string, f ChannelFactory) { factoriesMu.Lock() defer factoriesMu.Unlock() factories[name] = f } // getFactory looks up a channel factory by name. func getFactory(name string) (ChannelFactory, bool) { factoriesMu.RLock() defer factoriesMu.RUnlock() f, ok := factories[name] return f, ok } ================================================ FILE: pkg/channels/slack/init.go ================================================ package slack import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) func init() { channels.RegisterFactory("slack", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { return NewSlackChannel(cfg.Channels.Slack, b) }) } ================================================ FILE: pkg/channels/slack/slack.go ================================================ package slack import ( "context" "fmt" "strings" "sync" "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" "github.com/slack-go/slack/socketmode" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" ) type SlackChannel struct { *channels.BaseChannel config config.SlackConfig api *slack.Client socketClient *socketmode.Client botUserID string teamID string ctx context.Context cancel context.CancelFunc pendingAcks sync.Map } type slackMessageRef struct { ChannelID string Timestamp string } func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*SlackChannel, error) { if cfg.BotToken == "" || cfg.AppToken == "" { return nil, fmt.Errorf("slack bot_token and app_token are required") } api := slack.New( cfg.BotToken, slack.OptionAppLevelToken(cfg.AppToken), ) socketClient := socketmode.New(api) base := channels.NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(40000), channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) return &SlackChannel{ BaseChannel: base, config: cfg, api: api, socketClient: socketClient, }, nil } func (c *SlackChannel) Start(ctx context.Context) error { logger.InfoC("slack", "Starting Slack channel (Socket Mode)") c.ctx, c.cancel = context.WithCancel(ctx) authResp, err := c.api.AuthTest() if err != nil { return fmt.Errorf("slack auth test failed: %w", err) } c.botUserID = authResp.UserID c.teamID = authResp.TeamID logger.InfoCF("slack", "Slack bot connected", map[string]any{ "bot_user_id": c.botUserID, "team": authResp.Team, }) go c.eventLoop() go func() { if err := c.socketClient.RunContext(c.ctx); err != nil { if c.ctx.Err() == nil { logger.ErrorCF("slack", "Socket Mode connection error", map[string]any{ "error": err.Error(), }) } } }() c.SetRunning(true) logger.InfoC("slack", "Slack channel started (Socket Mode)") return nil } func (c *SlackChannel) Stop(ctx context.Context) error { logger.InfoC("slack", "Stopping Slack channel") if c.cancel != nil { c.cancel() } c.SetRunning(false) logger.InfoC("slack", "Slack channel stopped") return nil } func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } channelID, threadTS := parseSlackChatID(msg.ChatID) if channelID == "" { return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) } opts := []slack.MsgOption{ slack.MsgOptionText(msg.Content, false), } if msg.ReplyToMessageID != "" && threadTS == "" { // Answer to the message by creating a Thread under it opts = append(opts, slack.MsgOptionTS(msg.ReplyToMessageID)) } else if threadTS != "" { // If we are already in a thread, continue in the thread opts = append(opts, slack.MsgOptionTS(threadTS)) } _, _, err := c.api.PostMessageContext(ctx, channelID, opts...) if err != nil { return fmt.Errorf("slack send: %w", channels.ErrTemporary) } if ref, ok := c.pendingAcks.LoadAndDelete(msg.ChatID); ok { msgRef := ref.(slackMessageRef) c.api.AddReaction("white_check_mark", slack.ItemRef{ Channel: msgRef.ChannelID, Timestamp: msgRef.Timestamp, }) } logger.DebugCF("slack", "Message sent", map[string]any{ "channel_id": channelID, "thread_ts": threadTS, }) return nil } // SendMedia implements the channels.MediaSender interface. func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } channelID, _ := parseSlackChatID(msg.ChatID) if channelID == "" { return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) } store := c.GetMediaStore() if store == nil { return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } for _, part := range msg.Parts { localPath, err := store.Resolve(part.Ref) if err != nil { logger.ErrorCF("slack", "Failed to resolve media ref", map[string]any{ "ref": part.Ref, "error": err.Error(), }) continue } filename := part.Filename if filename == "" { filename = "file" } title := part.Caption if title == "" { title = filename } _, err = c.api.UploadFileV2Context(ctx, slack.UploadFileV2Parameters{ Channel: channelID, File: localPath, Filename: filename, Title: title, }) if err != nil { logger.ErrorCF("slack", "Failed to upload media", map[string]any{ "filename": filename, "error": err.Error(), }) return fmt.Errorf("slack send media: %w", channels.ErrTemporary) } } return nil } // ReactToMessage implements channels.ReactionCapable. // It adds an "eyes" (👀) reaction to the inbound message and returns an undo function // that removes the reaction. func (c *SlackChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) { channelID, _ := parseSlackChatID(chatID) if channelID == "" { return func() {}, nil } c.api.AddReaction("eyes", slack.ItemRef{ Channel: channelID, Timestamp: messageID, }) return func() { c.api.RemoveReaction("eyes", slack.ItemRef{ Channel: channelID, Timestamp: messageID, }) }, nil } func (c *SlackChannel) eventLoop() { for { select { case <-c.ctx.Done(): return case event, ok := <-c.socketClient.Events: if !ok { return } switch event.Type { case socketmode.EventTypeEventsAPI: c.handleEventsAPI(event) case socketmode.EventTypeSlashCommand: c.handleSlashCommand(event) case socketmode.EventTypeInteractive: if event.Request != nil { c.socketClient.Ack(*event.Request) } } } } } func (c *SlackChannel) handleEventsAPI(event socketmode.Event) { if event.Request != nil { c.socketClient.Ack(*event.Request) } eventsAPIEvent, ok := event.Data.(slackevents.EventsAPIEvent) if !ok { return } switch ev := eventsAPIEvent.InnerEvent.Data.(type) { case *slackevents.MessageEvent: c.handleMessageEvent(ev) case *slackevents.AppMentionEvent: c.handleAppMention(ev) } } func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { if ev.User == c.botUserID || ev.User == "" { return } if ev.BotID != "" { return } if ev.SubType != "" && ev.SubType != "file_share" { return } // check allowlist to avoid downloading attachments for rejected users sender := bus.SenderInfo{ Platform: "slack", PlatformID: ev.User, CanonicalID: identity.BuildCanonicalID("slack", ev.User), } if !c.IsAllowedSender(sender) { logger.DebugCF("slack", "Message rejected by allowlist", map[string]any{ "user_id": ev.User, }) return } senderID := ev.User channelID := ev.Channel threadTS := ev.ThreadTimeStamp messageTS := ev.TimeStamp chatID := channelID if threadTS != "" { chatID = channelID + "/" + threadTS } c.pendingAcks.Store(chatID, slackMessageRef{ ChannelID: channelID, Timestamp: messageTS, }) content := ev.Text content = c.stripBotMention(content) // In non-DM channels, apply group trigger filtering if !strings.HasPrefix(channelID, "D") { respond, cleaned := c.ShouldRespondInGroup(false, content) if !respond { return } content = cleaned } var mediaPaths []string scope := channels.BuildMediaScope("slack", chatID, messageTS) // Helper to register a local file with the media store storeMedia := func(localPath, filename string) string { if store := c.GetMediaStore(); store != nil { ref, err := store.Store(localPath, media.MediaMeta{ Filename: filename, Source: "slack", }, scope) if err == nil { return ref } } return localPath // fallback } if ev.Message != nil && len(ev.Message.Files) > 0 { for _, file := range ev.Message.Files { localPath := c.downloadSlackFile(file) if localPath == "" { continue } mediaPaths = append(mediaPaths, storeMedia(localPath, file.Name)) content += fmt.Sprintf("\n[file: %s]", file.Name) } } if strings.TrimSpace(content) == "" { return } peerKind := "channel" peerID := channelID if strings.HasPrefix(channelID, "D") { peerKind = "direct" peerID = senderID } peer := bus.Peer{Kind: peerKind, ID: peerID} metadata := map[string]string{ "message_ts": messageTS, "channel_id": channelID, "thread_ts": threadTS, "platform": "slack", "team_id": c.teamID, } logger.DebugCF("slack", "Received message", map[string]any{ "sender_id": senderID, "chat_id": chatID, "preview": utils.Truncate(content, 50), "has_thread": threadTS != "", }) c.HandleMessage(c.ctx, peer, messageTS, senderID, chatID, content, mediaPaths, metadata, sender) } func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { if ev.User == c.botUserID { return } if !c.IsAllowedSender(bus.SenderInfo{ Platform: "slack", PlatformID: ev.User, CanonicalID: identity.BuildCanonicalID("slack", ev.User), }) { logger.DebugCF("slack", "Mention rejected by allowlist", map[string]any{ "user_id": ev.User, }) return } senderID := ev.User mentionSender := bus.SenderInfo{ Platform: "slack", PlatformID: senderID, CanonicalID: identity.BuildCanonicalID("slack", senderID), } channelID := ev.Channel threadTS := ev.ThreadTimeStamp messageTS := ev.TimeStamp var chatID string if threadTS != "" { chatID = channelID + "/" + threadTS } else { chatID = channelID + "/" + messageTS } c.pendingAcks.Store(chatID, slackMessageRef{ ChannelID: channelID, Timestamp: messageTS, }) content := c.stripBotMention(ev.Text) if strings.TrimSpace(content) == "" { return } mentionPeerKind := "channel" mentionPeerID := channelID if strings.HasPrefix(channelID, "D") { mentionPeerKind = "direct" mentionPeerID = senderID } mentionPeer := bus.Peer{Kind: mentionPeerKind, ID: mentionPeerID} metadata := map[string]string{ "message_ts": messageTS, "channel_id": channelID, "thread_ts": threadTS, "platform": "slack", "is_mention": "true", "team_id": c.teamID, } c.HandleMessage(c.ctx, mentionPeer, messageTS, senderID, chatID, content, nil, metadata, mentionSender) } func (c *SlackChannel) handleSlashCommand(event socketmode.Event) { cmd, ok := event.Data.(slack.SlashCommand) if !ok { return } if event.Request != nil { c.socketClient.Ack(*event.Request) } cmdSender := bus.SenderInfo{ Platform: "slack", PlatformID: cmd.UserID, CanonicalID: identity.BuildCanonicalID("slack", cmd.UserID), } if !c.IsAllowedSender(cmdSender) { logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]any{ "user_id": cmd.UserID, }) return } senderID := cmd.UserID channelID := cmd.ChannelID chatID := channelID content := cmd.Text if strings.TrimSpace(content) == "" { content = "help" } metadata := map[string]string{ "channel_id": channelID, "platform": "slack", "is_command": "true", "trigger_id": cmd.TriggerID, "team_id": c.teamID, } logger.DebugCF("slack", "Slash command received", map[string]any{ "sender_id": senderID, "command": cmd.Command, "text": utils.Truncate(content, 50), }) c.HandleMessage( c.ctx, bus.Peer{Kind: "channel", ID: channelID}, "", senderID, chatID, content, nil, metadata, cmdSender, ) } func (c *SlackChannel) downloadSlackFile(file slack.File) string { downloadURL := file.URLPrivateDownload if downloadURL == "" { downloadURL = file.URLPrivate } if downloadURL == "" { logger.ErrorCF("slack", "No download URL for file", map[string]any{"file_id": file.ID}) return "" } return utils.DownloadFile(downloadURL, file.Name, utils.DownloadOptions{ LoggerPrefix: "slack", ExtraHeaders: map[string]string{ "Authorization": "Bearer " + c.config.BotToken, }, }) } func (c *SlackChannel) stripBotMention(text string) string { mention := fmt.Sprintf("<@%s>", c.botUserID) text = strings.ReplaceAll(text, mention, "") return strings.TrimSpace(text) } func parseSlackChatID(chatID string) (channelID, threadTS string) { parts := strings.SplitN(chatID, "/", 2) channelID = parts[0] if len(parts) > 1 { threadTS = parts[1] } return channelID, threadTS } ================================================ FILE: pkg/channels/slack/slack_test.go ================================================ package slack import ( "testing" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" ) func TestParseSlackChatID(t *testing.T) { tests := []struct { name string chatID string wantChanID string wantThread string }{ { name: "channel only", chatID: "C123456", wantChanID: "C123456", wantThread: "", }, { name: "channel with thread", chatID: "C123456/1234567890.123456", wantChanID: "C123456", wantThread: "1234567890.123456", }, { name: "DM channel", chatID: "D987654", wantChanID: "D987654", wantThread: "", }, { name: "empty string", chatID: "", wantChanID: "", wantThread: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { chanID, threadTS := parseSlackChatID(tt.chatID) if chanID != tt.wantChanID { t.Errorf("parseSlackChatID(%q) channelID = %q, want %q", tt.chatID, chanID, tt.wantChanID) } if threadTS != tt.wantThread { t.Errorf("parseSlackChatID(%q) threadTS = %q, want %q", tt.chatID, threadTS, tt.wantThread) } }) } } func TestStripBotMention(t *testing.T) { ch := &SlackChannel{botUserID: "U12345BOT"} tests := []struct { name string input string want string }{ { name: "mention at start", input: "<@U12345BOT> hello there", want: "hello there", }, { name: "mention in middle", input: "hey <@U12345BOT> can you help", want: "hey can you help", }, { name: "no mention", input: "hello world", want: "hello world", }, { name: "empty string", input: "", want: "", }, { name: "only mention", input: "<@U12345BOT>", want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := ch.stripBotMention(tt.input) if got != tt.want { t.Errorf("stripBotMention(%q) = %q, want %q", tt.input, got, tt.want) } }) } } func TestNewSlackChannel(t *testing.T) { msgBus := bus.NewMessageBus() t.Run("missing bot token", func(t *testing.T) { cfg := config.SlackConfig{ BotToken: "", AppToken: "xapp-test", } _, err := NewSlackChannel(cfg, msgBus) if err == nil { t.Error("expected error for missing bot_token, got nil") } }) t.Run("missing app token", func(t *testing.T) { cfg := config.SlackConfig{ BotToken: "xoxb-test", AppToken: "", } _, err := NewSlackChannel(cfg, msgBus) if err == nil { t.Error("expected error for missing app_token, got nil") } }) t.Run("valid config", func(t *testing.T) { cfg := config.SlackConfig{ BotToken: "xoxb-test", AppToken: "xapp-test", AllowFrom: []string{"U123"}, } ch, err := NewSlackChannel(cfg, msgBus) if err != nil { t.Fatalf("unexpected error: %v", err) } if ch.Name() != "slack" { t.Errorf("Name() = %q, want %q", ch.Name(), "slack") } if ch.IsRunning() { t.Error("new channel should not be running") } }) } func TestSlackChannelIsAllowed(t *testing.T) { msgBus := bus.NewMessageBus() t.Run("empty allowlist allows all", func(t *testing.T) { cfg := config.SlackConfig{ BotToken: "xoxb-test", AppToken: "xapp-test", AllowFrom: []string{}, } ch, _ := NewSlackChannel(cfg, msgBus) if !ch.IsAllowed("U_ANYONE") { t.Error("empty allowlist should allow all users") } }) t.Run("allowlist restricts users", func(t *testing.T) { cfg := config.SlackConfig{ BotToken: "xoxb-test", AppToken: "xapp-test", AllowFrom: []string{"U_ALLOWED"}, } ch, _ := NewSlackChannel(cfg, msgBus) if !ch.IsAllowed("U_ALLOWED") { t.Error("allowed user should pass allowlist check") } if ch.IsAllowed("U_BLOCKED") { t.Error("non-allowed user should be blocked") } }) } ================================================ FILE: pkg/channels/split.go ================================================ package channels import ( "strings" ) // SplitMessage splits long messages into chunks, preserving code block integrity. // The maxLen parameter is measured in runes (Unicode characters), not bytes. // The function reserves a buffer (10% of maxLen, min 50) to leave room for closing code blocks, // but may extend to maxLen when needed. // Call SplitMessage with the full text content and the maximum allowed length of a single message; // it returns a slice of message chunks that each respect maxLen and avoid splitting fenced code blocks. func SplitMessage(content string, maxLen int) []string { if maxLen <= 0 { if content == "" { return nil } return []string{content} } runes := []rune(content) totalLen := len(runes) var messages []string // Dynamic buffer: 10% of maxLen, but at least 50 chars if possible codeBlockBuffer := max(maxLen/10, 50) if codeBlockBuffer > maxLen/2 { codeBlockBuffer = maxLen / 2 } start := 0 for start < totalLen { remaining := totalLen - start if remaining <= maxLen { messages = append(messages, string(runes[start:totalLen])) break } // Effective split point: maxLen minus buffer, to leave room for code blocks effectiveLimit := max(maxLen-codeBlockBuffer, maxLen/2) end := start + effectiveLimit // Find natural split point within the effective limit msgEnd := findLastNewlineInRange(runes, start, end, 200) if msgEnd <= start { msgEnd = findLastSpaceInRange(runes, start, end, 100) } if msgEnd <= start { msgEnd = end } // Check if this would end with an incomplete code block unclosedIdx := findLastUnclosedCodeBlockInRange(runes, start, msgEnd) if unclosedIdx >= 0 { // Message would end with incomplete code block // Try to extend up to maxLen to include the closing ``` if totalLen > msgEnd { closingIdx := findNextClosingCodeBlockInRange(runes, msgEnd, totalLen) if closingIdx > 0 && closingIdx-start <= maxLen { // Extend to include the closing ``` msgEnd = closingIdx } else { // Code block is too long to fit in one chunk or missing closing fence. // Try to split inside by injecting closing and reopening fences. headerEnd := findNewlineFrom(runes, unclosedIdx) var header string if headerEnd == -1 { header = strings.TrimSpace(string(runes[unclosedIdx : unclosedIdx+3])) } else { header = strings.TrimSpace(string(runes[unclosedIdx:headerEnd])) } headerEndIdx := unclosedIdx + len([]rune(header)) if headerEnd != -1 { headerEndIdx = headerEnd } // If we have a reasonable amount of content after the header, split inside if msgEnd > headerEndIdx+20 { // Find a better split point closer to maxLen innerLimit := min( // Leave room for "\n```" start+maxLen-5, totalLen) betterEnd := findLastNewlineInRange(runes, start, innerLimit, 200) if betterEnd > headerEndIdx { msgEnd = betterEnd } else { msgEnd = innerLimit } chunk := strings.TrimRight(string(runes[start:msgEnd]), " \t\n\r") + "\n```" messages = append(messages, chunk) remaining := strings.TrimSpace(header + "\n" + string(runes[msgEnd:totalLen])) // Replace the tail of runes with the reconstructed remaining runes = []rune(remaining) totalLen = len(runes) start = 0 continue } // Otherwise, try to split before the code block starts newEnd := findLastNewlineInRange(runes, start, unclosedIdx, 200) if newEnd <= start { newEnd = findLastSpaceInRange(runes, start, unclosedIdx, 100) } if newEnd > start { msgEnd = newEnd } else { // If we can't split before, we MUST split inside (last resort) if unclosedIdx-start > 20 { msgEnd = unclosedIdx } else { splitAt := min(start+maxLen-5, totalLen) chunk := strings.TrimRight(string(runes[start:splitAt]), " \t\n\r") + "\n```" messages = append(messages, chunk) remaining := strings.TrimSpace(header + "\n" + string(runes[splitAt:totalLen])) runes = []rune(remaining) totalLen = len(runes) start = 0 continue } } } } } if msgEnd <= start { msgEnd = start + effectiveLimit } messages = append(messages, string(runes[start:msgEnd])) // Advance start, skipping leading whitespace of next chunk start = msgEnd for start < totalLen && (runes[start] == ' ' || runes[start] == '\t' || runes[start] == '\n' || runes[start] == '\r') { start++ } } return messages } // findLastUnclosedCodeBlockInRange finds the last opening ``` that doesn't have a closing ``` // within runes[start:end]. Returns the absolute rune index or -1. func findLastUnclosedCodeBlockInRange(runes []rune, start, end int) int { inCodeBlock := false lastOpenIdx := -1 for i := start; i < end; i++ { if i+2 < end && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' { if !inCodeBlock { lastOpenIdx = i } inCodeBlock = !inCodeBlock i += 2 } } if inCodeBlock { return lastOpenIdx } return -1 } // findNextClosingCodeBlockInRange finds the next closing ``` starting from startIdx // within runes[startIdx:end]. Returns the absolute index after the closing ``` or -1. func findNextClosingCodeBlockInRange(runes []rune, startIdx, end int) int { for i := startIdx; i < end; i++ { if i+2 < end && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' { return i + 3 } } return -1 } // findNewlineFrom finds the first newline character starting from the given index. // Returns the absolute index or -1 if not found. func findNewlineFrom(runes []rune, from int) int { for i := from; i < len(runes); i++ { if runes[i] == '\n' { return i } } return -1 } // findLastNewlineInRange finds the last newline within the last searchWindow runes // of the range runes[start:end]. Returns the absolute index or start-1 (indicating not found). func findLastNewlineInRange(runes []rune, start, end, searchWindow int) int { searchStart := max(end-searchWindow, start) for i := end - 1; i >= searchStart; i-- { if runes[i] == '\n' { return i } } return start - 1 } // findLastSpaceInRange finds the last space/tab within the last searchWindow runes // of the range runes[start:end]. Returns the absolute index or start-1 (indicating not found). func findLastSpaceInRange(runes []rune, start, end, searchWindow int) int { searchStart := max(end-searchWindow, start) for i := end - 1; i >= searchStart; i-- { if runes[i] == ' ' || runes[i] == '\t' { return i } } return start - 1 } ================================================ FILE: pkg/channels/split_test.go ================================================ package channels import ( "strings" "testing" ) func TestSplitMessage(t *testing.T) { longText := strings.Repeat("a", 2500) longCode := "```go\n" + strings.Repeat("fmt.Println(\"hello\")\n", 100) + "```" // ~2100 chars tests := []struct { name string content string maxLen int expectChunks int // Check number of chunks checkContent func(t *testing.T, chunks []string) // Custom validation }{ { name: "Empty message", content: "", maxLen: 2000, expectChunks: 0, }, { name: "Short message fits in one chunk", content: "Hello world", maxLen: 2000, expectChunks: 1, }, { name: "Simple split regular text", content: longText, maxLen: 2000, expectChunks: 2, checkContent: func(t *testing.T, chunks []string) { if len([]rune(chunks[0])) > 2000 { t.Errorf("Chunk 0 too large: %d runes", len([]rune(chunks[0]))) } if len([]rune(chunks[0]))+len([]rune(chunks[1])) != len([]rune(longText)) { t.Errorf( "Total rune length mismatch. Got %d, want %d", len([]rune(chunks[0]))+len([]rune(chunks[1])), len([]rune(longText)), ) } }, }, { name: "Split at newline", // 1750 chars then newline, then more chars. // Dynamic buffer: 2000 / 10 = 200. // Effective limit: 2000 - 200 = 1800. // Split should happen at newline because it's at 1750 (< 1800). // Total length must > 2000 to trigger split. 1750 + 1 + 300 = 2051. content: strings.Repeat("a", 1750) + "\n" + strings.Repeat("b", 300), maxLen: 2000, expectChunks: 2, checkContent: func(t *testing.T, chunks []string) { if len([]rune(chunks[0])) != 1750 { t.Errorf("Expected chunk 0 to be 1750 runes (split at newline), got %d", len([]rune(chunks[0]))) } if chunks[1] != strings.Repeat("b", 300) { t.Errorf("Chunk 1 content mismatch. Len: %d", len([]rune(chunks[1]))) } }, }, { name: "Long code block split", content: "Prefix\n" + longCode, maxLen: 2000, expectChunks: 2, checkContent: func(t *testing.T, chunks []string) { // Check that first chunk ends with closing fence if !strings.HasSuffix(chunks[0], "\n```") { t.Error("First chunk should end with injected closing fence") } // Check that second chunk starts with execution header if !strings.HasPrefix(chunks[1], "```go") { t.Error("Second chunk should start with injected code block header") } }, }, { name: "Preserve Unicode characters (rune-aware)", content: strings.Repeat("\u4e16", 2500), // 2500 runes, 7500 bytes maxLen: 2000, expectChunks: 2, checkContent: func(t *testing.T, chunks []string) { // Verify chunks contain valid unicode and don't split mid-rune for i, chunk := range chunks { runeCount := len([]rune(chunk)) if runeCount > 2000 { t.Errorf("Chunk %d has %d runes, exceeds maxLen 2000", i, runeCount) } if !strings.Contains(chunk, "\u4e16") { t.Errorf("Chunk %d should contain unicode characters", i) } } // Verify total rune count is preserved totalRunes := 0 for _, chunk := range chunks { totalRunes += len([]rune(chunk)) } if totalRunes != 2500 { t.Errorf("Total rune count mismatch. Got %d, want 2500", totalRunes) } }, }, { name: "Zero maxLen returns single chunk", content: "Hello world", maxLen: 0, expectChunks: 1, checkContent: func(t *testing.T, chunks []string) { if chunks[0] != "Hello world" { t.Errorf("Expected original content, got %q", chunks[0]) } }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := SplitMessage(tc.content, tc.maxLen) if tc.expectChunks == 0 { if len(got) != 0 { t.Errorf("Expected 0 chunks, got %d", len(got)) } return } if len(got) != tc.expectChunks { t.Errorf("Expected %d chunks, got %d", tc.expectChunks, len(got)) // Log sizes for debugging for i, c := range got { t.Logf("Chunk %d length: %d", i, len(c)) } return // Stop further checks if count assumes specific split } if tc.checkContent != nil { tc.checkContent(t, got) } }) } } // --- Helper function tests for index-based rune operations --- func TestFindLastNewlineInRange(t *testing.T) { runes := []rune("aaa\nbbb\nccc") // Indices: 0123 4567 89 10 tests := []struct { name string start, end int searchWindow int want int }{ {"finds last newline in full range", 0, 11, 200, 7}, {"finds newline within search window", 0, 11, 4, 7}, {"narrow window misses newline outside window", 4, 11, 3, 3}, // returns start-1 (not found) {"no newline in range", 0, 3, 200, -1}, // start-1 = -1 {"range limited to first segment", 0, 4, 200, 3}, {"search window of 1 at newline", 0, 8, 1, 7}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := findLastNewlineInRange(runes, tc.start, tc.end, tc.searchWindow) if got != tc.want { t.Errorf("findLastNewlineInRange(runes, %d, %d, %d) = %d, want %d", tc.start, tc.end, tc.searchWindow, got, tc.want) } }) } } func TestFindLastSpaceInRange(t *testing.T) { runes := []rune("abc def\tghi") // Indices: 0123 4567 89 10 tests := []struct { name string start, end int searchWindow int want int }{ {"finds tab as last space/tab", 0, 11, 200, 7}, {"finds space when tab out of window", 0, 7, 200, 3}, {"no space in range", 0, 3, 200, -1}, {"narrow window finds tab", 5, 11, 4, 7}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := findLastSpaceInRange(runes, tc.start, tc.end, tc.searchWindow) if got != tc.want { t.Errorf("findLastSpaceInRange(runes, %d, %d, %d) = %d, want %d", tc.start, tc.end, tc.searchWindow, got, tc.want) } }) } } func TestFindNewlineFrom(t *testing.T) { runes := []rune("hello\nworld\n") tests := []struct { name string from int want int }{ {"from start", 0, 5}, {"from after first newline", 6, 11}, {"from past all newlines", 12, -1}, {"from newline itself", 5, 5}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := findNewlineFrom(runes, tc.from) if got != tc.want { t.Errorf("findNewlineFrom(runes, %d) = %d, want %d", tc.from, got, tc.want) } }) } } func TestFindLastUnclosedCodeBlockInRange(t *testing.T) { tests := []struct { name string content string start, end int want int }{ { name: "no code blocks", content: "hello world", start: 0, end: 11, want: -1, }, { name: "complete code block", content: "```go\ncode\n```", start: 0, end: 14, want: -1, }, { name: "unclosed code block", content: "text\n```go\ncode here", start: 0, end: 20, want: 5, }, { name: "closed then unclosed", content: "```a\n```\n```b\ncode", start: 0, end: 17, want: 9, }, { name: "search within subrange", content: "```a\n```\n```b\ncode", start: 9, end: 17, want: 9, }, { name: "subrange with no code blocks", content: "```a\n```\nhello", start: 9, end: 14, want: -1, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { runes := []rune(tc.content) got := findLastUnclosedCodeBlockInRange(runes, tc.start, tc.end) if got != tc.want { t.Errorf("findLastUnclosedCodeBlockInRange(%q, %d, %d) = %d, want %d", tc.content, tc.start, tc.end, got, tc.want) } }) } } func TestFindNextClosingCodeBlockInRange(t *testing.T) { tests := []struct { name string content string startIdx int end int want int }{ { name: "finds closing fence", content: "code\n```\nmore", startIdx: 0, end: 13, want: 8, // position after ``` }, { name: "no closing fence", content: "just code here", startIdx: 0, end: 14, want: -1, }, { name: "fence at start of search", content: "```end", startIdx: 0, end: 6, want: 3, }, { name: "fence outside range", content: "code\n```", startIdx: 0, end: 4, want: -1, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { runes := []rune(tc.content) got := findNextClosingCodeBlockInRange(runes, tc.startIdx, tc.end) if got != tc.want { t.Errorf("findNextClosingCodeBlockInRange(%q, %d, %d) = %d, want %d", tc.content, tc.startIdx, tc.end, got, tc.want) } }) } } func TestSplitMessage_CodeBlockIntegrity(t *testing.T) { // Focused test for the core requirement: splitting inside a code block preserves syntax highlighting // 60 chars total approximately content := "```go\npackage main\n\nfunc main() {\n\tprintln(\"Hello\")\n}\n```" maxLen := 40 chunks := SplitMessage(content, maxLen) if len(chunks) != 2 { t.Fatalf("Expected 2 chunks, got %d: %q", len(chunks), chunks) } // First chunk must end with "\n```" if !strings.HasSuffix(chunks[0], "\n```") { t.Errorf("First chunk should end with closing fence. Got: %q", chunks[0]) } // Second chunk must start with the header "```go" if !strings.HasPrefix(chunks[1], "```go") { t.Errorf("Second chunk should start with code block header. Got: %q", chunks[1]) } // First chunk should contain meaningful content if len([]rune(chunks[0])) > 40 { t.Errorf("First chunk exceeded maxLen: length %d runes", len([]rune(chunks[0]))) } } ================================================ FILE: pkg/channels/telegram/command_registration.go ================================================ package telegram import ( "context" "math/rand" "slices" "time" "github.com/mymmrac/telego" "github.com/sipeed/picoclaw/pkg/commands" "github.com/sipeed/picoclaw/pkg/logger" ) var commandRegistrationBackoff = []time.Duration{ 5 * time.Second, 15 * time.Second, 60 * time.Second, 5 * time.Minute, 10 * time.Minute, } func commandRegistrationDelay(attempt int) time.Duration { if len(commandRegistrationBackoff) == 0 { return 0 } base := commandRegistrationBackoff[min(attempt, len(commandRegistrationBackoff)-1)] // Full jitter in [0.5, 1.0) to avoid synchronized retries across instances. return time.Duration(float64(base) * (0.5 + rand.Float64()*0.5)) } // RegisterCommands registers bot commands on Telegram platform. func (c *TelegramChannel) RegisterCommands(ctx context.Context, defs []commands.Definition) error { botCommands := make([]telego.BotCommand, 0, len(defs)) for _, def := range defs { if def.Name == "" || def.Description == "" { continue } botCommands = append(botCommands, telego.BotCommand{ Command: def.Name, Description: def.Description, }) } current, err := c.bot.GetMyCommands(ctx, &telego.GetMyCommandsParams{}) if err != nil { // If we can't read current commands, fall through to set them. logger.WarnCF("telegram", "Failed to get current commands, will set unconditionally", map[string]any{"error": err.Error()}) } else if slices.Equal(current, botCommands) { logger.DebugCF("telegram", "Bot commands are up to date", nil) return nil } return c.bot.SetMyCommands(ctx, &telego.SetMyCommandsParams{ Commands: botCommands, }) } func (c *TelegramChannel) startCommandRegistration(ctx context.Context, defs []commands.Definition) { if len(defs) == 0 { return } register := c.registerFunc if register == nil { register = c.RegisterCommands } regCtx, cancel := context.WithCancel(ctx) c.commandRegCancel = cancel // Registration runs asynchronously so Telegram message intake is never blocked // by temporary upstream API failures. Retry stops on success or channel shutdown. go func() { attempt := 0 timer := time.NewTimer(0) if !timer.Stop() { select { case <-timer.C: default: } } defer timer.Stop() for { err := register(regCtx, defs) if err == nil { logger.InfoCF("telegram", "Telegram commands registered", map[string]any{ "count": len(defs), }) return } delay := commandRegistrationDelay(attempt) logger.WarnCF("telegram", "Telegram command registration failed; will retry", map[string]any{ "error": err.Error(), "retry_after": delay.String(), }) attempt++ if !timer.Stop() { select { case <-timer.C: default: } } timer.Reset(delay) select { case <-regCtx.Done(): return case <-timer.C: } } }() } ================================================ FILE: pkg/channels/telegram/command_registration_test.go ================================================ package telegram import ( "context" "errors" "sync/atomic" "testing" "time" "github.com/sipeed/picoclaw/pkg/commands" ) func TestStartCommandRegistration_DoesNotBlock(t *testing.T) { ch := &TelegramChannel{} started := make(chan struct{}, 1) ctx, cancel := context.WithCancel(context.Background()) defer cancel() ch.registerFunc = func(context.Context, []commands.Definition) error { started <- struct{}{} return errors.New("temporary failure") } ch.startCommandRegistration(ctx, []commands.Definition{{Name: "help"}}) select { case <-started: case <-time.After(time.Second): t.Fatal("registration did not start asynchronously") } } func TestStartCommandRegistration_RetriesUntilSuccessThenStops(t *testing.T) { ch := &TelegramChannel{} ctx, cancel := context.WithCancel(context.Background()) defer cancel() origBackoff := commandRegistrationBackoff commandRegistrationBackoff = []time.Duration{5 * time.Millisecond} defer func() { commandRegistrationBackoff = origBackoff }() var attempts atomic.Int32 ch.registerFunc = func(context.Context, []commands.Definition) error { n := attempts.Add(1) if n < 3 { return errors.New("temporary failure") } return nil } ch.startCommandRegistration(ctx, []commands.Definition{{Name: "help", Description: "Help"}}) deadline := time.Now().Add(250 * time.Millisecond) for time.Now().Before(deadline) { if attempts.Load() >= 3 { break } time.Sleep(5 * time.Millisecond) } if attempts.Load() < 3 { t.Fatalf("expected at least 3 attempts, got %d", attempts.Load()) } stable := attempts.Load() time.Sleep(30 * time.Millisecond) if attempts.Load() != stable { t.Fatalf("expected retries to stop after success, got %d -> %d", stable, attempts.Load()) } } func TestStartCommandRegistration_StopsAfterCancel(t *testing.T) { ch := &TelegramChannel{} ctx, cancel := context.WithCancel(context.Background()) origBackoff := commandRegistrationBackoff commandRegistrationBackoff = []time.Duration{5 * time.Millisecond} defer func() { commandRegistrationBackoff = origBackoff }() defer cancel() var attempts atomic.Int32 ch.registerFunc = func(context.Context, []commands.Definition) error { attempts.Add(1) return errors.New("always fail") } ch.startCommandRegistration(ctx, []commands.Definition{{Name: "help", Description: "Help"}}) time.Sleep(20 * time.Millisecond) cancel() time.Sleep(20 * time.Millisecond) // allow in-flight attempt to settle stable := attempts.Load() time.Sleep(30 * time.Millisecond) if attempts.Load() != stable { t.Fatalf("expected retries to quiesce after cancel, got %d -> %d", stable, attempts.Load()) } } ================================================ FILE: pkg/channels/telegram/init.go ================================================ package telegram import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) func init() { channels.RegisterFactory("telegram", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { return NewTelegramChannel(cfg, b) }) } ================================================ FILE: pkg/channels/telegram/parse_markdown_to_md_v2.go ================================================ package telegram import ( "regexp" "strings" ) // mdV2SpecialChars are all characters that must be escaped in Telegram MarkdownV2 var mdV2SpecialChars = map[rune]bool{ '*': true, '_': true, '[': true, ']': true, '(': true, ')': true, '~': true, '`': true, '>': true, '<': true, '#': true, '+': true, '-': true, '=': true, '|': true, '{': true, '}': true, '.': true, '!': true, '\\': true, } // entityPattern describes one Telegram MarkdownV2 inline entity type. type entityPattern struct { re *regexp.Regexp open string close string } // allEntityPatterns lists every recognized entity in priority order // (longer / more-specific delimiters first so they win over shorter ones). // Each entry's regex is anchored to find the first occurrence in a string. var allEntityPatterns = []entityPattern{ // fenced code block — content is completely verbatim {re: regexp.MustCompile("(?s)```(?:[\\w]*\\n)?[\\s\\S]*?```"), open: "```", close: "```"}, // inline code — content is completely verbatim {re: regexp.MustCompile("`(?:[^`\\\n]|\\\\.)*`"), open: "`", close: "`"}, // expandable block-quote opener **>… {re: regexp.MustCompile(`(?m)\*\*>(?:[^\n]*)`), open: "**>", close: ""}, // block-quote line >… {re: regexp.MustCompile(`(?m)^>(?:[^\n]*)`), open: ">", close: ""}, // custom emoji / timestamp ![…](…) — must come before plain link {re: regexp.MustCompile(`!\[[^\]]*\]\([^)]*\)`), open: "!", close: ""}, // inline URL / user mention […](…) {re: regexp.MustCompile(`\[[^\]]*\]\([^)]*\)`), open: "[", close: ""}, // spoiler ||…|| — before single | so it wins {re: regexp.MustCompile(`\|\|(?:[^|\\\n]|\\.)*\|\|`), open: "||", close: "||"}, // underline __…__ — before single _ so it wins {re: regexp.MustCompile(`__(?:[^_\\\n]|\\.)*__`), open: "__", close: "__"}, // bold *…* {re: regexp.MustCompile(`\*(?:[^*\\\n]|\\.)*\*`), open: "*", close: "*"}, // italic _…_ {re: regexp.MustCompile(`_(?:[^_\\\n]|\\.)*_`), open: "_", close: "_"}, // strikethrough ~…~ {re: regexp.MustCompile(`~(?:[^~\\\n]|\\.)*~`), open: "~", close: "~"}, } // verbatimEntities are entity types whose inner content must never be // touched (code blocks, URLs, quotes, custom emoji). // Their content is passed through completely unchanged. var verbatimEntities = map[string]bool{ "```": true, "`": true, "**>": true, ">": true, "!": true, "[": true, } // markdownToTelegramMarkdownV2 converts a Markdown string into a string safe // for sending with Telegram's MarkdownV2 parse mode. // // Rules: // - Markdown headings (# … ######) are converted to *bold*. // - **bold** Markdown syntax is converted to *bold*. // - Recognized Telegram MarkdownV2 entity spans are preserved; their inner // content is processed recursively so that nested valid entities are kept // intact while stray special characters are escaped. // - All plain-text segments have their MarkdownV2 special characters escaped. // // Reference: https://core.telegram.org/bots/api#formatting-options func markdownToTelegramMarkdownV2(text string) string { // 1. Convert Markdown headings → *escaped heading text* text = reHeading.ReplaceAllStringFunc(text, func(match string) string { sub := reHeading.FindStringSubmatch(match) if len(sub) < 2 { return match } // The heading content is fresh plain text — escape everything // including * so the resulting *…* bold span stays valid. return "*" + escapeMarkdownV2(sub[1]) + "*" }) // 2. Convert **bold** → *bold* text = reBoldStar.ReplaceAllString(text, "*$1*") // 3. Recursively escape the full string. return processText(text) } // processText walks `text`, finds the leftmost / longest matching entity, // escapes the gap before it, processes the entity (recursing into its inner // content when appropriate), then continues with the remainder. func processText(text string) string { if text == "" { return "" } // Find the leftmost match among all entity patterns. bestStart := -1 bestEnd := -1 var bestPat *entityPattern for i := range allEntityPatterns { p := &allEntityPatterns[i] loc := p.re.FindStringIndex(text) if loc == nil { continue } if bestStart == -1 || loc[0] < bestStart || (loc[0] == bestStart && (loc[1]-loc[0]) > (bestEnd-bestStart)) { bestStart = loc[0] bestEnd = loc[1] bestPat = p } } if bestPat == nil { // No entity found — escape everything. return escapeMarkdownV2(text) } var b strings.Builder // Plain text before the entity. if bestStart > 0 { b.WriteString(escapeMarkdownV2(text[:bestStart])) } // The matched entity span. matched := text[bestStart:bestEnd] if verbatimEntities[bestPat.open] { // Code blocks, URLs, quotes: pass through completely untouched. b.WriteString(matched) } else { // Inline formatting (bold, italic, underline, strikethrough, spoiler): // keep the delimiters and recursively process the inner content so that // nested entities survive but stray specials get escaped. openLen := len(bestPat.open) closeLen := len(bestPat.close) inner := matched[openLen : len(matched)-closeLen] b.WriteString(bestPat.open) b.WriteString(processText(inner)) b.WriteString(bestPat.close) } // Continue with the remainder of the string. b.WriteString(processText(text[bestEnd:])) return b.String() } // escapeMarkdownV2 escapes every MarkdownV2 special character in a plain-text // segment (i.e. a segment that is not part of any recognized entity). // Already-escaped sequences (backslash + char) are forwarded verbatim to avoid // double-escaping. func escapeMarkdownV2(s string) string { var b strings.Builder b.Grow(len(s) + 8) runes := []rune(s) for i := 0; i < len(runes); i++ { ch := runes[i] // Forward an existing escape sequence verbatim. if ch == '\\' && i+1 < len(runes) { b.WriteRune(ch) b.WriteRune(runes[i+1]) i++ continue } if mdV2SpecialChars[ch] { b.WriteByte('\\') } b.WriteRune(ch) } return b.String() } ================================================ FILE: pkg/channels/telegram/parse_markdown_to_md_v2_test.go ================================================ package telegram import ( _ "embed" "testing" "github.com/stretchr/testify/require" ) //go:embed testdata/md2_all_formats.txt var md2AllFormats string func Test_markdownToTelegramMarkdownV2(t *testing.T) { cases := []struct { name string input string expected string }{ { name: "heading -> bolding", input: `## HeadingH2 #`, expected: "*HeadingH2 \\#*", }, { name: "strikethrough", input: "~strikethroughMD~", expected: "~strikethroughMD~", }, { name: "inline URL", input: "[inline URL](http://www.example.com/)", expected: "[inline URL](http://www.example.com/)", }, { name: "all telegram formats", input: md2AllFormats, expected: md2AllFormats, }, { name: "empty", input: "", expected: "", }, { name: "one letter", input: "o", expected: "o", }, { name: "", input: "*Last update: ~10 24h*", expected: "*Last update: \\~10 24h*", }, { name: "", input: "", expected: "\\", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { actual := markdownToTelegramMarkdownV2(tc.input) require.EqualValues(t, tc.expected, actual) }) } } ================================================ FILE: pkg/channels/telegram/parser_markdown_to_html.go ================================================ package telegram import ( "fmt" "strings" ) func markdownToTelegramHTML(text string) string { if text == "" { return "" } codeBlocks := extractCodeBlocks(text) text = codeBlocks.text inlineCodes := extractInlineCodes(text) text = inlineCodes.text text = reHeading.ReplaceAllString(text, "$1") text = reBlockquote.ReplaceAllString(text, "$1") text = escapeHTML(text) text = reLink.ReplaceAllString(text, `$1`) text = reBoldStar.ReplaceAllString(text, "$1") text = reBoldUnder.ReplaceAllString(text, "$1") text = reItalic.ReplaceAllStringFunc(text, func(s string) string { match := reItalic.FindStringSubmatch(s) if len(match) < 2 { return s } return "" + match[1] + "" }) text = reStrike.ReplaceAllString(text, "$1") text = reListItem.ReplaceAllString(text, "• ") for i, code := range inlineCodes.codes { escaped := escapeHTML(code) text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("%s", escaped)) } for i, code := range codeBlocks.codes { escaped := escapeHTML(code) text = strings.ReplaceAll( text, fmt.Sprintf("\x00CB%d\x00", i), fmt.Sprintf("
%s
", escaped), ) } return text } type codeBlockMatch struct { text string codes []string } func extractCodeBlocks(text string) codeBlockMatch { matches := reCodeBlock.FindAllStringSubmatch(text, -1) codes := make([]string, 0, len(matches)) for _, match := range matches { codes = append(codes, match[1]) } i := 0 text = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string { placeholder := fmt.Sprintf("\x00CB%d\x00", i) i++ return placeholder }) return codeBlockMatch{text: text, codes: codes} } type inlineCodeMatch struct { text string codes []string } func extractInlineCodes(text string) inlineCodeMatch { matches := reInlineCode.FindAllStringSubmatch(text, -1) codes := make([]string, 0, len(matches)) for _, match := range matches { codes = append(codes, match[1]) } i := 0 text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string { placeholder := fmt.Sprintf("\x00IC%d\x00", i) i++ return placeholder }) return inlineCodeMatch{text: text, codes: codes} } func escapeHTML(text string) string { text = strings.ReplaceAll(text, "&", "&") text = strings.ReplaceAll(text, "<", "<") text = strings.ReplaceAll(text, ">", ">") return text } ================================================ FILE: pkg/channels/telegram/telegram.go ================================================ package telegram import ( "context" "fmt" "io" "net/http" "net/url" "os" "regexp" "strconv" "strings" "time" "github.com/mymmrac/telego" th "github.com/mymmrac/telego/telegohandler" tu "github.com/mymmrac/telego/telegoutil" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/commands" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" ) var ( reHeading = regexp.MustCompile(`(?m)^#{1,6}\s+([^\n]+)`) reBlockquote = regexp.MustCompile(`^>\s*(.*)$`) reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`) reBoldUnder = regexp.MustCompile(`__(.+?)__`) reItalic = regexp.MustCompile(`_([^_]+)_`) reStrike = regexp.MustCompile(`~~(.+?)~~`) reListItem = regexp.MustCompile(`^[-*]\s+`) reCodeBlock = regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```") reInlineCode = regexp.MustCompile("`([^`]+)`") ) type TelegramChannel struct { *channels.BaseChannel bot *telego.Bot bh *th.BotHandler config *config.Config chatIDs map[string]int64 ctx context.Context cancel context.CancelFunc registerFunc func(context.Context, []commands.Definition) error commandRegCancel context.CancelFunc } func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) { var opts []telego.BotOption telegramCfg := cfg.Channels.Telegram if telegramCfg.Proxy != "" { proxyURL, parseErr := url.Parse(telegramCfg.Proxy) if parseErr != nil { return nil, fmt.Errorf("invalid proxy URL %q: %w", telegramCfg.Proxy, parseErr) } opts = append(opts, telego.WithHTTPClient(&http.Client{ Transport: &http.Transport{ Proxy: http.ProxyURL(proxyURL), }, })) } else if os.Getenv("HTTP_PROXY") != "" || os.Getenv("HTTPS_PROXY") != "" { // Use environment proxy if configured opts = append(opts, telego.WithHTTPClient(&http.Client{ Transport: &http.Transport{ Proxy: http.ProxyFromEnvironment, }, })) } if baseURL := strings.TrimRight(strings.TrimSpace(telegramCfg.BaseURL), "/"); baseURL != "" { opts = append(opts, telego.WithAPIServer(baseURL)) } opts = append(opts, telego.WithLogger(logger.NewLogger("telego"))) bot, err := telego.NewBot(telegramCfg.Token, opts...) if err != nil { return nil, fmt.Errorf("failed to create telegram bot: %w", err) } base := channels.NewBaseChannel( "telegram", telegramCfg, bus, telegramCfg.AllowFrom, channels.WithMaxMessageLength(4000), channels.WithGroupTrigger(telegramCfg.GroupTrigger), channels.WithReasoningChannelID(telegramCfg.ReasoningChannelID), ) return &TelegramChannel{ BaseChannel: base, bot: bot, config: cfg, chatIDs: make(map[string]int64), }, nil } func (c *TelegramChannel) Start(ctx context.Context) error { logger.InfoC("telegram", "Starting Telegram bot (polling mode)...") c.ctx, c.cancel = context.WithCancel(ctx) updates, err := c.bot.UpdatesViaLongPolling(c.ctx, &telego.GetUpdatesParams{ Timeout: 30, }) if err != nil { c.cancel() return fmt.Errorf("failed to start long polling: %w", err) } bh, err := th.NewBotHandler(c.bot, updates) if err != nil { c.cancel() return fmt.Errorf("failed to create bot handler: %w", err) } c.bh = bh bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { return c.handleMessage(ctx, &message) }, th.AnyMessage()) c.SetRunning(true) logger.InfoCF("telegram", "Telegram bot connected", map[string]any{ "username": c.bot.Username(), }) c.startCommandRegistration(c.ctx, commands.BuiltinDefinitions()) go func() { if err = bh.Start(); err != nil { logger.ErrorCF("telegram", "Bot handler failed", map[string]any{ "error": err.Error(), }) } }() return nil } func (c *TelegramChannel) Stop(ctx context.Context) error { logger.InfoC("telegram", "Stopping Telegram bot...") c.SetRunning(false) // Stop the bot handler if c.bh != nil { _ = c.bh.StopWithContext(ctx) } // Cancel our context (stops long polling) if c.cancel != nil { c.cancel() } if c.commandRegCancel != nil { c.commandRegCancel() } return nil } func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } useMarkdownV2 := c.config.Channels.Telegram.UseMarkdownV2 chatID, threadID, err := parseTelegramChatID(msg.ChatID) if err != nil { return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } if msg.Content == "" { return nil } // The Manager already splits messages to ≤4000 chars (WithMaxMessageLength), // so msg.Content is guaranteed to be within that limit. We still need to // check if HTML expansion pushes it beyond Telegram's 4096-char API limit. replyToID := msg.ReplyToMessageID queue := []string{msg.Content} for len(queue) > 0 { chunk := queue[0] queue = queue[1:] content := parseContent(chunk, useMarkdownV2) if len([]rune(content)) > 4096 { runeChunk := []rune(chunk) ratio := float64(len(runeChunk)) / float64(len([]rune(content))) smallerLen := int(float64(4096) * ratio * 0.95) // 5% safety margin // Guarantee progress: if estimated length is >= chunk length, force it smaller if smallerLen >= len(runeChunk) { smallerLen = len(runeChunk) - 1 } if smallerLen <= 0 { if err := c.sendChunk(ctx, sendChunkParams{ chatID: chatID, threadID: threadID, content: content, replyToID: replyToID, mdFallback: chunk, useMarkdownV2: useMarkdownV2, }); err != nil { return err } replyToID = "" continue } // Use the estimated smaller length as a guide for SplitMessage. // SplitMessage will find natural break points (newlines/spaces) and respect code blocks. subChunks := channels.SplitMessage(chunk, smallerLen) // Safety fallback: If SplitMessage failed to shorten the chunk, force a manual hard split. if len(subChunks) == 1 && subChunks[0] == chunk { part1 := string(runeChunk[:smallerLen]) part2 := string(runeChunk[smallerLen:]) subChunks = []string{part1, part2} } // Filter out empty chunks to avoid sending empty messages to Telegram. nonEmpty := make([]string, 0, len(subChunks)) for _, s := range subChunks { if s != "" { nonEmpty = append(nonEmpty, s) } } // Push sub-chunks back to the front of the queue queue = append(nonEmpty, queue...) continue } if err := c.sendChunk(ctx, sendChunkParams{ chatID: chatID, threadID: threadID, content: content, replyToID: replyToID, mdFallback: chunk, useMarkdownV2: useMarkdownV2, }); err != nil { return err } // Only the first chunk should be a reply; subsequent chunks are normal messages. replyToID = "" } return nil } type sendChunkParams struct { chatID int64 threadID int content string replyToID string mdFallback string useMarkdownV2 bool } // sendChunk sends a single HTML/MarkdownV2 message, falling back to the original // markdown as plain text on parse failure so users never see raw HTML/MarkdownV2 tags. func (c *TelegramChannel) sendChunk( ctx context.Context, params sendChunkParams, ) error { tgMsg := tu.Message(tu.ID(params.chatID), params.content) tgMsg.MessageThreadID = params.threadID if params.useMarkdownV2 { tgMsg.WithParseMode(telego.ModeMarkdownV2) } else { tgMsg.WithParseMode(telego.ModeHTML) } if params.replyToID != "" { if mid, parseErr := strconv.Atoi(params.replyToID); parseErr == nil { tgMsg.ReplyParameters = &telego.ReplyParameters{ MessageID: mid, } } } if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil { logParseFailed(err, params.useMarkdownV2) tgMsg.Text = params.mdFallback tgMsg.ParseMode = "" if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { return fmt.Errorf("telegram send: %w", channels.ErrTemporary) } } return nil } // maxTypingDuration limits how long the typing indicator can run. // Prevents endless typing when the LLM fails/hangs and preSend never invokes cancel. // Matches channels.Manager's typingStopTTL (5 min) so behavior is consistent. const maxTypingDuration = 5 * time.Minute // StartTyping implements channels.TypingCapable. // It sends ChatAction(typing) immediately and then repeats every 4 seconds // (Telegram's typing indicator expires after ~5s) in a background goroutine. // The returned stop function is idempotent and cancels the goroutine. // The goroutine also exits automatically after maxTypingDuration if cancel is // never called (e.g. when the LLM fails or times out without publishing). func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { cid, threadID, err := parseTelegramChatID(chatID) if err != nil { return func() {}, err } action := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping) action.MessageThreadID = threadID // Send the first typing action immediately _ = c.bot.SendChatAction(ctx, action) typingCtx, cancel := context.WithCancel(ctx) // Cap lifetime so the goroutine cannot run indefinitely if cancel is never called maxCtx, maxCancel := context.WithTimeout(typingCtx, maxTypingDuration) go func() { defer maxCancel() ticker := time.NewTicker(4 * time.Second) defer ticker.Stop() for { select { case <-maxCtx.Done(): return case <-ticker.C: a := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping) a.MessageThreadID = threadID _ = c.bot.SendChatAction(typingCtx, a) } } }() return cancel, nil } // EditMessage implements channels.MessageEditor. func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { useMarkdownV2 := c.config.Channels.Telegram.UseMarkdownV2 cid, _, err := parseTelegramChatID(chatID) if err != nil { return err } mid, err := strconv.Atoi(messageID) if err != nil { return err } parsedContent := parseContent(content, useMarkdownV2) editMsg := tu.EditMessageText(tu.ID(cid), mid, parsedContent) if useMarkdownV2 { editMsg.WithParseMode(telego.ModeMarkdownV2) } else { editMsg.WithParseMode(telego.ModeHTML) } _, err = c.bot.EditMessageText(ctx, editMsg) if err != nil { logParseFailed(err, useMarkdownV2) _, err = c.bot.EditMessageText(ctx, tu.EditMessageText(tu.ID(cid), mid, content)) } return err } // SendPlaceholder implements channels.PlaceholderCapable. // It sends a placeholder message (e.g. "Thinking... 💭") that will later be // edited to the actual response via EditMessage (channels.MessageEditor). func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { phCfg := c.config.Channels.Telegram.Placeholder if !phCfg.Enabled { return "", nil } text := phCfg.Text if text == "" { text = "Thinking... 💭" } cid, threadID, err := parseTelegramChatID(chatID) if err != nil { return "", err } phMsg := tu.Message(tu.ID(cid), text) phMsg.MessageThreadID = threadID pMsg, err := c.bot.SendMessage(ctx, phMsg) if err != nil { return "", err } return fmt.Sprintf("%d", pMsg.MessageID), nil } // SendMedia implements the channels.MediaSender interface. func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } chatID, threadID, err := parseTelegramChatID(msg.ChatID) if err != nil { return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } store := c.GetMediaStore() if store == nil { return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } for _, part := range msg.Parts { localPath, err := store.Resolve(part.Ref) if err != nil { logger.ErrorCF("telegram", "Failed to resolve media ref", map[string]any{ "ref": part.Ref, "error": err.Error(), }) continue } file, err := os.Open(localPath) if err != nil { logger.ErrorCF("telegram", "Failed to open media file", map[string]any{ "path": localPath, "error": err.Error(), }) continue } switch part.Type { case "image": params := &telego.SendPhotoParams{ ChatID: tu.ID(chatID), MessageThreadID: threadID, Photo: telego.InputFile{File: file}, Caption: part.Caption, } _, err = c.bot.SendPhoto(ctx, params) if err != nil && strings.Contains(err.Error(), "PHOTO_INVALID_DIMENSIONS") { if _, seekErr := file.Seek(0, io.SeekStart); seekErr != nil { file.Close() return fmt.Errorf("telegram rewind media after photo failure: %w", channels.ErrTemporary) } docParams := &telego.SendDocumentParams{ ChatID: tu.ID(chatID), MessageThreadID: threadID, Document: telego.InputFile{File: file}, Caption: part.Caption, } _, err = c.bot.SendDocument(ctx, docParams) } case "audio": params := &telego.SendAudioParams{ ChatID: tu.ID(chatID), MessageThreadID: threadID, Audio: telego.InputFile{File: file}, Caption: part.Caption, } _, err = c.bot.SendAudio(ctx, params) case "video": params := &telego.SendVideoParams{ ChatID: tu.ID(chatID), MessageThreadID: threadID, Video: telego.InputFile{File: file}, Caption: part.Caption, } _, err = c.bot.SendVideo(ctx, params) default: // "file" or unknown types params := &telego.SendDocumentParams{ ChatID: tu.ID(chatID), MessageThreadID: threadID, Document: telego.InputFile{File: file}, Caption: part.Caption, } _, err = c.bot.SendDocument(ctx, params) } file.Close() if err != nil { logger.ErrorCF("telegram", "Failed to send media", map[string]any{ "type": part.Type, "error": err.Error(), }) return fmt.Errorf("telegram send media: %w", channels.ErrTemporary) } } return nil } func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error { if message == nil { return fmt.Errorf("message is nil") } user := message.From if user == nil { return fmt.Errorf("message sender (user) is nil") } platformID := fmt.Sprintf("%d", user.ID) sender := bus.SenderInfo{ Platform: "telegram", PlatformID: platformID, CanonicalID: identity.BuildCanonicalID("telegram", platformID), Username: user.Username, DisplayName: user.FirstName, } // check allowlist to avoid downloading attachments for rejected users if !c.IsAllowedSender(sender) { logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{ "user_id": platformID, }) return nil } chatID := message.Chat.ID c.chatIDs[platformID] = chatID content := "" mediaPaths := []string{} chatIDStr := fmt.Sprintf("%d", chatID) messageIDStr := fmt.Sprintf("%d", message.MessageID) scope := channels.BuildMediaScope("telegram", chatIDStr, messageIDStr) // Helper to register a local file with the media store storeMedia := func(localPath, filename string) string { if store := c.GetMediaStore(); store != nil { ref, err := store.Store(localPath, media.MediaMeta{ Filename: filename, Source: "telegram", }, scope) if err == nil { return ref } } return localPath // fallback: use raw path } if message.Text != "" { content += message.Text } if message.Caption != "" { if content != "" { content += "\n" } content += message.Caption } if len(message.Photo) > 0 { photo := message.Photo[len(message.Photo)-1] photoPath := c.downloadPhoto(ctx, photo.FileID) if photoPath != "" { mediaPaths = append(mediaPaths, storeMedia(photoPath, "photo.jpg")) if content != "" { content += "\n" } content += "[image: photo]" } } if message.Voice != nil { voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg") if voicePath != "" { mediaPaths = append(mediaPaths, storeMedia(voicePath, "voice.ogg")) if content != "" { content += "\n" } content += "[voice]" } } if message.Audio != nil { audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3") if audioPath != "" { mediaPaths = append(mediaPaths, storeMedia(audioPath, "audio.mp3")) if content != "" { content += "\n" } content += "[audio]" } } if message.Document != nil { docPath := c.downloadFile(ctx, message.Document.FileID, "") if docPath != "" { mediaPaths = append(mediaPaths, storeMedia(docPath, "document")) if content != "" { content += "\n" } content += "[file]" } } if content == "" { content = "[empty message]" } // In group chats, apply unified group trigger filtering if message.Chat.Type != "private" { isMentioned := c.isBotMentioned(message) if isMentioned { content = c.stripBotMention(content) } respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) if !respond { return nil } content = cleaned } // For forum topics, embed the thread ID as "chatID/threadID" so replies // route to the correct topic and each topic gets its own session. // Only forum groups (IsForum) are handled; regular group reply threads // must share one session per group. compositeChatID := fmt.Sprintf("%d", chatID) threadID := message.MessageThreadID if message.Chat.IsForum && threadID != 0 { compositeChatID = fmt.Sprintf("%d/%d", chatID, threadID) } logger.DebugCF("telegram", "Received message", map[string]any{ "sender_id": sender.CanonicalID, "chat_id": compositeChatID, "thread_id": threadID, "preview": utils.Truncate(content, 50), }) peerKind := "direct" peerID := fmt.Sprintf("%d", user.ID) if message.Chat.Type != "private" { peerKind = "group" peerID = compositeChatID } peer := bus.Peer{Kind: peerKind, ID: peerID} messageID := fmt.Sprintf("%d", message.MessageID) metadata := map[string]string{ "user_id": fmt.Sprintf("%d", user.ID), "username": user.Username, "first_name": user.FirstName, "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), } // Set parent_peer metadata for per-topic agent binding. if message.Chat.IsForum && threadID != 0 { metadata["parent_peer_kind"] = "topic" metadata["parent_peer_id"] = fmt.Sprintf("%d", threadID) } c.HandleMessage(c.ctx, peer, messageID, platformID, compositeChatID, content, mediaPaths, metadata, sender, ) return nil } func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string { file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID}) if err != nil { logger.ErrorCF("telegram", "Failed to get photo file", map[string]any{ "error": err.Error(), }) return "" } return c.downloadFileWithInfo(file, ".jpg") } func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) string { if file.FilePath == "" { return "" } url := c.bot.FileDownloadURL(file.FilePath) logger.DebugCF("telegram", "File URL", map[string]any{"url": url}) // Use FilePath as filename for better identification filename := file.FilePath + ext return utils.DownloadFile(url, filename, utils.DownloadOptions{ LoggerPrefix: "telegram", }) } func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) string { file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID}) if err != nil { logger.ErrorCF("telegram", "Failed to get file", map[string]any{ "error": err.Error(), }) return "" } return c.downloadFileWithInfo(file, ext) } func parseContent(text string, useMarkdownV2 bool) string { if useMarkdownV2 { return markdownToTelegramMarkdownV2(text) } return markdownToTelegramHTML(text) } // parseTelegramChatID splits "chatID/threadID" into its components. // Returns threadID=0 when no "/" is present (non-forum messages). func parseTelegramChatID(chatID string) (int64, int, error) { idx := strings.Index(chatID, "/") if idx == -1 { cid, err := strconv.ParseInt(chatID, 10, 64) return cid, 0, err } cid, err := strconv.ParseInt(chatID[:idx], 10, 64) if err != nil { return 0, 0, err } tid, err := strconv.Atoi(chatID[idx+1:]) if err != nil { return 0, 0, fmt.Errorf("invalid thread ID in chat ID %q: %w", chatID, err) } return cid, tid, nil } func logParseFailed(err error, useMarkdownV2 bool) { parsingName := "HTML" if useMarkdownV2 { parsingName = "MarkdownV2" } logger.ErrorCF("telegram", fmt.Sprintf("%s parse failed, falling back to plain text", parsingName), map[string]any{ "error": err.Error(), }, ) } // isBotMentioned checks if the bot is mentioned in the message via entities. func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool { text, entities := telegramEntityTextAndList(message) if text == "" || len(entities) == 0 { return false } botUsername := "" if c.bot != nil { botUsername = c.bot.Username() } runes := []rune(text) for _, entity := range entities { entityText, ok := telegramEntityText(runes, entity) if !ok { continue } switch entity.Type { case telego.EntityTypeMention: if botUsername != "" && strings.EqualFold(entityText, "@"+botUsername) { return true } case telego.EntityTypeTextMention: if botUsername != "" && entity.User != nil && strings.EqualFold(entity.User.Username, botUsername) { return true } case telego.EntityTypeBotCommand: if isBotCommandEntityForThisBot(entityText, botUsername) { return true } } } return false } func telegramEntityTextAndList(message *telego.Message) (string, []telego.MessageEntity) { if message.Text != "" { return message.Text, message.Entities } return message.Caption, message.CaptionEntities } func telegramEntityText(runes []rune, entity telego.MessageEntity) (string, bool) { if entity.Offset < 0 || entity.Length <= 0 { return "", false } end := entity.Offset + entity.Length if entity.Offset >= len(runes) || end > len(runes) { return "", false } return string(runes[entity.Offset:end]), true } func isBotCommandEntityForThisBot(entityText, botUsername string) bool { if !strings.HasPrefix(entityText, "/") { return false } command := strings.TrimPrefix(entityText, "/") if command == "" { return false } at := strings.IndexRune(command, '@') if at == -1 { // A bare /command delivered to this bot is intended for this bot. return true } mentionUsername := command[at+1:] if mentionUsername == "" || botUsername == "" { return false } return strings.EqualFold(mentionUsername, botUsername) } // stripBotMention removes the @bot mention from the content. func (c *TelegramChannel) stripBotMention(content string) string { botUsername := c.bot.Username() if botUsername == "" { return content } // Case-insensitive replacement re := regexp.MustCompile(`(?i)@` + regexp.QuoteMeta(botUsername)) content = re.ReplaceAllString(content, "") return strings.TrimSpace(content) } ================================================ FILE: pkg/channels/telegram/telegram_dispatch_test.go ================================================ package telegram import ( "context" "testing" "github.com/mymmrac/telego" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" ) func TestHandleMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) { messageBus := bus.NewMessageBus() ch := &TelegramChannel{ BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), chatIDs: make(map[string]int64), ctx: context.Background(), } msg := &telego.Message{ Text: "/new", MessageID: 9, Chat: telego.Chat{ ID: 123, Type: "private", }, From: &telego.User{ ID: 42, FirstName: "Alice", }, } if err := ch.handleMessage(context.Background(), msg); err != nil { t.Fatalf("handleMessage error: %v", err) } inbound, ok := <-messageBus.InboundChan() if !ok { t.Fatal("expected inbound message to be forwarded") } if inbound.Channel != "telegram" { t.Fatalf("channel=%q", inbound.Channel) } if inbound.Content != "/new" { t.Fatalf("content=%q", inbound.Content) } } ================================================ FILE: pkg/channels/telegram/telegram_group_command_filter_test.go ================================================ package telegram import ( "context" "fmt" "strings" "testing" "time" "github.com/mymmrac/telego" ta "github.com/mymmrac/telego/telegoapi" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) type getMeCaller struct { username string } func (c getMeCaller) Call(_ context.Context, url string, _ *ta.RequestData) (*ta.Response, error) { if strings.HasSuffix(url, "/getMe") { result := fmt.Sprintf(`{"id":1,"is_bot":true,"first_name":"bot","username":%q}`, c.username) return &ta.Response{Ok: true, Result: []byte(result)}, nil } return &ta.Response{Ok: true, Result: []byte("true")}, nil } func newTestTelegramBot(t *testing.T, username string) *telego.Bot { t.Helper() token := "123456:" + strings.Repeat("a", 35) bot, err := telego.NewBot(token, telego.WithAPICaller(getMeCaller{username: username}), telego.WithDiscardLogger(), ) if err != nil { t.Fatalf("NewBot error: %v", err) } return bot } func newGroupMentionOnlyChannel(t *testing.T, botUsername string) (*TelegramChannel, *bus.MessageBus) { t.Helper() messageBus := bus.NewMessageBus() ch := &TelegramChannel{ BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil, channels.WithGroupTrigger(config.GroupTriggerConfig{MentionOnly: true}), ), bot: newTestTelegramBot(t, botUsername), chatIDs: make(map[string]int64), ctx: context.Background(), } return ch, messageBus } func TestHandleMessage_GroupMentionOnly_BotCommandEntity(t *testing.T) { tests := []struct { name string text string wantForwarded bool wantContent string }{ { name: "command with bot username", text: "/new@testbot", wantForwarded: true, wantContent: "/new", }, { name: "bare command", text: "/new", wantForwarded: true, wantContent: "/new", }, { name: "command for another bot", text: "/new@otherbot", wantForwarded: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { ch, messageBus := newGroupMentionOnlyChannel(t, "testbot") msg := &telego.Message{ Text: tc.text, Entities: []telego.MessageEntity{{ Type: telego.EntityTypeBotCommand, Offset: 0, Length: len([]rune(tc.text)), }}, MessageID: 42, Chat: telego.Chat{ ID: 123, Type: "group", }, From: &telego.User{ ID: 7, FirstName: "Alice", }, } if err := ch.handleMessage(context.Background(), msg); err != nil { t.Fatalf("handleMessage error: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 200*time.Microsecond) defer cancel() select { case <-ctx.Done(): if tc.wantForwarded { t.Fatal("timeout waiting for message to be forwarded") return } case inbound, ok := <-messageBus.InboundChan(): if tc.wantForwarded { if !ok { t.Fatal("expected inbound message to be forwarded") } if inbound.Content != tc.wantContent { t.Fatalf("content=%q want=%q", inbound.Content, tc.wantContent) } return } } }) } } func TestIsBotMentioned_MentionEntityUnaffected(t *testing.T) { ch, _ := newGroupMentionOnlyChannel(t, "testbot") msg := &telego.Message{ Text: "@testbot hello", Entities: []telego.MessageEntity{{ Type: telego.EntityTypeMention, Offset: 0, Length: len("@testbot"), }}, } if !ch.isBotMentioned(msg) { t.Fatal("expected mention entity to be treated as bot mention") } } ================================================ FILE: pkg/channels/telegram/telegram_test.go ================================================ package telegram import ( "context" "encoding/json" "errors" "io" "os" "path/filepath" "strings" "testing" "github.com/mymmrac/telego" ta "github.com/mymmrac/telego/telegoapi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/media" ) const testToken = "1234567890:aaaabbbbaaaabbbbaaaabbbbaaaabbbbccc" // stubCaller implements ta.Caller for testing. type stubCaller struct { calls []stubCall callFn func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) } type stubCall struct { URL string Data *ta.RequestData } func (s *stubCaller) Call(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { s.calls = append(s.calls, stubCall{URL: url, Data: data}) return s.callFn(ctx, url, data) } // stubConstructor implements ta.RequestConstructor for testing. type stubConstructor struct{} type multipartCall struct { Parameters map[string]string FileSizes map[string]int } func (s *stubConstructor) JSONRequest(parameters any) (*ta.RequestData, error) { b, err := json.Marshal(parameters) if err != nil { return nil, err } return &ta.RequestData{ ContentType: "application/json", BodyRaw: b, }, nil } func (s *stubConstructor) MultipartRequest( parameters map[string]string, files map[string]ta.NamedReader, ) (*ta.RequestData, error) { return &ta.RequestData{}, nil } type multipartRecordingConstructor struct { stubConstructor calls []multipartCall } func (s *multipartRecordingConstructor) MultipartRequest( parameters map[string]string, files map[string]ta.NamedReader, ) (*ta.RequestData, error) { call := multipartCall{ Parameters: make(map[string]string, len(parameters)), FileSizes: make(map[string]int, len(files)), } for k, v := range parameters { call.Parameters[k] = v } for field, file := range files { if file == nil { continue } data, err := io.ReadAll(file) if err != nil { return nil, err } call.FileSizes[field] = len(data) } s.calls = append(s.calls, call) return &ta.RequestData{}, nil } // successResponse returns a ta.Response that telego will treat as a successful SendMessage. func successResponse(t *testing.T) *ta.Response { t.Helper() msg := &telego.Message{MessageID: 1} b, err := json.Marshal(msg) require.NoError(t, err) return &ta.Response{Ok: true, Result: b} } // newTestChannel creates a TelegramChannel with a mocked bot for unit testing. func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel { return newTestChannelWithConstructor(t, caller, &stubConstructor{}) } func newTestChannelWithConstructor( t *testing.T, caller *stubCaller, constructor ta.RequestConstructor, ) *TelegramChannel { t.Helper() bot, err := telego.NewBot(testToken, telego.WithAPICaller(caller), telego.WithRequestConstructor(constructor), telego.WithDiscardLogger(), ) require.NoError(t, err) base := channels.NewBaseChannel("telegram", nil, nil, nil, channels.WithMaxMessageLength(4000), ) base.SetRunning(true) return &TelegramChannel{ BaseChannel: base, bot: bot, chatIDs: make(map[string]int64), config: config.DefaultConfig(), } } func TestSendMedia_ImageFallbacksToDocumentOnInvalidDimensions(t *testing.T) { constructor := &multipartRecordingConstructor{} caller := &stubCaller{ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { switch { case strings.Contains(url, "sendPhoto"): return nil, errors.New(`api: 400 "Bad Request: PHOTO_INVALID_DIMENSIONS"`) case strings.Contains(url, "sendDocument"): return successResponse(t), nil default: t.Fatalf("unexpected API call: %s", url) return nil, nil } }, } ch := newTestChannelWithConstructor(t, caller, constructor) store := media.NewFileMediaStore() ch.SetMediaStore(store) tmpDir := t.TempDir() localPath := filepath.Join(tmpDir, "woodstock-en-10s.png") content := []byte("fake-png-content") require.NoError(t, os.WriteFile(localPath, content, 0o644)) ref, err := store.Store( localPath, media.MediaMeta{Filename: "woodstock-en-10s.png", ContentType: "image/png"}, "scope-1", ) require.NoError(t, err) err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "12345", Parts: []bus.MediaPart{{ Type: "image", Ref: ref, Caption: "caption", }}, }) require.NoError(t, err) require.Len(t, caller.calls, 2) assert.Contains(t, caller.calls[0].URL, "sendPhoto") assert.Contains(t, caller.calls[1].URL, "sendDocument") require.Len(t, constructor.calls, 2) assert.Equal(t, len(content), constructor.calls[0].FileSizes["photo"]) assert.Equal(t, len(content), constructor.calls[1].FileSizes["document"]) assert.Equal(t, "caption", constructor.calls[1].Parameters["caption"]) } func TestSendMedia_ImageNonDimensionErrorDoesNotFallback(t *testing.T) { constructor := &multipartRecordingConstructor{} caller := &stubCaller{ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { return nil, errors.New("api: 500 \"server exploded\"") }, } ch := newTestChannelWithConstructor(t, caller, constructor) store := media.NewFileMediaStore() ch.SetMediaStore(store) tmpDir := t.TempDir() localPath := filepath.Join(tmpDir, "image.png") require.NoError(t, os.WriteFile(localPath, []byte("fake-png-content"), 0o644)) ref, err := store.Store(localPath, media.MediaMeta{Filename: "image.png", ContentType: "image/png"}, "scope-1") require.NoError(t, err) err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "12345", Parts: []bus.MediaPart{{ Type: "image", Ref: ref, }}, }) require.Error(t, err) assert.ErrorIs(t, err, channels.ErrTemporary) require.Len(t, caller.calls, 1) assert.Contains(t, caller.calls[0].URL, "sendPhoto") require.Len(t, constructor.calls, 1) assert.NotContains(t, caller.calls[0].URL, "sendDocument") } func TestSend_EmptyContent(t *testing.T) { caller := &stubCaller{ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { t.Fatal("SendMessage should not be called for empty content") return nil, nil }, } ch := newTestChannel(t, caller) err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "", }) assert.NoError(t, err) assert.Empty(t, caller.calls, "no API calls should be made for empty content") } func TestSend_ShortMessage_SingleCall(t *testing.T) { caller := &stubCaller{ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { return successResponse(t), nil }, } ch := newTestChannel(t, caller) err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "Hello, world!", }) assert.NoError(t, err) assert.Len(t, caller.calls, 1, "short message should result in exactly one SendMessage call") } func TestSend_LongMessage_SingleCall(t *testing.T) { // With WithMaxMessageLength(4000), the Manager pre-splits messages before // they reach Send(). A message at exactly 4000 chars should go through // as a single SendMessage call (no re-split needed since HTML expansion // won't exceed 4096 for plain text). caller := &stubCaller{ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { return successResponse(t), nil }, } ch := newTestChannel(t, caller) longContent := strings.Repeat("a", 4000) err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: longContent, }) assert.NoError(t, err) assert.Len(t, caller.calls, 1, "pre-split message within limit should result in one SendMessage call") } func TestSend_HTMLFallback_PerChunk(t *testing.T) { callCount := 0 caller := &stubCaller{ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { callCount++ // Fail on odd calls (HTML attempt), succeed on even calls (plain text fallback) if callCount%2 == 1 { return nil, errors.New("Bad Request: can't parse entities") } return successResponse(t), nil }, } ch := newTestChannel(t, caller) err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "Hello **world**", }) assert.NoError(t, err) // One short message → 1 HTML attempt (fail) + 1 plain text fallback (success) = 2 calls assert.Equal(t, 2, len(caller.calls), "should have HTML attempt + plain text fallback") } func TestSend_HTMLFallback_BothFail(t *testing.T) { caller := &stubCaller{ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { return nil, errors.New("send failed") }, } ch := newTestChannel(t, caller) err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "Hello", }) assert.Error(t, err) assert.True(t, errors.Is(err, channels.ErrTemporary), "error should wrap ErrTemporary") assert.Equal(t, 2, len(caller.calls), "should have HTML attempt + plain text attempt") } func TestSend_LongMessage_HTMLFallback_StopsOnError(t *testing.T) { // With a long message that gets split into 2 chunks, if both HTML and // plain text fail on the first chunk, Send should return early. caller := &stubCaller{ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { return nil, errors.New("send failed") }, } ch := newTestChannel(t, caller) longContent := strings.Repeat("x", 4001) err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: longContent, }) assert.Error(t, err) // Should fail on the first chunk (2 calls: HTML + fallback), never reaching the second chunk. assert.Equal(t, 2, len(caller.calls), "should stop after first chunk fails both HTML and plain text") } func TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) { caller := &stubCaller{ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { return successResponse(t), nil }, } ch := newTestChannel(t, caller) // Create markdown whose length is <= 4000 but whose HTML expansion is much longer. // "**a** " (6 chars) becomes "a " (9 chars) in HTML, so repeating it many times // yields HTML that exceeds Telegram's limit while markdown stays within it. markdownContent := strings.Repeat("**a** ", 600) // 3600 chars markdown, HTML ~5400+ chars assert.LessOrEqual(t, len([]rune(markdownContent)), 4000, "markdown content must not exceed chunk size") htmlExpanded := markdownToTelegramHTML(markdownContent) assert.Greater( t, len([]rune(htmlExpanded)), 4096, "HTML expansion must exceed Telegram limit for this test to be meaningful", ) err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: markdownContent, }) assert.NoError(t, err) assert.Greater( t, len(caller.calls), 1, "markdown-short but HTML-long message should be split into multiple SendMessage calls", ) } func TestSend_HTMLOverflow_WordBoundary(t *testing.T) { caller := &stubCaller{ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { return successResponse(t), nil }, } ch := newTestChannel(t, caller) // We want to force a split near index ~2600 while keeping markdown length <= 4000. // Prefix of 430 bold units (6 chars each) = 2580 chars. // Expansion per unit is +3 chars when converted to HTML, so 2580 + 430*3 = 3870. prefix := strings.Repeat("**a** ", 430) targetWord := "TARGETWORDTHATSTAYSTOGETHER" // Suffix of 230 bold units (6 chars each) = 1380 chars. // Total markdown length: 2580 (prefix) + 27 (target word) + 1380 (suffix) = 3987 <= 4000. // HTML expansion adds ~3 chars per bold unit: (430 + 230)*3 = 1980 extra chars, // so total HTML length comfortably exceeds 4096. suffix := strings.Repeat(" **b**", 230) content := prefix + targetWord + suffix // Ensure the test content matches the intended boundary conditions. assert.LessOrEqual(t, len([]rune(content)), 4000, "markdown content must not exceed chunk size for this test") err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "123456", Content: content, }) assert.NoError(t, err) foundFullWord := false for i, call := range caller.calls { var params map[string]any err := json.Unmarshal(call.Data.BodyRaw, ¶ms) require.NoError(t, err) text, _ := params["text"].(string) hasWord := strings.Contains(text, targetWord) t.Logf("Chunk %d length: %d, contains target word: %v", i, len(text), hasWord) if hasWord { foundFullWord = true break } } assert.True(t, foundFullWord, "The target word should not be split between chunks") } func TestSend_NotRunning(t *testing.T) { caller := &stubCaller{ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { t.Fatal("should not be called") return nil, nil }, } ch := newTestChannel(t, caller) ch.SetRunning(false) err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "Hello", }) assert.ErrorIs(t, err, channels.ErrNotRunning) assert.Empty(t, caller.calls) } func TestSend_InvalidChatID(t *testing.T) { caller := &stubCaller{ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { t.Fatal("should not be called") return nil, nil }, } ch := newTestChannel(t, caller) err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "not-a-number", Content: "Hello", }) assert.Error(t, err) assert.True(t, errors.Is(err, channels.ErrSendFailed), "error should wrap ErrSendFailed") assert.Empty(t, caller.calls) } func TestParseTelegramChatID_Plain(t *testing.T) { cid, tid, err := parseTelegramChatID("12345") assert.NoError(t, err) assert.Equal(t, int64(12345), cid) assert.Equal(t, 0, tid) } func TestParseTelegramChatID_NegativeGroup(t *testing.T) { cid, tid, err := parseTelegramChatID("-1001234567890") assert.NoError(t, err) assert.Equal(t, int64(-1001234567890), cid) assert.Equal(t, 0, tid) } func TestParseTelegramChatID_WithThreadID(t *testing.T) { cid, tid, err := parseTelegramChatID("-1001234567890/42") assert.NoError(t, err) assert.Equal(t, int64(-1001234567890), cid) assert.Equal(t, 42, tid) } func TestParseTelegramChatID_GeneralTopic(t *testing.T) { cid, tid, err := parseTelegramChatID("-100123/1") assert.NoError(t, err) assert.Equal(t, int64(-100123), cid) assert.Equal(t, 1, tid) } func TestParseTelegramChatID_Invalid(t *testing.T) { _, _, err := parseTelegramChatID("not-a-number") assert.Error(t, err) } func TestParseTelegramChatID_InvalidThreadID(t *testing.T) { _, _, err := parseTelegramChatID("-100123/not-a-thread") assert.Error(t, err) assert.Contains(t, err.Error(), "invalid thread ID") } func TestSend_WithForumThreadID(t *testing.T) { caller := &stubCaller{ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { return successResponse(t), nil }, } ch := newTestChannel(t, caller) err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "-1001234567890/42", Content: "Hello from topic", }) assert.NoError(t, err) assert.Len(t, caller.calls, 1) } func TestHandleMessage_ForumTopic_SetsMetadata(t *testing.T) { messageBus := bus.NewMessageBus() ch := &TelegramChannel{ BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), chatIDs: make(map[string]int64), ctx: context.Background(), } msg := &telego.Message{ Text: "hello from topic", MessageID: 10, MessageThreadID: 42, Chat: telego.Chat{ ID: -1001234567890, Type: "supergroup", IsForum: true, }, From: &telego.User{ ID: 7, FirstName: "Alice", }, } err := ch.handleMessage(context.Background(), msg) require.NoError(t, err) inbound, ok := <-messageBus.InboundChan() require.True(t, ok, "expected inbound message") // Composite chatID should include thread ID assert.Equal(t, "-1001234567890/42", inbound.ChatID) // Peer ID should include thread ID for session key isolation assert.Equal(t, "group", inbound.Peer.Kind) assert.Equal(t, "-1001234567890/42", inbound.Peer.ID) // Parent peer metadata should be set for agent binding assert.Equal(t, "topic", inbound.Metadata["parent_peer_kind"]) assert.Equal(t, "42", inbound.Metadata["parent_peer_id"]) } func TestHandleMessage_NoForum_NoThreadMetadata(t *testing.T) { messageBus := bus.NewMessageBus() ch := &TelegramChannel{ BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), chatIDs: make(map[string]int64), ctx: context.Background(), } msg := &telego.Message{ Text: "regular group message", MessageID: 11, Chat: telego.Chat{ ID: -100999, Type: "group", }, From: &telego.User{ ID: 8, FirstName: "Bob", }, } err := ch.handleMessage(context.Background(), msg) require.NoError(t, err) inbound, ok := <-messageBus.InboundChan() require.True(t, ok) // Plain chatID without thread suffix assert.Equal(t, "-100999", inbound.ChatID) // Peer ID should be raw chat ID (no thread suffix) assert.Equal(t, "group", inbound.Peer.Kind) assert.Equal(t, "-100999", inbound.Peer.ID) // No parent peer metadata assert.Empty(t, inbound.Metadata["parent_peer_kind"]) assert.Empty(t, inbound.Metadata["parent_peer_id"]) } func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) { messageBus := bus.NewMessageBus() ch := &TelegramChannel{ BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), chatIDs: make(map[string]int64), ctx: context.Background(), } // In regular groups, reply threads set MessageThreadID to the original // message ID. This should NOT trigger per-thread session isolation. msg := &telego.Message{ Text: "reply in thread", MessageID: 20, MessageThreadID: 15, Chat: telego.Chat{ ID: -100999, Type: "supergroup", IsForum: false, }, From: &telego.User{ ID: 9, FirstName: "Carol", }, } err := ch.handleMessage(context.Background(), msg) require.NoError(t, err) inbound, ok := <-messageBus.InboundChan() require.True(t, ok) // chatID should NOT include thread suffix for non-forum groups assert.Equal(t, "-100999", inbound.ChatID) // Peer ID should be raw chat ID (shared session for whole group) assert.Equal(t, "group", inbound.Peer.Kind) assert.Equal(t, "-100999", inbound.Peer.ID) // No parent peer metadata assert.Empty(t, inbound.Metadata["parent_peer_kind"]) assert.Empty(t, inbound.Metadata["parent_peer_id"]) } ================================================ FILE: pkg/channels/telegram/testdata/md2_all_formats.txt ================================================ *bold \*text* _italic \*text_ __underline__ ~strikethrough~ ||spoiler|| *bold _italic bold ~italic bold strikethrough ||italic bold strikethrough spoiler||~ __underline italic bold___ bold* [inline URL](http://www.example.com/) [inline mention of a user](tg://user?id=123456789) ![👍](tg://emoji?id=5368324170671202286) ![22:45 tomorrow](tg://time?unix=1647531900&format=wDT) ![22:45 tomorrow](tg://time?unix=1647531900&format=t) ![22:45 tomorrow](tg://time?unix=1647531900&format=r) ![22:45 tomorrow](tg://time?unix=1647531900) `inline fixed-width code` ``` pre-formatted fixed-width code block ``` ```python pre-formatted fixed-width code block written in the Python programming language ``` >Block quotation started >Block quotation continued >Block quotation continued >Block quotation continued >The last line of the block quotation **>The expandable block quotation started right after the previous block quotation >It is separated from the previous block quotation by an empty bold entity >Expandable block quotation continued >Hidden by default part of the expandable block quotation started >Expandable block quotation continued >The last line of the expandable block quotation with the expandability mark|| ================================================ FILE: pkg/channels/webhook.go ================================================ package channels import "net/http" // WebhookHandler is an optional interface for channels that receive messages // via HTTP webhooks. Manager discovers channels implementing this interface // and registers them on the shared HTTP server. type WebhookHandler interface { // WebhookPath returns the path to mount this handler on the shared server. // Examples: "/webhook/line", "/webhook/wecom" WebhookPath() string http.Handler // ServeHTTP(w http.ResponseWriter, r *http.Request) } // HealthChecker is an optional interface for channels that expose // a health check endpoint on the shared HTTP server. type HealthChecker interface { HealthPath() string HealthHandler(w http.ResponseWriter, r *http.Request) } ================================================ FILE: pkg/channels/wecom/aibot.go ================================================ package wecom import ( "bytes" "context" "crypto/rand" "encoding/base64" "encoding/json" "fmt" "io" "math/big" "net/http" "strings" "sync" "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) // responseURLHTTPClient is a shared HTTP client for posting to WeCom response_url. // Reusing it enables connection pooling across replies. var responseURLHTTPClient = &http.Client{Timeout: 15 * time.Second} // WeComAIBotChannel implements the Channel interface for WeCom AI Bot (企业微信智能机器人) type WeComAIBotChannel struct { *channels.BaseChannel config config.WeComAIBotConfig ctx context.Context cancel context.CancelFunc streamTasks map[string]*streamTask // streamID -> task (for poll lookups) chatTasks map[string][]*streamTask // chatID -> in-flight tasks queue (FIFO) taskMu sync.RWMutex } // streamTask represents a streaming task for AI Bot. // // Mutable fields (Finished, StreamClosed, StreamClosedAt) must be read/written // while holding WeComAIBotChannel.taskMu. Immutable fields (StreamID, ChatID, // ResponseURL, Question, CreatedTime, Deadline, answerCh, ctx, cancel) are set // once at creation and never modified, so they are safe to read without a lock. type streamTask struct { // immutable after creation StreamID string ChatID string // used by Send() to find this task ResponseURL string // temporary URL for proactive reply (valid 1 hour, use once) Question string CreatedTime time.Time Deadline time.Time // ~30s, we close the stream here and switch to response_url answerCh chan string // receives agent reply from Send() ctx context.Context // canceled when task is removed; used to interrupt the agent goroutine cancel context.CancelFunc // call on task removal to cancel ctx // mutable — guarded by WeComAIBotChannel.taskMu StreamClosed bool // stream returned finish:true; waiting for agent to reply via response_url StreamClosedAt time.Time // set when StreamClosed becomes true; used for accelerated cleanup Finished bool // fully done } // WeComAIBotMessage represents the decrypted JSON message from WeCom AI Bot // Ref: https://developer.work.weixin.qq.com/document/path/100719 type WeComAIBotMessage struct { MsgID string `json:"msgid"` AIBotID string `json:"aibotid"` ChatID string `json:"chatid"` // only for group chat ChatType string `json:"chattype"` // "single" or "group" From struct { UserID string `json:"userid"` } `json:"from"` ResponseURL string `json:"response_url"` // temporary URL for proactive reply MsgType string `json:"msgtype"` // text message Text *struct { Content string `json:"content"` } `json:"text,omitempty"` // stream polling refresh Stream *struct { ID string `json:"id"` } `json:"stream,omitempty"` // image message Image *struct { URL string `json:"url"` } `json:"image,omitempty"` // mixed message (text + image) Mixed *struct { MsgItem []struct { MsgType string `json:"msgtype"` Text *struct { Content string `json:"content"` } `json:"text,omitempty"` Image *struct { URL string `json:"url"` } `json:"image,omitempty"` } `json:"msg_item"` } `json:"mixed,omitempty"` // event field Event *struct { EventType string `json:"eventtype"` } `json:"event,omitempty"` } // WeComAIBotMsgItemImage holds the image payload inside a stream message item. type WeComAIBotMsgItemImage struct { Base64 string `json:"base64"` MD5 string `json:"md5"` } // WeComAIBotMsgItem is a single item inside a stream's msg_item list. type WeComAIBotMsgItem struct { MsgType string `json:"msgtype"` Image *WeComAIBotMsgItemImage `json:"image,omitempty"` } // WeComAIBotStreamInfo represents the detailed stream content in streaming responses. type WeComAIBotStreamInfo struct { ID string `json:"id"` Finish bool `json:"finish"` Content string `json:"content,omitempty"` MsgItem []WeComAIBotMsgItem `json:"msg_item,omitempty"` } // WeComAIBotStreamResponse represents the streaming response format type WeComAIBotStreamResponse struct { MsgType string `json:"msgtype"` Stream WeComAIBotStreamInfo `json:"stream"` } // WeComAIBotEncryptedResponse represents the encrypted response wrapper // Fields match WXBizJsonMsgCrypt.generate() in Python SDK type WeComAIBotEncryptedResponse struct { Encrypt string `json:"encrypt"` MsgSignature string `json:"msgsignature"` Timestamp string `json:"timestamp"` Nonce string `json:"nonce"` } // NewWeComAIBotChannel creates a WeCom AI Bot channel instance. // If cfg.BotID and cfg.Secret are both set, it returns a WeComAIBotWSChannel // using the WebSocket long-connection API. // Otherwise it returns the webhook-mode WeComAIBotChannel (requires Token + // EncodingAESKey). func NewWeComAIBotChannel( cfg config.WeComAIBotConfig, messageBus *bus.MessageBus, ) (channels.Channel, error) { // WebSocket long-connection mode takes priority when BotID + Secret are set. if cfg.BotID != "" && cfg.Secret != "" { logger.InfoC("wecom_aibot", "BotID and Secret provided, using WebSocket mode") return newWeComAIBotWSChannel(cfg, messageBus) } // Webhook (short-connection) mode. if cfg.Token == "" || cfg.EncodingAESKey == "" { return nil, fmt.Errorf( "WeCom AI Bot requires either (bot_id + secret) for WebSocket mode " + "or (token + encoding_aes_key) for webhook mode") } if cfg.ProcessingMessage == "" { cfg.ProcessingMessage = config.DefaultWeComAIBotProcessingMessage } base := channels.NewBaseChannel("wecom_aibot", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(2048), channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) return &WeComAIBotChannel{ BaseChannel: base, config: cfg, streamTasks: make(map[string]*streamTask), chatTasks: make(map[string][]*streamTask), }, nil } // Name returns the channel name func (c *WeComAIBotChannel) Name() string { return "wecom_aibot" } // Start initializes the WeCom AI Bot channel func (c *WeComAIBotChannel) Start(ctx context.Context) error { logger.InfoC("wecom_aibot", "Starting WeCom AI Bot channel...") c.ctx, c.cancel = context.WithCancel(ctx) // Start cleanup goroutine for old tasks go c.cleanupLoop() c.SetRunning(true) logger.InfoC("wecom_aibot", "WeCom AI Bot channel started") return nil } // Stop gracefully stops the WeCom AI Bot channel func (c *WeComAIBotChannel) Stop(ctx context.Context) error { logger.InfoC("wecom_aibot", "Stopping WeCom AI Bot channel...") if c.cancel != nil { c.cancel() } c.SetRunning(false) logger.InfoC("wecom_aibot", "WeCom AI Bot channel stopped") return nil } // Send delivers the agent reply into the active streamTask for msg.ChatID. // It writes into the earliest unfinished task in the queue (FIFO per chatID). // If the stream has already closed (deadline passed), it posts directly to response_url. func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } c.taskMu.Lock() queue := c.chatTasks[msg.ChatID] // Only compact Finished tasks at the head of the queue. // Tasks that are Finished in the middle are NOT removed here: doing a full // scan on every Send() call would be O(n) and is unnecessary given that // removeTask() always splices the task out of the queue immediately. // Any Finished task left stranded in the middle (e.g. due to an unexpected // code path) will be collected by cleanupOldTasks. for len(queue) > 0 && queue[0].Finished { queue = queue[1:] } c.chatTasks[msg.ChatID] = queue var task *streamTask var streamClosed bool var responseURL string if len(queue) > 0 { task = queue[0] // Read mutable fields while holding c.taskMu to avoid data races. streamClosed = task.StreamClosed responseURL = task.ResponseURL } c.taskMu.Unlock() if task == nil { logger.DebugCF( "wecom_aibot", "Send: no active task for chat (may have timed out)", map[string]any{ "chat_id": msg.ChatID, }, ) return nil } if streamClosed { // Stream already ended with a "please wait" notice; send the real reply via response_url. // Note: task.StreamID and task.ChatID are immutable, safe to read without a lock. logger.InfoCF("wecom_aibot", "Sending reply via response_url", map[string]any{ "stream_id": task.StreamID, "chat_id": msg.ChatID, }) if responseURL != "" { if err := c.sendViaResponseURL(responseURL, msg.Content); err != nil { logger.ErrorCF("wecom_aibot", "Failed to send via response_url", map[string]any{ "error": err, "stream_id": task.StreamID, }) c.removeTask(task) return fmt.Errorf("response_url delivery failed: %w", channels.ErrSendFailed) } } else { logger.WarnCF("wecom_aibot", "Stream closed but no response_url available", map[string]any{ "stream_id": task.StreamID, }) } c.removeTask(task) return nil } // Stream still open: deliver via answerCh for the next poll response. select { case task.answerCh <- msg.Content: case <-task.ctx.Done(): // Task was canceled (cleanup removed it); silently drop the reply. return nil case <-ctx.Done(): return ctx.Err() } return nil } // WebhookPath returns the path for registering on the shared HTTP server func (c *WeComAIBotChannel) WebhookPath() string { if c.config.WebhookPath == "" { return "/webhook/wecom-aibot" } return c.config.WebhookPath } // ServeHTTP implements http.Handler for the shared HTTP server func (c *WeComAIBotChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { c.handleWebhook(w, r) } // HealthPath returns the health check endpoint path func (c *WeComAIBotChannel) HealthPath() string { return c.WebhookPath() + "/health" } // HealthHandler handles health check requests func (c *WeComAIBotChannel) HealthHandler(w http.ResponseWriter, r *http.Request) { c.handleHealth(w, r) } // handleWebhook handles incoming webhook requests from WeCom AI Bot func (c *WeComAIBotChannel) handleWebhook(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Log all incoming requests for debugging logger.DebugCF("wecom_aibot", "Received webhook request", map[string]any{ "method": r.Method, "path": r.URL.Path, "query": r.URL.RawQuery, }) switch r.Method { case http.MethodGet: // URL verification c.handleVerification(ctx, w, r) case http.MethodPost: // Message callback c.handleMessageCallback(ctx, w, r) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } // handleVerification handles the URL verification request from WeCom func (c *WeComAIBotChannel) handleVerification( ctx context.Context, w http.ResponseWriter, r *http.Request, ) { msgSignature := r.URL.Query().Get("msg_signature") timestamp := r.URL.Query().Get("timestamp") nonce := r.URL.Query().Get("nonce") echostr := r.URL.Query().Get("echostr") logger.DebugCF("wecom_aibot", "URL verification request", map[string]any{ "msg_signature": msgSignature, "timestamp": timestamp, "nonce": nonce, }) // Verify signature if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) { logger.ErrorC("wecom_aibot", "Signature verification failed") http.Error(w, "Signature verification failed", http.StatusUnauthorized) return } // Decrypt echostr // For WeCom AI Bot (智能机器人), receiveid should be empty string decrypted, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, "") if err != nil { logger.ErrorCF("wecom_aibot", "Failed to decrypt echostr", map[string]any{ "error": err, }) http.Error(w, "Decryption failed", http.StatusInternalServerError) return } // Remove BOM and whitespace as per WeCom documentation decrypted = strings.TrimPrefix(decrypted, "\ufeff") decrypted = strings.TrimSpace(decrypted) logger.InfoC("wecom_aibot", "URL verification successful") w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) w.Write([]byte(decrypted)) } // handleMessageCallback handles incoming messages from WeCom AI Bot func (c *WeComAIBotChannel) handleMessageCallback( ctx context.Context, w http.ResponseWriter, r *http.Request, ) { msgSignature := r.URL.Query().Get("msg_signature") timestamp := r.URL.Query().Get("timestamp") nonce := r.URL.Query().Get("nonce") // Read request body (limit to 4 MB to prevent memory exhaustion) const maxBodySize = 4 << 20 // 4 MB body, err := io.ReadAll(io.LimitReader(r.Body, maxBodySize+1)) if err != nil { logger.ErrorCF("wecom_aibot", "Failed to read request body", map[string]any{ "error": err, }) http.Error(w, "Failed to read body", http.StatusBadRequest) return } if len(body) > maxBodySize { http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge) return } // Parse JSON body to get encrypted message // Format: {"encrypt": "base64_encrypted_string"} var encryptedMsg struct { Encrypt string `json:"encrypt"` } if unmarshalErr := json.Unmarshal(body, &encryptedMsg); unmarshalErr != nil { logger.ErrorCF("wecom_aibot", "Failed to parse JSON body", map[string]any{ "error": unmarshalErr, "body": string(body), }) http.Error(w, "Failed to parse JSON", http.StatusBadRequest) return } // Verify signature if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { logger.ErrorC("wecom_aibot", "Signature verification failed") http.Error(w, "Signature verification failed", http.StatusUnauthorized) return } // Decrypt message // For WeCom AI Bot (智能机器人), receiveid is empty string decrypted, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "") if err != nil { logger.ErrorCF("wecom_aibot", "Failed to decrypt message", map[string]any{ "error": err, }) http.Error(w, "Decryption failed", http.StatusInternalServerError) return } // Parse decrypted JSON message var msg WeComAIBotMessage if unmarshalErr := json.Unmarshal([]byte(decrypted), &msg); unmarshalErr != nil { logger.ErrorCF("wecom_aibot", "Failed to parse decrypted JSON", map[string]any{ "error": unmarshalErr, "decrypted": decrypted, }) http.Error(w, "Failed to parse message", http.StatusInternalServerError) return } logger.DebugCF("wecom_aibot", "Decrypted message", map[string]any{ "msgtype": msg.MsgType, }) // Process the message and get streaming response response := c.processMessage(ctx, msg, timestamp, nonce) // Check if response is empty (e.g. due to unsupported message type) if response == "" { response = c.encryptEmptyResponse(timestamp, nonce) } // Return encrypted JSON response w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) w.Write([]byte(response)) } // processMessage processes the received message and returns encrypted response func (c *WeComAIBotChannel) processMessage( ctx context.Context, msg WeComAIBotMessage, timestamp, nonce string, ) string { logger.DebugCF("wecom_aibot", "Processing message", map[string]any{ "msgtype": msg.MsgType, }) switch msg.MsgType { case "text": return c.handleTextMessage(ctx, msg, timestamp, nonce) case "stream": return c.handleStreamMessage(ctx, msg, timestamp, nonce) case "image": return c.handleImageMessage(ctx, msg, timestamp, nonce) case "mixed": return c.handleMixedMessage(ctx, msg, timestamp, nonce) case "event": return c.handleEventMessage(ctx, msg, timestamp, nonce) default: logger.WarnCF("wecom_aibot", "Unsupported message type", map[string]any{ "msgtype": msg.MsgType, }) return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ MsgType: "stream", Stream: WeComAIBotStreamInfo{ ID: c.generateStreamID(), Finish: true, Content: "Unsupported message type: " + msg.MsgType, }, }) } } // handleTextMessage handles text messages by starting a new streaming task func (c *WeComAIBotChannel) handleTextMessage( ctx context.Context, msg WeComAIBotMessage, timestamp, nonce string, ) string { if msg.Text == nil { logger.ErrorC("wecom_aibot", "text message missing text field") return c.encryptEmptyResponse(timestamp, nonce) } content := msg.Text.Content userID := msg.From.UserID if userID == "" { userID = "unknown" } // chatID: group chat uses chatid, single chat uses userid chatID := msg.ChatID if chatID == "" { chatID = userID } streamID := c.generateStreamID() // WeCom stops sending stream-refresh callbacks after 6 minutes. // Set a slightly shorter deadline so we can send a timeout notice before it gives up. deadline := time.Now().Add(30 * time.Second) // Each task gets its own context derived from the channel lifetime context. // Canceling taskCancel interrupts the agent goroutine when the task is removed. taskCtx, taskCancel := context.WithCancel(c.ctx) task := &streamTask{ StreamID: streamID, ChatID: chatID, ResponseURL: msg.ResponseURL, Question: content, CreatedTime: time.Now(), Deadline: deadline, Finished: false, answerCh: make(chan string, 1), ctx: taskCtx, cancel: taskCancel, } c.taskMu.Lock() c.streamTasks[streamID] = task c.chatTasks[chatID] = append(c.chatTasks[chatID], task) c.taskMu.Unlock() // Publish to agent asynchronously; agent will call Send() with reply. // Use task.ctx (not c.ctx) so the agent goroutine is canceled when the task is removed. go func() { sender := bus.SenderInfo{ Platform: "wecom_aibot", PlatformID: userID, CanonicalID: identity.BuildCanonicalID("wecom_aibot", userID), DisplayName: userID, } peerKind := "direct" if msg.ChatType == "group" { peerKind = "group" } peer := bus.Peer{Kind: peerKind, ID: chatID} metadata := map[string]string{ "channel": "wecom_aibot", "chat_type": msg.ChatType, "msg_type": "text", "msgid": msg.MsgID, "aibotid": msg.AIBotID, "stream_id": streamID, "response_url": msg.ResponseURL, } c.HandleMessage(task.ctx, peer, msg.MsgID, userID, chatID, content, nil, metadata, sender) }() // Return first streaming response immediately (finish=false, content empty) return c.getStreamResponse(task, timestamp, nonce) } // handleStreamMessage handles stream polling requests func (c *WeComAIBotChannel) handleStreamMessage( ctx context.Context, msg WeComAIBotMessage, timestamp, nonce string, ) string { if msg.Stream == nil { logger.ErrorC("wecom_aibot", "Stream message missing stream field") return c.encryptEmptyResponse(timestamp, nonce) } streamID := msg.Stream.ID c.taskMu.RLock() task, exists := c.streamTasks[streamID] c.taskMu.RUnlock() if !exists { logger.DebugCF( "wecom_aibot", "Stream task not found (may be from previous session)", map[string]any{ "stream_id": streamID, }, ) return c.encryptResponse(streamID, timestamp, nonce, WeComAIBotStreamResponse{ MsgType: "stream", Stream: WeComAIBotStreamInfo{ ID: streamID, Finish: true, Content: "Task not found or already finished. Please resend your message to start a new session.", }, }) } // Get next response return c.getStreamResponse(task, timestamp, nonce) } // handleImageMessage handles image messages func (c *WeComAIBotChannel) handleImageMessage( ctx context.Context, msg WeComAIBotMessage, timestamp, nonce string, ) string { logger.WarnC("wecom_aibot", "Image message type not yet fully implemented") if msg.Image == nil { logger.ErrorC("wecom_aibot", "Image message missing image field") return c.encryptEmptyResponse(timestamp, nonce) } imageURL := msg.Image.URL // For now, just acknowledge receipt without echoing the image return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ MsgType: "stream", Stream: WeComAIBotStreamInfo{ ID: c.generateStreamID(), Finish: true, Content: fmt.Sprintf( "Image received (URL: %s), but image messages are not yet supported", imageURL, ), }, }) } // handleMixedMessage handles mixed (text + image) messages func (c *WeComAIBotChannel) handleMixedMessage( ctx context.Context, msg WeComAIBotMessage, timestamp, nonce string, ) string { logger.WarnC("wecom_aibot", "Mixed message type not yet fully implemented") return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ MsgType: "stream", Stream: WeComAIBotStreamInfo{ ID: c.generateStreamID(), Finish: true, Content: "Mixed message type is not yet supported", }, }) } // handleEventMessage handles event messages func (c *WeComAIBotChannel) handleEventMessage( ctx context.Context, msg WeComAIBotMessage, timestamp, nonce string, ) string { eventType := "" if msg.Event != nil { eventType = msg.Event.EventType } logger.DebugCF("wecom_aibot", "Received event", map[string]any{ "event_type": eventType, }) // Send welcome message when user opens the chat window if eventType == "enter_chat" && c.config.WelcomeMessage != "" { streamID := c.generateStreamID() return c.encryptResponse(streamID, timestamp, nonce, WeComAIBotStreamResponse{ MsgType: "stream", Stream: WeComAIBotStreamInfo{ ID: streamID, Finish: true, Content: c.config.WelcomeMessage, }, }) } return c.encryptEmptyResponse(timestamp, nonce) } // getStreamResponse gets the next streaming response for a task. // - If agent replied: return finish=true with the real answer. // - If deadline passed: return finish=true with a "please wait" notice, keep task alive for response_url. // - Otherwise: return finish=false (empty), client will poll again. func (c *WeComAIBotChannel) getStreamResponse(task *streamTask, timestamp, nonce string) string { var content string var finish bool var closeStreamOnly bool // close stream but do NOT remove task (response_url still pending) select { case answer := <-task.answerCh: // Agent replied before deadline — normal finish. content = answer finish = true default: if time.Now().After(task.Deadline) { // Deadline reached: close the stream with a notice, then wait for agent via response_url. content = c.config.ProcessingMessage finish = true closeStreamOnly = true logger.InfoCF( "wecom_aibot", "Stream deadline reached, switching to response_url mode", map[string]any{ "stream_id": task.StreamID, "chat_id": task.ChatID, "response_url": task.ResponseURL != "", }, ) } // else: still waiting, return finish=false } if finish && !closeStreamOnly { // Normal finish: remove from all maps. c.removeTask(task) } else if closeStreamOnly { // Mark stream as closed and remove from streamTasks under a single lock // to keep StreamClosed/StreamClosedAt consistent with map membership. c.taskMu.Lock() task.StreamClosed = true task.StreamClosedAt = time.Now() delete(c.streamTasks, task.StreamID) c.taskMu.Unlock() } response := WeComAIBotStreamResponse{ MsgType: "stream", Stream: WeComAIBotStreamInfo{ ID: task.StreamID, Finish: finish, Content: content, }, } return c.encryptResponse(task.StreamID, timestamp, nonce, response) } // removeTask removes a task from both streamTasks and chatTasks, marks it finished, // and cancels its context to interrupt the associated agent goroutine. func (c *WeComAIBotChannel) removeTask(task *streamTask) { // Cancel first so the agent goroutine stops as soon as possible, // before we acquire the write lock. task.cancel() c.taskMu.Lock() task.Finished = true // written under c.taskMu, consistent with all readers delete(c.streamTasks, task.StreamID) queue := c.chatTasks[task.ChatID] for i, t := range queue { if t == task { c.chatTasks[task.ChatID] = append(queue[:i], queue[i+1:]...) break } } if len(c.chatTasks[task.ChatID]) == 0 { delete(c.chatTasks, task.ChatID) } c.taskMu.Unlock() } // sendViaResponseURL posts a markdown reply to the WeCom response_url. // response_url is valid for 1 hour and can only be used once per callback. // Returned errors are wrapped with channels.ErrRateLimit, channels.ErrTemporary, // or channels.ErrSendFailed so the manager can apply the right retry policy. func (c *WeComAIBotChannel) sendViaResponseURL(responseURL, content string) error { payload := map[string]any{ "msgtype": "markdown", "markdown": map[string]string{ "content": content, }, } body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("failed to marshal payload: %w", err) } ctx, cancel := context.WithTimeout(c.ctx, 15*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodPost, responseURL, bytes.NewBuffer(body)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json; charset=utf-8") resp, err := responseURLHTTPClient.Do(req) if err != nil { return fmt.Errorf("post to response_url failed: %w: %w", channels.ErrTemporary, err) } defer resp.Body.Close() if resp.StatusCode == http.StatusOK { return nil } const maxErrBody = 64 << 10 // 64 KB is more than enough for any error response respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxErrBody)) if err != nil { return fmt.Errorf("reading response_url body: %w: %w", channels.ErrTemporary, err) } switch { case resp.StatusCode == http.StatusTooManyRequests: return fmt.Errorf("response_url rate limited (%d): %s: %w", resp.StatusCode, respBody, channels.ErrRateLimit) case resp.StatusCode >= 500: return fmt.Errorf("response_url server error (%d): %s: %w", resp.StatusCode, respBody, channels.ErrTemporary) default: return fmt.Errorf("response_url returned %d: %s: %w", resp.StatusCode, respBody, channels.ErrSendFailed) } } // encryptResponse encrypts a streaming response func (c *WeComAIBotChannel) encryptResponse( streamID, timestamp, nonce string, response WeComAIBotStreamResponse, ) string { // Marshal response to JSON plaintext, err := json.Marshal(response) if err != nil { logger.ErrorCF("wecom_aibot", "Failed to marshal response", map[string]any{ "error": err, }) return "" } logger.DebugCF("wecom_aibot", "Encrypting response", map[string]any{ "stream_id": streamID, "finish": response.Stream.Finish, "preview": utils.Truncate(response.Stream.Content, 100), }) // Encrypt message encrypted, err := c.encryptMessage(string(plaintext), "") if err != nil { logger.ErrorCF("wecom_aibot", "Failed to encrypt message", map[string]any{ "error": err, }) return "" } // Generate signature signature := computeSignature(c.config.Token, timestamp, nonce, encrypted) // Build encrypted response encryptedResp := WeComAIBotEncryptedResponse{ Encrypt: encrypted, MsgSignature: signature, Timestamp: timestamp, Nonce: nonce, } respJSON, err := json.Marshal(encryptedResp) if err != nil { logger.ErrorCF("wecom_aibot", "Failed to marshal encrypted response", map[string]any{ "error": err, }) return "" } logger.DebugCF("wecom_aibot", "Response encrypted", map[string]any{ "stream_id": streamID, }) return string(respJSON) } // encryptEmptyResponse returns a minimal valid encrypted response func (c *WeComAIBotChannel) encryptEmptyResponse(timestamp, nonce string) string { // Construct a zero-value stream response and encrypt it so that // WeCom always receives a syntactically valid encrypted JSON object. emptyResp := WeComAIBotStreamResponse{} return c.encryptResponse("", timestamp, nonce, emptyResp) } // encryptMessage encrypts a plain text message for WeCom AI Bot func (c *WeComAIBotChannel) encryptMessage(plaintext, receiveid string) (string, error) { aesKey, err := decodeWeComAESKey(c.config.EncodingAESKey) if err != nil { return "", err } frame, err := packWeComFrame(plaintext, receiveid) if err != nil { return "", err } // PKCS7 padding then AES-CBC encrypt paddedFrame := pkcs7Pad(frame, blockSize) ciphertext, err := encryptAESCBC(aesKey, paddedFrame) if err != nil { return "", err } return base64.StdEncoding.EncodeToString(ciphertext), nil } // func (c *WeComAIBotChannel) downloadAndDecryptImage( // ctx context.Context, // imageURL string, // ) ([]byte, error) { // // Download image // req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) // if err != nil { // return nil, fmt.Errorf("failed to create request: %w", err) // } // client := &http.Client{ // Timeout: 15 * time.Second, // } // resp, err := client.Do(req) // if err != nil { // return nil, fmt.Errorf("failed to download image: %w", err) // } // defer resp.Body.Close() // if resp.StatusCode != http.StatusOK { // return nil, fmt.Errorf("download failed with status: %d", resp.StatusCode) // } // // Limit image download to 20 MB to prevent memory exhaustion // const maxImageSize = 20 << 20 // 20 MB // encryptedData, err := io.ReadAll(io.LimitReader(resp.Body, maxImageSize+1)) // if err != nil { // return nil, fmt.Errorf("failed to read image data: %w", err) // } // if len(encryptedData) > maxImageSize { // return nil, fmt.Errorf("image too large (exceeds %d MB)", maxImageSize>>20) // } // logger.DebugCF("wecom_aibot", "Image downloaded", map[string]any{ // "size": len(encryptedData), // }) // // Decode AES key // aesKey, err := decodeWeComAESKey(c.config.EncodingAESKey) // if err != nil { // return nil, err // } // // Decrypt image (AES-CBC with IV = first 16 bytes of key, PKCS7 padding stripped) // decryptedData, err := decryptAESCBC(aesKey, encryptedData) // if err != nil { // return nil, fmt.Errorf("failed to decrypt image: %w", err) // } // logger.DebugCF("wecom_aibot", "Image decrypted", map[string]any{ // "size": len(decryptedData), // }) // return decryptedData, nil // } // generateRandomID generates a cryptographically random alphanumeric ID of // length n. Used for stream IDs and WebSocket request IDs. func generateRandomID(n int) string { const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" b := make([]byte, n) for i := range b { num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) b[i] = letters[num.Int64()] } return string(b) } // generateStreamID generates a random 10-character stream ID (webhook mode). func (c *WeComAIBotChannel) generateStreamID() string { return generateRandomID(10) } // cleanupLoop periodically cleans up old streaming tasks func (c *WeComAIBotChannel) cleanupLoop() { ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() for { select { case <-ticker.C: c.cleanupOldTasks() case <-c.ctx.Done(): return } } } // cleanupOldTasks removes tasks that have exceeded their expected lifetime: // - Active tasks (in streamTasks): cleaned up after 1 hour (response_url validity window). // - StreamClosed tasks (in chatTasks only): cleaned up after streamClosedGracePeriod. // These tasks are waiting for the agent to call Send() via response_url. If the agent // crashes or times out without calling Send(), we must not let them accumulate indefinitely. // The grace period is generous enough to cover typical LLM latency but far shorter than 1 hour, // preventing chatTasks from filling up when many requests time out in quick succession. const ( streamClosedGracePeriod = 10 * time.Minute // max wait for agent after stream closes taskMaxLifetime = 1 * time.Hour // absolute max (≈ response_url validity) ) func (c *WeComAIBotChannel) cleanupOldTasks() { c.taskMu.Lock() defer c.taskMu.Unlock() now := time.Now() cutoff := now.Add(-taskMaxLifetime) for id, task := range c.streamTasks { if task.CreatedTime.Before(cutoff) { delete(c.streamTasks, id) task.cancel() // interrupt agent goroutine still waiting for LLM queue := c.chatTasks[task.ChatID] for i, t := range queue { if t == task { c.chatTasks[task.ChatID] = append(queue[:i], queue[i+1:]...) break } } if len(c.chatTasks[task.ChatID]) == 0 { delete(c.chatTasks, task.ChatID) } logger.DebugCF("wecom_aibot", "Cleaned up expired task", map[string]any{ "stream_id": id, }) } } // Clean up StreamClosed tasks from chatTasks. // Two expiry conditions are checked: // 1. Absolute expiry: task was created more than taskMaxLifetime ago. // 2. Grace expiry: stream closed more than streamClosedGracePeriod ago // (agent had enough time to reply; it is not coming back). for chatID, queue := range c.chatTasks { filtered := queue[:0] for i, t := range queue { absoluteExpired := t.CreatedTime.Before(cutoff) graceExpired := t.StreamClosed && !t.StreamClosedAt.IsZero() && t.StreamClosedAt.Before(now.Add(-streamClosedGracePeriod)) if t.Finished { // Finished tasks should have been removed by removeTask(). // Finding one here (especially not at position 0) means an // unexpected code path left it stranded, causing the queue to // grow silently. Log a warning so it is visible, then drop it. if i > 0 { logger.WarnCF("wecom_aibot", "Found stranded Finished task in the middle of chatTasks queue; "+ "this should not happen — removeTask() should have spliced it out", map[string]any{ "chat_id": chatID, "stream_id": t.StreamID, "position": i, }) } // The task is already finished; its context was already canceled // by removeTask(), so no further action is required. continue } else if !absoluteExpired && !graceExpired { filtered = append(filtered, t) } else { t.cancel() // cancel any lingering agent goroutine } } if len(filtered) == 0 { delete(c.chatTasks, chatID) } else { c.chatTasks[chatID] = filtered } } } // handleHealth handles health check requests func (c *WeComAIBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) { status := "ok" if !c.IsRunning() { status = "not running" } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{ "status": status, }) } ================================================ FILE: pkg/channels/wecom/aibot_test.go ================================================ package wecom import ( "context" "encoding/json" "testing" "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) // ---- Webhook mode tests ---- func TestNewWeComAIBotChannel_WebhookMode(t *testing.T) { t.Run("success with valid config", func(t *testing.T) { cfg := config.WeComAIBotConfig{ Enabled: true, Token: "test_token", EncodingAESKey: "testkey1234567890123456789012345678901234567", WebhookPath: "/webhook/test", } messageBus := bus.NewMessageBus() ch, err := NewWeComAIBotChannel(cfg, messageBus) if err != nil { t.Fatalf("Expected no error, got %v", err) } if ch == nil { t.Fatal("Expected channel to be created") } if ch.Name() != "wecom_aibot" { t.Errorf("Expected name 'wecom_aibot', got '%s'", ch.Name()) } // Webhook mode must implement WebhookHandler. if _, ok := ch.(channels.WebhookHandler); !ok { t.Error("Webhook mode channel should implement WebhookHandler") } }) t.Run("error with missing token", func(t *testing.T) { cfg := config.WeComAIBotConfig{ Enabled: true, EncodingAESKey: "testkey1234567890123456789012345678901234567", } messageBus := bus.NewMessageBus() _, err := NewWeComAIBotChannel(cfg, messageBus) if err == nil { t.Fatal("Expected error for missing token, got nil") } }) t.Run("error with missing encoding key", func(t *testing.T) { cfg := config.WeComAIBotConfig{ Enabled: true, Token: "test_token", } messageBus := bus.NewMessageBus() _, err := NewWeComAIBotChannel(cfg, messageBus) if err == nil { t.Fatal("Expected error for missing encoding key, got nil") } }) } func TestWeComAIBotWebhookChannelStartStop(t *testing.T) { cfg := config.WeComAIBotConfig{ Enabled: true, Token: "test_token", EncodingAESKey: "testkey1234567890123456789012345678901234567", } messageBus := bus.NewMessageBus() ch, err := NewWeComAIBotChannel(cfg, messageBus) if err != nil { t.Fatalf("Failed to create channel: %v", err) } ctx := context.Background() if err := ch.Start(ctx); err != nil { t.Fatalf("Failed to start channel: %v", err) } if !ch.IsRunning() { t.Error("Expected channel to be running after Start") } if err := ch.Stop(ctx); err != nil { t.Fatalf("Failed to stop channel: %v", err) } if ch.IsRunning() { t.Error("Expected channel to be stopped after Stop") } } func TestWeComAIBotChannelWebhookPath(t *testing.T) { t.Run("default path", func(t *testing.T) { cfg := config.WeComAIBotConfig{ Enabled: true, Token: "test_token", EncodingAESKey: "testkey1234567890123456789012345678901234567", } messageBus := bus.NewMessageBus() ch, _ := NewWeComAIBotChannel(cfg, messageBus) wh, ok := ch.(channels.WebhookHandler) if !ok { t.Fatal("Expected channel to implement WebhookHandler") } expectedPath := "/webhook/wecom-aibot" if wh.WebhookPath() != expectedPath { t.Errorf("Expected webhook path '%s', got '%s'", expectedPath, wh.WebhookPath()) } }) t.Run("custom path", func(t *testing.T) { customPath := "/custom/webhook" cfg := config.WeComAIBotConfig{ Enabled: true, Token: "test_token", EncodingAESKey: "testkey1234567890123456789012345678901234567", WebhookPath: customPath, } messageBus := bus.NewMessageBus() ch, _ := NewWeComAIBotChannel(cfg, messageBus) wh, ok := ch.(channels.WebhookHandler) if !ok { t.Fatal("Expected channel to implement WebhookHandler") } if wh.WebhookPath() != customPath { t.Errorf("Expected webhook path '%s', got '%s'", customPath, wh.WebhookPath()) } }) } func TestWeComAIBotChannelGetStreamResponseProcessingMessage(t *testing.T) { validAESKey := "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG" t.Run("uses default processing message", func(t *testing.T) { cfg := config.WeComAIBotConfig{ Enabled: true, Token: "test_token", EncodingAESKey: validAESKey, } messageBus := bus.NewMessageBus() channel, err := NewWeComAIBotChannel(cfg, messageBus) if err != nil { t.Fatalf("Failed to create channel: %v", err) } ch, ok := channel.(*WeComAIBotChannel) if !ok { t.Fatal("Expected webhook mode channel") } task := &streamTask{ StreamID: "stream-default", ChatID: "chat-default", Deadline: time.Now().Add(-time.Second), } ch.streamTasks[task.StreamID] = task ch.chatTasks[task.ChatID] = []*streamTask{task} resp := decodeStreamResponse(t, ch, ch.getStreamResponse(task, "1234567890", "nonce")) if !resp.Stream.Finish { t.Fatal("Expected finished stream response after deadline") } if resp.Stream.Content != config.DefaultWeComAIBotProcessingMessage { t.Fatalf("Expected default processing message %q, got %q", config.DefaultWeComAIBotProcessingMessage, resp.Stream.Content) } if !task.StreamClosed { t.Fatal("Expected task stream to be marked closed") } if _, ok := ch.streamTasks[task.StreamID]; ok { t.Fatal("Expected closed stream task to be removed from streamTasks") } if len(ch.chatTasks[task.ChatID]) != 1 { t.Fatalf("Expected task to remain queued for response_url delivery, got %d entries", len(ch.chatTasks[task.ChatID])) } }) t.Run("uses custom processing message", func(t *testing.T) { cfg := config.WeComAIBotConfig{ Enabled: true, Token: "test_token", EncodingAESKey: validAESKey, ProcessingMessage: "Please wait a moment. The result will be delivered in a follow-up message.", } messageBus := bus.NewMessageBus() channel, err := NewWeComAIBotChannel(cfg, messageBus) if err != nil { t.Fatalf("Failed to create channel: %v", err) } ch, ok := channel.(*WeComAIBotChannel) if !ok { t.Fatal("Expected webhook mode channel") } task := &streamTask{ StreamID: "stream-custom", ChatID: "chat-custom", Deadline: time.Now().Add(-time.Second), } resp := decodeStreamResponse(t, ch, ch.getStreamResponse(task, "1234567890", "nonce")) if resp.Stream.Content != cfg.ProcessingMessage { t.Fatalf("Expected custom processing message %q, got %q", cfg.ProcessingMessage, resp.Stream.Content) } }) } func TestGenerateStreamID(t *testing.T) { cfg := config.WeComAIBotConfig{ Enabled: true, Token: "test_token", EncodingAESKey: "testkey1234567890123456789012345678901234567", } messageBus := bus.NewMessageBus() ch, _ := NewWeComAIBotChannel(cfg, messageBus) webhookCh, ok := ch.(*WeComAIBotChannel) if !ok { t.Fatal("Expected webhook mode channel") } ids := make(map[string]bool) for i := 0; i < 100; i++ { id := webhookCh.generateStreamID() if len(id) != 10 { t.Errorf("Expected stream ID length 10, got %d", len(id)) } if ids[id] { t.Errorf("Duplicate stream ID generated: %s", id) } ids[id] = true } } func TestEncryptDecrypt(t *testing.T) { cfg := config.WeComAIBotConfig{ Enabled: true, Token: "test_token", EncodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", // 43 characters } messageBus := bus.NewMessageBus() ch, _ := NewWeComAIBotChannel(cfg, messageBus) webhookCh, ok := ch.(*WeComAIBotChannel) if !ok { t.Fatal("Expected webhook mode channel") } plaintext := "Hello, World!" receiveid := "" encrypted, err := webhookCh.encryptMessage(plaintext, receiveid) if err != nil { t.Fatalf("Failed to encrypt message: %v", err) } if encrypted == "" { t.Fatal("Encrypted message is empty") } decrypted, err := decryptMessageWithVerify(encrypted, cfg.EncodingAESKey, receiveid) if err != nil { t.Fatalf("Failed to decrypt message: %v", err) } if decrypted != plaintext { t.Errorf("Expected decrypted message '%s', got '%s'", plaintext, decrypted) } } func TestGenerateSignature(t *testing.T) { token := "test_token" timestamp := "1234567890" nonce := "test_nonce" encrypt := "encrypted_msg" signature := computeSignature(token, timestamp, nonce, encrypt) if signature == "" { t.Error("Generated signature is empty") } if !verifySignature(token, signature, timestamp, nonce, encrypt) { t.Error("Generated signature does not verify correctly") } } func decodeStreamResponse(t *testing.T, ch *WeComAIBotChannel, encryptedResponse string) WeComAIBotStreamResponse { t.Helper() var wrapped WeComAIBotEncryptedResponse if err := json.Unmarshal([]byte(encryptedResponse), &wrapped); err != nil { t.Fatalf("Failed to unmarshal encrypted response: %v", err) } plaintext, err := decryptMessageWithVerify(wrapped.Encrypt, ch.config.EncodingAESKey, "") if err != nil { t.Fatalf("Failed to decrypt response: %v", err) } var resp WeComAIBotStreamResponse if err := json.Unmarshal([]byte(plaintext), &resp); err != nil { t.Fatalf("Failed to unmarshal decrypted response: %v", err) } return resp } // ---- WebSocket long-connection mode tests ---- func TestNewWeComAIBotChannel_WSMode(t *testing.T) { t.Run("success with bot_id and secret", func(t *testing.T) { cfg := config.WeComAIBotConfig{ Enabled: true, BotID: "test_bot_id", Secret: "test_secret", } messageBus := bus.NewMessageBus() ch, err := NewWeComAIBotChannel(cfg, messageBus) if err != nil { t.Fatalf("Expected no error, got %v", err) } if ch == nil { t.Fatal("Expected channel to be created") } if ch.Name() != "wecom_aibot" { t.Errorf("Expected name 'wecom_aibot', got '%s'", ch.Name()) } // WebSocket mode must NOT implement WebhookHandler. if _, ok := ch.(channels.WebhookHandler); ok { t.Error("WebSocket mode channel should NOT implement WebhookHandler") } }) t.Run("ws mode takes priority over webhook fields", func(t *testing.T) { cfg := config.WeComAIBotConfig{ Enabled: true, BotID: "test_bot_id", Secret: "test_secret", Token: "also_set", EncodingAESKey: "testkey1234567890123456789012345678901234567", } messageBus := bus.NewMessageBus() ch, err := NewWeComAIBotChannel(cfg, messageBus) if err != nil { t.Fatalf("Expected no error, got %v", err) } if _, ok := ch.(*WeComAIBotWSChannel); !ok { t.Error("Expected WebSocket mode channel when both BotID+Secret and Token+Key are set") } }) t.Run("error with missing bot_id", func(t *testing.T) { cfg := config.WeComAIBotConfig{ Enabled: true, Secret: "test_secret", } messageBus := bus.NewMessageBus() _, err := NewWeComAIBotChannel(cfg, messageBus) // Missing bot_id alone means neither WS mode nor webhook mode is fully configured. if err == nil { t.Fatal("Expected error for missing bot_id, got nil") } }) t.Run("error with missing secret", func(t *testing.T) { cfg := config.WeComAIBotConfig{ Enabled: true, BotID: "test_bot_id", } messageBus := bus.NewMessageBus() _, err := NewWeComAIBotChannel(cfg, messageBus) if err == nil { t.Fatal("Expected error for missing secret, got nil") } }) } func TestWeComAIBotWSChannelStartStop(t *testing.T) { cfg := config.WeComAIBotConfig{ Enabled: true, BotID: "test_bot_id", Secret: "test_secret", } messageBus := bus.NewMessageBus() ch, err := NewWeComAIBotChannel(cfg, messageBus) if err != nil { t.Fatalf("Failed to create channel: %v", err) } ctx := context.Background() // Start launches a background goroutine; it should not block or return an error. if err := ch.Start(ctx); err != nil { t.Fatalf("Failed to start channel: %v", err) } if !ch.IsRunning() { t.Error("Expected channel to be running after Start") } // Stop should work regardless of whether the WebSocket actually connected. if err := ch.Stop(ctx); err != nil { t.Fatalf("Failed to stop channel: %v", err) } if ch.IsRunning() { t.Error("Expected channel to be stopped after Stop") } } func TestGenerateRandomID(t *testing.T) { ids := make(map[string]bool) for i := 0; i < 200; i++ { id := generateRandomID(10) if len(id) != 10 { t.Errorf("Expected ID length 10, got %d", len(id)) } if ids[id] { t.Errorf("Duplicate ID generated: %s", id) } ids[id] = true } } func TestWSGenerateID(t *testing.T) { ids := make(map[string]bool) for i := 0; i < 200; i++ { id := wsGenerateID() if len(id) != 10 { t.Errorf("Expected ID length 10, got %d", len(id)) } if ids[id] { t.Errorf("Duplicate wsGenerateID result: %s", id) } ids[id] = true } } // ---- Webhook streaming fallback tests ---- // makeWebhookChannel creates a started WeComAIBotChannel for testing. func makeWebhookChannel(t *testing.T) *WeComAIBotChannel { t.Helper() cfg := config.WeComAIBotConfig{ Enabled: true, Token: "test_token", EncodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", } ch, err := NewWeComAIBotChannel(cfg, bus.NewMessageBus()) if err != nil { t.Fatalf("create channel: %v", err) } wc := ch.(*WeComAIBotChannel) wc.ctx, wc.cancel = context.WithCancel(context.Background()) return wc } // makeStreamTask creates and registers a streamTask for testing. func makeStreamTask(t *testing.T, ch *WeComAIBotChannel, streamID, chatID string, deadline time.Time) *streamTask { t.Helper() task := &streamTask{ StreamID: streamID, ChatID: chatID, Deadline: deadline, answerCh: make(chan string, 1), } task.ctx, task.cancel = context.WithCancel(ch.ctx) ch.taskMu.Lock() ch.streamTasks[streamID] = task ch.chatTasks[chatID] = append(ch.chatTasks[chatID], task) ch.taskMu.Unlock() return task } // TestGetStreamResponse_ImmediateAnswer verifies that when the agent has already // placed its answer in answerCh, getStreamResponse returns a finish=true response // and fully removes the task. func TestGetStreamResponse_ImmediateAnswer(t *testing.T) { ch := makeWebhookChannel(t) defer ch.cancel() task := makeStreamTask(t, ch, "stream-1", "chat-1", time.Now().Add(30*time.Second)) task.answerCh <- "hello from agent" result := ch.getStreamResponse(task, "ts123", "nonce123") if result == "" { t.Fatal("expected non-empty encrypted response") } ch.taskMu.RLock() _, exists := ch.streamTasks["stream-1"] ch.taskMu.RUnlock() if exists { t.Error("task should have been removed from streamTasks after normal finish") } if !task.Finished { t.Error("task.Finished should be true after normal finish") } } // TestGetStreamResponse_DeadlinePassed verifies that when the stream deadline has // elapsed (no agent reply yet), getStreamResponse closes the stream but keeps the // task alive so the response_url fallback can still deliver the answer. func TestGetStreamResponse_DeadlinePassed(t *testing.T) { ch := makeWebhookChannel(t) defer ch.cancel() task := makeStreamTask(t, ch, "stream-2", "chat-2", time.Now().Add(-time.Millisecond)) result := ch.getStreamResponse(task, "ts456", "nonce456") if result == "" { t.Fatal("expected non-empty encrypted response") } ch.taskMu.RLock() _, stillStreaming := ch.streamTasks["stream-2"] ch.taskMu.RUnlock() if stillStreaming { t.Error("task should have been removed from streamTasks after deadline") } if !task.StreamClosed { t.Error("task.StreamClosed should be true after deadline") } if task.Finished { t.Error("task.Finished must remain false: agent reply still expected via response_url") } } // TestGetStreamResponse_StillPending verifies that when neither the agent has // replied nor the deadline has passed, getStreamResponse returns without altering // task state (client should poll again). func TestGetStreamResponse_StillPending(t *testing.T) { ch := makeWebhookChannel(t) defer ch.cancel() task := makeStreamTask(t, ch, "stream-3", "chat-3", time.Now().Add(30*time.Second)) result := ch.getStreamResponse(task, "ts789", "nonce789") if result == "" { t.Fatal("expected non-empty encrypted response") } ch.taskMu.RLock() _, exists := ch.streamTasks["stream-3"] ch.taskMu.RUnlock() if !exists { t.Error("pending task should still be in streamTasks") } if task.Finished || task.StreamClosed { t.Error("pending task should not be finished or stream-closed") } // Cleanup. ch.removeTask(task) } ================================================ FILE: pkg/channels/wecom/aibot_ws.go ================================================ package wecom import ( "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strings" "sync" "time" "github.com/gorilla/websocket" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" ) // Long-connection WebSocket endpoint. // Ref: https://developer.work.weixin.qq.com/document/path/101463 const ( wsEndpoint = "wss://openws.work.weixin.qq.com" wsHeartbeatInterval = 30 * time.Second wsConnectTimeout = 15 * time.Second wsSubscribeTimeout = 10 * time.Second wsSendMsgTimeout = 10 * time.Second wsRespondMsgTimeout = 10 * time.Second wsWelcomeMsgTimeout = 5 * time.Second // WeCom requires welcome reply within 5 seconds wsMaxReconnectWait = 60 * time.Second wsInitialReconnect = time.Second // WeCom requires finish=true within 6 minutes of the first stream frame. // wsStreamTickInterval controls how often we send an in-progress hint. // wsStreamMaxDuration is a safety margin below the 6-minute hard limit. wsStreamTickInterval = 30 * time.Second wsStreamMaxDuration = 5*time.Minute + 30*time.Second // wsImageDownloadTimeout caps the time we spend downloading an inbound image. wsImageDownloadTimeout = 30 * time.Second // Keep req_id -> chat route for late fallback pushes after stream window closes. wsLateReplyRouteTTL = 30 * time.Minute // wsStreamMaxContentBytes is the maximum UTF-8 byte length for the content field // of a single WeCom AI Bot stream / text / markdown frame. // Ref: https://developer.work.weixin.qq.com/document/path/101463 wsStreamMaxContentBytes = 20480 ) // wsImageHTTPClient is a shared HTTP client for downloading inbound images. // Reusing it enables connection pooling across multiple image downloads. var wsImageHTTPClient = &http.Client{Timeout: wsImageDownloadTimeout} // WeComAIBotWSChannel implements channels.Channel for WeCom AI Bot using the // WebSocket long-connection API. // Unlike the webhook counterpart it does NOT implement WebhookHandler, so the // HTTP manager will not register any callback URL for it. type WeComAIBotWSChannel struct { *channels.BaseChannel config config.WeComAIBotConfig ctx context.Context cancel context.CancelFunc // conn is the active WebSocket connection; nil when disconnected. // All writes are serialized through connMu. conn *websocket.Conn connMu sync.Mutex // dedupe prevents duplicate message processing (WeCom may re-deliver). dedupe *MessageDeduplicator // reqStates holds per-req_id runtime state. // It unifies active task state and late-reply fallback routing. reqStates map[string]*wsReqState reqStatesMu sync.Mutex // reqPending correlates command req_ids with response channels. // Used only for subscribe/ping command-response pairs. reqPending map[string]chan wsEnvelope reqPendingMu sync.Mutex } // wsTask tracks one in-progress agent reply for a single chat turn. type wsTask struct { ReqID string // req_id echoed in all replies for this turn ChatID string ChatType uint32 StreamID string // our generated stream.id answerCh chan string // agent delivers its reply here via Send() ctx context.Context cancel context.CancelFunc } type wsReqState struct { Task *wsTask Route wsLateReplyRoute } type wsLateReplyRoute struct { ChatID string ChatType uint32 ReadyAt time.Time ExpiresAt time.Time } // ---- WebSocket protocol types ---- // wsEnvelope is the generic JSON envelope for all WebSocket messages. type wsEnvelope struct { Cmd string `json:"cmd,omitempty"` Headers wsHeaders `json:"headers"` Body json.RawMessage `json:"body,omitempty"` ErrCode int `json:"errcode,omitempty"` ErrMsg string `json:"errmsg,omitempty"` } type wsHeaders struct { ReqID string `json:"req_id"` } // wsCommand is an outgoing request sent over the WebSocket. type wsCommand struct { Cmd string `json:"cmd"` Headers wsHeaders `json:"headers"` Body any `json:"body,omitempty"` } type wsSendMsgBody struct { ChatID string `json:"chatid"` ChatType uint32 `json:"chat_type,omitempty"` MsgType string `json:"msgtype"` Markdown *wsMarkdownContent `json:"markdown,omitempty"` } // wsRespondMsgBody is the body for aibot_respond_msg / aibot_respond_welcome_msg. type wsRespondMsgBody struct { MsgType string `json:"msgtype"` Stream *wsStreamContent `json:"stream,omitempty"` Text *wsTextContent `json:"text,omitempty"` Markdown *wsMarkdownContent `json:"markdown,omitempty"` Image *wsImageContent `json:"image,omitempty"` } type wsStreamContent struct { ID string `json:"id"` Finish bool `json:"finish"` Content string `json:"content,omitempty"` } // wsImageContent carries a base64-encoded image payload for outbound messages. type wsImageContent struct { Base64 string `json:"base64"` MD5 string `json:"md5"` } type wsTextContent struct { Content string `json:"content"` } type wsMarkdownContent struct { Content string `json:"content"` } // WeComAIBotWSMessage is the decoded body of aibot_msg_callback / // aibot_event_callback in WebSocket long-connection mode. // The structure mirrors WeComAIBotMessage but includes extra fields // that only appear in long-connection callbacks (Voice, AESKey on Image/File). type WeComAIBotWSMessage struct { MsgID string `json:"msgid"` CreateTime int64 `json:"create_time,omitempty"` AIBotID string `json:"aibotid"` ChatID string `json:"chatid,omitempty"` ChatType string `json:"chattype,omitempty"` // "single" | "group" From struct { UserID string `json:"userid"` } `json:"from"` MsgType string `json:"msgtype"` Text *struct { Content string `json:"content"` } `json:"text,omitempty"` Image *struct { URL string `json:"url"` AESKey string `json:"aeskey,omitempty"` // long-connection: per-resource decrypt key } `json:"image,omitempty"` Voice *struct { Content string `json:"content"` // WeCom transcribes voice to text in callbacks } `json:"voice,omitempty"` Mixed *struct { MsgItem []struct { MsgType string `json:"msgtype"` Text *struct { Content string `json:"content"` } `json:"text,omitempty"` Image *struct { URL string `json:"url"` AESKey string `json:"aeskey,omitempty"` } `json:"image,omitempty"` } `json:"msg_item"` } `json:"mixed,omitempty"` Event *struct { EventType string `json:"eventtype"` } `json:"event,omitempty"` File *struct { URL string `json:"url"` AESKey string `json:"aeskey,omitempty"` } `json:"file,omitempty"` Video *struct { URL string `json:"url"` AESKey string `json:"aeskey,omitempty"` } `json:"video,omitempty"` } // ---- Constructor ---- // newWeComAIBotWSChannel creates a WeComAIBotWSChannel for WebSocket mode. func newWeComAIBotWSChannel( cfg config.WeComAIBotConfig, messageBus *bus.MessageBus, ) (*WeComAIBotWSChannel, error) { if cfg.BotID == "" || cfg.Secret == "" { return nil, fmt.Errorf("bot_id and secret are required for WeCom AI Bot WebSocket mode") } base := channels.NewBaseChannel("wecom_aibot", cfg, messageBus, cfg.AllowFrom, channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) return &WeComAIBotWSChannel{ BaseChannel: base, config: cfg, dedupe: NewMessageDeduplicator(wecomMaxProcessedMessages), reqStates: make(map[string]*wsReqState), reqPending: make(map[string]chan wsEnvelope), }, nil } // ---- Channel interface ---- // Name implements channels.Channel. func (c *WeComAIBotWSChannel) Name() string { return "wecom_aibot" } // Start connects to the WeCom WebSocket endpoint and begins message processing. func (c *WeComAIBotWSChannel) Start(ctx context.Context) error { logger.InfoC("wecom_aibot", "Starting WeCom AI Bot channel (WebSocket long-connection mode)...") c.ctx, c.cancel = context.WithCancel(ctx) c.SetRunning(true) go c.connectLoop() logger.InfoC("wecom_aibot", "WeCom AI Bot channel started (WebSocket mode)") return nil } // Stop shuts down the channel and closes the WebSocket connection. func (c *WeComAIBotWSChannel) Stop(_ context.Context) error { logger.InfoC("wecom_aibot", "Stopping WeCom AI Bot channel (WebSocket mode)...") if c.cancel != nil { c.cancel() } c.connMu.Lock() if c.conn != nil { c.conn.Close() c.conn = nil } c.connMu.Unlock() c.SetRunning(false) logger.InfoC("wecom_aibot", "WeCom AI Bot channel stopped") return nil } // Send delivers the agent reply for msg.ChatID. // The waiting task goroutine picks it up and writes the final stream response. func (c *WeComAIBotWSChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } // msg.ChatID carries the inbound req_id (set by dispatchWSAgentTask). // For cron-triggered messages, msg.ChatID is the real WeCom chat/user ID // and there will be no matching entry in reqStates; fall through to proactive push. task, route, ok := c.getReqState(msg.ChatID) if !ok { // No req_id record found — this is a cron/scheduler-originated message. // Send it as a proactive markdown push using the chat ID directly. logger.InfoCF("wecom_aibot", "Send: no req_id state, delivering via proactive push (cron/scheduler)", map[string]any{"chat_id": msg.ChatID}) if err := c.wsSendActivePush(msg.ChatID, 0, msg.Content); err != nil { logger.WarnCF("wecom_aibot", "Proactive push failed", map[string]any{"chat_id": msg.ChatID, "error": err.Error()}) return fmt.Errorf("websocket delivery failed: %w", channels.ErrSendFailed) } return nil } if task == nil { if time.Now().Before(route.ReadyAt) { // Keep using aibot_respond_msg within stream window; do not proactively // push unless wsStreamMaxDuration has elapsed. logger.WarnCF("wecom_aibot", "Send: stream window still open, skip proactive push", map[string]any{"req_id": msg.ChatID, "ready_at": route.ReadyAt.Format(time.RFC3339)}) return nil } if err := c.wsSendActivePush(route.ChatID, route.ChatType, msg.Content); err != nil { logger.WarnCF("wecom_aibot", "Late reply proactive push failed", map[string]any{"req_id": msg.ChatID, "chat_id": route.ChatID, "error": err.Error()}) return fmt.Errorf("websocket delivery failed: %w", channels.ErrSendFailed) } logger.InfoCF("wecom_aibot", "Late reply delivered via proactive push", map[string]any{"req_id": msg.ChatID, "chat_id": route.ChatID, "chat_type": route.ChatType}) c.deleteReqState(msg.ChatID) return nil } // Non-blocking fast path: when answerCh has space, deliver without racing // against task.ctx.Done() (which fires when the task is canceled by a new // incoming message, but the response must still be sent). select { case task.answerCh <- msg.Content: return nil default: } // answerCh was full; block with cancellation guards. select { case task.answerCh <- msg.Content: case <-task.ctx.Done(): return nil case <-ctx.Done(): return ctx.Err() } return nil } // ---- Connection management ---- // wsBackoffResetDuration is the minimum duration a WebSocket connection must // stay up before we reset the reconnect backoff to its initial value. This // prevents a short burst of failures from causing long waits after later, // stable connection periods. const wsBackoffResetDuration = time.Minute // connectLoop maintains the WebSocket connection, reconnecting on failure with // exponential backoff. func (c *WeComAIBotWSChannel) connectLoop() { backoff := wsInitialReconnect for { select { case <-c.ctx.Done(): return default: } logger.InfoC("wecom_aibot", "Connecting to WeCom WebSocket endpoint...") start := time.Now() if err := c.runConnection(); err != nil { elapsed := time.Since(start) // If the connection was stable for long enough, reset backoff so that // a previous burst of failures does not keep us at the maximum delay. if elapsed >= wsBackoffResetDuration { backoff = wsInitialReconnect } select { case <-c.ctx.Done(): return default: logger.WarnCF("wecom_aibot", "WebSocket connection lost, reconnecting", map[string]any{"error": err.Error(), "backoff": backoff.String()}) select { case <-time.After(backoff): case <-c.ctx.Done(): return } if backoff < wsMaxReconnectWait { backoff *= 2 if backoff > wsMaxReconnectWait { backoff = wsMaxReconnectWait } } } } else { // Clean exit (context canceled); stop reconnecting. return } } } // runConnection dials, subscribes, and runs the read/heartbeat loops until the // connection closes or the channel context is canceled. func (c *WeComAIBotWSChannel) runConnection() error { dialCtx, dialCancel := context.WithTimeout(c.ctx, wsConnectTimeout) conn, httpResp, err := websocket.DefaultDialer.DialContext(dialCtx, wsEndpoint, nil) dialCancel() if httpResp != nil { httpResp.Body.Close() } if err != nil { return fmt.Errorf("dial failed: %w", err) } c.connMu.Lock() c.conn = conn c.connMu.Unlock() defer func() { c.connMu.Lock() if c.conn == conn { c.conn = nil } c.connMu.Unlock() // Cancel any tasks that were started over this connection so their // agent goroutines do not keep running after the connection is gone. c.cancelAllTasks() }() // ---- Read loop (must start BEFORE subscribing) ---- // sendAndWait blocks waiting for the subscribe response on reqPending; // readLoop is the only goroutine that delivers messages to reqPending. // Starting readLoop first avoids a deadlock where sendAndWait times out // because no one reads the server's reply. readErrCh := make(chan error, 1) go func() { readErrCh <- c.readLoop(conn) }() // ---- Subscribe ---- reqID := wsGenerateID() resp, err := c.sendAndWait(conn, reqID, wsCommand{ Cmd: "aibot_subscribe", Headers: wsHeaders{ReqID: reqID}, Body: map[string]string{ "bot_id": c.config.BotID, "secret": c.config.Secret, }, }, wsSubscribeTimeout) if err != nil { conn.Close() // stop readLoop <-readErrCh return fmt.Errorf("subscribe failed: %w", err) } if resp.ErrCode != 0 { conn.Close() <-readErrCh return fmt.Errorf("subscribe rejected (errcode=%d): %s", resp.ErrCode, resp.ErrMsg) } logger.InfoC("wecom_aibot", "WebSocket subscription successful") // ---- Heartbeat goroutine ---- hbDone := make(chan struct{}) go func() { defer close(hbDone) c.heartbeatLoop(conn) }() // Wait for the read loop to exit, then tear down the heartbeat. readErr := <-readErrCh conn.Close() // signal heartbeat to stop (idempotent) <-hbDone return readErr } // sendAndWait registers a pending-response slot, sends cmd, and blocks until // the matching response arrives or the timeout/context fires. func (c *WeComAIBotWSChannel) sendAndWait( conn *websocket.Conn, reqID string, cmd wsCommand, timeout time.Duration, ) (wsEnvelope, error) { ch := make(chan wsEnvelope, 1) c.reqPendingMu.Lock() c.reqPending[reqID] = ch c.reqPendingMu.Unlock() cleanup := func() { c.reqPendingMu.Lock() delete(c.reqPending, reqID) c.reqPendingMu.Unlock() } data, err := json.Marshal(cmd) if err != nil { cleanup() return wsEnvelope{}, fmt.Errorf("marshal command: %w", err) } c.connMu.Lock() err = conn.WriteMessage(websocket.TextMessage, data) c.connMu.Unlock() if err != nil { cleanup() return wsEnvelope{}, fmt.Errorf("write command: %w", err) } timer := time.NewTimer(timeout) defer timer.Stop() select { case env := <-ch: return env, nil case <-timer.C: cleanup() return wsEnvelope{}, fmt.Errorf("timeout waiting for response (req_id=%s)", reqID) case <-c.ctx.Done(): cleanup() return wsEnvelope{}, c.ctx.Err() } } // heartbeatLoop sends a ping every wsHeartbeatInterval until conn is closed. // It validates the server's pong response via sendAndWait; a failed pong // triggers a reconnection by closing the connection. func (c *WeComAIBotWSChannel) heartbeatLoop(conn *websocket.Conn) { ticker := time.NewTicker(wsHeartbeatInterval) defer ticker.Stop() for { select { case <-ticker.C: reqID := wsGenerateID() resp, err := c.sendAndWait(conn, reqID, wsCommand{ Cmd: "ping", Headers: wsHeaders{ReqID: reqID}, }, wsHeartbeatInterval) if err != nil { logger.WarnCF("wecom_aibot", "Heartbeat failed, closing connection", map[string]any{"error": err.Error()}) conn.Close() return } if resp.ErrCode != 0 { logger.WarnCF("wecom_aibot", "Heartbeat rejected", map[string]any{"errcode": resp.ErrCode, "errmsg": resp.ErrMsg}) conn.Close() return } logger.DebugCF("wecom_aibot", "Heartbeat pong received", map[string]any{"req_id": reqID}) case <-c.ctx.Done(): return } } } // readLoop reads WebSocket messages and dispatches them until the connection // closes or the channel is stopped. func (c *WeComAIBotWSChannel) readLoop(conn *websocket.Conn) error { for { _, raw, err := conn.ReadMessage() if err != nil { select { case <-c.ctx.Done(): return nil // clean shutdown default: return fmt.Errorf("read error: %w", err) } } var env wsEnvelope if err := json.Unmarshal(raw, &env); err != nil { logger.WarnCF("wecom_aibot", "Failed to parse WebSocket message", map[string]any{"error": err.Error(), "raw": string(raw)}) continue } // Command responses have an empty Cmd field; forward to any waiting // sendAndWait() call, or silently drop if no one is waiting (e.g. // late responses after timeout). if env.Cmd == "" && env.Headers.ReqID != "" { c.reqPendingMu.Lock() ch, ok := c.reqPending[env.Headers.ReqID] if ok { delete(c.reqPending, env.Headers.ReqID) } c.reqPendingMu.Unlock() if ok { ch <- env } continue } // Dispatch to appropriate handler in a separate goroutine so the // read loop is never blocked by a slow agent. go c.handleEnvelope(env) } } // ---- Message / event handlers ---- // handleEnvelope routes a WebSocket envelope to the right handler. func (c *WeComAIBotWSChannel) handleEnvelope(env wsEnvelope) { switch env.Cmd { case "aibot_msg_callback": c.handleMsgCallback(env) case "aibot_event_callback": c.handleEventCallback(env) default: logger.DebugCF("wecom_aibot", "Unhandled WebSocket command", map[string]any{"cmd": env.Cmd}) } } // handleMsgCallback processes aibot_msg_callback. func (c *WeComAIBotWSChannel) handleMsgCallback(env wsEnvelope) { var msg WeComAIBotWSMessage if err := json.Unmarshal(env.Body, &msg); err != nil { logger.WarnCF("wecom_aibot", "Failed to parse msg callback body", map[string]any{"error": err.Error()}) return } // Deduplicate by msgid (WeCom may re-deliver on network issues). if msg.MsgID != "" && !c.dedupe.MarkMessageProcessed(msg.MsgID) { logger.DebugCF("wecom_aibot", "Duplicate message ignored", map[string]any{"msgid": msg.MsgID}) return } reqID := env.Headers.ReqID switch msg.MsgType { case "text": c.handleWSTextMessage(reqID, msg) case "image": c.handleWSImageMessage(reqID, msg) case "voice": c.handleWSVoiceMessage(reqID, msg) case "mixed": c.handleWSMixedMessage(reqID, msg) case "file": c.handleWSFileMessage(reqID, msg) case "video": c.handleWSVideoMessage(reqID, msg) default: logger.WarnCF("wecom_aibot", "Unsupported message type", map[string]any{"msgtype": msg.MsgType}) c.wsSendStreamFinish(reqID, wsGenerateID(), "Unsupported message type: "+msg.MsgType) } } // handleEventCallback processes aibot_event_callback. func (c *WeComAIBotWSChannel) handleEventCallback(env wsEnvelope) { var msg WeComAIBotWSMessage if err := json.Unmarshal(env.Body, &msg); err != nil { logger.WarnCF("wecom_aibot", "Failed to parse event callback body", map[string]any{"error": err.Error()}) return } // Deduplicate by msgid. if msg.MsgID != "" && !c.dedupe.MarkMessageProcessed(msg.MsgID) { logger.DebugCF("wecom_aibot", "Duplicate event ignored", map[string]any{"msgid": msg.MsgID}) return } var eventType string if msg.Event != nil { eventType = msg.Event.EventType } logger.DebugCF("wecom_aibot", "Received event callback", map[string]any{"event_type": eventType}) switch eventType { case "enter_chat": if c.config.WelcomeMessage != "" { c.wsSendWelcomeMsg(env.Headers.ReqID, c.config.WelcomeMessage) } case "disconnected_event": // The server will close this connection after sending this event. // connectLoop will detect the closure and reconnect automatically. logger.WarnC("wecom_aibot", "Received disconnected_event: this connection is being replaced by a newer one") default: logger.DebugCF("wecom_aibot", "Unhandled event type", map[string]any{"event_type": eventType}) } } // handleWSTextMessage dispatches a plain-text message to the agent and streams // the reply back over the WebSocket connection. func (c *WeComAIBotWSChannel) handleWSTextMessage(reqID string, msg WeComAIBotWSMessage) { if msg.Text == nil { logger.ErrorC("wecom_aibot", "text message missing text field") return } c.dispatchWSAgentTask(reqID, msg, msg.Text.Content, nil) } // handleWSImageMessage downloads and stores the inbound image, then dispatches // it to the agent as a media-tagged message. func (c *WeComAIBotWSChannel) handleWSImageMessage(reqID string, msg WeComAIBotWSMessage) { if msg.Image == nil { logger.WarnC("wecom_aibot", "Image message missing image field") c.wsSendStreamFinish(reqID, wsGenerateID(), "Image message could not be processed.") return } c.wsHandleMediaMessage(reqID, msg, msg.Image.URL, msg.Image.AESKey, "image") } // wsHandleMediaMessage is a shared helper for image, file and video messages. // It downloads the resource, stores it in MediaStore, and dispatches to the agent. func (c *WeComAIBotWSChannel) wsHandleMediaMessage( reqID string, msg WeComAIBotWSMessage, resourceURL, aesKey, label string, ) { chatID := wsChatID(msg) ctx, cancel := context.WithTimeout(c.ctx, wsImageDownloadTimeout) defer cancel() ref, err := c.storeWSMedia(ctx, chatID, msg.MsgID, resourceURL, aesKey, wsLabelToDefaultExt(label)) if err != nil { logger.WarnCF("wecom_aibot", "Failed to download/store WS "+label, map[string]any{"error": err.Error(), "url": resourceURL}) c.wsSendStreamFinish(reqID, wsGenerateID(), strings.ToUpper(label[:1])+label[1:]+" message could not be processed.") return } c.dispatchWSAgentTask(reqID, msg, "["+label+"]", []string{ref}) } // handleWSMixedMessage handles mixed text+image messages. // All text parts are collected into the content string; all image parts are // downloaded and stored in MediaStore before dispatching to the agent. func (c *WeComAIBotWSChannel) handleWSMixedMessage(reqID string, msg WeComAIBotWSMessage) { if msg.Mixed == nil { logger.WarnC("wecom_aibot", "Mixed message has no content") c.wsSendStreamFinish(reqID, wsGenerateID(), "Mixed message type is not yet fully supported.") return } chatID := wsChatID(msg) ctx, cancel := context.WithTimeout(c.ctx, wsImageDownloadTimeout) defer cancel() var textParts []string var mediaRefs []string for _, item := range msg.Mixed.MsgItem { switch item.MsgType { case "text": if item.Text != nil && item.Text.Content != "" { textParts = append(textParts, item.Text.Content) } case "image": if item.Image != nil { ref, err := c.storeWSMedia(ctx, chatID, msg.MsgID+"-"+wsGenerateID(), item.Image.URL, item.Image.AESKey, ".jpg") if err != nil { logger.WarnCF("wecom_aibot", "Failed to download/store mixed image", map[string]any{"error": err.Error()}) } else { mediaRefs = append(mediaRefs, ref) } } default: logger.WarnCF("wecom_aibot", "Unsupported item type in mixed message", map[string]any{"msgtype": item.MsgType}) } } if len(textParts) == 0 && len(mediaRefs) == 0 { logger.WarnC("wecom_aibot", "Mixed message has no usable content") c.wsSendStreamFinish(reqID, wsGenerateID(), "Mixed message type is not yet fully supported.") return } content := strings.Join(textParts, "\n") if content == "" { content = "[images]" } c.dispatchWSAgentTask(reqID, msg, content, mediaRefs) } // dispatchWSAgentTask registers a new agent task, sends the opening stream frame, // and starts a goroutine that runs the agent and streams the reply back. // content is the text forwarded to the agent; mediaRefs are optional media // store references attached to the inbound message. func (c *WeComAIBotWSChannel) dispatchWSAgentTask( reqID string, msg WeComAIBotWSMessage, content string, mediaRefs []string, ) { userID := msg.From.UserID if userID == "" { userID = "unknown" } // actualChatID is the real WeCom chat/user ID used for peer identification. // reqID is used as the routing chatID so each turn is independently addressable. actualChatID := wsChatID(msg) streamID := wsGenerateID() chatType := wsChatTypeValue(msg.ChatType) taskCtx, taskCancel := context.WithCancel(c.ctx) task := &wsTask{ ReqID: reqID, ChatID: actualChatID, ChatType: chatType, StreamID: streamID, answerCh: make(chan string, 1), ctx: taskCtx, cancel: taskCancel, } // Each req_id is unique per WeCom turn; tasks run concurrently, no cancellation. c.setReqState(reqID, &wsReqState{ Task: task, Route: wsLateReplyRoute{ ChatID: actualChatID, ChatType: chatType, ReadyAt: time.Now().Add(wsStreamMaxDuration), ExpiresAt: time.Now().Add(wsLateReplyRouteTTL), }, }) logger.DebugCF("wecom_aibot", "Registered new agent task", map[string]any{"chat_id": actualChatID, "req_id": reqID, "stream_id": streamID}) // Send an empty stream opening frame (finish=false) immediately. c.wsSendStreamChunk(reqID, streamID, false, "") go func() { defer func() { taskCancel() c.clearReqTask(reqID, task) }() sender := bus.SenderInfo{ Platform: "wecom_aibot", PlatformID: userID, CanonicalID: identity.BuildCanonicalID("wecom_aibot", userID), DisplayName: userID, } peerKind := "direct" if msg.ChatType == "group" { peerKind = "group" } peer := bus.Peer{Kind: peerKind, ID: actualChatID} metadata := map[string]string{ "channel": "wecom_aibot", "chat_id": actualChatID, "chat_type": msg.ChatType, "msg_type": msg.MsgType, "msgid": msg.MsgID, "aibotid": msg.AIBotID, "stream_id": streamID, } // Pass reqID as chatID: OutboundMessage.ChatID = reqID → Send() finds tasks[reqID]. c.HandleMessage(taskCtx, peer, reqID, userID, reqID, content, mediaRefs, metadata, sender) // Wait for the agent reply. While waiting, send periodic finish=false // hints so the user knows processing is still in progress. // WeCom requires finish=true within 6 minutes of the first stream frame; // wsStreamMaxDuration enforces that limit with a safety margin. waitHints := []string{ "⏳ Processing, please wait...", "⏳ Still processing, please wait...", "⏳ Almost there, please wait...", } ticker := time.NewTicker(wsStreamTickInterval) defer ticker.Stop() deadlineTimer := time.NewTimer(wsStreamMaxDuration) defer deadlineTimer.Stop() tickCount := 0 for { select { case answer := <-task.answerCh: // Split the answer into byte-bounded chunks and send as stream frames. // All but the last carry finish=false; the final frame closes the stream. chunks := splitWSContent(answer, wsStreamMaxContentBytes) for i, chunk := range chunks { c.wsSendStreamChunk(reqID, streamID, i == len(chunks)-1, chunk) } c.deleteReqState(reqID) return case <-ticker.C: hint := waitHints[tickCount%len(waitHints)] tickCount++ logger.DebugCF("wecom_aibot", "Sending stream progress hint", map[string]any{"chat_id": actualChatID, "tick": tickCount}) c.wsSendStreamChunk(reqID, streamID, false, hint) case <-deadlineTimer.C: logger.WarnCF("wecom_aibot", "Stream response deadline reached, closing stream; late reply will be pushed", map[string]any{"chat_id": actualChatID}) c.wsSendStreamFinish(reqID, streamID, "⏳ Processing is taking longer than expected, the response will be sent as a follow-up message.") return case <-taskCtx.Done(): // Give a short grace period so that a response queued in the bus // just before cancellation can still be delivered. This closes a // race where a rapid second message cancels this task after the // agent already published but before Send() wrote to answerCh. // // The connection is gone at this point, so we cannot use // wsSendStreamFinish. Try wsSendActivePush on the (possibly // already-restored) connection; if that also fails, leave the // route intact so Send() can push the reply once reconnected. select { case answer := <-task.answerCh: if err := c.wsSendActivePush(task.ChatID, task.ChatType, answer); err != nil { logger.WarnCF("wecom_aibot", "Grace-period push failed after task cancellation; reply may be lost", map[string]any{"req_id": reqID, "chat_id": task.ChatID, "error": err.Error()}) } else { c.deleteReqState(reqID) } case <-time.After(100 * time.Millisecond): } return } } }() } // handleWSVoiceMessage handles voice messages. // WeCom transcribes voice to text in the callback; if the transcription is // present it is dispatched as plain text to the agent. func (c *WeComAIBotWSChannel) handleWSVoiceMessage(reqID string, msg WeComAIBotWSMessage) { if msg.Voice != nil && msg.Voice.Content != "" { c.dispatchWSAgentTask(reqID, msg, msg.Voice.Content, nil) return } c.wsSendStreamFinish(reqID, wsGenerateID(), "Voice messages are not yet supported.") } // handleWSFileMessage handles file messages. func (c *WeComAIBotWSChannel) handleWSFileMessage(reqID string, msg WeComAIBotWSMessage) { if msg.File == nil { logger.WarnC("wecom_aibot", "File message missing file field") c.wsSendStreamFinish(reqID, wsGenerateID(), "File message could not be processed.") return } c.wsHandleMediaMessage(reqID, msg, msg.File.URL, msg.File.AESKey, "file") } // handleWSVideoMessage handles video messages. func (c *WeComAIBotWSChannel) handleWSVideoMessage(reqID string, msg WeComAIBotWSMessage) { if msg.Video == nil { logger.WarnC("wecom_aibot", "Video message missing video field") c.wsSendStreamFinish(reqID, wsGenerateID(), "Video message could not be processed.") return } c.wsHandleMediaMessage(reqID, msg, msg.Video.URL, msg.Video.AESKey, "video") } // ---- WebSocket write helpers ---- // wsSendStreamChunk sends an aibot_respond_msg stream frame. func (c *WeComAIBotWSChannel) wsSendStreamChunk(reqID, streamID string, finish bool, content string) { logger.DebugCF("wecom_aibot", "Sending stream chunk", map[string]any{ "stream_id": streamID, "finish": finish, "preview": utils.Truncate(content, 100), }) cmd := wsCommand{ Cmd: "aibot_respond_msg", Headers: wsHeaders{ReqID: reqID}, Body: wsRespondMsgBody{ MsgType: "stream", Stream: &wsStreamContent{ ID: streamID, Finish: finish, Content: content, }, }, } if err := c.writeWSAndWait(cmd, wsRespondMsgTimeout); err != nil { logger.WarnCF("wecom_aibot", "Stream chunk ack failed", map[string]any{ "req_id": reqID, "stream_id": streamID, "finish": finish, "error": err, }) } } // wsSendStreamFinish sends the final aibot_respond_msg frame (finish=true, no images). func (c *WeComAIBotWSChannel) wsSendStreamFinish(reqID, streamID, content string) { c.wsSendStreamChunk(reqID, streamID, true, content) } // wsSendWelcomeMsg sends a text welcome message via aibot_respond_welcome_msg. func (c *WeComAIBotWSChannel) wsSendWelcomeMsg(reqID, content string) { logger.DebugCF("wecom_aibot", "Sending welcome message", map[string]any{"req_id": reqID}) cmd := wsCommand{ Cmd: "aibot_respond_welcome_msg", Headers: wsHeaders{ReqID: reqID}, Body: wsRespondMsgBody{ MsgType: "text", Text: &wsTextContent{Content: content}, }, } if err := c.writeWSAndWait(cmd, wsWelcomeMsgTimeout); err != nil { logger.WarnCF("wecom_aibot", "Welcome message ack failed", map[string]any{"req_id": reqID, "error": err.Error()}) } } // wsSendActivePush sends a proactive markdown message using aibot_send_msg. // Long content is automatically split into byte-bounded chunks (≤ wsStreamMaxContentBytes // each) and delivered as consecutive messages. // It is used as a fallback for late replies after stream response window expires. func (c *WeComAIBotWSChannel) wsSendActivePush(chatID string, chatType uint32, content string) error { if chatID == "" { return fmt.Errorf("chatid is empty") } for _, chunk := range splitWSContent(content, wsStreamMaxContentBytes) { reqID := wsGenerateID() if err := c.writeWSAndWait(wsCommand{ Cmd: "aibot_send_msg", Headers: wsHeaders{ReqID: reqID}, Body: wsSendMsgBody{ ChatID: chatID, ChatType: chatType, MsgType: "markdown", Markdown: &wsMarkdownContent{Content: chunk}, }, }, wsSendMsgTimeout); err != nil { return err } } return nil } // writeWSAndWait writes cmd to the active connection and validates the command response. func (c *WeComAIBotWSChannel) writeWSAndWait(cmd wsCommand, timeout time.Duration) error { if cmd.Headers.ReqID == "" { return fmt.Errorf("req_id is empty") } c.connMu.Lock() conn := c.conn c.connMu.Unlock() if conn == nil { return fmt.Errorf("websocket not connected") } resp, err := c.sendAndWait(conn, cmd.Headers.ReqID, cmd, timeout) if err != nil { return err } if resp.ErrCode != 0 { return fmt.Errorf("%s rejected (errcode=%d): %s", cmd.Cmd, resp.ErrCode, resp.ErrMsg) } return nil } // cancelAllTasks cancels every pending agent task; called when the connection drops. // It also expires each task's stream window (ReadyAt = now) so that when the agent // eventually delivers its reply via Send(), the message is forwarded via // wsSendActivePush on the restored connection instead of being silently discarded. func (c *WeComAIBotWSChannel) cancelAllTasks() { c.reqStatesMu.Lock() defer c.reqStatesMu.Unlock() now := time.Now() for _, state := range c.reqStates { if state != nil && state.Task != nil { state.Task.cancel() state.Task = nil // Expire the stream window immediately so Send() uses wsSendActivePush. state.Route.ReadyAt = now } } } func (c *WeComAIBotWSChannel) setReqState(reqID string, state *wsReqState) { c.reqStatesMu.Lock() defer c.reqStatesMu.Unlock() now := time.Now() for k, v := range c.reqStates { if v == nil || now.After(v.Route.ExpiresAt) { delete(c.reqStates, k) } } c.reqStates[reqID] = state } func (c *WeComAIBotWSChannel) getReqState(reqID string) (*wsTask, wsLateReplyRoute, bool) { c.reqStatesMu.Lock() defer c.reqStatesMu.Unlock() state, ok := c.reqStates[reqID] if !ok || state == nil { return nil, wsLateReplyRoute{}, false } if time.Now().After(state.Route.ExpiresAt) { delete(c.reqStates, reqID) return nil, wsLateReplyRoute{}, false } return state.Task, state.Route, true } func (c *WeComAIBotWSChannel) deleteReqState(reqID string) { c.reqStatesMu.Lock() delete(c.reqStates, reqID) c.reqStatesMu.Unlock() } func (c *WeComAIBotWSChannel) clearReqTask(reqID string, task *wsTask) { c.reqStatesMu.Lock() defer c.reqStatesMu.Unlock() state, ok := c.reqStates[reqID] if !ok || state == nil { return } if state.Task == task { state.Task = nil } } func wsChatTypeValue(chatType string) uint32 { if chatType == "group" { return 2 } return 1 } // wsChatID returns the effective chat ID from a WS message. // For group messages it is msg.ChatID; for single chats it falls back to the sender's UserID. func wsChatID(msg WeComAIBotWSMessage) string { if msg.ChatID != "" { return msg.ChatID } return msg.From.UserID } // wsGenerateID generates a random 10-character alphanumeric ID. // It is package-level (not a method) so it can be shared by both channel modes. func wsGenerateID() string { return generateRandomID(10) } // ---- Inbound media download helpers ---- // storeWSMedia downloads the resource at resourceURL (with optional AES-CBC // decryption) and stores it in the MediaStore. The file extension is inferred // from the HTTP Content-Type response header; defaultExt is used as a fallback // when the content type is absent or unrecognized. func (c *WeComAIBotWSChannel) storeWSMedia( ctx context.Context, chatID, msgID, resourceURL, aesKey, defaultExt string, ) (string, error) { store := c.GetMediaStore() if store == nil { return "", fmt.Errorf("no media store available") } const maxSize = 20 << 20 // 20 MB req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURL, nil) if err != nil { return "", fmt.Errorf("create request: %w", err) } resp, err := wsImageHTTPClient.Do(req) if err != nil { return "", fmt.Errorf("download: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("download HTTP %d", resp.StatusCode) } // Infer file extension from the Content-Type response header. ext := wsMediaExtFromContentType(resp.Header.Get("Content-Type")) if ext == "" { ext = defaultExt } // Buffer the media in memory, bounded to maxSize. data, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxSize)+1)) if err != nil { return "", fmt.Errorf("read media: %w", err) } if len(data) > maxSize { return "", fmt.Errorf("media too large (> %d MB)", maxSize>>20) } // AES-CBC decryption if a key is present. if aesKey != "" { key, decErr := base64.StdEncoding.DecodeString(aesKey) if decErr != nil || len(key) != 32 { key, decErr = decodeWeComAESKey(aesKey) if decErr != nil { return "", fmt.Errorf("decode media AES key: %w", decErr) } } data, err = decryptAESCBC(key, data) if err != nil { return "", fmt.Errorf("decrypt media: %w", err) } } // Write to a temp file. The file is owned by the MediaStore and deleted by // store.ReleaseAll — no caller-side cleanup needed. mediaDir := filepath.Join(os.TempDir(), "picoclaw_media") if err = os.MkdirAll(mediaDir, 0o700); err != nil { return "", fmt.Errorf("mkdir: %w", err) } tmpFile, err := os.CreateTemp(mediaDir, msgID+"-*"+ext) if err != nil { return "", fmt.Errorf("create temp file: %w", err) } tmpPath := tmpFile.Name() _, writeErr := tmpFile.Write(data) closeErr := tmpFile.Close() if writeErr != nil { os.Remove(tmpPath) return "", fmt.Errorf("write media: %w", writeErr) } if closeErr != nil { os.Remove(tmpPath) return "", fmt.Errorf("close media: %w", closeErr) } scope := channels.BuildMediaScope("wecom_aibot", chatID, msgID) ref, err := store.Store(tmpPath, media.MediaMeta{ Filename: msgID + ext, Source: "wecom_aibot", }, scope) if err != nil { os.Remove(tmpPath) return "", fmt.Errorf("store: %w", err) } return ref, nil } // wsMediaExtFromContentType returns the lowercase file extension (with leading // dot) for the given Content-Type value, or "" when the type is unrecognized. func wsMediaExtFromContentType(contentType string) string { if contentType == "" { return "" } // Strip parameters (e.g. "image/jpeg; charset=utf-8" → "image/jpeg"). mt := strings.ToLower(strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0])) switch mt { case "image/jpeg", "image/jpg": return ".jpg" case "image/png": return ".png" case "image/gif": return ".gif" case "image/webp": return ".webp" case "video/mp4": return ".mp4" case "video/mpeg", "video/x-mpeg": return ".mpeg" case "video/quicktime": return ".mov" case "video/webm": return ".webm" case "audio/mpeg", "audio/mp3": return ".mp3" case "audio/ogg": return ".ogg" case "audio/wav": return ".wav" case "application/pdf": return ".pdf" case "application/zip": return ".zip" case "application/x-rar-compressed", "application/vnd.rar": return ".rar" case "text/plain": return ".txt" case "application/msword": return ".doc" case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": return ".docx" case "application/vnd.ms-excel": return ".xls" case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": return ".xlsx" case "application/vnd.ms-powerpoint": return ".ppt" case "application/vnd.openxmlformats-officedocument.presentationml.presentation": return ".pptx" } return "" } // wsLabelToDefaultExt returns the default file extension for the given media label // used in wsHandleMediaMessage. It is the fallback when Content-Type detection fails. func wsLabelToDefaultExt(label string) string { switch label { case "image": return ".jpg" case "video": return ".mp4" default: // "file" and any future labels return ".bin" } } // ---- Content length helpers ---- // splitWSContent splits content into chunks each fitting within maxBytes UTF-8 // bytes, preserving code block integrity via channels.SplitMessage. // When SplitMessage still produces an oversized chunk (e.g. dense CJK content), // splitAtByteBoundary is applied as a last-resort byte-level fallback. func splitWSContent(content string, maxBytes int) []string { if len(content) <= maxBytes { return []string{content} } // SplitMessage works in runes. Use maxBytes as the rune limit: for pure ASCII // this is exact; for multibyte content the byte verification below catches // any chunk that still overflows. chunks := channels.SplitMessage(content, maxBytes) var result []string for _, chunk := range chunks { if len(chunk) <= maxBytes { result = append(result, chunk) } else { // Still too large in bytes (e.g. dense CJK); force-split at UTF-8 boundaries. result = append(result, splitAtByteBoundary(chunk, maxBytes)...) } } return result } // splitAtByteBoundary splits s into parts each ≤ maxBytes bytes by walking back // from the hard byte limit to find a valid UTF-8 rune start boundary. // This is a last-resort fallback; it does not try to preserve code blocks. func splitAtByteBoundary(s string, maxBytes int) []string { var parts []string for len(s) > maxBytes { end := maxBytes // Walk back past any UTF-8 continuation bytes (high two bits == 10). for end > 0 && s[end]>>6 == 0b10 { end-- } if end == 0 { end = maxBytes // shouldn't happen with valid UTF-8 } parts = append(parts, s[:end]) s = strings.TrimLeft(s[end:], " \t\n\r") } if s != "" { parts = append(parts, s) } return parts } ================================================ FILE: pkg/channels/wecom/aibot_ws_test.go ================================================ package wecom import ( "bytes" "context" "net/http" "net/http/httptest" "os" "strings" "testing" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/media" ) // newTestWSChannel creates a WeComAIBotWSChannel ready for unit testing. func newTestWSChannel(t *testing.T) *WeComAIBotWSChannel { t.Helper() cfg := config.WeComAIBotConfig{ Enabled: true, BotID: "test_bot_id", Secret: "test_secret", } ch, err := newWeComAIBotWSChannel(cfg, bus.NewMessageBus()) if err != nil { t.Fatalf("create WS channel: %v", err) } return ch } // TestStoreWSMedia_NilStore verifies that storeWSMedia returns an error when no // MediaStore has been injected. func TestStoreWSMedia_NilStore(t *testing.T) { ch := newTestWSChannel(t) _, err := ch.storeWSMedia(context.Background(), "chat1", "msg1", "http://any", "", ".jpg") if err == nil { t.Fatal("expected error when no MediaStore is set") } } // TestStoreWSMedia_HTTPError verifies that storeWSMedia propagates HTTP errors // from the media server. func TestStoreWSMedia_HTTPError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "not found", http.StatusNotFound) })) defer srv.Close() ch := newTestWSChannel(t) ch.SetMediaStore(media.NewFileMediaStore()) _, err := ch.storeWSMedia(context.Background(), "chat1", "msg1", srv.URL, "", ".jpg") if err == nil { t.Fatal("expected error for HTTP 404") } } // TestStoreWSMedia_ServerUnavailable verifies that storeWSMedia returns a clear // error when the media server cannot be reached. func TestStoreWSMedia_ServerUnavailable(t *testing.T) { ch := newTestWSChannel(t) ch.SetMediaStore(media.NewFileMediaStore()) // Port 1 is reserved and will refuse the connection immediately. _, err := ch.storeWSMedia(context.Background(), "chat1", "msg1", "http://127.0.0.1:1", "", ".jpg") if err == nil { t.Fatal("expected error for unreachable server") } } // TestStoreWSMedia_Success_NoAES verifies the happy path: the media is downloaded, // a media ref is returned, and the file persists and is readable via Resolve until // ReleaseAll is called. The server returns no Content-Type, so the defaultExt is used. func TestStoreWSMedia_Success_NoAES(t *testing.T) { imageData := bytes.Repeat([]byte("x"), 256) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write(imageData) })) defer srv.Close() ch := newTestWSChannel(t) store := media.NewFileMediaStore() ch.SetMediaStore(store) ref, err := ch.storeWSMedia(context.Background(), "chat1", "msg1", srv.URL, "", ".jpg") if err != nil { t.Fatalf("expected no error, got %v", err) } if ref == "" { t.Fatal("expected non-empty ref") } // File must be accessible after storeWSMedia returns (no premature deletion). path, err := store.Resolve(ref) if err != nil { t.Fatalf("ref should resolve: %v", err) } got, err := os.ReadFile(path) if err != nil { t.Fatalf("file should exist at %s: %v", path, err) } if !bytes.Equal(got, imageData) { t.Errorf("content mismatch: got len=%d, want len=%d", len(got), len(imageData)) } // ReleaseAll must delete the file (store owns lifecycle). scope := channels.BuildMediaScope("wecom_aibot", "chat1", "msg1") if err := store.ReleaseAll(scope); err != nil { t.Fatalf("ReleaseAll failed: %v", err) } if _, err := os.Stat(path); !os.IsNotExist(err) { t.Errorf("file should have been deleted by ReleaseAll, stat err: %v", err) } } // TestStoreWSMedia_MultipleMessages verifies that concurrent media messages with // different msgIDs do not collide and each resolve to distinct files. func TestStoreWSMedia_MultipleMessages(t *testing.T) { imageA := bytes.Repeat([]byte("a"), 64) imageB := bytes.Repeat([]byte("b"), 64) srvA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write(imageA) })) defer srvA.Close() srvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write(imageB) })) defer srvB.Close() ch := newTestWSChannel(t) store := media.NewFileMediaStore() ch.SetMediaStore(store) refA, err := ch.storeWSMedia(context.Background(), "chat1", "msgA", srvA.URL, "", ".jpg") if err != nil { t.Fatalf("storeWSMedia A: %v", err) } refB, err := ch.storeWSMedia(context.Background(), "chat1", "msgB", srvB.URL, "", ".jpg") if err != nil { t.Fatalf("storeWSMedia B: %v", err) } if refA == refB { t.Fatal("distinct messages must produce distinct refs") } pathA, _ := store.Resolve(refA) pathB, _ := store.Resolve(refB) if pathA == pathB { t.Fatal("distinct messages must be stored at distinct paths") } gotA, _ := os.ReadFile(pathA) gotB, _ := os.ReadFile(pathB) if !bytes.Equal(gotA, imageA) { t.Errorf("content mismatch for message A") } if !bytes.Equal(gotB, imageB) { t.Errorf("content mismatch for message B") } } // TestStoreWSMedia_ContentTypeExt verifies that the file extension is inferred // from the HTTP Content-Type header and the defaultExt fallback is used when the // type is absent or unrecognized. func TestStoreWSMedia_ContentTypeExt(t *testing.T) { tests := []struct { contentType string wantExt string }{ {"image/jpeg", ".jpg"}, {"image/png", ".png"}, {"video/mp4", ".mp4"}, {"application/pdf", ".pdf"}, {"application/zip", ".zip"}, // With parameters stripped. {"video/mp4; codecs=avc1", ".mp4"}, // Unknown type → falls back to defaultExt. {"", ""}, {"application/octet-stream", ""}, } for _, tc := range tests { got := wsMediaExtFromContentType(tc.contentType) if got != tc.wantExt { t.Errorf("wsMediaExtFromContentType(%q) = %q, want %q", tc.contentType, got, tc.wantExt) } } // End-to-end: server returns Content-Type: video/mp4, defaultExt is .bin. // The stored file should carry the .mp4 extension, not .bin. payload := bytes.Repeat([]byte("v"), 128) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "video/mp4") w.WriteHeader(http.StatusOK) _, _ = w.Write(payload) })) defer srv.Close() ch := newTestWSChannel(t) store := media.NewFileMediaStore() ch.SetMediaStore(store) ref, err := ch.storeWSMedia(context.Background(), "chat1", "vid1", srv.URL, "", ".bin") if err != nil { t.Fatalf("storeWSMedia: %v", err) } path, err := store.Resolve(ref) if err != nil { t.Fatalf("resolve: %v", err) } if ext := path[len(path)-4:]; ext != ".mp4" { t.Errorf("expected .mp4 extension from Content-Type, got %q", ext) } } // TestSplitWSContent verifies byte-aware splitting of stream content. func TestSplitWSContent(t *testing.T) { t.Run("short content is not split", func(t *testing.T) { chunks := splitWSContent("hello", 20480) if len(chunks) != 1 || chunks[0] != "hello" { t.Fatalf("unexpected chunks: %v", chunks) } }) t.Run("ASCII content split at byte boundary", func(t *testing.T) { // Build a string just over the limit. content := strings.Repeat("a", 20481) chunks := splitWSContent(content, 20480) if len(chunks) < 2 { t.Fatalf("expected >= 2 chunks, got %d", len(chunks)) } for i, c := range chunks { if len(c) > 20480 { t.Errorf("chunk %d has %d bytes, want <= 20480", i, len(c)) } } // Reassembled content must equal the original (possibly without leading // whitespace that splitWSContent trims between chunks). joined := strings.Join(chunks, "") if len(joined) < len(content)-len(chunks) { t.Errorf("joined length %d too short (original %d)", len(joined), len(content)) } }) t.Run("CJK content split within byte limit", func(t *testing.T) { // Each CJK rune is 3 bytes in UTF-8. // 7000 CJK chars = 21000 bytes, which exceeds 20480. content := strings.Repeat("\u4e2d", 7000) chunks := splitWSContent(content, 20480) if len(chunks) < 2 { t.Fatalf("expected >= 2 chunks for 21000-byte CJK content, got %d", len(chunks)) } for i, c := range chunks { if len(c) > 20480 { t.Errorf("chunk %d has %d bytes, want <= 20480", i, len(c)) } // Every chunk must be valid UTF-8. if !strings.ContainsRune(c, '\u4e2d') && len(c) > 0 { // quick plausibility check — content was pure CJK } } }) } // TestSplitAtByteBoundary verifies the last-resort byte-boundary splitter. func TestSplitAtByteBoundary(t *testing.T) { t.Run("ASCII fits in one chunk", func(t *testing.T) { parts := splitAtByteBoundary("hello world", 100) if len(parts) != 1 { t.Fatalf("expected 1 part, got %d", len(parts)) } }) t.Run("splits at byte boundary, never mid-rune", func(t *testing.T) { // 10 CJK characters = 30 bytes; split at 20 bytes. s := strings.Repeat("\u6587", 10) // 10 × 3 bytes = 30 bytes parts := splitAtByteBoundary(s, 20) for i, p := range parts { if len(p) > 20 { t.Errorf("part %d has %d bytes, want <= 20", i, len(p)) } // Must be valid UTF-8 (no torn multi-byte sequences). for j, r := range p { if r == '\uFFFD' { t.Errorf("part %d has replacement rune at position %d: torn UTF-8", i, j) } } } }) } ================================================ FILE: pkg/channels/wecom/app.go ================================================ package wecom import ( "bytes" "context" "encoding/json" "encoding/xml" "fmt" "io" "mime/multipart" "net/http" "net/url" "os" "path/filepath" "strings" "sync" "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) const ( wecomAPIBase = "https://qyapi.weixin.qq.com" ) // WeComAppChannel implements the Channel interface for WeCom App (企业微信自建应用) type WeComAppChannel struct { *channels.BaseChannel config config.WeComAppConfig client *http.Client accessToken string tokenExpiry time.Time tokenMu sync.RWMutex ctx context.Context cancel context.CancelFunc processedMsgs *MessageDeduplicator } // WeComXMLMessage represents the XML message structure from WeCom type WeComXMLMessage struct { XMLName xml.Name `xml:"xml"` ToUserName string `xml:"ToUserName"` FromUserName string `xml:"FromUserName"` CreateTime int64 `xml:"CreateTime"` MsgType string `xml:"MsgType"` Content string `xml:"Content"` MsgId int64 `xml:"MsgId"` AgentID int64 `xml:"AgentID"` PicUrl string `xml:"PicUrl"` MediaId string `xml:"MediaId"` Format string `xml:"Format"` ThumbMediaId string `xml:"ThumbMediaId"` LocationX float64 `xml:"Location_X"` LocationY float64 `xml:"Location_Y"` Scale int `xml:"Scale"` Label string `xml:"Label"` Title string `xml:"Title"` Description string `xml:"Description"` Url string `xml:"Url"` Event string `xml:"Event"` EventKey string `xml:"EventKey"` } // WeComTextMessage represents text message for sending type WeComTextMessage struct { ToUser string `json:"touser"` MsgType string `json:"msgtype"` AgentID int64 `json:"agentid"` Text struct { Content string `json:"content"` } `json:"text"` Safe int `json:"safe,omitempty"` } // WeComMarkdownMessage represents markdown message for sending type WeComMarkdownMessage struct { ToUser string `json:"touser"` MsgType string `json:"msgtype"` AgentID int64 `json:"agentid"` Markdown struct { Content string `json:"content"` } `json:"markdown"` } // WeComImageMessage represents image message for sending type WeComImageMessage struct { ToUser string `json:"touser"` MsgType string `json:"msgtype"` AgentID int64 `json:"agentid"` Image struct { MediaID string `json:"media_id"` } `json:"image"` } // WeComAccessTokenResponse represents the access token API response type WeComAccessTokenResponse struct { ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` } // WeComSendMessageResponse represents the send message API response type WeComSendMessageResponse struct { ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` InvalidUser string `json:"invaliduser"` InvalidParty string `json:"invalidparty"` InvalidTag string `json:"invalidtag"` } // PKCS7Padding adds PKCS7 padding type PKCS7Padding struct{} // NewWeComAppChannel creates a new WeCom App channel instance func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (*WeComAppChannel, error) { if cfg.CorpID == "" || cfg.CorpSecret == "" || cfg.AgentID == 0 { return nil, fmt.Errorf("wecom_app corp_id, corp_secret and agent_id are required") } base := channels.NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(2048), channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) // Client timeout must be >= the configured ReplyTimeout so the // per-request context deadline is always the effective limit. clientTimeout := 30 * time.Second if d := time.Duration(cfg.ReplyTimeout) * time.Second; d > clientTimeout { clientTimeout = d } ctx, cancel := context.WithCancel(context.Background()) return &WeComAppChannel{ BaseChannel: base, config: cfg, client: &http.Client{Timeout: clientTimeout}, ctx: ctx, cancel: cancel, processedMsgs: NewMessageDeduplicator(wecomMaxProcessedMessages), }, nil } // Name returns the channel name func (c *WeComAppChannel) Name() string { return "wecom_app" } // Start initializes the WeCom App channel func (c *WeComAppChannel) Start(ctx context.Context) error { logger.InfoC("wecom_app", "Starting WeCom App channel...") // Cancel the context created in the constructor to avoid a resource leak. if c.cancel != nil { c.cancel() } c.ctx, c.cancel = context.WithCancel(ctx) // Get initial access token if err := c.refreshAccessToken(); err != nil { logger.WarnCF("wecom_app", "Failed to get initial access token", map[string]any{ "error": err.Error(), }) } // Start token refresh goroutine go c.tokenRefreshLoop() c.SetRunning(true) logger.InfoC("wecom_app", "WeCom App channel started") return nil } // Stop gracefully stops the WeCom App channel func (c *WeComAppChannel) Stop(ctx context.Context) error { logger.InfoC("wecom_app", "Stopping WeCom App channel...") if c.cancel != nil { c.cancel() } c.SetRunning(false) logger.InfoC("wecom_app", "WeCom App channel stopped") return nil } // Send sends a message to WeCom user proactively using access token func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } accessToken := c.getAccessToken() if accessToken == "" { return fmt.Errorf("no valid access token available") } logger.DebugCF("wecom_app", "Sending message", map[string]any{ "chat_id": msg.ChatID, "preview": utils.Truncate(msg.Content, 100), }) return c.sendTextMessage(ctx, accessToken, msg.ChatID, msg.Content) } // SendMedia implements the channels.MediaSender interface. func (c *WeComAppChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } accessToken := c.getAccessToken() if accessToken == "" { return fmt.Errorf("no valid access token available: %w", channels.ErrTemporary) } store := c.GetMediaStore() if store == nil { return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } for _, part := range msg.Parts { localPath, err := store.Resolve(part.Ref) if err != nil { logger.ErrorCF("wecom_app", "Failed to resolve media ref", map[string]any{ "ref": part.Ref, "error": err.Error(), }) continue } // Map part type to WeCom media type var mediaType string switch part.Type { case "image": mediaType = "image" case "audio": mediaType = "voice" case "video": mediaType = "video" default: mediaType = "file" } // Upload media to get media_id mediaID, err := c.uploadMedia(ctx, accessToken, mediaType, localPath) if err != nil { logger.ErrorCF("wecom_app", "Failed to upload media", map[string]any{ "type": mediaType, "error": err.Error(), }) // Fallback: send caption as text if part.Caption != "" { _ = c.sendTextMessage(ctx, accessToken, msg.ChatID, part.Caption) } continue } // Send media message using the media_id if mediaType == "image" { err = c.sendImageMessage(ctx, accessToken, msg.ChatID, mediaID) } else { // For non-image types, send as text fallback with caption caption := part.Caption if caption == "" { caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename) } err = c.sendTextMessage(ctx, accessToken, msg.ChatID, caption) } if err != nil { return err } } return nil } // uploadMedia uploads a local file to WeCom temporary media storage. func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaType, localPath string) (string, error) { apiURL := fmt.Sprintf("%s/cgi-bin/media/upload?access_token=%s&type=%s", wecomAPIBase, url.QueryEscape(accessToken), url.QueryEscape(mediaType)) file, err := os.Open(localPath) if err != nil { return "", fmt.Errorf("failed to open file: %w", err) } defer file.Close() body := &bytes.Buffer{} writer := multipart.NewWriter(body) filename := filepath.Base(localPath) formFile, err := writer.CreateFormFile("media", filename) if err != nil { return "", fmt.Errorf("failed to create form file: %w", err) } if _, err = io.Copy(formFile, file); err != nil { return "", fmt.Errorf("failed to copy file content: %w", err) } writer.Close() req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, body) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", writer.FormDataContentType()) resp, err := c.client.Do(req) if err != nil { return "", channels.ClassifyNetError(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, readErr := io.ReadAll(resp.Body) if readErr != nil { return "", channels.ClassifySendError( resp.StatusCode, fmt.Errorf("reading wecom upload error response: %w", readErr), ) } return "", channels.ClassifySendError( resp.StatusCode, fmt.Errorf("wecom upload error: %s", string(respBody)), ) } var result struct { ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` MediaID string `json:"media_id"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", fmt.Errorf("failed to parse upload response: %w", err) } if result.ErrCode != 0 { return "", fmt.Errorf("upload API error: %s (code: %d)", result.ErrMsg, result.ErrCode) } return result.MediaID, nil } // sendWeComMessage marshals payload and POSTs it to the WeCom message API. func (c *WeComAppChannel) sendWeComMessage(ctx context.Context, accessToken string, payload any) error { apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken) jsonData, err := json.Marshal(payload) if err != nil { return fmt.Errorf("failed to marshal message: %w", err) } timeout := c.config.ReplyTimeout if timeout <= 0 { timeout = 5 } reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) defer cancel() req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := c.client.Do(req) if err != nil { return channels.ClassifyNetError(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, readErr := io.ReadAll(resp.Body) if readErr != nil { return channels.ClassifySendError( resp.StatusCode, fmt.Errorf("reading wecom_app error response: %w", readErr), ) } return channels.ClassifySendError( resp.StatusCode, fmt.Errorf("wecom_app API error: %s", string(respBody)), ) } respBody, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read response: %w", err) } var sendResp WeComSendMessageResponse if err := json.Unmarshal(respBody, &sendResp); err != nil { return fmt.Errorf("failed to parse response: %w", err) } if sendResp.ErrCode != 0 { return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode) } return nil } // sendImageMessage sends an image message using a media_id. func (c *WeComAppChannel) sendImageMessage(ctx context.Context, accessToken, userID, mediaID string) error { msg := WeComImageMessage{ ToUser: userID, MsgType: "image", AgentID: c.config.AgentID, } msg.Image.MediaID = mediaID return c.sendWeComMessage(ctx, accessToken, msg) } // WebhookPath returns the path for registering on the shared HTTP server. func (c *WeComAppChannel) WebhookPath() string { if c.config.WebhookPath != "" { return c.config.WebhookPath } return "/webhook/wecom-app" } // ServeHTTP implements http.Handler for the shared HTTP server. func (c *WeComAppChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { c.handleWebhook(w, r) } // HealthPath returns the health check endpoint path. func (c *WeComAppChannel) HealthPath() string { return "/health/wecom-app" } // HealthHandler handles health check requests. func (c *WeComAppChannel) HealthHandler(w http.ResponseWriter, r *http.Request) { c.handleHealth(w, r) } // handleWebhook handles incoming webhook requests from WeCom func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Log all incoming requests for debugging logger.DebugCF("wecom_app", "Received webhook request", map[string]any{ "method": r.Method, "url": r.URL.String(), "path": r.URL.Path, "query": r.URL.RawQuery, }) if r.Method == http.MethodGet { // Handle verification request c.handleVerification(ctx, w, r) return } if r.Method == http.MethodPost { // Handle message callback c.handleMessageCallback(ctx, w, r) return } logger.WarnCF("wecom_app", "Method not allowed", map[string]any{ "method": r.Method, }) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } // handleVerification handles the URL verification request from WeCom func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.ResponseWriter, r *http.Request) { query := r.URL.Query() msgSignature := query.Get("msg_signature") timestamp := query.Get("timestamp") nonce := query.Get("nonce") echostr := query.Get("echostr") logger.DebugCF("wecom_app", "Handling verification request", map[string]any{ "msg_signature": msgSignature, "timestamp": timestamp, "nonce": nonce, "echostr": echostr, "corp_id": c.config.CorpID, }) if msgSignature == "" || timestamp == "" || nonce == "" || echostr == "" { logger.ErrorC("wecom_app", "Missing parameters in verification request") http.Error(w, "Missing parameters", http.StatusBadRequest) return } // Verify signature if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) { logger.WarnCF("wecom_app", "Signature verification failed", map[string]any{ "token": c.config.Token, "msg_signature": msgSignature, "timestamp": timestamp, "nonce": nonce, }) http.Error(w, "Invalid signature", http.StatusForbidden) return } logger.DebugC("wecom_app", "Signature verification passed") // Decrypt echostr with CorpID verification // For WeCom App (自建应用), receiveid should be corp_id logger.DebugCF("wecom_app", "Attempting to decrypt echostr", map[string]any{ "encoding_aes_key": c.config.EncodingAESKey, "corp_id": c.config.CorpID, }) decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID) if err != nil { logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]any{ "error": err.Error(), "encoding_aes_key": c.config.EncodingAESKey, "corp_id": c.config.CorpID, }) http.Error(w, "Decryption failed", http.StatusInternalServerError) return } logger.DebugCF("wecom_app", "Successfully decrypted echostr", map[string]any{ "decrypted": decryptedEchoStr, }) // Remove BOM and whitespace as per WeCom documentation // The response must be plain text without quotes, BOM, or newlines decryptedEchoStr = strings.TrimSpace(decryptedEchoStr) decryptedEchoStr = strings.TrimPrefix(decryptedEchoStr, "\xef\xbb\xbf") // Remove UTF-8 BOM w.Write([]byte(decryptedEchoStr)) } // handleMessageCallback handles incoming messages from WeCom func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.ResponseWriter, r *http.Request) { query := r.URL.Query() msgSignature := query.Get("msg_signature") timestamp := query.Get("timestamp") nonce := query.Get("nonce") if msgSignature == "" || timestamp == "" || nonce == "" { http.Error(w, "Missing parameters", http.StatusBadRequest) return } // Read request body body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Failed to read body", http.StatusBadRequest) return } defer r.Body.Close() // Parse XML to get encrypted message var encryptedMsg struct { XMLName xml.Name `xml:"xml"` ToUserName string `xml:"ToUserName"` Encrypt string `xml:"Encrypt"` AgentID string `xml:"AgentID"` } if err = xml.Unmarshal(body, &encryptedMsg); err != nil { logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]any{ "error": err.Error(), }) http.Error(w, "Invalid XML", http.StatusBadRequest) return } // Verify signature if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { logger.WarnC("wecom_app", "Message signature verification failed") http.Error(w, "Invalid signature", http.StatusForbidden) return } // Decrypt message with CorpID verification // For WeCom App (自建应用), receiveid should be corp_id decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID) if err != nil { logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]any{ "error": err.Error(), }) http.Error(w, "Decryption failed", http.StatusInternalServerError) return } // Parse decrypted XML message var msg WeComXMLMessage if err := xml.Unmarshal([]byte(decryptedMsg), &msg); err != nil { logger.ErrorCF("wecom_app", "Failed to parse decrypted message", map[string]any{ "error": err.Error(), }) http.Error(w, "Invalid message format", http.StatusBadRequest) return } // Process the message with the channel's long-lived context (not the HTTP // request context, which is canceled as soon as we return the response). go c.processMessage(c.ctx, msg) // Return success response immediately // WeCom App requires response within configured timeout (default 5 seconds) w.Write([]byte("success")) } // processMessage processes the received message func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessage) { // Skip non-text messages for now (can be extended) if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" { logger.DebugCF("wecom_app", "Skipping non-supported message type", map[string]any{ "msg_type": msg.MsgType, }) return } // Message deduplication: Use msg_id to prevent duplicate processing // As per WeCom documentation, use msg_id for deduplication msgID := fmt.Sprintf("%d", msg.MsgId) if !c.processedMsgs.MarkMessageProcessed(msgID) { logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]any{ "msg_id": msgID, }) return } senderID := msg.FromUserName chatID := senderID // WeCom App uses user ID as chat ID for direct messages // Build metadata // WeCom App only supports direct messages (private chat) peer := bus.Peer{Kind: "direct", ID: senderID} messageID := fmt.Sprintf("%d", msg.MsgId) metadata := map[string]string{ "msg_type": msg.MsgType, "msg_id": fmt.Sprintf("%d", msg.MsgId), "agent_id": fmt.Sprintf("%d", msg.AgentID), "platform": "wecom_app", "media_id": msg.MediaId, "create_time": fmt.Sprintf("%d", msg.CreateTime), } content := msg.Content logger.DebugCF("wecom_app", "Received message", map[string]any{ "sender_id": senderID, "msg_type": msg.MsgType, "preview": utils.Truncate(content, 50), }) // Build sender info appSender := bus.SenderInfo{ Platform: "wecom", PlatformID: senderID, CanonicalID: identity.BuildCanonicalID("wecom", senderID), } // Handle the message through the base channel c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata, appSender) } // tokenRefreshLoop periodically refreshes the access token func (c *WeComAppChannel) tokenRefreshLoop() { ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() for { select { case <-c.ctx.Done(): return case <-ticker.C: if err := c.refreshAccessToken(); err != nil { logger.ErrorCF("wecom_app", "Failed to refresh access token", map[string]any{ "error": err.Error(), }) } } } } // refreshAccessToken gets a new access token from WeCom API func (c *WeComAppChannel) refreshAccessToken() error { apiURL := fmt.Sprintf("%s/cgi-bin/gettoken?corpid=%s&corpsecret=%s", wecomAPIBase, url.QueryEscape(c.config.CorpID), url.QueryEscape(c.config.CorpSecret)) resp, err := http.Get(apiURL) if err != nil { return fmt.Errorf("failed to request access token: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read response: %w", err) } var tokenResp WeComAccessTokenResponse if err := json.Unmarshal(body, &tokenResp); err != nil { return fmt.Errorf("failed to parse response: %w", err) } if tokenResp.ErrCode != 0 { return fmt.Errorf("API error: %s (code: %d)", tokenResp.ErrMsg, tokenResp.ErrCode) } c.tokenMu.Lock() c.accessToken = tokenResp.AccessToken c.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn-300) * time.Second) // Refresh 5 minutes early c.tokenMu.Unlock() logger.DebugC("wecom_app", "Access token refreshed successfully") return nil } // getAccessToken returns the current valid access token func (c *WeComAppChannel) getAccessToken() string { c.tokenMu.RLock() defer c.tokenMu.RUnlock() if time.Now().After(c.tokenExpiry) { return "" } return c.accessToken } // sendTextMessage sends a text message to a user. func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, userID, content string) error { msg := WeComTextMessage{ ToUser: userID, MsgType: "text", AgentID: c.config.AgentID, } msg.Text.Content = content return c.sendWeComMessage(ctx, accessToken, msg) } // handleHealth handles health check requests func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) { status := map[string]any{ "status": "ok", "running": c.IsRunning(), "has_token": c.getAccessToken() != "", } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(status) } ================================================ FILE: pkg/channels/wecom/app_test.go ================================================ package wecom import ( "bytes" "context" "crypto/aes" "crypto/cipher" "crypto/sha1" "encoding/base64" "encoding/binary" "encoding/json" "encoding/xml" "fmt" "net/http" "net/http/httptest" "sort" "strings" "testing" "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" ) // generateTestAESKeyApp generates a valid test AES key for WeCom App func generateTestAESKeyApp() string { // AES key needs to be 32 bytes (256 bits) for AES-256 key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } // Return base64 encoded key without padding return base64.StdEncoding.EncodeToString(key)[:43] } // encryptTestMessageApp encrypts a message for testing WeCom App func encryptTestMessageApp(message, aesKey string) (string, error) { // Decode AES key key, err := base64.StdEncoding.DecodeString(aesKey + "=") if err != nil { return "", err } // Prepare message: random(16) + msg_len(4) + msg + corp_id random := make([]byte, 0, 16) for i := range 16 { random = append(random, byte(i+1)) } msgBytes := []byte(message) corpID := []byte("test_corp_id") msgLen := uint32(len(msgBytes)) lenBytes := make([]byte, 4) binary.BigEndian.PutUint32(lenBytes, msgLen) plainText := append(random, lenBytes...) plainText = append(plainText, msgBytes...) plainText = append(plainText, corpID...) // PKCS7 padding blockSize := aes.BlockSize padding := blockSize - len(plainText)%blockSize padText := bytes.Repeat([]byte{byte(padding)}, padding) plainText = append(plainText, padText...) // Encrypt block, err := aes.NewCipher(key) if err != nil { return "", err } mode := cipher.NewCBCEncrypter(block, key[:aes.BlockSize]) cipherText := make([]byte, len(plainText)) mode.CryptBlocks(cipherText, plainText) return base64.StdEncoding.EncodeToString(cipherText), nil } // generateSignatureApp generates a signature for testing WeCom App func generateSignatureApp(token, timestamp, nonce, msgEncrypt string) string { params := []string{token, timestamp, nonce, msgEncrypt} sort.Strings(params) str := strings.Join(params, "") hash := sha1.Sum([]byte(str)) return fmt.Sprintf("%x", hash) } func TestNewWeComAppChannel(t *testing.T) { msgBus := bus.NewMessageBus() t.Run("missing corp_id", func(t *testing.T) { cfg := config.WeComAppConfig{ CorpID: "", CorpSecret: "test_secret", AgentID: 1000002, } _, err := NewWeComAppChannel(cfg, msgBus) if err == nil { t.Error("expected error for missing corp_id, got nil") } }) t.Run("missing corp_secret", func(t *testing.T) { cfg := config.WeComAppConfig{ CorpID: "test_corp_id", CorpSecret: "", AgentID: 1000002, } _, err := NewWeComAppChannel(cfg, msgBus) if err == nil { t.Error("expected error for missing corp_secret, got nil") } }) t.Run("missing agent_id", func(t *testing.T) { cfg := config.WeComAppConfig{ CorpID: "test_corp_id", CorpSecret: "test_secret", AgentID: 0, } _, err := NewWeComAppChannel(cfg, msgBus) if err == nil { t.Error("expected error for missing agent_id, got nil") } }) t.Run("valid config", func(t *testing.T) { cfg := config.WeComAppConfig{ CorpID: "test_corp_id", CorpSecret: "test_secret", AgentID: 1000002, AllowFrom: []string{"user1", "user2"}, } ch, err := NewWeComAppChannel(cfg, msgBus) if err != nil { t.Fatalf("unexpected error: %v", err) } if ch.Name() != "wecom_app" { t.Errorf("Name() = %q, want %q", ch.Name(), "wecom_app") } if ch.IsRunning() { t.Error("new channel should not be running") } }) } func TestWeComAppChannelIsAllowed(t *testing.T) { msgBus := bus.NewMessageBus() t.Run("empty allowlist allows all", func(t *testing.T) { cfg := config.WeComAppConfig{ CorpID: "test_corp_id", CorpSecret: "test_secret", AgentID: 1000002, AllowFrom: []string{}, } ch, _ := NewWeComAppChannel(cfg, msgBus) if !ch.IsAllowed("any_user") { t.Error("empty allowlist should allow all users") } }) t.Run("allowlist restricts users", func(t *testing.T) { cfg := config.WeComAppConfig{ CorpID: "test_corp_id", CorpSecret: "test_secret", AgentID: 1000002, AllowFrom: []string{"allowed_user"}, } ch, _ := NewWeComAppChannel(cfg, msgBus) if !ch.IsAllowed("allowed_user") { t.Error("allowed user should pass allowlist check") } if ch.IsAllowed("blocked_user") { t.Error("non-allowed user should be blocked") } }) } func TestWeComAppVerifySignature(t *testing.T) { msgBus := bus.NewMessageBus() cfg := config.WeComAppConfig{ CorpID: "test_corp_id", CorpSecret: "test_secret", AgentID: 1000002, Token: "test_token", } ch, _ := NewWeComAppChannel(cfg, msgBus) t.Run("valid signature", func(t *testing.T) { timestamp := "1234567890" nonce := "test_nonce" msgEncrypt := "test_message" expectedSig := generateSignatureApp("test_token", timestamp, nonce, msgEncrypt) if !verifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) { t.Error("valid signature should pass verification") } }) t.Run("invalid signature", func(t *testing.T) { timestamp := "1234567890" nonce := "test_nonce" msgEncrypt := "test_message" if verifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) { t.Error("invalid signature should fail verification") } }) t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) { cfgEmpty := config.WeComAppConfig{ CorpID: "test_corp_id", CorpSecret: "test_secret", AgentID: 1000002, Token: "", } chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus) if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { t.Error("empty token should reject verification (fail-closed)") } }) } func TestWeComAppDecryptMessage(t *testing.T) { msgBus := bus.NewMessageBus() t.Run("decrypt without AES key", func(t *testing.T) { cfg := config.WeComAppConfig{ CorpID: "test_corp_id", CorpSecret: "test_secret", AgentID: 1000002, EncodingAESKey: "", } ch, _ := NewWeComAppChannel(cfg, msgBus) // Without AES key, message should be base64 decoded only plainText := "hello world" encoded := base64.StdEncoding.EncodeToString([]byte(plainText)) result, err := decryptMessage(encoded, ch.config.EncodingAESKey) if err != nil { t.Fatalf("unexpected error: %v", err) } if result != plainText { t.Errorf("decryptMessage() = %q, want %q", result, plainText) } }) t.Run("decrypt with AES key", func(t *testing.T) { aesKey := generateTestAESKeyApp() cfg := config.WeComAppConfig{ CorpID: "test_corp_id", CorpSecret: "test_secret", AgentID: 1000002, EncodingAESKey: aesKey, } ch, _ := NewWeComAppChannel(cfg, msgBus) originalMsg := "Hello" encrypted, err := encryptTestMessageApp(originalMsg, aesKey) if err != nil { t.Fatalf("failed to encrypt test message: %v", err) } result, err := decryptMessage(encrypted, ch.config.EncodingAESKey) if err != nil { t.Fatalf("unexpected error: %v", err) } if result != originalMsg { t.Errorf("WeComDecryptMessage() = %q, want %q", result, originalMsg) } }) t.Run("invalid base64", func(t *testing.T) { cfg := config.WeComAppConfig{ CorpID: "test_corp_id", CorpSecret: "test_secret", AgentID: 1000002, EncodingAESKey: "", } ch, _ := NewWeComAppChannel(cfg, msgBus) _, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey) if err == nil { t.Error("expected error for invalid base64, got nil") } }) t.Run("invalid AES key", func(t *testing.T) { cfg := config.WeComAppConfig{ CorpID: "test_corp_id", CorpSecret: "test_secret", AgentID: 1000002, EncodingAESKey: "invalid_key", } ch, _ := NewWeComAppChannel(cfg, msgBus) _, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey) if err == nil { t.Error("expected error for invalid AES key, got nil") } }) t.Run("ciphertext too short", func(t *testing.T) { aesKey := generateTestAESKeyApp() cfg := config.WeComAppConfig{ CorpID: "test_corp_id", CorpSecret: "test_secret", AgentID: 1000002, EncodingAESKey: aesKey, } ch, _ := NewWeComAppChannel(cfg, msgBus) // Encrypt a very short message that results in ciphertext less than block size shortData := make([]byte, 8) _, err := decryptMessage(base64.StdEncoding.EncodeToString(shortData), ch.config.EncodingAESKey) if err == nil { t.Error("expected error for short ciphertext, got nil") } }) } func TestWeComAppHandleVerification(t *testing.T) { msgBus := bus.NewMessageBus() aesKey := generateTestAESKeyApp() cfg := config.WeComAppConfig{ CorpID: "test_corp_id", CorpSecret: "test_secret", AgentID: 1000002, Token: "test_token", EncodingAESKey: aesKey, } ch, _ := NewWeComAppChannel(cfg, msgBus) t.Run("valid verification request", func(t *testing.T) { echostr := "test_echostr_123" encryptedEchostr, _ := encryptTestMessageApp(echostr, aesKey) timestamp := "1234567890" nonce := "test_nonce" signature := generateSignatureApp("test_token", timestamp, nonce, encryptedEchostr) req := httptest.NewRequest( http.MethodGet, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil, ) w := httptest.NewRecorder() ch.handleVerification(context.Background(), w, req) if w.Code != http.StatusOK { t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) } if w.Body.String() != echostr { t.Errorf("response body = %q, want %q", w.Body.String(), echostr) } }) t.Run("missing parameters", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature=sig×tamp=ts", nil) w := httptest.NewRecorder() ch.handleVerification(context.Background(), w, req) if w.Code != http.StatusBadRequest { t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest) } }) t.Run("invalid signature", func(t *testing.T) { echostr := "test_echostr" encryptedEchostr, _ := encryptTestMessageApp(echostr, aesKey) timestamp := "1234567890" nonce := "test_nonce" req := httptest.NewRequest( http.MethodGet, "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil, ) w := httptest.NewRecorder() ch.handleVerification(context.Background(), w, req) if w.Code != http.StatusForbidden { t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden) } }) } func TestWeComAppHandleMessageCallback(t *testing.T) { msgBus := bus.NewMessageBus() aesKey := generateTestAESKeyApp() cfg := config.WeComAppConfig{ CorpID: "test_corp_id", CorpSecret: "test_secret", AgentID: 1000002, Token: "test_token", EncodingAESKey: aesKey, } ch, _ := NewWeComAppChannel(cfg, msgBus) t.Run("valid message callback", func(t *testing.T) { // Create XML message xmlMsg := WeComXMLMessage{ ToUserName: "corp_id", FromUserName: "user123", CreateTime: 1234567890, MsgType: "text", Content: "Hello World", MsgId: 123456, AgentID: 1000002, } xmlData, _ := xml.Marshal(xmlMsg) // Encrypt message encrypted, _ := encryptTestMessageApp(string(xmlData), aesKey) // Create encrypted XML wrapper encryptedWrapper := struct { XMLName xml.Name `xml:"xml"` Encrypt string `xml:"Encrypt"` }{ Encrypt: encrypted, } wrapperData, _ := xml.Marshal(encryptedWrapper) timestamp := "1234567890" nonce := "test_nonce" signature := generateSignatureApp("test_token", timestamp, nonce, encrypted) req := httptest.NewRequest( http.MethodPost, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData), ) w := httptest.NewRecorder() ch.handleMessageCallback(context.Background(), w, req) if w.Code != http.StatusOK { t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) } if w.Body.String() != "success" { t.Errorf("response body = %q, want %q", w.Body.String(), "success") } }) t.Run("missing parameters", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature=sig", nil) w := httptest.NewRecorder() ch.handleMessageCallback(context.Background(), w, req) if w.Code != http.StatusBadRequest { t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest) } }) t.Run("invalid XML", func(t *testing.T) { timestamp := "1234567890" nonce := "test_nonce" signature := generateSignatureApp("test_token", timestamp, nonce, "") req := httptest.NewRequest( http.MethodPost, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, strings.NewReader("invalid xml"), ) w := httptest.NewRecorder() ch.handleMessageCallback(context.Background(), w, req) if w.Code != http.StatusBadRequest { t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest) } }) t.Run("invalid signature", func(t *testing.T) { encryptedWrapper := struct { XMLName xml.Name `xml:"xml"` Encrypt string `xml:"Encrypt"` }{ Encrypt: "encrypted_data", } wrapperData, _ := xml.Marshal(encryptedWrapper) timestamp := "1234567890" nonce := "test_nonce" req := httptest.NewRequest( http.MethodPost, "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData), ) w := httptest.NewRecorder() ch.handleMessageCallback(context.Background(), w, req) if w.Code != http.StatusForbidden { t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden) } }) } func TestWeComAppProcessMessage(t *testing.T) { msgBus := bus.NewMessageBus() cfg := config.WeComAppConfig{ CorpID: "test_corp_id", CorpSecret: "test_secret", AgentID: 1000002, } ch, _ := NewWeComAppChannel(cfg, msgBus) t.Run("process text message", func(t *testing.T) { msg := WeComXMLMessage{ ToUserName: "corp_id", FromUserName: "user123", CreateTime: 1234567890, MsgType: "text", Content: "Hello World", MsgId: 123456, AgentID: 1000002, } // Should not panic ch.processMessage(context.Background(), msg) }) t.Run("process image message", func(t *testing.T) { msg := WeComXMLMessage{ ToUserName: "corp_id", FromUserName: "user123", CreateTime: 1234567890, MsgType: "image", PicUrl: "https://example.com/image.jpg", MediaId: "media_123", MsgId: 123456, AgentID: 1000002, } // Should not panic ch.processMessage(context.Background(), msg) }) t.Run("process voice message", func(t *testing.T) { msg := WeComXMLMessage{ ToUserName: "corp_id", FromUserName: "user123", CreateTime: 1234567890, MsgType: "voice", MediaId: "media_123", Format: "amr", MsgId: 123456, AgentID: 1000002, } // Should not panic ch.processMessage(context.Background(), msg) }) t.Run("skip unsupported message type", func(t *testing.T) { msg := WeComXMLMessage{ ToUserName: "corp_id", FromUserName: "user123", CreateTime: 1234567890, MsgType: "video", MsgId: 123456, AgentID: 1000002, } // Should not panic ch.processMessage(context.Background(), msg) }) t.Run("process event message", func(t *testing.T) { msg := WeComXMLMessage{ ToUserName: "corp_id", FromUserName: "user123", CreateTime: 1234567890, MsgType: "event", Event: "subscribe", MsgId: 123456, AgentID: 1000002, } // Should not panic ch.processMessage(context.Background(), msg) }) } func TestWeComAppHandleWebhook(t *testing.T) { msgBus := bus.NewMessageBus() cfg := config.WeComAppConfig{ CorpID: "test_corp_id", CorpSecret: "test_secret", AgentID: 1000002, Token: "test_token", } ch, _ := NewWeComAppChannel(cfg, msgBus) t.Run("GET request calls verification", func(t *testing.T) { echostr := "test_echostr" encoded := base64.StdEncoding.EncodeToString([]byte(echostr)) timestamp := "1234567890" nonce := "test_nonce" signature := generateSignatureApp("test_token", timestamp, nonce, encoded) req := httptest.NewRequest( http.MethodGet, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, nil, ) w := httptest.NewRecorder() ch.handleWebhook(w, req) if w.Code != http.StatusOK { t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) } }) t.Run("POST request calls message callback", func(t *testing.T) { encryptedWrapper := struct { XMLName xml.Name `xml:"xml"` Encrypt string `xml:"Encrypt"` }{ Encrypt: base64.StdEncoding.EncodeToString([]byte("test")), } wrapperData, _ := xml.Marshal(encryptedWrapper) timestamp := "1234567890" nonce := "test_nonce" signature := generateSignatureApp("test_token", timestamp, nonce, encryptedWrapper.Encrypt) req := httptest.NewRequest( http.MethodPost, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData), ) w := httptest.NewRecorder() ch.handleWebhook(w, req) // Should not be method not allowed if w.Code == http.StatusMethodNotAllowed { t.Error("POST request should not return Method Not Allowed") } }) t.Run("unsupported method", func(t *testing.T) { req := httptest.NewRequest(http.MethodPut, "/webhook/wecom-app", nil) w := httptest.NewRecorder() ch.handleWebhook(w, req) if w.Code != http.StatusMethodNotAllowed { t.Errorf("status code = %d, want %d", w.Code, http.StatusMethodNotAllowed) } }) } func TestWeComAppHandleHealth(t *testing.T) { msgBus := bus.NewMessageBus() cfg := config.WeComAppConfig{ CorpID: "test_corp_id", CorpSecret: "test_secret", AgentID: 1000002, } ch, _ := NewWeComAppChannel(cfg, msgBus) req := httptest.NewRequest(http.MethodGet, "/health/wecom-app", nil) w := httptest.NewRecorder() ch.handleHealth(w, req) if w.Code != http.StatusOK { t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) } contentType := w.Header().Get("Content-Type") if contentType != "application/json" { t.Errorf("Content-Type = %q, want %q", contentType, "application/json") } body := w.Body.String() if !strings.Contains(body, "status") || !strings.Contains(body, "running") || !strings.Contains(body, "has_token") { t.Errorf("response body should contain status, running, and has_token fields, got: %s", body) } } func TestWeComAppAccessToken(t *testing.T) { msgBus := bus.NewMessageBus() cfg := config.WeComAppConfig{ CorpID: "test_corp_id", CorpSecret: "test_secret", AgentID: 1000002, } ch, _ := NewWeComAppChannel(cfg, msgBus) t.Run("get empty access token initially", func(t *testing.T) { token := ch.getAccessToken() if token != "" { t.Errorf("getAccessToken() = %q, want empty string", token) } }) t.Run("set and get access token", func(t *testing.T) { ch.tokenMu.Lock() ch.accessToken = "test_token_123" ch.tokenExpiry = time.Now().Add(1 * time.Hour) ch.tokenMu.Unlock() token := ch.getAccessToken() if token != "test_token_123" { t.Errorf("getAccessToken() = %q, want %q", token, "test_token_123") } }) t.Run("expired token returns empty", func(t *testing.T) { ch.tokenMu.Lock() ch.accessToken = "expired_token" ch.tokenExpiry = time.Now().Add(-1 * time.Hour) ch.tokenMu.Unlock() token := ch.getAccessToken() if token != "" { t.Errorf("getAccessToken() = %q, want empty string for expired token", token) } }) } func TestWeComAppMessageStructures(t *testing.T) { t.Run("WeComTextMessage structure", func(t *testing.T) { msg := WeComTextMessage{ ToUser: "user123", MsgType: "text", AgentID: 1000002, } msg.Text.Content = "Hello World" if msg.ToUser != "user123" { t.Errorf("ToUser = %q, want %q", msg.ToUser, "user123") } if msg.MsgType != "text" { t.Errorf("MsgType = %q, want %q", msg.MsgType, "text") } if msg.AgentID != 1000002 { t.Errorf("AgentID = %d, want %d", msg.AgentID, 1000002) } if msg.Text.Content != "Hello World" { t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World") } // Test JSON marshaling jsonData, err := json.Marshal(msg) if err != nil { t.Fatalf("failed to marshal JSON: %v", err) } var unmarshaled WeComTextMessage err = json.Unmarshal(jsonData, &unmarshaled) if err != nil { t.Fatalf("failed to unmarshal JSON: %v", err) } if unmarshaled.ToUser != msg.ToUser { t.Errorf("JSON round-trip failed for ToUser") } }) t.Run("WeComMarkdownMessage structure", func(t *testing.T) { msg := WeComMarkdownMessage{ ToUser: "user123", MsgType: "markdown", AgentID: 1000002, } msg.Markdown.Content = "# Hello\nWorld" if msg.Markdown.Content != "# Hello\nWorld" { t.Errorf("Markdown.Content = %q, want %q", msg.Markdown.Content, "# Hello\nWorld") } // Test JSON marshaling jsonData, err := json.Marshal(msg) if err != nil { t.Fatalf("failed to marshal JSON: %v", err) } if !bytes.Contains(jsonData, []byte("markdown")) { t.Error("JSON should contain 'markdown' field") } }) t.Run("WeComImageMessage structure", func(t *testing.T) { msg := WeComImageMessage{ ToUser: "user123", MsgType: "image", AgentID: 1000002, } msg.Image.MediaID = "media_123456" if msg.Image.MediaID != "media_123456" { t.Errorf("Image.MediaID = %q, want %q", msg.Image.MediaID, "media_123456") } if msg.ToUser != "user123" { t.Errorf("ToUser = %q, want %q", msg.ToUser, "user123") } if msg.MsgType != "image" { t.Errorf("MsgType = %q, want %q", msg.MsgType, "image") } if msg.AgentID != 1000002 { t.Errorf("AgentID = %d, want %d", msg.AgentID, 1000002) } }) t.Run("WeComAccessTokenResponse structure", func(t *testing.T) { jsonData := `{ "errcode": 0, "errmsg": "ok", "access_token": "test_access_token", "expires_in": 7200 }` var resp WeComAccessTokenResponse err := json.Unmarshal([]byte(jsonData), &resp) if err != nil { t.Fatalf("failed to unmarshal JSON: %v", err) } if resp.ErrCode != 0 { t.Errorf("ErrCode = %d, want %d", resp.ErrCode, 0) } if resp.ErrMsg != "ok" { t.Errorf("ErrMsg = %q, want %q", resp.ErrMsg, "ok") } if resp.AccessToken != "test_access_token" { t.Errorf("AccessToken = %q, want %q", resp.AccessToken, "test_access_token") } if resp.ExpiresIn != 7200 { t.Errorf("ExpiresIn = %d, want %d", resp.ExpiresIn, 7200) } }) t.Run("WeComSendMessageResponse structure", func(t *testing.T) { jsonData := `{ "errcode": 0, "errmsg": "ok", "invaliduser": "", "invalidparty": "", "invalidtag": "" }` var resp WeComSendMessageResponse err := json.Unmarshal([]byte(jsonData), &resp) if err != nil { t.Fatalf("failed to unmarshal JSON: %v", err) } if resp.ErrCode != 0 { t.Errorf("ErrCode = %d, want %d", resp.ErrCode, 0) } if resp.ErrMsg != "ok" { t.Errorf("ErrMsg = %q, want %q", resp.ErrMsg, "ok") } }) } func TestWeComAppXMLMessageStructure(t *testing.T) { xmlData := ` 1234567890 1234567890123456 1000002 ` var msg WeComXMLMessage err := xml.Unmarshal([]byte(xmlData), &msg) if err != nil { t.Fatalf("failed to unmarshal XML: %v", err) } if msg.ToUserName != "corp_id" { t.Errorf("ToUserName = %q, want %q", msg.ToUserName, "corp_id") } if msg.FromUserName != "user123" { t.Errorf("FromUserName = %q, want %q", msg.FromUserName, "user123") } if msg.CreateTime != 1234567890 { t.Errorf("CreateTime = %d, want %d", msg.CreateTime, 1234567890) } if msg.MsgType != "text" { t.Errorf("MsgType = %q, want %q", msg.MsgType, "text") } if msg.Content != "Hello World" { t.Errorf("Content = %q, want %q", msg.Content, "Hello World") } if msg.MsgId != 1234567890123456 { t.Errorf("MsgId = %d, want %d", msg.MsgId, 1234567890123456) } if msg.AgentID != 1000002 { t.Errorf("AgentID = %d, want %d", msg.AgentID, 1000002) } } func TestWeComAppXMLMessageImage(t *testing.T) { xmlData := ` 1234567890 1234567890123456 1000002 ` var msg WeComXMLMessage err := xml.Unmarshal([]byte(xmlData), &msg) if err != nil { t.Fatalf("failed to unmarshal XML: %v", err) } if msg.MsgType != "image" { t.Errorf("MsgType = %q, want %q", msg.MsgType, "image") } if msg.PicUrl != "https://example.com/image.jpg" { t.Errorf("PicUrl = %q, want %q", msg.PicUrl, "https://example.com/image.jpg") } if msg.MediaId != "media_123" { t.Errorf("MediaId = %q, want %q", msg.MediaId, "media_123") } } func TestWeComAppXMLMessageVoice(t *testing.T) { xmlData := ` 1234567890 1234567890123456 1000002 ` var msg WeComXMLMessage err := xml.Unmarshal([]byte(xmlData), &msg) if err != nil { t.Fatalf("failed to unmarshal XML: %v", err) } if msg.MsgType != "voice" { t.Errorf("MsgType = %q, want %q", msg.MsgType, "voice") } if msg.Format != "amr" { t.Errorf("Format = %q, want %q", msg.Format, "amr") } } func TestWeComAppXMLMessageLocation(t *testing.T) { xmlData := ` 1234567890 39.9042 116.4074 16 1234567890123456 1000002 ` var msg WeComXMLMessage err := xml.Unmarshal([]byte(xmlData), &msg) if err != nil { t.Fatalf("failed to unmarshal XML: %v", err) } if msg.MsgType != "location" { t.Errorf("MsgType = %q, want %q", msg.MsgType, "location") } if msg.LocationX != 39.9042 { t.Errorf("LocationX = %f, want %f", msg.LocationX, 39.9042) } if msg.LocationY != 116.4074 { t.Errorf("LocationY = %f, want %f", msg.LocationY, 116.4074) } if msg.Scale != 16 { t.Errorf("Scale = %d, want %d", msg.Scale, 16) } if msg.Label != "Beijing" { t.Errorf("Label = %q, want %q", msg.Label, "Beijing") } } func TestWeComAppXMLMessageLink(t *testing.T) { xmlData := ` 1234567890 <![CDATA[Link Title]]> 1234567890123456 1000002 ` var msg WeComXMLMessage err := xml.Unmarshal([]byte(xmlData), &msg) if err != nil { t.Fatalf("failed to unmarshal XML: %v", err) } if msg.MsgType != "link" { t.Errorf("MsgType = %q, want %q", msg.MsgType, "link") } if msg.Title != "Link Title" { t.Errorf("Title = %q, want %q", msg.Title, "Link Title") } if msg.Description != "Link Description" { t.Errorf("Description = %q, want %q", msg.Description, "Link Description") } if msg.Url != "https://example.com" { t.Errorf("Url = %q, want %q", msg.Url, "https://example.com") } } func TestWeComAppXMLMessageEvent(t *testing.T) { xmlData := ` 1234567890 1000002 ` var msg WeComXMLMessage err := xml.Unmarshal([]byte(xmlData), &msg) if err != nil { t.Fatalf("failed to unmarshal XML: %v", err) } if msg.MsgType != "event" { t.Errorf("MsgType = %q, want %q", msg.MsgType, "event") } if msg.Event != "subscribe" { t.Errorf("Event = %q, want %q", msg.Event, "subscribe") } if msg.EventKey != "event_key_123" { t.Errorf("EventKey = %q, want %q", msg.EventKey, "event_key_123") } } ================================================ FILE: pkg/channels/wecom/bot.go ================================================ package wecom import ( "bytes" "context" "encoding/json" "encoding/xml" "fmt" "io" "net/http" "strings" "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) // WeComBotChannel implements the Channel interface for WeCom Bot (企业微信智能机器人) // Uses webhook callback mode - simpler than WeCom App but only supports passive replies type WeComBotChannel struct { *channels.BaseChannel config config.WeComConfig client *http.Client ctx context.Context cancel context.CancelFunc processedMsgs *MessageDeduplicator } // WeComBotMessage represents the JSON message structure from WeCom Bot (AIBOT) type WeComBotMessage struct { MsgID string `json:"msgid"` AIBotID string `json:"aibotid"` ChatID string `json:"chatid"` // Session ID, only present for group chats ChatType string `json:"chattype"` // "single" for DM, "group" for group chat From struct { UserID string `json:"userid"` } `json:"from"` ResponseURL string `json:"response_url"` MsgType string `json:"msgtype"` // text, image, voice, file, mixed Text struct { Content string `json:"content"` } `json:"text"` Image struct { URL string `json:"url"` } `json:"image"` Voice struct { Content string `json:"content"` // Voice to text content } `json:"voice"` File struct { URL string `json:"url"` } `json:"file"` Mixed struct { MsgItem []struct { MsgType string `json:"msgtype"` Text struct { Content string `json:"content"` } `json:"text"` Image struct { URL string `json:"url"` } `json:"image"` } `json:"msg_item"` } `json:"mixed"` Quote struct { MsgType string `json:"msgtype"` Text struct { Content string `json:"content"` } `json:"text"` } `json:"quote"` } // WeComBotReplyMessage represents the reply message structure type WeComBotReplyMessage struct { MsgType string `json:"msgtype"` Text struct { Content string `json:"content"` } `json:"text,omitempty"` } // NewWeComBotChannel creates a new WeCom Bot channel instance func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*WeComBotChannel, error) { if cfg.Token == "" || cfg.WebhookURL == "" { return nil, fmt.Errorf("wecom token and webhook_url are required") } base := channels.NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(2048), channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) // Client timeout must be >= the configured ReplyTimeout so the // per-request context deadline is always the effective limit. clientTimeout := 30 * time.Second if d := time.Duration(cfg.ReplyTimeout) * time.Second; d > clientTimeout { clientTimeout = d } ctx, cancel := context.WithCancel(context.Background()) return &WeComBotChannel{ BaseChannel: base, config: cfg, client: &http.Client{Timeout: clientTimeout}, ctx: ctx, cancel: cancel, processedMsgs: NewMessageDeduplicator(wecomMaxProcessedMessages), }, nil } // Name returns the channel name func (c *WeComBotChannel) Name() string { return "wecom" } // Start initializes the WeCom Bot channel func (c *WeComBotChannel) Start(ctx context.Context) error { logger.InfoC("wecom", "Starting WeCom Bot channel...") // Cancel the context created in the constructor to avoid a resource leak. if c.cancel != nil { c.cancel() } c.ctx, c.cancel = context.WithCancel(ctx) c.SetRunning(true) logger.InfoC("wecom", "WeCom Bot channel started") return nil } // Stop gracefully stops the WeCom Bot channel func (c *WeComBotChannel) Stop(ctx context.Context) error { logger.InfoC("wecom", "Stopping WeCom Bot channel...") if c.cancel != nil { c.cancel() } c.SetRunning(false) logger.InfoC("wecom", "WeCom Bot channel stopped") return nil } // Send sends a message to WeCom user via webhook API // Note: WeCom Bot can only reply within the configured timeout (default 5 seconds) of receiving a message // For delayed responses, we use the webhook URL func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } logger.DebugCF("wecom", "Sending message via webhook", map[string]any{ "chat_id": msg.ChatID, "preview": utils.Truncate(msg.Content, 100), }) return c.sendWebhookReply(ctx, msg.ChatID, msg.Content) } // WebhookPath returns the path for registering on the shared HTTP server. func (c *WeComBotChannel) WebhookPath() string { if c.config.WebhookPath != "" { return c.config.WebhookPath } return "/webhook/wecom" } // ServeHTTP implements http.Handler for the shared HTTP server. func (c *WeComBotChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { c.handleWebhook(w, r) } // HealthPath returns the health check endpoint path. func (c *WeComBotChannel) HealthPath() string { return "/health/wecom" } // HealthHandler handles health check requests. func (c *WeComBotChannel) HealthHandler(w http.ResponseWriter, r *http.Request) { c.handleHealth(w, r) } // handleWebhook handles incoming webhook requests from WeCom func (c *WeComBotChannel) handleWebhook(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if r.Method == http.MethodGet { // Handle verification request c.handleVerification(ctx, w, r) return } if r.Method == http.MethodPost { // Handle message callback c.handleMessageCallback(ctx, w, r) return } http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } // handleVerification handles the URL verification request from WeCom func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.ResponseWriter, r *http.Request) { query := r.URL.Query() msgSignature := query.Get("msg_signature") timestamp := query.Get("timestamp") nonce := query.Get("nonce") echostr := query.Get("echostr") if msgSignature == "" || timestamp == "" || nonce == "" || echostr == "" { http.Error(w, "Missing parameters", http.StatusBadRequest) return } // Verify signature if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) { logger.WarnC("wecom", "Signature verification failed") http.Error(w, "Invalid signature", http.StatusForbidden) return } // Decrypt echostr // For AIBOT (智能机器人), receiveid should be empty string "" // Reference: https://developer.work.weixin.qq.com/document/path/101033 decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, "") if err != nil { logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]any{ "error": err.Error(), }) http.Error(w, "Decryption failed", http.StatusInternalServerError) return } // Remove BOM and whitespace as per WeCom documentation // The response must be plain text without quotes, BOM, or newlines decryptedEchoStr = strings.TrimSpace(decryptedEchoStr) decryptedEchoStr = strings.TrimPrefix(decryptedEchoStr, "\xef\xbb\xbf") // Remove UTF-8 BOM w.Write([]byte(decryptedEchoStr)) } // handleMessageCallback handles incoming messages from WeCom func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.ResponseWriter, r *http.Request) { query := r.URL.Query() msgSignature := query.Get("msg_signature") timestamp := query.Get("timestamp") nonce := query.Get("nonce") if msgSignature == "" || timestamp == "" || nonce == "" { http.Error(w, "Missing parameters", http.StatusBadRequest) return } // Read request body body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Failed to read body", http.StatusBadRequest) return } defer r.Body.Close() // Parse XML to get encrypted message var encryptedMsg struct { XMLName xml.Name `xml:"xml"` ToUserName string `xml:"ToUserName"` Encrypt string `xml:"Encrypt"` AgentID string `xml:"AgentID"` } if err = xml.Unmarshal(body, &encryptedMsg); err != nil { logger.ErrorCF("wecom", "Failed to parse XML", map[string]any{ "error": err.Error(), }) http.Error(w, "Invalid XML", http.StatusBadRequest) return } // Verify signature if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { logger.WarnC("wecom", "Message signature verification failed") http.Error(w, "Invalid signature", http.StatusForbidden) return } // Decrypt message // For AIBOT (智能机器人), receiveid should be empty string "" // Reference: https://developer.work.weixin.qq.com/document/path/101033 decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "") if err != nil { logger.ErrorCF("wecom", "Failed to decrypt message", map[string]any{ "error": err.Error(), }) http.Error(w, "Decryption failed", http.StatusInternalServerError) return } // Parse decrypted JSON message (AIBOT uses JSON format) var msg WeComBotMessage if err := json.Unmarshal([]byte(decryptedMsg), &msg); err != nil { logger.ErrorCF("wecom", "Failed to parse decrypted message", map[string]any{ "error": err.Error(), }) http.Error(w, "Invalid message format", http.StatusBadRequest) return } // Process the message with the channel's long-lived context (not the HTTP // request context, which is canceled as soon as we return the response). go c.processMessage(c.ctx, msg) // Return success response immediately // WeCom Bot requires response within configured timeout (default 5 seconds) w.Write([]byte("success")) } // processMessage processes the received message func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessage) { // Skip unsupported message types if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" && msg.MsgType != "file" && msg.MsgType != "mixed" { logger.DebugCF("wecom", "Skipping non-supported message type", map[string]any{ "msg_type": msg.MsgType, }) return } // Message deduplication: Use msg_id to prevent duplicate processing msgID := msg.MsgID if !c.processedMsgs.MarkMessageProcessed(msgID) { logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{ "msg_id": msgID, }) return } senderID := msg.From.UserID // Determine if this is a group chat or direct message // ChatType: "single" for DM, "group" for group chat isGroupChat := msg.ChatType == "group" var chatID, peerKind, peerID string if isGroupChat { // Group chat: use ChatID as chatID and peer_id chatID = msg.ChatID peerKind = "group" peerID = msg.ChatID } else { // Direct message: use senderID as chatID and peer_id chatID = senderID peerKind = "direct" peerID = senderID } // Extract content based on message type var content string switch msg.MsgType { case "text": content = msg.Text.Content case "voice": content = msg.Voice.Content // Voice to text content case "mixed": // For mixed messages, concatenate text items for _, item := range msg.Mixed.MsgItem { if item.MsgType == "text" { content += item.Text.Content } } case "image", "file": // For image and file, we don't have text content content = "" } // Build metadata peer := bus.Peer{Kind: peerKind, ID: peerID} // In group chats, apply unified group trigger filtering if isGroupChat { respond, cleaned := c.ShouldRespondInGroup(false, content) if !respond { return } content = cleaned } metadata := map[string]string{ "msg_type": msg.MsgType, "msg_id": msg.MsgID, "platform": "wecom", "response_url": msg.ResponseURL, } if isGroupChat { metadata["chat_id"] = msg.ChatID metadata["sender_id"] = senderID } logger.DebugCF("wecom", "Received message", map[string]any{ "sender_id": senderID, "msg_type": msg.MsgType, "peer_kind": peerKind, "is_group_chat": isGroupChat, "preview": utils.Truncate(content, 50), }) // Build sender info sender := bus.SenderInfo{ Platform: "wecom", PlatformID: senderID, CanonicalID: identity.BuildCanonicalID("wecom", senderID), } if !c.IsAllowedSender(sender) { return } // Handle the message through the base channel c.HandleMessage(ctx, peer, msg.MsgID, senderID, chatID, content, nil, metadata, sender) } // sendWebhookReply sends a reply using the webhook URL func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content string) error { reply := WeComBotReplyMessage{ MsgType: "text", } reply.Text.Content = content jsonData, err := json.Marshal(reply) if err != nil { return fmt.Errorf("failed to marshal reply: %w", err) } // Use configurable timeout (default 5 seconds) timeout := c.config.ReplyTimeout if timeout <= 0 { timeout = 5 } reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) defer cancel() req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.config.WebhookURL, bytes.NewBuffer(jsonData)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := c.client.Do(req) if err != nil { return channels.ClassifyNetError(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, readErr := io.ReadAll(resp.Body) if readErr != nil { return channels.ClassifySendError( resp.StatusCode, fmt.Errorf("reading webhook error response: %w", readErr), ) } return channels.ClassifySendError( resp.StatusCode, fmt.Errorf("webhook API error: %s", string(body)), ) } body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read response: %w", err) } // Check response var result struct { ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` } if err := json.Unmarshal(body, &result); err != nil { return fmt.Errorf("failed to parse response: %w", err) } if result.ErrCode != 0 { return fmt.Errorf("webhook API error: %s (code: %d)", result.ErrMsg, result.ErrCode) } return nil } // handleHealth handles health check requests func (c *WeComBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) { status := map[string]any{ "status": "ok", "running": c.IsRunning(), } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(status) } ================================================ FILE: pkg/channels/wecom/bot_test.go ================================================ package wecom import ( "bytes" "context" "crypto/aes" "crypto/cipher" "crypto/sha1" "encoding/base64" "encoding/binary" "encoding/json" "encoding/xml" "fmt" "net/http" "net/http/httptest" "sort" "strings" "testing" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" ) // generateTestAESKey generates a valid test AES key func generateTestAESKey() string { // AES key needs to be 32 bytes (256 bits) for AES-256 key := make([]byte, 32) for i := range key { key[i] = byte(i) } // Return base64 encoded key without padding return base64.StdEncoding.EncodeToString(key)[:43] } // encryptTestMessage encrypts a message for testing (AIBOT JSON format) func encryptTestMessage(message, aesKey string) (string, error) { // Decode AES key key, err := base64.StdEncoding.DecodeString(aesKey + "=") if err != nil { return "", err } // Prepare message: random(16) + msg_len(4) + msg + receiveid random := make([]byte, 0, 16) for i := range 16 { random = append(random, byte(i)) } msgBytes := []byte(message) receiveID := []byte("test_aibot_id") msgLen := uint32(len(msgBytes)) lenBytes := make([]byte, 4) binary.BigEndian.PutUint32(lenBytes, msgLen) plainText := append(random, lenBytes...) plainText = append(plainText, msgBytes...) plainText = append(plainText, receiveID...) // PKCS7 padding blockSize := aes.BlockSize padding := blockSize - len(plainText)%blockSize padText := bytes.Repeat([]byte{byte(padding)}, padding) plainText = append(plainText, padText...) // Encrypt block, err := aes.NewCipher(key) if err != nil { return "", err } mode := cipher.NewCBCEncrypter(block, key[:aes.BlockSize]) cipherText := make([]byte, len(plainText)) mode.CryptBlocks(cipherText, plainText) return base64.StdEncoding.EncodeToString(cipherText), nil } // generateSignature generates a signature for testing func generateSignature(token, timestamp, nonce, msgEncrypt string) string { params := []string{token, timestamp, nonce, msgEncrypt} sort.Strings(params) str := strings.Join(params, "") hash := sha1.Sum([]byte(str)) return fmt.Sprintf("%x", hash) } func TestNewWeComBotChannel(t *testing.T) { msgBus := bus.NewMessageBus() t.Run("missing token", func(t *testing.T) { cfg := config.WeComConfig{ Token: "", WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", } _, err := NewWeComBotChannel(cfg, msgBus) if err == nil { t.Error("expected error for missing token, got nil") } }) t.Run("missing webhook_url", func(t *testing.T) { cfg := config.WeComConfig{ Token: "test_token", WebhookURL: "", } _, err := NewWeComBotChannel(cfg, msgBus) if err == nil { t.Error("expected error for missing webhook_url, got nil") } }) t.Run("valid config", func(t *testing.T) { cfg := config.WeComConfig{ Token: "test_token", WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", AllowFrom: []string{"user1", "user2"}, } ch, err := NewWeComBotChannel(cfg, msgBus) if err != nil { t.Fatalf("unexpected error: %v", err) } if ch.Name() != "wecom" { t.Errorf("Name() = %q, want %q", ch.Name(), "wecom") } if ch.IsRunning() { t.Error("new channel should not be running") } }) } func TestWeComBotChannelIsAllowed(t *testing.T) { msgBus := bus.NewMessageBus() t.Run("empty allowlist allows all", func(t *testing.T) { cfg := config.WeComConfig{ Token: "test_token", WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", AllowFrom: []string{}, } ch, _ := NewWeComBotChannel(cfg, msgBus) if !ch.IsAllowed("any_user") { t.Error("empty allowlist should allow all users") } }) t.Run("allowlist restricts users", func(t *testing.T) { cfg := config.WeComConfig{ Token: "test_token", WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", AllowFrom: []string{"allowed_user"}, } ch, _ := NewWeComBotChannel(cfg, msgBus) if !ch.IsAllowed("allowed_user") { t.Error("allowed user should pass allowlist check") } if ch.IsAllowed("blocked_user") { t.Error("non-allowed user should be blocked") } }) } func TestWeComBotVerifySignature(t *testing.T) { msgBus := bus.NewMessageBus() cfg := config.WeComConfig{ Token: "test_token", WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", } ch, _ := NewWeComBotChannel(cfg, msgBus) t.Run("valid signature", func(t *testing.T) { timestamp := "1234567890" nonce := "test_nonce" msgEncrypt := "test_message" expectedSig := generateSignature("test_token", timestamp, nonce, msgEncrypt) if !verifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) { t.Error("valid signature should pass verification") } }) t.Run("invalid signature", func(t *testing.T) { timestamp := "1234567890" nonce := "test_nonce" msgEncrypt := "test_message" if verifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) { t.Error("invalid signature should fail verification") } }) t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) { cfgEmpty := config.WeComConfig{ Token: "", WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", } chEmpty := &WeComBotChannel{ config: cfgEmpty, } if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { t.Error("empty token should reject verification (fail-closed)") } }) } func TestWeComBotDecryptMessage(t *testing.T) { msgBus := bus.NewMessageBus() t.Run("decrypt without AES key", func(t *testing.T) { cfg := config.WeComConfig{ Token: "test_token", WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", EncodingAESKey: "", } ch, _ := NewWeComBotChannel(cfg, msgBus) // Without AES key, message should be base64 decoded only plainText := "hello world" encoded := base64.StdEncoding.EncodeToString([]byte(plainText)) result, err := decryptMessage(encoded, ch.config.EncodingAESKey) if err != nil { t.Fatalf("unexpected error: %v", err) } if result != plainText { t.Errorf("decryptMessage() = %q, want %q", result, plainText) } }) t.Run("decrypt with AES key", func(t *testing.T) { aesKey := generateTestAESKey() cfg := config.WeComConfig{ Token: "test_token", WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", EncodingAESKey: aesKey, } ch, _ := NewWeComBotChannel(cfg, msgBus) originalMsg := "Hello" encrypted, err := encryptTestMessage(originalMsg, aesKey) if err != nil { t.Fatalf("failed to encrypt test message: %v", err) } result, err := decryptMessage(encrypted, ch.config.EncodingAESKey) if err != nil { t.Fatalf("unexpected error: %v", err) } if result != originalMsg { t.Errorf("WeComDecryptMessage() = %q, want %q", result, originalMsg) } }) t.Run("invalid base64", func(t *testing.T) { cfg := config.WeComConfig{ Token: "test_token", WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", EncodingAESKey: "", } ch, _ := NewWeComBotChannel(cfg, msgBus) _, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey) if err == nil { t.Error("expected error for invalid base64, got nil") } }) t.Run("invalid AES key", func(t *testing.T) { cfg := config.WeComConfig{ Token: "test_token", WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", EncodingAESKey: "invalid_key", } ch, _ := NewWeComBotChannel(cfg, msgBus) _, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey) if err == nil { t.Error("expected error for invalid AES key, got nil") } }) } func TestWeComBotPKCS7Unpad(t *testing.T) { tests := []struct { name string input []byte expected []byte }{ { name: "empty input", input: []byte{}, expected: []byte{}, }, { name: "valid padding 3 bytes", input: append([]byte("hello"), bytes.Repeat([]byte{3}, 3)...), expected: []byte("hello"), }, { name: "valid padding 16 bytes (full block)", input: append([]byte("123456789012345"), bytes.Repeat([]byte{16}, 16)...), expected: []byte("123456789012345"), }, { name: "invalid padding larger than data", input: []byte{20}, expected: nil, // should return error }, { name: "invalid padding zero", input: append([]byte("test"), byte(0)), expected: nil, // should return error }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := pkcs7Unpad(tt.input) if tt.expected == nil { // This case should return an error if err == nil { t.Errorf("pkcs7Unpad() expected error for invalid padding, got result: %v", result) } return } if err != nil { t.Errorf("pkcs7Unpad() unexpected error: %v", err) return } if !bytes.Equal(result, tt.expected) { t.Errorf("pkcs7Unpad() = %v, want %v", result, tt.expected) } }) } } func TestWeComBotHandleVerification(t *testing.T) { msgBus := bus.NewMessageBus() aesKey := generateTestAESKey() cfg := config.WeComConfig{ Token: "test_token", EncodingAESKey: aesKey, WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", } ch, _ := NewWeComBotChannel(cfg, msgBus) t.Run("valid verification request", func(t *testing.T) { echostr := "test_echostr_123" encryptedEchostr, _ := encryptTestMessage(echostr, aesKey) timestamp := "1234567890" nonce := "test_nonce" signature := generateSignature("test_token", timestamp, nonce, encryptedEchostr) req := httptest.NewRequest( http.MethodGet, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil, ) w := httptest.NewRecorder() ch.handleVerification(context.Background(), w, req) if w.Code != http.StatusOK { t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) } if w.Body.String() != echostr { t.Errorf("response body = %q, want %q", w.Body.String(), echostr) } }) t.Run("missing parameters", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature=sig×tamp=ts", nil) w := httptest.NewRecorder() ch.handleVerification(context.Background(), w, req) if w.Code != http.StatusBadRequest { t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest) } }) t.Run("invalid signature", func(t *testing.T) { echostr := "test_echostr" encryptedEchostr, _ := encryptTestMessage(echostr, aesKey) timestamp := "1234567890" nonce := "test_nonce" req := httptest.NewRequest( http.MethodGet, "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil, ) w := httptest.NewRecorder() ch.handleVerification(context.Background(), w, req) if w.Code != http.StatusForbidden { t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden) } }) } func TestWeComBotHandleMessageCallback(t *testing.T) { msgBus := bus.NewMessageBus() aesKey := generateTestAESKey() cfg := config.WeComConfig{ Token: "test_token", EncodingAESKey: aesKey, WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", } ch, _ := NewWeComBotChannel(cfg, msgBus) runBotMessageCallback := func(t *testing.T, jsonMsg string) *httptest.ResponseRecorder { t.Helper() encrypted, _ := encryptTestMessage(jsonMsg, aesKey) encryptedWrapper := struct { XMLName xml.Name `xml:"xml"` Encrypt string `xml:"Encrypt"` }{ Encrypt: encrypted, } wrapperData, _ := xml.Marshal(encryptedWrapper) timestamp := "1234567890" nonce := "test_nonce" signature := generateSignature("test_token", timestamp, nonce, encrypted) req := httptest.NewRequest( http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData), ) w := httptest.NewRecorder() ch.handleMessageCallback(context.Background(), w, req) return w } t.Run("valid direct message callback", func(t *testing.T) { w := runBotMessageCallback(t, `{ "msgid": "test_msg_id_123", "aibotid": "test_aibot_id", "chattype": "single", "from": {"userid": "user123"}, "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", "msgtype": "text", "text": {"content": "Hello World"} }`) if w.Code != http.StatusOK { t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) } if w.Body.String() != "success" { t.Errorf("response body = %q, want %q", w.Body.String(), "success") } }) t.Run("valid group message callback", func(t *testing.T) { w := runBotMessageCallback(t, `{ "msgid": "test_msg_id_456", "aibotid": "test_aibot_id", "chatid": "group_chat_id_123", "chattype": "group", "from": {"userid": "user456"}, "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", "msgtype": "text", "text": {"content": "Hello Group"} }`) if w.Code != http.StatusOK { t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) } if w.Body.String() != "success" { t.Errorf("response body = %q, want %q", w.Body.String(), "success") } }) t.Run("missing parameters", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature=sig", nil) w := httptest.NewRecorder() ch.handleMessageCallback(context.Background(), w, req) if w.Code != http.StatusBadRequest { t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest) } }) t.Run("invalid XML", func(t *testing.T) { timestamp := "1234567890" nonce := "test_nonce" signature := generateSignature("test_token", timestamp, nonce, "") req := httptest.NewRequest( http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, strings.NewReader("invalid xml"), ) w := httptest.NewRecorder() ch.handleMessageCallback(context.Background(), w, req) if w.Code != http.StatusBadRequest { t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest) } }) t.Run("invalid signature", func(t *testing.T) { encryptedWrapper := struct { XMLName xml.Name `xml:"xml"` Encrypt string `xml:"Encrypt"` }{ Encrypt: "encrypted_data", } wrapperData, _ := xml.Marshal(encryptedWrapper) timestamp := "1234567890" nonce := "test_nonce" req := httptest.NewRequest( http.MethodPost, "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData), ) w := httptest.NewRecorder() ch.handleMessageCallback(context.Background(), w, req) if w.Code != http.StatusForbidden { t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden) } }) } func TestWeComBotProcessMessage(t *testing.T) { msgBus := bus.NewMessageBus() cfg := config.WeComConfig{ Token: "test_token", WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", } ch, _ := NewWeComBotChannel(cfg, msgBus) t.Run("process direct text message", func(t *testing.T) { msg := WeComBotMessage{ MsgID: "test_msg_id_123", AIBotID: "test_aibot_id", ChatType: "single", ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", MsgType: "text", } msg.From.UserID = "user123" msg.Text.Content = "Hello World" // Should not panic ch.processMessage(context.Background(), msg) }) t.Run("process group text message", func(t *testing.T) { msg := WeComBotMessage{ MsgID: "test_msg_id_456", AIBotID: "test_aibot_id", ChatID: "group_chat_id_123", ChatType: "group", ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", MsgType: "text", } msg.From.UserID = "user456" msg.Text.Content = "Hello Group" // Should not panic ch.processMessage(context.Background(), msg) }) t.Run("process voice message", func(t *testing.T) { msg := WeComBotMessage{ MsgID: "test_msg_id_789", AIBotID: "test_aibot_id", ChatType: "single", ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", MsgType: "voice", } msg.From.UserID = "user123" msg.Voice.Content = "Voice message text" // Should not panic ch.processMessage(context.Background(), msg) }) t.Run("skip unsupported message type", func(t *testing.T) { msg := WeComBotMessage{ MsgID: "test_msg_id_000", AIBotID: "test_aibot_id", ChatType: "single", ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", MsgType: "video", } msg.From.UserID = "user123" // Should not panic ch.processMessage(context.Background(), msg) }) } func TestWeComBotHandleWebhook(t *testing.T) { msgBus := bus.NewMessageBus() cfg := config.WeComConfig{ Token: "test_token", WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", } ch, _ := NewWeComBotChannel(cfg, msgBus) t.Run("GET request calls verification", func(t *testing.T) { echostr := "test_echostr" encoded := base64.StdEncoding.EncodeToString([]byte(echostr)) timestamp := "1234567890" nonce := "test_nonce" signature := generateSignature("test_token", timestamp, nonce, encoded) req := httptest.NewRequest( http.MethodGet, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, nil, ) w := httptest.NewRecorder() ch.handleWebhook(w, req) if w.Code != http.StatusOK { t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) } }) t.Run("POST request calls message callback", func(t *testing.T) { encryptedWrapper := struct { XMLName xml.Name `xml:"xml"` Encrypt string `xml:"Encrypt"` }{ Encrypt: base64.StdEncoding.EncodeToString([]byte("test")), } wrapperData, _ := xml.Marshal(encryptedWrapper) timestamp := "1234567890" nonce := "test_nonce" signature := generateSignature("test_token", timestamp, nonce, encryptedWrapper.Encrypt) req := httptest.NewRequest( http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData), ) w := httptest.NewRecorder() ch.handleWebhook(w, req) // Should not be method not allowed if w.Code == http.StatusMethodNotAllowed { t.Error("POST request should not return Method Not Allowed") } }) t.Run("unsupported method", func(t *testing.T) { req := httptest.NewRequest(http.MethodPut, "/webhook/wecom", nil) w := httptest.NewRecorder() ch.handleWebhook(w, req) if w.Code != http.StatusMethodNotAllowed { t.Errorf("status code = %d, want %d", w.Code, http.StatusMethodNotAllowed) } }) } func TestWeComBotHandleHealth(t *testing.T) { msgBus := bus.NewMessageBus() cfg := config.WeComConfig{ Token: "test_token", WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", } ch, _ := NewWeComBotChannel(cfg, msgBus) req := httptest.NewRequest(http.MethodGet, "/health/wecom", nil) w := httptest.NewRecorder() ch.handleHealth(w, req) if w.Code != http.StatusOK { t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) } contentType := w.Header().Get("Content-Type") if contentType != "application/json" { t.Errorf("Content-Type = %q, want %q", contentType, "application/json") } body := w.Body.String() if !strings.Contains(body, "status") || !strings.Contains(body, "running") { t.Errorf("response body should contain status and running fields, got: %s", body) } } func TestWeComBotReplyMessage(t *testing.T) { msg := WeComBotReplyMessage{ MsgType: "text", } msg.Text.Content = "Hello World" if msg.MsgType != "text" { t.Errorf("MsgType = %q, want %q", msg.MsgType, "text") } if msg.Text.Content != "Hello World" { t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World") } } func TestWeComBotMessageStructure(t *testing.T) { jsonData := `{ "msgid": "test_msg_id_123", "aibotid": "test_aibot_id", "chatid": "group_chat_id_123", "chattype": "group", "from": {"userid": "user123"}, "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", "msgtype": "text", "text": {"content": "Hello World"} }` var msg WeComBotMessage err := json.Unmarshal([]byte(jsonData), &msg) if err != nil { t.Fatalf("failed to unmarshal JSON: %v", err) } if msg.MsgID != "test_msg_id_123" { t.Errorf("MsgID = %q, want %q", msg.MsgID, "test_msg_id_123") } if msg.AIBotID != "test_aibot_id" { t.Errorf("AIBotID = %q, want %q", msg.AIBotID, "test_aibot_id") } if msg.ChatID != "group_chat_id_123" { t.Errorf("ChatID = %q, want %q", msg.ChatID, "group_chat_id_123") } if msg.ChatType != "group" { t.Errorf("ChatType = %q, want %q", msg.ChatType, "group") } if msg.From.UserID != "user123" { t.Errorf("From.UserID = %q, want %q", msg.From.UserID, "user123") } if msg.MsgType != "text" { t.Errorf("MsgType = %q, want %q", msg.MsgType, "text") } if msg.Text.Content != "Hello World" { t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World") } } ================================================ FILE: pkg/channels/wecom/common.go ================================================ package wecom import ( "bytes" "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha1" "encoding/base64" "encoding/binary" "fmt" "math/big" "sort" "strings" ) // blockSize is the PKCS7 block size used by WeCom (32) const blockSize = 32 // computeSignature computes the WeCom message signature from the given parameters. // It sorts [token, timestamp, nonce, encrypt], concatenates them and returns the SHA1 hex digest. func computeSignature(token, timestamp, nonce, encrypt string) string { params := []string{token, timestamp, nonce, encrypt} sort.Strings(params) str := strings.Join(params, "") hash := sha1.Sum([]byte(str)) return fmt.Sprintf("%x", hash) } // verifySignature verifies the message signature for WeCom // This is a common function used by both WeCom Bot and WeCom App func verifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool { if token == "" { return false } return computeSignature(token, timestamp, nonce, msgEncrypt) == msgSignature } // decryptMessage decrypts the encrypted message using AES // For AIBOT, receiveid should be the aibotid; for other apps, it should be corp_id func decryptMessage(encryptedMsg, encodingAESKey string) (string, error) { return decryptMessageWithVerify(encryptedMsg, encodingAESKey, "") } // decryptMessageWithVerify decrypts the encrypted message and optionally verifies receiveid // receiveid: for AIBOT use aibotid, for WeCom App use corp_id. If empty, skip verification. func decryptMessageWithVerify(encryptedMsg, encodingAESKey, receiveid string) (string, error) { if encodingAESKey == "" { // No encryption, return as is (base64 decode) decoded, err := base64.StdEncoding.DecodeString(encryptedMsg) if err != nil { return "", err } return string(decoded), nil } aesKey, err := decodeWeComAESKey(encodingAESKey) if err != nil { return "", err } cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg) if err != nil { return "", fmt.Errorf("failed to decode message: %w", err) } plainText, err := decryptAESCBC(aesKey, cipherText) if err != nil { return "", err } return unpackWeComFrame(plainText, receiveid) } // decodeWeComAESKey base64-decodes the 43-character EncodingAESKey (trailing "=" is // appended automatically) and validates that the result is exactly 32 bytes. // It is the single place that handles this repeated pattern in both encrypt and decrypt paths. func decodeWeComAESKey(encodingAESKey string) ([]byte, error) { aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=") if err != nil { return nil, fmt.Errorf("failed to decode AES key: %w", err) } if len(aesKey) != 32 { return nil, fmt.Errorf("invalid AES key length: %d", len(aesKey)) } return aesKey, nil } // encryptAESCBC encrypts plaintext using AES-CBC with the given key, mirroring // decryptAESCBC. IV = aesKey[:aes.BlockSize]. The caller must PKCS7-pad the // plaintext to a multiple of aes.BlockSize before calling. func encryptAESCBC(aesKey, plaintext []byte) ([]byte, error) { block, err := aes.NewCipher(aesKey) if err != nil { return nil, fmt.Errorf("failed to create cipher: %w", err) } iv := aesKey[:aes.BlockSize] ciphertext := make([]byte, len(plaintext)) cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, plaintext) return ciphertext, nil } // packWeComFrame builds the WeCom wire format: // // random(16 ASCII digits) + msg_len(4, big-endian) + msg + receiveid func packWeComFrame(msg, receiveid string) ([]byte, error) { randomBytes := make([]byte, 16) for i := range 16 { n, err := rand.Int(rand.Reader, big.NewInt(10)) if err != nil { return nil, fmt.Errorf("failed to generate random: %w", err) } randomBytes[i] = byte('0' + n.Int64()) } msgBytes := []byte(msg) msgLenBytes := make([]byte, 4) binary.BigEndian.PutUint32(msgLenBytes, uint32(len(msgBytes))) var buf bytes.Buffer buf.Write(randomBytes) buf.Write(msgLenBytes) buf.Write(msgBytes) buf.WriteString(receiveid) return buf.Bytes(), nil } // unpackWeComFrame parses the WeCom wire format produced by packWeComFrame. // If receiveid is non-empty it verifies the frame's trailing receiveid field. func unpackWeComFrame(data []byte, receiveid string) (string, error) { if len(data) < 20 { return "", fmt.Errorf("decrypted frame too short: %d bytes", len(data)) } msgLen := binary.BigEndian.Uint32(data[16:20]) if int(msgLen) > len(data)-20 { return "", fmt.Errorf("invalid message length: %d", msgLen) } msg := data[20 : 20+msgLen] if receiveid != "" && len(data) > 20+int(msgLen) { actualReceiveID := string(data[20+msgLen:]) if actualReceiveID != receiveid { return "", fmt.Errorf("receiveid mismatch: expected %s, got %s", receiveid, actualReceiveID) } } return string(msg), nil } // decryptAESCBC decrypts ciphertext using AES-CBC with the given key. // IV = aesKey[:aes.BlockSize]. PKCS7 padding is stripped from the returned plaintext. func decryptAESCBC(aesKey, ciphertext []byte) ([]byte, error) { if len(ciphertext) == 0 { return nil, fmt.Errorf("ciphertext is empty") } if len(ciphertext)%aes.BlockSize != 0 { return nil, fmt.Errorf("ciphertext length %d is not a multiple of block size", len(ciphertext)) } block, err := aes.NewCipher(aesKey) if err != nil { return nil, fmt.Errorf("failed to create cipher: %w", err) } iv := aesKey[:aes.BlockSize] plaintext := make([]byte, len(ciphertext)) cipher.NewCBCDecrypter(block, iv).CryptBlocks(plaintext, ciphertext) plaintext, err = pkcs7Unpad(plaintext) if err != nil { return nil, fmt.Errorf("failed to unpad: %w", err) } return plaintext, nil } // pkcs7Pad adds PKCS7 padding func pkcs7Pad(data []byte, blockSize int) []byte { padding := blockSize - (len(data) % blockSize) if padding == 0 { padding = blockSize } padText := bytes.Repeat([]byte{byte(padding)}, padding) return append(data, padText...) } // pkcs7Unpad removes PKCS7 padding with validation func pkcs7Unpad(data []byte) ([]byte, error) { if len(data) == 0 { return data, nil } padding := int(data[len(data)-1]) // WeCom uses 32-byte block size for PKCS7 padding if padding == 0 || padding > blockSize { return nil, fmt.Errorf("invalid padding size: %d", padding) } if padding > len(data) { return nil, fmt.Errorf("padding size larger than data") } // Verify all padding bytes for i := range padding { if data[len(data)-1-i] != byte(padding) { return nil, fmt.Errorf("invalid padding byte at position %d", i) } } return data[:len(data)-padding], nil } ================================================ FILE: pkg/channels/wecom/dedupe.go ================================================ package wecom import "sync" const wecomMaxProcessedMessages = 1000 // MessageDeduplicator provides thread-safe message deduplication using a circular queue (ring buffer) // combined with a hash map. This ensures fast O(1) lookups while naturally evicting the oldest // messages without causing "amnesia cliffs" when the limit is reached. type MessageDeduplicator struct { mu sync.Mutex msgs map[string]bool ring []string idx int max int } // NewMessageDeduplicator creates a new deduplicator with the specified capacity. func NewMessageDeduplicator(maxEntries int) *MessageDeduplicator { if maxEntries <= 0 { maxEntries = wecomMaxProcessedMessages } return &MessageDeduplicator{ msgs: make(map[string]bool, maxEntries), ring: make([]string, maxEntries), max: maxEntries, } } // MarkMessageProcessed marks msgID as processed and returns false for duplicates. func (d *MessageDeduplicator) MarkMessageProcessed(msgID string) bool { d.mu.Lock() defer d.mu.Unlock() // 1. Check for duplicate if d.msgs[msgID] { return false } // 2. Evict the oldest message at our current ring position (if any) oldestID := d.ring[d.idx] if oldestID != "" { delete(d.msgs, oldestID) } // 3. Store the new message d.msgs[msgID] = true d.ring[d.idx] = msgID // 4. Advance the circle queue index d.idx = (d.idx + 1) % d.max return true } ================================================ FILE: pkg/channels/wecom/dedupe_test.go ================================================ package wecom import ( "sync" "testing" ) func TestMessageDeduplicator_DuplicateDetection(t *testing.T) { d := NewMessageDeduplicator(wecomMaxProcessedMessages) if ok := d.MarkMessageProcessed("msg-1"); !ok { t.Fatalf("first message should be accepted") } if ok := d.MarkMessageProcessed("msg-1"); ok { t.Fatalf("duplicate message should be rejected") } } func TestMessageDeduplicator_ConcurrentSameMessage(t *testing.T) { d := NewMessageDeduplicator(wecomMaxProcessedMessages) const goroutines = 64 var wg sync.WaitGroup wg.Add(goroutines) results := make(chan bool, goroutines) for i := 0; i < goroutines; i++ { go func() { defer wg.Done() results <- d.MarkMessageProcessed("msg-concurrent") }() } wg.Wait() close(results) successes := 0 for ok := range results { if ok { successes++ } } if successes != 1 { t.Fatalf("expected exactly 1 successful mark, got %d", successes) } } func TestMessageDeduplicator_CircularQueueEviction(t *testing.T) { // Create a deduplicator with a very small capacity to test eviction easily. capacity := 3 d := NewMessageDeduplicator(capacity) // Fill the queue. d.MarkMessageProcessed("msg-1") d.MarkMessageProcessed("msg-2") d.MarkMessageProcessed("msg-3") // At this point, the queue is full. msg-1 is the oldest. if len(d.msgs) != 3 { t.Fatalf("expected map size to be 3, got %d", len(d.msgs)) } // This should evict msg-1 and add msg-4. if ok := d.MarkMessageProcessed("msg-4"); !ok { t.Fatalf("msg-4 should be accepted") } if len(d.msgs) != 3 { t.Fatalf("expected map size to remain at max capacity (3), got %d", len(d.msgs)) } // msg-1 should now be forgotten (evicted). if ok := d.MarkMessageProcessed("msg-1"); !ok { t.Fatalf("msg-1 should be accepted again because it was evicted") } // msg-2 should have been evicted when we added msg-1 back. if ok := d.MarkMessageProcessed("msg-2"); !ok { t.Fatalf("msg-2 should be accepted again because it was evicted") } } ================================================ FILE: pkg/channels/wecom/init.go ================================================ package wecom import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) func init() { channels.RegisterFactory("wecom", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { return NewWeComBotChannel(cfg.Channels.WeCom, b) }) channels.RegisterFactory("wecom_app", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { return NewWeComAppChannel(cfg.Channels.WeComApp, b) }) channels.RegisterFactory("wecom_aibot", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { return NewWeComAIBotChannel(cfg.Channels.WeComAIBot, b) }) } ================================================ FILE: pkg/channels/whatsapp/init.go ================================================ package whatsapp import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) func init() { channels.RegisterFactory("whatsapp", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { return NewWhatsAppChannel(cfg.Channels.WhatsApp, b) }) } ================================================ FILE: pkg/channels/whatsapp/whatsapp.go ================================================ package whatsapp import ( "context" "encoding/json" "fmt" "sync" "time" "github.com/gorilla/websocket" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) type WhatsAppChannel struct { *channels.BaseChannel conn *websocket.Conn config config.WhatsAppConfig url string ctx context.Context cancel context.CancelFunc mu sync.Mutex connected bool } func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) { base := channels.NewBaseChannel( "whatsapp", cfg, bus, cfg.AllowFrom, channels.WithMaxMessageLength(65536), channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) return &WhatsAppChannel{ BaseChannel: base, config: cfg, url: cfg.BridgeURL, connected: false, }, nil } func (c *WhatsAppChannel) Start(ctx context.Context) error { logger.InfoCF("whatsapp", "Starting WhatsApp channel", map[string]any{ "bridge_url": c.url, }) c.ctx, c.cancel = context.WithCancel(ctx) dialer := websocket.DefaultDialer dialer.HandshakeTimeout = 10 * time.Second conn, resp, err := dialer.Dial(c.url, nil) if resp != nil { resp.Body.Close() } if err != nil { c.cancel() return fmt.Errorf("failed to connect to WhatsApp bridge: %w", err) } c.mu.Lock() c.conn = conn c.connected = true c.mu.Unlock() c.SetRunning(true) logger.InfoC("whatsapp", "WhatsApp channel connected") go c.listen() return nil } func (c *WhatsAppChannel) Stop(ctx context.Context) error { logger.InfoC("whatsapp", "Stopping WhatsApp channel...") // Cancel context first to signal listen goroutine to exit if c.cancel != nil { c.cancel() } c.mu.Lock() defer c.mu.Unlock() if c.conn != nil { if err := c.conn.Close(); err != nil { logger.ErrorCF("whatsapp", "Error closing WhatsApp connection", map[string]any{ "error": err.Error(), }) } c.conn = nil } c.connected = false c.SetRunning(false) return nil } func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } // Check ctx before acquiring lock select { case <-ctx.Done(): return ctx.Err() default: } c.mu.Lock() defer c.mu.Unlock() if c.conn == nil { return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) } payload := map[string]any{ "type": "message", "to": msg.ChatID, "content": msg.Content, } data, err := json.Marshal(payload) if err != nil { return fmt.Errorf("failed to marshal message: %w", err) } _ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { _ = c.conn.SetWriteDeadline(time.Time{}) return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary) } _ = c.conn.SetWriteDeadline(time.Time{}) return nil } func (c *WhatsAppChannel) listen() { for { select { case <-c.ctx.Done(): return default: c.mu.Lock() conn := c.conn c.mu.Unlock() if conn == nil { time.Sleep(1 * time.Second) continue } _, message, err := conn.ReadMessage() if err != nil { logger.ErrorCF("whatsapp", "WhatsApp read error", map[string]any{ "error": err.Error(), }) time.Sleep(2 * time.Second) continue } var msg map[string]any if err := json.Unmarshal(message, &msg); err != nil { logger.ErrorCF("whatsapp", "Failed to unmarshal WhatsApp message", map[string]any{ "error": err.Error(), }) continue } msgType, ok := msg["type"].(string) if !ok { continue } if msgType == "message" { c.handleIncomingMessage(msg) } } } } func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) { senderID, ok := msg["from"].(string) if !ok { return } chatID, ok := msg["chat"].(string) if !ok { chatID = senderID } content, ok := msg["content"].(string) if !ok { content = "" } var mediaPaths []string if mediaData, ok := msg["media"].([]any); ok { mediaPaths = make([]string, 0, len(mediaData)) for _, m := range mediaData { if path, ok := m.(string); ok { mediaPaths = append(mediaPaths, path) } } } metadata := make(map[string]string) var messageID string if mid, ok := msg["id"].(string); ok { messageID = mid } if userName, ok := msg["from_name"].(string); ok { metadata["user_name"] = userName } var peer bus.Peer if chatID == senderID { peer = bus.Peer{Kind: "direct", ID: senderID} } else { peer = bus.Peer{Kind: "group", ID: chatID} } logger.InfoCF("whatsapp", "WhatsApp message received", map[string]any{ "sender": senderID, "preview": utils.Truncate(content, 50), }) sender := bus.SenderInfo{ Platform: "whatsapp", PlatformID: senderID, CanonicalID: identity.BuildCanonicalID("whatsapp", senderID), } if display, ok := metadata["user_name"]; ok { sender.DisplayName = display } if !c.IsAllowedSender(sender) { return } c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender) } ================================================ FILE: pkg/channels/whatsapp/whatsapp_command_test.go ================================================ package whatsapp import ( "context" "testing" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) func TestHandleIncomingMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) { messageBus := bus.NewMessageBus() ch := &WhatsAppChannel{ BaseChannel: channels.NewBaseChannel("whatsapp", config.WhatsAppConfig{}, messageBus, nil), ctx: context.Background(), } ch.handleIncomingMessage(map[string]any{ "type": "message", "id": "mid1", "from": "user1", "chat": "chat1", "content": "/help", }) inbound, ok := <-messageBus.InboundChan() if !ok { t.Fatal("expected inbound message to be forwarded") } if inbound.Channel != "whatsapp" { t.Fatalf("channel=%q", inbound.Channel) } if inbound.Content != "/help" { t.Fatalf("content=%q", inbound.Content) } } ================================================ FILE: pkg/channels/whatsapp_native/init.go ================================================ package whatsapp import ( "path/filepath" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) func init() { channels.RegisterFactory("whatsapp_native", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { waCfg := cfg.Channels.WhatsApp storePath := waCfg.SessionStorePath if storePath == "" { storePath = filepath.Join(cfg.WorkspacePath(), "whatsapp") } return NewWhatsAppNativeChannel(waCfg, b, storePath) }) } ================================================ FILE: pkg/channels/whatsapp_native/whatsapp_command_test.go ================================================ //go:build whatsapp_native package whatsapp import ( "context" "testing" "time" "go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" "google.golang.org/protobuf/proto" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) func TestHandleIncoming_DoesNotConsumeGenericCommandsLocally(t *testing.T) { messageBus := bus.NewMessageBus() ch := &WhatsAppNativeChannel{ BaseChannel: channels.NewBaseChannel("whatsapp_native", config.WhatsAppConfig{}, messageBus, nil), runCtx: context.Background(), } evt := &events.Message{ Info: types.MessageInfo{ MessageSource: types.MessageSource{ Sender: types.NewJID("1001", types.DefaultUserServer), Chat: types.NewJID("1001", types.DefaultUserServer), }, ID: "mid1", PushName: "Alice", }, Message: &waE2E.Message{ Conversation: proto.String("/new"), }, } ch.handleIncoming(evt) ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() select { case <-ctx.Done(): t.Fatal("timeout waiting for message to be forwarded") return case inbound, ok := <-messageBus.InboundChan(): if !ok { t.Fatal("expected inbound message to be forwarded") } if inbound.Channel != "whatsapp_native" { t.Fatalf("channel=%q", inbound.Channel) } if inbound.Content != "/new" { t.Fatalf("content=%q", inbound.Content) } } } ================================================ FILE: pkg/channels/whatsapp_native/whatsapp_native.go ================================================ //go:build whatsapp_native // PicoClaw - Ultra-lightweight personal AI agent // License: MIT // // Copyright (c) 2026 PicoClaw contributors package whatsapp import ( "context" "database/sql" "fmt" "os" "path/filepath" "strings" "sync" "sync/atomic" "time" "github.com/mdp/qrterminal/v3" "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/store/sqlstore" "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" waLog "go.mau.fi/whatsmeow/util/log" "google.golang.org/protobuf/proto" _ "modernc.org/sqlite" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) const ( sqliteDriver = "sqlite" whatsappDBName = "store.db" reconnectInitial = 5 * time.Second reconnectMax = 5 * time.Minute reconnectMultiplier = 2.0 ) // WhatsAppNativeChannel implements the WhatsApp channel using whatsmeow (in-process, no external bridge). type WhatsAppNativeChannel struct { *channels.BaseChannel config config.WhatsAppConfig storePath string client *whatsmeow.Client container *sqlstore.Container mu sync.Mutex runCtx context.Context runCancel context.CancelFunc reconnectMu sync.Mutex reconnecting bool stopping atomic.Bool // set once Stop begins; prevents new wg.Add calls wg sync.WaitGroup // tracks background goroutines (QR handler, reconnect) } // NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection. // storePath is the directory for the SQLite session store (e.g. workspace/whatsapp). func NewWhatsAppNativeChannel( cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string, ) (channels.Channel, error) { base := channels.NewBaseChannel("whatsapp_native", cfg, bus, cfg.AllowFrom, channels.WithMaxMessageLength(65536)) if storePath == "" { storePath = "whatsapp" } c := &WhatsAppNativeChannel{ BaseChannel: base, config: cfg, storePath: storePath, } return c, nil } func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { logger.InfoCF("whatsapp", "Starting WhatsApp native channel (whatsmeow)", map[string]any{"store": c.storePath}) // Reset lifecycle state from any previous Stop() so a restarted channel // behaves correctly. Use reconnectMu to be consistent with eventHandler // and Stop() which coordinate under the same lock. c.reconnectMu.Lock() c.stopping.Store(false) c.reconnecting = false c.reconnectMu.Unlock() if err := os.MkdirAll(c.storePath, 0o700); err != nil { return fmt.Errorf("create session store dir: %w", err) } dbPath := filepath.Join(c.storePath, whatsappDBName) connStr := "file:" + dbPath + "?_foreign_keys=on" db, err := sql.Open(sqliteDriver, connStr) if err != nil { return fmt.Errorf("open whatsapp store: %w", err) } db.SetMaxOpenConns(1) db.SetMaxIdleConns(1) if _, err = db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil { _ = db.Close() return fmt.Errorf("enable foreign keys: %w", err) } waLogger := waLog.Stdout("WhatsApp", "WARN", true) container := sqlstore.NewWithDB(db, sqliteDriver, waLogger) if err = container.Upgrade(ctx); err != nil { _ = db.Close() return fmt.Errorf("open whatsapp store: %w", err) } deviceStore, err := container.GetFirstDevice(ctx) if err != nil { _ = container.Close() return fmt.Errorf("get device store: %w", err) } client := whatsmeow.NewClient(deviceStore, waLogger) // Create runCtx/runCancel BEFORE registering event handler and starting // goroutines so that Stop() can cancel them at any time, including during // the QR-login flow. c.runCtx, c.runCancel = context.WithCancel(ctx) client.AddEventHandler(c.eventHandler) c.mu.Lock() c.container = container c.client = client c.mu.Unlock() // cleanupOnError clears struct references and releases resources when // Start() fails after fields are already assigned. This prevents // Stop() from operating on stale references (double-close, disconnect // of a partially-initialized client, or stray event handler callbacks). startOK := false defer func() { if startOK { return } c.runCancel() client.Disconnect() c.mu.Lock() c.client = nil c.container = nil c.mu.Unlock() _ = container.Close() }() if client.Store.ID == nil { qrChan, err := client.GetQRChannel(c.runCtx) if err != nil { return fmt.Errorf("get QR channel: %w", err) } if err := client.Connect(); err != nil { return fmt.Errorf("connect: %w", err) } // Handle QR events in a background goroutine so Start() returns // promptly. The goroutine is tracked via c.wg and respects // c.runCtx for cancellation. // Guard wg.Add with reconnectMu + stopping check (same protocol // as eventHandler) so a concurrent Stop() cannot enter wg.Wait() // while we call wg.Add(1). c.reconnectMu.Lock() if c.stopping.Load() { c.reconnectMu.Unlock() return fmt.Errorf("channel stopped during QR setup") } c.wg.Add(1) c.reconnectMu.Unlock() go func() { defer c.wg.Done() for { select { case <-c.runCtx.Done(): return case evt, ok := <-qrChan: if !ok { return } if evt.Event == "code" { logger.InfoCF("whatsapp", "Scan this QR code with WhatsApp (Linked Devices):", nil) qrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{ Level: qrterminal.L, Writer: os.Stdout, HalfBlocks: true, }) } else { logger.InfoCF("whatsapp", "WhatsApp login event", map[string]any{"event": evt.Event}) } } } }() } else { if err := client.Connect(); err != nil { return fmt.Errorf("connect: %w", err) } } startOK = true c.SetRunning(true) logger.InfoC("whatsapp", "WhatsApp native channel connected") return nil } func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error { logger.InfoC("whatsapp", "Stopping WhatsApp native channel") // Mark as stopping under reconnectMu so the flag is visible to // eventHandler atomically with respect to its wg.Add(1) call. // This closes the TOCTOU window where eventHandler could check // stopping (false), then Stop sets it true + enters wg.Wait, // then eventHandler calls wg.Add(1) — causing a panic. c.reconnectMu.Lock() c.stopping.Store(true) c.reconnectMu.Unlock() if c.runCancel != nil { c.runCancel() } // Disconnect the client first so any blocking Connect()/reconnect loops // can be interrupted before we wait on the goroutines. c.mu.Lock() client := c.client container := c.container c.mu.Unlock() if client != nil { client.Disconnect() } // Wait for background goroutines (QR handler, reconnect) to finish in a // context-aware way so Stop can be bounded by ctx. done := make(chan struct{}) go func() { c.wg.Wait() close(done) }() select { case <-done: // All goroutines have finished. case <-ctx.Done(): // Context canceled or timed out; log and proceed with best-effort cleanup. logger.WarnC("whatsapp", fmt.Sprintf("Stop context canceled before all goroutines finished: %v", ctx.Err())) } // Now it is safe to clear and close resources. c.mu.Lock() c.client = nil c.container = nil c.mu.Unlock() if container != nil { _ = container.Close() } c.SetRunning(false) return nil } func (c *WhatsAppNativeChannel) eventHandler(evt any) { switch evt.(type) { case *events.Message: c.handleIncoming(evt.(*events.Message)) case *events.Disconnected: logger.InfoCF("whatsapp", "WhatsApp disconnected, will attempt reconnection", nil) c.reconnectMu.Lock() if c.reconnecting { c.reconnectMu.Unlock() return } // Check stopping while holding the lock so the check and wg.Add // are atomic with respect to Stop() setting the flag + calling // wg.Wait(). This prevents the TOCTOU race. if c.stopping.Load() { c.reconnectMu.Unlock() return } c.reconnecting = true c.wg.Add(1) c.reconnectMu.Unlock() go func() { defer c.wg.Done() c.reconnectWithBackoff() }() } } func (c *WhatsAppNativeChannel) reconnectWithBackoff() { defer func() { c.reconnectMu.Lock() c.reconnecting = false c.reconnectMu.Unlock() }() backoff := reconnectInitial for { select { case <-c.runCtx.Done(): return default: } c.mu.Lock() client := c.client c.mu.Unlock() if client == nil { return } logger.InfoCF("whatsapp", "WhatsApp reconnecting", map[string]any{"backoff": backoff.String()}) err := client.Connect() if err == nil { logger.InfoC("whatsapp", "WhatsApp reconnected") return } logger.WarnCF("whatsapp", "WhatsApp reconnect failed", map[string]any{"error": err.Error()}) select { case <-c.runCtx.Done(): return case <-time.After(backoff): if backoff < reconnectMax { next := time.Duration(float64(backoff) * reconnectMultiplier) if next > reconnectMax { next = reconnectMax } backoff = next } } } } func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) { if evt.Message == nil { return } senderID := evt.Info.Sender.String() chatID := evt.Info.Chat.String() content := evt.Message.GetConversation() if content == "" && evt.Message.ExtendedTextMessage != nil { content = evt.Message.ExtendedTextMessage.GetText() } content = utils.SanitizeMessageContent(content) if content == "" { return } var mediaPaths []string metadata := make(map[string]string) metadata["message_id"] = evt.Info.ID if evt.Info.PushName != "" { metadata["user_name"] = evt.Info.PushName } if evt.Info.Chat.Server == types.GroupServer { metadata["peer_kind"] = "group" metadata["peer_id"] = chatID } else { metadata["peer_kind"] = "direct" metadata["peer_id"] = senderID } peerKind := "direct" if evt.Info.Chat.Server == types.GroupServer { peerKind = "group" } peer := bus.Peer{Kind: peerKind, ID: chatID} messageID := evt.Info.ID sender := bus.SenderInfo{ Platform: "whatsapp", PlatformID: senderID, CanonicalID: identity.BuildCanonicalID("whatsapp", senderID), DisplayName: evt.Info.PushName, } if !c.IsAllowedSender(sender) { return } logger.DebugCF( "whatsapp", "WhatsApp message received", map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)}, ) c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender) } func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } select { case <-ctx.Done(): return ctx.Err() default: } c.mu.Lock() client := c.client c.mu.Unlock() if client == nil || !client.IsConnected() { return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) } // Detect unpaired state: the client is connected (to WhatsApp servers) // but has not completed QR-login yet, so sending would fail. if client.Store.ID == nil { return fmt.Errorf("whatsapp not yet paired (QR login pending): %w", channels.ErrTemporary) } to, err := parseJID(msg.ChatID) if err != nil { return fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err) } waMsg := &waE2E.Message{ Conversation: proto.String(msg.Content), } if _, err = client.SendMessage(ctx, to, waMsg); err != nil { return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary) } return nil } // parseJID converts a chat ID (phone number or JID string) to types.JID. func parseJID(s string) (types.JID, error) { s = strings.TrimSpace(s) if s == "" { return types.JID{}, fmt.Errorf("empty chat id") } if strings.Contains(s, "@") { return types.ParseJID(s) } return types.NewJID(s, types.DefaultUserServer), nil } ================================================ FILE: pkg/channels/whatsapp_native/whatsapp_native_stub.go ================================================ //go:build !whatsapp_native package whatsapp import ( "fmt" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) // NewWhatsAppNativeChannel returns an error when the binary was not built with -tags whatsapp_native. // Build with: go build -tags whatsapp_native ./cmd/... func NewWhatsAppNativeChannel( cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string, ) (channels.Channel, error) { return nil, fmt.Errorf("whatsapp native not compiled in; build with -tags whatsapp_native") } ================================================ FILE: pkg/commands/builtin.go ================================================ package commands // BuiltinDefinitions returns all built-in command definitions. // Each command group is defined in its own cmd_*.go file. // Definitions are stateless — runtime dependencies are provided // via the Runtime parameter passed to handlers at execution time. func BuiltinDefinitions() []Definition { return []Definition{ startCommand(), helpCommand(), showCommand(), listCommand(), switchCommand(), checkCommand(), clearCommand(), reloadCommand(), } } ================================================ FILE: pkg/commands/builtin_test.go ================================================ package commands import ( "context" "strings" "testing" ) func findDefinitionByName(t *testing.T, defs []Definition, name string) Definition { t.Helper() for _, def := range defs { if def.Name == name { return def } } t.Fatalf("missing /%s definition", name) return Definition{} } func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) { defs := BuiltinDefinitions() helpDef := findDefinitionByName(t, defs, "help") if helpDef.Handler == nil { t.Fatalf("/help handler should not be nil") } var reply string err := helpDef.Handler(context.Background(), Request{ Text: "/help", Reply: func(text string) error { reply = text return nil }, }, nil) if err != nil { t.Fatalf("/help handler error: %v", err) } // Now uses auto-generated EffectiveUsage which includes agents if !strings.Contains(reply, "/show [model|channel|agents]") { t.Fatalf("/help reply missing /show usage, got %q", reply) } if !strings.Contains(reply, "/list [models|channels|agents]") { t.Fatalf("/help reply missing /list usage, got %q", reply) } } func TestBuiltinShowChannel_PreservesUserVisibleBehavior(t *testing.T) { defs := BuiltinDefinitions() ex := NewExecutor(NewRegistry(defs), nil) cases := []string{"telegram", "whatsapp"} for _, channel := range cases { var reply string res := ex.Execute(context.Background(), Request{ Channel: channel, Text: "/show channel", Reply: func(text string) error { reply = text return nil }, }) if res.Outcome != OutcomeHandled { t.Fatalf("/show channel on %s: outcome=%v, want=%v", channel, res.Outcome, OutcomeHandled) } want := "Current Channel: " + channel if reply != want { t.Fatalf("/show channel reply=%q, want=%q", reply, want) } } } func TestBuiltinListChannels_UsesGetEnabledChannels(t *testing.T) { rt := &Runtime{ GetEnabledChannels: func() []string { return []string{"telegram", "slack"} }, } defs := BuiltinDefinitions() ex := NewExecutor(NewRegistry(defs), rt) var reply string res := ex.Execute(context.Background(), Request{ Text: "/list channels", Reply: func(text string) error { reply = text return nil }, }) if res.Outcome != OutcomeHandled { t.Fatalf("/list channels: outcome=%v, want=%v", res.Outcome, OutcomeHandled) } if !strings.Contains(reply, "telegram") || !strings.Contains(reply, "slack") { t.Fatalf("/list channels reply=%q, want telegram and slack", reply) } } func TestBuiltinShowAgents_RestoresOldBehavior(t *testing.T) { rt := &Runtime{ ListAgentIDs: func() []string { return []string{"default", "coder"} }, } defs := BuiltinDefinitions() ex := NewExecutor(NewRegistry(defs), rt) var reply string res := ex.Execute(context.Background(), Request{ Text: "/show agents", Reply: func(text string) error { reply = text return nil }, }) if res.Outcome != OutcomeHandled { t.Fatalf("/show agents: outcome=%v, want=%v", res.Outcome, OutcomeHandled) } if !strings.Contains(reply, "default") || !strings.Contains(reply, "coder") { t.Fatalf("/show agents reply=%q, want agent IDs", reply) } } func TestBuiltinListAgents_RestoresOldBehavior(t *testing.T) { rt := &Runtime{ ListAgentIDs: func() []string { return []string{"default", "coder"} }, } defs := BuiltinDefinitions() ex := NewExecutor(NewRegistry(defs), rt) var reply string res := ex.Execute(context.Background(), Request{ Text: "/list agents", Reply: func(text string) error { reply = text return nil }, }) if res.Outcome != OutcomeHandled { t.Fatalf("/list agents: outcome=%v, want=%v", res.Outcome, OutcomeHandled) } if !strings.Contains(reply, "default") || !strings.Contains(reply, "coder") { t.Fatalf("/list agents reply=%q, want agent IDs", reply) } } ================================================ FILE: pkg/commands/cmd_check.go ================================================ package commands import ( "context" "fmt" ) func checkCommand() Definition { return Definition{ Name: "check", Description: "Check channel availability", SubCommands: []SubCommand{ { Name: "channel", Description: "Check if a channel is available", ArgsUsage: "", Handler: func(_ context.Context, req Request, rt *Runtime) error { if rt == nil || rt.SwitchChannel == nil { return req.Reply(unavailableMsg) } value := nthToken(req.Text, 2) if value == "" { return req.Reply("Usage: /check channel ") } if err := rt.SwitchChannel(value); err != nil { return req.Reply(err.Error()) } return req.Reply(fmt.Sprintf("Channel '%s' is available and enabled", value)) }, }, }, } } ================================================ FILE: pkg/commands/cmd_clear.go ================================================ package commands import "context" func clearCommand() Definition { return Definition{ Name: "clear", Description: "Clear the chat history", Usage: "/clear", Handler: func(_ context.Context, req Request, rt *Runtime) error { if rt == nil || rt.ClearHistory == nil { return req.Reply(unavailableMsg) } if err := rt.ClearHistory(); err != nil { return req.Reply("Failed to clear chat history: " + err.Error()) } return req.Reply("Chat history cleared!") }, } } ================================================ FILE: pkg/commands/cmd_help.go ================================================ package commands import ( "context" "fmt" "strings" ) func helpCommand() Definition { return Definition{ Name: "help", Description: "Show this help message", Usage: "/help", Handler: func(_ context.Context, req Request, rt *Runtime) error { var defs []Definition if rt != nil && rt.ListDefinitions != nil { defs = rt.ListDefinitions() } else { defs = BuiltinDefinitions() } return req.Reply(formatHelpMessage(defs)) }, } } func formatHelpMessage(defs []Definition) string { if len(defs) == 0 { return "No commands available." } lines := make([]string, 0, len(defs)) for _, def := range defs { usage := def.EffectiveUsage() if usage == "" { usage = "/" + def.Name } desc := def.Description if desc == "" { desc = "No description" } lines = append(lines, fmt.Sprintf("%s - %s", usage, desc)) } return strings.Join(lines, "\n") } ================================================ FILE: pkg/commands/cmd_list.go ================================================ package commands import ( "context" "fmt" "strings" ) func listCommand() Definition { return Definition{ Name: "list", Description: "List available options", SubCommands: []SubCommand{ { Name: "models", Description: "Configured models", Handler: func(_ context.Context, req Request, rt *Runtime) error { if rt == nil || rt.GetModelInfo == nil { return req.Reply(unavailableMsg) } name, provider := rt.GetModelInfo() if provider == "" { provider = "configured default" } return req.Reply(fmt.Sprintf( "Configured Model: %s\nProvider: %s\n\nTo change models, update config.json", name, provider, )) }, }, { Name: "channels", Description: "Enabled channels", Handler: func(_ context.Context, req Request, rt *Runtime) error { if rt == nil || rt.GetEnabledChannels == nil { return req.Reply(unavailableMsg) } enabled := rt.GetEnabledChannels() if len(enabled) == 0 { return req.Reply("No channels enabled") } return req.Reply(fmt.Sprintf("Enabled Channels:\n- %s", strings.Join(enabled, "\n- "))) }, }, { Name: "agents", Description: "Registered agents", Handler: agentsHandler(), }, }, } } ================================================ FILE: pkg/commands/cmd_reload.go ================================================ package commands import "context" func reloadCommand() Definition { return Definition{ Name: "reload", Description: "Reload the configuration file", Usage: "/reload", Handler: func(_ context.Context, req Request, rt *Runtime) error { if rt == nil || rt.ReloadConfig == nil { return req.Reply(unavailableMsg) } if err := rt.ReloadConfig(); err != nil { return req.Reply("Failed to reload configuration: " + err.Error()) } return req.Reply("Config reload triggered!") }, } } ================================================ FILE: pkg/commands/cmd_show.go ================================================ package commands import ( "context" "fmt" ) func showCommand() Definition { return Definition{ Name: "show", Description: "Show current configuration", SubCommands: []SubCommand{ { Name: "model", Description: "Current model and provider", Handler: func(_ context.Context, req Request, rt *Runtime) error { if rt == nil || rt.GetModelInfo == nil { return req.Reply(unavailableMsg) } name, provider := rt.GetModelInfo() return req.Reply(fmt.Sprintf("Current Model: %s (Provider: %s)", name, provider)) }, }, { Name: "channel", Description: "Current channel", Handler: func(_ context.Context, req Request, _ *Runtime) error { return req.Reply(fmt.Sprintf("Current Channel: %s", req.Channel)) }, }, { Name: "agents", Description: "Registered agents", Handler: agentsHandler(), }, }, } } ================================================ FILE: pkg/commands/cmd_start.go ================================================ package commands import "context" func startCommand() Definition { return Definition{ Name: "start", Description: "Start the bot", Usage: "/start", Handler: func(_ context.Context, req Request, _ *Runtime) error { return req.Reply("Hello! I am PicoClaw 🦞") }, } } ================================================ FILE: pkg/commands/cmd_switch.go ================================================ package commands import ( "context" "fmt" ) func switchCommand() Definition { return Definition{ Name: "switch", Description: "Switch model", SubCommands: []SubCommand{ { Name: "model", Description: "Switch to a different model", ArgsUsage: "to ", Handler: func(_ context.Context, req Request, rt *Runtime) error { if rt == nil || rt.SwitchModel == nil { return req.Reply(unavailableMsg) } // Parse: /switch model to value := nthToken(req.Text, 3) // tokens: [/switch, model, to, ] if nthToken(req.Text, 2) != "to" || value == "" { return req.Reply("Usage: /switch model to ") } oldModel, err := rt.SwitchModel(value) if err != nil { return req.Reply(err.Error()) } return req.Reply(fmt.Sprintf("Switched model from %s to %s", oldModel, value)) }, }, { Name: "channel", Description: "Moved to /check channel", Handler: func(_ context.Context, req Request, _ *Runtime) error { return req.Reply("This command has moved. Please use: /check channel ") }, }, }, } } ================================================ FILE: pkg/commands/cmd_switch_test.go ================================================ package commands import ( "context" "fmt" "testing" ) func TestSwitchModel_Success(t *testing.T) { rt := &Runtime{ SwitchModel: func(value string) (string, error) { return "old-model", nil }, } ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) var reply string res := ex.Execute(context.Background(), Request{ Text: "/switch model to gpt-4", Reply: func(text string) error { reply = text return nil }, }) if res.Outcome != OutcomeHandled { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) } want := "Switched model from old-model to gpt-4" if reply != want { t.Fatalf("reply=%q, want=%q", reply, want) } } func TestSwitchModel_MissingToKeyword(t *testing.T) { rt := &Runtime{ SwitchModel: func(value string) (string, error) { return "old", nil }, } ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) var reply string res := ex.Execute(context.Background(), Request{ Text: "/switch model gpt-4", Reply: func(text string) error { reply = text return nil }, }) if res.Outcome != OutcomeHandled { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) } if reply != "Usage: /switch model to " { t.Fatalf("reply=%q, want usage message", reply) } } func TestSwitchModel_MissingValue(t *testing.T) { rt := &Runtime{ SwitchModel: func(value string) (string, error) { return "old", nil }, } ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) var reply string res := ex.Execute(context.Background(), Request{ Text: "/switch model to", Reply: func(text string) error { reply = text return nil }, }) if res.Outcome != OutcomeHandled { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) } if reply != "Usage: /switch model to " { t.Fatalf("reply=%q, want usage message", reply) } } func TestSwitchModel_Error(t *testing.T) { rt := &Runtime{ SwitchModel: func(value string) (string, error) { return "", fmt.Errorf("model not found") }, } ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) var reply string res := ex.Execute(context.Background(), Request{ Text: "/switch model to bad-model", Reply: func(text string) error { reply = text return nil }, }) if res.Outcome != OutcomeHandled { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) } if reply != "model not found" { t.Fatalf("reply=%q, want error message", reply) } } func TestSwitchModel_NilDep(t *testing.T) { ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{}) var reply string res := ex.Execute(context.Background(), Request{ Text: "/switch model to gpt-4", Reply: func(text string) error { reply = text return nil }, }) if res.Outcome != OutcomeHandled { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) } if reply != "Command unavailable in current context." { t.Fatalf("reply=%q, want unavailable message", reply) } } func TestSwitchChannel_Redirect(t *testing.T) { ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{}) var reply string res := ex.Execute(context.Background(), Request{ Text: "/switch channel to telegram", Reply: func(text string) error { reply = text return nil }, }) if res.Outcome != OutcomeHandled { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) } want := "This command has moved. Please use: /check channel " if reply != want { t.Fatalf("reply=%q, want=%q", reply, want) } } func TestCheckChannel_Success(t *testing.T) { rt := &Runtime{ SwitchChannel: func(value string) error { return nil }, } ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) var reply string res := ex.Execute(context.Background(), Request{ Text: "/check channel telegram", Reply: func(text string) error { reply = text return nil }, }) if res.Outcome != OutcomeHandled { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) } want := "Channel 'telegram' is available and enabled" if reply != want { t.Fatalf("reply=%q, want=%q", reply, want) } } func TestCheckChannel_Error(t *testing.T) { rt := &Runtime{ SwitchChannel: func(value string) error { return fmt.Errorf("channel '%s' not found", value) }, } ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) var reply string res := ex.Execute(context.Background(), Request{ Text: "/check channel unknown", Reply: func(text string) error { reply = text return nil }, }) if res.Outcome != OutcomeHandled { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) } if reply != "channel 'unknown' not found" { t.Fatalf("reply=%q, want error message", reply) } } func TestCheckChannel_NilDep(t *testing.T) { ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{}) var reply string res := ex.Execute(context.Background(), Request{ Text: "/check channel telegram", Reply: func(text string) error { reply = text return nil }, }) if res.Outcome != OutcomeHandled { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) } if reply != "Command unavailable in current context." { t.Fatalf("reply=%q, want unavailable message", reply) } } func TestCheckChannel_MissingValue(t *testing.T) { rt := &Runtime{ SwitchChannel: func(value string) error { return nil }, } ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) var reply string res := ex.Execute(context.Background(), Request{ Text: "/check channel", Reply: func(text string) error { reply = text return nil }, }) if res.Outcome != OutcomeHandled { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) } if reply != "Usage: /check channel " { t.Fatalf("reply=%q, want usage message", reply) } } func TestSwitch_BangPrefix(t *testing.T) { rt := &Runtime{ SwitchModel: func(value string) (string, error) { return "old", nil }, } ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) var reply string res := ex.Execute(context.Background(), Request{ Text: "!switch model to gpt-4", Reply: func(text string) error { reply = text return nil }, }) if res.Outcome != OutcomeHandled { t.Fatalf("! prefix: outcome=%v, want=%v", res.Outcome, OutcomeHandled) } if reply != "Switched model from old to gpt-4" { t.Fatalf("! prefix: reply=%q, want success message", reply) } } func TestSwitch_NoSubCommand(t *testing.T) { ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{}) var reply string res := ex.Execute(context.Background(), Request{ Text: "/switch", Reply: func(text string) error { reply = text return nil }, }) if res.Outcome != OutcomeHandled { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) } // Should get usage message from executor's sub-command routing if reply == "" { t.Fatal("expected usage reply for bare /switch") } } ================================================ FILE: pkg/commands/definition.go ================================================ package commands import ( "fmt" "strings" ) // SubCommand defines a single sub-command within a parent command. type SubCommand struct { Name string Description string ArgsUsage string // optional, e.g. "" Handler Handler } // Definition is the single-source metadata and behavior contract for a slash command. // // Design notes (phase 1): // - Every channel reads command shape from this type instead of keeping local copies. // - Visibility is global: all definitions are considered available to all channels. // - Platform menu registration (for example Telegram BotCommand) also derives from this // same definition so UI labels and runtime behavior stay aligned. type Definition struct { Name string Description string Usage string // for simple commands; ignored when SubCommands is set Aliases []string SubCommands []SubCommand // optional; when set, Executor routes to sub-command handlers Handler Handler // for simple commands without sub-commands } // EffectiveUsage returns the usage string. When SubCommands are present, // it is auto-generated from sub-command names so metadata and behavior // cannot drift. func (d Definition) EffectiveUsage() string { if len(d.SubCommands) == 0 { return d.Usage } names := make([]string, 0, len(d.SubCommands)) for _, sc := range d.SubCommands { name := sc.Name if sc.ArgsUsage != "" { name += " " + sc.ArgsUsage } names = append(names, name) } return fmt.Sprintf("/%s [%s]", d.Name, strings.Join(names, "|")) } ================================================ FILE: pkg/commands/definition_test.go ================================================ package commands import ( "testing" ) func TestDefinition_EffectiveUsage_NoSubCommands(t *testing.T) { d := Definition{Name: "start", Usage: "/start"} if got := d.EffectiveUsage(); got != "/start" { t.Fatalf("EffectiveUsage()=%q, want %q", got, "/start") } } func TestDefinition_EffectiveUsage_WithSubCommands(t *testing.T) { d := Definition{ Name: "show", SubCommands: []SubCommand{ {Name: "model"}, {Name: "channel"}, {Name: "agents"}, }, } want := "/show [model|channel|agents]" if got := d.EffectiveUsage(); got != want { t.Fatalf("EffectiveUsage()=%q, want %q", got, want) } } func TestDefinition_EffectiveUsage_WithArgsUsage(t *testing.T) { d := Definition{ Name: "session", SubCommands: []SubCommand{ {Name: "list"}, {Name: "resume", ArgsUsage: ""}, }, } want := "/session [list|resume ]" if got := d.EffectiveUsage(); got != want { t.Fatalf("EffectiveUsage()=%q, want %q", got, want) } } ================================================ FILE: pkg/commands/executor.go ================================================ package commands import ( "context" "fmt" ) type Outcome int const ( // OutcomePassthrough means this input should continue through normal agent flow. OutcomePassthrough Outcome = iota // OutcomeHandled means a command handler executed (with or without handler error). OutcomeHandled ) type ExecuteResult struct { Outcome Outcome Command string Err error } type Executor struct { reg *Registry rt *Runtime } func NewExecutor(reg *Registry, rt *Runtime) *Executor { return &Executor{reg: reg, rt: rt} } // Execute implements a two-state command decision: // 1) handled: execute command immediately; // 2) passthrough: not a command or intentionally deferred to agent logic. func (e *Executor) Execute(ctx context.Context, req Request) ExecuteResult { cmdName, ok := parseCommandName(req.Text) if !ok { return ExecuteResult{Outcome: OutcomePassthrough} } if e == nil || e.reg == nil { return ExecuteResult{Outcome: OutcomePassthrough, Command: cmdName} } def, found := e.reg.Lookup(cmdName) if !found { return ExecuteResult{Outcome: OutcomePassthrough, Command: cmdName} } return e.executeDefinition(ctx, req, def) } func (e *Executor) executeDefinition(ctx context.Context, req Request, def Definition) ExecuteResult { // Ensure Reply is always non-nil so handlers don't need to check. if req.Reply == nil { req.Reply = func(string) error { return nil } } // Simple command — no sub-commands if len(def.SubCommands) == 0 { if def.Handler == nil { return ExecuteResult{Outcome: OutcomePassthrough, Command: def.Name} } err := def.Handler(ctx, req, e.rt) return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err} } // Sub-command routing subName := nthToken(req.Text, 1) if subName == "" { err := req.Reply("Usage: " + def.EffectiveUsage()) return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err} } normalized := normalizeCommandName(subName) for _, sc := range def.SubCommands { if normalizeCommandName(sc.Name) == normalized { if sc.Handler == nil { return ExecuteResult{Outcome: OutcomePassthrough, Command: def.Name} } err := sc.Handler(ctx, req, e.rt) return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err} } } // Unknown sub-command err := req.Reply(fmt.Sprintf("Unknown option: %s. Usage: %s", subName, def.EffectiveUsage())) return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err} } ================================================ FILE: pkg/commands/executor_test.go ================================================ package commands import ( "context" "errors" "strings" "testing" ) func TestExecutor_RegisteredWithoutHandler_ReturnsPassthrough(t *testing.T) { defs := []Definition{{Name: "show"}} ex := NewExecutor(NewRegistry(defs), nil) res := ex.Execute(context.Background(), Request{Channel: "whatsapp", Text: "/show"}) if res.Outcome != OutcomePassthrough { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough) } } func TestExecutor_UnknownSlashCommand_ReturnsPassthrough(t *testing.T) { defs := []Definition{{Name: "show"}} ex := NewExecutor(NewRegistry(defs), nil) res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/unknown"}) if res.Outcome != OutcomePassthrough { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough) } } func TestExecutor_SupportedCommandWithHandler_ReturnsHandled(t *testing.T) { called := false defs := []Definition{ { Name: "help", Handler: func(context.Context, Request, *Runtime) error { called = true return nil }, }, } ex := NewExecutor(NewRegistry(defs), nil) res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/help@my_bot"}) if res.Outcome != OutcomeHandled { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) } if !called { t.Fatalf("expected handler to be called") } } func TestExecutor_AliasWithoutHandler_ReturnsPassthrough(t *testing.T) { defs := []Definition{ { Name: "show", Aliases: []string{"display"}, }, } ex := NewExecutor(NewRegistry(defs), nil) res := ex.Execute(context.Background(), Request{Channel: "whatsapp", Text: "/display"}) if res.Outcome != OutcomePassthrough { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough) } if res.Command != "show" { t.Fatalf("command=%q, want=%q", res.Command, "show") } } func TestExecutor_AliasWithHandler_ReturnsHandled(t *testing.T) { called := false defs := []Definition{ { Name: "clear", Aliases: []string{"reset"}, Handler: func(context.Context, Request, *Runtime) error { called = true return nil }, }, } ex := NewExecutor(NewRegistry(defs), nil) res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/reset"}) if res.Outcome != OutcomeHandled { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) } if res.Command != "clear" { t.Fatalf("command=%q, want=%q", res.Command, "clear") } if !called { t.Fatalf("expected handler to be called") } } func TestExecutor_SupportedCommandWithNilHandler_ReturnsPassthrough(t *testing.T) { defs := []Definition{ {Name: "placeholder"}, } ex := NewExecutor(NewRegistry(defs), nil) res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/placeholder list"}) if res.Outcome != OutcomePassthrough { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough) } if res.Command != "placeholder" { t.Fatalf("command=%q, want=%q", res.Command, "placeholder") } } func TestExecutor_NilHandlerDoesNotMaskLaterHandler(t *testing.T) { // With Lookup-based dispatch, the first registered definition for a name wins. // A definition with nil Handler and no SubCommands returns Passthrough. defs := []Definition{ {Name: "placeholder"}, } ex := NewExecutor(NewRegistry(defs), nil) res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/placeholder"}) if res.Outcome != OutcomePassthrough { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough) } if res.Command != "placeholder" { t.Fatalf("command=%q, want=%q", res.Command, "placeholder") } } func TestExecutor_HandlerErrorIsPropagated(t *testing.T) { wantErr := errors.New("handler failed") defs := []Definition{ { Name: "help", Handler: func(context.Context, Request, *Runtime) error { return wantErr }, }, } ex := NewExecutor(NewRegistry(defs), nil) res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/help"}) if res.Outcome != OutcomeHandled { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) } if !errors.Is(res.Err, wantErr) { t.Fatalf("err=%v, want=%v", res.Err, wantErr) } } func TestExecutor_SupportsBangPrefixAndCaseInsensitiveCommand(t *testing.T) { called := false defs := []Definition{ { Name: "help", Handler: func(context.Context, Request, *Runtime) error { called = true return nil }, }, } ex := NewExecutor(NewRegistry(defs), nil) res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "!HELP"}) if res.Outcome != OutcomeHandled { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) } if !called { t.Fatalf("expected handler to be called") } } func TestExecutor_SubCommand_RoutesToCorrectHandler(t *testing.T) { modelCalled := false defs := []Definition{ { Name: "show", SubCommands: []SubCommand{ {Name: "model", Handler: func(_ context.Context, _ Request, _ *Runtime) error { modelCalled = true return nil }}, {Name: "channel"}, }, }, } ex := NewExecutor(NewRegistry(defs), nil) res := ex.Execute(context.Background(), Request{Text: "/show model"}) if res.Outcome != OutcomeHandled { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) } if !modelCalled { t.Fatal("model sub-command handler was not called") } } func TestExecutor_SubCommand_NoArg_RepliesUsage(t *testing.T) { defs := []Definition{ { Name: "show", SubCommands: []SubCommand{ {Name: "model"}, {Name: "channel"}, }, }, } ex := NewExecutor(NewRegistry(defs), nil) var reply string res := ex.Execute(context.Background(), Request{ Text: "/show", Reply: func(text string) error { reply = text; return nil }, }) if res.Outcome != OutcomeHandled { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) } if reply != "Usage: /show [model|channel]" { t.Fatalf("reply=%q, want usage message", reply) } } func TestExecutor_SubCommand_UnknownArg_RepliesError(t *testing.T) { defs := []Definition{ { Name: "show", SubCommands: []SubCommand{ {Name: "model"}, }, }, } ex := NewExecutor(NewRegistry(defs), nil) var reply string res := ex.Execute(context.Background(), Request{ Text: "/show foobar", Reply: func(text string) error { reply = text; return nil }, }) if res.Outcome != OutcomeHandled { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) } if !strings.Contains(reply, "foobar") { t.Fatalf("reply=%q, should mention unknown sub-command", reply) } } func TestExecutor_SubCommand_NilHandler_ReturnsPassthrough(t *testing.T) { defs := []Definition{ { Name: "show", SubCommands: []SubCommand{ {Name: "model"}, // nil Handler }, }, } ex := NewExecutor(NewRegistry(defs), nil) res := ex.Execute(context.Background(), Request{Text: "/show model"}) if res.Outcome != OutcomePassthrough { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough) } } ================================================ FILE: pkg/commands/handler_agents.go ================================================ package commands import ( "context" "fmt" "strings" ) // agentsHandler returns a shared handler for both /show agents and /list agents. func agentsHandler() Handler { return func(_ context.Context, req Request, rt *Runtime) error { if rt == nil || rt.ListAgentIDs == nil { return req.Reply(unavailableMsg) } ids := rt.ListAgentIDs() if len(ids) == 0 { return req.Reply("No agents registered") } return req.Reply(fmt.Sprintf("Registered agents: %s", strings.Join(ids, ", "))) } } ================================================ FILE: pkg/commands/registry.go ================================================ package commands type Registry struct { defs []Definition index map[string]int } // NewRegistry stores the canonical command set used by both dispatch and // optional platform registration adapters. func NewRegistry(defs []Definition) *Registry { stored := make([]Definition, len(defs)) copy(stored, defs) index := make(map[string]int, len(stored)*2) for i, def := range stored { registerCommandName(index, def.Name, i) for _, alias := range def.Aliases { registerCommandName(index, alias, i) } } return &Registry{defs: stored, index: index} } // Definitions returns all registered command definitions. // Command availability is global and no longer channel-scoped. func (r *Registry) Definitions() []Definition { out := make([]Definition, len(r.defs)) copy(out, r.defs) return out } // Lookup returns a command definition by normalized command name or alias. func (r *Registry) Lookup(name string) (Definition, bool) { key := normalizeCommandName(name) if key == "" { return Definition{}, false } idx, ok := r.index[key] if !ok { return Definition{}, false } return r.defs[idx], true } func registerCommandName(index map[string]int, name string, defIndex int) { key := normalizeCommandName(name) if key == "" { return } if _, exists := index[key]; exists { return } index[key] = defIndex } ================================================ FILE: pkg/commands/registry_test.go ================================================ package commands import "testing" func TestRegistry_Definitions_ReturnsCopy(t *testing.T) { defs := []Definition{ {Name: "help", Description: "Show help"}, {Name: "admin", Description: "Admin command"}, } r := NewRegistry(defs) got := r.Definitions() if len(got) != 2 { t.Fatalf("definitions len = %d, want 2", len(got)) } got[0].Name = "mutated" again := r.Definitions() if again[0].Name != "help" { t.Fatalf("registry should not be mutated by caller, got first name %q", again[0].Name) } } func TestRegistry_Lookup_MatchesByLowercaseNameAndAlias(t *testing.T) { r := NewRegistry([]Definition{ {Name: "Help", Aliases: []string{"Assist"}}, {Name: "List"}, }) def, ok := r.Lookup("help") if !ok || def.Name != "Help" { t.Fatalf("lookup by lowercase name failed: ok=%v def=%+v", ok, def) } def, ok = r.Lookup("HELP") if !ok || def.Name != "Help" { t.Fatalf("lookup by uppercase name failed: ok=%v def=%+v", ok, def) } def, ok = r.Lookup("assist") if !ok || def.Name != "Help" { t.Fatalf("lookup by lowercase alias failed: ok=%v def=%+v", ok, def) } def, ok = r.Lookup("ASSIST") if !ok || def.Name != "Help" { t.Fatalf("lookup by uppercase alias failed: ok=%v def=%+v", ok, def) } } ================================================ FILE: pkg/commands/request.go ================================================ package commands import ( "context" "strings" ) type Handler func(ctx context.Context, req Request, rt *Runtime) error type Request struct { Channel string ChatID string SenderID string Text string Reply func(text string) error } const unavailableMsg = "Command unavailable in current context." var commandPrefixes = []string{"/", "!"} // parseCommandName accepts "/name", "!name", and Telegram's "/name@bot", then // normalizes to lowercase command names. func parseCommandName(input string) (string, bool) { token := nthToken(input, 0) if token == "" { return "", false } name, ok := trimCommandPrefix(token) if !ok { return "", false } if i := strings.Index(name, "@"); i >= 0 { name = name[:i] } name = normalizeCommandName(name) if name == "" { return "", false } return name, true } func trimCommandPrefix(token string) (string, bool) { for _, prefix := range commandPrefixes { if strings.HasPrefix(token, prefix) { return strings.TrimPrefix(token, prefix), true } } return "", false } // HasCommandPrefix returns true if the input starts with a recognized // command prefix (e.g. "/" or "!"). func HasCommandPrefix(input string) bool { token := nthToken(input, 0) if token == "" { return false } _, ok := trimCommandPrefix(token) return ok } // nthToken returns the 0-indexed token from whitespace-split input. func nthToken(input string, n int) string { parts := strings.Fields(strings.TrimSpace(input)) if n >= len(parts) { return "" } return parts[n] } func normalizeCommandName(name string) string { return strings.ToLower(strings.TrimSpace(name)) } ================================================ FILE: pkg/commands/request_test.go ================================================ package commands import "testing" func TestHasCommandPrefix(t *testing.T) { tests := []struct { input string want bool }{ {"/help", true}, {"!help", true}, {"/switch model to gpt-4", true}, {"!switch model to gpt-4", true}, {"hello", false}, {"", false}, {" ", false}, {"hello /world", false}, {"/", true}, {"!", true}, {" /help", true}, } for _, tt := range tests { got := HasCommandPrefix(tt.input) if got != tt.want { t.Errorf("HasCommandPrefix(%q) = %v, want %v", tt.input, got, tt.want) } } } ================================================ FILE: pkg/commands/runtime.go ================================================ package commands import "github.com/sipeed/picoclaw/pkg/config" // Runtime provides runtime dependencies to command handlers. It is constructed // per-request by the agent loop so that per-request state (like session scope) // can coexist with long-lived callbacks (like GetModelInfo). type Runtime struct { Config *config.Config GetModelInfo func() (name, provider string) ListAgentIDs func() []string ListDefinitions func() []Definition GetEnabledChannels func() []string SwitchModel func(value string) (oldModel string, err error) SwitchChannel func(value string) error ClearHistory func() error ReloadConfig func() error } ================================================ FILE: pkg/commands/show_list_handlers_test.go ================================================ package commands import ( "context" "strings" "testing" ) func TestShowListHandlers_ChannelPolicy(t *testing.T) { ex := NewExecutor(NewRegistry(BuiltinDefinitions()), nil) var telegramReply string handled := ex.Execute(context.Background(), Request{ Channel: "telegram", Text: "/show channel", Reply: func(text string) error { telegramReply = text return nil }, }) if handled.Outcome != OutcomeHandled { t.Fatalf("telegram /show outcome=%v, want=%v", handled.Outcome, OutcomeHandled) } if telegramReply != "Current Channel: telegram" { t.Fatalf("telegram /show reply=%q, want=%q", telegramReply, "Current Channel: telegram") } var whatsappReply string handledWhatsApp := ex.Execute(context.Background(), Request{ Channel: "whatsapp", Text: "/show channel", Reply: func(text string) error { whatsappReply = text return nil }, }) if handledWhatsApp.Outcome != OutcomeHandled { t.Fatalf("whatsapp /show outcome=%v, want=%v", handledWhatsApp.Outcome, OutcomeHandled) } if handledWhatsApp.Command != "show" { t.Fatalf("whatsapp /show command=%q, want=%q", handledWhatsApp.Command, "show") } if whatsappReply != "Current Channel: whatsapp" { t.Fatalf("whatsapp /show reply=%q, want=%q", whatsappReply, "Current Channel: whatsapp") } passthrough := ex.Execute(context.Background(), Request{ Channel: "whatsapp", Text: "/foo", }) if passthrough.Outcome != OutcomePassthrough { t.Fatalf("whatsapp /foo outcome=%v, want=%v", passthrough.Outcome, OutcomePassthrough) } if passthrough.Command != "foo" { t.Fatalf("whatsapp /foo command=%q, want=%q", passthrough.Command, "foo") } } func TestShowListHandlers_ListHandledOnAllChannels(t *testing.T) { rt := &Runtime{ GetEnabledChannels: func() []string { return []string{"telegram"} }, } ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) var reply string res := ex.Execute(context.Background(), Request{ Channel: "whatsapp", Text: "/list channels", Reply: func(text string) error { reply = text return nil }, }) if res.Outcome != OutcomeHandled { t.Fatalf("whatsapp /list outcome=%v, want=%v", res.Outcome, OutcomeHandled) } if res.Command != "list" { t.Fatalf("whatsapp /list command=%q, want=%q", res.Command, "list") } if !strings.Contains(reply, "telegram") { t.Fatalf("whatsapp /list reply=%q, expected enabled channels content", reply) } } ================================================ FILE: pkg/config/config.go ================================================ package config import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "sync/atomic" "github.com/caarlos0/env/v11" "github.com/sipeed/picoclaw/pkg/credential" "github.com/sipeed/picoclaw/pkg/fileutil" ) // rrCounter is a global counter for round-robin load balancing across models. var rrCounter atomic.Uint64 // FlexibleStringSlice is a []string that also accepts JSON numbers, // so allow_from can contain both "123" and 123. // It also supports parsing comma-separated strings from environment variables, // including both English (,) and Chinese (,) commas. type FlexibleStringSlice []string func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { // Try []string first var ss []string if err := json.Unmarshal(data, &ss); err == nil { *f = ss return nil } // Try []interface{} to handle mixed types var raw []any if err := json.Unmarshal(data, &raw); err != nil { return err } result := make([]string, 0, len(raw)) for _, v := range raw { switch val := v.(type) { case string: result = append(result, val) case float64: result = append(result, fmt.Sprintf("%.0f", val)) default: result = append(result, fmt.Sprintf("%v", val)) } } *f = result return nil } // UnmarshalText implements encoding.TextUnmarshaler to support env variable parsing. // It handles comma-separated values with both English (,) and Chinese (,) commas. func (f *FlexibleStringSlice) UnmarshalText(text []byte) error { if len(text) == 0 { *f = nil return nil } s := string(text) // Replace Chinese comma with English comma, then split s = strings.ReplaceAll(s, ",", ",") parts := strings.Split(s, ",") result := make([]string, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part != "" { result = append(result, part) } } *f = result return nil } type Config struct { Agents AgentsConfig `json:"agents"` Bindings []AgentBinding `json:"bindings,omitempty"` Session SessionConfig `json:"session,omitempty"` Channels ChannelsConfig `json:"channels"` Providers ProvidersConfig `json:"providers,omitempty"` ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration Gateway GatewayConfig `json:"gateway"` Tools ToolsConfig `json:"tools"` Heartbeat HeartbeatConfig `json:"heartbeat"` Devices DevicesConfig `json:"devices"` Voice VoiceConfig `json:"voice"` // BuildInfo contains build-time version information BuildInfo BuildInfo `json:"build_info,omitempty"` } // BuildInfo contains build-time version information type BuildInfo struct { Version string `json:"version"` GitCommit string `json:"git_commit"` BuildTime string `json:"build_time"` GoVersion string `json:"go_version"` } // MarshalJSON implements custom JSON marshaling for Config // to omit providers section when empty and session when empty func (c Config) MarshalJSON() ([]byte, error) { type Alias Config aux := &struct { Providers *ProvidersConfig `json:"providers,omitempty"` Session *SessionConfig `json:"session,omitempty"` *Alias }{ Alias: (*Alias)(&c), } // Only include providers if not empty if !c.Providers.IsEmpty() { aux.Providers = &c.Providers } // Only include session if not empty if c.Session.DMScope != "" || len(c.Session.IdentityLinks) > 0 { aux.Session = &c.Session } return json.Marshal(aux) } type AgentsConfig struct { Defaults AgentDefaults `json:"defaults"` List []AgentConfig `json:"list,omitempty"` } // AgentModelConfig supports both string and structured model config. // String format: "gpt-4" (just primary, no fallbacks) // Object format: {"primary": "gpt-4", "fallbacks": ["claude-haiku"]} type AgentModelConfig struct { Primary string `json:"primary,omitempty"` Fallbacks []string `json:"fallbacks,omitempty"` } func (m *AgentModelConfig) UnmarshalJSON(data []byte) error { var s string if err := json.Unmarshal(data, &s); err == nil { m.Primary = s m.Fallbacks = nil return nil } type raw struct { Primary string `json:"primary"` Fallbacks []string `json:"fallbacks"` } var r raw if err := json.Unmarshal(data, &r); err != nil { return err } m.Primary = r.Primary m.Fallbacks = r.Fallbacks return nil } func (m AgentModelConfig) MarshalJSON() ([]byte, error) { if len(m.Fallbacks) == 0 && m.Primary != "" { return json.Marshal(m.Primary) } type raw struct { Primary string `json:"primary,omitempty"` Fallbacks []string `json:"fallbacks,omitempty"` } return json.Marshal(raw{Primary: m.Primary, Fallbacks: m.Fallbacks}) } type AgentConfig struct { ID string `json:"id"` Default bool `json:"default,omitempty"` Name string `json:"name,omitempty"` Workspace string `json:"workspace,omitempty"` Model *AgentModelConfig `json:"model,omitempty"` Skills []string `json:"skills,omitempty"` Subagents *SubagentsConfig `json:"subagents,omitempty"` } type SubagentsConfig struct { AllowAgents []string `json:"allow_agents,omitempty"` Model *AgentModelConfig `json:"model,omitempty"` } type PeerMatch struct { Kind string `json:"kind"` ID string `json:"id"` } type BindingMatch struct { Channel string `json:"channel"` AccountID string `json:"account_id,omitempty"` Peer *PeerMatch `json:"peer,omitempty"` GuildID string `json:"guild_id,omitempty"` TeamID string `json:"team_id,omitempty"` } type AgentBinding struct { AgentID string `json:"agent_id"` Match BindingMatch `json:"match"` } type SessionConfig struct { DMScope string `json:"dm_scope,omitempty"` IdentityLinks map[string][]string `json:"identity_links,omitempty"` } // RoutingConfig controls the intelligent model routing feature. // When enabled, each incoming message is scored against structural features // (message length, code blocks, tool call history, conversation depth, attachments). // Messages scoring below Threshold are sent to LightModel; all others use the // agent's primary model. This reduces cost and latency for simple tasks without // requiring any keyword matching — all scoring is language-agnostic. type RoutingConfig struct { Enabled bool `json:"enabled"` LightModel string `json:"light_model"` // model_name from model_list to use for simple tasks Threshold float64 `json:"threshold"` // complexity score in [0,1]; score >= threshold → primary model } // ToolFeedbackConfig controls whether tool execution details are sent to the // chat channel as real-time feedback messages. When enabled, every tool call // produces a short notification with the tool name and its parameters. type ToolFeedbackConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED"` MaxArgsLength int `json:"max_args_length" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH"` } type AgentDefaults struct { Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"` Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` Model string `json:"model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead ModelFallbacks []string `json:"model_fallbacks,omitempty"` ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"` SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"` MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"` Routing *RoutingConfig `json:"routing,omitempty"` ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"` } const ( DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB DefaultWeComAIBotProcessingMessage = "⏳ Processing, please wait. The results will be sent shortly." ) func (d *AgentDefaults) GetMaxMediaSize() int { if d.MaxMediaSize > 0 { return d.MaxMediaSize } return DefaultMaxMediaSize } // GetToolFeedbackMaxArgsLength returns the max args preview length for tool feedback messages. func (d *AgentDefaults) GetToolFeedbackMaxArgsLength() int { if d.ToolFeedback.MaxArgsLength > 0 { return d.ToolFeedback.MaxArgsLength } return 300 } // IsToolFeedbackEnabled returns true when tool feedback messages should be sent to the chat. func (d *AgentDefaults) IsToolFeedbackEnabled() bool { return d.ToolFeedback.Enabled } // GetModelName returns the effective model name for the agent defaults. // It prefers the new "model_name" field but falls back to "model" for backward compatibility. func (d *AgentDefaults) GetModelName() string { if d.ModelName != "" { return d.ModelName } return d.Model } type ChannelsConfig struct { WhatsApp WhatsAppConfig `json:"whatsapp"` Telegram TelegramConfig `json:"telegram"` Feishu FeishuConfig `json:"feishu"` Discord DiscordConfig `json:"discord"` MaixCam MaixCamConfig `json:"maixcam"` QQ QQConfig `json:"qq"` DingTalk DingTalkConfig `json:"dingtalk"` Slack SlackConfig `json:"slack"` Matrix MatrixConfig `json:"matrix"` LINE LINEConfig `json:"line"` OneBot OneBotConfig `json:"onebot"` WeCom WeComConfig `json:"wecom"` WeComApp WeComAppConfig `json:"wecom_app"` WeComAIBot WeComAIBotConfig `json:"wecom_aibot"` Pico PicoConfig `json:"pico"` IRC IRCConfig `json:"irc"` } // GroupTriggerConfig controls when the bot responds in group chats. type GroupTriggerConfig struct { MentionOnly bool `json:"mention_only,omitempty"` Prefixes []string `json:"prefixes,omitempty"` } // TypingConfig controls typing indicator behavior (Phase 10). type TypingConfig struct { Enabled bool `json:"enabled,omitempty"` } // PlaceholderConfig controls placeholder message behavior (Phase 10). type PlaceholderConfig struct { Enabled bool `json:"enabled,omitempty"` Text string `json:"text,omitempty"` } type WhatsAppConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"` SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"` } type TelegramConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"` Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Typing TypingConfig `json:"typing,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"` UseMarkdownV2 bool `json:"use_markdown_v2" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"` } type FeishuConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"` RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"` IsLark bool `json:"is_lark" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"` } type DiscordConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_DISCORD_PROXY"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Typing TypingConfig `json:"typing,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"` } type MaixCamConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MAIXCAM_REASONING_CHANNEL_ID"` } type QQConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` MaxMessageLength int `json:"max_message_length" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"` MaxBase64FileSizeMiB int64 `json:"max_base64_file_size_mib" env:"PICOCLAW_CHANNELS_QQ_MAX_BASE64_FILE_SIZE_MIB"` SendMarkdown bool `json:"send_markdown" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"` } type DingTalkConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"` } type SlackConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Typing TypingConfig `json:"typing,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"` } type MatrixConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"` Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"` UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"` AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"` DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"` JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"` MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"` } type LINEConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"` ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"` WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"` WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"` WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Typing TypingConfig `json:"typing,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"` } type OneBotConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"` WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"` AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"` ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Typing TypingConfig `json:"typing,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"` } type WeComConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"` Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"` EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"` WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"` WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"` WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"` WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"` ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID"` } type WeComAppConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"` CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"` CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"` AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"` Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"` EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"` WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"` WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"` WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"` ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"` } type WeComAIBotConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"` BotID string `json:"bot_id,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_BOT_ID"` Secret string `json:"secret,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_SECRET"` Token string `json:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"` EncodingAESKey string `json:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"` WebhookPath string `json:"webhook_path,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"` ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"` MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome ProcessingMessage string `json:"processing_message,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_PROCESSING_MESSAGE"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"` } type PicoConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"` Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"` AllowTokenQuery bool `json:"allow_token_query,omitempty"` AllowOrigins []string `json:"allow_origins,omitempty"` PingInterval int `json:"ping_interval,omitempty"` ReadTimeout int `json:"read_timeout,omitempty"` WriteTimeout int `json:"write_timeout,omitempty"` MaxConnections int `json:"max_connections,omitempty"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_ALLOW_FROM"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"` } type IRCConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"` Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"` TLS bool `json:"tls" env:"PICOCLAW_CHANNELS_IRC_TLS"` Nick string `json:"nick" env:"PICOCLAW_CHANNELS_IRC_NICK"` User string `json:"user,omitempty" env:"PICOCLAW_CHANNELS_IRC_USER"` RealName string `json:"real_name,omitempty" env:"PICOCLAW_CHANNELS_IRC_REAL_NAME"` Password string `json:"password" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"` NickServPassword string `json:"nickserv_password" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"` SASLUser string `json:"sasl_user" env:"PICOCLAW_CHANNELS_IRC_SASL_USER"` SASLPassword string `json:"sasl_password" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"` Channels FlexibleStringSlice `json:"channels" env:"PICOCLAW_CHANNELS_IRC_CHANNELS"` RequestCaps FlexibleStringSlice `json:"request_caps,omitempty" env:"PICOCLAW_CHANNELS_IRC_REQUEST_CAPS"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_IRC_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Typing TypingConfig `json:"typing,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"` } type HeartbeatConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5 } type DevicesConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"` MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"` } type VoiceConfig struct { EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"` } type ProvidersConfig struct { Anthropic ProviderConfig `json:"anthropic"` OpenAI OpenAIProviderConfig `json:"openai"` LiteLLM ProviderConfig `json:"litellm"` OpenRouter ProviderConfig `json:"openrouter"` Groq ProviderConfig `json:"groq"` Zhipu ProviderConfig `json:"zhipu"` VLLM ProviderConfig `json:"vllm"` Gemini ProviderConfig `json:"gemini"` Nvidia ProviderConfig `json:"nvidia"` Ollama ProviderConfig `json:"ollama"` Moonshot ProviderConfig `json:"moonshot"` ShengSuanYun ProviderConfig `json:"shengsuanyun"` DeepSeek ProviderConfig `json:"deepseek"` Cerebras ProviderConfig `json:"cerebras"` Vivgrid ProviderConfig `json:"vivgrid"` VolcEngine ProviderConfig `json:"volcengine"` GitHubCopilot ProviderConfig `json:"github_copilot"` Antigravity ProviderConfig `json:"antigravity"` Qwen ProviderConfig `json:"qwen"` Mistral ProviderConfig `json:"mistral"` Avian ProviderConfig `json:"avian"` Minimax ProviderConfig `json:"minimax"` LongCat ProviderConfig `json:"longcat"` ModelScope ProviderConfig `json:"modelscope"` Novita ProviderConfig `json:"novita"` } // IsEmpty checks if all provider configs are empty (no API keys or API bases set) // Note: WebSearch is an optimization option and doesn't count as "non-empty" func (p ProvidersConfig) IsEmpty() bool { return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" && p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" && p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" && p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" && p.Groq.APIKey == "" && p.Groq.APIBase == "" && p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" && p.VLLM.APIKey == "" && p.VLLM.APIBase == "" && p.Gemini.APIKey == "" && p.Gemini.APIBase == "" && p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" && p.Ollama.APIKey == "" && p.Ollama.APIBase == "" && p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" && p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" && p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" && p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" && p.Vivgrid.APIKey == "" && p.Vivgrid.APIBase == "" && p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" && p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" && p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" && p.Qwen.APIKey == "" && p.Qwen.APIBase == "" && p.Mistral.APIKey == "" && p.Mistral.APIBase == "" && p.Avian.APIKey == "" && p.Avian.APIBase == "" && p.Minimax.APIKey == "" && p.Minimax.APIBase == "" && p.LongCat.APIKey == "" && p.LongCat.APIBase == "" && p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" && p.Novita.APIKey == "" && p.Novita.APIBase == "" } // MarshalJSON implements custom JSON marshaling for ProvidersConfig // to omit the entire section when empty func (p ProvidersConfig) MarshalJSON() ([]byte, error) { if p.IsEmpty() { return []byte("null"), nil } type Alias ProvidersConfig return json.Marshal((*Alias)(&p)) } type ProviderConfig struct { APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"` APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"` Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"` RequestTimeout int `json:"request_timeout,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_REQUEST_TIMEOUT"` AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"` ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc` } type OpenAIProviderConfig struct { ProviderConfig WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"` } // ModelConfig represents a model-centric provider configuration. // It allows adding new providers (especially OpenAI-compatible ones) via configuration only. // The model field uses protocol prefix format: [protocol/]model-identifier // Supported protocols include openai, anthropic, antigravity, claude-cli, // codex-cli, github-copilot, and named OpenAI-compatible protocols such as // groq, deepseek, modelscope, and novita. // Default protocol is "openai" if no prefix is specified. type ModelConfig struct { // Required fields ModelName string `json:"model_name"` // User-facing alias for the model Model string `json:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4.6") // HTTP-based providers APIBase string `json:"api_base,omitempty"` // API endpoint URL APIKey string `json:"api_key"` // API authentication key (single key) APIKeys []string `json:"api_keys,omitempty"` // API authentication keys (multiple keys for failover) Proxy string `json:"proxy,omitempty"` // HTTP proxy URL Fallbacks []string `json:"fallbacks,omitempty"` // Fallback model names for failover // Special providers (CLI-based, OAuth, etc.) AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token ConnectMode string `json:"connect_mode,omitempty"` // Connection mode: stdio, grpc Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers // Optional optimizations RPM int `json:"rpm,omitempty"` // Requests per minute limit MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens") RequestTimeout int `json:"request_timeout,omitempty"` ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive } // Validate checks if the ModelConfig has all required fields. func (c *ModelConfig) Validate() error { if c.ModelName == "" { return fmt.Errorf("model_name is required") } if c.Model == "" { return fmt.Errorf("model is required") } return nil } type GatewayConfig struct { Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"` } type ToolDiscoveryConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"` TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"` MaxSearchResults int `json:"max_search_results" env:"PICOCLAW_MAX_SEARCH_RESULTS"` UseBM25 bool `json:"use_bm25" env:"PICOCLAW_TOOLS_DISCOVERY_USE_BM25"` UseRegex bool `json:"use_regex" env:"PICOCLAW_TOOLS_DISCOVERY_USE_REGEX"` } type ToolConfig struct { Enabled bool `json:"enabled" env:"ENABLED"` } type BraveConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"` APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"` MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` } type TavilyConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"` APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"` APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEYS"` BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"` MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"` } type DuckDuckGoConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"` MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"` } type PerplexityConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"` APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"` APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"` MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` } type SearXNGConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_SEARXNG_ENABLED"` BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_SEARXNG_BASE_URL"` MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SEARXNG_MAX_RESULTS"` } type GLMSearchConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"` APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"` BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"` // SearchEngine specifies the search backend: "search_std" (default), // "search_pro", "search_pro_sogou", or "search_pro_quark". SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"` MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_GLM_MAX_RESULTS"` } type WebToolsConfig struct { ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"` Brave BraveConfig ` json:"brave"` Tavily TavilyConfig ` json:"tavily"` DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"` Perplexity PerplexityConfig ` json:"perplexity"` SearXNG SearXNGConfig ` json:"searxng"` GLMSearch GLMSearchConfig ` json:"glm_search"` // PreferNative controls whether to use provider-native web search when // the active LLM supports it (e.g. OpenAI web_search_preview). When true, // the client-side web_search tool is hidden to avoid duplicate search surfaces, // and the provider's built-in search is used instead. Falls back to client-side // search when the provider does not support native search. PreferNative bool `json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"` // Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h). // For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config. Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"` FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"` Format string `json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"` PrivateHostWhitelist FlexibleStringSlice `json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"` } type CronToolsConfig struct { ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"` ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout AllowCommand bool ` env:"PICOCLAW_TOOLS_CRON_ALLOW_COMMAND" json:"allow_command"` } type ExecConfig struct { ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"` EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"` AllowRemote bool ` env:"PICOCLAW_TOOLS_EXEC_ALLOW_REMOTE" json:"allow_remote"` CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"` CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"` TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s) } type SkillsToolsConfig struct { ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"` Registries SkillsRegistriesConfig ` json:"registries"` Github SkillsGithubConfig ` json:"github"` MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"` SearchCache SearchCacheConfig ` json:"search_cache"` } type MediaCleanupConfig struct { ToolConfig ` envPrefix:"PICOCLAW_MEDIA_CLEANUP_"` MaxAge int ` env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE" json:"max_age_minutes"` Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"` } type ReadFileToolConfig struct { Enabled bool `json:"enabled"` MaxReadFileSize int `json:"max_read_file_size"` } type ToolsConfig struct { AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"` AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"` Web WebToolsConfig `json:"web"` Cron CronToolsConfig `json:"cron"` Exec ExecConfig `json:"exec"` Skills SkillsToolsConfig `json:"skills"` MediaCleanup MediaCleanupConfig `json:"media_cleanup"` MCP MCPConfig `json:"mcp"` AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"` EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"` FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"` I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"` InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` SpawnStatus ToolConfig `json:"spawn_status" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"` SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"` Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"` WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"` WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"` } type SearchCacheConfig struct { MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"` TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"` } type SkillsRegistriesConfig struct { ClawHub ClawHubRegistryConfig `json:"clawhub"` } type SkillsGithubConfig struct { Token string `json:"token,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_AUTH_TOKEN"` Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"` } type ClawHubRegistryConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"` BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"` AuthToken string `json:"auth_token" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN"` SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"` SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"` DownloadPath string `json:"download_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH"` Timeout int `json:"timeout" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT"` MaxZipSize int `json:"max_zip_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_ZIP_SIZE"` MaxResponseSize int `json:"max_response_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"` } // MCPServerConfig defines configuration for a single MCP server type MCPServerConfig struct { // Enabled indicates whether this MCP server is active Enabled bool `json:"enabled"` // Deferred controls whether this server's tools are registered as hidden (deferred/discovery mode). // When nil, the global Discovery.Enabled setting applies. // When explicitly set to true or false, it overrides the global setting for this server only. Deferred *bool `json:"deferred,omitempty"` // Command is the executable to run (e.g., "npx", "python", "/path/to/server") Command string `json:"command"` // Args are the arguments to pass to the command Args []string `json:"args,omitempty"` // Env are environment variables to set for the server process (stdio only) Env map[string]string `json:"env,omitempty"` // EnvFile is the path to a file containing environment variables (stdio only) EnvFile string `json:"env_file,omitempty"` // Type is "stdio", "sse", or "http" (default: stdio if command is set, sse if url is set) Type string `json:"type,omitempty"` // URL is used for SSE/HTTP transport URL string `json:"url,omitempty"` // Headers are HTTP headers to send with requests (sse/http only) Headers map[string]string `json:"headers,omitempty"` } // MCPConfig defines configuration for all MCP servers type MCPConfig struct { ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"` Discovery ToolDiscoveryConfig ` json:"discovery"` // Servers is a map of server name to server configuration Servers map[string]MCPServerConfig `json:"servers,omitempty"` } func LoadConfig(path string) (*Config, error) { cfg := DefaultConfig() data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return cfg, nil } return nil, err } // Pre-scan the JSON to check how many model_list entries the user provided. // Go's JSON decoder reuses existing slice backing-array elements rather than // zero-initializing them, so fields absent from the user's JSON (e.g. api_base) // would silently inherit values from the DefaultConfig template at the same // index position. We only reset cfg.ModelList when the user actually provides // entries; when count is 0 we keep DefaultConfig's built-in list as fallback. var tmp Config if err := json.Unmarshal(data, &tmp); err != nil { return nil, err } if len(tmp.ModelList) > 0 { cfg.ModelList = nil } if err := json.Unmarshal(data, cfg); err != nil { return nil, err } if passphrase := credential.PassphraseProvider(); passphrase != "" { for _, m := range cfg.ModelList { if m.APIKey != "" && !strings.HasPrefix(m.APIKey, "enc://") && !strings.HasPrefix(m.APIKey, "file://") { fmt.Fprintf(os.Stderr, "picoclaw: warning: model %q has a plaintext api_key; call SaveConfig to encrypt it\n", m.ModelName) } } } if err := env.Parse(cfg); err != nil { return nil, err } if err := resolveAPIKeys(cfg.ModelList, filepath.Dir(path)); err != nil { return nil, err } // Expand multi-key configs into separate entries for key-level failover cfg.ModelList = ExpandMultiKeyModels(cfg.ModelList) // Migrate legacy channel config fields to new unified structures cfg.migrateChannelConfigs() // Auto-migrate: if only legacy providers config exists, convert to model_list if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() { cfg.ModelList = ConvertProvidersToModelList(cfg) } // Inherit credentials from providers to model_list entries (#1635). // When both providers and model_list are present, model_list entries // whose api_key/api_base are empty will inherit from the matching // provider (matched by protocol prefix). Explicit model_list values // always take precedence. if cfg.HasProvidersConfig() { InheritProviderCredentials(cfg.ModelList, cfg.Providers) } // Validate model_list for uniqueness and required fields if err := cfg.ValidateModelList(); err != nil { return nil, err } return cfg, nil } // encryptPlaintextAPIKeys returns a copy of models with plaintext api_key values // encrypted. Returns (nil, nil) when nothing changed (all keys already sealed or // empty). Returns (nil, error) if any key fails to encrypt — callers must treat // this as a hard failure to prevent a mixed plaintext/ciphertext state on disk. // Symmetric counterpart of resolveAPIKeys: both operate purely on []ModelConfig // and leave JSON marshaling to the caller. func encryptPlaintextAPIKeys(models []ModelConfig, passphrase string) ([]ModelConfig, error) { sealed := make([]ModelConfig, len(models)) copy(sealed, models) changed := false for i := range sealed { m := &sealed[i] if m.APIKey == "" || strings.HasPrefix(m.APIKey, "enc://") || strings.HasPrefix(m.APIKey, "file://") { continue } encrypted, err := credential.Encrypt(passphrase, "", m.APIKey) if err != nil { return nil, fmt.Errorf("cannot seal api_key for model %q: %w", m.ModelName, err) } m.APIKey = encrypted changed = true } if !changed { return nil, nil } return sealed, nil } // resolveAPIKeys decrypts or dereferences each api_key in models in-place. // Supports plaintext (no-op), file:// (read from configDir), and enc:// (AES-GCM decrypt). // Also resolves api_keys array if present. func resolveAPIKeys(models []ModelConfig, configDir string) error { cr := credential.NewResolver(configDir) for i := range models { // Resolve single APIKey resolved, err := cr.Resolve(models[i].APIKey) if err != nil { return fmt.Errorf("model_list[%d] (%s): %w", i, models[i].ModelName, err) } models[i].APIKey = resolved // Resolve APIKeys array for j, key := range models[i].APIKeys { resolved, err := cr.Resolve(key) if err != nil { return fmt.Errorf("model_list[%d] (%s): api_keys[%d]: %w", i, models[i].ModelName, j, err) } models[i].APIKeys[j] = resolved } } return nil } func (c *Config) migrateChannelConfigs() { // Discord: mention_only -> group_trigger.mention_only if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly { c.Channels.Discord.GroupTrigger.MentionOnly = true } // OneBot: group_trigger_prefix -> group_trigger.prefixes if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 && len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 { c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix } } func SaveConfig(path string, cfg *Config) error { if passphrase := credential.PassphraseProvider(); passphrase != "" { sealed, err := encryptPlaintextAPIKeys(cfg.ModelList, passphrase) if err != nil { return err } if sealed != nil { tmp := *cfg tmp.ModelList = sealed cfg = &tmp } } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } return fileutil.WriteFileAtomic(path, data, 0o600) } func (c *Config) WorkspacePath() string { return expandHome(c.Agents.Defaults.Workspace) } func (c *Config) GetAPIKey() string { if c.Providers.OpenRouter.APIKey != "" { return c.Providers.OpenRouter.APIKey } if c.Providers.Anthropic.APIKey != "" { return c.Providers.Anthropic.APIKey } if c.Providers.OpenAI.APIKey != "" { return c.Providers.OpenAI.APIKey } if c.Providers.Gemini.APIKey != "" { return c.Providers.Gemini.APIKey } if c.Providers.Zhipu.APIKey != "" { return c.Providers.Zhipu.APIKey } if c.Providers.Groq.APIKey != "" { return c.Providers.Groq.APIKey } if c.Providers.VLLM.APIKey != "" { return c.Providers.VLLM.APIKey } if c.Providers.ShengSuanYun.APIKey != "" { return c.Providers.ShengSuanYun.APIKey } if c.Providers.Cerebras.APIKey != "" { return c.Providers.Cerebras.APIKey } return "" } func (c *Config) GetAPIBase() string { if c.Providers.OpenRouter.APIKey != "" { if c.Providers.OpenRouter.APIBase != "" { return c.Providers.OpenRouter.APIBase } return "https://openrouter.ai/api/v1" } if c.Providers.Zhipu.APIKey != "" { return c.Providers.Zhipu.APIBase } if c.Providers.VLLM.APIKey != "" && c.Providers.VLLM.APIBase != "" { return c.Providers.VLLM.APIBase } return "" } func expandHome(path string) string { if path == "" { return path } if path[0] == '~' { home, _ := os.UserHomeDir() if len(path) > 1 && path[1] == '/' { return home + path[1:] } return home } return path } // GetModelConfig returns the ModelConfig for the given model name. // If multiple configs exist with the same model_name, it uses round-robin // selection for load balancing. Returns an error if the model is not found. func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) { matches := c.findMatches(modelName) if len(matches) == 0 { return nil, fmt.Errorf("model %q not found in model_list or providers", modelName) } if len(matches) == 1 { return &matches[0], nil } // Multiple configs - use round-robin for load balancing idx := (rrCounter.Add(1) - 1) % uint64(len(matches)) return &matches[idx], nil } // findMatches finds all ModelConfig entries with the given model_name. func (c *Config) findMatches(modelName string) []ModelConfig { var matches []ModelConfig for i := range c.ModelList { if c.ModelList[i].ModelName == modelName { matches = append(matches, c.ModelList[i]) } } return matches } // HasProvidersConfig checks if any provider in the old providers config has configuration. func (c *Config) HasProvidersConfig() bool { return !c.Providers.IsEmpty() } // ValidateModelList validates all ModelConfig entries in the model_list. // It checks that each model config is valid. // Note: Multiple entries with the same model_name are allowed for load balancing. func (c *Config) ValidateModelList() error { for i := range c.ModelList { if err := c.ModelList[i].Validate(); err != nil { return fmt.Errorf("model_list[%d]: %w", i, err) } } return nil } func MergeAPIKeys(apiKey string, apiKeys []string) []string { seen := make(map[string]struct{}) var all []string if k := strings.TrimSpace(apiKey); k != "" { if _, exists := seen[k]; !exists { seen[k] = struct{}{} all = append(all, k) } } for _, k := range apiKeys { if trimmed := strings.TrimSpace(k); trimmed != "" { if _, exists := seen[trimmed]; !exists { seen[trimmed] = struct{}{} all = append(all, trimmed) } } } return all } // ExpandMultiKeyModels expands ModelConfig entries with multiple API keys into // separate entries for key-level failover. Each key gets its own ModelConfig entry, // and the original entry's fallbacks are set up to chain through the expanded entries. // // Example: {"model_name": "gpt-4", "api_keys": ["k1", "k2", "k3"]} // Becomes: // - {"model_name": "gpt-4", "api_key": "k1", "fallbacks": ["gpt-4__key_1", "gpt-4__key_2"]} // - {"model_name": "gpt-4__key_1", "api_key": "k2"} // - {"model_name": "gpt-4__key_2", "api_key": "k3"} func ExpandMultiKeyModels(models []ModelConfig) []ModelConfig { var expanded []ModelConfig for _, m := range models { keys := MergeAPIKeys(m.APIKey, m.APIKeys) // Single key or no keys: keep as-is if len(keys) <= 1 { // Ensure APIKey is set from APIKeys if needed if m.APIKey == "" && len(keys) == 1 { m.APIKey = keys[0] } m.APIKeys = nil // Clear APIKeys to avoid confusion expanded = append(expanded, m) continue } // Multiple keys: expand originalName := m.ModelName // Create entries for additional keys (key_1, key_2, ...) var fallbackNames []string for i := 1; i < len(keys); i++ { suffix := fmt.Sprintf("__key_%d", i) expandedName := originalName + suffix // Create a copy for the additional key additionalEntry := ModelConfig{ ModelName: expandedName, Model: m.Model, APIBase: m.APIBase, APIKey: keys[i], Proxy: m.Proxy, AuthMethod: m.AuthMethod, ConnectMode: m.ConnectMode, Workspace: m.Workspace, RPM: m.RPM, MaxTokensField: m.MaxTokensField, RequestTimeout: m.RequestTimeout, ThinkingLevel: m.ThinkingLevel, } expanded = append(expanded, additionalEntry) fallbackNames = append(fallbackNames, expandedName) } // Create the primary entry with first key and fallbacks primaryEntry := ModelConfig{ ModelName: originalName, Model: m.Model, APIBase: m.APIBase, APIKey: keys[0], Proxy: m.Proxy, AuthMethod: m.AuthMethod, ConnectMode: m.ConnectMode, Workspace: m.Workspace, RPM: m.RPM, MaxTokensField: m.MaxTokensField, RequestTimeout: m.RequestTimeout, ThinkingLevel: m.ThinkingLevel, } // Prepend new fallbacks to existing ones if len(fallbackNames) > 0 { primaryEntry.Fallbacks = append(fallbackNames, m.Fallbacks...) } else if len(m.Fallbacks) > 0 { primaryEntry.Fallbacks = m.Fallbacks } expanded = append(expanded, primaryEntry) } return expanded } func (t *ToolsConfig) IsToolEnabled(name string) bool { switch name { case "web": return t.Web.Enabled case "cron": return t.Cron.Enabled case "exec": return t.Exec.Enabled case "skills": return t.Skills.Enabled case "media_cleanup": return t.MediaCleanup.Enabled case "append_file": return t.AppendFile.Enabled case "edit_file": return t.EditFile.Enabled case "find_skills": return t.FindSkills.Enabled case "i2c": return t.I2C.Enabled case "install_skill": return t.InstallSkill.Enabled case "list_dir": return t.ListDir.Enabled case "message": return t.Message.Enabled case "read_file": return t.ReadFile.Enabled case "spawn": return t.Spawn.Enabled case "spawn_status": return t.SpawnStatus.Enabled case "spi": return t.SPI.Enabled case "subagent": return t.Subagent.Enabled case "web_fetch": return t.WebFetch.Enabled case "send_file": return t.SendFile.Enabled case "write_file": return t.WriteFile.Enabled case "mcp": return t.MCP.Enabled default: return true } } ================================================ FILE: pkg/config/config_test.go ================================================ package config import ( "encoding/json" "os" "path/filepath" "runtime" "strings" "testing" "github.com/sipeed/picoclaw/pkg/credential" ) // mustSetupSSHKey generates a temporary Ed25519 SSH key in t.TempDir() and sets // PICOCLAW_SSH_KEY_PATH to its path for the duration of the test. This is required // whenever a test exercises encryption/decryption via credential.Encrypt or SaveConfig. func mustSetupSSHKey(t *testing.T) { t.Helper() keyPath := filepath.Join(t.TempDir(), "picoclaw_ed25519.key") if err := credential.GenerateSSHKey(keyPath); err != nil { t.Fatalf("mustSetupSSHKey: %v", err) } t.Setenv("PICOCLAW_SSH_KEY_PATH", keyPath) } func TestAgentModelConfig_UnmarshalString(t *testing.T) { var m AgentModelConfig if err := json.Unmarshal([]byte(`"gpt-4"`), &m); err != nil { t.Fatalf("unmarshal string: %v", err) } if m.Primary != "gpt-4" { t.Errorf("Primary = %q, want 'gpt-4'", m.Primary) } if m.Fallbacks != nil { t.Errorf("Fallbacks = %v, want nil", m.Fallbacks) } } func TestAgentModelConfig_UnmarshalObject(t *testing.T) { var m AgentModelConfig data := `{"primary": "claude-opus", "fallbacks": ["gpt-4o-mini", "haiku"]}` if err := json.Unmarshal([]byte(data), &m); err != nil { t.Fatalf("unmarshal object: %v", err) } if m.Primary != "claude-opus" { t.Errorf("Primary = %q, want 'claude-opus'", m.Primary) } if len(m.Fallbacks) != 2 { t.Fatalf("Fallbacks len = %d, want 2", len(m.Fallbacks)) } if m.Fallbacks[0] != "gpt-4o-mini" || m.Fallbacks[1] != "haiku" { t.Errorf("Fallbacks = %v", m.Fallbacks) } } func TestAgentModelConfig_MarshalString(t *testing.T) { m := AgentModelConfig{Primary: "gpt-4"} data, err := json.Marshal(m) if err != nil { t.Fatalf("marshal: %v", err) } if string(data) != `"gpt-4"` { t.Errorf("marshal = %s, want '\"gpt-4\"'", string(data)) } } func TestAgentModelConfig_MarshalObject(t *testing.T) { m := AgentModelConfig{Primary: "claude-opus", Fallbacks: []string{"haiku"}} data, err := json.Marshal(m) if err != nil { t.Fatalf("marshal: %v", err) } var result map[string]any json.Unmarshal(data, &result) if result["primary"] != "claude-opus" { t.Errorf("primary = %v", result["primary"]) } } func TestProvidersConfig_IsEmpty(t *testing.T) { var empty ProvidersConfig if !empty.IsEmpty() { t.Fatal("empty ProvidersConfig should report empty") } novita := ProvidersConfig{ Novita: ProviderConfig{ APIKey: "test-key", }, } if novita.IsEmpty() { t.Fatal("ProvidersConfig with novita settings should not report empty") } } func TestAgentConfig_FullParse(t *testing.T) { jsonData := `{ "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "model": "glm-4.7", "max_tokens": 8192, "max_tool_iterations": 20 }, "list": [ { "id": "sales", "default": true, "name": "Sales Bot", "model": "gpt-4" }, { "id": "support", "name": "Support Bot", "model": { "primary": "claude-opus", "fallbacks": ["haiku"] }, "subagents": { "allow_agents": ["sales"] } } ] }, "bindings": [ { "agent_id": "support", "match": { "channel": "telegram", "account_id": "*", "peer": {"kind": "direct", "id": "user123"} } } ], "session": { "dm_scope": "per-peer", "identity_links": { "john": ["telegram:123", "discord:john#1234"] } } }` cfg := DefaultConfig() if err := json.Unmarshal([]byte(jsonData), cfg); err != nil { t.Fatalf("unmarshal: %v", err) } if len(cfg.Agents.List) != 2 { t.Fatalf("agents.list len = %d, want 2", len(cfg.Agents.List)) } sales := cfg.Agents.List[0] if sales.ID != "sales" || !sales.Default || sales.Name != "Sales Bot" { t.Errorf("sales = %+v", sales) } if sales.Model == nil || sales.Model.Primary != "gpt-4" { t.Errorf("sales.Model = %+v", sales.Model) } support := cfg.Agents.List[1] if support.ID != "support" || support.Name != "Support Bot" { t.Errorf("support = %+v", support) } if support.Model == nil || support.Model.Primary != "claude-opus" { t.Errorf("support.Model = %+v", support.Model) } if len(support.Model.Fallbacks) != 1 || support.Model.Fallbacks[0] != "haiku" { t.Errorf("support.Model.Fallbacks = %v", support.Model.Fallbacks) } if support.Subagents == nil || len(support.Subagents.AllowAgents) != 1 { t.Errorf("support.Subagents = %+v", support.Subagents) } if len(cfg.Bindings) != 1 { t.Fatalf("bindings len = %d, want 1", len(cfg.Bindings)) } binding := cfg.Bindings[0] if binding.AgentID != "support" || binding.Match.Channel != "telegram" { t.Errorf("binding = %+v", binding) } if binding.Match.Peer == nil || binding.Match.Peer.Kind != "direct" || binding.Match.Peer.ID != "user123" { t.Errorf("binding.Match.Peer = %+v", binding.Match.Peer) } if cfg.Session.DMScope != "per-peer" { t.Errorf("Session.DMScope = %q", cfg.Session.DMScope) } if len(cfg.Session.IdentityLinks) != 1 { t.Errorf("Session.IdentityLinks = %v", cfg.Session.IdentityLinks) } links := cfg.Session.IdentityLinks["john"] if len(links) != 2 { t.Errorf("john links = %v", links) } } func TestConfig_BackwardCompat_NoAgentsList(t *testing.T) { jsonData := `{ "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "model": "glm-4.7", "max_tokens": 8192, "max_tool_iterations": 20 } } }` cfg := DefaultConfig() if err := json.Unmarshal([]byte(jsonData), cfg); err != nil { t.Fatalf("unmarshal: %v", err) } if len(cfg.Agents.List) != 0 { t.Errorf("agents.list should be empty for backward compat, got %d", len(cfg.Agents.List)) } if len(cfg.Bindings) != 0 { t.Errorf("bindings should be empty, got %d", len(cfg.Bindings)) } } // TestDefaultConfig_HeartbeatEnabled verifies heartbeat is enabled by default func TestDefaultConfig_HeartbeatEnabled(t *testing.T) { cfg := DefaultConfig() if !cfg.Heartbeat.Enabled { t.Error("Heartbeat should be enabled by default") } } // TestDefaultConfig_WorkspacePath verifies workspace path is correctly set func TestDefaultConfig_WorkspacePath(t *testing.T) { cfg := DefaultConfig() if cfg.Agents.Defaults.Workspace == "" { t.Error("Workspace should not be empty") } } // TestDefaultConfig_Model verifies model is set func TestDefaultConfig_Model(t *testing.T) { cfg := DefaultConfig() if cfg.Agents.Defaults.Model != "" { t.Error("Model should be empty") } } // TestDefaultConfig_MaxTokens verifies max tokens has default value func TestDefaultConfig_MaxTokens(t *testing.T) { cfg := DefaultConfig() if cfg.Agents.Defaults.MaxTokens == 0 { t.Error("MaxTokens should not be zero") } } // TestDefaultConfig_MaxToolIterations verifies max tool iterations has default value func TestDefaultConfig_MaxToolIterations(t *testing.T) { cfg := DefaultConfig() if cfg.Agents.Defaults.MaxToolIterations == 0 { t.Error("MaxToolIterations should not be zero") } } // TestDefaultConfig_Temperature verifies temperature has default value func TestDefaultConfig_Temperature(t *testing.T) { cfg := DefaultConfig() if cfg.Agents.Defaults.Temperature != nil { t.Error("Temperature should be nil when not provided") } } // TestDefaultConfig_Gateway verifies gateway defaults func TestDefaultConfig_Gateway(t *testing.T) { cfg := DefaultConfig() if cfg.Gateway.Host != "127.0.0.1" { t.Error("Gateway host should have default value") } if cfg.Gateway.Port == 0 { t.Error("Gateway port should have default value") } if cfg.Gateway.HotReload { t.Error("Gateway hot reload should be disabled by default") } } // TestDefaultConfig_Providers verifies provider structure func TestDefaultConfig_Providers(t *testing.T) { cfg := DefaultConfig() if cfg.Providers.Anthropic.APIKey != "" { t.Error("Anthropic API key should be empty by default") } if cfg.Providers.OpenAI.APIKey != "" { t.Error("OpenAI API key should be empty by default") } if cfg.Providers.OpenRouter.APIKey != "" { t.Error("OpenRouter API key should be empty by default") } } // TestDefaultConfig_Channels verifies channels are disabled by default func TestDefaultConfig_Channels(t *testing.T) { cfg := DefaultConfig() if cfg.Channels.Telegram.Enabled { t.Error("Telegram should be disabled by default") } if cfg.Channels.Discord.Enabled { t.Error("Discord should be disabled by default") } if cfg.Channels.Slack.Enabled { t.Error("Slack should be disabled by default") } if cfg.Channels.Matrix.Enabled { t.Error("Matrix should be disabled by default") } } // TestDefaultConfig_WebTools verifies web tools config func TestDefaultConfig_WebTools(t *testing.T) { cfg := DefaultConfig() // Verify web tools defaults if cfg.Tools.Web.Brave.MaxResults != 5 { t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults) } if len(cfg.Tools.Web.Brave.APIKeys) != 0 { t.Error("Brave API key should be empty by default") } if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 { t.Error("Expected DuckDuckGo MaxResults 5, got ", cfg.Tools.Web.DuckDuckGo.MaxResults) } } func TestSaveConfig_FilePermissions(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("file permission bits are not enforced on Windows") } tmpDir := t.TempDir() path := filepath.Join(tmpDir, "config.json") cfg := DefaultConfig() if err := SaveConfig(path, cfg); err != nil { t.Fatalf("SaveConfig failed: %v", err) } info, err := os.Stat(path) if err != nil { t.Fatalf("Stat failed: %v", err) } perm := info.Mode().Perm() if perm != 0o600 { t.Errorf("config file has permission %04o, want 0600", perm) } } func TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) { tmpDir := t.TempDir() path := filepath.Join(tmpDir, "config.json") cfg := DefaultConfig() if err := SaveConfig(path, cfg); err != nil { t.Fatalf("SaveConfig failed: %v", err) } data, err := os.ReadFile(path) if err != nil { t.Fatalf("ReadFile failed: %v", err) } if !strings.Contains(string(data), `"model_name": ""`) { t.Fatalf("saved config should include empty legacy model_name field, got: %s", string(data)) } } // TestConfig_Complete verifies all config fields are set func TestConfig_Complete(t *testing.T) { cfg := DefaultConfig() if cfg.Agents.Defaults.Workspace == "" { t.Error("Workspace should not be empty") } if cfg.Agents.Defaults.Model != "" { t.Error("Model should be empty") } if cfg.Agents.Defaults.Temperature != nil { t.Error("Temperature should be nil when not provided") } if cfg.Agents.Defaults.MaxTokens == 0 { t.Error("MaxTokens should not be zero") } if cfg.Agents.Defaults.MaxToolIterations == 0 { t.Error("MaxToolIterations should not be zero") } if cfg.Gateway.Host != "127.0.0.1" { t.Error("Gateway host should have default value") } if cfg.Gateway.Port == 0 { t.Error("Gateway port should have default value") } if !cfg.Heartbeat.Enabled { t.Error("Heartbeat should be enabled by default") } } func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) { cfg := DefaultConfig() if !cfg.Providers.OpenAI.WebSearch { t.Fatal("DefaultConfig().Providers.OpenAI.WebSearch should be true") } } func TestDefaultConfig_WebPreferNativeEnabled(t *testing.T) { cfg := DefaultConfig() if !cfg.Tools.Web.PreferNative { t.Fatal("DefaultConfig().Tools.Web.PreferNative should be true") } } func TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") if err := os.WriteFile(configPath, []byte(`{"tools":{"web":{"enabled":true}}}`), 0o600); err != nil { t.Fatalf("WriteFile() error: %v", err) } cfg, err := LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error: %v", err) } if !cfg.Tools.Web.PreferNative { t.Fatal("PreferNative should remain true when unset in config file") } } func TestLoadConfig_WebPreferNativeCanBeDisabled(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") if err := os.WriteFile(configPath, []byte(`{"tools":{"web":{"prefer_native":false}}}`), 0o600); err != nil { t.Fatalf("WriteFile() error: %v", err) } cfg, err := LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error: %v", err) } if cfg.Tools.Web.PreferNative { t.Fatal("PreferNative should be false when disabled in config file") } } func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) { cfg := DefaultConfig() if !cfg.Tools.Exec.AllowRemote { t.Fatal("DefaultConfig().Tools.Exec.AllowRemote should be true") } } func TestDefaultConfig_CronAllowCommandEnabled(t *testing.T) { cfg := DefaultConfig() if !cfg.Tools.Cron.AllowCommand { t.Fatal("DefaultConfig().Tools.Cron.AllowCommand should be true") } } func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"api_base":""}}}`), 0o600); err != nil { t.Fatalf("WriteFile() error: %v", err) } cfg, err := LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error: %v", err) } if !cfg.Providers.OpenAI.WebSearch { t.Fatal("OpenAI codex web search should remain true when unset in config file") } } func TestLoadConfig_ExecAllowRemoteDefaultsTrueWhenUnset(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") if err := os.WriteFile(configPath, []byte(`{"tools":{"exec":{"enable_deny_patterns":true}}}`), 0o600); err != nil { t.Fatalf("WriteFile() error: %v", err) } cfg, err := LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error: %v", err) } if !cfg.Tools.Exec.AllowRemote { t.Fatal("tools.exec.allow_remote should remain true when unset in config file") } } func TestLoadConfig_CronAllowCommandDefaultsTrueWhenUnset(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") if err := os.WriteFile(configPath, []byte(`{"tools":{"cron":{"exec_timeout_minutes":5}}}`), 0o600); err != nil { t.Fatalf("WriteFile() error: %v", err) } cfg, err := LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error: %v", err) } if !cfg.Tools.Cron.AllowCommand { t.Fatal("tools.cron.allow_command should remain true when unset in config file") } } func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"web_search":false}}}`), 0o600); err != nil { t.Fatalf("WriteFile() error: %v", err) } cfg, err := LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error: %v", err) } if cfg.Providers.OpenAI.WebSearch { t.Fatal("OpenAI codex web search should be false when disabled in config file") } } func TestLoadConfig_WebToolsProxy(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "config.json") configJSON := `{ "agents": {"defaults":{"workspace":"./workspace","model":"gpt4","max_tokens":8192,"max_tool_iterations":20}}, "model_list": [{"model_name":"gpt4","model":"openai/gpt-5.4","api_key":"x"}], "tools": {"web":{"proxy":"http://127.0.0.1:7890"}} }` if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil { t.Fatalf("os.WriteFile() error: %v", err) } cfg, err := LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error: %v", err) } if cfg.Tools.Web.Proxy != "http://127.0.0.1:7890" { t.Fatalf("Tools.Web.Proxy = %q, want %q", cfg.Tools.Web.Proxy, "http://127.0.0.1:7890") } } // TestDefaultConfig_DMScope verifies the default dm_scope value // TestDefaultConfig_SummarizationThresholds verifies summarization defaults func TestDefaultConfig_SummarizationThresholds(t *testing.T) { cfg := DefaultConfig() if cfg.Agents.Defaults.SummarizeMessageThreshold != 20 { t.Errorf("SummarizeMessageThreshold = %d, want 20", cfg.Agents.Defaults.SummarizeMessageThreshold) } if cfg.Agents.Defaults.SummarizeTokenPercent != 75 { t.Errorf("SummarizeTokenPercent = %d, want 75", cfg.Agents.Defaults.SummarizeTokenPercent) } } func TestDefaultConfig_DMScope(t *testing.T) { cfg := DefaultConfig() if cfg.Session.DMScope != "per-channel-peer" { t.Errorf("Session.DMScope = %q, want 'per-channel-peer'", cfg.Session.DMScope) } } func TestDefaultConfig_WorkspacePath_Default(t *testing.T) { t.Setenv("PICOCLAW_HOME", "") var fakeHome string if runtime.GOOS == "windows" { fakeHome = `C:\tmp\home` t.Setenv("USERPROFILE", fakeHome) } else { fakeHome = "/tmp/home" t.Setenv("HOME", fakeHome) } cfg := DefaultConfig() want := filepath.Join(fakeHome, ".picoclaw", "workspace") if cfg.Agents.Defaults.Workspace != want { t.Errorf("Default workspace path = %q, want %q", cfg.Agents.Defaults.Workspace, want) } } func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) { t.Setenv("PICOCLAW_HOME", "/custom/picoclaw/home") cfg := DefaultConfig() want := filepath.Join("/custom/picoclaw/home", "workspace") if cfg.Agents.Defaults.Workspace != want { t.Errorf("Workspace path with PICOCLAW_HOME = %q, want %q", cfg.Agents.Defaults.Workspace, want) } } // TestFlexibleStringSlice_UnmarshalText tests UnmarshalText with various comma separators func TestFlexibleStringSlice_UnmarshalText(t *testing.T) { tests := []struct { name string input string expected []string }{ { name: "English commas only", input: "123,456,789", expected: []string{"123", "456", "789"}, }, { name: "Chinese commas only", input: "123,456,789", expected: []string{"123", "456", "789"}, }, { name: "Mixed English and Chinese commas", input: "123,456,789", expected: []string{"123", "456", "789"}, }, { name: "Single value", input: "123", expected: []string{"123"}, }, { name: "Values with whitespace", input: " 123 , 456 , 789 ", expected: []string{"123", "456", "789"}, }, { name: "Empty string", input: "", expected: nil, }, { name: "Only commas - English", input: ",,", expected: []string{}, }, { name: "Only commas - Chinese", input: ",,", expected: []string{}, }, { name: "Mixed commas with empty parts", input: "123,,456,,789", expected: []string{"123", "456", "789"}, }, { name: "Complex mixed values", input: "user1@example.com,user2@test.com, admin@domain.org", expected: []string{"user1@example.com", "user2@test.com", "admin@domain.org"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var f FlexibleStringSlice err := f.UnmarshalText([]byte(tt.input)) if err != nil { t.Fatalf("UnmarshalText(%q) error = %v", tt.input, err) } if tt.expected == nil { if f != nil { t.Errorf("UnmarshalText(%q) = %v, want nil", tt.input, f) } return } if len(f) != len(tt.expected) { t.Errorf("UnmarshalText(%q) length = %d, want %d", tt.input, len(f), len(tt.expected)) return } for i, v := range tt.expected { if f[i] != v { t.Errorf("UnmarshalText(%q)[%d] = %q, want %q", tt.input, i, f[i], v) } } }) } } // TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency tests nil vs empty slice behavior func TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency(t *testing.T) { t.Run("Empty string returns nil", func(t *testing.T) { var f FlexibleStringSlice err := f.UnmarshalText([]byte("")) if err != nil { t.Fatalf("UnmarshalText error = %v", err) } if f != nil { t.Errorf("Empty string should return nil, got %v", f) } }) t.Run("Commas only returns empty slice", func(t *testing.T) { var f FlexibleStringSlice err := f.UnmarshalText([]byte(",,,")) if err != nil { t.Fatalf("UnmarshalText error = %v", err) } if f == nil { t.Error("Commas only should return empty slice, not nil") } if len(f) != 0 { t.Errorf("Expected empty slice, got %v", f) } }) } // TestLoadConfig_WarnsForPlaintextAPIKey verifies that LoadConfig resolves a plaintext // api_key into memory but does NOT rewrite the config file. File writes are the sole // responsibility of SaveConfig. func TestLoadConfig_WarnsForPlaintextAPIKey(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.json") const original = `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}` if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil { t.Fatalf("setup: %v", err) } t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") t.Setenv("PICOCLAW_SSH_KEY_PATH", "") cfg, err := LoadConfig(cfgPath) if err != nil { t.Fatalf("LoadConfig: %v", err) } // In-memory value must be the resolved plaintext. if cfg.ModelList[0].APIKey != "sk-plaintext" { t.Errorf("in-memory api_key = %q, want %q", cfg.ModelList[0].APIKey, "sk-plaintext") } // The file on disk must remain unchanged — LoadConfig must not write anything. raw, _ := os.ReadFile(cfgPath) if string(raw) != original { t.Errorf("LoadConfig must not modify the config file; got:\n%s", string(raw)) } } // TestSaveConfig_EncryptsPlaintextAPIKey verifies that SaveConfig writes enc:// ciphertext // to disk and that a subsequent LoadConfig decrypts it back to the original plaintext. func TestSaveConfig_EncryptsPlaintextAPIKey(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.json") t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") mustSetupSSHKey(t) cfg := DefaultConfig() cfg.ModelList = []ModelConfig{ {ModelName: "test", Model: "openai/gpt-4", APIKey: "sk-plaintext"}, } if err := SaveConfig(cfgPath, cfg); err != nil { t.Fatalf("SaveConfig: %v", err) } // Disk must contain enc://, not the raw key. raw, _ := os.ReadFile(cfgPath) if !strings.Contains(string(raw), "enc://") { t.Errorf("saved file should contain enc://, got:\n%s", string(raw)) } if strings.Contains(string(raw), "sk-plaintext") { t.Errorf("saved file must not contain the plaintext key") } // A fresh load must decrypt back to the original plaintext. cfg2, err := LoadConfig(cfgPath) if err != nil { t.Fatalf("LoadConfig after SaveConfig: %v", err) } if cfg2.ModelList[0].APIKey != "sk-plaintext" { t.Errorf("loaded api_key = %q, want %q", cfg2.ModelList[0].APIKey, "sk-plaintext") } } // TestLoadConfig_NoSealWithoutPassphrase verifies that api_key values are left // unchanged when PICOCLAW_KEY_PASSPHRASE is not set. func TestLoadConfig_NoSealWithoutPassphrase(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.json") data := `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}` if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { t.Fatalf("setup: %v", err) } t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") t.Setenv("PICOCLAW_SSH_KEY_PATH", "") if _, err := LoadConfig(cfgPath); err != nil { t.Fatalf("LoadConfig: %v", err) } raw, _ := os.ReadFile(cfgPath) if strings.Contains(string(raw), "enc://") { t.Error("config file must not be modified when no passphrase is set") } } // TestLoadConfig_FileRefNotSealed verifies that file:// api_key references are not // converted to enc:// values (they are resolved at runtime by the Resolver). func TestLoadConfig_FileRefNotSealed(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.json") keyFile := filepath.Join(dir, "openai.key") if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil { t.Fatalf("setup: %v", err) } data := `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"file://openai.key"}]}` if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { t.Fatalf("setup: %v", err) } t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") t.Setenv("PICOCLAW_SSH_KEY_PATH", "") if _, err := LoadConfig(cfgPath); err != nil { t.Fatalf("LoadConfig: %v", err) } raw, _ := os.ReadFile(cfgPath) if !strings.Contains(string(raw), "file://openai.key") { t.Error("file:// reference should be preserved unchanged in the config file") } if strings.Contains(string(raw), "enc://") { t.Error("file:// reference must not be converted to enc://") } } // TestSaveConfig_MixedKeys verifies that SaveConfig encrypts only plaintext api_keys // and leaves already-encrypted (enc://) and file:// entries unchanged. func TestSaveConfig_MixedKeys(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.json") t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") mustSetupSSHKey(t) // Pre-encrypt one key so we have a genuine enc:// value to put in the config. if err := SaveConfig(cfgPath, &Config{ ModelList: []ModelConfig{ {ModelName: "pre", Model: "openai/gpt-4", APIKey: "sk-already-plain"}, }, }); err != nil { t.Fatalf("setup SaveConfig: %v", err) } raw, _ := os.ReadFile(cfgPath) // Extract the enc:// value from the saved file. var tmp struct { ModelList []struct { APIKey string `json:"api_key"` } `json:"model_list"` } if err := json.Unmarshal(raw, &tmp); err != nil || len(tmp.ModelList) == 0 { t.Fatalf("setup: could not parse saved config: %v", err) } alreadyEncrypted := tmp.ModelList[0].APIKey if !strings.HasPrefix(alreadyEncrypted, "enc://") { t.Fatalf("setup: expected enc:// key, got %q", alreadyEncrypted) } // Build a config with three models: // 1. plaintext → must be encrypted by SaveConfig // 2. enc:// → must be left unchanged (already encrypted) // 3. file:// → must be left unchanged (file reference) keyFile := filepath.Join(dir, "api.key") if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil { t.Fatalf("setup: %v", err) } cfg := &Config{ ModelList: []ModelConfig{ {ModelName: "plain", Model: "openai/gpt-4", APIKey: "sk-new-plaintext"}, {ModelName: "enc", Model: "openai/gpt-4", APIKey: alreadyEncrypted}, {ModelName: "file", Model: "openai/gpt-4", APIKey: "file://api.key"}, }, } if err := SaveConfig(cfgPath, cfg); err != nil { t.Fatalf("SaveConfig: %v", err) } raw, _ = os.ReadFile(cfgPath) s := string(raw) // 1. Plaintext must be encrypted. if strings.Contains(s, "sk-new-plaintext") { t.Error("plaintext key must not appear in saved file") } // 2. The pre-existing enc:// value must still be present (byte-for-byte unchanged). if !strings.Contains(s, alreadyEncrypted) { t.Error("pre-existing enc:// entry must be preserved unchanged") } // 3. file:// must be preserved. if !strings.Contains(s, "file://api.key") { t.Error("file:// reference must be preserved unchanged") } // Now load and verify all three decrypt/resolve correctly. cfg2, err := LoadConfig(cfgPath) if err != nil { t.Fatalf("LoadConfig after SaveConfig: %v", err) } byName := make(map[string]string) for _, m := range cfg2.ModelList { byName[m.ModelName] = m.APIKey } if byName["plain"] != "sk-new-plaintext" { t.Errorf("plain model api_key = %q, want %q", byName["plain"], "sk-new-plaintext") } if byName["enc"] != "sk-already-plain" { t.Errorf("enc model api_key = %q, want %q", byName["enc"], "sk-already-plain") } if byName["file"] != "sk-from-file" { t.Errorf("file model api_key = %q, want %q", byName["file"], "sk-from-file") } } // TestLoadConfig_MixedKeys_NoPassphrase verifies that when PICOCLAW_KEY_PASSPHRASE // is not set, enc:// entries cause LoadConfig to return an error, while plaintext // and file:// entries in the same config are not affected. func TestLoadConfig_MixedKeys_NoPassphrase(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.json") // First encrypt a key so we have a real enc:// value. t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") mustSetupSSHKey(t) if err := SaveConfig(cfgPath, &Config{ ModelList: []ModelConfig{ {ModelName: "m", Model: "openai/gpt-4", APIKey: "sk-secret"}, }, }); err != nil { t.Fatalf("setup SaveConfig: %v", err) } raw, _ := os.ReadFile(cfgPath) var tmp struct { ModelList []struct { APIKey string `json:"api_key"` } `json:"model_list"` } if err := json.Unmarshal(raw, &tmp); err != nil { t.Fatalf("setup parse: %v", err) } encValue := tmp.ModelList[0].APIKey // Write a mixed config: enc:// + plaintext + file:// keyFile := filepath.Join(dir, "api.key") if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil { t.Fatalf("setup: %v", err) } mixed, _ := json.Marshal(map[string]any{ "model_list": []map[string]any{ {"model_name": "enc", "model": "openai/gpt-4", "api_key": encValue}, {"model_name": "plain", "model": "openai/gpt-4", "api_key": "sk-plain"}, {"model_name": "file", "model": "openai/gpt-4", "api_key": "file://api.key"}, }, }) if err := os.WriteFile(cfgPath, mixed, 0o600); err != nil { t.Fatalf("setup write: %v", err) } // Now clear the passphrase — LoadConfig must fail because enc:// cannot be decrypted. t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") _, err := LoadConfig(cfgPath) if err == nil { t.Fatal("LoadConfig should fail when enc:// key is present and no passphrase is set") } if !strings.Contains(err.Error(), "passphrase required") { t.Errorf("error should mention passphrase required, got: %v", err) } } // TestSaveConfig_UsesPassphraseProvider verifies that SaveConfig encrypts plaintext // api_keys using credential.PassphraseProvider() rather than os.Getenv directly. // This matters for the launcher, which clears the environment variable and redirects // PassphraseProvider to an in-memory SecureStore. func TestSaveConfig_UsesPassphraseProvider(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.json") // Ensure the env var is empty — passphrase must come from PassphraseProvider only. t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") mustSetupSSHKey(t) // Replace PassphraseProvider with an in-memory function (simulating SecureStore). const testPassphrase = "provider-passphrase" orig := credential.PassphraseProvider credential.PassphraseProvider = func() string { return testPassphrase } t.Cleanup(func() { credential.PassphraseProvider = orig }) cfg := DefaultConfig() cfg.ModelList = []ModelConfig{ {ModelName: "test", Model: "openai/gpt-4", APIKey: "sk-plaintext"}, } if err := SaveConfig(cfgPath, cfg); err != nil { t.Fatalf("SaveConfig: %v", err) } raw, _ := os.ReadFile(cfgPath) if !strings.Contains(string(raw), "enc://") { t.Errorf("SaveConfig should have encrypted plaintext key via PassphraseProvider; got:\n%s", raw) } } // TestLoadConfig_UsesPassphraseProvider verifies that LoadConfig decrypts enc:// keys // using credential.PassphraseProvider() rather than os.Getenv directly. func TestLoadConfig_UsesPassphraseProvider(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.json") // Ensure the env var is empty throughout. t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") mustSetupSSHKey(t) const testPassphrase = "provider-passphrase" const plainKey = "sk-secret" // First, encrypt the key using the same passphrase. encrypted, err := credential.Encrypt(testPassphrase, "", plainKey) if err != nil { t.Fatalf("Encrypt: %v", err) } raw, _ := json.Marshal(map[string]any{ "model_list": []map[string]any{ {"model_name": "test", "model": "openai/gpt-4", "api_key": encrypted}, }, }) if err = os.WriteFile(cfgPath, raw, 0o600); err != nil { t.Fatalf("setup: %v", err) } // Redirect PassphraseProvider — env var is empty, so without this the load would fail. orig := credential.PassphraseProvider credential.PassphraseProvider = func() string { return testPassphrase } t.Cleanup(func() { credential.PassphraseProvider = orig }) cfg, err := LoadConfig(cfgPath) if err != nil { t.Fatalf("LoadConfig: %v", err) } if cfg.ModelList[0].APIKey != plainKey { t.Errorf("api_key = %q, want %q", cfg.ModelList[0].APIKey, plainKey) } } ================================================ FILE: pkg/config/defaults.go ================================================ // PicoClaw - Ultra-lightweight personal AI agent // License: MIT // // Copyright (c) 2026 PicoClaw contributors package config import ( "os" "path/filepath" ) // DefaultConfig returns the default configuration for PicoClaw. func DefaultConfig() *Config { // Determine the base path for the workspace. // Priority: $PICOCLAW_HOME > ~/.picoclaw var homePath string if picoclawHome := os.Getenv(EnvHome); picoclawHome != "" { homePath = picoclawHome } else { userHome, _ := os.UserHomeDir() homePath = filepath.Join(userHome, ".picoclaw") } workspacePath := filepath.Join(homePath, "workspace") return &Config{ Agents: AgentsConfig{ Defaults: AgentDefaults{ Workspace: workspacePath, RestrictToWorkspace: true, Provider: "", Model: "", MaxTokens: 32768, Temperature: nil, // nil means use provider default MaxToolIterations: 50, SummarizeMessageThreshold: 20, SummarizeTokenPercent: 75, ToolFeedback: ToolFeedbackConfig{ Enabled: true, MaxArgsLength: 300, }, }, }, Bindings: []AgentBinding{}, Session: SessionConfig{ DMScope: "per-channel-peer", }, Channels: ChannelsConfig{ WhatsApp: WhatsAppConfig{ Enabled: false, BridgeURL: "ws://localhost:3001", UseNative: false, SessionStorePath: "", AllowFrom: FlexibleStringSlice{}, }, Telegram: TelegramConfig{ Enabled: false, Token: "", AllowFrom: FlexibleStringSlice{}, Typing: TypingConfig{Enabled: true}, Placeholder: PlaceholderConfig{ Enabled: true, Text: "Thinking... 💭", }, UseMarkdownV2: false, }, Feishu: FeishuConfig{ Enabled: false, AppID: "", AppSecret: "", EncryptKey: "", VerificationToken: "", AllowFrom: FlexibleStringSlice{}, }, Discord: DiscordConfig{ Enabled: false, Token: "", AllowFrom: FlexibleStringSlice{}, MentionOnly: false, }, MaixCam: MaixCamConfig{ Enabled: false, Host: "0.0.0.0", Port: 18790, AllowFrom: FlexibleStringSlice{}, }, QQ: QQConfig{ Enabled: false, AppID: "", AppSecret: "", AllowFrom: FlexibleStringSlice{}, MaxMessageLength: 2000, MaxBase64FileSizeMiB: 0, }, DingTalk: DingTalkConfig{ Enabled: false, ClientID: "", ClientSecret: "", AllowFrom: FlexibleStringSlice{}, }, Slack: SlackConfig{ Enabled: false, BotToken: "", AppToken: "", AllowFrom: FlexibleStringSlice{}, }, Matrix: MatrixConfig{ Enabled: false, Homeserver: "https://matrix.org", UserID: "", AccessToken: "", DeviceID: "", JoinOnInvite: true, AllowFrom: FlexibleStringSlice{}, GroupTrigger: GroupTriggerConfig{ MentionOnly: true, }, Placeholder: PlaceholderConfig{ Enabled: true, Text: "Thinking... 💭", }, }, LINE: LINEConfig{ Enabled: false, ChannelSecret: "", ChannelAccessToken: "", WebhookHost: "0.0.0.0", WebhookPort: 18791, WebhookPath: "/webhook/line", AllowFrom: FlexibleStringSlice{}, GroupTrigger: GroupTriggerConfig{MentionOnly: true}, }, OneBot: OneBotConfig{ Enabled: false, WSUrl: "ws://127.0.0.1:3001", AccessToken: "", ReconnectInterval: 5, GroupTriggerPrefix: []string{}, AllowFrom: FlexibleStringSlice{}, }, WeCom: WeComConfig{ Enabled: false, Token: "", EncodingAESKey: "", WebhookURL: "", WebhookHost: "0.0.0.0", WebhookPort: 18793, WebhookPath: "/webhook/wecom", AllowFrom: FlexibleStringSlice{}, ReplyTimeout: 5, }, WeComApp: WeComAppConfig{ Enabled: false, CorpID: "", CorpSecret: "", AgentID: 0, Token: "", EncodingAESKey: "", WebhookHost: "0.0.0.0", WebhookPort: 18792, WebhookPath: "/webhook/wecom-app", AllowFrom: FlexibleStringSlice{}, ReplyTimeout: 5, }, WeComAIBot: WeComAIBotConfig{ Enabled: false, Token: "", EncodingAESKey: "", WebhookPath: "/webhook/wecom-aibot", AllowFrom: FlexibleStringSlice{}, ReplyTimeout: 5, MaxSteps: 10, WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?", ProcessingMessage: DefaultWeComAIBotProcessingMessage, }, Pico: PicoConfig{ Enabled: false, Token: "", PingInterval: 30, ReadTimeout: 60, WriteTimeout: 10, MaxConnections: 100, AllowFrom: FlexibleStringSlice{}, }, }, Providers: ProvidersConfig{ OpenAI: OpenAIProviderConfig{WebSearch: true}, }, ModelList: []ModelConfig{ // ============================================ // Add your API key to the model you want to use // ============================================ // Zhipu AI (智谱) - https://open.bigmodel.cn/usercenter/apikeys { ModelName: "glm-4.7", Model: "zhipu/glm-4.7", APIBase: "https://open.bigmodel.cn/api/paas/v4", APIKey: "", }, // OpenAI - https://platform.openai.com/api-keys { ModelName: "gpt-5.4", Model: "openai/gpt-5.4", APIBase: "https://api.openai.com/v1", APIKey: "", }, // Anthropic Claude - https://console.anthropic.com/settings/keys { ModelName: "claude-sonnet-4.6", Model: "anthropic/claude-sonnet-4.6", APIBase: "https://api.anthropic.com/v1", APIKey: "", }, // DeepSeek - https://platform.deepseek.com/ { ModelName: "deepseek-chat", Model: "deepseek/deepseek-chat", APIBase: "https://api.deepseek.com/v1", APIKey: "", }, // Google Gemini - https://ai.google.dev/ { ModelName: "gemini-2.0-flash", Model: "gemini/gemini-2.0-flash-exp", APIBase: "https://generativelanguage.googleapis.com/v1beta", APIKey: "", }, // Qwen (通义千问) - https://dashscope.console.aliyun.com/apiKey { ModelName: "qwen-plus", Model: "qwen/qwen-plus", APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1", APIKey: "", }, // Moonshot (月之暗面) - https://platform.moonshot.cn/console/api-keys { ModelName: "moonshot-v1-8k", Model: "moonshot/moonshot-v1-8k", APIBase: "https://api.moonshot.cn/v1", APIKey: "", }, // Groq - https://console.groq.com/keys { ModelName: "llama-3.3-70b", Model: "groq/llama-3.3-70b-versatile", APIBase: "https://api.groq.com/openai/v1", APIKey: "", }, // OpenRouter (100+ models) - https://openrouter.ai/keys { ModelName: "openrouter-auto", Model: "openrouter/auto", APIBase: "https://openrouter.ai/api/v1", APIKey: "", }, { ModelName: "openrouter-gpt-5.4", Model: "openrouter/openai/gpt-5.4", APIBase: "https://openrouter.ai/api/v1", APIKey: "", }, // NVIDIA - https://build.nvidia.com/ { ModelName: "nemotron-4-340b", Model: "nvidia/nemotron-4-340b-instruct", APIBase: "https://integrate.api.nvidia.com/v1", APIKey: "", }, // Cerebras - https://inference.cerebras.ai/ { ModelName: "cerebras-llama-3.3-70b", Model: "cerebras/llama-3.3-70b", APIBase: "https://api.cerebras.ai/v1", APIKey: "", }, // Vivgrid - https://vivgrid.com { ModelName: "vivgrid-auto", Model: "vivgrid/auto", APIBase: "https://api.vivgrid.com/v1", APIKey: "", }, // Volcengine (火山引擎) - https://console.volcengine.com/ark { ModelName: "ark-code-latest", Model: "volcengine/ark-code-latest", APIBase: "https://ark.cn-beijing.volces.com/api/v3", APIKey: "", }, { ModelName: "doubao-pro", Model: "volcengine/doubao-pro-32k", APIBase: "https://ark.cn-beijing.volces.com/api/v3", APIKey: "", }, // ShengsuanYun (神算云) { ModelName: "deepseek-v3", Model: "shengsuanyun/deepseek-v3", APIBase: "https://api.shengsuanyun.com/v1", APIKey: "", }, // Antigravity (Google Cloud Code Assist) - OAuth only { ModelName: "gemini-flash", Model: "antigravity/gemini-3-flash", AuthMethod: "oauth", }, // GitHub Copilot - https://github.com/settings/tokens { ModelName: "copilot-gpt-5.4", Model: "github-copilot/gpt-5.4", APIBase: "http://localhost:4321", AuthMethod: "oauth", }, // Ollama (local) - https://ollama.com { ModelName: "llama3", Model: "ollama/llama3", APIBase: "http://localhost:11434/v1", APIKey: "ollama", }, // Mistral AI - https://console.mistral.ai/api-keys { ModelName: "mistral-small", Model: "mistral/mistral-small-latest", APIBase: "https://api.mistral.ai/v1", APIKey: "", }, // Avian - https://avian.io { ModelName: "deepseek-v3.2", Model: "avian/deepseek/deepseek-v3.2", APIBase: "https://api.avian.io/v1", APIKey: "", }, { ModelName: "kimi-k2.5", Model: "avian/moonshotai/kimi-k2.5", APIBase: "https://api.avian.io/v1", APIKey: "", }, // Minimax - https://api.minimaxi.com/ { ModelName: "MiniMax-M2.5", Model: "minimax/MiniMax-M2.5", APIBase: "https://api.minimaxi.com/v1", APIKey: "", }, // LongCat - https://longcat.chat/platform { ModelName: "LongCat-Flash-Thinking", Model: "longcat/LongCat-Flash-Thinking", APIBase: "https://api.longcat.chat/openai", APIKey: "", }, // ModelScope (魔搭社区) - https://modelscope.cn/my/tokens { ModelName: "modelscope-qwen", Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507", APIBase: "https://api-inference.modelscope.cn/v1", APIKey: "", }, // VLLM (local) - http://localhost:8000 { ModelName: "local-model", Model: "vllm/custom-model", APIBase: "http://localhost:8000/v1", APIKey: "", }, // Azure OpenAI - https://portal.azure.com // model_name is a user-friendly alias; the model field's path after "azure/" is your deployment name { ModelName: "azure-gpt5", Model: "azure/my-gpt5-deployment", APIBase: "https://your-resource.openai.azure.com", APIKey: "", }, }, Gateway: GatewayConfig{ Host: "127.0.0.1", Port: 18790, HotReload: false, }, Tools: ToolsConfig{ MediaCleanup: MediaCleanupConfig{ ToolConfig: ToolConfig{ Enabled: true, }, MaxAge: 30, Interval: 5, }, Web: WebToolsConfig{ ToolConfig: ToolConfig{ Enabled: true, }, PreferNative: true, Proxy: "", FetchLimitBytes: 10 * 1024 * 1024, // 10MB by default Format: "plaintext", Brave: BraveConfig{ Enabled: false, APIKey: "", APIKeys: nil, MaxResults: 5, }, Tavily: TavilyConfig{ Enabled: false, APIKey: "", APIKeys: nil, MaxResults: 5, }, DuckDuckGo: DuckDuckGoConfig{ Enabled: true, MaxResults: 5, }, Perplexity: PerplexityConfig{ Enabled: false, APIKey: "", APIKeys: nil, MaxResults: 5, }, SearXNG: SearXNGConfig{ Enabled: false, BaseURL: "", MaxResults: 5, }, GLMSearch: GLMSearchConfig{ Enabled: false, APIKey: "", BaseURL: "https://open.bigmodel.cn/api/paas/v4/web_search", SearchEngine: "search_std", MaxResults: 5, }, }, Cron: CronToolsConfig{ ToolConfig: ToolConfig{ Enabled: true, }, ExecTimeoutMinutes: 5, AllowCommand: true, }, Exec: ExecConfig{ ToolConfig: ToolConfig{ Enabled: true, }, EnableDenyPatterns: true, AllowRemote: true, TimeoutSeconds: 60, }, Skills: SkillsToolsConfig{ ToolConfig: ToolConfig{ Enabled: true, }, Registries: SkillsRegistriesConfig{ ClawHub: ClawHubRegistryConfig{ Enabled: true, BaseURL: "https://clawhub.ai", }, }, MaxConcurrentSearches: 2, SearchCache: SearchCacheConfig{ MaxSize: 50, TTLSeconds: 300, }, }, SendFile: ToolConfig{ Enabled: true, }, MCP: MCPConfig{ ToolConfig: ToolConfig{ Enabled: false, }, Discovery: ToolDiscoveryConfig{ Enabled: false, TTL: 5, MaxSearchResults: 5, UseBM25: true, UseRegex: false, }, Servers: map[string]MCPServerConfig{}, }, AppendFile: ToolConfig{ Enabled: true, }, EditFile: ToolConfig{ Enabled: true, }, FindSkills: ToolConfig{ Enabled: true, }, I2C: ToolConfig{ Enabled: false, // Hardware tool - Linux only }, InstallSkill: ToolConfig{ Enabled: true, }, ListDir: ToolConfig{ Enabled: true, }, Message: ToolConfig{ Enabled: true, }, ReadFile: ReadFileToolConfig{ Enabled: true, MaxReadFileSize: 64 * 1024, // 64KB }, Spawn: ToolConfig{ Enabled: true, }, SpawnStatus: ToolConfig{ Enabled: false, }, SPI: ToolConfig{ Enabled: false, // Hardware tool - Linux only }, Subagent: ToolConfig{ Enabled: true, }, WebFetch: ToolConfig{ Enabled: true, }, WriteFile: ToolConfig{ Enabled: true, }, }, Heartbeat: HeartbeatConfig{ Enabled: true, Interval: 30, }, Devices: DevicesConfig{ Enabled: false, MonitorUSB: true, }, Voice: VoiceConfig{ EchoTranscription: false, }, BuildInfo: BuildInfo{ Version: Version, GitCommit: GitCommit, BuildTime: BuildTime, GoVersion: GoVersion, }, } } ================================================ FILE: pkg/config/envkeys.go ================================================ // PicoClaw - Ultra-lightweight personal AI agent // License: MIT // // Copyright (c) 2026 PicoClaw contributors package config // Runtime environment variable keys for the picoclaw process. // These control the location of files and binaries at runtime and are read // directly via os.Getenv / os.LookupEnv. All picoclaw-specific keys use the // PICOCLAW_ prefix. Reference these constants instead of inline string // literals to keep all supported knobs visible in one place and to prevent // typos. const ( // EnvHome overrides the base directory for all picoclaw data // (config, workspace, skills, auth store, …). // Default: ~/.picoclaw EnvHome = "PICOCLAW_HOME" // EnvConfig overrides the full path to the JSON config file. // Default: $PICOCLAW_HOME/config.json EnvConfig = "PICOCLAW_CONFIG" // EnvBuiltinSkills overrides the directory from which built-in // skills are loaded. // Default: /skills EnvBuiltinSkills = "PICOCLAW_BUILTIN_SKILLS" // EnvBinary overrides the path to the picoclaw executable. // Used by the web launcher when spawning the gateway subprocess. // Default: resolved from the same directory as the current executable. EnvBinary = "PICOCLAW_BINARY" // EnvGatewayHost overrides the host address for the gateway server. // Default: "127.0.0.1" EnvGatewayHost = "PICOCLAW_GATEWAY_HOST" ) ================================================ FILE: pkg/config/migration.go ================================================ // PicoClaw - Ultra-lightweight personal AI agent // License: MIT // // Copyright (c) 2026 PicoClaw contributors package config import ( "slices" "strings" ) // buildModelWithProtocol constructs a model string with protocol prefix. // If the model already contains a "/" (indicating it has a protocol prefix), it is returned as-is. // Otherwise, the protocol prefix is added. func buildModelWithProtocol(protocol, model string) string { if strings.Contains(model, "/") { // Model already has a protocol prefix, return as-is return model } return protocol + "/" + model } // providerMigrationConfig defines how to migrate a provider from old config to new format. type providerMigrationConfig struct { // providerNames are the possible names used in agents.defaults.provider providerNames []string // protocol is the protocol prefix for the model field protocol string // buildConfig creates the ModelConfig from ProviderConfig buildConfig func(p ProvidersConfig) (ModelConfig, bool) } // ConvertProvidersToModelList converts the old ProvidersConfig to a slice of ModelConfig. // This enables backward compatibility with existing configurations. // It preserves the user's configured model from agents.defaults.model when possible. func ConvertProvidersToModelList(cfg *Config) []ModelConfig { if cfg == nil { return nil } // Get user's configured provider and model userProvider := strings.ToLower(cfg.Agents.Defaults.Provider) userModel := cfg.Agents.Defaults.GetModelName() p := cfg.Providers var result []ModelConfig // Track if we've applied the legacy model name fix (only for first provider) legacyModelNameApplied := false // Define migration rules for each provider migrations := []providerMigrationConfig{ { providerNames: []string{"openai", "gpt"}, protocol: "openai", buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { if p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" { return ModelConfig{}, false } return ModelConfig{ ModelName: "openai", Model: "openai/gpt-5.4", APIKey: p.OpenAI.APIKey, APIBase: p.OpenAI.APIBase, Proxy: p.OpenAI.Proxy, RequestTimeout: p.OpenAI.RequestTimeout, AuthMethod: p.OpenAI.AuthMethod, }, true }, }, { providerNames: []string{"anthropic", "claude"}, protocol: "anthropic", buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { if p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" { return ModelConfig{}, false } return ModelConfig{ ModelName: "anthropic", Model: "anthropic/claude-sonnet-4.6", APIKey: p.Anthropic.APIKey, APIBase: p.Anthropic.APIBase, Proxy: p.Anthropic.Proxy, RequestTimeout: p.Anthropic.RequestTimeout, AuthMethod: p.Anthropic.AuthMethod, }, true }, }, { providerNames: []string{"litellm"}, protocol: "litellm", buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { if p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" { return ModelConfig{}, false } return ModelConfig{ ModelName: "litellm", Model: "litellm/auto", APIKey: p.LiteLLM.APIKey, APIBase: p.LiteLLM.APIBase, Proxy: p.LiteLLM.Proxy, RequestTimeout: p.LiteLLM.RequestTimeout, }, true }, }, { providerNames: []string{"openrouter"}, protocol: "openrouter", buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { if p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" { return ModelConfig{}, false } return ModelConfig{ ModelName: "openrouter", Model: "openrouter/auto", APIKey: p.OpenRouter.APIKey, APIBase: p.OpenRouter.APIBase, Proxy: p.OpenRouter.Proxy, RequestTimeout: p.OpenRouter.RequestTimeout, }, true }, }, { providerNames: []string{"groq"}, protocol: "groq", buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { if p.Groq.APIKey == "" && p.Groq.APIBase == "" { return ModelConfig{}, false } return ModelConfig{ ModelName: "groq", Model: "groq/llama-3.1-70b-versatile", APIKey: p.Groq.APIKey, APIBase: p.Groq.APIBase, Proxy: p.Groq.Proxy, RequestTimeout: p.Groq.RequestTimeout, }, true }, }, { providerNames: []string{"zhipu", "glm"}, protocol: "zhipu", buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { if p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" { return ModelConfig{}, false } return ModelConfig{ ModelName: "zhipu", Model: "zhipu/glm-4", APIKey: p.Zhipu.APIKey, APIBase: p.Zhipu.APIBase, Proxy: p.Zhipu.Proxy, RequestTimeout: p.Zhipu.RequestTimeout, }, true }, }, { providerNames: []string{"vllm"}, protocol: "vllm", buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { if p.VLLM.APIKey == "" && p.VLLM.APIBase == "" { return ModelConfig{}, false } return ModelConfig{ ModelName: "vllm", Model: "vllm/auto", APIKey: p.VLLM.APIKey, APIBase: p.VLLM.APIBase, Proxy: p.VLLM.Proxy, RequestTimeout: p.VLLM.RequestTimeout, }, true }, }, { providerNames: []string{"gemini", "google"}, protocol: "gemini", buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { if p.Gemini.APIKey == "" && p.Gemini.APIBase == "" { return ModelConfig{}, false } return ModelConfig{ ModelName: "gemini", Model: "gemini/gemini-pro", APIKey: p.Gemini.APIKey, APIBase: p.Gemini.APIBase, Proxy: p.Gemini.Proxy, RequestTimeout: p.Gemini.RequestTimeout, }, true }, }, { providerNames: []string{"nvidia"}, protocol: "nvidia", buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { if p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" { return ModelConfig{}, false } return ModelConfig{ ModelName: "nvidia", Model: "nvidia/meta/llama-3.1-8b-instruct", APIKey: p.Nvidia.APIKey, APIBase: p.Nvidia.APIBase, Proxy: p.Nvidia.Proxy, RequestTimeout: p.Nvidia.RequestTimeout, }, true }, }, { providerNames: []string{"ollama"}, protocol: "ollama", buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { if p.Ollama.APIKey == "" && p.Ollama.APIBase == "" { return ModelConfig{}, false } return ModelConfig{ ModelName: "ollama", Model: "ollama/llama3", APIKey: p.Ollama.APIKey, APIBase: p.Ollama.APIBase, Proxy: p.Ollama.Proxy, RequestTimeout: p.Ollama.RequestTimeout, }, true }, }, { providerNames: []string{"moonshot", "kimi"}, protocol: "moonshot", buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { if p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" { return ModelConfig{}, false } return ModelConfig{ ModelName: "moonshot", Model: "moonshot/kimi", APIKey: p.Moonshot.APIKey, APIBase: p.Moonshot.APIBase, Proxy: p.Moonshot.Proxy, RequestTimeout: p.Moonshot.RequestTimeout, }, true }, }, { providerNames: []string{"shengsuanyun"}, protocol: "shengsuanyun", buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { if p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" { return ModelConfig{}, false } return ModelConfig{ ModelName: "shengsuanyun", Model: "shengsuanyun/auto", APIKey: p.ShengSuanYun.APIKey, APIBase: p.ShengSuanYun.APIBase, Proxy: p.ShengSuanYun.Proxy, RequestTimeout: p.ShengSuanYun.RequestTimeout, }, true }, }, { providerNames: []string{"deepseek"}, protocol: "deepseek", buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { if p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" { return ModelConfig{}, false } return ModelConfig{ ModelName: "deepseek", Model: "deepseek/deepseek-chat", APIKey: p.DeepSeek.APIKey, APIBase: p.DeepSeek.APIBase, Proxy: p.DeepSeek.Proxy, RequestTimeout: p.DeepSeek.RequestTimeout, }, true }, }, { providerNames: []string{"cerebras"}, protocol: "cerebras", buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { if p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" { return ModelConfig{}, false } return ModelConfig{ ModelName: "cerebras", Model: "cerebras/llama-3.3-70b", APIKey: p.Cerebras.APIKey, APIBase: p.Cerebras.APIBase, Proxy: p.Cerebras.Proxy, RequestTimeout: p.Cerebras.RequestTimeout, }, true }, }, { providerNames: []string{"vivgrid"}, protocol: "vivgrid", buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { if p.Vivgrid.APIKey == "" && p.Vivgrid.APIBase == "" { return ModelConfig{}, false } return ModelConfig{ ModelName: "vivgrid", Model: "vivgrid/auto", APIKey: p.Vivgrid.APIKey, APIBase: p.Vivgrid.APIBase, Proxy: p.Vivgrid.Proxy, RequestTimeout: p.Vivgrid.RequestTimeout, }, true }, }, { providerNames: []string{"volcengine", "doubao"}, protocol: "volcengine", buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { if p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" { return ModelConfig{}, false } return ModelConfig{ ModelName: "volcengine", Model: "volcengine/doubao-pro", APIKey: p.VolcEngine.APIKey, APIBase: p.VolcEngine.APIBase, Proxy: p.VolcEngine.Proxy, RequestTimeout: p.VolcEngine.RequestTimeout, }, true }, }, { providerNames: []string{"github_copilot", "copilot"}, protocol: "github-copilot", buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { if p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" && p.GitHubCopilot.ConnectMode == "" { return ModelConfig{}, false } return ModelConfig{ ModelName: "github-copilot", Model: "github-copilot/gpt-5.4", APIBase: p.GitHubCopilot.APIBase, ConnectMode: p.GitHubCopilot.ConnectMode, }, true }, }, { providerNames: []string{"antigravity"}, protocol: "antigravity", buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { if p.Antigravity.APIKey == "" && p.Antigravity.AuthMethod == "" { return ModelConfig{}, false } return ModelConfig{ ModelName: "antigravity", Model: "antigravity/gemini-2.0-flash", APIKey: p.Antigravity.APIKey, AuthMethod: p.Antigravity.AuthMethod, }, true }, }, { providerNames: []string{"qwen", "tongyi"}, protocol: "qwen", buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { if p.Qwen.APIKey == "" && p.Qwen.APIBase == "" { return ModelConfig{}, false } return ModelConfig{ ModelName: "qwen", Model: "qwen/qwen-max", APIKey: p.Qwen.APIKey, APIBase: p.Qwen.APIBase, Proxy: p.Qwen.Proxy, RequestTimeout: p.Qwen.RequestTimeout, }, true }, }, { providerNames: []string{"mistral"}, protocol: "mistral", buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { if p.Mistral.APIKey == "" && p.Mistral.APIBase == "" { return ModelConfig{}, false } return ModelConfig{ ModelName: "mistral", Model: "mistral/mistral-small-latest", APIKey: p.Mistral.APIKey, APIBase: p.Mistral.APIBase, Proxy: p.Mistral.Proxy, RequestTimeout: p.Mistral.RequestTimeout, }, true }, }, { providerNames: []string{"avian"}, protocol: "avian", buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { if p.Avian.APIKey == "" && p.Avian.APIBase == "" { return ModelConfig{}, false } return ModelConfig{ ModelName: "avian", Model: "avian/deepseek/deepseek-v3.2", APIKey: p.Avian.APIKey, APIBase: p.Avian.APIBase, Proxy: p.Avian.Proxy, RequestTimeout: p.Avian.RequestTimeout, }, true }, }, { providerNames: []string{"longcat"}, protocol: "longcat", buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { if p.LongCat.APIKey == "" && p.LongCat.APIBase == "" { return ModelConfig{}, false } return ModelConfig{ ModelName: "longcat", Model: "longcat/LongCat-Flash-Thinking", APIKey: p.LongCat.APIKey, APIBase: p.LongCat.APIBase, Proxy: p.LongCat.Proxy, RequestTimeout: p.LongCat.RequestTimeout, }, true }, }, { providerNames: []string{"modelscope"}, protocol: "modelscope", buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { if p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" { return ModelConfig{}, false } return ModelConfig{ ModelName: "modelscope", Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507", APIKey: p.ModelScope.APIKey, APIBase: p.ModelScope.APIBase, Proxy: p.ModelScope.Proxy, RequestTimeout: p.ModelScope.RequestTimeout, }, true }, }, } // Process each provider migration for _, m := range migrations { mc, ok := m.buildConfig(p) if !ok { continue } // Check if this is the user's configured provider if slices.Contains(m.providerNames, userProvider) && userModel != "" { // Use the user's configured model instead of default mc.Model = buildModelWithProtocol(m.protocol, userModel) } else if userProvider == "" && userModel != "" && !legacyModelNameApplied { // Legacy config: no explicit provider field but model is specified // Use userModel as ModelName for the FIRST provider so GetModelConfig(model) can find it // This maintains backward compatibility with old configs that relied on implicit provider selection mc.ModelName = userModel mc.Model = buildModelWithProtocol(m.protocol, userModel) legacyModelNameApplied = true } result = append(result, mc) } return result } // protocolProviderMapping maps a model protocol prefix (the part before "/" in // the Model field) to a function that extracts the corresponding ProviderConfig // from the legacy ProvidersConfig. Used by InheritProviderCredentials. var protocolProviderMapping = map[string]func(p ProvidersConfig) ProviderConfig{ "openai": func(p ProvidersConfig) ProviderConfig { return p.OpenAI.ProviderConfig }, "anthropic": func(p ProvidersConfig) ProviderConfig { return p.Anthropic }, "litellm": func(p ProvidersConfig) ProviderConfig { return p.LiteLLM }, "openrouter": func(p ProvidersConfig) ProviderConfig { return p.OpenRouter }, "groq": func(p ProvidersConfig) ProviderConfig { return p.Groq }, "zhipu": func(p ProvidersConfig) ProviderConfig { return p.Zhipu }, "vllm": func(p ProvidersConfig) ProviderConfig { return p.VLLM }, "gemini": func(p ProvidersConfig) ProviderConfig { return p.Gemini }, "nvidia": func(p ProvidersConfig) ProviderConfig { return p.Nvidia }, "ollama": func(p ProvidersConfig) ProviderConfig { return p.Ollama }, "moonshot": func(p ProvidersConfig) ProviderConfig { return p.Moonshot }, "shengsuanyun": func(p ProvidersConfig) ProviderConfig { return p.ShengSuanYun }, "deepseek": func(p ProvidersConfig) ProviderConfig { return p.DeepSeek }, "cerebras": func(p ProvidersConfig) ProviderConfig { return p.Cerebras }, "vivgrid": func(p ProvidersConfig) ProviderConfig { return p.Vivgrid }, "volcengine": func(p ProvidersConfig) ProviderConfig { return p.VolcEngine }, "github-copilot": func(p ProvidersConfig) ProviderConfig { return p.GitHubCopilot }, "antigravity": func(p ProvidersConfig) ProviderConfig { return p.Antigravity }, "qwen": func(p ProvidersConfig) ProviderConfig { return p.Qwen }, "mistral": func(p ProvidersConfig) ProviderConfig { return p.Mistral }, "avian": func(p ProvidersConfig) ProviderConfig { return p.Avian }, "minimax": func(p ProvidersConfig) ProviderConfig { return p.Minimax }, "longcat": func(p ProvidersConfig) ProviderConfig { return p.LongCat }, "modelscope": func(p ProvidersConfig) ProviderConfig { return p.ModelScope }, "novita": func(p ProvidersConfig) ProviderConfig { return p.Novita }, } // InheritProviderCredentials fills in missing api_key, api_base, proxy, and // request_timeout on model_list entries from the matching legacy providers // configuration. The match is determined by the protocol prefix in the Model // field (e.g. "deepseek/deepseek-chat" matches providers.deepseek). // // Only empty fields are filled — any value explicitly set on a model_list entry // takes precedence. This function modifies the slice in place. // // This bridges the gap described in issue #1635: users who configure // credentials once in the providers section expect model_list entries using // the same protocol to "just work" without duplicating credentials. func InheritProviderCredentials(models []ModelConfig, providers ProvidersConfig) { if providers.IsEmpty() { return } for i := range models { m := &models[i] // Extract protocol prefix from Model field protocol := "" if idx := strings.Index(m.Model, "/"); idx > 0 { protocol = strings.ToLower(m.Model[:idx]) } if protocol == "" { continue } getProvider, ok := protocolProviderMapping[protocol] if !ok { continue } pc := getProvider(providers) // Only fill empty fields — explicit model_list values win if m.APIKey == "" && pc.APIKey != "" { m.APIKey = pc.APIKey } if m.APIBase == "" && pc.APIBase != "" { m.APIBase = pc.APIBase } if m.Proxy == "" && pc.Proxy != "" { m.Proxy = pc.Proxy } if m.RequestTimeout == 0 && pc.RequestTimeout != 0 { m.RequestTimeout = pc.RequestTimeout } } } ================================================ FILE: pkg/config/migration_test.go ================================================ // PicoClaw - Ultra-lightweight personal AI agent // License: MIT // // Copyright (c) 2026 PicoClaw contributors package config import ( "strings" "testing" ) func TestConvertProvidersToModelList_OpenAI(t *testing.T) { cfg := &Config{ Providers: ProvidersConfig{ OpenAI: OpenAIProviderConfig{ ProviderConfig: ProviderConfig{ APIKey: "sk-test-key", APIBase: "https://custom.api.com/v1", }, }, }, } result := ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) } if result[0].ModelName != "openai" { t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openai") } if result[0].Model != "openai/gpt-5.4" { t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.4") } if result[0].APIKey != "sk-test-key" { t.Errorf("APIKey = %q, want %q", result[0].APIKey, "sk-test-key") } } func TestConvertProvidersToModelList_Anthropic(t *testing.T) { cfg := &Config{ Providers: ProvidersConfig{ Anthropic: ProviderConfig{ APIKey: "ant-key", APIBase: "https://custom.anthropic.com", }, }, } result := ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) } if result[0].ModelName != "anthropic" { t.Errorf("ModelName = %q, want %q", result[0].ModelName, "anthropic") } if result[0].Model != "anthropic/claude-sonnet-4.6" { t.Errorf("Model = %q, want %q", result[0].Model, "anthropic/claude-sonnet-4.6") } } func TestConvertProvidersToModelList_LiteLLM(t *testing.T) { cfg := &Config{ Providers: ProvidersConfig{ LiteLLM: ProviderConfig{ APIKey: "litellm-key", APIBase: "http://localhost:4000/v1", }, }, } result := ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) } if result[0].ModelName != "litellm" { t.Errorf("ModelName = %q, want %q", result[0].ModelName, "litellm") } if result[0].Model != "litellm/auto" { t.Errorf("Model = %q, want %q", result[0].Model, "litellm/auto") } if result[0].APIBase != "http://localhost:4000/v1" { t.Errorf("APIBase = %q, want %q", result[0].APIBase, "http://localhost:4000/v1") } } func TestConvertProvidersToModelList_Multiple(t *testing.T) { cfg := &Config{ Providers: ProvidersConfig{ OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "openai-key"}}, Groq: ProviderConfig{APIKey: "groq-key"}, Zhipu: ProviderConfig{APIKey: "zhipu-key"}, }, } result := ConvertProvidersToModelList(cfg) if len(result) != 3 { t.Fatalf("len(result) = %d, want 3", len(result)) } // Check that all providers are present found := make(map[string]bool) for _, mc := range result { found[mc.ModelName] = true } for _, name := range []string{"openai", "groq", "zhipu"} { if !found[name] { t.Errorf("Missing provider %q in result", name) } } } func TestConvertProvidersToModelList_Empty(t *testing.T) { cfg := &Config{ Providers: ProvidersConfig{}, } result := ConvertProvidersToModelList(cfg) if len(result) != 0 { t.Errorf("len(result) = %d, want 0", len(result)) } } func TestConvertProvidersToModelList_Nil(t *testing.T) { result := ConvertProvidersToModelList(nil) if result != nil { t.Errorf("result = %v, want nil", result) } } func TestConvertProvidersToModelList_AllProviders(t *testing.T) { cfg := &Config{ Providers: ProvidersConfig{ OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "key1"}}, LiteLLM: ProviderConfig{APIKey: "key-litellm", APIBase: "http://localhost:4000/v1"}, Anthropic: ProviderConfig{APIKey: "key2"}, OpenRouter: ProviderConfig{APIKey: "key3"}, Groq: ProviderConfig{APIKey: "key4"}, Zhipu: ProviderConfig{APIKey: "key5"}, VLLM: ProviderConfig{APIKey: "key6"}, Gemini: ProviderConfig{APIKey: "key7"}, Nvidia: ProviderConfig{APIKey: "key8"}, Ollama: ProviderConfig{APIKey: "key9"}, Moonshot: ProviderConfig{APIKey: "key10"}, ShengSuanYun: ProviderConfig{APIKey: "key11"}, DeepSeek: ProviderConfig{APIKey: "key12"}, Cerebras: ProviderConfig{APIKey: "key13"}, Vivgrid: ProviderConfig{APIKey: "key14"}, VolcEngine: ProviderConfig{APIKey: "key15"}, GitHubCopilot: ProviderConfig{ConnectMode: "grpc"}, Antigravity: ProviderConfig{AuthMethod: "oauth"}, Qwen: ProviderConfig{APIKey: "key17"}, Mistral: ProviderConfig{APIKey: "key18"}, Avian: ProviderConfig{APIKey: "key19"}, LongCat: ProviderConfig{APIKey: "key-longcat"}, ModelScope: ProviderConfig{APIKey: "key-modelscope"}, }, } result := ConvertProvidersToModelList(cfg) // All 23 providers should be converted if len(result) != 23 { t.Errorf("len(result) = %d, want 23", len(result)) } } func TestConvertProvidersToModelList_Proxy(t *testing.T) { cfg := &Config{ Providers: ProvidersConfig{ OpenAI: OpenAIProviderConfig{ ProviderConfig: ProviderConfig{ APIKey: "key", Proxy: "http://proxy:8080", }, }, }, } result := ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) } if result[0].Proxy != "http://proxy:8080" { t.Errorf("Proxy = %q, want %q", result[0].Proxy, "http://proxy:8080") } } func TestConvertProvidersToModelList_RequestTimeout(t *testing.T) { cfg := &Config{ Providers: ProvidersConfig{ Ollama: ProviderConfig{ APIKey: "ollama-key", RequestTimeout: 300, }, }, } result := ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) } if result[0].RequestTimeout != 300 { t.Errorf("RequestTimeout = %d, want %d", result[0].RequestTimeout, 300) } } func TestConvertProvidersToModelList_AuthMethod(t *testing.T) { cfg := &Config{ Providers: ProvidersConfig{ OpenAI: OpenAIProviderConfig{ ProviderConfig: ProviderConfig{ AuthMethod: "oauth", }, }, }, } result := ConvertProvidersToModelList(cfg) if len(result) != 0 { t.Errorf("len(result) = %d, want 0 (AuthMethod alone should not create entry)", len(result)) } } // Tests for preserving user's configured model during migration func TestConvertProvidersToModelList_PreservesUserModel_DeepSeek(t *testing.T) { cfg := &Config{ Agents: AgentsConfig{ Defaults: AgentDefaults{ Provider: "deepseek", Model: "deepseek-reasoner", }, }, Providers: ProvidersConfig{ DeepSeek: ProviderConfig{APIKey: "sk-deepseek"}, }, } result := ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) } // Should use user's model, not default if result[0].Model != "deepseek/deepseek-reasoner" { t.Errorf("Model = %q, want %q (user's configured model)", result[0].Model, "deepseek/deepseek-reasoner") } } func TestConvertProvidersToModelList_PreservesUserModel_OpenAI(t *testing.T) { cfg := &Config{ Agents: AgentsConfig{ Defaults: AgentDefaults{ Provider: "openai", Model: "gpt-4-turbo", }, }, Providers: ProvidersConfig{ OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "sk-openai"}}, }, } result := ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) } if result[0].Model != "openai/gpt-4-turbo" { t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-4-turbo") } } func TestConvertProvidersToModelList_PreservesUserModel_Anthropic(t *testing.T) { cfg := &Config{ Agents: AgentsConfig{ Defaults: AgentDefaults{ Provider: "claude", // alternative name Model: "claude-opus-4-20250514", }, }, Providers: ProvidersConfig{ Anthropic: ProviderConfig{APIKey: "sk-ant"}, }, } result := ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) } if result[0].Model != "anthropic/claude-opus-4-20250514" { t.Errorf("Model = %q, want %q", result[0].Model, "anthropic/claude-opus-4-20250514") } } func TestConvertProvidersToModelList_PreservesUserModel_Qwen(t *testing.T) { cfg := &Config{ Agents: AgentsConfig{ Defaults: AgentDefaults{ Provider: "qwen", Model: "qwen-plus", }, }, Providers: ProvidersConfig{ Qwen: ProviderConfig{APIKey: "sk-qwen"}, }, } result := ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) } if result[0].Model != "qwen/qwen-plus" { t.Errorf("Model = %q, want %q", result[0].Model, "qwen/qwen-plus") } } func TestConvertProvidersToModelList_UsesDefaultWhenNoUserModel(t *testing.T) { cfg := &Config{ Agents: AgentsConfig{ Defaults: AgentDefaults{ Provider: "deepseek", Model: "", // no model specified }, }, Providers: ProvidersConfig{ DeepSeek: ProviderConfig{APIKey: "sk-deepseek"}, }, } result := ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) } // Should use default model if result[0].Model != "deepseek/deepseek-chat" { t.Errorf("Model = %q, want %q (default)", result[0].Model, "deepseek/deepseek-chat") } } func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *testing.T) { cfg := &Config{ Agents: AgentsConfig{ Defaults: AgentDefaults{ Provider: "deepseek", Model: "deepseek-reasoner", }, }, Providers: ProvidersConfig{ OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "sk-openai"}}, DeepSeek: ProviderConfig{APIKey: "sk-deepseek"}, }, } result := ConvertProvidersToModelList(cfg) if len(result) != 2 { t.Fatalf("len(result) = %d, want 2", len(result)) } // Find each provider and verify model for _, mc := range result { switch mc.ModelName { case "openai": if mc.Model != "openai/gpt-5.4" { t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.4") } case "deepseek": if mc.Model != "deepseek/deepseek-reasoner" { t.Errorf("DeepSeek Model = %q, want %q (user's)", mc.Model, "deepseek/deepseek-reasoner") } } } } func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) { tests := []struct { providerAlias string expectedModel string provider ProviderConfig }{ {"gpt", "openai/gpt-4-custom", ProviderConfig{APIKey: "key"}}, {"claude", "anthropic/claude-custom", ProviderConfig{APIKey: "key"}}, {"doubao", "volcengine/doubao-custom", ProviderConfig{APIKey: "key"}}, {"tongyi", "qwen/qwen-custom", ProviderConfig{APIKey: "key"}}, {"kimi", "moonshot/kimi-custom", ProviderConfig{APIKey: "key"}}, } for _, tt := range tests { t.Run(tt.providerAlias, func(t *testing.T) { cfg := &Config{ Agents: AgentsConfig{ Defaults: AgentDefaults{ Provider: tt.providerAlias, Model: strings.TrimPrefix( tt.expectedModel, tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1], ), }, }, Providers: ProvidersConfig{}, } // Set the appropriate provider config switch tt.providerAlias { case "gpt": cfg.Providers.OpenAI = OpenAIProviderConfig{ProviderConfig: tt.provider} case "claude": cfg.Providers.Anthropic = tt.provider case "doubao": cfg.Providers.VolcEngine = tt.provider case "tongyi": cfg.Providers.Qwen = tt.provider case "kimi": cfg.Providers.Moonshot = tt.provider } // Need to fix the model name in config cfg.Agents.Defaults.Model = strings.TrimPrefix( tt.expectedModel, tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1], ) result := ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) } // Extract just the model ID part (after the first /) expectedModelID := tt.expectedModel if result[0].Model != expectedModelID { t.Errorf("Model = %q, want %q", result[0].Model, expectedModelID) } }) } } // Test for backward compatibility: single provider without explicit provider field // This matches the legacy config pattern where users only set model, not provider func TestConvertProvidersToModelList_NoProviderField_SingleProvider(t *testing.T) { // This matches the user's actual config: // - No provider field set // - model = "glm-4.7" // - Only zhipu has API key configured cfg := &Config{ Agents: AgentsConfig{ Defaults: AgentDefaults{ Provider: "", // Not set Model: "glm-4.7", }, }, Providers: ProvidersConfig{ Zhipu: ProviderConfig{APIKey: "test-zhipu-key"}, }, } result := ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) } // ModelName should be the user's model value for backward compatibility if result[0].ModelName != "glm-4.7" { t.Errorf("ModelName = %q, want %q (user's model for backward compatibility)", result[0].ModelName, "glm-4.7") } // Model should use the user's model with protocol prefix if result[0].Model != "zhipu/glm-4.7" { t.Errorf("Model = %q, want %q", result[0].Model, "zhipu/glm-4.7") } } func TestConvertProvidersToModelList_NoProviderField_MultipleProviders(t *testing.T) { // When multiple providers are configured but no provider field is set, // the FIRST provider (in migration order) will use userModel as ModelName // for backward compatibility with legacy implicit provider selection cfg := &Config{ Agents: AgentsConfig{ Defaults: AgentDefaults{ Provider: "", // Not set Model: "some-model", }, }, Providers: ProvidersConfig{ OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "openai-key"}}, Zhipu: ProviderConfig{APIKey: "zhipu-key"}, }, } result := ConvertProvidersToModelList(cfg) if len(result) != 2 { t.Fatalf("len(result) = %d, want 2", len(result)) } // The first provider (OpenAI in migration order) should use userModel as ModelName // This ensures GetModelConfig("some-model") will find it if result[0].ModelName != "some-model" { t.Errorf("First provider ModelName = %q, want %q", result[0].ModelName, "some-model") } // Other providers should use provider name as ModelName if result[1].ModelName != "zhipu" { t.Errorf("Second provider ModelName = %q, want %q", result[1].ModelName, "zhipu") } } func TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) { // Edge case: no provider, no model cfg := &Config{ Agents: AgentsConfig{ Defaults: AgentDefaults{ Provider: "", Model: "", }, }, Providers: ProvidersConfig{ Zhipu: ProviderConfig{APIKey: "zhipu-key"}, }, } result := ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) } // Should use default provider name since no model is specified if result[0].ModelName != "zhipu" { t.Errorf("ModelName = %q, want %q", result[0].ModelName, "zhipu") } } // Tests for buildModelWithProtocol helper function func TestBuildModelWithProtocol_NoPrefix(t *testing.T) { result := buildModelWithProtocol("openai", "gpt-5.4") if result != "openai/gpt-5.4" { t.Errorf("buildModelWithProtocol(openai, gpt-5.4) = %q, want %q", result, "openai/gpt-5.4") } } func TestBuildModelWithProtocol_AlreadyHasPrefix(t *testing.T) { result := buildModelWithProtocol("openrouter", "openrouter/auto") if result != "openrouter/auto" { t.Errorf("buildModelWithProtocol(openrouter, openrouter/auto) = %q, want %q", result, "openrouter/auto") } } func TestBuildModelWithProtocol_DifferentPrefix(t *testing.T) { result := buildModelWithProtocol("anthropic", "openrouter/claude-sonnet-4.6") if result != "openrouter/claude-sonnet-4.6" { t.Errorf( "buildModelWithProtocol(anthropic, openrouter/claude-sonnet-4.6) = %q, want %q", result, "openrouter/claude-sonnet-4.6", ) } } // Test for legacy config with protocol prefix in model name func TestConvertProvidersToModelList_LegacyModelWithProtocolPrefix(t *testing.T) { cfg := &Config{ Agents: AgentsConfig{ Defaults: AgentDefaults{ Provider: "", // No explicit provider Model: "openrouter/auto", // Model already has protocol prefix }, }, Providers: ProvidersConfig{ OpenRouter: ProviderConfig{APIKey: "sk-or-test"}, }, } result := ConvertProvidersToModelList(cfg) if len(result) < 1 { t.Fatalf("len(result) = %d, want at least 1", len(result)) } // First provider should use userModel as ModelName for backward compatibility if result[0].ModelName != "openrouter/auto" { t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openrouter/auto") } // Model should NOT have duplicated prefix if result[0].Model != "openrouter/auto" { t.Errorf("Model = %q, want %q (should not duplicate prefix)", result[0].Model, "openrouter/auto") } } // ---------- InheritProviderCredentials tests ---------- func TestInheritProviderCredentials_FillsMissingAPIKey(t *testing.T) { models := []ModelConfig{ {ModelName: "my-deepseek", Model: "deepseek/deepseek-chat"}, } providers := ProvidersConfig{ DeepSeek: ProviderConfig{ APIKey: "sk-deepseek-from-providers", APIBase: "https://api.deepseek.com/v1", }, } InheritProviderCredentials(models, providers) if models[0].APIKey != "sk-deepseek-from-providers" { t.Errorf("APIKey = %q, want %q", models[0].APIKey, "sk-deepseek-from-providers") } if models[0].APIBase != "https://api.deepseek.com/v1" { t.Errorf("APIBase = %q, want %q", models[0].APIBase, "https://api.deepseek.com/v1") } } func TestInheritProviderCredentials_ExplicitValuesTakePrecedence(t *testing.T) { models := []ModelConfig{ { ModelName: "my-openai", Model: "openai/gpt-5.4", APIKey: "sk-explicit-model-key", APIBase: "https://my-custom-endpoint.com/v1", }, } providers := ProvidersConfig{ OpenAI: OpenAIProviderConfig{ ProviderConfig: ProviderConfig{ APIKey: "sk-provider-key", APIBase: "https://api.openai.com/v1", }, }, } InheritProviderCredentials(models, providers) if models[0].APIKey != "sk-explicit-model-key" { t.Errorf("APIKey = %q, want %q (explicit should win)", models[0].APIKey, "sk-explicit-model-key") } if models[0].APIBase != "https://my-custom-endpoint.com/v1" { t.Errorf("APIBase = %q, want %q (explicit should win)", models[0].APIBase, "https://my-custom-endpoint.com/v1") } } func TestInheritProviderCredentials_MultipleModels(t *testing.T) { models := []ModelConfig{ {ModelName: "groq-llama", Model: "groq/llama-3.1-70b"}, {ModelName: "zhipu-glm", Model: "zhipu/glm-4"}, {ModelName: "custom-openai", Model: "openai/gpt-5.4", APIKey: "sk-already-set"}, } providers := ProvidersConfig{ Groq: ProviderConfig{APIKey: "gsk-groq-key", Proxy: "http://proxy:8080"}, Zhipu: ProviderConfig{APIKey: "zhipu-key-123", APIBase: "https://zhipu.example.com"}, OpenAI: OpenAIProviderConfig{ ProviderConfig: ProviderConfig{APIKey: "sk-should-not-override"}, }, } InheritProviderCredentials(models, providers) // groq model should inherit if models[0].APIKey != "gsk-groq-key" { t.Errorf("groq APIKey = %q, want %q", models[0].APIKey, "gsk-groq-key") } if models[0].Proxy != "http://proxy:8080" { t.Errorf("groq Proxy = %q, want %q", models[0].Proxy, "http://proxy:8080") } // zhipu model should inherit if models[1].APIKey != "zhipu-key-123" { t.Errorf("zhipu APIKey = %q, want %q", models[1].APIKey, "zhipu-key-123") } if models[1].APIBase != "https://zhipu.example.com" { t.Errorf("zhipu APIBase = %q, want %q", models[1].APIBase, "https://zhipu.example.com") } // openai model already has key — should NOT be overridden if models[2].APIKey != "sk-already-set" { t.Errorf("openai APIKey = %q, want %q (should not be overridden)", models[2].APIKey, "sk-already-set") } } func TestInheritProviderCredentials_NoMatchingProvider(t *testing.T) { models := []ModelConfig{ {ModelName: "my-model", Model: "novelai/some-model"}, } providers := ProvidersConfig{ DeepSeek: ProviderConfig{APIKey: "sk-deepseek"}, } InheritProviderCredentials(models, providers) // No matching provider for "novelai" protocol — should stay empty if models[0].APIKey != "" { t.Errorf("APIKey = %q, want empty (no matching provider)", models[0].APIKey) } } func TestInheritProviderCredentials_EmptyProviders(t *testing.T) { models := []ModelConfig{ {ModelName: "my-model", Model: "openai/gpt-5.4"}, } providers := ProvidersConfig{} // all empty InheritProviderCredentials(models, providers) // Empty providers — nothing to inherit if models[0].APIKey != "" { t.Errorf("APIKey = %q, want empty", models[0].APIKey) } } func TestInheritProviderCredentials_InheritsRequestTimeout(t *testing.T) { models := []ModelConfig{ {ModelName: "my-ollama", Model: "ollama/llama3.2:3b"}, } providers := ProvidersConfig{ Ollama: ProviderConfig{ APIBase: "http://localhost:11434", RequestTimeout: 120, }, } InheritProviderCredentials(models, providers) if models[0].APIBase != "http://localhost:11434" { t.Errorf("APIBase = %q, want %q", models[0].APIBase, "http://localhost:11434") } if models[0].RequestTimeout != 120 { t.Errorf("RequestTimeout = %d, want 120", models[0].RequestTimeout) } } ================================================ FILE: pkg/config/model_config_test.go ================================================ // PicoClaw - Ultra-lightweight personal AI agent // License: MIT // // Copyright (c) 2026 PicoClaw contributors package config import ( "encoding/json" "strings" "sync" "testing" ) func TestGetModelConfig_Found(t *testing.T) { cfg := &Config{ ModelList: []ModelConfig{ {ModelName: "test-model", Model: "openai/gpt-4o", APIKey: "key1"}, {ModelName: "other-model", Model: "anthropic/claude", APIKey: "key2"}, }, } result, err := cfg.GetModelConfig("test-model") if err != nil { t.Fatalf("GetModelConfig() error = %v", err) } if result.Model != "openai/gpt-4o" { t.Errorf("Model = %q, want %q", result.Model, "openai/gpt-4o") } } func TestGetModelConfig_NotFound(t *testing.T) { cfg := &Config{ ModelList: []ModelConfig{ {ModelName: "test-model", Model: "openai/gpt-4o", APIKey: "key1"}, }, } _, err := cfg.GetModelConfig("nonexistent") if err == nil { t.Fatal("GetModelConfig() expected error for nonexistent model") } } func TestGetModelConfig_EmptyList(t *testing.T) { cfg := &Config{ ModelList: []ModelConfig{}, } _, err := cfg.GetModelConfig("any-model") if err == nil { t.Fatal("GetModelConfig() expected error for empty model list") } } func TestGetModelConfig_RoundRobin(t *testing.T) { cfg := &Config{ ModelList: []ModelConfig{ {ModelName: "lb-model", Model: "openai/gpt-4o-1", APIKey: "key1"}, {ModelName: "lb-model", Model: "openai/gpt-4o-2", APIKey: "key2"}, {ModelName: "lb-model", Model: "openai/gpt-4o-3", APIKey: "key3"}, }, } // Test round-robin distribution results := make(map[string]int) for range 30 { result, err := cfg.GetModelConfig("lb-model") if err != nil { t.Fatalf("GetModelConfig() error = %v", err) } results[result.Model]++ } // Each model should appear roughly 10 times (30 calls / 3 models) for model, count := range results { if count < 5 || count > 15 { t.Errorf("Model %s appeared %d times, expected ~10", model, count) } } } func TestGetModelConfig_RoundRobinStartsFromFirstMatch(t *testing.T) { rrCounter.Store(0) cfg := &Config{ ModelList: []ModelConfig{ {ModelName: "lb-model", Model: "openai/gpt-4o-1", APIKey: "key1"}, {ModelName: "lb-model", Model: "openai/gpt-4o-2", APIKey: "key2"}, {ModelName: "lb-model", Model: "openai/gpt-4o-3", APIKey: "key3"}, }, } wantOrder := []string{ "openai/gpt-4o-1", "openai/gpt-4o-2", "openai/gpt-4o-3", "openai/gpt-4o-1", "openai/gpt-4o-2", } for i, want := range wantOrder { result, err := cfg.GetModelConfig("lb-model") if err != nil { t.Fatalf("GetModelConfig() call %d error = %v", i, err) } if result.Model != want { t.Fatalf("GetModelConfig() call %d model = %q, want %q", i, result.Model, want) } } } func TestGetModelConfig_Concurrent(t *testing.T) { cfg := &Config{ ModelList: []ModelConfig{ {ModelName: "concurrent-model", Model: "openai/gpt-4o-1", APIKey: "key1"}, {ModelName: "concurrent-model", Model: "openai/gpt-4o-2", APIKey: "key2"}, }, } const goroutines = 100 const iterations = 10 var wg sync.WaitGroup errors := make(chan error, goroutines*iterations) for range goroutines { wg.Go(func() { for range iterations { _, err := cfg.GetModelConfig("concurrent-model") if err != nil { errors <- err } } }) } wg.Wait() close(errors) for err := range errors { t.Errorf("Concurrent GetModelConfig() error: %v", err) } } func TestAgentDefaults_GetModelName_BackwardCompat(t *testing.T) { tests := []struct { name string defaults AgentDefaults wantName string }{ { name: "new model_name field only", defaults: AgentDefaults{ModelName: "new-model"}, wantName: "new-model", }, { name: "old model field only", defaults: AgentDefaults{Model: "legacy-model"}, wantName: "legacy-model", }, { name: "both fields - model_name takes precedence", defaults: AgentDefaults{ModelName: "new-model", Model: "old-model"}, wantName: "new-model", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.defaults.GetModelName(); got != tt.wantName { t.Errorf("GetModelName() = %q, want %q", got, tt.wantName) } }) } } func TestAgentDefaults_JSON_BackwardCompat(t *testing.T) { tests := []struct { name string json string wantName string }{ { name: "new model_name field", json: `{"model_name": "gpt4"}`, wantName: "gpt4", }, { name: "old model field", json: `{"model": "gpt4"}`, wantName: "gpt4", }, { name: "both fields - model_name wins", json: `{"model_name": "new", "model": "old"}`, wantName: "new", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var defaults AgentDefaults if err := json.Unmarshal([]byte(tt.json), &defaults); err != nil { t.Fatalf("Unmarshal error: %v", err) } if got := defaults.GetModelName(); got != tt.wantName { t.Errorf("GetModelName() = %q, want %q", got, tt.wantName) } }) } } func TestFullConfig_JSON_BackwardCompat(t *testing.T) { // Test complete config with both old and new formats oldFormat := `{ "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "model": "gpt4", "max_tokens": 4096 } }, "model_list": [ { "model_name": "gpt4", "model": "openai/gpt-4o", "api_key": "test-key" } ] }` newFormat := `{ "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "model_name": "gpt4", "max_tokens": 4096 } }, "model_list": [ { "model_name": "gpt4", "model": "openai/gpt-4o", "api_key": "test-key" } ] }` for name, jsonStr := range map[string]string{ "old format (model)": oldFormat, "new format (model_name)": newFormat, } { t.Run(name, func(t *testing.T) { cfg := &Config{} if err := json.Unmarshal([]byte(jsonStr), cfg); err != nil { t.Fatalf("Unmarshal error: %v", err) } // Check that GetModelName returns correct value if got := cfg.Agents.Defaults.GetModelName(); got != "gpt4" { t.Errorf("GetModelName() = %q, want %q", got, "gpt4") } // Check that GetModelConfig works modelCfg, err := cfg.GetModelConfig("gpt4") if err != nil { t.Fatalf("GetModelConfig error: %v", err) } if modelCfg.Model != "openai/gpt-4o" { t.Errorf("Model = %q, want %q", modelCfg.Model, "openai/gpt-4o") } }) } } func TestModelConfig_Validate(t *testing.T) { tests := []struct { name string config ModelConfig wantErr bool }{ { name: "valid config", config: ModelConfig{ ModelName: "test", Model: "openai/gpt-4o", }, wantErr: false, }, { name: "missing model_name", config: ModelConfig{ Model: "openai/gpt-4o", }, wantErr: true, }, { name: "missing model", config: ModelConfig{ ModelName: "test", }, wantErr: true, }, { name: "empty config", config: ModelConfig{}, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.config.Validate() if (err != nil) != tt.wantErr { t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) } }) } } func TestConfig_ValidateModelList(t *testing.T) { tests := []struct { name string config *Config wantErr bool errMsg string // partial error message to check }{ { name: "valid list", config: &Config{ ModelList: []ModelConfig{ {ModelName: "test1", Model: "openai/gpt-4o"}, {ModelName: "test2", Model: "anthropic/claude"}, }, }, wantErr: false, }, { name: "invalid entry", config: &Config{ ModelList: []ModelConfig{ {ModelName: "test1", Model: "openai/gpt-4o"}, {ModelName: "", Model: "anthropic/claude"}, // missing model_name }, }, wantErr: true, errMsg: "model_name is required", }, { name: "empty list", config: &Config{ ModelList: []ModelConfig{}, }, wantErr: false, }, { // Load balancing: multiple entries with same model_name are allowed name: "duplicate model_name for load balancing", config: &Config{ ModelList: []ModelConfig{ {ModelName: "gpt-4", Model: "openai/gpt-4o", APIKey: "key1"}, {ModelName: "gpt-4", Model: "openai/gpt-4-turbo", APIKey: "key2"}, }, }, wantErr: false, // Changed: duplicates are allowed for load balancing }, { // Load balancing: non-adjacent entries with same model_name are also allowed name: "duplicate model_name non-adjacent for load balancing", config: &Config{ ModelList: []ModelConfig{ {ModelName: "model-a", Model: "openai/gpt-4o"}, {ModelName: "model-b", Model: "anthropic/claude"}, {ModelName: "model-a", Model: "openai/gpt-4-turbo"}, }, }, wantErr: false, // Changed: duplicates are allowed for load balancing }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.config.ValidateModelList() if (err != nil) != tt.wantErr { t.Errorf("ValidateModelList() error = %v, wantErr %v", err, tt.wantErr) } if err != nil && tt.errMsg != "" { if !strings.Contains(err.Error(), tt.errMsg) { t.Errorf("ValidateModelList() error = %v, want error containing %q", err, tt.errMsg) } } }) } } func TestModelConfig_RequestTimeoutParsing(t *testing.T) { jsonData := `{ "model_name": "slow-local", "model": "openai/local-model", "api_base": "http://localhost:11434/v1", "request_timeout": 300 }` var cfg ModelConfig if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if cfg.RequestTimeout != 300 { t.Fatalf("RequestTimeout = %d, want 300", cfg.RequestTimeout) } } func TestModelConfig_RequestTimeoutDefaultZeroValue(t *testing.T) { jsonData := `{ "model_name": "default-timeout", "model": "openai/gpt-4o", "api_key": "test-key" }` var cfg ModelConfig if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if cfg.RequestTimeout != 0 { t.Fatalf("RequestTimeout = %d, want 0", cfg.RequestTimeout) } } ================================================ FILE: pkg/config/multikey_test.go ================================================ package config import ( "testing" ) func TestExpandMultiKeyModels_SingleKey(t *testing.T) { models := []ModelConfig{ { ModelName: "gpt-4", Model: "openai/gpt-4o", APIKey: "single-key", }, } result := ExpandMultiKeyModels(models) if len(result) != 1 { t.Fatalf("expected 1 model, got %d", len(result)) } if result[0].ModelName != "gpt-4" { t.Errorf("expected model_name 'gpt-4', got %q", result[0].ModelName) } if result[0].APIKey != "single-key" { t.Errorf("expected api_key 'single-key', got %q", result[0].APIKey) } if len(result[0].Fallbacks) != 0 { t.Errorf("expected no fallbacks, got %v", result[0].Fallbacks) } } func TestExpandMultiKeyModels_APIKeysOnly(t *testing.T) { models := []ModelConfig{ { ModelName: "glm-4.7", Model: "zhipu/glm-4.7", APIBase: "https://api.example.com", APIKeys: []string{"key1", "key2", "key3"}, }, } result := ExpandMultiKeyModels(models) // Should expand to 3 models if len(result) != 3 { t.Fatalf("expected 3 models, got %d", len(result)) } // First entry should be the primary with key1 and fallbacks primary := result[2] // Primary is added last if primary.ModelName != "glm-4.7" { t.Errorf("expected primary model_name 'glm-4.7', got %q", primary.ModelName) } if primary.APIKey != "key1" { t.Errorf("expected primary api_key 'key1', got %q", primary.APIKey) } if len(primary.Fallbacks) != 2 { t.Errorf("expected 2 fallbacks, got %d", len(primary.Fallbacks)) } if primary.Fallbacks[0] != "glm-4.7__key_1" { t.Errorf("expected first fallback 'glm-4.7__key_1', got %q", primary.Fallbacks[0]) } if primary.Fallbacks[1] != "glm-4.7__key_2" { t.Errorf("expected second fallback 'glm-4.7__key_2', got %q", primary.Fallbacks[1]) } // Second entry should be key2 second := result[0] if second.ModelName != "glm-4.7__key_1" { t.Errorf("expected second model_name 'glm-4.7__key_1', got %q", second.ModelName) } if second.APIKey != "key2" { t.Errorf("expected second api_key 'key2', got %q", second.APIKey) } // Third entry should be key3 third := result[1] if third.ModelName != "glm-4.7__key_2" { t.Errorf("expected third model_name 'glm-4.7__key_2', got %q", third.ModelName) } if third.APIKey != "key3" { t.Errorf("expected third api_key 'key3', got %q", third.APIKey) } } func TestExpandMultiKeyModels_APIKeyAndAPIKeys(t *testing.T) { models := []ModelConfig{ { ModelName: "gpt-4", Model: "openai/gpt-4o", APIKey: "key0", APIKeys: []string{"key1", "key2"}, }, } result := ExpandMultiKeyModels(models) // Should expand to 3 models (key0 from APIKey + key1, key2 from APIKeys) if len(result) != 3 { t.Fatalf("expected 3 models, got %d", len(result)) } // Primary should use key0 primary := result[2] if primary.APIKey != "key0" { t.Errorf("expected primary api_key 'key0', got %q", primary.APIKey) } if len(primary.Fallbacks) != 2 { t.Errorf("expected 2 fallbacks, got %d", len(primary.Fallbacks)) } } func TestExpandMultiKeyModels_WithExistingFallbacks(t *testing.T) { models := []ModelConfig{ { ModelName: "gpt-4", Model: "openai/gpt-4o", APIKeys: []string{"key1", "key2"}, Fallbacks: []string{"claude-3"}, }, } result := ExpandMultiKeyModels(models) primary := result[1] // With 2 keys, we get 1 key fallback + 1 existing fallback = 2 total if len(primary.Fallbacks) != 2 { t.Fatalf("expected 2 fallbacks, got %d: %v", len(primary.Fallbacks), primary.Fallbacks) } // Key fallbacks should come first, then existing fallbacks if primary.Fallbacks[0] != "gpt-4__key_1" { t.Errorf("expected first fallback 'gpt-4__key_1', got %q", primary.Fallbacks[0]) } if primary.Fallbacks[1] != "claude-3" { t.Errorf("expected second fallback 'claude-3', got %q", primary.Fallbacks[1]) } } func TestExpandMultiKeyModels_EmptyAPIKeys(t *testing.T) { models := []ModelConfig{ { ModelName: "gpt-4", Model: "openai/gpt-4o", APIKey: "", APIKeys: []string{}, }, } result := ExpandMultiKeyModels(models) // Should keep as-is with no changes if len(result) != 1 { t.Fatalf("expected 1 model, got %d", len(result)) } if result[0].ModelName != "gpt-4" { t.Errorf("expected model_name 'gpt-4', got %q", result[0].ModelName) } } func TestExpandMultiKeyModels_Deduplication(t *testing.T) { models := []ModelConfig{ { ModelName: "gpt-4", Model: "openai/gpt-4o", APIKey: "key1", APIKeys: []string{"key1", "key2", "key1"}, // Duplicate key1 }, } result := ExpandMultiKeyModels(models) // Should only create 2 models (deduplicated keys) if len(result) != 2 { t.Fatalf("expected 2 models (deduplicated), got %d", len(result)) } primary := result[1] if primary.APIKey != "key1" { t.Errorf("expected primary api_key 'key1', got %q", primary.APIKey) } if len(primary.Fallbacks) != 1 { t.Errorf("expected 1 fallback, got %d", len(primary.Fallbacks)) } } func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) { models := []ModelConfig{ { ModelName: "gpt-4", Model: "openai/gpt-4o", APIBase: "https://api.example.com", APIKeys: []string{"key1", "key2"}, Proxy: "http://proxy:8080", RPM: 60, MaxTokensField: "max_completion_tokens", RequestTimeout: 30, ThinkingLevel: "high", }, } result := ExpandMultiKeyModels(models) // Check primary entry preserves all fields primary := result[1] if primary.APIBase != "https://api.example.com" { t.Errorf("expected api_base preserved, got %q", primary.APIBase) } if primary.Proxy != "http://proxy:8080" { t.Errorf("expected proxy preserved, got %q", primary.Proxy) } if primary.RPM != 60 { t.Errorf("expected rpm preserved, got %d", primary.RPM) } if primary.MaxTokensField != "max_completion_tokens" { t.Errorf("expected max_tokens_field preserved, got %q", primary.MaxTokensField) } if primary.RequestTimeout != 30 { t.Errorf("expected request_timeout preserved, got %d", primary.RequestTimeout) } if primary.ThinkingLevel != "high" { t.Errorf("expected thinking_level preserved, got %q", primary.ThinkingLevel) } // Check additional entry also preserves fields additional := result[0] if additional.APIBase != "https://api.example.com" { t.Errorf("expected additional api_base preserved, got %q", additional.APIBase) } if additional.RPM != 60 { t.Errorf("expected additional rpm preserved, got %d", additional.RPM) } } func TestMergeAPIKeys(t *testing.T) { tests := []struct { name string apiKey string apiKeys []string expected []string }{ { name: "both empty", apiKey: "", apiKeys: nil, expected: nil, }, { name: "only apiKey", apiKey: "key1", apiKeys: nil, expected: []string{"key1"}, }, { name: "only apiKeys", apiKey: "", apiKeys: []string{"key1", "key2"}, expected: []string{"key1", "key2"}, }, { name: "both with overlap", apiKey: "key1", apiKeys: []string{"key1", "key2", "key3"}, expected: []string{"key1", "key2", "key3"}, }, { name: "with whitespace", apiKey: " key1 ", apiKeys: []string{" key2 ", " key1 "}, expected: []string{"key1", "key2"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := MergeAPIKeys(tt.apiKey, tt.apiKeys) if len(result) != len(tt.expected) { t.Fatalf("expected %d keys, got %d", len(tt.expected), len(result)) } for i, k := range result { if k != tt.expected[i] { t.Errorf("expected key[%d] = %q, got %q", i, tt.expected[i], k) } } }) } } ================================================ FILE: pkg/config/version.go ================================================ package config import ( "fmt" "runtime" ) // Build-time variables injected via ldflags during build process. // These are set by the Makefile or .goreleaser.yaml using the -X flag: // // -X github.com/sipeed/picoclaw/pkg/config.Version= // -X github.com/sipeed/picoclaw/pkg/config.GitCommit= // -X github.com/sipeed/picoclaw/pkg/config.BuildTime= // -X github.com/sipeed/picoclaw/pkg/config.GoVersion= var ( Version = "dev" // Default value when not built with ldflags GitCommit string // Git commit SHA (short) BuildTime string // Build timestamp in RFC3339 format GoVersion string // Go version used for building ) // FormatVersion returns the version string with optional git commit func FormatVersion() string { v := Version if GitCommit != "" { v += fmt.Sprintf(" (git: %s)", GitCommit) } return v } // FormatBuildInfo returns build time and go version info func FormatBuildInfo() (string, string) { build := BuildTime goVer := GoVersion if goVer == "" { goVer = runtime.Version() } return build, goVer } // GetVersion returns the version string func GetVersion() string { return Version } ================================================ FILE: pkg/config/version_test.go ================================================ package config import ( "runtime" "testing" "github.com/stretchr/testify/assert" ) func TestFormatVersion_NoGitCommit(t *testing.T) { oldVersion, oldGit := Version, GitCommit t.Cleanup(func() { Version, GitCommit = oldVersion, oldGit }) Version = "1.2.3" GitCommit = "" assert.Equal(t, "1.2.3", FormatVersion()) } func TestFormatVersion_WithGitCommit(t *testing.T) { oldVersion, oldGit := Version, GitCommit t.Cleanup(func() { Version, GitCommit = oldVersion, oldGit }) Version = "1.2.3" GitCommit = "abc123" assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion()) } func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) { oldBuildTime, oldGoVersion := BuildTime, GoVersion t.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion }) BuildTime = "2026-02-20T00:00:00Z" GoVersion = "go1.23.0" build, goVer := FormatBuildInfo() assert.Equal(t, BuildTime, build) assert.Equal(t, GoVersion, goVer) } func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) { oldBuildTime, oldGoVersion := BuildTime, GoVersion t.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion }) BuildTime = "" GoVersion = "go1.23.0" build, goVer := FormatBuildInfo() assert.Empty(t, build) assert.Equal(t, GoVersion, goVer) } func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) { oldBuildTime, oldGoVersion := BuildTime, GoVersion t.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion }) BuildTime = "x" GoVersion = "" build, goVer := FormatBuildInfo() assert.Equal(t, "x", build) assert.Equal(t, runtime.Version(), goVer) } func TestGetVersion(t *testing.T) { oldVersion := Version t.Cleanup(func() { Version = oldVersion }) Version = "dev" assert.Equal(t, "dev", GetVersion()) } func TestGetVersion_Custom(t *testing.T) { oldVersion := Version t.Cleanup(func() { Version = oldVersion }) Version = "v1.0.0" assert.Equal(t, "v1.0.0", GetVersion()) } func TestVersion_DefaultIsDev(t *testing.T) { // Reset to default values oldVersion := Version Version = "dev" t.Cleanup(func() { Version = oldVersion }) assert.Equal(t, "dev", Version) } ================================================ FILE: pkg/constants/channels.go ================================================ // Package constants provides shared constants across the codebase. package constants // internalChannels defines channels that are used for internal communication // and should not be exposed to external users or recorded as last active channel. var internalChannels = map[string]struct{}{ "cli": {}, "system": {}, "subagent": {}, } // IsInternalChannel returns true if the channel is an internal channel. func IsInternalChannel(channel string) bool { _, found := internalChannels[channel] return found } ================================================ FILE: pkg/credential/credential.go ================================================ // Package credential resolves API credential values for model_list entries. // // An API key is a form of authorization credential. This package centralizes // how raw credential strings—plaintext or file references—are resolved into // their actual values, keeping that logic out of the config loader. // // Supported formats for the api_key field: // // - Plaintext: "sk-abc123" → returned as-is // - File ref: "file://filename.key" → content read from configDir/filename.key // - Encrypted: "enc://" → AES-256-GCM decrypt via PICOCLAW_KEY_PASSPHRASE // - Empty: "" → returned as-is (auth_method=oauth etc.) // // Encryption uses AES-256-GCM with HKDF-SHA256 key derivation (< 1ms, safe for embedded Linux). // An SSH private key is required for both encryption and decryption. // Key derivation: // // HKDF-SHA256(ikm=HMAC-SHA256(SHA256(sshKeyBytes), passphrase), salt, info) // // SSH key path resolution priority: // // 1. sshKeyPath argument to Encrypt (explicit) // 2. PICOCLAW_SSH_KEY_PATH env var // 3. ~/.ssh/picoclaw_ed25519.key (os.UserHomeDir is cross-platform) package credential import ( "crypto/aes" "crypto/cipher" "crypto/hkdf" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "errors" "fmt" "io" "os" "path/filepath" "strings" ) // PassphraseEnvVar is the environment variable that holds the encryption passphrase. // Other packages (e.g. config) reference this constant to avoid duplicating the string. const PassphraseEnvVar = "PICOCLAW_KEY_PASSPHRASE" // PassphraseProvider is the function used to retrieve the passphrase for enc:// // credential decryption. It defaults to reading PICOCLAW_KEY_PASSPHRASE from the // process environment. Replace it at startup to use a different source, such as // an in-memory SecureStore, so that all LoadConfig() calls everywhere share the // same passphrase source without needing os.Environ. // // Example (launcher main.go): // // credential.PassphraseProvider = apiHandler.passphraseStore.Get var PassphraseProvider func() string = func() string { return os.Getenv(PassphraseEnvVar) } // ErrPassphraseRequired is returned when an enc:// credential is encountered but // no passphrase is available from PassphraseProvider. Callers can detect this // with errors.Is to distinguish a missing-passphrase condition from other errors. var ErrPassphraseRequired = errors.New("credential: enc:// passphrase required") // ErrDecryptionFailed is returned when an enc:// credential cannot be decrypted, // indicating a wrong passphrase or SSH key. Callers can detect this with errors.Is. var ErrDecryptionFailed = errors.New("credential: enc:// decryption failed (wrong passphrase or SSH key?)") // SSHKeyPathEnvVar is the environment variable that specifies the path to the // SSH private key used for enc:// credential encryption and decryption. const SSHKeyPathEnvVar = "PICOCLAW_SSH_KEY_PATH" // picoclawHome is a package-local copy of config.EnvHome. It is kept here to // avoid a circular import between pkg/credential and pkg/config. const picoclawHome = "PICOCLAW_HOME" const ( fileScheme = "file://" encScheme = "enc://" hkdfInfo = "picoclaw-credential-v1" saltLen = 16 nonceLen = 12 keyLen = 32 ) // Resolver resolves raw credential strings for model_list api_key fields. // File references are resolved relative to the directory of the config file. type Resolver struct { configDir string resolvedConfigDir string // symlink-resolved form of configDir } // NewResolver returns a Resolver that resolves file:// references relative to // configDir (typically filepath.Dir of the config file path). func NewResolver(configDir string) *Resolver { resolved := configDir if configDir != "" { if linkedPath, err := filepath.EvalSymlinks(configDir); err == nil { resolved = linkedPath } } return &Resolver{configDir: configDir, resolvedConfigDir: resolved} } // Resolve returns the actual credential value for raw: // // - "" → "" (no error; auth_method=oauth needs no key) // - "file://name.key" → trimmed content of configDir/name.key // - anything else → raw unchanged (plaintext credential) func (r *Resolver) Resolve(raw string) (string, error) { if raw == "" { return "", nil } if strings.HasPrefix(raw, fileScheme) { fileName := strings.TrimSpace(strings.TrimPrefix(raw, fileScheme)) if fileName == "" { return "", fmt.Errorf("credential: file:// reference has no filename") } baseDir := r.resolvedConfigDir if baseDir == "" { baseDir = r.configDir } keyPath := filepath.Join(baseDir, fileName) // Resolve symlinks before enforcing containment to prevent escaping via symlinks. realKeyPath, err := filepath.EvalSymlinks(keyPath) if err != nil { return "", fmt.Errorf("credential: failed to resolve credential file path %q: %w", keyPath, err) } if !isWithinDir(realKeyPath, baseDir) { return "", fmt.Errorf("credential: file:// path escapes config directory") } data, err := os.ReadFile(realKeyPath) if err != nil { return "", fmt.Errorf("credential: failed to read credential file %q: %w", realKeyPath, err) } value := strings.TrimSpace(string(data)) if value == "" { return "", fmt.Errorf("credential: credential file %q is empty", realKeyPath) } return value, nil } if strings.HasPrefix(raw, encScheme) { return resolveEncrypted(raw) } // Plaintext credential — return unchanged. return raw, nil } // resolveEncrypted decrypts an enc:// credential using PassphraseProvider. func resolveEncrypted(raw string) (string, error) { passphrase := PassphraseProvider() if passphrase == "" { return "", ErrPassphraseRequired } sshKeyPath := pickSSHKeyPath("") // override="": consult env then auto-detect b64 := strings.TrimPrefix(raw, encScheme) blob, err := base64.StdEncoding.DecodeString(b64) if err != nil { return "", fmt.Errorf("credential: enc:// invalid base64: %w", err) } if len(blob) < saltLen+nonceLen+1 { return "", fmt.Errorf("credential: enc:// payload too short") } salt := blob[:saltLen] nonce := blob[saltLen : saltLen+nonceLen] ciphertext := blob[saltLen+nonceLen:] key, err := deriveKey(passphrase, sshKeyPath, salt) if err != nil { return "", err } block, err := aes.NewCipher(key) if err != nil { return "", fmt.Errorf("credential: enc:// cipher init: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { return "", fmt.Errorf("credential: enc:// gcm init: %w", err) } plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) if err != nil { return "", fmt.Errorf("%w: %w", ErrDecryptionFailed, err) } return string(plaintext), nil } // Encrypt encrypts plaintext and returns an enc:// credential string. // // passphrase is required (PICOCLAW_KEY_PASSPHRASE value). // sshKeyPath is the SSH private key file to use; pass "" to auto-detect via // PICOCLAW_SSH_KEY_PATH env var or ~/.ssh/picoclaw_ed25519.key. // An SSH private key must be resolvable or Encrypt returns an error. func Encrypt(passphrase, sshKeyPath, plaintext string) (string, error) { if passphrase == "" { return "", fmt.Errorf("credential: passphrase must not be empty") } sshKeyPath = pickSSHKeyPath(sshKeyPath) salt := make([]byte, saltLen) if _, err := io.ReadFull(rand.Reader, salt); err != nil { return "", fmt.Errorf("credential: failed to generate salt: %w", err) } key, err := deriveKey(passphrase, sshKeyPath, salt) if err != nil { return "", err } block, err := aes.NewCipher(key) if err != nil { return "", fmt.Errorf("credential: cipher init: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { return "", fmt.Errorf("credential: gcm init: %w", err) } nonce := make([]byte, nonceLen) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return "", fmt.Errorf("credential: failed to generate nonce: %w", err) } ciphertext := gcm.Seal(nil, nonce, []byte(plaintext), nil) blob := make([]byte, 0, saltLen+nonceLen+len(ciphertext)) blob = append(blob, salt...) blob = append(blob, nonce...) blob = append(blob, ciphertext...) return encScheme + base64.StdEncoding.EncodeToString(blob), nil } // isWithinDir reports whether path is contained within (or equal to) dir. // Uses filepath.IsLocal on the relative path for robust cross-platform traversal detection. func isWithinDir(path, dir string) bool { rel, err := filepath.Rel(filepath.Clean(dir), filepath.Clean(path)) return err == nil && filepath.IsLocal(rel) } // allowedSSHKeyPath reports whether path is in a permitted location for SSH key files: // - exact match with PICOCLAW_SSH_KEY_PATH env var // - within the PICOCLAW_HOME env var directory // - within ~/.ssh/ func allowedSSHKeyPath(path string) bool { if path == "" { return true // passphrase-only mode; no file will be read } clean := filepath.Clean(path) // Exact match with PICOCLAW_SSH_KEY_PATH. if envPath, ok := os.LookupEnv(SSHKeyPathEnvVar); ok && envPath != "" { if clean == filepath.Clean(envPath) { return true } } // Within PICOCLAW_HOME. if picoHome := os.Getenv(picoclawHome); picoHome != "" { if isWithinDir(clean, picoHome) { return true } } // Within ~/.ssh/. if userHome, err := os.UserHomeDir(); err == nil { if isWithinDir(clean, filepath.Join(userHome, ".ssh")) { return true } } return false } // deriveKey derives a 32-byte AES-256 key from passphrase and SSH private key. // // ikm = HMAC-SHA256(key=SHA256(sshKeyBytes), msg=passphrase) // Final key: HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) // sshKeyPath must be non-empty; returns an error otherwise. func deriveKey(passphrase, sshKeyPath string, salt []byte) ([]byte, error) { if sshKeyPath == "" { return nil, fmt.Errorf( "credential: SSH private key is required but not found" + " (set PICOCLAW_SSH_KEY_PATH or place key at ~/.ssh/picoclaw_ed25519.key)") } if !allowedSSHKeyPath(sshKeyPath) { return nil, fmt.Errorf( "credential: SSH key path %q is not in an allowed location (PICOCLAW_SSH_KEY_PATH, PICOCLAW_HOME, or ~/.ssh/)", sshKeyPath, ) } sshBytes, err := os.ReadFile(sshKeyPath) if err != nil { return nil, fmt.Errorf("credential: cannot read SSH key %q: %w", sshKeyPath, err) } sshHash := sha256.Sum256(sshBytes) mac := hmac.New(sha256.New, sshHash[:]) mac.Write([]byte(passphrase)) ikm := mac.Sum(nil) key, err := hkdf.Key(sha256.New, ikm, salt, hkdfInfo, keyLen) if err != nil { return nil, fmt.Errorf("credential: HKDF expand failed: %w", err) } return key, nil } // pickSSHKeyPath returns the SSH private key path to use for encryption/decryption. // // Priority: // 1. override (non-empty explicit argument) // 2. PICOCLAW_SSH_KEY_PATH env var // 3. ~/.ssh/picoclaw_ed25519.key (auto-detection) // // Returns "" when no key is found; deriveKey will return an error in that case. func pickSSHKeyPath(override string) string { if override != "" { return override } if p, ok := os.LookupEnv(SSHKeyPathEnvVar); ok { return p // respect explicit setting, even if "" } return findDefaultSSHKey() } // findDefaultSSHKey returns the picoclaw-specific SSH key path if it exists. func findDefaultSSHKey() string { p, err := DefaultSSHKeyPath() if err != nil { return "" } if _, err := os.Stat(p); err == nil { return p } return "" } ================================================ FILE: pkg/credential/credential_test.go ================================================ package credential_test import ( "os" "path/filepath" "testing" "github.com/sipeed/picoclaw/pkg/credential" ) func TestResolve_PlainKey(t *testing.T) { r := credential.NewResolver(t.TempDir()) got, err := r.Resolve("sk-plaintext-key") if err != nil { t.Fatalf("unexpected error: %v", err) } if got != "sk-plaintext-key" { t.Fatalf("got %q, want %q", got, "sk-plaintext-key") } } func TestResolve_FileKey_Success(t *testing.T) { dir := t.TempDir() keyFile := "openai_plain.key" if err := os.WriteFile(filepath.Join(dir, keyFile), []byte("sk-from-file\n"), 0o600); err != nil { t.Fatalf("setup: %v", err) } r := credential.NewResolver(dir) got, err := r.Resolve("file://" + keyFile) if err != nil { t.Fatalf("unexpected error: %v", err) } if got != "sk-from-file" { t.Fatalf("got %q, want %q", got, "sk-from-file") } } func TestResolve_FileKey_NotFound(t *testing.T) { r := credential.NewResolver(t.TempDir()) _, err := r.Resolve("file://missing.key") if err == nil { t.Fatal("expected error for missing file, got nil") } } func TestResolve_FileKey_Empty(t *testing.T) { dir := t.TempDir() keyFile := "empty.key" if err := os.WriteFile(filepath.Join(dir, keyFile), []byte(" \n"), 0o600); err != nil { t.Fatalf("setup: %v", err) } r := credential.NewResolver(dir) _, err := r.Resolve("file://" + keyFile) if err == nil { t.Fatal("expected error for empty credential file, got nil") } } // TestResolve_EncKey_RoundTrip tests basic encryption/decryption round-trip with an SSH key. func TestResolve_EncKey_RoundTrip(t *testing.T) { dir := t.TempDir() sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-key-material\n"), 0o600); err != nil { t.Fatalf("setup: %v", err) } const passphrase = "test-passphrase-32bytes-long-ok!" const plaintext = "sk-encrypted-secret" t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) enc, err := credential.Encrypt(passphrase, "", plaintext) if err != nil { t.Fatalf("Encrypt: %v", err) } t.Setenv("PICOCLAW_KEY_PASSPHRASE", passphrase) r := credential.NewResolver(t.TempDir()) got, err := r.Resolve(enc) if err != nil { t.Fatalf("Resolve: %v", err) } if got != plaintext { t.Fatalf("got %q, want %q", got, plaintext) } } // TestResolve_EncKey_WithSSHKey tests that the SSH key file is incorporated into key derivation. func TestResolve_EncKey_WithSSHKey(t *testing.T) { dir := t.TempDir() sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-private-key-material\n"), 0o600); err != nil { t.Fatalf("setup: %v", err) } const passphrase = "test-passphrase" const plaintext = "sk-ssh-protected-secret" // Set PICOCLAW_SSH_KEY_PATH before Encrypt so the path passes allowedSSHKeyPath validation. t.Setenv("PICOCLAW_KEY_PASSPHRASE", passphrase) t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) enc, err := credential.Encrypt(passphrase, sshKeyPath, plaintext) if err != nil { t.Fatalf("Encrypt: %v", err) } r := credential.NewResolver(t.TempDir()) got, err := r.Resolve(enc) if err != nil { t.Fatalf("Resolve: %v", err) } if got != plaintext { t.Fatalf("got %q, want %q", got, plaintext) } } func TestResolve_EncKey_NoPassphrase(t *testing.T) { dir := t.TempDir() sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-key\n"), 0o600); err != nil { t.Fatalf("setup: %v", err) } t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) enc, err := credential.Encrypt("some-passphrase", "", "sk-secret") if err != nil { t.Fatalf("Encrypt: %v", err) } t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") r := credential.NewResolver(t.TempDir()) _, err = r.Resolve(enc) if err == nil { t.Fatal("expected error when PICOCLAW_KEY_PASSPHRASE is unset, got nil") } } func TestResolve_EncKey_BadCiphertext(t *testing.T) { t.Setenv("PICOCLAW_KEY_PASSPHRASE", "some-passphrase") t.Setenv("PICOCLAW_SSH_KEY_PATH", "") r := credential.NewResolver(t.TempDir()) _, err := r.Resolve("enc://!!not-valid-base64!!") if err == nil { t.Fatal("expected error for invalid enc:// payload, got nil") } } func TestResolve_EncKey_PayloadTooShort(t *testing.T) { t.Setenv("PICOCLAW_KEY_PASSPHRASE", "some-passphrase") t.Setenv("PICOCLAW_SSH_KEY_PATH", "") // Valid base64 but fewer bytes than salt(16)+nonce(12)+1 minimum. import64 := "dG9vc2hvcnQ=" // "tooshort" = 8 bytes r := credential.NewResolver(t.TempDir()) _, err := r.Resolve("enc://" + import64) if err == nil { t.Fatal("expected error for too-short enc:// payload, got nil") } } func TestResolve_EncKey_WrongPassphrase(t *testing.T) { dir := t.TempDir() sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-key\n"), 0o600); err != nil { t.Fatalf("setup: %v", err) } t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) enc, err := credential.Encrypt("correct-passphrase", "", "sk-secret") if err != nil { t.Fatalf("Encrypt: %v", err) } t.Setenv("PICOCLAW_KEY_PASSPHRASE", "wrong-passphrase") r := credential.NewResolver(t.TempDir()) _, err = r.Resolve(enc) if err == nil { t.Fatal("expected decryption error for wrong passphrase, got nil") } } func TestEncrypt_EmptyPassphrase(t *testing.T) { _, err := credential.Encrypt("", "", "sk-secret") if err == nil { t.Fatal("expected error for empty passphrase, got nil") } } func TestDeriveKey_SSHKeyNotFound(t *testing.T) { // Encrypt with a real SSH key path, then try to decrypt with a missing path. dir := t.TempDir() sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") if err := os.WriteFile(sshKeyPath, []byte("fake-key\n"), 0o600); err != nil { t.Fatalf("setup: %v", err) } // Register the real key path so allowedSSHKeyPath validation passes for Encrypt. t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) enc, err := credential.Encrypt("passphrase", sshKeyPath, "sk-secret") if err != nil { t.Fatalf("Encrypt: %v", err) } // Point to a non-existent SSH key so deriveKey's ReadFile fails. // The path is still under the same dir, so allowedSSHKeyPath passes (exact env match). t.Setenv("PICOCLAW_KEY_PASSPHRASE", "passphrase") t.Setenv("PICOCLAW_SSH_KEY_PATH", filepath.Join(dir, "nonexistent_key")) r := credential.NewResolver(t.TempDir()) _, err = r.Resolve(enc) if err == nil { t.Fatal("expected error when SSH key file is missing, got nil") } } // TestResolve_FileRef_PathTraversal verifies that file:// references cannot escape configDir // via relative traversal ("../../etc/passwd") or absolute paths ("/abs/path"). func TestResolve_FileRef_PathTraversal(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.json") // Create a file outside configDir that the traversal would point to. outsideFile := filepath.Join(t.TempDir(), "secret.key") if err := os.WriteFile(outsideFile, []byte("stolen"), 0o600); err != nil { t.Fatalf("setup: %v", err) } r := credential.NewResolver(filepath.Dir(cfgPath)) cases := []string{ "file://../../secret.key", "file://../secret.key", "file://" + outsideFile, // absolute path } for _, raw := range cases { _, err := r.Resolve(raw) if err == nil { t.Errorf("Resolve(%q): expected path traversal error, got nil", raw) } } } // TestResolve_FileRef_withinConfigDir verifies that a legitimate relative file:// ref works. func TestResolve_FileRef_withinConfigDir(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "my.key"), []byte("sk-valid\n"), 0o600); err != nil { t.Fatalf("setup: %v", err) } r := credential.NewResolver(dir) got, err := r.Resolve("file://my.key") if err != nil { t.Fatalf("unexpected error: %v", err) } if got != "sk-valid" { t.Fatalf("got %q, want %q", got, "sk-valid") } } // TestEncrypt_SSHKeyOutsideAllowedDirs verifies that Encrypt rejects SSH key paths // that are not under PICOCLAW_SSH_KEY_PATH, PICOCLAW_HOME, or ~/.ssh/. func TestEncrypt_SSHKeyOutsideAllowedDirs(t *testing.T) { dir := t.TempDir() sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") if err := os.WriteFile(sshKeyPath, []byte("fake-key\n"), 0o600); err != nil { t.Fatalf("setup: %v", err) } // Make sure none of the allowed env vars point here. t.Setenv("PICOCLAW_SSH_KEY_PATH", "") t.Setenv("PICOCLAW_HOME", "") _, err := credential.Encrypt("passphrase", sshKeyPath, "sk-secret") if err == nil { t.Fatal("expected error for SSH key outside allowed directories, got nil") } } ================================================ FILE: pkg/credential/keygen.go ================================================ package credential import ( "crypto/ed25519" "crypto/rand" "encoding/pem" "fmt" "os" "path/filepath" "golang.org/x/crypto/ssh" ) // DefaultSSHKeyPath returns the canonical path for the picoclaw-specific SSH key. // The path is always ~/.ssh/picoclaw_ed25519.key (os.UserHomeDir is cross-platform). func DefaultSSHKeyPath() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("credential: cannot determine home directory: %w", err) } return filepath.Join(home, ".ssh", "picoclaw_ed25519.key"), nil } // GenerateSSHKey generates an Ed25519 SSH key pair and writes the private key // to path (permissions 0600) and the public key to path+".pub" (permissions 0644). // The ~/.ssh/ directory is created with 0700 if it does not exist. // If the files already exist they are overwritten. func GenerateSSHKey(path string) error { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return fmt.Errorf("credential: keygen: cannot create directory %q: %w", filepath.Dir(path), err) } pubRaw, privRaw, err := ed25519.GenerateKey(rand.Reader) if err != nil { return fmt.Errorf("credential: keygen: ed25519 key generation failed: %w", err) } // Marshal private key as OpenSSH PEM. block, err := ssh.MarshalPrivateKey(privRaw, "") if err != nil { return fmt.Errorf("credential: keygen: marshal private key: %w", err) } privPEM := pem.EncodeToMemory(block) if err = os.WriteFile(path, privPEM, 0o600); err != nil { return fmt.Errorf("credential: keygen: write private key %q: %w", path, err) } // Marshal public key as authorized_keys line. sshPub, err := ssh.NewPublicKey(pubRaw) if err != nil { return fmt.Errorf("credential: keygen: marshal public key: %w", err) } pubLine := ssh.MarshalAuthorizedKey(sshPub) pubPath := path + ".pub" if err := os.WriteFile(pubPath, pubLine, 0o644); err != nil { return fmt.Errorf("credential: keygen: write public key %q: %w", pubPath, err) } return nil } ================================================ FILE: pkg/credential/keygen_test.go ================================================ package credential import ( "crypto/ed25519" "os" "path/filepath" "runtime" "testing" "golang.org/x/crypto/ssh" ) func TestGenerateSSHKey_CreatesFiles(t *testing.T) { dir := t.TempDir() keyPath := filepath.Join(dir, "test_ed25519.key") if err := GenerateSSHKey(keyPath); err != nil { t.Fatalf("GenerateSSHKey() error = %v", err) } // Private key must exist. privInfo, err := os.Stat(keyPath) if err != nil { t.Fatalf("private key file missing: %v", err) } // Check permissions on non-Windows (Windows does not support Unix permission bits). if runtime.GOOS != "windows" { if got := privInfo.Mode().Perm(); got != 0o600 { t.Errorf("private key permissions = %04o, want 0600", got) } } // Public key must exist. pubPath := keyPath + ".pub" pubInfo, err := os.Stat(pubPath) if err != nil { t.Fatalf("public key file missing: %v", err) } if runtime.GOOS != "windows" { if got := pubInfo.Mode().Perm(); got != 0o644 { t.Errorf("public key permissions = %04o, want 0644", got) } } // Private key must be parseable as an OpenSSH ed25519 key. privPEM, err := os.ReadFile(keyPath) if err != nil { t.Fatalf("read private key: %v", err) } privKey, err := ssh.ParseRawPrivateKey(privPEM) if err != nil { t.Fatalf("parse private key: %v", err) } if _, ok := privKey.(*ed25519.PrivateKey); !ok { t.Errorf("private key type = %T, want *ed25519.PrivateKey", privKey) } // Public key must be parseable as authorized_keys line. pubBytes, err := os.ReadFile(pubPath) if err != nil { t.Fatalf("read public key: %v", err) } pubKey, _, _, rest, err := ssh.ParseAuthorizedKey(pubBytes) if err != nil { t.Fatalf("parse public key: %v", err) } if pubKey == nil { t.Fatal("expected non-nil public key") } if len(rest) > 0 { t.Errorf("unexpected trailing bytes after public key: %d bytes", len(rest)) } } func TestGenerateSSHKey_OverwritesExisting(t *testing.T) { dir := t.TempDir() keyPath := filepath.Join(dir, "test_ed25519.key") // Generate twice; second call must not error and must produce a different key. if err := GenerateSSHKey(keyPath); err != nil { t.Fatalf("first GenerateSSHKey() error = %v", err) } first, err := os.ReadFile(keyPath) if err != nil { t.Fatalf("read first key: %v", err) } if err = GenerateSSHKey(keyPath); err != nil { t.Fatalf("second GenerateSSHKey() error = %v", err) } second, err := os.ReadFile(keyPath) if err != nil { t.Fatalf("read second key: %v", err) } // Two independently generated Ed25519 keys must differ. if string(first) == string(second) { t.Error("expected overwritten key to differ from original") } } func TestGenerateSSHKey_CreatesDirectory(t *testing.T) { dir := t.TempDir() // Nested directory that does not yet exist. keyPath := filepath.Join(dir, "subdir", ".ssh", "picoclaw_ed25519.key") if err := GenerateSSHKey(keyPath); err != nil { t.Fatalf("GenerateSSHKey() error = %v", err) } if _, err := os.Stat(keyPath); err != nil { t.Fatalf("private key not created: %v", err) } } ================================================ FILE: pkg/credential/store.go ================================================ package credential import "sync/atomic" // SecureStore holds a passphrase in memory. // // Uses atomic.Pointer so reads and writes are lock-free. // The passphrase is never written to disk; callers decide how to // transport it outside this store (e.g., via cmd.Env or os.Environ). type SecureStore struct { val atomic.Pointer[string] } // NewSecureStore creates an empty SecureStore. func NewSecureStore() *SecureStore { return &SecureStore{} } // SetString stores the passphrase. An empty string clears the store. func (s *SecureStore) SetString(passphrase string) { if passphrase == "" { s.val.Store(nil) return } s.val.Store(&passphrase) } // Get returns the stored passphrase, or "" if not set. func (s *SecureStore) Get() string { if p := s.val.Load(); p != nil { return *p } return "" } // IsSet reports whether a passphrase is currently stored. func (s *SecureStore) IsSet() bool { return s.val.Load() != nil } // Clear removes the stored passphrase. func (s *SecureStore) Clear() { s.val.Store(nil) } ================================================ FILE: pkg/credential/store_test.go ================================================ package credential import ( "sync" "testing" ) func TestSecureStore_SetGet(t *testing.T) { s := NewSecureStore() if s.IsSet() { t.Error("expected empty store") } s.SetString("hunter2") if !s.IsSet() { t.Error("expected store to be set") } if got := s.Get(); got != "hunter2" { t.Errorf("Get() = %q, want %q", got, "hunter2") } } func TestSecureStore_Clear(t *testing.T) { s := NewSecureStore() s.SetString("secret") s.Clear() if s.IsSet() { t.Error("expected store to be empty after Clear()") } if got := s.Get(); got != "" { t.Errorf("Get() after Clear() = %q, want empty", got) } } func TestSecureStore_SetOverwrites(t *testing.T) { s := NewSecureStore() s.SetString("first") s.SetString("second") if got := s.Get(); got != "second" { t.Errorf("Get() = %q, want %q", got, "second") } } func TestSecureStore_EmptyPassphrase(t *testing.T) { s := NewSecureStore() s.SetString("") // empty → should not mark as set if s.IsSet() { t.Error("empty passphrase should not mark store as set") } } func TestSecureStore_ConcurrentSetGet(t *testing.T) { s := NewSecureStore() const goroutines = 10 const iterations = 1000 var wg sync.WaitGroup wg.Add(goroutines) for i := 0; i < goroutines; i++ { go func(id int) { defer wg.Done() for j := 0; j < iterations; j++ { if id%2 == 0 { s.SetString("even") } else { s.SetString("odd") } _ = s.Get() } }(i) } wg.Wait() final := s.Get() if final != "" && final != "even" && final != "odd" { t.Errorf("Get() returned unexpected value %q after concurrent Set/Get", final) } } ================================================ FILE: pkg/cron/service.go ================================================ package cron import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "log" "os" "sync" "time" "github.com/adhocore/gronx" "github.com/sipeed/picoclaw/pkg/fileutil" ) type CronSchedule struct { Kind string `json:"kind"` AtMS *int64 `json:"atMs,omitempty"` EveryMS *int64 `json:"everyMs,omitempty"` Expr string `json:"expr,omitempty"` TZ string `json:"tz,omitempty"` } type CronPayload struct { Kind string `json:"kind"` Message string `json:"message"` Command string `json:"command,omitempty"` Deliver bool `json:"deliver"` Channel string `json:"channel,omitempty"` To string `json:"to,omitempty"` } type CronJobState struct { NextRunAtMS *int64 `json:"nextRunAtMs,omitempty"` LastRunAtMS *int64 `json:"lastRunAtMs,omitempty"` LastStatus string `json:"lastStatus,omitempty"` LastError string `json:"lastError,omitempty"` } type CronJob struct { ID string `json:"id"` Name string `json:"name"` Enabled bool `json:"enabled"` Schedule CronSchedule `json:"schedule"` Payload CronPayload `json:"payload"` State CronJobState `json:"state"` CreatedAtMS int64 `json:"createdAtMs"` UpdatedAtMS int64 `json:"updatedAtMs"` DeleteAfterRun bool `json:"deleteAfterRun"` } type CronStore struct { Version int `json:"version"` Jobs []CronJob `json:"jobs"` } type JobHandler func(job *CronJob) (string, error) type CronService struct { storePath string store *CronStore onJob JobHandler mu sync.RWMutex running bool stopChan chan struct{} wakeChan chan struct{} gronx *gronx.Gronx } func NewCronService(storePath string, onJob JobHandler) *CronService { cs := &CronService{ storePath: storePath, onJob: onJob, gronx: gronx.New(), wakeChan: make(chan struct{}), } // Initialize and load store on creation cs.loadStore() return cs } func (cs *CronService) Start() error { cs.mu.Lock() defer cs.mu.Unlock() if cs.running { return nil } if err := cs.loadStore(); err != nil { return fmt.Errorf("failed to load store: %w", err) } cs.recomputeNextRuns() if err := cs.saveStoreUnsafe(); err != nil { return fmt.Errorf("failed to save store: %w", err) } cs.stopChan = make(chan struct{}) if cs.wakeChan == nil { cs.wakeChan = make(chan struct{}) } cs.running = true go cs.runLoop(cs.stopChan) return nil } func (cs *CronService) Stop() { cs.mu.Lock() defer cs.mu.Unlock() if !cs.running { return } cs.running = false if cs.stopChan != nil { close(cs.stopChan) cs.stopChan = nil } } func (cs *CronService) runLoop(stopChan chan struct{}) { timer := time.NewTimer(time.Hour) if !timer.Stop() { <-timer.C } defer timer.Stop() for { // every loop, recalculate the next wake time cs.mu.RLock() nextWake := cs.getNextWakeMS() cs.mu.RUnlock() var delay time.Duration now := time.Now().UnixMilli() if nextWake == nil { // no jobs, sleep for a long time (or until a new job is added) delay = time.Hour } else { diff := *nextWake - now if diff <= 0 { delay = 0 } else { delay = time.Duration(diff) * time.Millisecond } } timer.Reset(delay) select { case <-stopChan: return case <-cs.wakeChan: // wake on new job or update if !timer.Stop() { select { case <-timer.C: default: } } continue case <-timer.C: cs.checkJobs() } } } func (cs *CronService) checkJobs() { cs.mu.Lock() if !cs.running { cs.mu.Unlock() return } now := time.Now().UnixMilli() var dueJobIDs []string // Collect jobs that are due (we need to copy them to execute outside lock) for i := range cs.store.Jobs { job := &cs.store.Jobs[i] if job.Enabled && job.State.NextRunAtMS != nil && *job.State.NextRunAtMS <= now { dueJobIDs = append(dueJobIDs, job.ID) } } // Reset next run for due jobs before unlocking to avoid duplicate execution. dueMap := make(map[string]bool, len(dueJobIDs)) for _, jobID := range dueJobIDs { dueMap[jobID] = true } for i := range cs.store.Jobs { if dueMap[cs.store.Jobs[i].ID] { cs.store.Jobs[i].State.NextRunAtMS = nil } } if err := cs.saveStoreUnsafe(); err != nil { log.Printf("[cron] failed to save store: %v", err) } cs.mu.Unlock() // Execute jobs outside lock. for _, jobID := range dueJobIDs { cs.executeJobByID(jobID) } } func (cs *CronService) executeJobByID(jobID string) { startTime := time.Now().UnixMilli() cs.mu.RLock() var callbackJob *CronJob for i := range cs.store.Jobs { job := &cs.store.Jobs[i] if job.ID == jobID { jobCopy := *job callbackJob = &jobCopy break } } cs.mu.RUnlock() if callbackJob == nil { log.Printf("[cron] job %s not found, skipping", jobID) return } // Log job execution start log.Printf("[cron] ▶ executing job '%s' (id: %s, schedule: %s, channel: %s)", callbackJob.Name, jobID, callbackJob.Schedule.Kind, callbackJob.Payload.Channel) var err error if cs.onJob != nil { _, err = cs.onJob(callbackJob) } execDuration := time.Now().UnixMilli() - startTime // Now acquire lock to update state cs.mu.Lock() defer cs.mu.Unlock() var job *CronJob for i := range cs.store.Jobs { if cs.store.Jobs[i].ID == jobID { job = &cs.store.Jobs[i] break } } if job == nil { log.Printf("[cron] job %s disappeared before state update", jobID) return } job.State.LastRunAtMS = &startTime job.UpdatedAtMS = time.Now().UnixMilli() if err != nil { job.State.LastStatus = "error" job.State.LastError = err.Error() log.Printf("[cron] ✗ job '%s' failed after %dms: %v", job.Name, execDuration, err) } else { job.State.LastStatus = "ok" job.State.LastError = "" } // Compute next run time var nextRunStr string if job.Schedule.Kind == "at" { if job.DeleteAfterRun { cs.removeJobUnsafe(job.ID) nextRunStr = "(deleted)" } else { job.Enabled = false job.State.NextRunAtMS = nil nextRunStr = "(disabled)" } } else { nextRun := cs.computeNextRun(&job.Schedule, time.Now().UnixMilli()) job.State.NextRunAtMS = nextRun if nextRun != nil { nextRunStr = time.UnixMilli(*nextRun).Format("2006-01-02 15:04:05") } else { nextRunStr = "(none)" } } if err == nil { log.Printf("[cron] ✓ job '%s' completed in %dms, next run: %s", job.Name, execDuration, nextRunStr) } if err := cs.saveStoreUnsafe(); err != nil { log.Printf("[cron] failed to save store: %v", err) } } func (cs *CronService) computeNextRun(schedule *CronSchedule, nowMS int64) *int64 { switch schedule.Kind { case "at": if schedule.AtMS != nil && *schedule.AtMS > nowMS { return schedule.AtMS } return nil case "every": if schedule.EveryMS == nil || *schedule.EveryMS <= 0 { return nil } next := nowMS + *schedule.EveryMS return &next case "cron": if schedule.Expr == "" { return nil } // Use gronx to calculate next run time now := time.UnixMilli(nowMS) nextTime, err := gronx.NextTickAfter(schedule.Expr, now, false) if err != nil { log.Printf("[cron] failed to compute next run for expr '%s': %v", schedule.Expr, err) return nil } nextMS := nextTime.UnixMilli() return &nextMS default: log.Printf("[cron] unknown schedule kind '%s'", schedule.Kind) return nil } } // wake up the loop to re-evaluate next wake time immediately (e.g. after add/update/remove jobs) func (cs *CronService) notify() { select { case cs.wakeChan <- struct{}{}: default: // if the channel is full, it means the loop will wake up soon anyway, so we can skip sending } } func (cs *CronService) recomputeNextRuns() { now := time.Now().UnixMilli() for i := range cs.store.Jobs { job := &cs.store.Jobs[i] if job.Enabled { job.State.NextRunAtMS = cs.computeNextRun(&job.Schedule, now) } } } func (cs *CronService) getNextWakeMS() *int64 { var nextWake *int64 for _, job := range cs.store.Jobs { if job.Enabled && job.State.NextRunAtMS != nil { if nextWake == nil || *job.State.NextRunAtMS < *nextWake { nextWake = job.State.NextRunAtMS } } } return nextWake } func (cs *CronService) Load() error { cs.mu.Lock() defer cs.mu.Unlock() return cs.loadStore() } func (cs *CronService) SetOnJob(handler JobHandler) { cs.mu.Lock() defer cs.mu.Unlock() cs.onJob = handler } func (cs *CronService) loadStore() error { cs.store = &CronStore{ Version: 1, Jobs: []CronJob{}, } data, err := os.ReadFile(cs.storePath) if err != nil { if os.IsNotExist(err) { return nil } return err } return json.Unmarshal(data, cs.store) } func (cs *CronService) saveStoreUnsafe() error { data, err := json.MarshalIndent(cs.store, "", " ") if err != nil { return err } // Use unified atomic write utility with explicit sync for flash storage reliability. return fileutil.WriteFileAtomic(cs.storePath, data, 0o600) } func (cs *CronService) AddJob( name string, schedule CronSchedule, message string, deliver bool, channel, to string, ) (*CronJob, error) { cs.mu.Lock() defer cs.mu.Unlock() now := time.Now().UnixMilli() // One-time tasks (at) should be deleted after execution deleteAfterRun := (schedule.Kind == "at") job := CronJob{ ID: generateID(), Name: name, Enabled: true, Schedule: schedule, Payload: CronPayload{ Kind: "agent_turn", Message: message, Deliver: deliver, Channel: channel, To: to, }, State: CronJobState{ NextRunAtMS: cs.computeNextRun(&schedule, now), }, CreatedAtMS: now, UpdatedAtMS: now, DeleteAfterRun: deleteAfterRun, } cs.store.Jobs = append(cs.store.Jobs, job) if err := cs.saveStoreUnsafe(); err != nil { return nil, err } cs.notify() return &job, nil } func (cs *CronService) UpdateJob(job *CronJob) error { cs.mu.Lock() defer cs.mu.Unlock() for i := range cs.store.Jobs { if cs.store.Jobs[i].ID == job.ID { cs.store.Jobs[i] = *job cs.store.Jobs[i].UpdatedAtMS = time.Now().UnixMilli() cs.notify() return cs.saveStoreUnsafe() } } return fmt.Errorf("job not found") } func (cs *CronService) RemoveJob(jobID string) bool { cs.mu.Lock() defer cs.mu.Unlock() return cs.removeJobUnsafe(jobID) } func (cs *CronService) removeJobUnsafe(jobID string) bool { before := len(cs.store.Jobs) var jobs []CronJob for _, job := range cs.store.Jobs { if job.ID != jobID { jobs = append(jobs, job) } } cs.store.Jobs = jobs removed := len(cs.store.Jobs) < before if removed { if err := cs.saveStoreUnsafe(); err != nil { log.Printf("[cron] failed to save store after remove: %v", err) } } cs.notify() return removed } func (cs *CronService) EnableJob(jobID string, enabled bool) *CronJob { cs.mu.Lock() defer cs.mu.Unlock() for i := range cs.store.Jobs { job := &cs.store.Jobs[i] if job.ID == jobID { job.Enabled = enabled job.UpdatedAtMS = time.Now().UnixMilli() if enabled { job.State.NextRunAtMS = cs.computeNextRun(&job.Schedule, time.Now().UnixMilli()) } else { job.State.NextRunAtMS = nil } if err := cs.saveStoreUnsafe(); err != nil { log.Printf("[cron] failed to save store after enable: %v", err) } cs.notify() return job } } return nil } func (cs *CronService) ListJobs(includeDisabled bool) []CronJob { cs.mu.RLock() defer cs.mu.RUnlock() if includeDisabled { return cs.store.Jobs } var enabled []CronJob for _, job := range cs.store.Jobs { if job.Enabled { enabled = append(enabled, job) } } return enabled } func (cs *CronService) Status() map[string]any { cs.mu.RLock() defer cs.mu.RUnlock() var enabledCount int for _, job := range cs.store.Jobs { if job.Enabled { enabledCount++ } } return map[string]any{ "enabled": cs.running, "jobs": len(cs.store.Jobs), "nextWakeAtMS": cs.getNextWakeMS(), } } func generateID() string { // Use crypto/rand for better uniqueness under concurrent access b := make([]byte, 8) if _, err := rand.Read(b); err != nil { // Fallback to time-based if crypto/rand fails return fmt.Sprintf("%d", time.Now().UnixNano()) } return hex.EncodeToString(b) } ================================================ FILE: pkg/cron/service_test.go ================================================ package cron import ( "fmt" "os" "path/filepath" "runtime" "sync" "testing" "time" ) func TestSaveStore_FilePermissions(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("file permission bits are not enforced on Windows") } tmpDir := t.TempDir() storePath := filepath.Join(tmpDir, "cron", "jobs.json") cs := NewCronService(storePath, nil) _, err := cs.AddJob("test", CronSchedule{Kind: "every", EveryMS: int64Ptr(60000)}, "hello", false, "cli", "direct") if err != nil { t.Fatalf("AddJob failed: %v", err) } info, err := os.Stat(storePath) if err != nil { t.Fatalf("Stat failed: %v", err) } perm := info.Mode().Perm() if perm != 0o600 { t.Errorf("cron store has permission %04o, want 0600", perm) } } func int64Ptr(v int64) *int64 { return &v } func setupService(handler JobHandler) (*CronService, string) { tmpFile := fmt.Sprintf("test_cron_%d.json", time.Now().UnixNano()) cs := NewCronService(tmpFile, handler) return cs, tmpFile } func TestCronService_CRUD(t *testing.T) { cs, path := setupService(nil) defer os.Remove(path) // Test AddJob at := time.Now().Add(time.Hour).UnixMilli() job, err := cs.AddJob("Task1", CronSchedule{Kind: "at", AtMS: &at}, "msg", true, "ch", "to") if err != nil || job.ID == "" { t.Fatalf("AddJob failed: %v", err) } // Test ListJobs if len(cs.ListJobs(true)) != 1 { t.Error("ListJobs should return 1 job") } // Test UpdateJob job.Name = "UpdatedName" err = cs.UpdateJob(job) if err != nil || cs.store.Jobs[0].Name != "UpdatedName" { t.Error("UpdateJob failed") } // Test EnableJob cs.EnableJob(job.ID, false) if cs.store.Jobs[0].Enabled != false || cs.store.Jobs[0].State.NextRunAtMS != nil { t.Error("EnableJob(false) failed to clear state") } // Test RemoveJob removed := cs.RemoveJob(job.ID) if !removed || len(cs.store.Jobs) != 0 { t.Error("RemoveJob failed") } } // 2. Test Cron Expression Calculation Logic func TestCronService_ComputeNextRun(t *testing.T) { cs, path := setupService(nil) defer os.Remove(path) now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC).UnixMilli() tests := []struct { name string schedule CronSchedule wantNil bool }{ {"Valid Cron", CronSchedule{Kind: "cron", Expr: "0 * * * *"}, false}, {"Invalid Cron", CronSchedule{Kind: "cron", Expr: "invalid"}, true}, {"Every MS", CronSchedule{Kind: "every", EveryMS: int64Ptr(5000)}, false}, {"At Future", CronSchedule{Kind: "at", AtMS: int64Ptr(now + 1000)}, false}, {"At Past", CronSchedule{Kind: "at", AtMS: int64Ptr(now - 1000)}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := cs.computeNextRun(&tt.schedule, now) if (got == nil) != tt.wantNil { t.Errorf("%s: got %v, wantNil %v", tt.name, got, tt.wantNil) } }) } } // 3. Test Execution Flow func TestCronService_ExecutionFlow(t *testing.T) { var mu sync.Mutex executedJobs := make(map[string]bool) handler := func(job *CronJob) (string, error) { mu.Lock() executedJobs[job.ID] = true mu.Unlock() return "ok", nil } cs, path := setupService(handler) defer os.Remove(path) // Start the service if err := cs.Start(); err != nil { t.Fatalf("Start failed: %v", err) } defer cs.Stop() // Add a job then runs 100ms from now target := time.Now().Add(100 * time.Millisecond).UnixMilli() job, _ := cs.AddJob("FastJob", CronSchedule{Kind: "at", AtMS: &target}, "", false, "", "") // Check for job execution with a timeout success := false for range 20 { mu.Lock() if executedJobs[job.ID] { success = true mu.Unlock() break } mu.Unlock() time.Sleep(100 * time.Millisecond) } if !success { t.Error("Job was not executed in time") } // check that the job is removed after execution (DeleteAfterRun = true) status := cs.Status() if status["jobs"].(int) != 0 { t.Errorf("Job should be deleted after run, got count: %v", status["jobs"]) } } func TestCronService_PersistenceIntegrity(t *testing.T) { tmpFile := "persist_test.json" defer os.Remove(tmpFile) // write a job and persist cs1 := NewCronService(tmpFile, nil) at := int64(2000000000000) cs1.AddJob("PersistMe", CronSchedule{Kind: "at", AtMS: &at}, "payload", true, "ch1", "") // check file exists if _, err := os.Stat(tmpFile); os.IsNotExist(err) { t.Fatal("Store file was not created") } // reload and check data integrity cs2 := NewCronService(tmpFile, nil) if err := cs2.Load(); err != nil { t.Fatalf("Failed to load store: %v", err) } jobs := cs2.ListJobs(true) if len(jobs) != 1 || jobs[0].Name != "PersistMe" { t.Errorf("Data corruption after reload. Got: %+v", jobs) } // test loading invalid JSON os.WriteFile(tmpFile, []byte("{invalid json}"), 0o644) cs3 := NewCronService(tmpFile, nil) err := cs3.loadStore() if err == nil { t.Error("Should return error when loading invalid JSON") } } func TestCronService_ConcurrentAccess(t *testing.T) { cs, path := setupService(nil) defer os.Remove(path) cs.Start() defer cs.Stop() var wg sync.WaitGroup workers := 10 iterations := 50 wg.Add(workers * 2) // add jobs concurrently for i := range workers { go func(id int) { defer wg.Done() for j := range iterations { at := time.Now().Add(time.Hour).UnixMilli() cs.AddJob(fmt.Sprintf("Job-%d-%d", id, j), CronSchedule{Kind: "at", AtMS: &at}, "", false, "", "") time.Sleep(100 * time.Microsecond) } }(i) } // read and update jobs concurrently for range workers { go func() { defer wg.Done() for j := range iterations { jobs := cs.ListJobs(true) if len(jobs) > 0 { cs.EnableJob(jobs[0].ID, j%2 == 0) } time.Sleep(100 * time.Microsecond) } }() } wg.Wait() } ================================================ FILE: pkg/devices/events/events.go ================================================ package events import "context" type EventSource interface { Kind() Kind Start(ctx context.Context) (<-chan *DeviceEvent, error) Stop() error } type Action string const ( ActionAdd Action = "add" ActionRemove Action = "remove" ActionChange Action = "change" ) type Kind string const ( KindUSB Kind = "usb" KindBluetooth Kind = "bluetooth" KindPCI Kind = "pci" KindGeneric Kind = "generic" ) type DeviceEvent struct { Action Action Kind Kind DeviceID string // e.g. "1-2" for USB bus 1 dev 2 Vendor string // Vendor name or ID Product string // Product name or ID Serial string // Serial number if available Capabilities string // Human-readable capability description Raw map[string]string // Raw properties for extensibility } func (e *DeviceEvent) FormatMessage() string { actionEmoji := "🔌" actionText := "Connected" if e.Action == ActionRemove { actionEmoji = "🔌" actionText = "Disconnected" } msg := actionEmoji + " Device " + actionText + "\n\n" msg += "Type: " + string(e.Kind) + "\n" msg += "Device: " + e.Vendor + " " + e.Product + "\n" if e.Capabilities != "" { msg += "Capabilities: " + e.Capabilities + "\n" } if e.Serial != "" { msg += "Serial: " + e.Serial + "\n" } return msg } ================================================ FILE: pkg/devices/service.go ================================================ package devices import ( "context" "strings" "sync" "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/devices/events" "github.com/sipeed/picoclaw/pkg/devices/sources" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/state" ) type Service struct { bus *bus.MessageBus state *state.Manager sources []events.EventSource enabled bool ctx context.Context cancel context.CancelFunc mu sync.RWMutex } type Config struct { Enabled bool MonitorUSB bool // When true, monitor USB hotplug (Linux only) // Future: MonitorBluetooth, MonitorPCI, etc. } func NewService(cfg Config, stateMgr *state.Manager) *Service { s := &Service{ state: stateMgr, enabled: cfg.Enabled, sources: make([]EventSource, 0), } if cfg.Enabled && cfg.MonitorUSB { s.sources = append(s.sources, sources.NewUSBMonitor()) } return s } func (s *Service) SetBus(msgBus *bus.MessageBus) { s.mu.Lock() defer s.mu.Unlock() s.bus = msgBus } func (s *Service) Start(ctx context.Context) error { s.mu.Lock() defer s.mu.Unlock() if !s.enabled || len(s.sources) == 0 { logger.InfoC("devices", "Device event service disabled or no sources") return nil } s.ctx, s.cancel = context.WithCancel(ctx) for _, src := range s.sources { eventCh, err := src.Start(s.ctx) if err != nil { logger.ErrorCF("devices", "Failed to start source", map[string]any{ "kind": src.Kind(), "error": err.Error(), }) continue } go s.handleEvents(src.Kind(), eventCh) logger.InfoCF("devices", "Device source started", map[string]any{ "kind": src.Kind(), }) } logger.InfoC("devices", "Device event service started") return nil } func (s *Service) Stop() { s.mu.Lock() defer s.mu.Unlock() if s.cancel != nil { s.cancel() s.cancel = nil } for _, src := range s.sources { src.Stop() } logger.InfoC("devices", "Device event service stopped") } func (s *Service) handleEvents(kind events.Kind, eventCh <-chan *events.DeviceEvent) { for ev := range eventCh { if ev == nil { continue } s.sendNotification(ev) } } func (s *Service) sendNotification(ev *events.DeviceEvent) { s.mu.RLock() msgBus := s.bus s.mu.RUnlock() if msgBus == nil { return } lastChannel := s.state.GetLastChannel() if lastChannel == "" { logger.DebugCF("devices", "No last channel, skipping notification", map[string]any{ "event": ev.FormatMessage(), }) return } platform, userID := parseLastChannel(lastChannel) if platform == "" || userID == "" || constants.IsInternalChannel(platform) { return } msg := ev.FormatMessage() pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) defer pubCancel() msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: platform, ChatID: userID, Content: msg, }) logger.InfoCF("devices", "Device notification sent", map[string]any{ "kind": ev.Kind, "action": ev.Action, "to": platform, }) } func parseLastChannel(lastChannel string) (platform, userID string) { if lastChannel == "" { return "", "" } parts := strings.SplitN(lastChannel, ":", 2) if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return "", "" } return parts[0], parts[1] } ================================================ FILE: pkg/devices/source.go ================================================ package devices import "github.com/sipeed/picoclaw/pkg/devices/events" type EventSource = events.EventSource ================================================ FILE: pkg/devices/sources/usb_linux.go ================================================ //go:build linux package sources import ( "bufio" "context" "fmt" "os/exec" "strings" "sync" "github.com/sipeed/picoclaw/pkg/devices/events" "github.com/sipeed/picoclaw/pkg/logger" ) var usbClassToCapability = map[string]string{ "00": "Interface Definition (by interface)", "01": "Audio", "02": "CDC Communication (Network Card/Modem)", "03": "HID (Keyboard/Mouse/Gamepad)", "05": "Physical Interface", "06": "Image (Scanner/Camera)", "07": "Printer", "08": "Mass Storage (USB Flash Drive/Hard Disk)", "09": "USB Hub", "0a": "CDC Data", "0b": "Smart Card", "0e": "Video (Camera)", "dc": "Diagnostic Device", "e0": "Wireless Controller (Bluetooth)", "ef": "Miscellaneous", "fe": "Application Specific", "ff": "Vendor Specific", } type USBMonitor struct { cmd *exec.Cmd mu sync.Mutex } func NewUSBMonitor() *USBMonitor { return &USBMonitor{} } func (m *USBMonitor) Kind() events.Kind { return events.KindUSB } func (m *USBMonitor) Start(ctx context.Context) (<-chan *events.DeviceEvent, error) { m.mu.Lock() defer m.mu.Unlock() // udevadm monitor outputs: UDEV/KERNEL [timestamp] action devpath (subsystem) // Followed by KEY=value lines, empty line separates events // Use -s/--subsystem-match (eudev) or --udev-subsystem-match (systemd udev) cmd := exec.CommandContext(ctx, "udevadm", "monitor", "--property", "--subsystem-match=usb") stdout, err := cmd.StdoutPipe() if err != nil { return nil, fmt.Errorf("udevadm stdout pipe: %w", err) } if err := cmd.Start(); err != nil { return nil, fmt.Errorf("udevadm start: %w (is udevadm installed?)", err) } m.cmd = cmd eventCh := make(chan *events.DeviceEvent, 16) go func() { defer close(eventCh) scanner := bufio.NewScanner(stdout) var props map[string]string var action string isUdev := false // Only UDEV events have complete info (ID_VENDOR, ID_MODEL); KERNEL events come first with less info for scanner.Scan() { line := scanner.Text() if line == "" { // End of event block - only process UDEV events (skip KERNEL to avoid duplicate/incomplete notifications) if isUdev && props != nil && (action == "add" || action == "remove") { if ev := parseUSBEvent(action, props); ev != nil { select { case eventCh <- ev: case <-ctx.Done(): return } } } props = nil action = "" isUdev = false continue } idx := strings.Index(line, "=") // First line of block: "UDEV [ts] action devpath" or "KERNEL[ts] action devpath" - no KEY=value if idx <= 0 { isUdev = strings.HasPrefix(strings.TrimSpace(line), "UDEV") continue } // Parse KEY=value key := line[:idx] val := line[idx+1:] if props == nil { props = make(map[string]string) } props[key] = val if key == "ACTION" { action = val } } if err := scanner.Err(); err != nil { logger.ErrorCF("devices", "udevadm scan error", map[string]any{"error": err.Error()}) } cmd.Wait() }() return eventCh, nil } func (m *USBMonitor) Stop() error { m.mu.Lock() defer m.mu.Unlock() if m.cmd != nil && m.cmd.Process != nil { m.cmd.Process.Kill() m.cmd = nil } return nil } func parseUSBEvent(action string, props map[string]string) *events.DeviceEvent { // Only care about add/remove for physical devices (not interfaces) subsystem := props["SUBSYSTEM"] if subsystem != "usb" { return nil } // Skip interface events - we want device-level only to avoid duplicates devType := props["DEVTYPE"] if devType == "usb_interface" { return nil } // Prefer usb_device, but accept if DEVTYPE not set (varies by udev version) if devType != "" && devType != "usb_device" { return nil } ev := &events.DeviceEvent{ Raw: props, } switch action { case "add": ev.Action = events.ActionAdd case "remove": ev.Action = events.ActionRemove default: return nil } ev.Kind = events.KindUSB ev.Vendor = props["ID_VENDOR"] if ev.Vendor == "" { ev.Vendor = props["ID_VENDOR_ID"] } if ev.Vendor == "" { ev.Vendor = "Unknown Vendor" } ev.Product = props["ID_MODEL"] if ev.Product == "" { ev.Product = props["ID_MODEL_ID"] } if ev.Product == "" { ev.Product = "Unknown Device" } ev.Serial = props["ID_SERIAL_SHORT"] ev.DeviceID = props["DEVPATH"] if bus := props["BUSNUM"]; bus != "" { if dev := props["DEVNUM"]; dev != "" { ev.DeviceID = bus + ":" + dev } } // Map USB class to capability if class := props["ID_USB_CLASS"]; class != "" { ev.Capabilities = usbClassToCapability[strings.ToLower(class)] } if ev.Capabilities == "" { ev.Capabilities = "USB Device" } return ev } ================================================ FILE: pkg/devices/sources/usb_stub.go ================================================ //go:build !linux package sources import ( "context" "github.com/sipeed/picoclaw/pkg/devices/events" ) type USBMonitor struct{} func NewUSBMonitor() *USBMonitor { return &USBMonitor{} } func (m *USBMonitor) Kind() events.Kind { return events.KindUSB } func (m *USBMonitor) Start(ctx context.Context) (<-chan *events.DeviceEvent, error) { ch := make(chan *events.DeviceEvent) close(ch) // Immediately close, no events return ch, nil } func (m *USBMonitor) Stop() error { return nil } ================================================ FILE: pkg/fileutil/file.go ================================================ // PicoClaw - Ultra-lightweight personal AI agent // Inspired by and based on nanobot: https://github.com/HKUDS/nanobot // License: MIT // // Copyright (c) 2026 PicoClaw contributors // Package fileutil provides file manipulation utilities. package fileutil import ( "fmt" "os" "path/filepath" "time" ) // WriteFileAtomic atomically writes data to a file using a temp file + rename pattern. // // This guarantees that the target file is either: // - Completely written with the new data // - Unchanged (if any step fails before rename) // // The function: // 1. Creates a temp file in the same directory (original untouched) // 2. Writes data to temp file // 3. Syncs data to disk (critical for SD cards/flash storage) // 4. Sets file permissions // 5. Syncs directory metadata (ensures rename is durable) // 6. Atomically renames temp file to target path // // Safety guarantees: // - Original file is NEVER modified until successful rename // - Temp file is always cleaned up on error // - Data is flushed to physical storage before rename // - Directory entry is synced to prevent orphaned inodes // // Parameters: // - path: Target file path // - data: Data to write // - perm: File permission mode (e.g., 0o600 for secure, 0o644 for readable) // // Returns: // - Error if any step fails, nil on success // // Example: // // // Secure config file (owner read/write only) // err := utils.WriteFileAtomic("config.json", data, 0o600) // // // Public readable file // err := utils.WriteFileAtomic("public.txt", data, 0o644) func WriteFileAtomic(path string, data []byte, perm os.FileMode) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("failed to create directory: %w", err) } // Create temp file in the same directory (ensures atomic rename works) // Using a hidden prefix (.tmp-) to avoid issues with some tools tmpFile, err := os.OpenFile( filepath.Join(dir, fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano())), os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm, ) if err != nil { return fmt.Errorf("failed to create temp file: %w", err) } tmpPath := tmpFile.Name() cleanup := true defer func() { if cleanup { tmpFile.Close() _ = os.Remove(tmpPath) } }() // Write data to temp file // Note: Original file is untouched at this point if _, err := tmpFile.Write(data); err != nil { return fmt.Errorf("failed to write temp file: %w", err) } // CRITICAL: Force sync to storage medium before any other operations. // This ensures data is physically written to disk, not just cached. // Essential for SD cards, eMMC, and other flash storage on edge devices. if err := tmpFile.Sync(); err != nil { return fmt.Errorf("failed to sync temp file: %w", err) } // Set file permissions before closing if err := tmpFile.Chmod(perm); err != nil { return fmt.Errorf("failed to set permissions: %w", err) } // Close file before rename (required on Windows) if err := tmpFile.Close(); err != nil { return fmt.Errorf("failed to close temp file: %w", err) } // Atomic rename: temp file becomes the target // On POSIX: rename() is atomic // On Windows: Rename() is atomic for files if err := os.Rename(tmpPath, path); err != nil { return fmt.Errorf("failed to rename temp file: %w", err) } // Sync directory to ensure rename is durable // This prevents the renamed file from disappearing after a crash if dirFile, err := os.Open(dir); err == nil { _ = dirFile.Sync() dirFile.Close() } // Success: skip cleanup (file was renamed, no temp to remove) cleanup = false return nil } ================================================ FILE: pkg/gateway/gateway.go ================================================ package gateway import ( "context" "fmt" "os" "os/signal" "path/filepath" "sync" "sync/atomic" "syscall" "time" "github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" _ "github.com/sipeed/picoclaw/pkg/channels/dingtalk" _ "github.com/sipeed/picoclaw/pkg/channels/discord" _ "github.com/sipeed/picoclaw/pkg/channels/feishu" _ "github.com/sipeed/picoclaw/pkg/channels/irc" _ "github.com/sipeed/picoclaw/pkg/channels/line" _ "github.com/sipeed/picoclaw/pkg/channels/maixcam" _ "github.com/sipeed/picoclaw/pkg/channels/matrix" _ "github.com/sipeed/picoclaw/pkg/channels/onebot" _ "github.com/sipeed/picoclaw/pkg/channels/pico" _ "github.com/sipeed/picoclaw/pkg/channels/qq" _ "github.com/sipeed/picoclaw/pkg/channels/slack" _ "github.com/sipeed/picoclaw/pkg/channels/telegram" _ "github.com/sipeed/picoclaw/pkg/channels/wecom" _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp" _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/cron" "github.com/sipeed/picoclaw/pkg/devices" "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/heartbeat" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/voice" ) const ( serviceShutdownTimeout = 30 * time.Second providerReloadTimeout = 30 * time.Second gracefulShutdownTimeout = 15 * time.Second ) type services struct { CronService *cron.CronService HeartbeatService *heartbeat.HeartbeatService MediaStore media.MediaStore ChannelManager *channels.Manager DeviceService *devices.Service HealthServer *health.Server manualReloadChan chan struct{} reloading atomic.Bool } type startupBlockedProvider struct { reason string } func (p *startupBlockedProvider) Chat( _ context.Context, _ []providers.Message, _ []providers.ToolDefinition, _ string, _ map[string]any, ) (*providers.LLMResponse, error) { return nil, fmt.Errorf("%s", p.reason) } func (p *startupBlockedProvider) GetDefaultModel() string { return "" } // Run starts the gateway runtime using the configuration loaded from configPath. func Run(debug bool, configPath string, allowEmptyStartup bool) error { if debug { logger.SetLevel(logger.DEBUG) fmt.Println("🔍 Debug mode enabled") } cfg, err := config.LoadConfig(configPath) if err != nil { return fmt.Errorf("error loading config: %w", err) } provider, modelID, err := createStartupProvider(cfg, allowEmptyStartup) if err != nil { return fmt.Errorf("error creating provider: %w", err) } if modelID != "" { cfg.Agents.Defaults.ModelName = modelID } msgBus := bus.NewMessageBus() agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) fmt.Println("\n📦 Agent Status:") startupInfo := agentLoop.GetStartupInfo() toolsInfo := startupInfo["tools"].(map[string]any) skillsInfo := startupInfo["skills"].(map[string]any) fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"]) fmt.Printf(" • Skills: %d/%d available\n", skillsInfo["available"], skillsInfo["total"]) logger.InfoCF("agent", "Agent initialized", map[string]any{ "tools_count": toolsInfo["count"], "skills_total": skillsInfo["total"], "skills_available": skillsInfo["available"], }) runningServices, err := setupAndStartServices(cfg, agentLoop, msgBus) if err != nil { return err } // Setup manual reload channel for /reload endpoint manualReloadChan := make(chan struct{}, 1) runningServices.manualReloadChan = manualReloadChan reloadTrigger := func() error { if !runningServices.reloading.CompareAndSwap(false, true) { return fmt.Errorf("reload already in progress") } select { case manualReloadChan <- struct{}{}: return nil default: // Should not happen, but reset flag if channel is full runningServices.reloading.Store(false) return fmt.Errorf("reload already queued") } } runningServices.HealthServer.SetReloadFunc(reloadTrigger) agentLoop.SetReloadFunc(reloadTrigger) fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) fmt.Println("Press Ctrl+C to stop") ctx, cancel := context.WithCancel(context.Background()) defer cancel() go agentLoop.Run(ctx) var configReloadChan <-chan *config.Config stopWatch := func() {} if cfg.Gateway.HotReload { configReloadChan, stopWatch = setupConfigWatcherPolling(configPath, debug) logger.Info("Config hot reload enabled") } defer stopWatch() sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) for { select { case <-sigChan: logger.Info("Shutting down...") shutdownGateway(runningServices, agentLoop, provider, true) return nil case newCfg := <-configReloadChan: if !runningServices.reloading.CompareAndSwap(false, true) { logger.Warn("Config reload skipped: another reload is in progress") continue } err := executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup) if err != nil { logger.Errorf("Config reload failed: %v", err) } case <-manualReloadChan: logger.Info("Manual reload triggered via /reload endpoint") newCfg, err := config.LoadConfig(configPath) if err != nil { logger.Errorf("Error loading config for manual reload: %v", err) runningServices.reloading.Store(false) continue } if err = newCfg.ValidateModelList(); err != nil { logger.Errorf("Config validation failed: %v", err) runningServices.reloading.Store(false) continue } err = executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup) if err != nil { logger.Errorf("Manual reload failed: %v", err) } else { logger.Info("Manual reload completed successfully") } } } } func executeReload( ctx context.Context, agentLoop *agent.AgentLoop, newCfg *config.Config, provider *providers.LLMProvider, runningServices *services, msgBus *bus.MessageBus, allowEmptyStartup bool, ) error { defer runningServices.reloading.Store(false) return handleConfigReload(ctx, agentLoop, newCfg, provider, runningServices, msgBus, allowEmptyStartup) } func createStartupProvider( cfg *config.Config, allowEmptyStartup bool, ) (providers.LLMProvider, string, error) { modelName := cfg.Agents.Defaults.GetModelName() if modelName == "" && allowEmptyStartup { reason := "no default model configured; gateway started in limited mode" fmt.Printf("⚠ Warning: %s\n", reason) logger.WarnCF("gateway", "Gateway started without default model", map[string]any{ "limited_mode": true, }) return &startupBlockedProvider{reason: reason}, "", nil } return providers.CreateProvider(cfg) } func setupAndStartServices( cfg *config.Config, agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, ) (*services, error) { runningServices := &services{} execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute var err error runningServices.CronService, err = setupCronTool( agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout, cfg, ) if err != nil { return nil, fmt.Errorf("error setting up cron service: %w", err) } if err = runningServices.CronService.Start(); err != nil { return nil, fmt.Errorf("error starting cron service: %w", err) } fmt.Println("✓ Cron service started") runningServices.HeartbeatService = heartbeat.NewHeartbeatService( cfg.WorkspacePath(), cfg.Heartbeat.Interval, cfg.Heartbeat.Enabled, ) runningServices.HeartbeatService.SetBus(msgBus) runningServices.HeartbeatService.SetHandler(createHeartbeatHandler(agentLoop)) if err = runningServices.HeartbeatService.Start(); err != nil { return nil, fmt.Errorf("error starting heartbeat service: %w", err) } fmt.Println("✓ Heartbeat service started") runningServices.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{ Enabled: cfg.Tools.MediaCleanup.Enabled, MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute, Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute, }) if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok { fms.Start() } runningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore) if err != nil { if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok { fms.Stop() } return nil, fmt.Errorf("error creating channel manager: %w", err) } agentLoop.SetChannelManager(runningServices.ChannelManager) agentLoop.SetMediaStore(runningServices.MediaStore) if transcriber := voice.DetectTranscriber(cfg); transcriber != nil { agentLoop.SetTranscriber(transcriber) logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()}) } enabledChannels := runningServices.ChannelManager.GetEnabledChannels() if len(enabledChannels) > 0 { fmt.Printf("✓ Channels enabled: %s\n", enabledChannels) } else { fmt.Println("⚠ Warning: No channels enabled") } addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port) runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer) if err = runningServices.ChannelManager.StartAll(context.Background()); err != nil { return nil, fmt.Errorf("error starting channels: %w", err) } fmt.Printf( "✓ Health endpoints available at http://%s:%d/health, /ready and /reload (POST)\n", cfg.Gateway.Host, cfg.Gateway.Port, ) stateManager := state.NewManager(cfg.WorkspacePath()) runningServices.DeviceService = devices.NewService(devices.Config{ Enabled: cfg.Devices.Enabled, MonitorUSB: cfg.Devices.MonitorUSB, }, stateManager) runningServices.DeviceService.SetBus(msgBus) if err = runningServices.DeviceService.Start(context.Background()); err != nil { logger.ErrorCF("device", "Error starting device service", map[string]any{"error": err.Error()}) } else if cfg.Devices.Enabled { fmt.Println("✓ Device event service started") } return runningServices, nil } func stopAndCleanupServices(runningServices *services, shutdownTimeout time.Duration, isReload bool) { shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout) defer shutdownCancel() // reload should not stop channel manager if !isReload && runningServices.ChannelManager != nil { runningServices.ChannelManager.StopAll(shutdownCtx) } if runningServices.DeviceService != nil { runningServices.DeviceService.Stop() } if runningServices.HeartbeatService != nil { runningServices.HeartbeatService.Stop() } if runningServices.CronService != nil { runningServices.CronService.Stop() } if runningServices.MediaStore != nil { if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok { fms.Stop() } } } func shutdownGateway( runningServices *services, agentLoop *agent.AgentLoop, provider providers.LLMProvider, fullShutdown bool, ) { if cp, ok := provider.(providers.StatefulProvider); ok && fullShutdown { cp.Close() } stopAndCleanupServices(runningServices, gracefulShutdownTimeout, false) agentLoop.Stop() agentLoop.Close() logger.Info("✓ Gateway stopped") } func handleConfigReload( ctx context.Context, al *agent.AgentLoop, newCfg *config.Config, providerRef *providers.LLMProvider, runningServices *services, msgBus *bus.MessageBus, allowEmptyStartup bool, ) error { logger.Info("🔄 Config file changed, reloading...") newModel := newCfg.Agents.Defaults.ModelName if newModel == "" { newModel = newCfg.Agents.Defaults.Model } logger.Infof(" New model is '%s', recreating provider...", newModel) logger.Info(" Stopping all services...") stopAndCleanupServices(runningServices, serviceShutdownTimeout, true) newProvider, newModelID, err := createStartupProvider(newCfg, allowEmptyStartup) if err != nil { logger.Errorf(" ⚠ Error creating new provider: %v", err) logger.Warn(" Attempting to restart services with old provider and config...") if restartErr := restartServices(al, runningServices, msgBus); restartErr != nil { logger.Errorf(" ⚠ Failed to restart services: %v", restartErr) } return fmt.Errorf("error creating new provider: %w", err) } if newModelID != "" { newCfg.Agents.Defaults.ModelName = newModelID } reloadCtx, reloadCancel := context.WithTimeout(context.Background(), providerReloadTimeout) defer reloadCancel() if err := al.ReloadProviderAndConfig(reloadCtx, newProvider, newCfg); err != nil { logger.Errorf(" ⚠ Error reloading agent loop: %v", err) if cp, ok := newProvider.(providers.StatefulProvider); ok { cp.Close() } logger.Warn(" Attempting to restart services with old provider and config...") if restartErr := restartServices(al, runningServices, msgBus); restartErr != nil { logger.Errorf(" ⚠ Failed to restart services: %v", restartErr) } return fmt.Errorf("error reloading agent loop: %w", err) } *providerRef = newProvider logger.Info(" Restarting all services with new configuration...") if err := restartServices(al, runningServices, msgBus); err != nil { logger.Errorf(" ⚠ Error restarting services: %v", err) return fmt.Errorf("error restarting services: %w", err) } logger.Info(" ✓ Provider, configuration, and services reloaded successfully (thread-safe)") return nil } func restartServices( al *agent.AgentLoop, runningServices *services, msgBus *bus.MessageBus, ) error { cfg := al.GetConfig() execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute var err error runningServices.CronService, err = setupCronTool( al, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout, cfg, ) if err != nil { return fmt.Errorf("error restarting cron service: %w", err) } if err = runningServices.CronService.Start(); err != nil { return fmt.Errorf("error restarting cron service: %w", err) } fmt.Println(" ✓ Cron service restarted") runningServices.HeartbeatService = heartbeat.NewHeartbeatService( cfg.WorkspacePath(), cfg.Heartbeat.Interval, cfg.Heartbeat.Enabled, ) runningServices.HeartbeatService.SetBus(msgBus) runningServices.HeartbeatService.SetHandler(createHeartbeatHandler(al)) if err = runningServices.HeartbeatService.Start(); err != nil { return fmt.Errorf("error restarting heartbeat service: %w", err) } fmt.Println(" ✓ Heartbeat service restarted") runningServices.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{ Enabled: cfg.Tools.MediaCleanup.Enabled, MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute, Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute, }) if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok { fms.Start() } al.SetMediaStore(runningServices.MediaStore) runningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore) if err != nil { return fmt.Errorf("error recreating channel manager: %w", err) } al.SetChannelManager(runningServices.ChannelManager) enabledChannels := runningServices.ChannelManager.GetEnabledChannels() if len(enabledChannels) > 0 { fmt.Printf(" ✓ Channels enabled: %s\n", enabledChannels) } else { fmt.Println(" ⚠ Warning: No channels enabled") } addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port) // Reuse existing HealthServer to preserve reloadFunc if runningServices.HealthServer == nil { runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) } runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer) if err = runningServices.ChannelManager.Reload(context.Background(), cfg); err != nil { return fmt.Errorf("error reload channels: %w", err) } fmt.Println(" ✓ Channels restarted.") stateManager := state.NewManager(cfg.WorkspacePath()) runningServices.DeviceService = devices.NewService(devices.Config{ Enabled: cfg.Devices.Enabled, MonitorUSB: cfg.Devices.MonitorUSB, }, stateManager) runningServices.DeviceService.SetBus(msgBus) if err := runningServices.DeviceService.Start(context.Background()); err != nil { logger.WarnCF("device", "Failed to restart device service", map[string]any{"error": err.Error()}) } else if cfg.Devices.Enabled { fmt.Println(" ✓ Device event service restarted") } transcriber := voice.DetectTranscriber(cfg) al.SetTranscriber(transcriber) if transcriber != nil { logger.InfoCF("voice", "Transcription re-enabled (agent-level)", map[string]any{"provider": transcriber.Name()}) } else { logger.InfoCF("voice", "Transcription disabled", nil) } return nil } func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Config, func()) { configChan := make(chan *config.Config, 1) stop := make(chan struct{}) var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() lastModTime := getFileModTime(configPath) lastSize := getFileSize(configPath) ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() for { select { case <-ticker.C: currentModTime := getFileModTime(configPath) currentSize := getFileSize(configPath) if currentModTime.After(lastModTime) || currentSize != lastSize { if debug { logger.Debugf("🔍 Config file change detected") } time.Sleep(500 * time.Millisecond) lastModTime = currentModTime lastSize = currentSize newCfg, err := config.LoadConfig(configPath) if err != nil { logger.Errorf("⚠ Error loading new config: %v", err) logger.Warn(" Using previous valid config") continue } if err := newCfg.ValidateModelList(); err != nil { logger.Errorf(" ⚠ New config validation failed: %v", err) logger.Warn(" Using previous valid config") continue } logger.Info("✓ Config file validated and loaded") select { case configChan <- newCfg: default: logger.Warn("⚠ Previous config reload still in progress, skipping") } } case <-stop: return } } }() stopFunc := func() { close(stop) wg.Wait() } return configChan, stopFunc } func getFileModTime(path string) time.Time { info, err := os.Stat(path) if err != nil { return time.Time{} } return info.ModTime() } func getFileSize(path string) int64 { info, err := os.Stat(path) if err != nil { return 0 } return info.Size() } func setupCronTool( agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, cfg *config.Config, ) (*cron.CronService, error) { cronStorePath := filepath.Join(workspace, "cron", "jobs.json") cronService := cron.NewCronService(cronStorePath, nil) var cronTool *tools.CronTool if cfg.Tools.IsToolEnabled("cron") { var err error cronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg) if err != nil { return nil, fmt.Errorf("critical error during CronTool initialization: %w", err) } agentLoop.RegisterTool(cronTool) } if cronTool != nil { cronService.SetOnJob(func(job *cron.CronJob) (string, error) { result := cronTool.ExecuteJob(context.Background(), job) return result, nil }) } return cronService, nil } func createHeartbeatHandler(agentLoop *agent.AgentLoop) func(prompt, channel, chatID string) *tools.ToolResult { return func(prompt, channel, chatID string) *tools.ToolResult { if channel == "" || chatID == "" { channel, chatID = "cli", "direct" } response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID) if err != nil { return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err)) } if response == "HEARTBEAT_OK" { return tools.SilentResult("Heartbeat OK") } return tools.SilentResult(response) } } ================================================ FILE: pkg/health/server.go ================================================ package health import ( "context" "encoding/json" "fmt" "maps" "net/http" "os" "sync" "time" ) type Server struct { server *http.Server mu sync.RWMutex ready bool checks map[string]Check startTime time.Time reloadFunc func() error } type Check struct { Name string `json:"name"` Status string `json:"status"` Message string `json:"message,omitempty"` Timestamp time.Time `json:"timestamp"` } type StatusResponse struct { Status string `json:"status"` Uptime string `json:"uptime"` Checks map[string]Check `json:"checks,omitempty"` Pid int `json:"pid"` } func NewServer(host string, port int) *Server { mux := http.NewServeMux() s := &Server{ ready: false, checks: make(map[string]Check), startTime: time.Now(), } mux.HandleFunc("/health", s.healthHandler) mux.HandleFunc("/ready", s.readyHandler) mux.HandleFunc("/reload", s.reloadHandler) addr := fmt.Sprintf("%s:%d", host, port) s.server = &http.Server{ Addr: addr, Handler: mux, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, } return s } func (s *Server) Start() error { s.mu.Lock() s.ready = true s.mu.Unlock() return s.server.ListenAndServe() } func (s *Server) StartContext(ctx context.Context) error { s.mu.Lock() s.ready = true s.mu.Unlock() errCh := make(chan error, 1) go func() { errCh <- s.server.ListenAndServe() }() select { case err := <-errCh: return err case <-ctx.Done(): return s.server.Shutdown(context.Background()) } } func (s *Server) Stop(ctx context.Context) error { s.mu.Lock() s.ready = false s.mu.Unlock() return s.server.Shutdown(ctx) } func (s *Server) SetReady(ready bool) { s.mu.Lock() s.ready = ready s.mu.Unlock() } func (s *Server) RegisterCheck(name string, checkFn func() (bool, string)) { s.mu.Lock() defer s.mu.Unlock() status, msg := checkFn() s.checks[name] = Check{ Name: name, Status: statusString(status), Message: msg, Timestamp: time.Now(), } } // SetReloadFunc sets the callback function for config reload. func (s *Server) SetReloadFunc(fn func() error) { s.mu.Lock() defer s.mu.Unlock() s.reloadFunc = fn } func (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusMethodNotAllowed) json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed, use POST"}) return } s.mu.Lock() reloadFunc := s.reloadFunc s.mu.Unlock() if reloadFunc == nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusServiceUnavailable) json.NewEncoder(w).Encode(map[string]string{"error": "reload not configured"}) return } if err := reloadFunc(); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{"status": "reload triggered"}) } func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) uptime := time.Since(s.startTime) resp := StatusResponse{ Status: "ok", Uptime: uptime.String(), Pid: os.Getpid(), } json.NewEncoder(w).Encode(resp) } func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") s.mu.RLock() ready := s.ready checks := make(map[string]Check) maps.Copy(checks, s.checks) s.mu.RUnlock() if !ready { w.WriteHeader(http.StatusServiceUnavailable) json.NewEncoder(w).Encode(StatusResponse{ Status: "not ready", Checks: checks, }) return } for _, check := range checks { if check.Status == "fail" { w.WriteHeader(http.StatusServiceUnavailable) json.NewEncoder(w).Encode(StatusResponse{ Status: "not ready", Checks: checks, }) return } } w.WriteHeader(http.StatusOK) uptime := time.Since(s.startTime) json.NewEncoder(w).Encode(StatusResponse{ Status: "ready", Uptime: uptime.String(), Checks: checks, }) } // RegisterOnMux registers /health, /ready and /reload handlers onto the given mux. // This allows the health endpoints to be served by a shared HTTP server. func (s *Server) RegisterOnMux(mux *http.ServeMux) { mux.HandleFunc("/health", s.healthHandler) mux.HandleFunc("/ready", s.readyHandler) mux.HandleFunc("/reload", s.reloadHandler) } func statusString(ok bool) string { if ok { return "ok" } return "fail" } ================================================ FILE: pkg/heartbeat/service.go ================================================ // PicoClaw - Ultra-lightweight personal AI agent // Inspired by and based on nanobot: https://github.com/HKUDS/nanobot // License: MIT // // Copyright (c) 2026 PicoClaw contributors package heartbeat import ( "context" "fmt" "os" "path/filepath" "strings" "sync" "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" ) const ( minIntervalMinutes = 5 defaultIntervalMinutes = 30 ) // HeartbeatHandler is the function type for handling heartbeat. // It returns a ToolResult that can indicate async operations. // channel and chatID are derived from the last active user channel. type HeartbeatHandler func(prompt, channel, chatID string) *tools.ToolResult // HeartbeatService manages periodic heartbeat checks type HeartbeatService struct { workspace string bus *bus.MessageBus state *state.Manager handler HeartbeatHandler interval time.Duration enabled bool mu sync.RWMutex stopChan chan struct{} } // NewHeartbeatService creates a new heartbeat service func NewHeartbeatService(workspace string, intervalMinutes int, enabled bool) *HeartbeatService { // Apply minimum interval if intervalMinutes < minIntervalMinutes && intervalMinutes != 0 { intervalMinutes = minIntervalMinutes } if intervalMinutes == 0 { intervalMinutes = defaultIntervalMinutes } return &HeartbeatService{ workspace: workspace, interval: time.Duration(intervalMinutes) * time.Minute, enabled: enabled, state: state.NewManager(workspace), } } // SetBus sets the message bus for delivering heartbeat results. func (hs *HeartbeatService) SetBus(msgBus *bus.MessageBus) { hs.mu.Lock() defer hs.mu.Unlock() hs.bus = msgBus } // SetHandler sets the heartbeat handler. func (hs *HeartbeatService) SetHandler(handler HeartbeatHandler) { hs.mu.Lock() defer hs.mu.Unlock() hs.handler = handler } // Start begins the heartbeat service func (hs *HeartbeatService) Start() error { hs.mu.Lock() defer hs.mu.Unlock() if hs.stopChan != nil { logger.InfoC("heartbeat", "Heartbeat service already running") return nil } if !hs.enabled { logger.InfoC("heartbeat", "Heartbeat service disabled") return nil } hs.stopChan = make(chan struct{}) go hs.runLoop(hs.stopChan) logger.InfoCF("heartbeat", "Heartbeat service started", map[string]any{ "interval_minutes": hs.interval.Minutes(), }) return nil } // Stop gracefully stops the heartbeat service func (hs *HeartbeatService) Stop() { hs.mu.Lock() defer hs.mu.Unlock() if hs.stopChan == nil { return } logger.InfoC("heartbeat", "Stopping heartbeat service") close(hs.stopChan) hs.stopChan = nil } // IsRunning returns whether the service is running func (hs *HeartbeatService) IsRunning() bool { hs.mu.RLock() defer hs.mu.RUnlock() return hs.stopChan != nil } // runLoop runs the heartbeat ticker func (hs *HeartbeatService) runLoop(stopChan chan struct{}) { ticker := time.NewTicker(hs.interval) defer ticker.Stop() // Run first heartbeat after initial delay time.AfterFunc(time.Second, func() { hs.executeHeartbeat() }) for { select { case <-stopChan: return case <-ticker.C: hs.executeHeartbeat() } } } // executeHeartbeat performs a single heartbeat check func (hs *HeartbeatService) executeHeartbeat() { hs.mu.RLock() enabled := hs.enabled handler := hs.handler if !hs.enabled || hs.stopChan == nil { hs.mu.RUnlock() return } hs.mu.RUnlock() if !enabled { return } logger.DebugC("heartbeat", "Executing heartbeat") prompt := hs.buildPrompt() if prompt == "" { logger.InfoC("heartbeat", "No heartbeat prompt (HEARTBEAT.md empty or missing)") return } if handler == nil { hs.logErrorf("Heartbeat handler not configured") return } // Get last channel info for context lastChannel := hs.state.GetLastChannel() channel, chatID := hs.parseLastChannel(lastChannel) // Debug log for channel resolution hs.logInfof("Resolved channel: %s, chatID: %s (from lastChannel: %s)", channel, chatID, lastChannel) result := handler(prompt, channel, chatID) if result == nil { hs.logInfof("Heartbeat handler returned nil result") return } // Handle different result types if result.IsError { hs.logErrorf("Heartbeat error: %s", result.ForLLM) return } if result.Async { hs.logInfof("Async task started: %s", result.ForLLM) logger.InfoCF("heartbeat", "Async heartbeat task started", map[string]any{ "message": result.ForLLM, }) return } // Check if silent if result.Silent { hs.logInfof("Heartbeat OK - silent") return } // Send result to user if result.ForUser != "" { hs.sendResponse(result.ForUser) } else if result.ForLLM != "" { hs.sendResponse(result.ForLLM) } hs.logInfof("Heartbeat completed: %s", result.ForLLM) } // buildPrompt builds the heartbeat prompt from HEARTBEAT.md func (hs *HeartbeatService) buildPrompt() string { heartbeatPath := filepath.Join(hs.workspace, "HEARTBEAT.md") data, err := os.ReadFile(heartbeatPath) if err != nil { if os.IsNotExist(err) { hs.createDefaultHeartbeatTemplate() return "" } hs.logErrorf("Error reading HEARTBEAT.md: %v", err) return "" } content := string(data) if len(content) == 0 { return "" } now := time.Now().Format("2006-01-02 15:04:05") return fmt.Sprintf(`# Heartbeat Check Current time: %s You are a proactive AI assistant. This is a scheduled heartbeat check. Review the following tasks and execute any necessary actions using available skills. If there is nothing that requires attention, respond ONLY with: HEARTBEAT_OK %s `, now, content) } // createDefaultHeartbeatTemplate creates the default HEARTBEAT.md file func (hs *HeartbeatService) createDefaultHeartbeatTemplate() { heartbeatPath := filepath.Join(hs.workspace, "HEARTBEAT.md") defaultContent := `# Heartbeat Check List This file contains tasks for the heartbeat service to check periodically. ## Examples - Check for unread messages - Review upcoming calendar events - Check device status (e.g., MaixCam) ## Instructions - Execute ALL tasks listed below. Do NOT skip any task. - For simple tasks (e.g., report current time), respond directly. - For complex tasks that may take time, use the spawn tool to create a subagent. - The spawn tool is async - subagent results will be sent to the user automatically. - After spawning a subagent, CONTINUE to process remaining tasks. - Only respond with HEARTBEAT_OK when ALL tasks are done AND nothing needs attention. --- Add your heartbeat tasks below this line: ` if err := fileutil.WriteFileAtomic(heartbeatPath, []byte(defaultContent), 0o644); err != nil { hs.logErrorf("Failed to create default HEARTBEAT.md: %v", err) } else { hs.logInfof("Created default HEARTBEAT.md template") } } // sendResponse sends the heartbeat response to the last channel func (hs *HeartbeatService) sendResponse(response string) { hs.mu.RLock() msgBus := hs.bus hs.mu.RUnlock() if msgBus == nil { hs.logInfof("No message bus configured, heartbeat result not sent") return } // Get last channel from state lastChannel := hs.state.GetLastChannel() if lastChannel == "" { hs.logInfof("No last channel recorded, heartbeat result not sent") return } platform, userID := hs.parseLastChannel(lastChannel) // Skip internal channels that can't receive messages if platform == "" || userID == "" { return } pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) defer pubCancel() msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: platform, ChatID: userID, Content: response, }) hs.logInfof("Heartbeat result sent to %s", platform) } // parseLastChannel parses the last channel string into platform and userID. // Returns empty strings for invalid or internal channels. func (hs *HeartbeatService) parseLastChannel(lastChannel string) (platform, userID string) { if lastChannel == "" { return "", "" } // Parse channel format: "platform:user_id" (e.g., "telegram:123456") parts := strings.SplitN(lastChannel, ":", 2) if len(parts) != 2 || parts[0] == "" || parts[1] == "" { hs.logErrorf("Invalid last channel format: %s", lastChannel) return "", "" } platform, userID = parts[0], parts[1] // Skip internal channels if constants.IsInternalChannel(platform) { hs.logInfof("Skipping internal channel: %s", platform) return "", "" } return platform, userID } // logInfof logs an informational message to the heartbeat log func (hs *HeartbeatService) logInfof(format string, args ...any) { hs.logf("INFO", format, args...) } // logErrorf logs an error message to the heartbeat log func (hs *HeartbeatService) logErrorf(format string, args ...any) { hs.logf("ERROR", format, args...) } // logf writes a message to the heartbeat log file func (hs *HeartbeatService) logf(level, format string, args ...any) { logFile := filepath.Join(hs.workspace, "heartbeat.log") f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return } defer f.Close() timestamp := time.Now().Format("2006-01-02 15:04:05") fmt.Fprintf(f, "[%s] [%s] %s\n", timestamp, level, fmt.Sprintf(format, args...)) } ================================================ FILE: pkg/heartbeat/service_test.go ================================================ package heartbeat import ( "os" "path/filepath" "testing" "time" "github.com/sipeed/picoclaw/pkg/tools" ) func TestExecuteHeartbeat_Async(t *testing.T) { tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) hs := NewHeartbeatService(tmpDir, 30, true) hs.stopChan = make(chan struct{}) // Enable for testing asyncCalled := false asyncResult := &tools.ToolResult{ ForLLM: "Background task started", ForUser: "Task started in background", Silent: false, IsError: false, Async: true, } hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { asyncCalled = true if prompt == "" { t.Error("Expected non-empty prompt") } return asyncResult }) // Create HEARTBEAT.md os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644) // Execute heartbeat directly (internal method for testing) hs.executeHeartbeat() if !asyncCalled { t.Error("Expected handler to be called") } } func TestExecuteHeartbeat_ResultLogging(t *testing.T) { tests := []struct { name string result *tools.ToolResult wantLog string }{ { name: "error result", result: &tools.ToolResult{ ForLLM: "Heartbeat failed: connection error", ForUser: "", Silent: false, IsError: true, Async: false, }, wantLog: "error message", }, { name: "silent result", result: &tools.ToolResult{ ForLLM: "Heartbeat completed successfully", ForUser: "", Silent: true, IsError: false, Async: false, }, wantLog: "completion message", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) hs := NewHeartbeatService(tmpDir, 30, true) hs.stopChan = make(chan struct{}) // Enable for testing hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { return tt.result }) os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644) hs.executeHeartbeat() logFile := filepath.Join(tmpDir, "heartbeat.log") data, err := os.ReadFile(logFile) if err != nil { t.Fatalf("Failed to read log file: %v", err) } if string(data) == "" { t.Errorf("Expected log file to contain %s", tt.wantLog) } }) } } func TestHeartbeatService_StartStop(t *testing.T) { tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) hs := NewHeartbeatService(tmpDir, 1, true) err = hs.Start() if err != nil { t.Fatalf("Failed to start heartbeat service: %v", err) } hs.Stop() time.Sleep(100 * time.Millisecond) } func TestHeartbeatService_Disabled(t *testing.T) { tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) hs := NewHeartbeatService(tmpDir, 1, false) if hs.enabled != false { t.Error("Expected service to be disabled") } err = hs.Start() _ = err // Disabled service returns nil } func TestExecuteHeartbeat_NilResult(t *testing.T) { tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) hs := NewHeartbeatService(tmpDir, 30, true) hs.stopChan = make(chan struct{}) // Enable for testing hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { return nil }) // Create HEARTBEAT.md os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644) // Should not panic with nil result hs.executeHeartbeat() } // TestLogPath verifies heartbeat log is written to workspace directory func TestLogPath(t *testing.T) { tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) hs := NewHeartbeatService(tmpDir, 30, true) // Write a log entry hs.logf("INFO", "Test log entry") // Verify log file exists at workspace root expectedLogPath := filepath.Join(tmpDir, "heartbeat.log") if _, err := os.Stat(expectedLogPath); os.IsNotExist(err) { t.Errorf("Expected log file at %s, but it doesn't exist", expectedLogPath) } } // TestHeartbeatFilePath verifies HEARTBEAT.md is at workspace root func TestHeartbeatFilePath(t *testing.T) { tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) hs := NewHeartbeatService(tmpDir, 30, true) // Trigger default template creation hs.buildPrompt() // Verify HEARTBEAT.md exists at workspace root expectedPath := filepath.Join(tmpDir, "HEARTBEAT.md") if _, err := os.Stat(expectedPath); os.IsNotExist(err) { t.Errorf("Expected HEARTBEAT.md at %s, but it doesn't exist", expectedPath) } } ================================================ FILE: pkg/identity/identity.go ================================================ // Package identity provides unified user identity utilities for PicoClaw. // It introduces a canonical "platform:id" format and matching logic // that is backward-compatible with all legacy allow-list formats. package identity import ( "strings" "github.com/sipeed/picoclaw/pkg/bus" ) // BuildCanonicalID constructs a canonical "platform:id" identifier. // Both platform and platformID are lowercased and trimmed. func BuildCanonicalID(platform, platformID string) string { p := strings.ToLower(strings.TrimSpace(platform)) id := strings.TrimSpace(platformID) if p == "" || id == "" { return "" } return p + ":" + id } // ParseCanonicalID splits a canonical ID ("platform:id") into its parts. // Returns ok=false if the input does not contain a colon separator. func ParseCanonicalID(canonical string) (platform, id string, ok bool) { canonical = strings.TrimSpace(canonical) idx := strings.Index(canonical, ":") if idx <= 0 || idx == len(canonical)-1 { return "", "", false } return canonical[:idx], canonical[idx+1:], true } // MatchAllowed checks whether the given sender matches a single allow-list entry. // It is backward-compatible with all legacy formats: // // - "123456" → matches sender.PlatformID // - "@alice" → matches sender.Username // - "123456|alice" → matches PlatformID or Username // - "telegram:123456" → exact match on sender.CanonicalID func MatchAllowed(sender bus.SenderInfo, allowed string) bool { allowed = strings.TrimSpace(allowed) if allowed == "" { return false } // Try canonical match first: "platform:id" format if platform, id, ok := ParseCanonicalID(allowed); ok { // Only treat as canonical if the platform portion looks like a known platform name // (not a pure-numeric string, which could be a compound ID) if !isNumeric(platform) { candidate := BuildCanonicalID(platform, id) if candidate != "" && sender.CanonicalID != "" { return strings.EqualFold(sender.CanonicalID, candidate) } // If sender has no canonical ID, try matching platform + platformID return strings.EqualFold(platform, sender.Platform) && sender.PlatformID == id } } // Keep track of explicit username format isAtUsername := strings.HasPrefix(allowed, "@") // Strip leading "@" for username matching trimmed := strings.TrimPrefix(allowed, "@") // Split compound "id|username" format allowedID := trimmed allowedUser := "" if idx := strings.Index(trimmed, "|"); idx > 0 { allowedID = trimmed[:idx] allowedUser = trimmed[idx+1:] } // Match against PlatformID if sender.PlatformID != "" && sender.PlatformID == allowedID { return true } // Match against Username only when explicitly requested via "@username" if isAtUsername && sender.Username != "" && sender.Username == trimmed { return true } // Match compound sender format against allowed parts if allowedUser != "" && sender.PlatformID != "" && sender.PlatformID == allowedID { return true } if allowedUser != "" && sender.Username != "" && sender.Username == allowedUser { return true } return false } // isNumeric returns true if s consists entirely of digits. func isNumeric(s string) bool { if s == "" { return false } for _, r := range s { if r < '0' || r > '9' { return false } } return true } ================================================ FILE: pkg/identity/identity_test.go ================================================ package identity import ( "testing" "github.com/sipeed/picoclaw/pkg/bus" ) func TestBuildCanonicalID(t *testing.T) { tests := []struct { platform string platformID string want string }{ {"telegram", "123456", "telegram:123456"}, {"Discord", "98765432", "discord:98765432"}, {"SLACK", "U123ABC", "slack:U123ABC"}, {"", "123", ""}, {"telegram", "", ""}, {" telegram ", " 123 ", "telegram:123"}, } for _, tt := range tests { got := BuildCanonicalID(tt.platform, tt.platformID) if got != tt.want { t.Errorf("BuildCanonicalID(%q, %q) = %q, want %q", tt.platform, tt.platformID, got, tt.want) } } } func TestParseCanonicalID(t *testing.T) { tests := []struct { input string wantPlatform string wantID string wantOk bool }{ {"telegram:123456", "telegram", "123456", true}, {"discord:98765432", "discord", "98765432", true}, {"slack:U123ABC", "slack", "U123ABC", true}, {"nocolon", "", "", false}, {"", "", "", false}, {":missing", "", "", false}, {"missing:", "", "", false}, } for _, tt := range tests { platform, id, ok := ParseCanonicalID(tt.input) if ok != tt.wantOk || platform != tt.wantPlatform || id != tt.wantID { t.Errorf("ParseCanonicalID(%q) = (%q, %q, %v), want (%q, %q, %v)", tt.input, platform, id, ok, tt.wantPlatform, tt.wantID, tt.wantOk) } } } func TestMatchAllowed(t *testing.T) { telegramSender := bus.SenderInfo{ Platform: "telegram", PlatformID: "123456", CanonicalID: "telegram:123456", Username: "alice", DisplayName: "Alice Smith", } discordSender := bus.SenderInfo{ Platform: "discord", PlatformID: "98765432", CanonicalID: "discord:98765432", Username: "bob", DisplayName: "bob#1234", } noCanonicalSender := bus.SenderInfo{ Platform: "telegram", PlatformID: "999", Username: "carol", } tests := []struct { name string sender bus.SenderInfo allowed string want bool }{ // Pure numeric ID matching { name: "numeric ID matches PlatformID", sender: telegramSender, allowed: "123456", want: true, }, { name: "numeric ID does not match", sender: telegramSender, allowed: "654321", want: false, }, // Username matching { name: "@username matches Username", sender: telegramSender, allowed: "@alice", want: true, }, { name: "plain entry does not match username", sender: bus.SenderInfo{ Platform: "discord", PlatformID: "999999", Username: "123456", }, allowed: "123456", want: false, }, { name: "@username does not match", sender: telegramSender, allowed: "@bob", want: false, }, // Compound format "id|username" { name: "compound matches by ID", sender: telegramSender, allowed: "123456|alice", want: true, }, { name: "compound matches by username", sender: telegramSender, allowed: "999|alice", want: true, }, { name: "compound matches by ID when username differs", sender: bus.SenderInfo{ Platform: "discord", PlatformID: "123456", Username: "not123456", }, allowed: "123456|alice", want: true, }, { name: "compound does not match", sender: telegramSender, allowed: "654321|bob", want: false, }, // Canonical format "platform:id" { name: "canonical matches exactly", sender: telegramSender, allowed: "telegram:123456", want: true, }, { name: "canonical case-insensitive platform", sender: telegramSender, allowed: "Telegram:123456", want: true, }, { name: "canonical wrong platform", sender: telegramSender, allowed: "discord:123456", want: false, }, { name: "canonical wrong ID", sender: telegramSender, allowed: "telegram:654321", want: false, }, // Cross-platform canonical { name: "discord canonical match", sender: discordSender, allowed: "discord:98765432", want: true, }, { name: "telegram canonical does not match discord sender", sender: discordSender, allowed: "telegram:98765432", want: false, }, // Sender without canonical ID { name: "canonical match falls back to platform+platformID", sender: noCanonicalSender, allowed: "telegram:999", want: true, }, { name: "platform mismatch on fallback", sender: noCanonicalSender, allowed: "discord:999", want: false, }, // Empty allowed string { name: "empty allowed never matches", sender: telegramSender, allowed: "", want: false, }, // Whitespace handling { name: "trimmed allowed matches", sender: telegramSender, allowed: " 123456 ", want: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := MatchAllowed(tt.sender, tt.allowed) if got != tt.want { t.Errorf("MatchAllowed(%+v, %q) = %v, want %v", tt.sender, tt.allowed, got, tt.want) } }) } } func TestIsNumeric(t *testing.T) { tests := []struct { input string want bool }{ {"123456", true}, {"0", true}, {"", false}, {"abc", false}, {"12a34", false}, {"telegram", false}, } for _, tt := range tests { got := isNumeric(tt.input) if got != tt.want { t.Errorf("isNumeric(%q) = %v, want %v", tt.input, got, tt.want) } } } ================================================ FILE: pkg/logger/logger.go ================================================ package logger import ( "fmt" "os" "path/filepath" "runtime" "strconv" "strings" "sync" "github.com/rs/zerolog" ) type LogLevel = zerolog.Level const ( DEBUG = zerolog.DebugLevel INFO = zerolog.InfoLevel WARN = zerolog.WarnLevel ERROR = zerolog.ErrorLevel FATAL = zerolog.FatalLevel ) var ( logLevelNames = map[LogLevel]string{ DEBUG: "DEBUG", INFO: "INFO", WARN: "WARN", ERROR: "ERROR", FATAL: "FATAL", } currentLevel = INFO logger zerolog.Logger fileLogger zerolog.Logger logFile *os.File once sync.Once mu sync.RWMutex ) func init() { once.Do(func() { zerolog.SetGlobalLevel(zerolog.InfoLevel) consoleWriter := zerolog.ConsoleWriter{ Out: os.Stdout, TimeFormat: "15:04:05", // TODO: make it configurable??? // Custom formatter to handle multiline strings and JSON objects FormatFieldValue: formatFieldValue, } logger = zerolog.New(consoleWriter).With().Timestamp().Caller().Logger() fileLogger = zerolog.Logger{} }) } func formatFieldValue(i any) string { var s string switch val := i.(type) { case string: s = val case []byte: s = string(val) default: return fmt.Sprintf("%v", i) } if unquoted, err := strconv.Unquote(s); err == nil { s = unquoted } if strings.Contains(s, "\n") { return fmt.Sprintf("\n%s", s) } if strings.Contains(s, " ") { if (strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}")) || (strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]")) { return s } return fmt.Sprintf("%q", s) } return s } func SetLevel(level LogLevel) { mu.Lock() defer mu.Unlock() currentLevel = level zerolog.SetGlobalLevel(level) } func SetConsoleLevel(level LogLevel) { mu.Lock() defer mu.Unlock() logger = logger.Level(level) } func GetLevel() LogLevel { mu.RLock() defer mu.RUnlock() return currentLevel } func EnableFileLogging(filePath string) error { mu.Lock() defer mu.Unlock() if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { return fmt.Errorf("failed to create log directory: %w", err) } newFile, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return fmt.Errorf("failed to open log file: %w", err) } // Close old file if exists if logFile != nil { logFile.Close() } logFile = newFile fileLogger = zerolog.New(logFile).With().Timestamp().Caller().Logger() return nil } func DisableFileLogging() { mu.Lock() defer mu.Unlock() if logFile != nil { logFile.Close() logFile = nil } fileLogger = zerolog.Logger{} } func getCallerSkip() int { for i := 2; i < 15; i++ { pc, file, _, ok := runtime.Caller(i) if !ok { continue } fn := runtime.FuncForPC(pc) if fn == nil { continue } // bypass common loggers if strings.HasSuffix(file, "/logger.go") || strings.HasSuffix(file, "/logger_3rd_party.go") || strings.HasSuffix(file, "/log.go") { continue } funcName := fn.Name() if strings.HasPrefix(funcName, "runtime.") { continue } return i - 1 } return 3 } //nolint:zerologlint func getEvent(logger zerolog.Logger, level LogLevel) *zerolog.Event { switch level { case zerolog.DebugLevel: return logger.Debug() case zerolog.InfoLevel: return logger.Info() case zerolog.WarnLevel: return logger.Warn() case zerolog.ErrorLevel: return logger.Error() case zerolog.FatalLevel: return logger.Fatal() default: return logger.Info() } } func logMessage(level LogLevel, component string, message string, fields map[string]any) { if level < currentLevel { return } skip := getCallerSkip() event := getEvent(logger, level) if component != "" { event.Str("component", component) } appendFields(event, fields) event.CallerSkipFrame(skip).Msg(message) // Also log to file if enabled if fileLogger.GetLevel() != zerolog.NoLevel { fileEvent := getEvent(fileLogger, level) if component != "" { fileEvent.Str("component", component) } // fileEvent.Str("caller", fmt.Sprintf("%s:%d (%s)", callerFile, callerLine, callerFunc)) appendFields(fileEvent, fields) fileEvent.CallerSkipFrame(skip).Msg(message) } if level == FATAL { os.Exit(1) } } func appendFields(event *zerolog.Event, fields map[string]any) { for k, v := range fields { // Type switch to avoid double JSON serialization of strings switch val := v.(type) { case string: event.Str(k, val) case int: event.Int(k, val) case int64: event.Int64(k, val) case float64: event.Float64(k, val) case bool: event.Bool(k, val) default: event.Interface(k, v) // Fallback for struct, slice and maps } } } func Debug(message string) { logMessage(DEBUG, "", message, nil) } func DebugC(component string, message string) { logMessage(DEBUG, component, message, nil) } func Debugf(message string, ss ...any) { logMessage(DEBUG, "", fmt.Sprintf(message, ss...), nil) } func DebugF(message string, fields map[string]any) { logMessage(DEBUG, "", message, fields) } func DebugCF(component string, message string, fields map[string]any) { logMessage(DEBUG, component, message, fields) } func Info(message string) { logMessage(INFO, "", message, nil) } func InfoC(component string, message string) { logMessage(INFO, component, message, nil) } func InfoF(message string, fields map[string]any) { logMessage(INFO, "", message, fields) } func Infof(message string, ss ...any) { logMessage(INFO, "", fmt.Sprintf(message, ss...), nil) } func InfoCF(component string, message string, fields map[string]any) { logMessage(INFO, component, message, fields) } func Warn(message string) { logMessage(WARN, "", message, nil) } func WarnC(component string, message string) { logMessage(WARN, component, message, nil) } func WarnF(message string, fields map[string]any) { logMessage(WARN, "", message, fields) } func WarnCF(component string, message string, fields map[string]any) { logMessage(WARN, component, message, fields) } func Error(message string) { logMessage(ERROR, "", message, nil) } func ErrorC(component string, message string) { logMessage(ERROR, component, message, nil) } func Errorf(message string, ss ...any) { logMessage(ERROR, "", fmt.Sprintf(message, ss...), nil) } func ErrorF(message string, fields map[string]any) { logMessage(ERROR, "", message, fields) } func ErrorCF(component string, message string, fields map[string]any) { logMessage(ERROR, component, message, fields) } func Fatal(message string) { logMessage(FATAL, "", message, nil) } func FatalC(component string, message string) { logMessage(FATAL, component, message, nil) } func Fatalf(message string, ss ...any) { logMessage(FATAL, "", fmt.Sprintf(message, ss...), nil) } func FatalF(message string, fields map[string]any) { logMessage(FATAL, "", message, fields) } func FatalCF(component string, message string, fields map[string]any) { logMessage(FATAL, component, message, fields) } ================================================ FILE: pkg/logger/logger_3rd_party.go ================================================ // this file is for compatible with 3rd party loggers, should not be called in PicoClaw project package logger import ( "fmt" "regexp" ) // botTokenRe matches the bot ID prefix and the secret part of a Telegram bot token. // Groups: 1 = "bot:", 2 = first 4 chars of secret, 3 = middle, 4 = last 4 chars. var botTokenRe = regexp.MustCompile(`(bot\d+:)([A-Za-z0-9_-]{4})[A-Za-z0-9_-]{12,}([A-Za-z0-9_-]{4})`) // maskSecrets replaces any embedded bot tokens in s with a redacted placeholder // that keeps the first and last 4 characters of the secret for identification. func maskSecrets(s string) string { return botTokenRe.ReplaceAllString(s, "${1}${2}****${3}") } // Logger implements common Logger interface type Logger struct { component string levels map[int]LogLevel } // Debug logs debug messages func (b *Logger) Debug(v ...any) { logMessage(DEBUG, b.component, maskSecrets(fmt.Sprint(v...)), nil) } // Info logs info messages func (b *Logger) Info(v ...any) { logMessage(INFO, b.component, maskSecrets(fmt.Sprint(v...)), nil) } // Warn logs warning messages func (b *Logger) Warn(v ...any) { logMessage(WARN, b.component, maskSecrets(fmt.Sprint(v...)), nil) } // Error logs error messages func (b *Logger) Error(v ...any) { logMessage(ERROR, b.component, maskSecrets(fmt.Sprint(v...)), nil) } // Debugf logs formatted debug messages func (b *Logger) Debugf(format string, v ...any) { logMessage(DEBUG, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil) } // Infof logs formatted info messages func (b *Logger) Infof(format string, v ...any) { logMessage(INFO, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil) } // Warnf logs formatted warning messages func (b *Logger) Warnf(format string, v ...any) { logMessage(WARN, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil) } // Warningf logs formatted warning messages func (b *Logger) Warningf(format string, v ...any) { logMessage(WARN, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil) } // Errorf logs formatted error messages func (b *Logger) Errorf(format string, v ...any) { logMessage(ERROR, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil) } // Fatalf logs formatted fatal messages and exits func (b *Logger) Fatalf(format string, v ...any) { logMessage(FATAL, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil) } // Log logs a message at a given level with caller information // the func name must be this because 3rd party loggers expect this // msgL: message level (DEBUG, INFO, WARN, ERROR, FATAL) // caller: unused parameter reserved for compatibility // format: format string // a: format arguments // //nolint:goprintffuncname func (b *Logger) Log(msgL, caller int, format string, a ...any) { level := LogLevel(msgL) if b.levels != nil { if lvl, ok := b.levels[msgL]; ok { level = lvl } } logMessage(level, b.component, maskSecrets(fmt.Sprintf(format, a...)), nil) } // Sync flushes log buffer (no-op for this implementation) func (b *Logger) Sync() error { return nil } // WithLevels sets log levels mapping for this logger func (b *Logger) WithLevels(levels map[int]LogLevel) *Logger { b.levels = levels return b } // NewLogger creates a new logger instance with optional component name func NewLogger(component string) *Logger { return &Logger{component: component} } ================================================ FILE: pkg/logger/logger_test.go ================================================ package logger import ( "testing" ) func TestLogLevelFiltering(t *testing.T) { initialLevel := GetLevel() defer SetLevel(initialLevel) SetLevel(WARN) tests := []struct { name string level LogLevel shouldLog bool }{ {"DEBUG message", DEBUG, false}, {"INFO message", INFO, false}, {"WARN message", WARN, true}, {"ERROR message", ERROR, true}, {"FATAL message", FATAL, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { switch tt.level { case DEBUG: Debug(tt.name) case INFO: Info(tt.name) case WARN: Warn(tt.name) case ERROR: Error(tt.name) case FATAL: if tt.shouldLog { t.Logf("FATAL test skipped to prevent program exit") } } }) } SetLevel(INFO) } func TestLoggerWithComponent(t *testing.T) { initialLevel := GetLevel() defer SetLevel(initialLevel) SetLevel(DEBUG) tests := []struct { name string component string message string fields map[string]any }{ {"Simple message", "test", "Hello, world!", nil}, {"Message with component", "discord", "Discord message", nil}, {"Message with fields", "telegram", "Telegram message", map[string]any{ "user_id": "12345", "count": 42, }}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { switch { case tt.fields == nil && tt.component != "": InfoC(tt.component, tt.message) case tt.fields != nil: InfoF(tt.message, tt.fields) default: Info(tt.message) } }) } SetLevel(INFO) } func TestLogLevels(t *testing.T) { tests := []struct { name string level LogLevel want string }{ {"DEBUG level", DEBUG, "DEBUG"}, {"INFO level", INFO, "INFO"}, {"WARN level", WARN, "WARN"}, {"ERROR level", ERROR, "ERROR"}, {"FATAL level", FATAL, "FATAL"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if logLevelNames[tt.level] != tt.want { t.Errorf("logLevelNames[%d] = %s, want %s", tt.level, logLevelNames[tt.level], tt.want) } }) } } func TestSetGetLevel(t *testing.T) { initialLevel := GetLevel() defer SetLevel(initialLevel) tests := []LogLevel{DEBUG, INFO, WARN, ERROR, FATAL} for _, level := range tests { SetLevel(level) if GetLevel() != level { t.Errorf("SetLevel(%v) -> GetLevel() = %v, want %v", level, GetLevel(), level) } } } func TestLoggerHelperFunctions(t *testing.T) { initialLevel := GetLevel() defer SetLevel(initialLevel) SetLevel(INFO) Debug("This should not log") Debugf("this should not log") Info("This should log") Warn("This should log") Error("This should log") InfoC("test", "Component message") InfoF("Fields message", map[string]any{"key": "value"}) Infof("test from %v", "Infof") WarnC("test", "Warning with component") ErrorF("Error with fields", map[string]any{"error": "test"}) Errorf("test from %v", "Errorf") SetLevel(DEBUG) DebugC("test", "Debug with component") Debugf("test from %v", "Debugf") WarnF("Warning with fields", map[string]any{"key": "value"}) } func TestFormatFieldValue(t *testing.T) { tests := []struct { name string input any expected string }{ // Basic types test (default case of the switch) { name: "Integer Type", input: 42, expected: "42", }, { name: "Boolean Type", input: true, expected: "true", }, { name: "Unsupported Struct Type", input: struct{ A int }{A: 1}, expected: "{1}", }, // Simple strings and byte slices test { name: "Simple string without spaces", input: "simple_value", expected: "simple_value", }, { name: "Simple byte slice", input: []byte("byte_value"), expected: "byte_value", }, // Unquoting test (strconv.Unquote) { name: "Quoted string", input: `"quoted_value"`, expected: "quoted_value", }, // Strings with newline (\n) test { name: "String with newline", input: "line1\nline2", expected: "\nline1\nline2", }, { name: "Quoted string with newline (Unquote -> newline)", input: `"line1\nline2"`, // Escaped \n that Unquote will resolve expected: "\nline1\nline2", }, // Strings with spaces test (which should be quoted) { name: "String with spaces", input: "hello world", expected: `"hello world"`, }, { name: "Quoted string with spaces (Unquote -> has spaces -> Re-quote)", input: `"hello world"`, expected: `"hello world"`, }, // JSON formats test (strings with spaces that start/end with brackets) { name: "Valid JSON object", input: `{"key": "value"}`, expected: `{"key": "value"}`, }, { name: "Valid JSON array", input: `[1, 2, "three"]`, expected: `[1, 2, "three"]`, }, { name: "Fake JSON (starts with { but doesn't end with })", input: `{"key": "value"`, // Missing closing bracket, has spaces expected: `"{\"key\": \"value\""`, }, { name: "Empty JSON (object)", input: `{ }`, expected: `{ }`, }, // 7. Edge Cases { name: "Empty string", input: "", expected: "", }, { name: "Whitespace only string", input: " ", expected: `" "`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { actual := formatFieldValue(tt.input) if actual != tt.expected { t.Errorf("formatFieldValue() = %q, expected %q", actual, tt.expected) } }) } } ================================================ FILE: pkg/mcp/manager.go ================================================ package mcp import ( "bufio" "context" "errors" "fmt" "net/http" "os" "os/exec" "path/filepath" "strings" "sync" "sync/atomic" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" ) // headerTransport is an http.RoundTripper that adds custom headers to requests type headerTransport struct { base http.RoundTripper headers map[string]string } func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) { // Clone the request to avoid modifying the original req = req.Clone(req.Context()) // Add custom headers for key, value := range t.headers { req.Header.Set(key, value) } // Use the base transport base := t.base if base == nil { base = http.DefaultTransport } return base.RoundTrip(req) } // loadEnvFile loads environment variables from a file in .env format // Each line should be in the format: KEY=value // Lines starting with # are comments // Empty lines are ignored func loadEnvFile(path string) (map[string]string, error) { file, err := os.Open(path) if err != nil { return nil, fmt.Errorf("failed to open env file: %w", err) } defer file.Close() envVars := make(map[string]string) scanner := bufio.NewScanner(file) lineNum := 0 for scanner.Scan() { lineNum++ line := strings.TrimSpace(scanner.Text()) // Skip empty lines and comments if line == "" || strings.HasPrefix(line, "#") { continue } // Parse KEY=value parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { return nil, fmt.Errorf("invalid format at line %d: %s", lineNum, line) } key := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) if key == "" { return nil, fmt.Errorf("invalid format at line %d: empty key", lineNum) } // Remove surrounding quotes if present if len(value) >= 2 { if (value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'') { value = value[1 : len(value)-1] } } envVars[key] = value } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("error reading env file: %w", err) } return envVars, nil } // ServerConnection represents a connection to an MCP server type ServerConnection struct { Name string Client *mcp.Client Session *mcp.ClientSession Tools []*mcp.Tool } // Manager manages multiple MCP server connections type Manager struct { servers map[string]*ServerConnection mu sync.RWMutex closed atomic.Bool // changed from bool to atomic.Bool to avoid TOCTOU race wg sync.WaitGroup // tracks in-flight CallTool calls } // NewManager creates a new MCP manager func NewManager() *Manager { return &Manager{ servers: make(map[string]*ServerConnection), } } // LoadFromConfig loads MCP servers from configuration func (m *Manager) LoadFromConfig(ctx context.Context, cfg *config.Config) error { return m.LoadFromMCPConfig(ctx, cfg.Tools.MCP, cfg.WorkspacePath()) } // LoadFromMCPConfig loads MCP servers from MCP configuration and workspace path. // This is the minimal dependency version that doesn't require the full Config object. func (m *Manager) LoadFromMCPConfig( ctx context.Context, mcpCfg config.MCPConfig, workspacePath string, ) error { if !mcpCfg.Enabled { logger.InfoCF("mcp", "MCP integration is disabled", nil) return nil } if len(mcpCfg.Servers) == 0 { logger.InfoCF("mcp", "No MCP servers configured", nil) return nil } logger.InfoCF("mcp", "Initializing MCP servers", map[string]any{ "count": len(mcpCfg.Servers), }) var wg sync.WaitGroup errs := make(chan error, len(mcpCfg.Servers)) enabledCount := 0 for name, serverCfg := range mcpCfg.Servers { if !serverCfg.Enabled { logger.DebugCF("mcp", "Skipping disabled server", map[string]any{ "server": name, }) continue } enabledCount++ wg.Add(1) go func(name string, serverCfg config.MCPServerConfig, workspace string) { defer wg.Done() // Resolve relative envFile paths relative to workspace if serverCfg.EnvFile != "" && !filepath.IsAbs(serverCfg.EnvFile) { if workspace == "" { err := fmt.Errorf( "workspace path is empty while resolving relative envFile %q for server %s", serverCfg.EnvFile, name, ) logger.ErrorCF("mcp", "Invalid MCP server configuration", map[string]any{ "server": name, "env_file": serverCfg.EnvFile, "error": err.Error(), }) errs <- err return } serverCfg.EnvFile = filepath.Join(workspace, serverCfg.EnvFile) } if err := m.ConnectServer(ctx, name, serverCfg); err != nil { logger.ErrorCF("mcp", "Failed to connect to MCP server", map[string]any{ "server": name, "error": err.Error(), }) errs <- fmt.Errorf("failed to connect to server %s: %w", name, err) } }(name, serverCfg, workspacePath) } wg.Wait() close(errs) // Collect errors var allErrors []error for err := range errs { allErrors = append(allErrors, err) } connectedCount := len(m.GetServers()) // If all enabled servers failed to connect, return aggregated error if enabledCount > 0 && connectedCount == 0 { logger.ErrorCF("mcp", "All MCP servers failed to connect", map[string]any{ "failed": len(allErrors), "total": enabledCount, }) return errors.Join(allErrors...) } if len(allErrors) > 0 { logger.WarnCF("mcp", "Some MCP servers failed to connect", map[string]any{ "failed": len(allErrors), "connected": connectedCount, "total": enabledCount, }) // Don't fail completely if some servers successfully connected } logger.InfoCF("mcp", "MCP server initialization complete", map[string]any{ "connected": connectedCount, "total": enabledCount, }) return nil } // ConnectServer connects to a single MCP server func (m *Manager) ConnectServer( ctx context.Context, name string, cfg config.MCPServerConfig, ) error { logger.InfoCF("mcp", "Connecting to MCP server", map[string]any{ "server": name, "command": cfg.Command, "args_count": len(cfg.Args), }) // Create client client := mcp.NewClient(&mcp.Implementation{ Name: "picoclaw", Version: "1.0.0", }, nil) // Create transport based on configuration // Auto-detect transport type if not explicitly specified var transport mcp.Transport transportType := cfg.Type // Auto-detect: if URL is provided, use SSE; if command is provided, use stdio if transportType == "" { if cfg.URL != "" { transportType = "sse" } else if cfg.Command != "" { transportType = "stdio" } else { return fmt.Errorf("either URL or command must be provided") } } switch transportType { case "sse", "http": if cfg.URL == "" { return fmt.Errorf("URL is required for SSE/HTTP transport") } logger.DebugCF("mcp", "Using SSE/HTTP transport", map[string]any{ "server": name, "url": cfg.URL, }) sseTransport := &mcp.StreamableClientTransport{ Endpoint: cfg.URL, } // Add custom headers if provided if len(cfg.Headers) > 0 { // Create a custom HTTP client with header-injecting transport sseTransport.HTTPClient = &http.Client{ Transport: &headerTransport{ base: http.DefaultTransport, headers: cfg.Headers, }, } logger.DebugCF("mcp", "Added custom HTTP headers", map[string]any{ "server": name, "header_count": len(cfg.Headers), }) } transport = sseTransport case "stdio": if cfg.Command == "" { return fmt.Errorf("command is required for stdio transport") } logger.DebugCF("mcp", "Using stdio transport", map[string]any{ "server": name, "command": cfg.Command, }) // Create command with context cmd := exec.CommandContext(ctx, cfg.Command, cfg.Args...) // Build environment variables with proper override semantics // Use a map to ensure config variables override file variables envMap := make(map[string]string) // Start with parent process environment for _, e := range cmd.Environ() { if idx := strings.Index(e, "="); idx > 0 { envMap[e[:idx]] = e[idx+1:] } } // Load environment variables from file if specified if cfg.EnvFile != "" { envVars, err := loadEnvFile(cfg.EnvFile) if err != nil { return fmt.Errorf("failed to load env file %s: %w", cfg.EnvFile, err) } for k, v := range envVars { envMap[k] = v } logger.DebugCF("mcp", "Loaded environment variables from file", map[string]any{ "server": name, "envFile": cfg.EnvFile, "var_count": len(envVars), }) } // Environment variables from config override those from file for k, v := range cfg.Env { envMap[k] = v } // Convert map to slice env := make([]string, 0, len(envMap)) for k, v := range envMap { env = append(env, fmt.Sprintf("%s=%s", k, v)) } cmd.Env = env transport = &mcp.CommandTransport{Command: cmd} default: return fmt.Errorf( "unsupported transport type: %s (supported: stdio, sse, http)", transportType, ) } // Connect to server session, err := client.Connect(ctx, transport, nil) if err != nil { return fmt.Errorf("failed to connect: %w", err) } // Get server info initResult := session.InitializeResult() logger.InfoCF("mcp", "Connected to MCP server", map[string]any{ "server": name, "serverName": initResult.ServerInfo.Name, "serverVersion": initResult.ServerInfo.Version, "protocol": initResult.ProtocolVersion, }) // List available tools if supported var tools []*mcp.Tool if initResult.Capabilities.Tools != nil { for tool, err := range session.Tools(ctx, nil) { if err != nil { logger.WarnCF("mcp", "Error listing tool", map[string]any{ "server": name, "error": err.Error(), }) continue } tools = append(tools, tool) } logger.InfoCF("mcp", "Listed tools from MCP server", map[string]any{ "server": name, "toolCount": len(tools), }) } // Store connection m.mu.Lock() m.servers[name] = &ServerConnection{ Name: name, Client: client, Session: session, Tools: tools, } m.mu.Unlock() return nil } // GetServers returns all connected servers func (m *Manager) GetServers() map[string]*ServerConnection { m.mu.RLock() defer m.mu.RUnlock() result := make(map[string]*ServerConnection, len(m.servers)) for k, v := range m.servers { result[k] = v } return result } // GetServer returns a specific server connection func (m *Manager) GetServer(name string) (*ServerConnection, bool) { m.mu.RLock() defer m.mu.RUnlock() conn, ok := m.servers[name] return conn, ok } // CallTool calls a tool on a specific server func (m *Manager) CallTool( ctx context.Context, serverName, toolName string, arguments map[string]any, ) (*mcp.CallToolResult, error) { // Check if closed before acquiring lock (fast path) if m.closed.Load() { return nil, fmt.Errorf("manager is closed") } m.mu.RLock() // Double-check after acquiring lock to prevent TOCTOU race if m.closed.Load() { m.mu.RUnlock() return nil, fmt.Errorf("manager is closed") } conn, ok := m.servers[serverName] if ok { m.wg.Add(1) // Add to WaitGroup while holding the lock } m.mu.RUnlock() if !ok { return nil, fmt.Errorf("server %s not found", serverName) } defer m.wg.Done() params := &mcp.CallToolParams{ Name: toolName, Arguments: arguments, } result, err := conn.Session.CallTool(ctx, params) if err != nil { return nil, fmt.Errorf("failed to call tool: %w", err) } return result, nil } // Close closes all server connections func (m *Manager) Close() error { // Use Swap to atomically set closed=true and get the previous value // This prevents TOCTOU race with CallTool's closed check if m.closed.Swap(true) { return nil // already closed } // Wait for all in-flight CallTool calls to finish before closing sessions // After closed=true is set, no new CallTool can start (they check closed first) m.wg.Wait() m.mu.Lock() defer m.mu.Unlock() logger.InfoCF("mcp", "Closing all MCP server connections", map[string]any{ "count": len(m.servers), }) var errs []error for name, conn := range m.servers { if err := conn.Session.Close(); err != nil { logger.ErrorCF("mcp", "Failed to close server connection", map[string]any{ "server": name, "error": err.Error(), }) errs = append(errs, fmt.Errorf("server %s: %w", name, err)) } } m.servers = make(map[string]*ServerConnection) if len(errs) > 0 { return fmt.Errorf("failed to close %d server(s): %w", len(errs), errors.Join(errs...)) } return nil } // GetAllTools returns all tools from all connected servers func (m *Manager) GetAllTools() map[string][]*mcp.Tool { m.mu.RLock() defer m.mu.RUnlock() result := make(map[string][]*mcp.Tool) for name, conn := range m.servers { if len(conn.Tools) > 0 { result[name] = conn.Tools } } return result } ================================================ FILE: pkg/mcp/manager_test.go ================================================ package mcp import ( "context" "os" "path/filepath" "strings" "testing" sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/sipeed/picoclaw/pkg/config" ) func TestLoadEnvFile(t *testing.T) { tests := []struct { name string content string expected map[string]string expectErr bool }{ { name: "basic env file", content: `API_KEY=secret123 DATABASE_URL=postgres://localhost/db PORT=8080`, expected: map[string]string{ "API_KEY": "secret123", "DATABASE_URL": "postgres://localhost/db", "PORT": "8080", }, expectErr: false, }, { name: "with comments and empty lines", content: `# This is a comment API_KEY=secret123 # Another comment DATABASE_URL=postgres://localhost/db PORT=8080`, expected: map[string]string{ "API_KEY": "secret123", "DATABASE_URL": "postgres://localhost/db", "PORT": "8080", }, expectErr: false, }, { name: "with quoted values", content: `API_KEY="secret with spaces" NAME='single quoted' PLAIN=no-quotes`, expected: map[string]string{ "API_KEY": "secret with spaces", "NAME": "single quoted", "PLAIN": "no-quotes", }, expectErr: false, }, { name: "with spaces around equals", content: `API_KEY = secret123 DATABASE_URL= postgres://localhost/db PORT =8080`, expected: map[string]string{ "API_KEY": "secret123", "DATABASE_URL": "postgres://localhost/db", "PORT": "8080", }, expectErr: false, }, { name: "invalid format - no equals", content: `INVALID_LINE`, expectErr: true, }, { name: "empty file", content: ``, expected: map[string]string{}, expectErr: false, }, { name: "only comments", content: `# Comment 1 # Comment 2`, expected: map[string]string{}, expectErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir := t.TempDir() envFile := filepath.Join(tmpDir, ".env") if err := os.WriteFile(envFile, []byte(tt.content), 0o644); err != nil { t.Fatalf("Failed to create test file: %v", err) } result, err := loadEnvFile(envFile) if tt.expectErr { if err == nil { t.Errorf("Expected error but got none") } return } if err != nil { t.Errorf("Unexpected error: %v", err) return } if len(result) != len(tt.expected) { t.Errorf("Expected %d variables, got %d", len(tt.expected), len(result)) } for key, expectedValue := range tt.expected { if actualValue, ok := result[key]; !ok { t.Errorf("Expected key %s not found", key) } else if actualValue != expectedValue { t.Errorf("For key %s: expected %q, got %q", key, expectedValue, actualValue) } } }) } } func TestLoadEnvFileNotFound(t *testing.T) { _, err := loadEnvFile("/nonexistent/file.env") if err == nil { t.Error("Expected error for nonexistent file") } } func TestEnvFilePriority(t *testing.T) { // Create a temporary .env file tmpDir := t.TempDir() envFile := filepath.Join(tmpDir, ".env") envContent := `API_KEY=from_file DATABASE_URL=from_file SHARED_VAR=from_file` if err := os.WriteFile(envFile, []byte(envContent), 0o644); err != nil { t.Fatalf("Failed to create .env file: %v", err) } // Load envFile envVars, err := loadEnvFile(envFile) if err != nil { t.Fatalf("Failed to load env file: %v", err) } // Verify envFile variables if envVars["API_KEY"] != "from_file" { t.Errorf("Expected API_KEY=from_file, got %s", envVars["API_KEY"]) } // Simulate config.Env overriding envFile configEnv := map[string]string{ "SHARED_VAR": "from_config", "NEW_VAR": "from_config", } // Merge: envFile first, then config overrides merged := make(map[string]string) for k, v := range envVars { merged[k] = v } for k, v := range configEnv { merged[k] = v } // Verify priority: config.Env should override envFile if merged["SHARED_VAR"] != "from_config" { t.Errorf( "Expected SHARED_VAR=from_config (config should override file), got %s", merged["SHARED_VAR"], ) } if merged["API_KEY"] != "from_file" { t.Errorf("Expected API_KEY=from_file, got %s", merged["API_KEY"]) } if merged["NEW_VAR"] != "from_config" { t.Errorf("Expected NEW_VAR=from_config, got %s", merged["NEW_VAR"]) } } func TestLoadFromMCPConfig_EmptyWorkspaceWithRelativeEnvFile(t *testing.T) { mgr := NewManager() mcpCfg := config.MCPConfig{ ToolConfig: config.ToolConfig{ Enabled: true, }, Servers: map[string]config.MCPServerConfig{ "test-server": { Enabled: true, Command: "echo", Args: []string{"ok"}, EnvFile: ".env", }, }, } err := mgr.LoadFromMCPConfig(context.Background(), mcpCfg, "") if err == nil { t.Fatal("expected error for relative env_file with empty workspace path, got nil") } if !strings.Contains(err.Error(), "workspace path is empty") { t.Fatalf("expected workspace path validation error, got: %v", err) } } func TestNewManager_InitialState(t *testing.T) { mgr := NewManager() if mgr == nil { t.Fatal("expected manager instance, got nil") } if len(mgr.GetServers()) != 0 { t.Fatalf("expected no servers on new manager, got %d", len(mgr.GetServers())) } } func TestLoadFromMCPConfig_DisabledOrEmptyServers(t *testing.T) { mgr := NewManager() err := mgr.LoadFromMCPConfig( context.Background(), config.MCPConfig{ToolConfig: config.ToolConfig{Enabled: false}}, "/tmp", ) if err != nil { t.Fatalf("expected nil error when MCP disabled, got: %v", err) } err = mgr.LoadFromMCPConfig( context.Background(), config.MCPConfig{ToolConfig: config.ToolConfig{Enabled: true}}, "/tmp", ) if err != nil { t.Fatalf("expected nil error when no servers configured, got: %v", err) } } func TestGetServers_ReturnsCopy(t *testing.T) { mgr := NewManager() mgr.servers["s1"] = &ServerConnection{Name: "s1"} servers := mgr.GetServers() delete(servers, "s1") if _, ok := mgr.GetServer("s1"); !ok { t.Fatal("expected internal manager state to remain unchanged") } } func TestGetAllTools_FiltersEmptyTools(t *testing.T) { mgr := NewManager() mgr.servers["empty"] = &ServerConnection{Name: "empty", Tools: nil} mgr.servers["with-tools"] = &ServerConnection{Name: "with-tools", Tools: []*sdkmcp.Tool{{}}} all := mgr.GetAllTools() if _, ok := all["empty"]; ok { t.Fatal("expected server without tools to be excluded") } if _, ok := all["with-tools"]; !ok { t.Fatal("expected server with tools to be included") } } func TestCallTool_ErrorsForClosedOrMissingServer(t *testing.T) { t.Run("manager closed", func(t *testing.T) { mgr := NewManager() mgr.closed.Store(true) _, err := mgr.CallTool(context.Background(), "s1", "tool", nil) if err == nil || !strings.Contains(err.Error(), "manager is closed") { t.Fatalf("expected manager closed error, got: %v", err) } }) t.Run("server missing", func(t *testing.T) { mgr := NewManager() _, err := mgr.CallTool(context.Background(), "missing", "tool", nil) if err == nil || !strings.Contains(err.Error(), "not found") { t.Fatalf("expected server not found error, got: %v", err) } }) } func TestClose_IdempotentOnEmptyManager(t *testing.T) { mgr := NewManager() if err := mgr.Close(); err != nil { t.Fatalf("first close should succeed, got: %v", err) } if err := mgr.Close(); err != nil { t.Fatalf("second close should be idempotent, got: %v", err) } } ================================================ FILE: pkg/media/store.go ================================================ package media import ( "fmt" "os" "sync" "time" "github.com/google/uuid" "github.com/sipeed/picoclaw/pkg/logger" ) // MediaMeta holds metadata about a stored media file. type MediaMeta struct { Filename string ContentType string Source string // "telegram", "discord", "tool:image-gen", etc. } // MediaStore manages the lifecycle of media files associated with processing scopes. type MediaStore interface { // Store registers an existing local file under the given scope. // Returns a ref identifier (e.g. "media://"). // Store does not move or copy the file; it only records the mapping. Store(localPath string, meta MediaMeta, scope string) (ref string, err error) // Resolve returns the local file path for a given ref. Resolve(ref string) (localPath string, err error) // ResolveWithMeta returns the local file path and metadata for a given ref. ResolveWithMeta(ref string) (localPath string, meta MediaMeta, err error) // ReleaseAll deletes all files registered under the given scope // and removes the mapping entries. File-not-exist errors are ignored. ReleaseAll(scope string) error } // mediaEntry holds the path and metadata for a stored media file. type mediaEntry struct { path string meta MediaMeta storedAt time.Time } // MediaCleanerConfig configures the background TTL cleanup. type MediaCleanerConfig struct { Enabled bool MaxAge time.Duration Interval time.Duration } // FileMediaStore is a pure in-memory implementation of MediaStore. // Files are expected to already exist on disk (e.g. in /tmp/picoclaw_media/). type FileMediaStore struct { mu sync.RWMutex refs map[string]mediaEntry scopeToRefs map[string]map[string]struct{} refToScope map[string]string cleanerCfg MediaCleanerConfig stop chan struct{} startOnce sync.Once stopOnce sync.Once nowFunc func() time.Time // for testing } // NewFileMediaStore creates a new FileMediaStore without background cleanup. func NewFileMediaStore() *FileMediaStore { return &FileMediaStore{ refs: make(map[string]mediaEntry), scopeToRefs: make(map[string]map[string]struct{}), refToScope: make(map[string]string), nowFunc: time.Now, } } // NewFileMediaStoreWithCleanup creates a FileMediaStore with TTL-based background cleanup. func NewFileMediaStoreWithCleanup(cfg MediaCleanerConfig) *FileMediaStore { return &FileMediaStore{ refs: make(map[string]mediaEntry), scopeToRefs: make(map[string]map[string]struct{}), refToScope: make(map[string]string), cleanerCfg: cfg, stop: make(chan struct{}), nowFunc: time.Now, } } // Store registers a local file under the given scope. The file must exist. func (s *FileMediaStore) Store(localPath string, meta MediaMeta, scope string) (string, error) { if _, err := os.Stat(localPath); err != nil { return "", fmt.Errorf("media store: %s: %w", localPath, err) } ref := "media://" + uuid.New().String() s.mu.Lock() defer s.mu.Unlock() s.refs[ref] = mediaEntry{path: localPath, meta: meta, storedAt: s.nowFunc()} if s.scopeToRefs[scope] == nil { s.scopeToRefs[scope] = make(map[string]struct{}) } s.scopeToRefs[scope][ref] = struct{}{} s.refToScope[ref] = scope return ref, nil } // Resolve returns the local path for the given ref. func (s *FileMediaStore) Resolve(ref string) (string, error) { s.mu.RLock() defer s.mu.RUnlock() entry, ok := s.refs[ref] if !ok { return "", fmt.Errorf("media store: unknown ref: %s", ref) } return entry.path, nil } // ResolveWithMeta returns the local path and metadata for the given ref. func (s *FileMediaStore) ResolveWithMeta(ref string) (string, MediaMeta, error) { s.mu.RLock() defer s.mu.RUnlock() entry, ok := s.refs[ref] if !ok { return "", MediaMeta{}, fmt.Errorf("media store: unknown ref: %s", ref) } return entry.path, entry.meta, nil } // ReleaseAll removes all files under the given scope and cleans up mappings. // Phase 1 (under lock): remove entries from maps. // Phase 2 (no lock): delete files from disk. func (s *FileMediaStore) ReleaseAll(scope string) error { // Phase 1: collect paths and remove from maps under lock var paths []string s.mu.Lock() refs, ok := s.scopeToRefs[scope] if !ok { s.mu.Unlock() return nil } for ref := range refs { if entry, exists := s.refs[ref]; exists { paths = append(paths, entry.path) } delete(s.refs, ref) delete(s.refToScope, ref) } delete(s.scopeToRefs, scope) s.mu.Unlock() // Phase 2: delete files without holding the lock for _, p := range paths { if err := os.Remove(p); err != nil && !os.IsNotExist(err) { logger.WarnCF("media", "release: failed to remove file", map[string]any{ "path": p, "error": err.Error(), }) } } return nil } // CleanExpired removes all entries older than MaxAge. // Phase 1 (under lock): identify expired entries and remove from maps. // Phase 2 (no lock): delete files from disk to minimize lock contention. func (s *FileMediaStore) CleanExpired() int { if s.cleanerCfg.MaxAge <= 0 { return 0 } // Phase 1: collect expired entries under lock type expiredEntry struct { ref string path string } s.mu.Lock() cutoff := s.nowFunc().Add(-s.cleanerCfg.MaxAge) var expired []expiredEntry for ref, entry := range s.refs { if entry.storedAt.Before(cutoff) { expired = append(expired, expiredEntry{ref: ref, path: entry.path}) if scope, ok := s.refToScope[ref]; ok { if scopeRefs, ok := s.scopeToRefs[scope]; ok { delete(scopeRefs, ref) if len(scopeRefs) == 0 { delete(s.scopeToRefs, scope) } } } delete(s.refs, ref) delete(s.refToScope, ref) } } s.mu.Unlock() // Phase 2: delete files without holding the lock for _, e := range expired { if err := os.Remove(e.path); err != nil && !os.IsNotExist(err) { logger.WarnCF("media", "cleanup: failed to remove file", map[string]any{ "path": e.path, "error": err.Error(), }) } } return len(expired) } // Start begins the background cleanup goroutine if cleanup is enabled. // Safe to call multiple times; only the first call starts the goroutine. func (s *FileMediaStore) Start() { if !s.cleanerCfg.Enabled || s.stop == nil { return } if s.cleanerCfg.Interval <= 0 || s.cleanerCfg.MaxAge <= 0 { logger.WarnCF("media", "cleanup: skipped due to invalid config", map[string]any{ "interval": s.cleanerCfg.Interval.String(), "max_age": s.cleanerCfg.MaxAge.String(), }) return } s.startOnce.Do(func() { logger.InfoCF("media", "cleanup enabled", map[string]any{ "interval": s.cleanerCfg.Interval.String(), "max_age": s.cleanerCfg.MaxAge.String(), }) go func() { ticker := time.NewTicker(s.cleanerCfg.Interval) defer ticker.Stop() for { select { case <-ticker.C: if n := s.CleanExpired(); n > 0 { logger.InfoCF("media", "cleanup: removed expired entries", map[string]any{ "count": n, }) } case <-s.stop: return } } }() }) } // Stop terminates the background cleanup goroutine. // Safe to call multiple times; only the first call closes the channel. func (s *FileMediaStore) Stop() { if s.stop == nil { return } s.stopOnce.Do(func() { close(s.stop) }) } ================================================ FILE: pkg/media/store_test.go ================================================ package media import ( "fmt" "os" "path/filepath" "strings" "sync" "testing" "time" ) func createTempFile(t *testing.T, dir, name string) string { t.Helper() path := filepath.Join(dir, name) if err := os.WriteFile(path, []byte("test content"), 0o644); err != nil { t.Fatalf("failed to create temp file: %v", err) } return path } func TestStoreAndResolve(t *testing.T) { dir := t.TempDir() store := NewFileMediaStore() path := createTempFile(t, dir, "photo.jpg") ref, err := store.Store(path, MediaMeta{Filename: "photo.jpg", Source: "telegram"}, "scope1") if err != nil { t.Fatalf("Store failed: %v", err) } if !strings.HasPrefix(ref, "media://") { t.Errorf("ref should start with media://, got %q", ref) } resolved, err := store.Resolve(ref) if err != nil { t.Fatalf("Resolve failed: %v", err) } if resolved != path { t.Errorf("Resolve returned %q, want %q", resolved, path) } } func TestReleaseAll(t *testing.T) { dir := t.TempDir() store := NewFileMediaStore() paths := make([]string, 3) refs := make([]string, 3) for i := range 3 { paths[i] = createTempFile(t, dir, strings.Repeat("a", i+1)+".jpg") var err error refs[i], err = store.Store(paths[i], MediaMeta{Source: "test"}, "scope1") if err != nil { t.Fatalf("Store failed: %v", err) } } if err := store.ReleaseAll("scope1"); err != nil { t.Fatalf("ReleaseAll failed: %v", err) } // Files should be deleted for _, p := range paths { if _, err := os.Stat(p); !os.IsNotExist(err) { t.Errorf("file %q should have been deleted", p) } } // Refs should be unresolvable for _, ref := range refs { if _, err := store.Resolve(ref); err == nil { t.Errorf("Resolve(%q) should fail after ReleaseAll", ref) } } } func TestMultiScopeIsolation(t *testing.T) { dir := t.TempDir() store := NewFileMediaStore() pathA := createTempFile(t, dir, "fileA.jpg") pathB := createTempFile(t, dir, "fileB.jpg") refA, _ := store.Store(pathA, MediaMeta{Source: "test"}, "scopeA") refB, _ := store.Store(pathB, MediaMeta{Source: "test"}, "scopeB") // Release only scopeA if err := store.ReleaseAll("scopeA"); err != nil { t.Fatalf("ReleaseAll(scopeA) failed: %v", err) } // scopeA file should be gone if _, err := os.Stat(pathA); !os.IsNotExist(err) { t.Error("file A should have been deleted") } if _, err := store.Resolve(refA); err == nil { t.Error("refA should be unresolvable after release") } // scopeB file should still exist if _, err := os.Stat(pathB); err != nil { t.Error("file B should still exist") } resolved, err := store.Resolve(refB) if err != nil { t.Fatalf("refB should still resolve: %v", err) } if resolved != pathB { t.Errorf("resolved %q, want %q", resolved, pathB) } } func TestReleaseAllIdempotent(t *testing.T) { store := NewFileMediaStore() // ReleaseAll on non-existent scope should not error if err := store.ReleaseAll("nonexistent"); err != nil { t.Fatalf("ReleaseAll on empty scope should not error: %v", err) } // Create and release, then release again dir := t.TempDir() path := createTempFile(t, dir, "file.jpg") _, _ = store.Store(path, MediaMeta{Source: "test"}, "scope1") if err := store.ReleaseAll("scope1"); err != nil { t.Fatalf("first ReleaseAll failed: %v", err) } if err := store.ReleaseAll("scope1"); err != nil { t.Fatalf("second ReleaseAll should not error: %v", err) } } func TestReleaseAllCleansMappingsIfRefsMissing(t *testing.T) { dir := t.TempDir() store := NewFileMediaStore() path := createTempFile(t, dir, "file.jpg") ref, err := store.Store(path, MediaMeta{Source: "test"}, "scope1") if err != nil { t.Fatalf("Store failed: %v", err) } // Simulate internal inconsistency: scopeToRefs/refToScope contains ref but refs map doesn't. store.mu.Lock() delete(store.refs, ref) store.mu.Unlock() if err := store.ReleaseAll("scope1"); err != nil { t.Fatalf("ReleaseAll failed: %v", err) } // ReleaseAll should still clean mappings (even if it can't delete the file without the path). store.mu.RLock() defer store.mu.RUnlock() if _, ok := store.refToScope[ref]; ok { t.Error("refToScope should not contain ref after ReleaseAll") } if _, ok := store.scopeToRefs["scope1"]; ok { t.Error("scopeToRefs should not contain scope1 after ReleaseAll") } } func TestStoreNonexistentFile(t *testing.T) { store := NewFileMediaStore() _, err := store.Store("/nonexistent/path/file.jpg", MediaMeta{Source: "test"}, "scope1") if err == nil { t.Error("Store should fail for nonexistent file") } // Error message should include the underlying os error, not just "file does not exist" if !strings.Contains(err.Error(), "no such file or directory") && !strings.Contains(err.Error(), "cannot find") { t.Errorf("Error should contain OS error detail, got: %v", err) } } func TestResolveWithMeta(t *testing.T) { dir := t.TempDir() store := NewFileMediaStore() path := createTempFile(t, dir, "image.png") meta := MediaMeta{ Filename: "image.png", ContentType: "image/png", Source: "telegram", } ref, err := store.Store(path, meta, "scope1") if err != nil { t.Fatalf("Store failed: %v", err) } resolvedPath, resolvedMeta, err := store.ResolveWithMeta(ref) if err != nil { t.Fatalf("ResolveWithMeta failed: %v", err) } if resolvedPath != path { t.Errorf("ResolveWithMeta path = %q, want %q", resolvedPath, path) } if resolvedMeta.Filename != meta.Filename { t.Errorf("ResolveWithMeta Filename = %q, want %q", resolvedMeta.Filename, meta.Filename) } if resolvedMeta.ContentType != meta.ContentType { t.Errorf("ResolveWithMeta ContentType = %q, want %q", resolvedMeta.ContentType, meta.ContentType) } if resolvedMeta.Source != meta.Source { t.Errorf("ResolveWithMeta Source = %q, want %q", resolvedMeta.Source, meta.Source) } // Unknown ref should fail _, _, err = store.ResolveWithMeta("media://nonexistent") if err == nil { t.Error("ResolveWithMeta should fail for unknown ref") } } func TestConcurrentSafety(t *testing.T) { dir := t.TempDir() store := NewFileMediaStore() const goroutines = 20 const filesPerGoroutine = 5 var wg sync.WaitGroup wg.Add(goroutines) for g := range goroutines { go func(gIdx int) { defer wg.Done() scope := strings.Repeat("s", gIdx+1) for i := range filesPerGoroutine { path := createTempFile(t, dir, strings.Repeat("f", gIdx*filesPerGoroutine+i+1)+".tmp") ref, err := store.Store(path, MediaMeta{Source: "test"}, scope) if err != nil { t.Errorf("Store failed: %v", err) return } if _, err := store.Resolve(ref); err != nil { t.Errorf("Resolve failed: %v", err) } } if err := store.ReleaseAll(scope); err != nil { t.Errorf("ReleaseAll failed: %v", err) } }(g) } wg.Wait() } // --- TTL cleanup tests --- func newTestStoreWithCleanup(maxAge time.Duration) *FileMediaStore { s := NewFileMediaStoreWithCleanup(MediaCleanerConfig{ Enabled: true, MaxAge: maxAge, Interval: time.Hour, // won't tick in tests }) return s } func TestCleanExpiredRemovesOldEntries(t *testing.T) { dir := t.TempDir() now := time.Now() store := newTestStoreWithCleanup(10 * time.Minute) store.nowFunc = func() time.Time { return now.Add(-20 * time.Minute) } path := createTempFile(t, dir, "old.jpg") ref, err := store.Store(path, MediaMeta{Source: "test"}, "scope1") if err != nil { t.Fatalf("Store failed: %v", err) } // Advance clock to present store.nowFunc = func() time.Time { return now } removed := store.CleanExpired() if removed != 1 { t.Errorf("expected 1 removed, got %d", removed) } if _, err := store.Resolve(ref); err == nil { t.Error("expired ref should be unresolvable") } if _, err := os.Stat(path); !os.IsNotExist(err) { t.Error("expired file should be deleted") } } func TestCleanExpiredKeepsNonExpired(t *testing.T) { dir := t.TempDir() now := time.Now() store := newTestStoreWithCleanup(10 * time.Minute) store.nowFunc = func() time.Time { return now } path := createTempFile(t, dir, "fresh.jpg") ref, err := store.Store(path, MediaMeta{Source: "test"}, "scope1") if err != nil { t.Fatalf("Store failed: %v", err) } removed := store.CleanExpired() if removed != 0 { t.Errorf("expected 0 removed, got %d", removed) } if _, err := store.Resolve(ref); err != nil { t.Errorf("fresh ref should still resolve: %v", err) } if _, err := os.Stat(path); err != nil { t.Error("fresh file should still exist") } } func TestCleanExpiredMixedAges(t *testing.T) { dir := t.TempDir() now := time.Now() store := newTestStoreWithCleanup(10 * time.Minute) // Store old entry store.nowFunc = func() time.Time { return now.Add(-20 * time.Minute) } oldPath := createTempFile(t, dir, "old.jpg") oldRef, _ := store.Store(oldPath, MediaMeta{Source: "test"}, "scope1") // Store fresh entry store.nowFunc = func() time.Time { return now } freshPath := createTempFile(t, dir, "fresh.jpg") freshRef, _ := store.Store(freshPath, MediaMeta{Source: "test"}, "scope1") removed := store.CleanExpired() if removed != 1 { t.Errorf("expected 1 removed, got %d", removed) } if _, err := store.Resolve(oldRef); err == nil { t.Error("old ref should be gone") } if _, err := store.Resolve(freshRef); err != nil { t.Errorf("fresh ref should still resolve: %v", err) } } func TestCleanExpiredCleansEmptyScopes(t *testing.T) { dir := t.TempDir() now := time.Now() store := newTestStoreWithCleanup(10 * time.Minute) // Store old entry as the only one in scope store.nowFunc = func() time.Time { return now.Add(-20 * time.Minute) } path := createTempFile(t, dir, "only.jpg") store.Store(path, MediaMeta{Source: "test"}, "lonely_scope") store.nowFunc = func() time.Time { return now } store.CleanExpired() store.mu.RLock() defer store.mu.RUnlock() if _, ok := store.scopeToRefs["lonely_scope"]; ok { t.Error("empty scope should be cleaned up") } } func TestStartStopLifecycle(t *testing.T) { store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{ Enabled: true, MaxAge: time.Minute, Interval: 50 * time.Millisecond, }) // Start and stop should not panic store.Start() // Double start should not spawn a second goroutine store.Start() time.Sleep(100 * time.Millisecond) store.Stop() // Double stop should not panic store.Stop() } func TestCleanExpiredZeroMaxAge(t *testing.T) { store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{ Enabled: true, MaxAge: 0, Interval: time.Hour, }) dir := t.TempDir() path := createTempFile(t, dir, "file.jpg") ref, _ := store.Store(path, MediaMeta{Source: "test"}, "scope1") // Zero MaxAge should be a no-op removed := store.CleanExpired() if removed != 0 { t.Errorf("expected 0 removed with zero MaxAge, got %d", removed) } if _, err := store.Resolve(ref); err != nil { t.Errorf("ref should still resolve: %v", err) } } func TestStartDisabledIsNoop(t *testing.T) { store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{ Enabled: false, MaxAge: time.Minute, Interval: time.Minute, }) // Should not start any goroutine or panic store.Start() store.Stop() } func TestStartZeroIntervalNoPanic(t *testing.T) { store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{ Enabled: true, MaxAge: time.Minute, Interval: 0, }) // Zero interval should not panic (time.NewTicker panics on <= 0) store.Start() store.Stop() } func TestStartZeroMaxAgeNoPanic(t *testing.T) { store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{ Enabled: true, MaxAge: 0, Interval: time.Minute, }) store.Start() store.Stop() } func TestConcurrentCleanupSafety(t *testing.T) { dir := t.TempDir() store := newTestStoreWithCleanup(50 * time.Millisecond) store.nowFunc = time.Now const workers = 10 const ops = 20 var wg sync.WaitGroup wg.Add(workers * 4) // Store workers for w := range workers { go func(wIdx int) { defer wg.Done() scope := fmt.Sprintf("scope-%d", wIdx) for i := range ops { p := createTempFile(t, dir, fmt.Sprintf("w%d-f%d.tmp", wIdx, i)) store.Store(p, MediaMeta{Source: "test"}, scope) } }(w) } // Resolve workers for range workers { go func() { defer wg.Done() for range ops { store.Resolve("media://nonexistent") } }() } // ReleaseAll workers for w := range workers { go func(wIdx int) { defer wg.Done() for range ops { store.ReleaseAll(fmt.Sprintf("scope-%d", wIdx)) } }(w) } // CleanExpired workers for range workers { go func() { defer wg.Done() for range ops { store.CleanExpired() } }() } wg.Wait() } func TestRefToScopeConsistency(t *testing.T) { dir := t.TempDir() store := NewFileMediaStore() // Store entries in two scopes ref1, _ := store.Store(createTempFile(t, dir, "a.jpg"), MediaMeta{Source: "test"}, "s1") ref2, _ := store.Store(createTempFile(t, dir, "b.jpg"), MediaMeta{Source: "test"}, "s1") ref3, _ := store.Store(createTempFile(t, dir, "c.jpg"), MediaMeta{Source: "test"}, "s2") store.mu.RLock() checkRef := func(ref, expectedScope string) { t.Helper() if scope, ok := store.refToScope[ref]; !ok || scope != expectedScope { t.Errorf("refToScope[%s] = %q, want %q", ref, scope, expectedScope) } } checkRef(ref1, "s1") checkRef(ref2, "s1") checkRef(ref3, "s2") store.mu.RUnlock() // Release s1 and verify refToScope is cleaned store.ReleaseAll("s1") store.mu.RLock() defer store.mu.RUnlock() if _, ok := store.refToScope[ref1]; ok { t.Error("refToScope should not contain ref1 after ReleaseAll") } if _, ok := store.refToScope[ref2]; ok { t.Error("refToScope should not contain ref2 after ReleaseAll") } if _, ok := store.refToScope[ref3]; !ok { t.Error("refToScope should still contain ref3") } } ================================================ FILE: pkg/media/tempdir.go ================================================ package media import ( "os" "path/filepath" ) const TempDirName = "picoclaw_media" // TempDir returns the shared temporary directory used for downloaded media. func TempDir() string { return filepath.Join(os.TempDir(), TempDirName) } ================================================ FILE: pkg/memory/jsonl.go ================================================ package memory import ( "bufio" "bytes" "context" "encoding/json" "fmt" "hash/fnv" "log" "os" "path/filepath" "strings" "sync" "time" "github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/providers" ) const ( // numLockShards is the fixed number of mutexes used to serialize // per-session access. Using a sharded array instead of a map keeps // memory bounded regardless of how many sessions are created over // the lifetime of the process — important for a long-running daemon. numLockShards = 64 // maxLineSize is the maximum size of a single JSON line in a .jsonl // file. Tool results (read_file, web search, etc.) can be large, so // we set a generous limit. The scanner starts at 64 KB and grows // only as needed up to this cap. maxLineSize = 10 * 1024 * 1024 // 10 MB ) // sessionMeta holds per-session metadata stored in a .meta.json file. type sessionMeta struct { Key string `json:"key"` Summary string `json:"summary"` Skip int `json:"skip"` Count int `json:"count"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // JSONLStore implements Store using append-only JSONL files. // // Each session is stored as two files: // // {sanitized_key}.jsonl — one JSON-encoded message per line, append-only // {sanitized_key}.meta.json — session metadata (summary, logical truncation offset) // // Messages are never physically deleted from the JSONL file. Instead, // TruncateHistory records a "skip" offset in the metadata file and // GetHistory ignores lines before that offset. This keeps all writes // append-only, which is both fast and crash-safe. type JSONLStore struct { dir string locks [numLockShards]sync.Mutex } // NewJSONLStore creates a new JSONL-backed store rooted at dir. func NewJSONLStore(dir string) (*JSONLStore, error) { err := os.MkdirAll(dir, 0o755) if err != nil { return nil, fmt.Errorf("memory: create directory: %w", err) } return &JSONLStore{dir: dir}, nil } // sessionLock returns a mutex for the given session key. // Keys are mapped to a fixed pool of shards via FNV hash, so // memory usage is O(1) regardless of total session count. func (s *JSONLStore) sessionLock(key string) *sync.Mutex { h := fnv.New32a() h.Write([]byte(key)) return &s.locks[h.Sum32()%numLockShards] } func (s *JSONLStore) jsonlPath(key string) string { return filepath.Join(s.dir, sanitizeKey(key)+".jsonl") } func (s *JSONLStore) metaPath(key string) string { return filepath.Join(s.dir, sanitizeKey(key)+".meta.json") } // sanitizeKey converts a session key to a safe filename component. // Mirrors pkg/session.sanitizeFilename so that migration paths match. // Replaces ':' with '_' (session key separator) and '/' and '\' with '_' // so composite IDs (e.g. Telegram forum "chatID/threadID", Slack "channel/thread_ts") // do not create subdirectories or break on Windows. func sanitizeKey(key string) string { s := strings.ReplaceAll(key, ":", "_") s = strings.ReplaceAll(s, "/", "_") s = strings.ReplaceAll(s, "\\", "_") return s } // readMeta loads the metadata file for a session. // Returns a zero-value sessionMeta if the file does not exist. func (s *JSONLStore) readMeta(key string) (sessionMeta, error) { data, err := os.ReadFile(s.metaPath(key)) if os.IsNotExist(err) { return sessionMeta{Key: key}, nil } if err != nil { return sessionMeta{}, fmt.Errorf("memory: read meta: %w", err) } var meta sessionMeta err = json.Unmarshal(data, &meta) if err != nil { return sessionMeta{}, fmt.Errorf("memory: decode meta: %w", err) } return meta, nil } // writeMeta atomically writes the metadata file using the project's // standard WriteFileAtomic (temp + fsync + rename). func (s *JSONLStore) writeMeta(key string, meta sessionMeta) error { data, err := json.MarshalIndent(meta, "", " ") if err != nil { return fmt.Errorf("memory: encode meta: %w", err) } return fileutil.WriteFileAtomic(s.metaPath(key), data, 0o644) } // readMessages reads valid JSON lines from a .jsonl file, skipping // the first `skip` lines without unmarshaling them. This avoids the // cost of json.Unmarshal on logically truncated messages. // Malformed trailing lines (e.g. from a crash) are silently skipped. func readMessages(path string, skip int) ([]providers.Message, error) { f, err := os.Open(path) if os.IsNotExist(err) { return []providers.Message{}, nil } if err != nil { return nil, fmt.Errorf("memory: open jsonl: %w", err) } defer f.Close() var msgs []providers.Message scanner := bufio.NewScanner(f) // Allow large lines for tool results (read_file, web search, etc.). scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize) lineNum := 0 for scanner.Scan() { line := scanner.Bytes() if len(line) == 0 { continue } lineNum++ if lineNum <= skip { continue } var msg providers.Message if err := json.Unmarshal(line, &msg); err != nil { // Corrupt line — likely a partial write from a crash. // Log so operators know data was skipped, but don't // fail the entire read; this is the standard JSONL // recovery pattern. log.Printf("memory: skipping corrupt line %d in %s: %v", lineNum, filepath.Base(path), err) continue } msgs = append(msgs, msg) } if scanner.Err() != nil { return nil, fmt.Errorf("memory: scan jsonl: %w", scanner.Err()) } if msgs == nil { msgs = []providers.Message{} } return msgs, nil } // countLines counts the total number of non-empty lines in a .jsonl file. // Used by TruncateHistory to reconcile a stale meta.Count without // the overhead of unmarshaling every message. func countLines(path string) (int, error) { f, err := os.Open(path) if os.IsNotExist(err) { return 0, nil } if err != nil { return 0, fmt.Errorf("memory: open jsonl: %w", err) } defer f.Close() n := 0 scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize) for scanner.Scan() { if len(scanner.Bytes()) > 0 { n++ } } return n, scanner.Err() } func (s *JSONLStore) AddMessage( _ context.Context, sessionKey, role, content string, ) error { return s.addMsg(sessionKey, providers.Message{ Role: role, Content: content, }) } func (s *JSONLStore) AddFullMessage( _ context.Context, sessionKey string, msg providers.Message, ) error { return s.addMsg(sessionKey, msg) } // addMsg is the shared implementation for AddMessage and AddFullMessage. func (s *JSONLStore) addMsg(sessionKey string, msg providers.Message) error { l := s.sessionLock(sessionKey) l.Lock() defer l.Unlock() // Append the message as a single JSON line. line, err := json.Marshal(msg) if err != nil { return fmt.Errorf("memory: marshal message: %w", err) } line = append(line, '\n') f, err := os.OpenFile( s.jsonlPath(sessionKey), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644, ) if err != nil { return fmt.Errorf("memory: open jsonl for append: %w", err) } _, writeErr := f.Write(line) if writeErr != nil { f.Close() return fmt.Errorf("memory: append message: %w", writeErr) } // Flush to physical storage before closing. This matches the // durability guarantee of writeMeta and rewriteJSONL (which use // WriteFileAtomic with fsync). Without Sync, a power loss could // leave the append in the kernel page cache only — lost on reboot. if syncErr := f.Sync(); syncErr != nil { f.Close() return fmt.Errorf("memory: sync jsonl: %w", syncErr) } if closeErr := f.Close(); closeErr != nil { return fmt.Errorf("memory: close jsonl: %w", closeErr) } // Update metadata. meta, err := s.readMeta(sessionKey) if err != nil { return err } now := time.Now() if meta.Count == 0 && meta.CreatedAt.IsZero() { meta.CreatedAt = now } meta.Count++ meta.UpdatedAt = now return s.writeMeta(sessionKey, meta) } func (s *JSONLStore) GetHistory( _ context.Context, sessionKey string, ) ([]providers.Message, error) { l := s.sessionLock(sessionKey) l.Lock() defer l.Unlock() meta, err := s.readMeta(sessionKey) if err != nil { return nil, err } // Pass meta.Skip so readMessages skips those lines without // unmarshaling them — avoids wasted CPU on truncated messages. msgs, err := readMessages(s.jsonlPath(sessionKey), meta.Skip) if err != nil { return nil, err } return msgs, nil } func (s *JSONLStore) GetSummary( _ context.Context, sessionKey string, ) (string, error) { l := s.sessionLock(sessionKey) l.Lock() defer l.Unlock() meta, err := s.readMeta(sessionKey) if err != nil { return "", err } return meta.Summary, nil } func (s *JSONLStore) SetSummary( _ context.Context, sessionKey, summary string, ) error { l := s.sessionLock(sessionKey) l.Lock() defer l.Unlock() meta, err := s.readMeta(sessionKey) if err != nil { return err } now := time.Now() if meta.CreatedAt.IsZero() { meta.CreatedAt = now } meta.Summary = summary meta.UpdatedAt = now return s.writeMeta(sessionKey, meta) } func (s *JSONLStore) TruncateHistory( _ context.Context, sessionKey string, keepLast int, ) error { l := s.sessionLock(sessionKey) l.Lock() defer l.Unlock() meta, err := s.readMeta(sessionKey) if err != nil { return err } // Always reconcile meta.Count with the actual line count on disk. // A crash between the JSONL append and the meta update in addMsg // leaves meta.Count stale (e.g. file has 101 lines but meta says // 100). Counting lines is cheap — no unmarshal, just a scan — and // TruncateHistory is not a hot path, so always re-count. n, countErr := countLines(s.jsonlPath(sessionKey)) if countErr != nil { return countErr } meta.Count = n if keepLast <= 0 { meta.Skip = meta.Count } else { effective := meta.Count - meta.Skip if keepLast < effective { meta.Skip = meta.Count - keepLast } } meta.UpdatedAt = time.Now() return s.writeMeta(sessionKey, meta) } func (s *JSONLStore) SetHistory( _ context.Context, sessionKey string, history []providers.Message, ) error { l := s.sessionLock(sessionKey) l.Lock() defer l.Unlock() meta, err := s.readMeta(sessionKey) if err != nil { return err } now := time.Now() if meta.CreatedAt.IsZero() { meta.CreatedAt = now } meta.Skip = 0 meta.Count = len(history) meta.UpdatedAt = now // Write meta BEFORE rewriting the JSONL file. If we crash between // the two writes, meta has Skip=0 and the old file is still intact, // so GetHistory reads from line 1 — returning "too many" messages // rather than losing data. The next SetHistory call corrects this. err = s.writeMeta(sessionKey, meta) if err != nil { return err } return s.rewriteJSONL(sessionKey, history) } // Compact physically rewrites the JSONL file, dropping all logically // skipped lines. This reclaims disk space that accumulates after // repeated TruncateHistory calls. // // It is safe to call at any time; if there is nothing to compact // (skip == 0) the method returns immediately. func (s *JSONLStore) Compact( _ context.Context, sessionKey string, ) error { l := s.sessionLock(sessionKey) l.Lock() defer l.Unlock() meta, err := s.readMeta(sessionKey) if err != nil { return err } if meta.Skip == 0 { return nil } // Read only the active messages, skipping truncated lines // without unmarshaling them. active, err := readMessages(s.jsonlPath(sessionKey), meta.Skip) if err != nil { return err } // Write meta BEFORE rewriting the JSONL file. If the process // crashes between the two writes, meta has Skip=0 and the old // (uncompacted) file is still intact, so GetHistory reads from // line 1 — returning previously-truncated messages rather than // losing data. The next Compact or TruncateHistory corrects this. meta.Skip = 0 meta.Count = len(active) meta.UpdatedAt = time.Now() err = s.writeMeta(sessionKey, meta) if err != nil { return err } return s.rewriteJSONL(sessionKey, active) } // rewriteJSONL atomically replaces the JSONL file with the given messages // using the project's standard WriteFileAtomic (temp + fsync + rename). func (s *JSONLStore) rewriteJSONL( sessionKey string, msgs []providers.Message, ) error { var buf bytes.Buffer for i, msg := range msgs { line, err := json.Marshal(msg) if err != nil { return fmt.Errorf("memory: marshal message %d: %w", i, err) } buf.Write(line) buf.WriteByte('\n') } return fileutil.WriteFileAtomic(s.jsonlPath(sessionKey), buf.Bytes(), 0o644) } func (s *JSONLStore) Close() error { return nil } ================================================ FILE: pkg/memory/jsonl_test.go ================================================ package memory import ( "context" "os" "path/filepath" "sync" "testing" "github.com/sipeed/picoclaw/pkg/providers" ) func newTestStore(t *testing.T) *JSONLStore { t.Helper() store, err := NewJSONLStore(t.TempDir()) if err != nil { t.Fatalf("NewJSONLStore: %v", err) } return store } func TestNewJSONLStore_CreatesDirectory(t *testing.T) { dir := filepath.Join(t.TempDir(), "nested", "sessions") store, err := NewJSONLStore(dir) if err != nil { t.Fatalf("NewJSONLStore: %v", err) } defer store.Close() info, err := os.Stat(dir) if err != nil { t.Fatalf("Stat: %v", err) } if !info.IsDir() { t.Errorf("expected directory, got file") } } func TestAddMessage_BasicRoundtrip(t *testing.T) { store := newTestStore(t) ctx := context.Background() err := store.AddMessage(ctx, "s1", "user", "hello") if err != nil { t.Fatalf("AddMessage: %v", err) } err = store.AddMessage(ctx, "s1", "assistant", "hi there") if err != nil { t.Fatalf("AddMessage: %v", err) } history, err := store.GetHistory(ctx, "s1") if err != nil { t.Fatalf("GetHistory: %v", err) } if len(history) != 2 { t.Fatalf("expected 2 messages, got %d", len(history)) } if history[0].Role != "user" || history[0].Content != "hello" { t.Errorf("msg[0] = %+v", history[0]) } if history[1].Role != "assistant" || history[1].Content != "hi there" { t.Errorf("msg[1] = %+v", history[1]) } } func TestAddMessage_AutoCreatesSession(t *testing.T) { store := newTestStore(t) ctx := context.Background() // Adding a message to a non-existent session should work. err := store.AddMessage(ctx, "new-session", "user", "first message") if err != nil { t.Fatalf("AddMessage: %v", err) } history, err := store.GetHistory(ctx, "new-session") if err != nil { t.Fatalf("GetHistory: %v", err) } if len(history) != 1 { t.Fatalf("expected 1 message, got %d", len(history)) } } func TestAddFullMessage_WithToolCalls(t *testing.T) { store := newTestStore(t) ctx := context.Background() msg := providers.Message{ Role: "assistant", Content: "Let me search that.", ToolCalls: []providers.ToolCall{ { ID: "call_abc", Type: "function", Function: &providers.FunctionCall{ Name: "web_search", Arguments: `{"q":"golang jsonl"}`, }, }, }, } err := store.AddFullMessage(ctx, "tc", msg) if err != nil { t.Fatalf("AddFullMessage: %v", err) } history, err := store.GetHistory(ctx, "tc") if err != nil { t.Fatalf("GetHistory: %v", err) } if len(history) != 1 { t.Fatalf("expected 1, got %d", len(history)) } if len(history[0].ToolCalls) != 1 { t.Fatalf("expected 1 tool call, got %d", len(history[0].ToolCalls)) } tc := history[0].ToolCalls[0] if tc.ID != "call_abc" { t.Errorf("tool call ID = %q", tc.ID) } if tc.Function == nil || tc.Function.Name != "web_search" { t.Errorf("tool call function = %+v", tc.Function) } } func TestAddFullMessage_ToolCallID(t *testing.T) { store := newTestStore(t) ctx := context.Background() msg := providers.Message{ Role: "tool", Content: "search results here", ToolCallID: "call_abc", } err := store.AddFullMessage(ctx, "tr", msg) if err != nil { t.Fatalf("AddFullMessage: %v", err) } history, err := store.GetHistory(ctx, "tr") if err != nil { t.Fatalf("GetHistory: %v", err) } if len(history) != 1 { t.Fatalf("expected 1, got %d", len(history)) } if history[0].ToolCallID != "call_abc" { t.Errorf("ToolCallID = %q", history[0].ToolCallID) } } func TestGetHistory_EmptySession(t *testing.T) { store := newTestStore(t) ctx := context.Background() history, err := store.GetHistory(ctx, "nonexistent") if err != nil { t.Fatalf("GetHistory: %v", err) } if history == nil { t.Fatal("expected non-nil empty slice") } if len(history) != 0 { t.Errorf("expected 0 messages, got %d", len(history)) } } func TestGetHistory_Ordering(t *testing.T) { store := newTestStore(t) ctx := context.Background() for i := 0; i < 5; i++ { err := store.AddMessage( ctx, "order", "user", string(rune('a'+i)), ) if err != nil { t.Fatalf("AddMessage(%d): %v", i, err) } } history, err := store.GetHistory(ctx, "order") if err != nil { t.Fatalf("GetHistory: %v", err) } if len(history) != 5 { t.Fatalf("expected 5, got %d", len(history)) } for i := 0; i < 5; i++ { expected := string(rune('a' + i)) if history[i].Content != expected { t.Errorf("msg[%d].Content = %q, want %q", i, history[i].Content, expected) } } } func TestSetSummary_GetSummary(t *testing.T) { store := newTestStore(t) ctx := context.Background() // No summary yet. summary, err := store.GetSummary(ctx, "s1") if err != nil { t.Fatalf("GetSummary: %v", err) } if summary != "" { t.Errorf("expected empty, got %q", summary) } // Set a summary. err = store.SetSummary(ctx, "s1", "talked about Go") if err != nil { t.Fatalf("SetSummary: %v", err) } summary, err = store.GetSummary(ctx, "s1") if err != nil { t.Fatalf("GetSummary: %v", err) } if summary != "talked about Go" { t.Errorf("summary = %q", summary) } // Update summary. err = store.SetSummary(ctx, "s1", "updated summary") if err != nil { t.Fatalf("SetSummary: %v", err) } summary, err = store.GetSummary(ctx, "s1") if err != nil { t.Fatalf("GetSummary: %v", err) } if summary != "updated summary" { t.Errorf("summary = %q", summary) } } func TestTruncateHistory_KeepLast(t *testing.T) { store := newTestStore(t) ctx := context.Background() for i := 0; i < 10; i++ { err := store.AddMessage( ctx, "trunc", "user", string(rune('a'+i)), ) if err != nil { t.Fatalf("AddMessage: %v", err) } } err := store.TruncateHistory(ctx, "trunc", 4) if err != nil { t.Fatalf("TruncateHistory: %v", err) } history, err := store.GetHistory(ctx, "trunc") if err != nil { t.Fatalf("GetHistory: %v", err) } if len(history) != 4 { t.Fatalf("expected 4, got %d", len(history)) } // Should be the last 4: g, h, i, j if history[0].Content != "g" { t.Errorf("first kept = %q, want 'g'", history[0].Content) } if history[3].Content != "j" { t.Errorf("last kept = %q, want 'j'", history[3].Content) } } func TestTruncateHistory_KeepZero(t *testing.T) { store := newTestStore(t) ctx := context.Background() for i := 0; i < 5; i++ { err := store.AddMessage(ctx, "empty", "user", "msg") if err != nil { t.Fatalf("AddMessage: %v", err) } } err := store.TruncateHistory(ctx, "empty", 0) if err != nil { t.Fatalf("TruncateHistory: %v", err) } history, err := store.GetHistory(ctx, "empty") if err != nil { t.Fatalf("GetHistory: %v", err) } if len(history) != 0 { t.Errorf("expected 0, got %d", len(history)) } } func TestTruncateHistory_KeepMoreThanExists(t *testing.T) { store := newTestStore(t) ctx := context.Background() for i := 0; i < 3; i++ { err := store.AddMessage(ctx, "few", "user", "msg") if err != nil { t.Fatalf("AddMessage: %v", err) } } // Keep 100, but only 3 exist — should keep all. err := store.TruncateHistory(ctx, "few", 100) if err != nil { t.Fatalf("TruncateHistory: %v", err) } history, err := store.GetHistory(ctx, "few") if err != nil { t.Fatalf("GetHistory: %v", err) } if len(history) != 3 { t.Errorf("expected 3, got %d", len(history)) } } func TestSetHistory_ReplacesAll(t *testing.T) { store := newTestStore(t) ctx := context.Background() // Add some initial messages. for i := 0; i < 5; i++ { err := store.AddMessage(ctx, "replace", "user", "old") if err != nil { t.Fatalf("AddMessage: %v", err) } } // Replace with new history. newHistory := []providers.Message{ {Role: "user", Content: "new1"}, {Role: "assistant", Content: "new2"}, } err := store.SetHistory(ctx, "replace", newHistory) if err != nil { t.Fatalf("SetHistory: %v", err) } history, err := store.GetHistory(ctx, "replace") if err != nil { t.Fatalf("GetHistory: %v", err) } if len(history) != 2 { t.Fatalf("expected 2, got %d", len(history)) } if history[0].Content != "new1" || history[1].Content != "new2" { t.Errorf("history = %+v", history) } } func TestSetHistory_ResetsSkip(t *testing.T) { store := newTestStore(t) ctx := context.Background() // Add messages and truncate. for i := 0; i < 10; i++ { err := store.AddMessage(ctx, "skip-reset", "user", "old") if err != nil { t.Fatalf("AddMessage: %v", err) } } err := store.TruncateHistory(ctx, "skip-reset", 3) if err != nil { t.Fatalf("TruncateHistory: %v", err) } // SetHistory should reset skip to 0. newHistory := []providers.Message{ {Role: "user", Content: "fresh"}, } err = store.SetHistory(ctx, "skip-reset", newHistory) if err != nil { t.Fatalf("SetHistory: %v", err) } history, err := store.GetHistory(ctx, "skip-reset") if err != nil { t.Fatalf("GetHistory: %v", err) } if len(history) != 1 { t.Fatalf("expected 1, got %d", len(history)) } if history[0].Content != "fresh" { t.Errorf("content = %q", history[0].Content) } } func TestColonInKey(t *testing.T) { store := newTestStore(t) ctx := context.Background() err := store.AddMessage(ctx, "telegram:123", "user", "hi") if err != nil { t.Fatalf("AddMessage: %v", err) } history, err := store.GetHistory(ctx, "telegram:123") if err != nil { t.Fatalf("GetHistory: %v", err) } if len(history) != 1 { t.Fatalf("expected 1, got %d", len(history)) } // Verify the file is named with underscore. jsonlFile := filepath.Join(store.dir, "telegram_123.jsonl") if _, statErr := os.Stat(jsonlFile); statErr != nil { t.Errorf("expected file %s to exist: %v", jsonlFile, statErr) } } func TestCompact_RemovesSkippedMessages(t *testing.T) { store := newTestStore(t) ctx := context.Background() // Write 10 messages, then truncate to keep last 3. for i := 0; i < 10; i++ { err := store.AddMessage(ctx, "compact", "user", string(rune('a'+i))) if err != nil { t.Fatalf("AddMessage: %v", err) } } err := store.TruncateHistory(ctx, "compact", 3) if err != nil { t.Fatalf("TruncateHistory: %v", err) } // Before compact: file still has 10 lines. allOnDisk, err := readMessages(store.jsonlPath("compact"), 0) if err != nil { t.Fatalf("readMessages: %v", err) } if len(allOnDisk) != 10 { t.Fatalf("before compact: expected 10 on disk, got %d", len(allOnDisk)) } // Compact. err = store.Compact(ctx, "compact") if err != nil { t.Fatalf("Compact: %v", err) } // After compact: file should have only 3 lines. allOnDisk, err = readMessages(store.jsonlPath("compact"), 0) if err != nil { t.Fatalf("readMessages: %v", err) } if len(allOnDisk) != 3 { t.Fatalf("after compact: expected 3 on disk, got %d", len(allOnDisk)) } // GetHistory should still return the same 3 messages. history, err := store.GetHistory(ctx, "compact") if err != nil { t.Fatalf("GetHistory: %v", err) } if len(history) != 3 { t.Fatalf("expected 3, got %d", len(history)) } if history[0].Content != "h" || history[2].Content != "j" { t.Errorf("wrong content: %+v", history) } } func TestCompact_NoOpWhenNoSkip(t *testing.T) { store := newTestStore(t) ctx := context.Background() for i := 0; i < 5; i++ { err := store.AddMessage(ctx, "noop", "user", "msg") if err != nil { t.Fatalf("AddMessage: %v", err) } } // Compact without prior truncation — should be a no-op. err := store.Compact(ctx, "noop") if err != nil { t.Fatalf("Compact: %v", err) } history, err := store.GetHistory(ctx, "noop") if err != nil { t.Fatalf("GetHistory: %v", err) } if len(history) != 5 { t.Errorf("expected 5, got %d", len(history)) } } func TestCompact_ThenAppend(t *testing.T) { store := newTestStore(t) ctx := context.Background() for i := 0; i < 8; i++ { err := store.AddMessage(ctx, "cap", "user", string(rune('a'+i))) if err != nil { t.Fatalf("AddMessage: %v", err) } } err := store.TruncateHistory(ctx, "cap", 2) if err != nil { t.Fatalf("TruncateHistory: %v", err) } err = store.Compact(ctx, "cap") if err != nil { t.Fatalf("Compact: %v", err) } // Append after compaction should work correctly. err = store.AddMessage(ctx, "cap", "user", "new") if err != nil { t.Fatalf("AddMessage after compact: %v", err) } history, err := store.GetHistory(ctx, "cap") if err != nil { t.Fatalf("GetHistory: %v", err) } if len(history) != 3 { t.Fatalf("expected 3, got %d", len(history)) } // g, h (kept from truncation), new (appended after compaction). if history[0].Content != "g" { t.Errorf("first = %q, want 'g'", history[0].Content) } if history[2].Content != "new" { t.Errorf("last = %q, want 'new'", history[2].Content) } } func TestTruncateHistory_StaleMetaCount(t *testing.T) { // Simulates a crash between JSONL append and meta update in addMsg: // file has N+1 lines but meta.Count is still N. TruncateHistory must // reconcile with the real line count so that keepLast is accurate. store := newTestStore(t) ctx := context.Background() // Write 10 messages normally (meta.Count = 10). for i := 0; i < 10; i++ { err := store.AddMessage(ctx, "stale", "user", string(rune('a'+i))) if err != nil { t.Fatalf("AddMessage: %v", err) } } // Simulate crash: append a line to JSONL but do NOT update meta. // This leaves meta.Count = 10 while the file has 11 lines. jsonlPath := store.jsonlPath("stale") f, err := os.OpenFile(jsonlPath, os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { t.Fatalf("open for append: %v", err) } _, err = f.WriteString(`{"role":"user","content":"orphan"}` + "\n") if err != nil { t.Fatalf("write orphan: %v", err) } f.Close() // TruncateHistory(keepLast=4) should keep the last 4 of 11 lines, // not the last 4 of 10. err = store.TruncateHistory(ctx, "stale", 4) if err != nil { t.Fatalf("TruncateHistory: %v", err) } history, err := store.GetHistory(ctx, "stale") if err != nil { t.Fatalf("GetHistory: %v", err) } if len(history) != 4 { t.Fatalf("expected 4, got %d", len(history)) } // Last 4 of [a,b,c,d,e,f,g,h,i,j,orphan] = [h,i,j,orphan] if history[0].Content != "h" { t.Errorf("first kept = %q, want 'h'", history[0].Content) } if history[3].Content != "orphan" { t.Errorf("last kept = %q, want 'orphan'", history[3].Content) } } func TestCrashRecovery_PartialLine(t *testing.T) { store := newTestStore(t) ctx := context.Background() // Write a valid message first. err := store.AddMessage(ctx, "crash", "user", "valid") if err != nil { t.Fatalf("AddMessage: %v", err) } // Simulate a crash by appending a partial JSON line directly. jsonlPath := store.jsonlPath("crash") f, err := os.OpenFile(jsonlPath, os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { t.Fatalf("open for append: %v", err) } _, err = f.WriteString(`{"role":"user","content":"incomple`) if err != nil { t.Fatalf("write partial: %v", err) } f.Close() // GetHistory should return only the valid message. history, err := store.GetHistory(ctx, "crash") if err != nil { t.Fatalf("GetHistory: %v", err) } if len(history) != 1 { t.Fatalf("expected 1 valid message, got %d", len(history)) } if history[0].Content != "valid" { t.Errorf("content = %q", history[0].Content) } } func TestPersistence_AcrossInstances(t *testing.T) { dir := t.TempDir() ctx := context.Background() // Write with first instance. store1, err := NewJSONLStore(dir) if err != nil { t.Fatalf("NewJSONLStore: %v", err) } err = store1.AddMessage(ctx, "persist", "user", "remember me") if err != nil { t.Fatalf("AddMessage: %v", err) } err = store1.SetSummary(ctx, "persist", "a test session") if err != nil { t.Fatalf("SetSummary: %v", err) } store1.Close() // Read with second instance. store2, err := NewJSONLStore(dir) if err != nil { t.Fatalf("NewJSONLStore: %v", err) } defer store2.Close() history, err := store2.GetHistory(ctx, "persist") if err != nil { t.Fatalf("GetHistory: %v", err) } if len(history) != 1 || history[0].Content != "remember me" { t.Errorf("history = %+v", history) } summary, err := store2.GetSummary(ctx, "persist") if err != nil { t.Fatalf("GetSummary: %v", err) } if summary != "a test session" { t.Errorf("summary = %q", summary) } } func TestConcurrent_AddAndRead(t *testing.T) { store := newTestStore(t) ctx := context.Background() var wg sync.WaitGroup const goroutines = 10 const msgsPerGoroutine = 20 // Concurrent writes. for g := 0; g < goroutines; g++ { wg.Add(1) go func() { defer wg.Done() for i := 0; i < msgsPerGoroutine; i++ { _ = store.AddMessage(ctx, "concurrent", "user", "msg") } }() } wg.Wait() history, err := store.GetHistory(ctx, "concurrent") if err != nil { t.Fatalf("GetHistory: %v", err) } expected := goroutines * msgsPerGoroutine if len(history) != expected { t.Errorf("expected %d messages, got %d", expected, len(history)) } } func TestConcurrent_SummarizeRace(t *testing.T) { // Simulates the #704 race: one goroutine adds messages while // another truncates + sets summary — like summarizeSession(). store := newTestStore(t) ctx := context.Background() // Seed with some messages. for i := 0; i < 20; i++ { err := store.AddMessage(ctx, "race", "user", "seed") if err != nil { t.Fatalf("AddMessage: %v", err) } } var wg sync.WaitGroup // Writer goroutine (main agent loop). wg.Add(1) go func() { defer wg.Done() for i := 0; i < 50; i++ { _ = store.AddMessage(ctx, "race", "user", "new") } }() // Summarizer goroutine (background task). wg.Add(1) go func() { defer wg.Done() for i := 0; i < 10; i++ { _ = store.SetSummary(ctx, "race", "summary") _ = store.TruncateHistory(ctx, "race", 5) } }() wg.Wait() // Verify the store is still in a consistent state. _, err := store.GetHistory(ctx, "race") if err != nil { t.Fatalf("GetHistory after race: %v", err) } _, err = store.GetSummary(ctx, "race") if err != nil { t.Fatalf("GetSummary after race: %v", err) } } func TestMultipleSessions_Isolation(t *testing.T) { store := newTestStore(t) ctx := context.Background() err := store.AddMessage(ctx, "s1", "user", "msg for s1") if err != nil { t.Fatalf("AddMessage: %v", err) } err = store.AddMessage(ctx, "s2", "user", "msg for s2") if err != nil { t.Fatalf("AddMessage: %v", err) } h1, err := store.GetHistory(ctx, "s1") if err != nil { t.Fatalf("GetHistory s1: %v", err) } h2, err := store.GetHistory(ctx, "s2") if err != nil { t.Fatalf("GetHistory s2: %v", err) } if len(h1) != 1 || h1[0].Content != "msg for s1" { t.Errorf("s1 history = %+v", h1) } if len(h2) != 1 || h2[0].Content != "msg for s2" { t.Errorf("s2 history = %+v", h2) } } func BenchmarkAddMessage(b *testing.B) { dir := b.TempDir() store, err := NewJSONLStore(dir) if err != nil { b.Fatalf("NewJSONLStore: %v", err) } defer store.Close() ctx := context.Background() b.ResetTimer() for i := 0; i < b.N; i++ { _ = store.AddMessage(ctx, "bench", "user", "benchmark message content") } } func BenchmarkGetHistory_100(b *testing.B) { dir := b.TempDir() store, err := NewJSONLStore(dir) if err != nil { b.Fatalf("NewJSONLStore: %v", err) } defer store.Close() ctx := context.Background() for i := 0; i < 100; i++ { _ = store.AddMessage(ctx, "bench", "user", "message content") } b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = store.GetHistory(ctx, "bench") } } func BenchmarkGetHistory_1000(b *testing.B) { dir := b.TempDir() store, err := NewJSONLStore(dir) if err != nil { b.Fatalf("NewJSONLStore: %v", err) } defer store.Close() ctx := context.Background() for i := 0; i < 1000; i++ { _ = store.AddMessage(ctx, "bench", "user", "message content") } b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = store.GetHistory(ctx, "bench") } } ================================================ FILE: pkg/memory/migration.go ================================================ package memory import ( "context" "encoding/json" "fmt" "log" "os" "path/filepath" "strings" "time" "github.com/sipeed/picoclaw/pkg/providers" ) // jsonSession mirrors pkg/session.Session for migration purposes. type jsonSession struct { Key string `json:"key"` Messages []providers.Message `json:"messages"` Summary string `json:"summary,omitempty"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` } // MigrateFromJSON reads legacy sessions/*.json files from sessionsDir, // writes them into the Store, and renames each migrated file to // .json.migrated as a backup. Returns the number of sessions migrated. // // Files that fail to parse are logged and skipped. Already-migrated // files (.json.migrated) are ignored, making the function idempotent. func MigrateFromJSON( ctx context.Context, sessionsDir string, store Store, ) (int, error) { entries, err := os.ReadDir(sessionsDir) if os.IsNotExist(err) { return 0, nil } if err != nil { return 0, fmt.Errorf("memory: read sessions dir: %w", err) } migrated := 0 for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() if !strings.HasSuffix(name, ".json") { continue } // Skip JSONL metadata files. They are part of the new storage format, // not legacy session snapshots, and re-importing them would overwrite // the paired .jsonl history with an empty message list. if strings.HasSuffix(name, ".meta.json") { continue } // Skip already-migrated files. if strings.HasSuffix(name, ".migrated") { continue } srcPath := filepath.Join(sessionsDir, name) data, readErr := os.ReadFile(srcPath) if readErr != nil { log.Printf("memory: migrate: skip %s: %v", name, readErr) continue } var sess jsonSession if parseErr := json.Unmarshal(data, &sess); parseErr != nil { log.Printf("memory: migrate: skip %s: %v", name, parseErr) continue } // Use the key from the JSON content, not the filename. // Filenames are sanitized (":" → "_") but keys are not. key := sess.Key if key == "" { key = strings.TrimSuffix(name, ".json") } // Use SetHistory (atomic replace) instead of per-message // AddFullMessage. This makes migration idempotent: if the // process crashes after writing messages but before the // rename below, a retry replaces the partial data cleanly // instead of duplicating messages. if setErr := store.SetHistory(ctx, key, sess.Messages); setErr != nil { return migrated, fmt.Errorf( "memory: migrate %s: set history: %w", name, setErr, ) } if sess.Summary != "" { if sumErr := store.SetSummary(ctx, key, sess.Summary); sumErr != nil { return migrated, fmt.Errorf( "memory: migrate %s: set summary: %w", name, sumErr, ) } } // Rename to .migrated as backup (not delete). renameErr := os.Rename(srcPath, srcPath+".migrated") if renameErr != nil { log.Printf("memory: migrate: rename %s: %v", name, renameErr) } migrated++ } return migrated, nil } ================================================ FILE: pkg/memory/migration_test.go ================================================ package memory import ( "context" "encoding/json" "os" "path/filepath" "testing" "time" "github.com/sipeed/picoclaw/pkg/providers" ) func writeJSONSession( t *testing.T, dir string, filename string, sess jsonSession, ) { t.Helper() data, err := json.MarshalIndent(sess, "", " ") if err != nil { t.Fatalf("marshal session: %v", err) } err = os.WriteFile(filepath.Join(dir, filename), data, 0o644) if err != nil { t.Fatalf("write session file: %v", err) } } func TestMigrateFromJSON_Basic(t *testing.T) { sessionsDir := t.TempDir() store := newTestStore(t) ctx := context.Background() writeJSONSession(t, sessionsDir, "test.json", jsonSession{ Key: "test", Messages: []providers.Message{ {Role: "user", Content: "hello"}, {Role: "assistant", Content: "hi"}, }, Summary: "A greeting.", Created: time.Now(), Updated: time.Now(), }) count, err := MigrateFromJSON(ctx, sessionsDir, store) if err != nil { t.Fatalf("MigrateFromJSON: %v", err) } if count != 1 { t.Errorf("expected 1 migrated, got %d", count) } history, err := store.GetHistory(ctx, "test") if err != nil { t.Fatalf("GetHistory: %v", err) } if len(history) != 2 { t.Fatalf("expected 2 messages, got %d", len(history)) } if history[0].Content != "hello" || history[1].Content != "hi" { t.Errorf("unexpected messages: %+v", history) } summary, err := store.GetSummary(ctx, "test") if err != nil { t.Fatalf("GetSummary: %v", err) } if summary != "A greeting." { t.Errorf("summary = %q", summary) } } func TestMigrateFromJSON_WithToolCalls(t *testing.T) { sessionsDir := t.TempDir() store := newTestStore(t) ctx := context.Background() writeJSONSession(t, sessionsDir, "tools.json", jsonSession{ Key: "tools", Messages: []providers.Message{ { Role: "assistant", Content: "Searching...", ToolCalls: []providers.ToolCall{ { ID: "call_1", Type: "function", Function: &providers.FunctionCall{ Name: "web_search", Arguments: `{"q":"test"}`, }, }, }, }, { Role: "tool", Content: "result", ToolCallID: "call_1", }, }, Created: time.Now(), Updated: time.Now(), }) count, err := MigrateFromJSON(ctx, sessionsDir, store) if err != nil { t.Fatalf("MigrateFromJSON: %v", err) } if count != 1 { t.Errorf("expected 1, got %d", count) } history, err := store.GetHistory(ctx, "tools") if err != nil { t.Fatalf("GetHistory: %v", err) } if len(history) != 2 { t.Fatalf("expected 2 messages, got %d", len(history)) } if len(history[0].ToolCalls) != 1 { t.Fatalf("expected 1 tool call, got %d", len(history[0].ToolCalls)) } if history[0].ToolCalls[0].Function.Name != "web_search" { t.Errorf("function = %q", history[0].ToolCalls[0].Function.Name) } if history[1].ToolCallID != "call_1" { t.Errorf("ToolCallID = %q", history[1].ToolCallID) } } func TestMigrateFromJSON_MultipleFiles(t *testing.T) { sessionsDir := t.TempDir() store := newTestStore(t) ctx := context.Background() for i := 0; i < 3; i++ { key := string(rune('a' + i)) writeJSONSession(t, sessionsDir, key+".json", jsonSession{ Key: key, Messages: []providers.Message{{Role: "user", Content: "msg " + key}}, Created: time.Now(), Updated: time.Now(), }) } count, err := MigrateFromJSON(ctx, sessionsDir, store) if err != nil { t.Fatalf("MigrateFromJSON: %v", err) } if count != 3 { t.Errorf("expected 3, got %d", count) } for i := 0; i < 3; i++ { key := string(rune('a' + i)) history, histErr := store.GetHistory(ctx, key) if histErr != nil { t.Fatalf("GetHistory(%q): %v", key, histErr) } if len(history) != 1 { t.Errorf("session %q: expected 1 msg, got %d", key, len(history)) } } } func TestMigrateFromJSON_InvalidJSON(t *testing.T) { sessionsDir := t.TempDir() store := newTestStore(t) ctx := context.Background() // One valid, one invalid. writeJSONSession(t, sessionsDir, "good.json", jsonSession{ Key: "good", Messages: []providers.Message{{Role: "user", Content: "ok"}}, Created: time.Now(), Updated: time.Now(), }) err := os.WriteFile( filepath.Join(sessionsDir, "bad.json"), []byte("{invalid json"), 0o644, ) if err != nil { t.Fatalf("write bad file: %v", err) } count, err := MigrateFromJSON(ctx, sessionsDir, store) if err != nil { t.Fatalf("MigrateFromJSON: %v", err) } if count != 1 { t.Errorf("expected 1 (bad file skipped), got %d", count) } history, err := store.GetHistory(ctx, "good") if err != nil { t.Fatalf("GetHistory: %v", err) } if len(history) != 1 { t.Errorf("expected 1 message, got %d", len(history)) } } func TestMigrateFromJSON_RenamesFiles(t *testing.T) { sessionsDir := t.TempDir() store := newTestStore(t) ctx := context.Background() writeJSONSession(t, sessionsDir, "rename.json", jsonSession{ Key: "rename", Messages: []providers.Message{{Role: "user", Content: "hi"}}, Created: time.Now(), Updated: time.Now(), }) _, err := MigrateFromJSON(ctx, sessionsDir, store) if err != nil { t.Fatalf("MigrateFromJSON: %v", err) } // Original .json should not exist. _, statErr := os.Stat(filepath.Join(sessionsDir, "rename.json")) if !os.IsNotExist(statErr) { t.Error("rename.json should have been renamed") } // .json.migrated should exist. _, statErr = os.Stat( filepath.Join(sessionsDir, "rename.json.migrated"), ) if statErr != nil { t.Errorf("rename.json.migrated should exist: %v", statErr) } } func TestMigrateFromJSON_Idempotent(t *testing.T) { sessionsDir := t.TempDir() store := newTestStore(t) ctx := context.Background() writeJSONSession(t, sessionsDir, "idem.json", jsonSession{ Key: "idem", Messages: []providers.Message{{Role: "user", Content: "once"}}, Created: time.Now(), Updated: time.Now(), }) count1, err := MigrateFromJSON(ctx, sessionsDir, store) if err != nil { t.Fatalf("first migration: %v", err) } if count1 != 1 { t.Errorf("first run: expected 1, got %d", count1) } // Second run should find only .migrated files, skip them. count2, err := MigrateFromJSON(ctx, sessionsDir, store) if err != nil { t.Fatalf("second migration: %v", err) } if count2 != 0 { t.Errorf("second run: expected 0, got %d", count2) } history, err := store.GetHistory(ctx, "idem") if err != nil { t.Fatalf("GetHistory: %v", err) } if len(history) != 1 { t.Errorf("expected 1 message, got %d", len(history)) } } func TestMigrateFromJSON_ColonInKey(t *testing.T) { sessionsDir := t.TempDir() store := newTestStore(t) ctx := context.Background() // File is named telegram_123 (sanitized), but the key inside is telegram:123. writeJSONSession(t, sessionsDir, "telegram_123.json", jsonSession{ Key: "telegram:123", Messages: []providers.Message{{Role: "user", Content: "from telegram"}}, Created: time.Now(), Updated: time.Now(), }) count, err := MigrateFromJSON(ctx, sessionsDir, store) if err != nil { t.Fatalf("MigrateFromJSON: %v", err) } if count != 1 { t.Errorf("expected 1, got %d", count) } // Accessible via the original key "telegram:123". history, err := store.GetHistory(ctx, "telegram:123") if err != nil { t.Fatalf("GetHistory: %v", err) } if len(history) != 1 { t.Fatalf("expected 1 message, got %d", len(history)) } if history[0].Content != "from telegram" { t.Errorf("content = %q", history[0].Content) } // In the file-based store, "telegram:123" and "telegram_123" both // sanitize to the same filename, so they share storage. This is // expected — the colon-to-underscore mapping is a one-way function. history2, err := store.GetHistory(ctx, "telegram_123") if err != nil { t.Fatalf("GetHistory: %v", err) } if len(history2) != 1 { t.Errorf("expected 1 (same file), got %d", len(history2)) } } func TestMigrateFromJSON_RetryAfterCrash(t *testing.T) { // Simulates a crash during migration: first run writes messages // but doesn't rename the .json file. Second run must replace // (not duplicate) the messages thanks to SetHistory semantics. sessionsDir := t.TempDir() store := newTestStore(t) ctx := context.Background() writeJSONSession(t, sessionsDir, "retry.json", jsonSession{ Key: "retry", Messages: []providers.Message{ {Role: "user", Content: "one"}, {Role: "assistant", Content: "two"}, }, Created: time.Now(), Updated: time.Now(), }) // First migration succeeds — writes messages and renames file. count, err := MigrateFromJSON(ctx, sessionsDir, store) if err != nil { t.Fatalf("first migration: %v", err) } if count != 1 { t.Fatalf("expected 1, got %d", count) } // Simulate "crash before rename": restore the .json file. src := filepath.Join(sessionsDir, "retry.json.migrated") dst := filepath.Join(sessionsDir, "retry.json") if renameErr := os.Rename(src, dst); renameErr != nil { t.Fatalf("restore .json: %v", renameErr) } // Second migration should re-import without duplicating messages. count, err = MigrateFromJSON(ctx, sessionsDir, store) if err != nil { t.Fatalf("second migration: %v", err) } if count != 1 { t.Fatalf("expected 1, got %d", count) } history, err := store.GetHistory(ctx, "retry") if err != nil { t.Fatalf("GetHistory: %v", err) } // Must be exactly 2 messages (not 4 from duplication). if len(history) != 2 { t.Fatalf("expected 2 messages (no duplicates), got %d", len(history)) } if history[0].Content != "one" || history[1].Content != "two" { t.Errorf("unexpected messages: %+v", history) } } func TestMigrateFromJSON_NonexistentDir(t *testing.T) { store := newTestStore(t) ctx := context.Background() count, err := MigrateFromJSON(ctx, "/nonexistent/path", store) if err != nil { t.Fatalf("MigrateFromJSON: %v", err) } if count != 0 { t.Errorf("expected 0, got %d", count) } } func TestMigrateFromJSON_SkipsMetaJSONFiles(t *testing.T) { sessionsDir := t.TempDir() store, err := NewJSONLStore(sessionsDir) if err != nil { t.Fatalf("NewJSONLStore: %v", err) } ctx := context.Background() if addErr := store.AddMessage(ctx, "agent:main:pico:direct:pico:test", "user", "keep me"); addErr != nil { t.Fatalf("AddMessage: %v", addErr) } if summaryErr := store.SetSummary(ctx, "agent:main:pico:direct:pico:test", "keep summary"); summaryErr != nil { t.Fatalf("SetSummary: %v", summaryErr) } metaPath := filepath.Join(sessionsDir, "agent_main_pico_direct_pico_test.meta.json") if _, statErr := os.Stat(metaPath); statErr != nil { t.Fatalf("meta file missing before migration: %v", statErr) } count, err := MigrateFromJSON(ctx, sessionsDir, store) if err != nil { t.Fatalf("MigrateFromJSON: %v", err) } if count != 0 { t.Fatalf("expected 0 migrated, got %d", count) } history, err := store.GetHistory(ctx, "agent:main:pico:direct:pico:test") if err != nil { t.Fatalf("GetHistory: %v", err) } if len(history) != 1 || history[0].Content != "keep me" { t.Fatalf("history = %+v, want preserved single message", history) } summary, err := store.GetSummary(ctx, "agent:main:pico:direct:pico:test") if err != nil { t.Fatalf("GetSummary: %v", err) } if summary != "keep summary" { t.Fatalf("summary = %q, want %q", summary, "keep summary") } if _, statErr := os.Stat(metaPath); statErr != nil { t.Fatalf("meta file should remain in place: %v", statErr) } if _, statErr := os.Stat(metaPath + ".migrated"); !os.IsNotExist(statErr) { t.Fatalf("meta file should not be renamed, stat err = %v", statErr) } } ================================================ FILE: pkg/memory/store.go ================================================ package memory import ( "context" "github.com/sipeed/picoclaw/pkg/providers" ) // Store defines an interface for persistent session storage. // Each method is an atomic operation — there is no separate Save() call. type Store interface { // AddMessage appends a simple text message to a session. AddMessage(ctx context.Context, sessionKey, role, content string) error // AddFullMessage appends a complete message (with tool calls, etc.) to a session. AddFullMessage(ctx context.Context, sessionKey string, msg providers.Message) error // GetHistory returns all messages for a session in insertion order. // Returns an empty slice (not nil) if the session does not exist. GetHistory(ctx context.Context, sessionKey string) ([]providers.Message, error) // GetSummary returns the conversation summary for a session. // Returns an empty string if no summary exists. GetSummary(ctx context.Context, sessionKey string) (string, error) // SetSummary updates the conversation summary for a session. SetSummary(ctx context.Context, sessionKey, summary string) error // TruncateHistory removes all but the last keepLast messages from a session. // If keepLast <= 0, all messages are removed. TruncateHistory(ctx context.Context, sessionKey string, keepLast int) error // SetHistory replaces all messages in a session with the provided history. SetHistory(ctx context.Context, sessionKey string, history []providers.Message) error // Compact reclaims storage by physically removing logically truncated // data. Backends that do not accumulate dead data may return nil. Compact(ctx context.Context, sessionKey string) error // Close releases any resources held by the store. Close() error } ================================================ FILE: pkg/migrate/internal/common.go ================================================ package internal import ( "fmt" "io" "os" "path/filepath" "github.com/sipeed/picoclaw/pkg/config" ) func ResolveTargetHome(override string) (string, error) { if override != "" { return ExpandHome(override), nil } if envHome := os.Getenv(config.EnvHome); envHome != "" { return ExpandHome(envHome), nil } home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("resolving home directory: %w", err) } return filepath.Join(home, ".picoclaw"), nil } func ExpandHome(path string) string { if path == "" { return path } if path[0] == '~' { home, _ := os.UserHomeDir() if len(path) > 1 && path[1] == '/' { return home + path[1:] } return home } return path } func ResolveWorkspace(homeDir string) string { return filepath.Join(homeDir, "workspace") } func PlanWorkspaceMigration( srcWorkspace, dstWorkspace string, migrateableFiles []string, migrateableDirs []string, force bool, ) ([]Action, error) { var actions []Action for _, filename := range migrateableFiles { src := filepath.Join(srcWorkspace, filename) dst := filepath.Join(dstWorkspace, filename) action := planFileCopy(src, dst, force) if action.Type != ActionSkip || action.Description != "" { actions = append(actions, action) } } for _, dirname := range migrateableDirs { srcDir := filepath.Join(srcWorkspace, dirname) if _, err := os.Stat(srcDir); os.IsNotExist(err) { continue } dirActions, err := planDirCopy(srcDir, filepath.Join(dstWorkspace, dirname), force) if err != nil { return nil, err } actions = append(actions, dirActions...) } return actions, nil } func planFileCopy(src, dst string, force bool) Action { if _, err := os.Stat(src); os.IsNotExist(err) { return Action{ Type: ActionSkip, Source: src, Target: dst, Description: "source file not found", } } _, dstExists := os.Stat(dst) if dstExists == nil && !force { return Action{ Type: ActionBackup, Source: src, Target: dst, Description: "destination exists, will backup and overwrite", } } return Action{ Type: ActionCopy, Source: src, Target: dst, Description: "copy file", } } func planDirCopy(srcDir, dstDir string, force bool) ([]Action, error) { var actions []Action err := filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } relPath, err := filepath.Rel(srcDir, path) if err != nil { return err } dst := filepath.Join(dstDir, relPath) if info.IsDir() { actions = append(actions, Action{ Type: ActionCreateDir, Target: dst, Description: "create directory", }) return nil } action := planFileCopy(path, dst, force) actions = append(actions, action) return nil }) return actions, err } func RelPath(path, base string) string { rel, err := filepath.Rel(base, path) if err != nil { return filepath.Base(path) } return rel } func CopyFile(src, dst string) error { srcFile, err := os.Open(src) if err != nil { return err } defer srcFile.Close() info, err := srcFile.Stat() if err != nil { return err } dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode()) if err != nil { return err } defer dstFile.Close() _, err = io.Copy(dstFile, srcFile) return err } ================================================ FILE: pkg/migrate/internal/common_test.go ================================================ package internal import ( "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestExpandHome(t *testing.T) { tests := []struct { input string expected string }{ {"", ""}, {"/absolute/path", "/absolute/path"}, {"relative/path", "relative/path"}, } for _, tt := range tests { result := ExpandHome(tt.input) assert.Equal(t, tt.expected, result) } } func TestExpandHomeWithTilde(t *testing.T) { home, err := os.UserHomeDir() require.NoError(t, err) result := ExpandHome("~/path") assert.Equal(t, home+"/path", result) result = ExpandHome("~") assert.Equal(t, home, result) } func TestResolveWorkspace(t *testing.T) { result := ResolveWorkspace("/home/user/.picoclaw") assert.Equal(t, "/home/user/.picoclaw/workspace", result) } func TestRelPath(t *testing.T) { result := RelPath("/home/user/.picoclaw/workspace/file.txt", "/home/user/.picoclaw") assert.Equal(t, "workspace/file.txt", result) } func TestRelPathError(t *testing.T) { result := RelPath("relative/path", "/different/base") assert.Equal(t, "path", result) } func TestResolveTargetHome(t *testing.T) { home, err := os.UserHomeDir() require.NoError(t, err) result, err := ResolveTargetHome("") require.NoError(t, err) assert.Equal(t, filepath.Join(home, ".picoclaw"), result) } func TestResolveTargetHomeWithOverride(t *testing.T) { result, err := ResolveTargetHome("/custom/path") require.NoError(t, err) assert.Equal(t, "/custom/path", result) } func TestCopyFile(t *testing.T) { tmpDir := t.TempDir() sourceFile := filepath.Join(tmpDir, "source.txt") err := os.WriteFile(sourceFile, []byte("test content"), 0o644) require.NoError(t, err) dstFile := filepath.Join(tmpDir, "dest.txt") err = CopyFile(sourceFile, dstFile) require.NoError(t, err) content, err := os.ReadFile(dstFile) require.NoError(t, err) assert.Equal(t, "test content", string(content)) } func TestCopyFileSourceNotFound(t *testing.T) { tmpDir := t.TempDir() err := CopyFile(filepath.Join(tmpDir, "nonexistent.txt"), filepath.Join(tmpDir, "dest.txt")) require.Error(t, err) } func TestPlanWorkspaceMigration(t *testing.T) { tmpDir := t.TempDir() srcWorkspace := filepath.Join(tmpDir, "src", "workspace") dstWorkspace := filepath.Join(tmpDir, "dst", "workspace") err := os.MkdirAll(srcWorkspace, 0o755) require.NoError(t, err) err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("content"), 0o644) require.NoError(t, err) err = os.MkdirAll(filepath.Join(srcWorkspace, "subdir"), 0o755) require.NoError(t, err) err = os.WriteFile(filepath.Join(srcWorkspace, "subdir", "file2.txt"), []byte("content"), 0o644) require.NoError(t, err) actions, err := PlanWorkspaceMigration( srcWorkspace, dstWorkspace, []string{"file1.txt"}, []string{"subdir"}, false, ) require.NoError(t, err) assert.GreaterOrEqual(t, len(actions), 1) } func TestPlanWorkspaceMigrationExistingFile(t *testing.T) { tests := []struct { name string force bool wantActionType ActionType }{ { name: "backup when not forced", force: false, wantActionType: ActionBackup, }, { name: "copy when forced", force: true, wantActionType: ActionCopy, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir := t.TempDir() srcWorkspace := filepath.Join(tmpDir, "src", "workspace") dstWorkspace := filepath.Join(tmpDir, "dst", "workspace") err := os.MkdirAll(srcWorkspace, 0o755) require.NoError(t, err) err = os.MkdirAll(dstWorkspace, 0o755) require.NoError(t, err) err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("source"), 0o644) require.NoError(t, err) err = os.WriteFile(filepath.Join(dstWorkspace, "file1.txt"), []byte("existing"), 0o644) require.NoError(t, err) actions, err := PlanWorkspaceMigration( srcWorkspace, dstWorkspace, []string{"file1.txt"}, []string{}, tt.force, ) require.NoError(t, err) require.GreaterOrEqual(t, len(actions), 1) assert.Equal(t, tt.wantActionType, actions[0].Type) }) } } func TestPlanWorkspaceMigrationNonExistentSource(t *testing.T) { tmpDir := t.TempDir() actions, err := PlanWorkspaceMigration( filepath.Join(tmpDir, "nonexistent"), filepath.Join(tmpDir, "dst", "workspace"), []string{"file1.txt"}, []string{}, false, ) require.NoError(t, err) require.Len(t, actions, 1) assert.Equal(t, ActionSkip, actions[0].Type) assert.Contains(t, actions[0].Description, "source file not found") } ================================================ FILE: pkg/migrate/internal/types.go ================================================ package internal type Options struct { DryRun bool ConfigOnly bool WorkspaceOnly bool Force bool Refresh bool Source string SourceHome string TargetHome string } type Operation interface { GetSourceName() string GetSourceHome() (string, error) GetSourceWorkspace() (string, error) GetSourceConfigFile() (string, error) ExecuteConfigMigration(srcConfigPath, dstConfigPath string) error GetMigrateableFiles() []string GetMigrateableDirs() []string } type HandlerFactory func(opts Options) Operation type ActionType int const ( ActionCopy ActionType = iota ActionSkip ActionBackup ActionConvertConfig ActionCreateDir ActionMergeConfig ) type Action struct { Type ActionType Source string Target string Description string } type Result struct { FilesCopied int FilesSkipped int BackupsCreated int ConfigMigrated bool DirsCreated int Warnings []string Errors []error } ================================================ FILE: pkg/migrate/migrate.go ================================================ package migrate import ( "fmt" "os" "path/filepath" "strings" "github.com/sipeed/picoclaw/pkg/migrate/internal" "github.com/sipeed/picoclaw/pkg/migrate/sources/openclaw" ) type ( Options = internal.Options Operation = internal.Operation ActionType = internal.ActionType Action = internal.Action Result = internal.Result HandlerFactory = internal.HandlerFactory ) const ( ActionCopy = internal.ActionCopy ActionSkip = internal.ActionSkip ActionBackup = internal.ActionBackup ActionConvertConfig = internal.ActionConvertConfig ActionCreateDir = internal.ActionCreateDir ActionMergeConfig = internal.ActionMergeConfig ) type MigrateInstance struct { options Options handlers map[string]Operation } func NewMigrateInstance(opts Options) *MigrateInstance { instance := &MigrateInstance{ options: opts, handlers: make(map[string]Operation), } openclaw_handler, err := openclaw.NewOpenclawHandler(opts) if err == nil { instance.Register(openclaw_handler.GetSourceName(), openclaw_handler) } return instance } func (m *MigrateInstance) Register(moduleName string, module Operation) { m.handlers[moduleName] = module } func (m *MigrateInstance) getCurrentHandler() (Operation, error) { source := m.options.Source if source == "" { source = "openclaw" } handler, ok := m.handlers[source] if !ok { return nil, fmt.Errorf("Source '%s' not found", source) } return handler, nil } func (m *MigrateInstance) Run(opts Options) (*Result, error) { handler, err := m.getCurrentHandler() if err != nil { return nil, err } if opts.ConfigOnly && opts.WorkspaceOnly { return nil, fmt.Errorf("--config-only and --workspace-only are mutually exclusive") } if opts.Refresh { opts.WorkspaceOnly = true } sourceHome, err := handler.GetSourceHome() if err != nil { return nil, err } targetHome, err := internal.ResolveTargetHome(opts.TargetHome) if err != nil { return nil, err } if _, err = os.Stat(sourceHome); os.IsNotExist(err) { return nil, fmt.Errorf("Source installation not found at %s", sourceHome) } actions, warnings, err := m.Plan(opts, sourceHome, targetHome) if err != nil { return nil, err } fmt.Println("Migrating from Source to PicoClaw") fmt.Printf(" Source: %s\n", sourceHome) fmt.Printf(" Target: %s\n", targetHome) fmt.Println() if opts.DryRun { PrintPlan(actions, warnings) return &Result{Warnings: warnings}, nil } if !opts.Force { PrintPlan(actions, warnings) if !Confirm() { fmt.Println("Aborted.") return &Result{Warnings: warnings}, nil } fmt.Println() } result := m.Execute(actions, sourceHome, targetHome) result.Warnings = warnings return result, nil } func (m *MigrateInstance) Plan(opts Options, sourceHome, targetHome string) ([]Action, []string, error) { var actions []Action var warnings []string handler, err := m.getCurrentHandler() if err != nil { return nil, nil, err } force := opts.Force || opts.Refresh if !opts.WorkspaceOnly { configPath, err := handler.GetSourceConfigFile() if err != nil { if opts.ConfigOnly { return nil, nil, err } warnings = append(warnings, fmt.Sprintf("Config migration skipped: %v", err)) } else { actions = append(actions, Action{ Type: ActionConvertConfig, Source: configPath, Target: filepath.Join(targetHome, "config.json"), Description: "convert Source config to PicoClaw format", }) } } if !opts.ConfigOnly { srcWorkspace, err := handler.GetSourceWorkspace() if err != nil { return nil, nil, fmt.Errorf("getting source workspace: %w", err) } dstWorkspace := internal.ResolveWorkspace(targetHome) if _, err := os.Stat(srcWorkspace); err == nil { wsActions, err := internal.PlanWorkspaceMigration(srcWorkspace, dstWorkspace, handler.GetMigrateableFiles(), handler.GetMigrateableDirs(), force) if err != nil { return nil, nil, fmt.Errorf("planning workspace migration: %w", err) } actions = append(actions, wsActions...) } else { warnings = append(warnings, "Source workspace directory not found, skipping workspace migration") } } return actions, warnings, nil } func (m *MigrateInstance) Execute(actions []Action, sourceHome, targetHome string) *Result { result := &Result{} handler, err := m.getCurrentHandler() if err != nil { return result } for _, action := range actions { switch action.Type { case ActionConvertConfig: if err := handler.ExecuteConfigMigration(action.Source, action.Target); err != nil { result.Errors = append(result.Errors, fmt.Errorf("config migration: %w", err)) fmt.Printf(" ✗ Config migration failed: %v\n", err) } else { result.ConfigMigrated = true fmt.Printf(" ✓ Converted config: %s\n", action.Target) } case ActionCreateDir: if err := os.MkdirAll(action.Target, 0o755); err != nil { result.Errors = append(result.Errors, err) } else { result.DirsCreated++ } case ActionBackup: bakPath := action.Target + ".bak" if err := internal.CopyFile(action.Target, bakPath); err != nil { result.Errors = append(result.Errors, fmt.Errorf("backup %s: %w", action.Target, err)) fmt.Printf(" ✗ Backup failed: %s\n", action.Target) continue } result.BackupsCreated++ fmt.Printf( " ✓ Backed up %s -> %s.bak\n", filepath.Base(action.Target), filepath.Base(action.Target), ) if err := os.MkdirAll(filepath.Dir(action.Target), 0o755); err != nil { result.Errors = append(result.Errors, err) continue } if err := internal.CopyFile(action.Source, action.Target); err != nil { result.Errors = append(result.Errors, fmt.Errorf("copy %s: %w", action.Source, err)) fmt.Printf(" ✗ Copy failed: %s\n", action.Source) } else { result.FilesCopied++ fmt.Printf(" ✓ Copied %s\n", internal.RelPath(action.Source, sourceHome)) } case ActionCopy: if err := os.MkdirAll(filepath.Dir(action.Target), 0o755); err != nil { result.Errors = append(result.Errors, err) continue } if err := internal.CopyFile(action.Source, action.Target); err != nil { result.Errors = append(result.Errors, fmt.Errorf("copy %s: %w", action.Source, err)) fmt.Printf(" ✗ Copy failed: %s\n", action.Source) } else { result.FilesCopied++ fmt.Printf(" ✓ Copied %s\n", internal.RelPath(action.Source, sourceHome)) } case ActionSkip: result.FilesSkipped++ } } return result } func Confirm() bool { fmt.Print("Proceed with migration? (y/n): ") var response string fmt.Scanln(&response) return strings.ToLower(strings.TrimSpace(response)) == "y" } func (m *MigrateInstance) PrintSummary(result *Result) { fmt.Println() parts := []string{} if result.FilesCopied > 0 { parts = append(parts, fmt.Sprintf("%d files copied", result.FilesCopied)) } if result.ConfigMigrated { parts = append(parts, "1 config converted") } if result.BackupsCreated > 0 { parts = append(parts, fmt.Sprintf("%d backups created", result.BackupsCreated)) } if result.FilesSkipped > 0 { parts = append(parts, fmt.Sprintf("%d files skipped", result.FilesSkipped)) } if len(parts) > 0 { fmt.Printf("Migration complete! %s.\n", strings.Join(parts, ", ")) } else { fmt.Println("Migration complete! No actions taken.") } if len(result.Errors) > 0 { fmt.Println() fmt.Printf("%d errors occurred:\n", len(result.Errors)) for _, e := range result.Errors { fmt.Printf(" - %v\n", e) } } } func PrintPlan(actions []Action, warnings []string) { fmt.Println("Planned actions:") copies := 0 skips := 0 backups := 0 configCount := 0 for _, action := range actions { switch action.Type { case ActionConvertConfig: fmt.Printf(" [config] %s -> %s\n", action.Source, action.Target) configCount++ case ActionCopy: fmt.Printf(" [copy] %s\n", filepath.Base(action.Source)) copies++ case ActionBackup: fmt.Printf(" [backup] %s (exists, will backup and overwrite)\n", filepath.Base(action.Target)) backups++ copies++ case ActionSkip: if action.Description != "" { fmt.Printf(" [skip] %s (%s)\n", filepath.Base(action.Source), action.Description) } skips++ case ActionCreateDir: fmt.Printf(" [mkdir] %s\n", action.Target) } } if len(warnings) > 0 { fmt.Println() fmt.Println("Warnings:") for _, w := range warnings { fmt.Printf(" - %s\n", w) } } fmt.Println() fmt.Printf("%d files to copy, %d configs to convert, %d backups needed, %d skipped\n", copies, configCount, backups, skips) } ================================================ FILE: pkg/migrate/migrate_test.go ================================================ package migrate import ( "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewMigrateInstance(t *testing.T) { opts := Options{ Source: "openclaw", } instance := NewMigrateInstance(opts) require.NotNil(t, instance) assert.Equal(t, "openclaw", instance.options.Source) } func TestMigrateInstanceRegister(t *testing.T) { instance := NewMigrateInstance(Options{}) require.NotNil(t, instance) mockHandler := &mockOperation{} instance.Register("test-source", mockHandler) handler, ok := instance.handlers["test-source"] require.True(t, ok) assert.Equal(t, mockHandler, handler) } func TestMigrateInstanceGetCurrentHandler(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") err := os.WriteFile(configPath, []byte("{}"), 0o644) require.NoError(t, err) instance := NewMigrateInstance(Options{SourceHome: tmpDir}) require.NotNil(t, instance) handler, err := instance.getCurrentHandler() require.NoError(t, err) require.NotNil(t, handler) assert.Equal(t, "openclaw", handler.GetSourceName()) } func TestMigrateInstanceGetCurrentHandlerWithSource(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") err := os.WriteFile(configPath, []byte("{}"), 0o644) require.NoError(t, err) opts := Options{ Source: "openclaw", SourceHome: tmpDir, } instance := NewMigrateInstance(opts) handler, err := instance.getCurrentHandler() require.NoError(t, err) require.NotNil(t, handler) assert.Equal(t, "openclaw", handler.GetSourceName()) } func TestMigrateInstanceGetCurrentHandlerNotFound(t *testing.T) { instance := &MigrateInstance{ options: Options{}, handlers: make(map[string]Operation), } _, err := instance.getCurrentHandler() require.Error(t, err) assert.Contains(t, err.Error(), "not found") } func TestMigrateInstancePlanWithInvalidSource(t *testing.T) { instance := &MigrateInstance{ options: Options{}, handlers: make(map[string]Operation), } _, _, err := instance.Plan(Options{}, "/tmp/source", "/tmp/target") require.Error(t, err) } func TestMigrateInstancePlanConfigOnlyAndWorkspaceOnlyMutuallyExclusive(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") err := os.WriteFile(configPath, []byte("{}"), 0o644) require.NoError(t, err) instance := NewMigrateInstance(Options{SourceHome: tmpDir}) require.NotNil(t, instance) _, err = instance.Run(Options{ ConfigOnly: true, WorkspaceOnly: true, }) require.Error(t, err) assert.Contains(t, err.Error(), "mutually exclusive") } func TestMigrateInstancePlanRefreshSetsWorkspaceOnly(t *testing.T) { opts := Options{ Refresh: true, SourceHome: "/tmp/nonexistent", } instance := NewMigrateInstance(opts) require.NotNil(t, instance) _, err := instance.Run(opts) require.Error(t, err) assert.Contains(t, err.Error(), "not found") } func TestMigrateInstancePlanSourceNotFound(t *testing.T) { opts := Options{ SourceHome: "/tmp/nonexistent-source-home", } instance := NewMigrateInstance(opts) _, err := instance.Run(opts) require.Error(t, err) assert.Contains(t, err.Error(), "not found") } func TestMigrateInstanceExecute(t *testing.T) { tmpDir := t.TempDir() sourceDir := filepath.Join(tmpDir, "source") targetDir := filepath.Join(tmpDir, "target") workspaceDir := filepath.Join(sourceDir, "workspace") err := os.MkdirAll(workspaceDir, 0o755) require.NoError(t, err) err = os.WriteFile(filepath.Join(workspaceDir, "test.txt"), []byte("test"), 0o644) require.NoError(t, err) instance := &MigrateInstance{ options: Options{Source: "mock"}, handlers: make(map[string]Operation), } instance.Register("mock", &mockOperation{sourceHome: sourceDir, sourceWs: workspaceDir}) actions := []Action{ { Type: ActionCopy, Source: filepath.Join(workspaceDir, "test.txt"), Target: filepath.Join(targetDir, "workspace", "test.txt"), Description: "copy file", }, } result := instance.Execute(actions, workspaceDir, targetDir) require.NotNil(t, result) assert.Equal(t, 1, result.FilesCopied) _, err = os.Stat(filepath.Join(targetDir, "workspace", "test.txt")) assert.NoError(t, err) } func TestMigrateInstanceExecuteWithInvalidSource(t *testing.T) { tmpDir := t.TempDir() sourceDir := filepath.Join(tmpDir, "source") err := os.MkdirAll(sourceDir, 0o755) require.NoError(t, err) instance := &MigrateInstance{ options: Options{Source: "mock"}, handlers: make(map[string]Operation), } instance.Register("mock", &mockOperation{sourceHome: sourceDir}) actions := []Action{ { Type: ActionCopy, Source: filepath.Join(sourceDir, "nonexistent.txt"), Target: filepath.Join(tmpDir, "target.txt"), Description: "copy file", }, } result := instance.Execute(actions, sourceDir, tmpDir) require.NotNil(t, result) assert.Equal(t, 0, result.FilesCopied) assert.Greater(t, len(result.Errors), 0) } func TestMigrateInstanceExecuteCreateDir(t *testing.T) { tmpDir := t.TempDir() instance := &MigrateInstance{ options: Options{Source: "mock"}, handlers: make(map[string]Operation), } instance.Register("mock", &mockOperation{}) actions := []Action{ { Type: ActionCreateDir, Target: filepath.Join(tmpDir, "new", "dir"), Description: "create directory", }, } result := instance.Execute(actions, "", "") require.NotNil(t, result) assert.Equal(t, 1, result.DirsCreated) _, err := os.Stat(filepath.Join(tmpDir, "new", "dir")) assert.NoError(t, err) } func TestMigrateInstanceExecuteBackup(t *testing.T) { tmpDir := t.TempDir() sourceFile := filepath.Join(tmpDir, "source.txt") targetFile := filepath.Join(tmpDir, "target.txt") err := os.WriteFile(sourceFile, []byte("source"), 0o644) require.NoError(t, err) err = os.WriteFile(targetFile, []byte("target"), 0o644) require.NoError(t, err) instance := &MigrateInstance{ options: Options{Source: "mock"}, handlers: make(map[string]Operation), } instance.Register("mock", &mockOperation{}) actions := []Action{ { Type: ActionBackup, Source: sourceFile, Target: targetFile, Description: "backup and overwrite", }, } result := instance.Execute(actions, tmpDir, tmpDir) require.NotNil(t, result) assert.Equal(t, 1, result.BackupsCreated) assert.Equal(t, 1, result.FilesCopied) bakFile := targetFile + ".bak" _, err = os.Stat(bakFile) assert.NoError(t, err) content, err := os.ReadFile(targetFile) assert.NoError(t, err) assert.Equal(t, "source", string(content)) } func TestMigrateInstanceExecuteSkip(t *testing.T) { instance := &MigrateInstance{ options: Options{Source: "mock"}, handlers: make(map[string]Operation), } instance.Register("mock", &mockOperation{}) actions := []Action{ { Type: ActionSkip, Source: "/tmp/source.txt", Target: "/tmp/target.txt", Description: "skip file", }, } result := instance.Execute(actions, "", "") require.NotNil(t, result) assert.Equal(t, 1, result.FilesSkipped) } func TestMigrateInstancePrintSummary(t *testing.T) { instance := NewMigrateInstance(Options{}) result := &Result{ FilesCopied: 5, ConfigMigrated: true, BackupsCreated: 2, FilesSkipped: 3, Warnings: []string{"warning 1"}, Errors: []error{}, } instance.PrintSummary(result) } func TestMigrateInstancePrintSummaryWithErrors(t *testing.T) { instance := NewMigrateInstance(Options{}) result := &Result{ FilesCopied: 0, ConfigMigrated: false, BackupsCreated: 0, FilesSkipped: 0, Warnings: []string{}, Errors: []error{assert.AnError}, } instance.PrintSummary(result) } func TestMigrateInstancePrintSummaryNoActions(t *testing.T) { instance := NewMigrateInstance(Options{}) result := &Result{ FilesCopied: 0, ConfigMigrated: false, BackupsCreated: 0, FilesSkipped: 0, Warnings: []string{}, Errors: []error{}, } instance.PrintSummary(result) } func TestPrintPlan(t *testing.T) { actions := []Action{ { Type: ActionConvertConfig, Source: "/source/config.json", Target: "/target/config.json", Description: "convert config", }, { Type: ActionCopy, Source: "/source/file.txt", Target: "/target/file.txt", Description: "copy file", }, { Type: ActionBackup, Source: "/source/existing.txt", Target: "/target/existing.txt", Description: "backup and overwrite", }, { Type: ActionSkip, Source: "/source/skipped.txt", Target: "/target/skipped.txt", Description: "skip file", }, { Type: ActionCreateDir, Target: "/target/newdir", Description: "create directory", }, } warnings := []string{ "Warning: source directory not found", } PrintPlan(actions, warnings) } func TestPrintPlanEmpty(t *testing.T) { PrintPlan([]Action{}, []string{}) } type mockOperation struct { sourceHome string sourceConfig string sourceWs string migrateFiles []string migrateDirs []string } func (m *mockOperation) GetSourceName() string { return "mock" } func (m *mockOperation) GetSourceHome() (string, error) { if m.sourceHome != "" { return m.sourceHome, nil } return "/tmp/mock", nil } func (m *mockOperation) GetSourceWorkspace() (string, error) { if m.sourceWs != "" { return m.sourceWs, nil } if m.sourceHome != "" { return filepath.Join(m.sourceHome, "workspace"), nil } return "/tmp/mock/workspace", nil } func (m *mockOperation) GetSourceConfigFile() (string, error) { if m.sourceConfig != "" { return m.sourceConfig, nil } return "/tmp/mock/config.json", nil } func (m *mockOperation) ExecuteConfigMigration(src, dst string) error { return nil } func (m *mockOperation) GetMigrateableFiles() []string { if m.migrateFiles != nil { return m.migrateFiles } return []string{} } func (m *mockOperation) GetMigrateableDirs() []string { if m.migrateDirs != nil { return m.migrateDirs } return []string{} } ================================================ FILE: pkg/migrate/sources/openclaw/common.go ================================================ package openclaw var migrateableFiles = []string{ "AGENTS.md", "SOUL.md", "USER.md", "HEARTBEAT.md", } var migrateableDirs = []string{ "memory", "skills", } var supportedChannels = map[string]bool{ "whatsapp": true, "telegram": true, "feishu": true, "discord": true, "maixcam": true, "qq": true, "dingtalk": true, "slack": true, "matrix": true, "line": true, "onebot": true, "wecom": true, "wecom_app": true, } ================================================ FILE: pkg/migrate/sources/openclaw/openclaw_config.go ================================================ package openclaw import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "github.com/sipeed/picoclaw/pkg/config" ) type OpenClawConfig struct { Auth *OpenClawAuth `json:"auth"` Models *OpenClawModels `json:"models"` Agents *OpenClawAgents `json:"agents"` Tools *OpenClawTools `json:"tools"` Channels *OpenClawChannels `json:"channels"` Cron json.RawMessage `json:"cron"` Hooks json.RawMessage `json:"hooks"` Skills *OpenClawSkills `json:"skills"` Memory json.RawMessage `json:"memory"` Session json.RawMessage `json:"session"` } type OpenClawAuth struct { Profiles json.RawMessage `json:"profiles"` Order json.RawMessage `json:"order"` } type OpenClawModels struct { Providers map[string]json.RawMessage `json:"providers"` } type ProviderConfig struct { BaseUrl string `json:"baseUrl"` Api string `json:"api"` Models []ModelConfig `json:"models"` ApiKey string `json:"apiKey"` } type OpenClawModelConfig struct { ID string `json:"id"` Name string `json:"name"` Reasoning bool `json:"reasoning"` Input []string `json:"input"` Cost Cost `json:"cost"` ContextWindow int `json:"contextWindow"` MaxTokens int `json:"maxTokens"` Api string `json:"api,omitempty"` } type Cost struct { Input float64 `json:"input"` Output float64 `json:"output"` CacheRead float64 `json:"cacheRead"` CacheWrite float64 `json:"cacheWrite"` } type OpenClawTools struct { Profile *string `json:"profile"` Allow []string `json:"allow"` Deny []string `json:"deny"` } type OpenClawAgents struct { Defaults *OpenClawAgentDefaults `json:"defaults"` List []OpenClawAgentEntry `json:"list"` } type OpenClawAgentDefaults struct { Model *OpenClawAgentModel `json:"model"` Workspace *string `json:"workspace"` Tools *OpenClawAgentTools `json:"tools"` Identity *string `json:"identity"` } type OpenClawAgentModel struct { Simple string `json:"-"` Primary *string `json:"primary"` Fallbacks []string `json:"fallbacks"` } func (m *OpenClawAgentModel) GetPrimary() string { if m.Simple != "" { return m.Simple } if m.Primary != nil { return *m.Primary } return "" } func (m *OpenClawAgentModel) GetFallbacks() []string { return m.Fallbacks } type OpenClawAgentEntry struct { ID string `json:"id"` Name *string `json:"name"` Model *OpenClawAgentModel `json:"model"` Tools *OpenClawAgentTools `json:"tools"` Workspace *string `json:"workspace"` Skills []string `json:"skills"` Identity *string `json:"identity"` } type OpenClawAgentTools struct { Profile *string `json:"profile"` Allow []string `json:"allow"` Deny []string `json:"deny"` AlsoAllow []string `json:"alsoAllow"` } type OpenClawChannels struct { Telegram *OpenClawTelegramConfig `json:"telegram"` Discord *OpenClawDiscordConfig `json:"discord"` Slack *OpenClawSlackConfig `json:"slack"` WhatsApp *OpenClawWhatsAppConfig `json:"whatsapp"` Signal *OpenClawSignalConfig `json:"signal"` Matrix *OpenClawMatrixConfig `json:"matrix"` GoogleChat *OpenClawGoogleChatConfig `json:"googlechat"` Teams *OpenClawTeamsConfig `json:"msteams"` IRC *OpenClawIrcConfig `json:"irc"` Mattermost *OpenClawMattermostConfig `json:"mattermost"` Feishu *OpenClawFeishuConfig `json:"feishu"` IMessage *OpenClawIMessageConfig `json:"imessage"` BlueBubbles *OpenClawBlueBubblesConfig `json:"bluebubbles"` QQ *OpenClawQQConfig `json:"qq"` DingTalk *OpenClawDingTalkConfig `json:"dingtalk"` MaixCam *OpenClawMaixCamConfig `json:"maixcam"` } type OpenClawTelegramConfig struct { BotToken *string `json:"botToken"` AllowFrom []string `json:"allowFrom"` GroupPolicy *string `json:"groupPolicy"` DmPolicy *string `json:"dmPolicy"` Enabled *bool `json:"enabled"` UseMarkdownV2 *bool `json:"useMarkdownV2"` } type OpenClawDiscordConfig struct { Token *string `json:"token"` Guilds json.RawMessage `json:"guilds"` DmPolicy *string `json:"dmPolicy"` GroupPolicy *string `json:"groupPolicy"` AllowFrom []string `json:"allowFrom"` Enabled *bool `json:"enabled"` } type OpenClawSlackConfig struct { BotToken *string `json:"botToken"` AppToken *string `json:"appToken"` DmPolicy *string `json:"dmPolicy"` GroupPolicy *string `json:"groupPolicy"` AllowFrom []string `json:"allowFrom"` Enabled *bool `json:"enabled"` } type OpenClawWhatsAppConfig struct { AuthDir *string `json:"authDir"` DmPolicy *string `json:"dmPolicy"` AllowFrom []string `json:"allowFrom"` GroupPolicy *string `json:"groupPolicy"` Enabled *bool `json:"enabled"` BridgeURL *string `json:"bridgeUrl"` } type OpenClawSignalConfig struct { HttpUrl *string `json:"httpUrl"` HttpHost *string `json:"httpHost"` HttpPort *int `json:"httpPort"` Account *string `json:"account"` DmPolicy *string `json:"dmPolicy"` AllowFrom []string `json:"allowFrom"` Enabled *bool `json:"enabled"` } type OpenClawMatrixConfig struct { Homeserver *string `json:"homeserver"` UserID *string `json:"userId"` AccessToken *string `json:"accessToken"` Rooms []string `json:"rooms"` DmPolicy *string `json:"dmPolicy"` AllowFrom []string `json:"allowFrom"` Enabled *bool `json:"enabled"` } type OpenClawGoogleChatConfig struct { ServiceAccountFile *string `json:"serviceAccountFile"` WebhookPath *string `json:"webhookPath"` BotUser *string `json:"botUser"` DmPolicy *string `json:"dmPolicy"` Enabled *bool `json:"enabled"` } type OpenClawTeamsConfig struct { AppID *string `json:"appId"` AppPassword *string `json:"appPassword"` TenantID *string `json:"tenantId"` DmPolicy *string `json:"dmPolicy"` AllowFrom []string `json:"allowFrom"` Enabled *bool `json:"enabled"` } type OpenClawIrcConfig struct { Host *string `json:"host"` Port *int `json:"port"` TLS *bool `json:"tls"` Nick *string `json:"nick"` Password *string `json:"password"` Channels []string `json:"channels"` DmPolicy *string `json:"dmPolicy"` AllowFrom []string `json:"allowFrom"` Enabled *bool `json:"enabled"` } type OpenClawMattermostConfig struct { BotToken *string `json:"botToken"` BaseURL *string `json:"baseUrl"` DmPolicy *string `json:"dmPolicy"` AllowFrom []string `json:"allowFrom"` Enabled *bool `json:"enabled"` } type OpenClawFeishuConfig struct { AppID *string `json:"appId"` AppSecret *string `json:"appSecret"` Domain *string `json:"domain"` DmPolicy *string `json:"dmPolicy"` Enabled *bool `json:"enabled"` VerificationToken *string `json:"verificationToken"` EncryptKey *string `json:"encryptKey"` AllowFrom []string `json:"allowFrom"` } type OpenClawIMessageConfig struct { CliPath *string `json:"cliPath"` DbPath *string `json:"dbPath"` DmPolicy *string `json:"dmPolicy"` AllowFrom []string `json:"allowFrom"` Enabled *bool `json:"enabled"` } type OpenClawBlueBubblesConfig struct { ServerURL *string `json:"serverUrl"` Password *string `json:"password"` DmPolicy *string `json:"dmPolicy"` AllowFrom []string `json:"allowFrom"` Enabled *bool `json:"enabled"` } type OpenClawQQConfig struct { AppID *string `json:"appId"` AppSecret *string `json:"appSecret"` DmPolicy *string `json:"dmPolicy"` AllowFrom []string `json:"allowFrom"` Enabled *bool `json:"enabled"` } type OpenClawDingTalkConfig struct { AppID *string `json:"appId"` AppSecret *string `json:"appSecret"` DmPolicy *string `json:"dmPolicy"` AllowFrom []string `json:"allowFrom"` Enabled *bool `json:"enabled"` } type OpenClawMaixCamConfig struct { Host *string `json:"host"` Port *int `json:"port"` DmPolicy *string `json:"dmPolicy"` AllowFrom []string `json:"allowFrom"` Enabled *bool `json:"enabled"` } type OpenClawSkills struct { Entries map[string]json.RawMessage `json:"entries"` Load json.RawMessage `json:"load"` } type OpenClawProviderConfig struct { APIKey string `json:"api_key"` BaseURL string `json:"base_url"` } func (c *OpenClawConfig) GetEnabled() bool { return true } func LoadOpenClawConfig(path string) (*OpenClawConfig, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read config: %w", err) } var config OpenClawConfig if err := json.Unmarshal(data, &config); err != nil { return nil, fmt.Errorf("failed to parse JSON: %w", err) } return &config, nil } func LoadOpenClawConfigFromDir(dir string) (*OpenClawConfig, error) { candidates := []string{ filepath.Join(dir, "openclaw.json"), filepath.Join(dir, "config.json"), } for _, p := range candidates { if _, err := os.Stat(p); err == nil { return LoadOpenClawConfig(p) } } return nil, fmt.Errorf("no config file found in %s", dir) } func GetProviderConfig(models *OpenClawModels) map[string]OpenClawProviderConfig { result := make(map[string]OpenClawProviderConfig) if models == nil || models.Providers == nil { return result } for name, raw := range models.Providers { var prov OpenClawProviderConfig if err := json.Unmarshal(raw, &prov); err != nil { continue } mappedName := mapProvider(name) result[mappedName] = prov } return result } func GetProviderConfigFromDir(dir string) map[string]ProviderConfig { result := make(map[string]ProviderConfig) p := filepath.Join(dir, "agents", "main", "agent", "models.json") if _, err := os.Stat(p); err != nil { return result } data, err := os.ReadFile(p) if err != nil { return result } var models OpenClawModels if err := json.Unmarshal(data, &models); err != nil { return result } for name, raw := range models.Providers { var prov ProviderConfig if err := json.Unmarshal(raw, &prov); err != nil { continue } mappedName := mapProvider(name) result[mappedName] = prov } return result } func (c *OpenClawConfig) IsChannelEnabled(name string) bool { switch name { case "telegram": return c.Channels.Telegram == nil || c.Channels.Telegram.Enabled == nil || *c.Channels.Telegram.Enabled case "discord": return c.Channels.Discord == nil || c.Channels.Discord.Enabled == nil || *c.Channels.Discord.Enabled case "slack": return c.Channels.Slack == nil || c.Channels.Slack.Enabled == nil || *c.Channels.Slack.Enabled case "matrix": return c.Channels.Matrix == nil || c.Channels.Matrix.Enabled == nil || *c.Channels.Matrix.Enabled case "whatsapp": return c.Channels.WhatsApp == nil || c.Channels.WhatsApp.Enabled == nil || *c.Channels.WhatsApp.Enabled case "feishu": return c.Channels.Feishu == nil || c.Channels.Feishu.Enabled == nil || *c.Channels.Feishu.Enabled default: return false } } func GetChannelAllowFrom(ch any) []string { switch c := ch.(type) { case *OpenClawTelegramConfig: if c == nil { return nil } return c.AllowFrom case *OpenClawDiscordConfig: if c == nil { return nil } return c.AllowFrom case *OpenClawSlackConfig: if c == nil { return nil } return c.AllowFrom case *OpenClawMatrixConfig: if c == nil { return nil } return c.AllowFrom case *OpenClawWhatsAppConfig: if c == nil { return nil } return c.AllowFrom case *OpenClawFeishuConfig: if c == nil { return nil } return c.AllowFrom default: return nil } } func (c *OpenClawConfig) GetDefaultModel() (provider, model string) { if c.Agents == nil || c.Agents.Defaults == nil || c.Agents.Defaults.Model == nil { return "anthropic", "claude-sonnet-4-20250514" } primary := c.Agents.Defaults.Model.GetPrimary() if primary == "" { return "anthropic", "claude-sonnet-4-20250514" } parts := strings.Split(primary, "/") if len(parts) > 1 { return mapProvider(parts[0]), parts[1] } return "anthropic", primary } func (c *OpenClawConfig) GetDefaultWorkspace() string { if c.Agents == nil || c.Agents.Defaults == nil || c.Agents.Defaults.Workspace == nil { return "" } return rewriteWorkspacePath(*c.Agents.Defaults.Workspace) } func (c *OpenClawConfig) GetAgents() []OpenClawAgentEntry { if c.Agents == nil { return nil } return c.Agents.List } func (c *OpenClawConfig) HasSkills() bool { return c.Skills != nil && c.Skills.Entries != nil && len(c.Skills.Entries) > 0 } func (c *OpenClawConfig) HasMemory() bool { return c.Memory != nil && len(c.Memory) > 0 } func (c *OpenClawConfig) HasCron() bool { return c.Cron != nil && len(c.Cron) > 0 } func (c *OpenClawConfig) HasHooks() bool { return c.Hooks != nil && len(c.Hooks) > 0 } func (c *OpenClawConfig) HasSession() bool { return c.Session != nil && len(c.Session) > 0 } func (c *OpenClawConfig) HasAuthProfiles() bool { return c.Auth != nil && c.Auth.Profiles != nil && len(c.Auth.Profiles) > 0 } func (c *OpenClawConfig) ConvertToPicoClaw(sourceHome string) (*PicoClawConfig, []string, error) { cfg := &PicoClawConfig{} var warnings []string provider, modelName := c.GetDefaultModel() cfg.Agents.Defaults.Workspace = c.GetDefaultWorkspace() cfg.Agents.Defaults.ModelName = modelName providerConfigs := GetProviderConfigFromDir(sourceHome) defaultAPIKey := "" defaultBaseURL := "" if provCfg, ok := providerConfigs[provider]; ok { defaultAPIKey = provCfg.ApiKey defaultBaseURL = provCfg.BaseUrl } cfg.ModelList = []ModelConfig{ { ModelName: modelName, Model: fmt.Sprintf("%s/%s", provider, modelName), APIKey: defaultAPIKey, APIBase: defaultBaseURL, }, } for provName, provCfg := range providerConfigs { if provName == provider { continue } if provCfg.ApiKey != "" { continue } cfg.ModelList = append(cfg.ModelList, ModelConfig{ ModelName: fmt.Sprintf("%s", provName), Model: fmt.Sprintf("%s/%s", provName, provName), APIKey: provCfg.ApiKey, APIBase: provCfg.BaseUrl, }) } cfg.Channels = c.convertChannels(&warnings) agentList := c.convertAgents(&warnings) if len(agentList) > 0 { cfg.Agents.List = agentList } if c.HasSkills() { warnings = append( warnings, fmt.Sprintf( "Skills (%d entries) not automatically migrated - reinstall via picoclaw CLI", len(c.Skills.Entries), ), ) } if c.HasMemory() { warnings = append(warnings, "Memory backend config not migrated - PicoClaw uses SQLite with vector embeddings") } if c.HasCron() { warnings = append( warnings, "Cron job scheduling not supported in PicoClaw - consider using external schedulers", ) } if c.HasHooks() { warnings = append(warnings, "Webhook hooks not supported in PicoClaw - use event system instead") } if c.HasSession() { warnings = append(warnings, "Session scope config differs - PicoClaw uses per-agent sessions by default") } if c.HasAuthProfiles() { warnings = append( warnings, "Auth profiles (API keys, OAuth tokens) not migrated for security - set env vars manually", ) } return cfg, warnings, nil } type ModelConfig struct { ModelName string `json:"model_name"` Model string `json:"model"` APIBase string `json:"api_base,omitempty"` APIKey string `json:"api_key"` Proxy string `json:"proxy,omitempty"` } type PicoClawConfig struct { Agents AgentsConfig `json:"agents"` Bindings []AgentBinding `json:"bindings,omitempty"` Channels ChannelsConfig `json:"channels"` ModelList []ModelConfig `json:"model_list"` Gateway GatewayConfig `json:"gateway"` Tools ToolsConfig `json:"tools"` } type AgentsConfig struct { Defaults AgentDefaults `json:"defaults"` List []AgentConfig `json:"list,omitempty"` } type AgentDefaults struct { Workspace string `json:"workspace"` RestrictToWorkspace bool `json:"restrict_to_workspace"` Provider string `json:"provider"` ModelName string `json:"model_name"` Model string `json:"model,omitempty"` ModelFallbacks []string `json:"model_fallbacks,omitempty"` ImageModel string `json:"image_model,omitempty"` ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` MaxTokens int `json:"max_tokens"` Temperature *float64 `json:"temperature,omitempty"` MaxToolIterations int `json:"max_tool_iterations"` } type AgentConfig struct { ID string `json:"id"` Default bool `json:"default,omitempty"` Name string `json:"name,omitempty"` Workspace string `json:"workspace,omitempty"` Model *AgentModelConfig `json:"model,omitempty"` Skills []string `json:"skills,omitempty"` } type AgentModelConfig struct { Primary string `json:"primary,omitempty"` Fallbacks []string `json:"fallbacks,omitempty"` } type AgentBinding struct { AgentID string `json:"agent_id"` Match BindingMatch `json:"match"` } type BindingMatch struct { Channel string `json:"channel"` AccountID string `json:"account_id,omitempty"` Peer *PeerMatch `json:"peer,omitempty"` GuildID string `json:"guild_id,omitempty"` TeamID string `json:"team_id,omitempty"` } type PeerMatch struct { Kind string `json:"kind"` ID string `json:"id"` } type ChannelsConfig struct { WhatsApp WhatsAppConfig `json:"whatsapp"` Telegram TelegramConfig `json:"telegram"` Feishu FeishuConfig `json:"feishu"` Discord DiscordConfig `json:"discord"` MaixCam MaixCamConfig `json:"maixcam"` QQ QQConfig `json:"qq"` DingTalk DingTalkConfig `json:"dingtalk"` Slack SlackConfig `json:"slack"` Matrix MatrixConfig `json:"matrix"` LINE LINEConfig `json:"line"` } type WhatsAppConfig struct { Enabled bool `json:"enabled"` BridgeURL string `json:"bridge_url"` AllowFrom []string `json:"allow_from"` } type TelegramConfig struct { Enabled bool `json:"enabled"` Token string `json:"token"` Proxy string `json:"proxy"` AllowFrom []string `json:"allow_from"` UseMarkdownV2 bool `json:"use_markdown_v2"` } type FeishuConfig struct { Enabled bool `json:"enabled"` AppID string `json:"app_id"` AppSecret string `json:"app_secret"` EncryptKey string `json:"encrypt_key"` VerificationToken string `json:"verification_token"` AllowFrom []string `json:"allow_from"` } type DiscordConfig struct { Enabled bool `json:"enabled"` Token string `json:"token"` MentionOnly bool `json:"mention_only"` AllowFrom []string `json:"allow_from"` } type MaixCamConfig struct { Enabled bool `json:"enabled"` Host string `json:"host"` Port int `json:"port"` AllowFrom []string `json:"allow_from"` } type QQConfig struct { Enabled bool `json:"enabled"` AppID string `json:"app_id"` AppSecret string `json:"app_secret"` AllowFrom []string `json:"allow_from"` } type DingTalkConfig struct { Enabled bool `json:"enabled"` ClientID string `json:"client_id"` ClientSecret string `json:"client_secret"` AllowFrom []string `json:"allow_from"` } type SlackConfig struct { Enabled bool `json:"enabled"` BotToken string `json:"bot_token"` AppToken string `json:"app_token"` AllowFrom []string `json:"allow_from"` } type MatrixConfig struct { Enabled bool `json:"enabled"` Homeserver string `json:"homeserver"` UserID string `json:"user_id"` AccessToken string `json:"access_token"` AllowFrom []string `json:"allow_from"` } type LINEConfig struct { Enabled bool `json:"enabled"` ChannelSecret string `json:"channel_secret"` ChannelAccessToken string `json:"channel_access_token"` WebhookHost string `json:"webhook_host"` WebhookPort int `json:"webhook_port"` WebhookPath string `json:"webhook_path"` AllowFrom []string `json:"allow_from"` } type GatewayConfig struct { Host string `json:"host"` Port int `json:"port"` } type ToolsConfig struct { Web WebToolsConfig `json:"web"` Cron CronConfig `json:"cron"` Exec ExecConfig `json:"exec"` } type WebToolsConfig struct { Brave BraveConfig `json:"brave"` Tavily TavilyConfig `json:"tavily"` DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"` Perplexity PerplexityConfig `json:"perplexity"` Proxy string `json:"proxy,omitempty"` } type BraveConfig struct { Enabled bool `json:"enabled"` APIKey string `json:"api_key"` APIKeys []string `json:"api_keys"` MaxResults int `json:"max_results"` } type TavilyConfig struct { Enabled bool `json:"enabled"` APIKey string `json:"api_key"` APIKeys []string `json:"api_keys"` BaseURL string `json:"base_url"` MaxResults int `json:"max_results"` } type DuckDuckGoConfig struct { Enabled bool `json:"enabled"` MaxResults int `json:"max_results"` } type PerplexityConfig struct { Enabled bool `json:"enabled"` APIKey string `json:"api_key"` APIKeys []string `json:"api_keys"` MaxResults int `json:"max_results"` } type CronConfig struct { ExecTimeoutMinutes int `json:"exec_timeout_minutes"` } type ExecConfig struct { EnableDenyPatterns bool `json:"enable_deny_patterns"` CustomDenyPatterns []string `json:"custom_deny_patterns"` } func (c *OpenClawConfig) convertChannels(warnings *[]string) ChannelsConfig { channels := ChannelsConfig{} if c.Channels == nil { return channels } if c.Channels.Telegram != nil { enabled := c.Channels.Telegram.Enabled == nil || *c.Channels.Telegram.Enabled useMarkdownV2 := c.Channels.Telegram.UseMarkdownV2 != nil && *c.Channels.Telegram.UseMarkdownV2 channels.Telegram = TelegramConfig{ Enabled: enabled, AllowFrom: c.Channels.Telegram.AllowFrom, UseMarkdownV2: useMarkdownV2, } if c.Channels.Telegram.BotToken != nil { channels.Telegram.Token = *c.Channels.Telegram.BotToken } } if c.Channels.Discord != nil { enabled := c.Channels.Discord.Enabled == nil || *c.Channels.Discord.Enabled channels.Discord = DiscordConfig{ Enabled: enabled, AllowFrom: c.Channels.Discord.AllowFrom, } if c.Channels.Discord.Token != nil { channels.Discord.Token = *c.Channels.Discord.Token } } if c.Channels.Slack != nil { enabled := c.Channels.Slack.Enabled == nil || *c.Channels.Slack.Enabled channels.Slack = SlackConfig{ Enabled: enabled, AllowFrom: c.Channels.Slack.AllowFrom, } if c.Channels.Slack.BotToken != nil { channels.Slack.BotToken = *c.Channels.Slack.BotToken } if c.Channels.Slack.AppToken != nil { channels.Slack.AppToken = *c.Channels.Slack.AppToken } } if c.Channels.WhatsApp != nil { enabled := c.Channels.WhatsApp.Enabled == nil || *c.Channels.WhatsApp.Enabled channels.WhatsApp = WhatsAppConfig{ Enabled: enabled, AllowFrom: c.Channels.WhatsApp.AllowFrom, } if c.Channels.WhatsApp.BridgeURL != nil { channels.WhatsApp.BridgeURL = *c.Channels.WhatsApp.BridgeURL } } if c.Channels.Feishu != nil { enabled := c.Channels.Feishu.Enabled == nil || *c.Channels.Feishu.Enabled channels.Feishu = FeishuConfig{ Enabled: enabled, AllowFrom: c.Channels.Feishu.AllowFrom, } if c.Channels.Feishu.AppID != nil { channels.Feishu.AppID = *c.Channels.Feishu.AppID } if c.Channels.Feishu.AppSecret != nil { channels.Feishu.AppSecret = *c.Channels.Feishu.AppSecret } if c.Channels.Feishu.EncryptKey != nil { channels.Feishu.EncryptKey = *c.Channels.Feishu.EncryptKey } if c.Channels.Feishu.VerificationToken != nil { channels.Feishu.VerificationToken = *c.Channels.Feishu.VerificationToken } } if c.Channels.QQ != nil && supportedChannels["qq"] { channels.QQ = QQConfig{ Enabled: true, AllowFrom: c.Channels.QQ.AllowFrom, } if c.Channels.QQ.AppID != nil { channels.QQ.AppID = *c.Channels.QQ.AppID } if c.Channels.QQ.AppSecret != nil { channels.QQ.AppSecret = *c.Channels.QQ.AppSecret } } if c.Channels.DingTalk != nil && supportedChannels["dingtalk"] { channels.DingTalk = DingTalkConfig{ Enabled: true, AllowFrom: c.Channels.DingTalk.AllowFrom, } if c.Channels.DingTalk.AppID != nil { channels.DingTalk.ClientID = *c.Channels.DingTalk.AppID } if c.Channels.DingTalk.AppSecret != nil { channels.DingTalk.ClientSecret = *c.Channels.DingTalk.AppSecret } } if c.Channels.MaixCam != nil && supportedChannels["maixcam"] { channels.MaixCam = MaixCamConfig{ Enabled: true, AllowFrom: c.Channels.MaixCam.AllowFrom, } if c.Channels.MaixCam.Host != nil { channels.MaixCam.Host = *c.Channels.MaixCam.Host } if c.Channels.MaixCam.Port != nil { channels.MaixCam.Port = *c.Channels.MaixCam.Port } } if c.Channels.Matrix != nil && supportedChannels["matrix"] { enabled := c.Channels.Matrix.Enabled == nil || *c.Channels.Matrix.Enabled channels.Matrix = MatrixConfig{ Enabled: enabled, AllowFrom: c.Channels.Matrix.AllowFrom, } if c.Channels.Matrix.Homeserver != nil { channels.Matrix.Homeserver = *c.Channels.Matrix.Homeserver } if c.Channels.Matrix.UserID != nil { channels.Matrix.UserID = *c.Channels.Matrix.UserID } if c.Channels.Matrix.AccessToken != nil { channels.Matrix.AccessToken = *c.Channels.Matrix.AccessToken } } if c.Channels.Signal != nil { *warnings = append(*warnings, "Channel 'signal': No PicoClaw adapter available") } if c.Channels.IRC != nil { *warnings = append(*warnings, "Channel 'irc': No PicoClaw adapter available") } if c.Channels.Mattermost != nil { *warnings = append(*warnings, "Channel 'mattermost': No PicoClaw adapter available") } if c.Channels.IMessage != nil { *warnings = append(*warnings, "Channel 'imessage': macOS-only channel - requires manual setup") } if c.Channels.BlueBubbles != nil { *warnings = append( *warnings, "Channel 'bluebubbles': No PicoClaw adapter available - consider iMessage instead", ) } return channels } func (c *OpenClawConfig) convertAgents(warnings *[]string) []AgentConfig { var agents []AgentConfig if c.Agents == nil { return agents } for _, entry := range c.Agents.List { agentID := entry.ID if agentID == "" { continue } agentName := agentID if entry.Name != nil { agentName = *entry.Name } agentCfg := AgentConfig{ ID: agentID, Name: agentName, Default: len(agents) == 0, } if entry.Workspace != nil { agentCfg.Workspace = rewriteWorkspacePath(*entry.Workspace) } if entry.Model != nil { primary := entry.Model.GetPrimary() if primary != "" { agentCfg.Model = &AgentModelConfig{ Primary: primary, Fallbacks: entry.Model.GetFallbacks(), } } } if len(entry.Skills) > 0 { agentCfg.Skills = entry.Skills } agents = append(agents, agentCfg) } return agents } func (c *PicoClawConfig) ToStandardConfig() *config.Config { cfg := config.DefaultConfig() cfg.Agents.Defaults.Workspace = c.Agents.Defaults.Workspace cfg.Agents.Defaults.Provider = c.Agents.Defaults.Provider cfg.Agents.Defaults.ModelName = c.Agents.Defaults.ModelName cfg.Agents.Defaults.ModelFallbacks = c.Agents.Defaults.ModelFallbacks for _, m := range c.ModelList { cfg.ModelList = append(cfg.ModelList, config.ModelConfig{ ModelName: m.ModelName, Model: m.Model, APIBase: m.APIBase, APIKey: m.APIKey, Proxy: m.Proxy, }) } cfg.Channels = c.Channels.ToStandardChannels() cfg.Gateway = c.Gateway.ToStandardGateway() cfg.Tools = c.Tools.ToStandardTools() cfg.Agents.List = make([]config.AgentConfig, len(c.Agents.List)) for i, a := range c.Agents.List { cfg.Agents.List[i] = config.AgentConfig{ ID: a.ID, Default: a.Default, Name: a.Name, Workspace: a.Workspace, Skills: a.Skills, } if a.Model != nil { cfg.Agents.List[i].Model = &config.AgentModelConfig{ Primary: a.Model.Primary, Fallbacks: a.Model.Fallbacks, } } } return cfg } func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig { return config.ChannelsConfig{ WhatsApp: config.WhatsAppConfig{ Enabled: c.WhatsApp.Enabled, BridgeURL: c.WhatsApp.BridgeURL, }, Telegram: config.TelegramConfig{ Enabled: c.Telegram.Enabled, Token: c.Telegram.Token, Proxy: c.Telegram.Proxy, }, Feishu: config.FeishuConfig{ Enabled: c.Feishu.Enabled, AppID: c.Feishu.AppID, AppSecret: c.Feishu.AppSecret, EncryptKey: c.Feishu.EncryptKey, VerificationToken: c.Feishu.VerificationToken, }, Discord: config.DiscordConfig{ Enabled: c.Discord.Enabled, Token: c.Discord.Token, MentionOnly: c.Discord.MentionOnly, }, MaixCam: config.MaixCamConfig{ Enabled: c.MaixCam.Enabled, Host: c.MaixCam.Host, Port: c.MaixCam.Port, }, QQ: config.QQConfig{ Enabled: c.QQ.Enabled, AppID: c.QQ.AppID, AppSecret: c.QQ.AppSecret, }, DingTalk: config.DingTalkConfig{ Enabled: c.DingTalk.Enabled, ClientID: c.DingTalk.ClientID, ClientSecret: c.DingTalk.ClientSecret, }, Slack: config.SlackConfig{ Enabled: c.Slack.Enabled, BotToken: c.Slack.BotToken, AppToken: c.Slack.AppToken, }, Matrix: config.MatrixConfig{ Enabled: c.Matrix.Enabled, Homeserver: c.Matrix.Homeserver, UserID: c.Matrix.UserID, AccessToken: c.Matrix.AccessToken, AllowFrom: c.Matrix.AllowFrom, JoinOnInvite: true, }, LINE: config.LINEConfig{ Enabled: c.LINE.Enabled, ChannelSecret: c.LINE.ChannelSecret, ChannelAccessToken: c.LINE.ChannelAccessToken, WebhookHost: c.LINE.WebhookHost, WebhookPort: c.LINE.WebhookPort, WebhookPath: c.LINE.WebhookPath, }, } } func (c GatewayConfig) ToStandardGateway() config.GatewayConfig { return config.GatewayConfig{ Host: c.Host, Port: c.Port, } } func (c ToolsConfig) ToStandardTools() config.ToolsConfig { return config.ToolsConfig{ Web: config.WebToolsConfig{ Brave: config.BraveConfig{ Enabled: c.Web.Brave.Enabled, APIKey: c.Web.Brave.APIKey, APIKeys: c.Web.Brave.APIKeys, MaxResults: c.Web.Brave.MaxResults, }, Tavily: config.TavilyConfig{ Enabled: c.Web.Tavily.Enabled, APIKey: c.Web.Tavily.APIKey, BaseURL: c.Web.Tavily.BaseURL, MaxResults: c.Web.Tavily.MaxResults, }, DuckDuckGo: config.DuckDuckGoConfig{ Enabled: c.Web.DuckDuckGo.Enabled, MaxResults: c.Web.DuckDuckGo.MaxResults, }, Perplexity: config.PerplexityConfig{ Enabled: c.Web.Perplexity.Enabled, APIKey: c.Web.Perplexity.APIKey, MaxResults: c.Web.Perplexity.MaxResults, }, Proxy: c.Web.Proxy, }, Cron: config.CronToolsConfig{ ExecTimeoutMinutes: c.Cron.ExecTimeoutMinutes, }, Exec: config.ExecConfig{ EnableDenyPatterns: c.Exec.EnableDenyPatterns, CustomDenyPatterns: c.Exec.CustomDenyPatterns, AllowRemote: config.DefaultConfig().Tools.Exec.AllowRemote, }, } } ================================================ FILE: pkg/migrate/sources/openclaw/openclaw_config_test.go ================================================ package openclaw import ( "encoding/json" "os" "path/filepath" "strings" "testing" ) func TestLoadOpenClawConfig(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") testConfig := `{ "agents": { "defaults": { "model": { "primary": "anthropic/claude-sonnet-4-20250514" }, "workspace": "~/.openclaw/workspace" }, "list": [ { "id": "main", "name": "Main Agent", "model": { "primary": "openai/gpt-4o", "fallbacks": ["claude-3-opus"] } } ] }, "channels": { "telegram": { "enabled": true, "botToken": "test-token", "allowFrom": ["user1", "user2"] }, "discord": { "enabled": true, "token": "discord-token" } }, "models": { "providers": { "anthropic": { "api_key": "sk-ant-test", "base_url": "https://api.anthropic.com" }, "openai": { "api_key": "sk-test" } } } }` err := os.WriteFile(configPath, []byte(testConfig), 0o644) if err != nil { t.Fatalf("failed to write test config: %v", err) } cfg, err := LoadOpenClawConfig(configPath) if err != nil { t.Fatalf("failed to load config: %v", err) } if cfg.Agents == nil { t.Error("agents should not be nil") } if cfg.Agents.Defaults == nil { t.Error("agents.defaults should not be nil") } provider, model := cfg.GetDefaultModel() if provider != "anthropic" { t.Errorf("expected provider 'anthropic', got '%s'", provider) } if model != "claude-sonnet-4-20250514" { t.Errorf("expected model 'claude-sonnet-4-20250514', got '%s'", model) } workspace := cfg.GetDefaultWorkspace() if workspace != "~/.picoclaw/workspace" { t.Errorf("expected workspace '~/.picoclaw/workspace', got '%s'", workspace) } agents := cfg.GetAgents() if len(agents) != 1 { t.Errorf("expected 1 agent, got %d", len(agents)) } if agents[0].ID != "main" { t.Errorf("expected agent id 'main', got '%s'", agents[0].ID) } if cfg.Channels == nil { t.Error("channels should not be nil") } if cfg.Channels.Telegram == nil { t.Error("telegram channel should not be nil") } if cfg.Channels.Telegram.BotToken == nil || *cfg.Channels.Telegram.BotToken != "test-token" { t.Error("telegram bot token not parsed correctly") } } func TestGetProviderConfig(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") testConfig := `{ "models": { "providers": { "anthropic": { "api_key": "sk-ant-test", "base_url": "https://api.anthropic.com", "max_tokens": 4096 }, "openai": { "api_key": "sk-test", "base_url": "https://api.openai.com" } } } }` err := os.WriteFile(configPath, []byte(testConfig), 0o644) if err != nil { t.Fatalf("failed to write test config: %v", err) } cfg, err := LoadOpenClawConfig(configPath) if err != nil { t.Fatalf("failed to load config: %v", err) } providers := GetProviderConfig(cfg.Models) if len(providers) != 2 { t.Errorf("expected 2 providers, got %d", len(providers)) } if anthropic, ok := providers["anthropic"]; ok { if anthropic.APIKey != "sk-ant-test" { t.Errorf("expected anthropic api_key 'sk-ant-test', got '%s'", anthropic.APIKey) } if anthropic.BaseURL != "https://api.anthropic.com" { t.Errorf("expected anthropic base_url 'https://api.anthropic.com', got '%s'", anthropic.BaseURL) } } else { t.Error("anthropic provider not found") } if openai, ok := providers["openai"]; ok { if openai.APIKey != "sk-test" { t.Errorf("expected openai api_key 'sk-test', got '%s'", openai.APIKey) } } else { t.Error("openai provider not found") } } func TestConvertToPicoClaw(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") testConfig := `{ "agents": { "defaults": { "model": { "primary": "anthropic/claude-sonnet-4-20250514" }, "workspace": "~/.openclaw/workspace" }, "list": [ { "id": "main", "name": "Main Agent" }, { "id": "assistant", "name": "Assistant", "skills": ["skill1", "skill2"] } ] }, "channels": { "telegram": { "enabled": true, "botToken": "test-token", "allowFrom": ["user1", "user2"] }, "discord": { "enabled": false, "token": "discord-token" }, "whatsapp": { "enabled": true, "bridgeUrl": "http://localhost:3000" }, "feishu": { "enabled": true, "appId": "app-id", "appSecret": "app-secret", "allowFrom": ["user3"] }, "signal": { "enabled": true } }, "models": { "providers": { "anthropic": { "api_key": "sk-ant-test" }, "openai": { "api_key": "sk-test" } } }, "skills": { "entries": { "skill1": {} } }, "memory": {"enabled": true}, "cron": {"enabled": true} }` err := os.WriteFile(configPath, []byte(testConfig), 0o644) if err != nil { t.Fatalf("failed to write test config: %v", err) } cfg, err := LoadOpenClawConfig(configPath) if err != nil { t.Fatalf("failed to load config: %v", err) } picoCfg, warnings, err := cfg.ConvertToPicoClaw("") if err != nil { t.Fatalf("failed to convert config: %v", err) } if picoCfg.Agents.Defaults.ModelName != "claude-sonnet-4-20250514" { t.Errorf("expected model 'claude-sonnet-4-20250514', got '%s'", picoCfg.Agents.Defaults.ModelName) } if picoCfg.Agents.Defaults.Workspace != "~/.picoclaw/workspace" { t.Errorf("expected workspace '~/.picoclaw/workspace', got '%s'", picoCfg.Agents.Defaults.Workspace) } if len(picoCfg.Agents.List) != 2 { t.Errorf("expected 2 agents, got %d", len(picoCfg.Agents.List)) } if picoCfg.Agents.List[0].ID != "main" { t.Errorf("expected first agent id 'main', got '%s'", picoCfg.Agents.List[0].ID) } if picoCfg.Agents.List[1].Skills == nil || len(picoCfg.Agents.List[1].Skills) != 2 { t.Errorf("expected 2 skills for assistant agent") } if !picoCfg.Channels.Telegram.Enabled { t.Error("telegram should be enabled") } if picoCfg.Channels.Telegram.Token != "test-token" { t.Errorf("expected telegram token 'test-token', got '%s'", picoCfg.Channels.Telegram.Token) } if picoCfg.Channels.WhatsApp.BridgeURL != "http://localhost:3000" { t.Errorf("expected whatsapp bridge URL 'http://localhost:3000', got '%s'", picoCfg.Channels.WhatsApp.BridgeURL) } if picoCfg.Channels.Feishu.AppID != "app-id" { t.Errorf("expected feishu app ID 'app-id', got '%s'", picoCfg.Channels.Feishu.AppID) } if len(picoCfg.ModelList) != 1 { t.Errorf("expected 1 model config (no models.json provided), got %d", len(picoCfg.ModelList)) } foundWarning := false for _, w := range warnings { if len(w) > 0 { foundWarning = true break } } if !foundWarning { t.Log("warnings should be generated for skills, memory, cron, and unsupported channels") } } func TestToStandardConfig_ExecAllowRemoteDefaultsTrue(t *testing.T) { cfg := (&PicoClawConfig{ Tools: ToolsConfig{ Exec: ExecConfig{ EnableDenyPatterns: true, }, }, }).ToStandardConfig() if !cfg.Tools.Exec.AllowRemote { t.Fatal("ToStandardConfig() should preserve the default tools.exec.allow_remote=true") } } func TestConvertToPicoClawWithQQAndDingTalk(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") testConfig := `{ "agents": { "defaults": { "model": { "primary": "anthropic/claude-sonnet-4-20250514" } } }, "channels": { "qq": { "enabled": true, "appId": "qq-app-id", "appSecret": "qq-app-secret" }, "dingtalk": { "enabled": true, "appId": "ding-app-id", "appSecret": "ding-app-secret" }, "maixcam": { "enabled": true, "host": "192.168.1.100", "port": 9000 }, "slack": { "enabled": true, "botToken": "xoxb-test", "appToken": "xapp-test" } } }` err := os.WriteFile(configPath, []byte(testConfig), 0o644) if err != nil { t.Fatalf("failed to write test config: %v", err) } cfg, err := LoadOpenClawConfig(configPath) if err != nil { t.Fatalf("failed to load config: %v", err) } picoCfg, _, err := cfg.ConvertToPicoClaw("") if err != nil { t.Fatalf("failed to convert config: %v", err) } if !picoCfg.Channels.QQ.Enabled { t.Error("qq should be enabled") } if picoCfg.Channels.QQ.AppID != "qq-app-id" { t.Errorf("expected qq app ID 'qq-app-id', got '%s'", picoCfg.Channels.QQ.AppID) } if !picoCfg.Channels.DingTalk.Enabled { t.Error("dingtalk should be enabled") } if picoCfg.Channels.DingTalk.ClientID != "ding-app-id" { t.Errorf("expected dingtalk client ID 'ding-app-id', got '%s'", picoCfg.Channels.DingTalk.ClientID) } if !picoCfg.Channels.MaixCam.Enabled { t.Error("maixcam should be enabled") } if picoCfg.Channels.MaixCam.Host != "192.168.1.100" { t.Errorf("expected maixcam host '192.168.1.100', got '%s'", picoCfg.Channels.MaixCam.Host) } if picoCfg.Channels.MaixCam.Port != 9000 { t.Errorf("expected maixcam port 9000, got %d", picoCfg.Channels.MaixCam.Port) } if !picoCfg.Channels.Slack.Enabled { t.Error("slack should be enabled") } if picoCfg.Channels.Slack.BotToken != "xoxb-test" { t.Errorf("expected slack bot token 'xoxb-test', got '%s'", picoCfg.Channels.Slack.BotToken) } if picoCfg.Channels.Slack.AppToken != "xapp-test" { t.Errorf("expected slack app token 'xapp-test', got '%s'", picoCfg.Channels.Slack.AppToken) } } func TestConvertToPicoClawWithMatrix(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") testConfig := `{ "channels": { "matrix": { "enabled": true, "homeserver": "https://matrix.example.com", "userId": "@bot:matrix.example.com", "accessToken": "syt_test_token", "allowFrom": ["@alice:matrix.example.com"] } } }` err := os.WriteFile(configPath, []byte(testConfig), 0o644) if err != nil { t.Fatalf("failed to write test config: %v", err) } cfg, err := LoadOpenClawConfig(configPath) if err != nil { t.Fatalf("failed to load config: %v", err) } picoCfg, warnings, err := cfg.ConvertToPicoClaw("") if err != nil { t.Fatalf("failed to convert config: %v", err) } if !picoCfg.Channels.Matrix.Enabled { t.Error("matrix should be enabled") } if picoCfg.Channels.Matrix.Homeserver != "https://matrix.example.com" { t.Errorf("expected matrix homeserver, got %q", picoCfg.Channels.Matrix.Homeserver) } if picoCfg.Channels.Matrix.UserID != "@bot:matrix.example.com" { t.Errorf("expected matrix user_id, got %q", picoCfg.Channels.Matrix.UserID) } if picoCfg.Channels.Matrix.AccessToken != "syt_test_token" { t.Errorf("expected matrix access_token, got %q", picoCfg.Channels.Matrix.AccessToken) } if len(picoCfg.Channels.Matrix.AllowFrom) != 1 || picoCfg.Channels.Matrix.AllowFrom[0] != "@alice:matrix.example.com" { t.Errorf("unexpected matrix allow_from: %#v", picoCfg.Channels.Matrix.AllowFrom) } for _, w := range warnings { if strings.Contains(w, "Channel 'matrix'") { t.Fatalf("matrix should no longer be reported as unsupported, warning=%q", w) } } } func TestConvertToPicoClawWithMatrixDisabled(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") testConfig := `{ "channels": { "matrix": { "enabled": false, "homeserver": "https://matrix.example.com", "userId": "@bot:matrix.example.com", "accessToken": "syt_test_token" } } }` err := os.WriteFile(configPath, []byte(testConfig), 0o644) if err != nil { t.Fatalf("failed to write test config: %v", err) } cfg, err := LoadOpenClawConfig(configPath) if err != nil { t.Fatalf("failed to load config: %v", err) } picoCfg, _, err := cfg.ConvertToPicoClaw("") if err != nil { t.Fatalf("failed to convert config: %v", err) } if picoCfg.Channels.Matrix.Enabled { t.Error("matrix should respect enabled=false from source config") } } func TestOpenClawAgentModel(t *testing.T) { model := &OpenClawAgentModel{ Primary: strPtr("anthropic/claude-3-opus"), Fallbacks: []string{"claude-3-sonnet", "claude-3-haiku"}, } primary := model.GetPrimary() if primary != "anthropic/claude-3-opus" { t.Errorf("expected primary 'anthropic/claude-3-opus', got '%s'", primary) } fallbacks := model.GetFallbacks() if len(fallbacks) != 2 { t.Errorf("expected 2 fallbacks, got %d", len(fallbacks)) } model2 := &OpenClawAgentModel{ Simple: "claude-3-opus", } primary2 := model2.GetPrimary() if primary2 != "claude-3-opus" { t.Errorf("expected primary 'claude-3-opus' from Simple, got '%s'", primary2) } } func TestChannelEnabled(t *testing.T) { cfg := &OpenClawConfig{ Channels: &OpenClawChannels{ Telegram: &OpenClawTelegramConfig{ Enabled: boolPtr(true), }, Discord: &OpenClawDiscordConfig{ Enabled: boolPtr(false), }, Slack: &OpenClawSlackConfig{ Enabled: boolPtr(true), }, }, } if !cfg.IsChannelEnabled("telegram") { t.Error("telegram should be enabled") } if cfg.IsChannelEnabled("discord") { t.Error("discord should be disabled") } if !cfg.IsChannelEnabled("slack") { t.Error("slack should be enabled (explicitly set)") } if !cfg.IsChannelEnabled("matrix") { t.Error("matrix should be enabled (nil config defaults to enabled)") } if cfg.IsChannelEnabled("line") { t.Error("line should return false (not in switch cases)") } } func TestGetDefaultModel(t *testing.T) { cfg := &OpenClawConfig{ Agents: &OpenClawAgents{ Defaults: &OpenClawAgentDefaults{ Model: &OpenClawAgentModel{ Primary: strPtr("openai/gpt-4"), }, }, }, } provider, model := cfg.GetDefaultModel() if provider != "openai" { t.Errorf("expected provider 'openai', got '%s'", provider) } if model != "gpt-4" { t.Errorf("expected model 'gpt-4', got '%s'", model) } } func TestGetDefaultModelWithNoDefaults(t *testing.T) { cfg := &OpenClawConfig{} provider, model := cfg.GetDefaultModel() if provider != "anthropic" { t.Errorf("expected default provider 'anthropic', got '%s'", provider) } if model != "claude-sonnet-4-20250514" { t.Errorf("expected default model 'claude-sonnet-4-20250514', got '%s'", model) } } func TestHasFunctions(t *testing.T) { cfg := &OpenClawConfig{ Skills: &OpenClawSkills{Entries: map[string]json.RawMessage{"skill1": nil}}, Memory: json.RawMessage(`{"enabled": true}`), Cron: json.RawMessage(`{"enabled": true}`), Hooks: json.RawMessage(`{"enabled": true}`), Session: json.RawMessage(`{"enabled": true}`), Auth: &OpenClawAuth{Profiles: json.RawMessage(`{"profile1": {}}`)}, } if !cfg.HasSkills() { t.Error("should have skills") } if !cfg.HasMemory() { t.Error("should have memory") } if !cfg.HasCron() { t.Error("should have cron") } if !cfg.HasHooks() { t.Error("should have hooks") } if !cfg.HasSession() { t.Error("should have session") } if !cfg.HasAuthProfiles() { t.Error("should have auth profiles") } cfg2 := &OpenClawConfig{} if cfg2.HasSkills() { t.Error("should not have skills") } if cfg2.HasMemory() { t.Error("should not have memory") } } func TestLoadOpenClawConfigFromDir(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") testConfig := `{"agents": {}}` err := os.WriteFile(configPath, []byte(testConfig), 0o644) if err != nil { t.Fatalf("failed to write test config: %v", err) } cfg, err := LoadOpenClawConfigFromDir(tmpDir) if err != nil { t.Fatalf("failed to load config from dir: %v", err) } if cfg.Agents == nil { t.Error("agents should not be nil") } _, err = LoadOpenClawConfigFromDir("/nonexistent/dir") if err == nil { t.Error("should return error for nonexistent dir") } } func TestToStandardConfig(t *testing.T) { picoCfg := &PicoClawConfig{ Agents: AgentsConfig{ Defaults: AgentDefaults{ Provider: "anthropic", ModelName: "claude-sonnet-4-20250514", Workspace: "~/.picoclaw/workspace", }, List: []AgentConfig{ { ID: "main", Name: "Main Agent", Default: true, }, }, }, ModelList: []ModelConfig{ { ModelName: "claude-sonnet-4-20250514", Model: "anthropic/claude-sonnet-4-20250514", APIKey: "sk-ant-test", }, }, Channels: ChannelsConfig{ Telegram: TelegramConfig{ Enabled: true, Token: "test-token", AllowFrom: []string{"user1"}, }, WhatsApp: WhatsAppConfig{ Enabled: true, BridgeURL: "http://localhost:3000", }, }, Gateway: GatewayConfig{ Host: "0.0.0.0", Port: 8080, }, } stdCfg := picoCfg.ToStandardConfig() if stdCfg.Agents.Defaults.Provider != "anthropic" { t.Errorf("expected provider 'anthropic', got '%s'", stdCfg.Agents.Defaults.Provider) } if stdCfg.Agents.Defaults.ModelName != "claude-sonnet-4-20250514" { t.Errorf("expected model name 'claude-sonnet-4-20250514', got '%s'", stdCfg.Agents.Defaults.ModelName) } if stdCfg.Agents.Defaults.Workspace != "~/.picoclaw/workspace" { t.Errorf("expected workspace '~/.picoclaw/workspace', got '%s'", stdCfg.Agents.Defaults.Workspace) } if len(stdCfg.Agents.List) != 1 { t.Errorf("expected 1 agent, got %d", len(stdCfg.Agents.List)) } if stdCfg.Agents.List[0].ID != "main" { t.Errorf("expected agent id 'main', got '%s'", stdCfg.Agents.List[0].ID) } foundModel := false var foundAPIKey string for _, m := range stdCfg.ModelList { if m.ModelName == "claude-sonnet-4-20250514" { foundModel = true foundAPIKey = m.APIKey break } } if !foundModel { t.Error("expected to find claude-sonnet-4-20250514 model config") } if foundAPIKey != "sk-ant-test" { t.Errorf("expected api key 'sk-ant-test', got '%s'", foundAPIKey) } if !stdCfg.Channels.Telegram.Enabled { t.Error("telegram should be enabled") } if stdCfg.Channels.Telegram.Token != "test-token" { t.Errorf("expected token 'test-token', got '%s'", stdCfg.Channels.Telegram.Token) } if stdCfg.Gateway.Port != 8080 { t.Errorf("expected gateway port 8080, got %d", stdCfg.Gateway.Port) } } func TestLoadProviderConfigFromAgentsDir(t *testing.T) { tmpDir := t.TempDir() agentsDir := filepath.Join(tmpDir, "agents", "main", "agent") err := os.MkdirAll(agentsDir, 0o755) if err != nil { t.Fatalf("failed to create agents dir: %v", err) } modelsJSON := `{ "providers": { "anthropic": { "baseUrl": "https://api.anthropic.com", "api": "anthropic", "apiKey": "sk-ant-from-models", "models": [ { "id": "claude-sonnet-4-20250514", "name": "Claude Sonnet 4" } ] }, "openai": { "baseUrl": "https://api.openai.com", "api": "openai", "apiKey": "sk-from-models", "models": [ { "id": "gpt-4o", "name": "GPT-4o" } ] }, "zhipu": { "baseUrl": "https://open.bigmodel.cn/api/paas/v4", "api": "openai", "apiKey": "zhipu-key", "models": [] } } }` err = os.WriteFile(filepath.Join(agentsDir, "models.json"), []byte(modelsJSON), 0o644) if err != nil { t.Fatalf("failed to write models.json: %v", err) } providers := GetProviderConfigFromDir(tmpDir) if len(providers) != 3 { t.Errorf("expected 3 providers, got %d", len(providers)) } if anthropic, ok := providers["anthropic"]; ok { if anthropic.ApiKey != "sk-ant-from-models" { t.Errorf("expected anthropic apiKey 'sk-ant-from-models', got '%s'", anthropic.ApiKey) } if anthropic.BaseUrl != "https://api.anthropic.com" { t.Errorf("expected anthropic baseUrl 'https://api.anthropic.com', got '%s'", anthropic.BaseUrl) } } else { t.Error("anthropic provider not found") } if openai, ok := providers["openai"]; ok { if openai.ApiKey != "sk-from-models" { t.Errorf("expected openai apiKey 'sk-from-models', got '%s'", openai.ApiKey) } if openai.BaseUrl != "https://api.openai.com" { t.Errorf("expected openai baseUrl 'https://api.openai.com', got '%s'", openai.BaseUrl) } } else { t.Error("openai provider not found") } if zhipu, ok := providers["zhipu"]; ok { if zhipu.ApiKey != "zhipu-key" { t.Errorf("expected zhipu apiKey 'zhipu-key', got '%s'", zhipu.ApiKey) } if zhipu.BaseUrl != "https://open.bigmodel.cn/api/paas/v4" { t.Errorf("expected zhipu baseUrl 'https://open.bigmodel.cn/api/paas/v4', got '%s'", zhipu.BaseUrl) } } else { t.Error("zhipu provider not found") } } func TestGetProviderConfigFromDirNotExist(t *testing.T) { providers := GetProviderConfigFromDir("/nonexistent/path") if len(providers) != 0 { t.Errorf("expected 0 providers for nonexistent path, got %d", len(providers)) } } func strPtr(s string) *string { return &s } func boolPtr(b bool) *bool { return &b } ================================================ FILE: pkg/migrate/sources/openclaw/openclaw_handler.go ================================================ package openclaw import ( "fmt" "os" "path/filepath" "strings" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/migrate/internal" ) // OpenclawHomeEnvVar is the environment variable that overrides the source // openclaw home directory when migrating from openclaw to picoclaw. // Default: ~/.openclaw const OpenclawHomeEnvVar = "OPENCLAW_HOME" var providerMapping = map[string]string{ "anthropic": "anthropic", "claude": "anthropic", "openai": "openai", "gpt": "openai", "groq": "groq", "ollama": "ollama", "openrouter": "openrouter", "deepseek": "deepseek", "together": "together", "mistral": "mistral", "fireworks": "fireworks", "google": "google", "gemini": "google", "xai": "xai", "grok": "xai", "cerebras": "cerebras", "sambanova": "sambanova", } type OpenclawHandler struct { opts Options sourceConfigFile string sourceWorkspace string } type ( Options = internal.Options Action = internal.Action Result = internal.Result Operation = internal.Operation ) func NewOpenclawHandler(opts Options) (Operation, error) { home, err := resolveSourceHome(opts.SourceHome) if err != nil { return nil, err } opts.SourceHome = home configFile, err := findSourceConfig(home) if err != nil { return nil, err } return &OpenclawHandler{ opts: opts, sourceWorkspace: filepath.Join(opts.SourceHome, "workspace"), sourceConfigFile: configFile, }, nil } func (o *OpenclawHandler) GetSourceName() string { return "openclaw" } func (o *OpenclawHandler) GetSourceHome() (string, error) { return o.opts.SourceHome, nil } func (o *OpenclawHandler) GetSourceWorkspace() (string, error) { return o.sourceWorkspace, nil } func (o *OpenclawHandler) GetSourceConfigFile() (string, error) { return o.sourceConfigFile, nil } func (o *OpenclawHandler) GetMigrateableFiles() []string { return migrateableFiles } func (o *OpenclawHandler) GetMigrateableDirs() []string { return migrateableDirs } func (o *OpenclawHandler) ExecuteConfigMigration(srcConfigPath, dstConfigPath string) error { openclawCfg, err := LoadOpenClawConfig(srcConfigPath) if err != nil { return err } picoCfg, warnings, err := openclawCfg.ConvertToPicoClaw(o.opts.SourceHome) if err != nil { return err } for _, w := range warnings { fmt.Printf(" Warning: %s\n", w) } incoming := picoCfg.ToStandardConfig() if err := os.MkdirAll(filepath.Dir(dstConfigPath), 0o755); err != nil { return err } return config.SaveConfig(dstConfigPath, incoming) } func resolveSourceHome(override string) (string, error) { if override != "" { return internal.ExpandHome(override), nil } if envHome := os.Getenv(OpenclawHomeEnvVar); envHome != "" { return internal.ExpandHome(envHome), nil } home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("resolving home directory: %w", err) } return filepath.Join(home, ".openclaw"), nil } func findSourceConfig(sourceHome string) (string, error) { candidates := []string{ filepath.Join(sourceHome, "openclaw.json"), filepath.Join(sourceHome, "config.json"), } for _, p := range candidates { if _, err := os.Stat(p); err == nil { return p, nil } } return "", fmt.Errorf("no config file found in %s (tried openclaw.json, config.json)", sourceHome) } func rewriteWorkspacePath(path string) string { path = strings.Replace(path, ".openclaw", ".picoclaw", 1) return path } func mapProvider(provider string) string { if mapped, ok := providerMapping[strings.ToLower(provider)]; ok { return mapped } return strings.ToLower(provider) } ================================================ FILE: pkg/migrate/sources/openclaw/openclaw_handler_test.go ================================================ package openclaw import ( "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewOpenclawHandler(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") err := os.WriteFile(configPath, []byte("{}"), 0o644) require.NoError(t, err) handler, err := NewOpenclawHandler(Options{ SourceHome: tmpDir, }) require.NoError(t, err) require.NotNil(t, handler) } func TestNewOpenclawHandlerNoConfig(t *testing.T) { tmpDir := t.TempDir() _, err := NewOpenclawHandler(Options{ SourceHome: tmpDir, }) require.Error(t, err) } func TestOpenclawHandlerGetSourceName(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") err := os.WriteFile(configPath, []byte("{}"), 0o644) require.NoError(t, err) handler, err := NewOpenclawHandler(Options{ SourceHome: tmpDir, }) require.NoError(t, err) assert.Equal(t, "openclaw", handler.GetSourceName()) } func TestOpenclawHandlerGetSourceHome(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") err := os.WriteFile(configPath, []byte("{}"), 0o644) require.NoError(t, err) handler, err := NewOpenclawHandler(Options{ SourceHome: tmpDir, }) require.NoError(t, err) home, err := handler.GetSourceHome() require.NoError(t, err) assert.Equal(t, tmpDir, home) } func TestOpenclawHandlerGetSourceWorkspace(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") err := os.WriteFile(configPath, []byte("{}"), 0o644) require.NoError(t, err) handler, err := NewOpenclawHandler(Options{ SourceHome: tmpDir, }) require.NoError(t, err) workspace, err := handler.GetSourceWorkspace() require.NoError(t, err) assert.Equal(t, filepath.Join(tmpDir, "workspace"), workspace) } func TestOpenclawHandlerGetSourceConfigFile(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") err := os.WriteFile(configPath, []byte("{}"), 0o644) require.NoError(t, err) handler, err := NewOpenclawHandler(Options{ SourceHome: tmpDir, }) require.NoError(t, err) configFile, err := handler.GetSourceConfigFile() require.NoError(t, err) assert.Equal(t, configPath, configFile) } func TestOpenclawHandlerGetSourceConfigFileWithConfigJson(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "config.json") err := os.WriteFile(configPath, []byte("{}"), 0o644) require.NoError(t, err) handler, err := NewOpenclawHandler(Options{ SourceHome: tmpDir, }) require.NoError(t, err) configFile, err := handler.GetSourceConfigFile() require.NoError(t, err) assert.Equal(t, configPath, configFile) } func TestOpenclawHandlerGetMigrateableFiles(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") err := os.WriteFile(configPath, []byte("{}"), 0o644) require.NoError(t, err) handler, err := NewOpenclawHandler(Options{ SourceHome: tmpDir, }) require.NoError(t, err) files := handler.GetMigrateableFiles() assert.NotEmpty(t, files) assert.Contains(t, files, "AGENTS.md") assert.Contains(t, files, "SOUL.md") assert.Contains(t, files, "USER.md") } func TestOpenclawHandlerGetMigrateableDirs(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") err := os.WriteFile(configPath, []byte("{}"), 0o644) require.NoError(t, err) handler, err := NewOpenclawHandler(Options{ SourceHome: tmpDir, }) require.NoError(t, err) dirs := handler.GetMigrateableDirs() assert.NotEmpty(t, dirs) assert.Contains(t, dirs, "memory") assert.Contains(t, dirs, "skills") } func TestResolveSourceHome(t *testing.T) { result, err := resolveSourceHome("/custom/path") require.NoError(t, err) assert.Equal(t, "/custom/path", result) } func TestResolveSourceHomeWithEnvVar(t *testing.T) { t.Setenv("OPENCLAW_HOME", "/env/path") result, err := resolveSourceHome("") require.NoError(t, err) assert.Equal(t, "/env/path", result) } func TestResolveSourceHomeWithTilde(t *testing.T) { home, err := os.UserHomeDir() require.NoError(t, err) result, err := resolveSourceHome("~/openclaw") require.NoError(t, err) assert.Equal(t, filepath.Join(home, "openclaw"), result) } func TestFindSourceConfig(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") err := os.WriteFile(configPath, []byte("{}"), 0o644) require.NoError(t, err) result, err := findSourceConfig(tmpDir) require.NoError(t, err) assert.Equal(t, configPath, result) } func TestFindSourceConfigWithConfigJson(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "config.json") err := os.WriteFile(configPath, []byte("{}"), 0o644) require.NoError(t, err) result, err := findSourceConfig(tmpDir) require.NoError(t, err) assert.Equal(t, configPath, result) } func TestFindSourceConfigNotFound(t *testing.T) { tmpDir := t.TempDir() _, err := findSourceConfig(tmpDir) require.Error(t, err) assert.Contains(t, err.Error(), "no config file found") } func TestMapProvider(t *testing.T) { tests := []struct { input string expected string }{ {"anthropic", "anthropic"}, {"claude", "anthropic"}, {"openai", "openai"}, {"gpt", "openai"}, {"groq", "groq"}, {"ollama", "ollama"}, {"openrouter", "openrouter"}, {"deepseek", "deepseek"}, {"together", "together"}, {"mistral", "mistral"}, {"fireworks", "fireworks"}, {"google", "google"}, {"gemini", "google"}, {"xai", "xai"}, {"grok", "xai"}, {"cerebras", "cerebras"}, {"sambanova", "sambanova"}, {"unknown", "unknown"}, {"", ""}, } for _, tt := range tests { result := mapProvider(tt.input) assert.Equal(t, tt.expected, result, "mapProvider(%q)", tt.input) } } func TestRewriteWorkspacePath(t *testing.T) { tests := []struct { input string expected string }{ {"~/.openclaw/workspace", "~/.picoclaw/workspace"}, {"/home/user/.openclaw/workspace", "/home/user/.picoclaw/workspace"}, {"/path/without/openclaw/change", "/path/without/openclaw/change"}, {"", ""}, } for _, tt := range tests { result := rewriteWorkspacePath(tt.input) assert.Equal(t, tt.expected, result, "rewriteWorkspacePath(%q)", tt.input) } } ================================================ FILE: pkg/providers/anthropic/provider.go ================================================ package anthropicprovider import ( "context" "encoding/json" "fmt" "log" "strings" "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/option" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) type ( ToolCall = protocoltypes.ToolCall FunctionCall = protocoltypes.FunctionCall LLMResponse = protocoltypes.LLMResponse UsageInfo = protocoltypes.UsageInfo Message = protocoltypes.Message ToolDefinition = protocoltypes.ToolDefinition ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition ) const ( defaultBaseURL = "https://api.anthropic.com" anthropicBetaHeader = "oauth-2025-04-20" ) type Provider struct { client *anthropic.Client tokenSource func() (string, error) baseURL string } // SupportsThinking implements providers.ThinkingCapable. func (p *Provider) SupportsThinking() bool { return true } func NewProvider(token string) *Provider { return NewProviderWithBaseURL(token, "") } func NewProviderWithBaseURL(token, apiBase string) *Provider { baseURL := normalizeBaseURL(apiBase) client := anthropic.NewClient( option.WithAuthToken(token), option.WithBaseURL(baseURL), ) return &Provider{ client: &client, baseURL: baseURL, } } func NewProviderWithClient(client *anthropic.Client) *Provider { return &Provider{ client: client, baseURL: defaultBaseURL, } } func NewProviderWithTokenSource(token string, tokenSource func() (string, error)) *Provider { return NewProviderWithTokenSourceAndBaseURL(token, tokenSource, "") } func NewProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (string, error), apiBase string) *Provider { p := NewProviderWithBaseURL(token, apiBase) p.tokenSource = tokenSource return p } func (p *Provider) Chat( ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, ) (*LLMResponse, error) { var opts []option.RequestOption if p.tokenSource != nil { tok, err := p.tokenSource() if err != nil { return nil, fmt.Errorf("refreshing token: %w", err) } opts = append(opts, option.WithAuthToken(tok), option.WithHeader("anthropic-beta", anthropicBetaHeader), ) } params, err := buildParams(messages, tools, model, options) if err != nil { return nil, err } // OAuth/setup-tokens require streaming; API keys use non-streaming. if p.tokenSource != nil { return p.chatStreaming(ctx, params, opts) } resp, err := p.client.Messages.New(ctx, params, opts...) if err != nil { return nil, fmt.Errorf("claude API call: %w", err) } return parseResponse(resp), nil } func (p *Provider) chatStreaming( ctx context.Context, params anthropic.MessageNewParams, opts []option.RequestOption, ) (*LLMResponse, error) { stream := p.client.Messages.NewStreaming(ctx, params, opts...) defer stream.Close() var msg anthropic.Message for stream.Next() { event := stream.Current() if err := msg.Accumulate(event); err != nil { return nil, fmt.Errorf("claude streaming accumulate: %w", err) } } if err := stream.Err(); err != nil { return nil, fmt.Errorf("claude API call: %w", err) } return parseResponse(&msg), nil } func (p *Provider) GetDefaultModel() string { return "claude-sonnet-4.6" } func (p *Provider) BaseURL() string { return p.baseURL } func buildParams( messages []Message, tools []ToolDefinition, model string, options map[string]any, ) (anthropic.MessageNewParams, error) { var system []anthropic.TextBlockParam var anthropicMessages []anthropic.MessageParam for _, msg := range messages { switch msg.Role { case "system": // Prefer structured SystemParts for per-block cache_control. // This enables LLM-side KV cache reuse: the static block's prefix // hash stays stable across requests while dynamic parts change freely. if len(msg.SystemParts) > 0 { for _, part := range msg.SystemParts { block := anthropic.TextBlockParam{Text: part.Text} if part.CacheControl != nil && part.CacheControl.Type == "ephemeral" { block.CacheControl = anthropic.NewCacheControlEphemeralParam() } system = append(system, block) } } else { system = append(system, anthropic.TextBlockParam{Text: msg.Content}) } case "user": if msg.ToolCallID != "" { anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)), ) } else { anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content)), ) } case "assistant": if len(msg.ToolCalls) > 0 { var blocks []anthropic.ContentBlockParamUnion if msg.Content != "" { blocks = append(blocks, anthropic.NewTextBlock(msg.Content)) } for _, tc := range msg.ToolCalls { // Skip tool calls with empty names to avoid API errors if tc.Name == "" { continue } args := tc.Arguments if args == nil && tc.Function != nil && tc.Function.Arguments != "" { if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { args = map[string]any{} } } if args == nil { args = map[string]any{} } blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, args, tc.Name)) } anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...)) } else { anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(anthropic.NewTextBlock(msg.Content)), ) } case "tool": anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)), ) } } maxTokens := int64(4096) if mt, ok := options["max_tokens"].(int); ok { maxTokens = int64(mt) } // Normalize model ID: Anthropic API uses hyphens (claude-sonnet-4-6), // but config may use dots (claude-sonnet-4.6). apiModel := strings.ReplaceAll(model, ".", "-") params := anthropic.MessageNewParams{ Model: anthropic.Model(apiModel), Messages: anthropicMessages, MaxTokens: maxTokens, } if len(system) > 0 { params.System = system } if temp, ok := options["temperature"].(float64); ok { params.Temperature = anthropic.Float(temp) } if len(tools) > 0 { params.Tools = translateTools(tools) } // Extended Thinking / Adaptive Thinking // The thinking_level value directly determines the API parameter format: // "adaptive" → {thinking: {type: "adaptive"}} + output_config.effort // "low/medium/high/xhigh" → {thinking: {type: "enabled", budget_tokens: N}} if level, ok := options["thinking_level"].(string); ok && level != "" && level != "off" { applyThinkingConfig(¶ms, level) } return params, nil } // applyThinkingConfig sets thinking parameters based on the level value. // "adaptive" uses the adaptive thinking API (Claude 4.6+). // All other levels use budget_tokens which is universally supported. // // Anthropic API constraint: temperature must not be set when thinking is enabled. // budget_tokens must be strictly less than max_tokens. func applyThinkingConfig(params *anthropic.MessageNewParams, level string) { // Anthropic API rejects requests with temperature set alongside thinking. // Reset to zero value (omitted from JSON serialization). if params.Temperature.Valid() { log.Printf("anthropic: temperature cleared because thinking is enabled (level=%s)", level) } params.Temperature = anthropic.MessageNewParams{}.Temperature if level == "adaptive" { adaptive := anthropic.NewThinkingConfigAdaptiveParam() params.Thinking = anthropic.ThinkingConfigParamUnion{OfAdaptive: &adaptive} params.OutputConfig = anthropic.OutputConfigParam{ Effort: anthropic.OutputConfigEffortHigh, } return } budget := int64(levelToBudget(level)) if budget <= 0 { return } // budget_tokens must be < max_tokens; clamp to respect user's max_tokens setting. if budget >= params.MaxTokens { log.Printf("anthropic: budget_tokens (%d) clamped to %d (max_tokens-1)", budget, params.MaxTokens-1) budget = params.MaxTokens - 1 } else if budget > params.MaxTokens*80/100 { log.Printf("anthropic: thinking budget (%d) exceeds 80%% of max_tokens (%d), output may be truncated", budget, params.MaxTokens) } params.Thinking = anthropic.ThinkingConfigParamOfEnabled(budget) } // levelToBudget maps a thinking level to budget_tokens. // Values are based on Anthropic's recommendations and community best practices: // // low = 4,096 — simple reasoning, quick debugging (Claude Code "think") // medium = 16,384 — Anthropic recommended sweet spot for most tasks // high = 32,000 — complex architecture, deep analysis (diminishing returns above this) // xhigh = 64,000 — extreme reasoning, research problems, benchmarks // // Note: For Claude 4.6+, prefer adaptive thinking over manual budget_tokens. func levelToBudget(level string) int { switch level { case "low": return 4096 case "medium": return 16384 case "high": return 32000 case "xhigh": return 64000 default: return 0 } } func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam { result := make([]anthropic.ToolUnionParam, 0, len(tools)) for _, t := range tools { tool := anthropic.ToolParam{ Name: t.Function.Name, InputSchema: anthropic.ToolInputSchemaParam{ Properties: t.Function.Parameters["properties"], }, } if desc := t.Function.Description; desc != "" { tool.Description = anthropic.String(desc) } if req, ok := t.Function.Parameters["required"].([]any); ok { required := make([]string, 0, len(req)) for _, r := range req { if s, ok := r.(string); ok { required = append(required, s) } } tool.InputSchema.Required = required } result = append(result, anthropic.ToolUnionParam{OfTool: &tool}) } return result } func parseResponse(resp *anthropic.Message) *LLMResponse { var content strings.Builder var reasoning strings.Builder var toolCalls []ToolCall for _, block := range resp.Content { switch block.Type { case "thinking": tb := block.AsThinking() reasoning.WriteString(tb.Thinking) case "text": tb := block.AsText() content.WriteString(tb.Text) case "tool_use": tu := block.AsToolUse() var args map[string]any if err := json.Unmarshal(tu.Input, &args); err != nil { log.Printf("anthropic: failed to decode tool call input for %q: %v", tu.Name, err) args = map[string]any{"raw": string(tu.Input)} } toolCalls = append(toolCalls, ToolCall{ ID: tu.ID, Name: tu.Name, Arguments: args, }) } } finishReason := "stop" switch resp.StopReason { case anthropic.StopReasonToolUse: finishReason = "tool_calls" case anthropic.StopReasonMaxTokens: finishReason = "length" case anthropic.StopReasonEndTurn: finishReason = "stop" } return &LLMResponse{ Content: content.String(), Reasoning: reasoning.String(), ToolCalls: toolCalls, FinishReason: finishReason, Usage: &UsageInfo{ PromptTokens: int(resp.Usage.InputTokens), CompletionTokens: int(resp.Usage.OutputTokens), TotalTokens: int(resp.Usage.InputTokens + resp.Usage.OutputTokens), }, } } func normalizeBaseURL(apiBase string) string { base := strings.TrimSpace(apiBase) if base == "" { return defaultBaseURL } base = strings.TrimRight(base, "/") if before, ok := strings.CutSuffix(base, "/v1"); ok { base = before } if base == "" { return defaultBaseURL } return base } ================================================ FILE: pkg/providers/anthropic/provider_test.go ================================================ package anthropicprovider import ( "encoding/json" "net/http" "net/http/httptest" "sync/atomic" "testing" "github.com/anthropics/anthropic-sdk-go" anthropicoption "github.com/anthropics/anthropic-sdk-go/option" ) func TestBuildParams_BasicMessage(t *testing.T) { messages := []Message{ {Role: "user", Content: "Hello"}, } params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{ "max_tokens": 1024, }) if err != nil { t.Fatalf("buildParams() error: %v", err) } if string(params.Model) != "claude-sonnet-4-6" { t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4-6") } if params.MaxTokens != 1024 { t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens) } if len(params.Messages) != 1 { t.Fatalf("len(Messages) = %d, want 1", len(params.Messages)) } } func TestBuildParams_SystemMessage(t *testing.T) { messages := []Message{ {Role: "system", Content: "You are helpful"}, {Role: "user", Content: "Hi"}, } params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{}) if err != nil { t.Fatalf("buildParams() error: %v", err) } if len(params.System) != 1 { t.Fatalf("len(System) = %d, want 1", len(params.System)) } if params.System[0].Text != "You are helpful" { t.Errorf("System[0].Text = %q, want %q", params.System[0].Text, "You are helpful") } if len(params.Messages) != 1 { t.Fatalf("len(Messages) = %d, want 1", len(params.Messages)) } } func TestBuildParams_ToolCallMessage(t *testing.T) { messages := []Message{ {Role: "user", Content: "What's the weather?"}, { Role: "assistant", Content: "", ToolCalls: []ToolCall{ { ID: "call_1", Name: "get_weather", Arguments: map[string]any{"city": "SF"}, }, }, }, {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"}, } params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{}) if err != nil { t.Fatalf("buildParams() error: %v", err) } if len(params.Messages) != 3 { t.Fatalf("len(Messages) = %d, want 3", len(params.Messages)) } } func TestBuildParams_WithTools(t *testing.T) { tools := []ToolDefinition{ { Type: "function", Function: ToolFunctionDefinition{ Name: "get_weather", Description: "Get weather for a city", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "city": map[string]any{"type": "string"}, }, "required": []any{"city"}, }, }, }, } params, err := buildParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4.6", map[string]any{}) if err != nil { t.Fatalf("buildParams() error: %v", err) } if len(params.Tools) != 1 { t.Fatalf("len(Tools) = %d, want 1", len(params.Tools)) } } func TestParseResponse_TextOnly(t *testing.T) { resp := &anthropic.Message{ Content: []anthropic.ContentBlockUnion{}, Usage: anthropic.Usage{ InputTokens: 10, OutputTokens: 20, }, } result := parseResponse(resp) if result.Usage.PromptTokens != 10 { t.Errorf("PromptTokens = %d, want 10", result.Usage.PromptTokens) } if result.Usage.CompletionTokens != 20 { t.Errorf("CompletionTokens = %d, want 20", result.Usage.CompletionTokens) } if result.FinishReason != "stop" { t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop") } } func TestParseResponse_StopReasons(t *testing.T) { tests := []struct { stopReason anthropic.StopReason want string }{ {anthropic.StopReasonEndTurn, "stop"}, {anthropic.StopReasonMaxTokens, "length"}, {anthropic.StopReasonToolUse, "tool_calls"}, } for _, tt := range tests { resp := &anthropic.Message{ StopReason: tt.stopReason, } result := parseResponse(resp) if result.FinishReason != tt.want { t.Errorf("StopReason %q: FinishReason = %q, want %q", tt.stopReason, result.FinishReason, tt.want) } } } func TestProvider_ChatRoundTrip(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/messages" { http.Error(w, "not found", http.StatusNotFound) return } if r.Header.Get("Authorization") != "Bearer test-token" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var reqBody map[string]any json.NewDecoder(r.Body).Decode(&reqBody) resp := map[string]any{ "id": "msg_test", "type": "message", "role": "assistant", "model": reqBody["model"], "stop_reason": "end_turn", "content": []map[string]any{ {"type": "text", "text": "Hello! How can I help you?"}, }, "usage": map[string]any{ "input_tokens": 15, "output_tokens": 8, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) })) defer server.Close() provider := NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token")) messages := []Message{{Role: "user", Content: "Hello"}} resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]any{"max_tokens": 1024}) if err != nil { t.Fatalf("Chat() error: %v", err) } if resp.Content != "Hello! How can I help you?" { t.Errorf("Content = %q, want %q", resp.Content, "Hello! How can I help you?") } if resp.FinishReason != "stop" { t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") } if resp.Usage.PromptTokens != 15 { t.Errorf("PromptTokens = %d, want 15", resp.Usage.PromptTokens) } } func TestProvider_GetDefaultModel(t *testing.T) { p := NewProvider("test-token") if got := p.GetDefaultModel(); got != "claude-sonnet-4.6" { t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-sonnet-4.6") } } func TestProvider_NewProviderWithBaseURL_NormalizesV1Suffix(t *testing.T) { p := NewProviderWithBaseURL("token", "https://api.anthropic.com/v1/") if got := p.BaseURL(); got != "https://api.anthropic.com" { t.Fatalf("BaseURL() = %q, want %q", got, "https://api.anthropic.com") } } func TestProvider_ChatUsesTokenSource(t *testing.T) { var requests int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/messages" { http.Error(w, "not found", http.StatusNotFound) return } atomic.AddInt32(&requests, 1) if got := r.Header.Get("Authorization"); got != "Bearer refreshed-token" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var reqBody map[string]any json.NewDecoder(r.Body).Decode(&reqBody) resp := map[string]any{ "id": "msg_test", "type": "message", "role": "assistant", "model": reqBody["model"], "stop_reason": "end_turn", "content": []map[string]any{ {"type": "text", "text": "ok"}, }, "usage": map[string]any{ "input_tokens": 1, "output_tokens": 1, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) })) defer server.Close() p := NewProviderWithTokenSourceAndBaseURL("stale-token", func() (string, error) { return "refreshed-token", nil }, server.URL) _, err := p.Chat( t.Context(), []Message{{Role: "user", Content: "hello"}}, nil, "claude-sonnet-4.6", map[string]any{}, ) if err != nil { t.Fatalf("Chat() error: %v", err) } if got := atomic.LoadInt32(&requests); got != 1 { t.Fatalf("requests = %d, want 1", got) } } func TestProvider_ChatStreamingRoundTrip(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/messages" { http.Error(w, "not found", http.StatusNotFound) return } if got := r.Header.Get("Authorization"); got != "Bearer refreshed-token" { t.Errorf("Authorization = %q, want %q", got, "Bearer refreshed-token") } if got := r.Header.Get("Anthropic-Beta"); got != anthropicBetaHeader { t.Errorf("Anthropic-Beta = %q, want %q", got, anthropicBetaHeader) } w.Header().Set("Content-Type", "text/event-stream") flusher, _ := w.(http.Flusher) events := []string{ "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-sonnet-4-6\",\"stop_reason\":null,\"usage\":{\"input_tokens\":12,\"output_tokens\":0}}}\n\n", "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n", "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\n", "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" world\"}}\n\n", "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n", "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}\n\n", "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", } for _, e := range events { w.Write([]byte(e)) if flusher != nil { flusher.Flush() } } })) defer server.Close() p := NewProviderWithTokenSourceAndBaseURL("stale-token", func() (string, error) { return "refreshed-token", nil }, server.URL) resp, err := p.Chat( t.Context(), []Message{{Role: "user", Content: "Hello"}}, nil, "claude-sonnet-4.6", map[string]any{}, ) if err != nil { t.Fatalf("Chat() error: %v", err) } if resp.Content != "Hello world" { t.Errorf("Content = %q, want %q", resp.Content, "Hello world") } if resp.FinishReason != "stop" { t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") } if resp.Usage.CompletionTokens != 5 { t.Errorf("CompletionTokens = %d, want 5", resp.Usage.CompletionTokens) } } func createAnthropicTestClient(baseURL, token string) *anthropic.Client { c := anthropic.NewClient( anthropicoption.WithAuthToken(token), anthropicoption.WithBaseURL(baseURL), ) return &c } ================================================ FILE: pkg/providers/anthropic/thinking_test.go ================================================ package anthropicprovider import ( "encoding/json" "testing" "github.com/anthropics/anthropic-sdk-go" ) func TestApplyThinkingConfig_Adaptive(t *testing.T) { params := anthropic.MessageNewParams{ MaxTokens: 16000, Temperature: anthropic.Float(0.7), } applyThinkingConfig(¶ms, "adaptive") if params.Thinking.OfAdaptive == nil { t.Fatal("expected adaptive thinking") } if params.Thinking.OfEnabled != nil { t.Error("should not set enabled thinking in adaptive mode") } if params.OutputConfig.Effort != anthropic.OutputConfigEffortHigh { t.Errorf("effort = %q, want %q", params.OutputConfig.Effort, anthropic.OutputConfigEffortHigh) } if params.Temperature.Valid() { t.Error("temperature should be cleared when thinking is enabled") } } func TestApplyThinkingConfig_BudgetLevels(t *testing.T) { tests := []struct { level string wantBudget int64 }{ {"low", 4096}, {"medium", 16384}, {"high", 32000}, {"xhigh", 64000}, } for _, tt := range tests { t.Run(tt.level, func(t *testing.T) { params := anthropic.MessageNewParams{ MaxTokens: 200000, Temperature: anthropic.Float(0.5), } applyThinkingConfig(¶ms, tt.level) if params.Thinking.OfEnabled == nil { t.Fatal("expected enabled thinking") } if params.Thinking.OfAdaptive != nil { t.Error("should not set adaptive thinking") } if params.Thinking.OfEnabled.BudgetTokens != tt.wantBudget { t.Errorf("budget_tokens = %d, want %d", params.Thinking.OfEnabled.BudgetTokens, tt.wantBudget) } if params.OutputConfig.Effort != "" { t.Errorf("effort = %q, want empty", params.OutputConfig.Effort) } if params.Temperature.Valid() { t.Error("temperature should be cleared when thinking is enabled") } }) } } func TestApplyThinkingConfig_BudgetClamp(t *testing.T) { // budget_tokens must be < max_tokens; clamp budget down to respect user's max_tokens. params := anthropic.MessageNewParams{MaxTokens: 4096} applyThinkingConfig(¶ms, "high") // budget=32000 > maxTokens=4096 if params.Thinking.OfEnabled == nil { t.Fatal("expected enabled thinking") } if params.Thinking.OfEnabled.BudgetTokens != 4095 { t.Errorf("budget_tokens = %d, want 4095 (maxTokens-1)", params.Thinking.OfEnabled.BudgetTokens) } if params.MaxTokens != 4096 { t.Errorf("max_tokens should not be modified, got %d", params.MaxTokens) } } func TestApplyThinkingConfig_UnknownLevel(t *testing.T) { params := anthropic.MessageNewParams{MaxTokens: 16000} applyThinkingConfig(¶ms, "unknown") if params.Thinking.OfEnabled != nil { t.Error("should not set enabled thinking for unknown level") } if params.Thinking.OfAdaptive != nil { t.Error("should not set adaptive thinking for unknown level") } } func TestLevelToBudget(t *testing.T) { tests := []struct { name string level string want int }{ {"low", "low", 4096}, {"medium", "medium", 16384}, {"high", "high", 32000}, {"xhigh", "xhigh", 64000}, {"off", "off", 0}, {"empty", "", 0}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := levelToBudget(tt.level); got != tt.want { t.Errorf("levelToBudget(%q) = %d, want %d", tt.level, got, tt.want) } }) } } func TestBuildParams_ThinkingClearsTemperature(t *testing.T) { msgs := []Message{{Role: "user", Content: "hello"}} opts := map[string]any{ "max_tokens": 200000, "temperature": 0.8, "thinking_level": "medium", } params, err := buildParams(msgs, nil, "claude-sonnet-4-6", opts) if err != nil { t.Fatal(err) } if params.Temperature.Valid() { t.Error("temperature should be cleared when thinking_level is set") } if params.Thinking.OfEnabled == nil { t.Fatal("expected enabled thinking") } if params.Thinking.OfEnabled.BudgetTokens != 16384 { t.Errorf("budget_tokens = %d, want 16384", params.Thinking.OfEnabled.BudgetTokens) } } // unmarshalBlocks constructs []ContentBlockUnion via JSON round-trip so that // the internal JSON.raw field is populated (required by AsText/AsThinking). func unmarshalBlocks(t *testing.T, jsonStr string) []anthropic.ContentBlockUnion { t.Helper() var blocks []anthropic.ContentBlockUnion if err := json.Unmarshal([]byte(jsonStr), &blocks); err != nil { t.Fatalf("unmarshalBlocks: %v", err) } return blocks } func TestParseResponse_ThinkingBlock(t *testing.T) { resp := &anthropic.Message{ Content: unmarshalBlocks(t, `[ {"type":"thinking","thinking":"Let me reason step by step...","signature":"sig"}, {"type":"text","text":"The answer is 42."} ]`), StopReason: anthropic.StopReasonEndTurn, } result := parseResponse(resp) if result.Reasoning != "Let me reason step by step..." { t.Errorf("Reasoning = %q, want thinking content", result.Reasoning) } if result.Content != "The answer is 42." { t.Errorf("Content = %q, want text content", result.Content) } if result.FinishReason != "stop" { t.Errorf("FinishReason = %q, want stop", result.FinishReason) } } func TestParseResponse_NoThinkingBlock(t *testing.T) { resp := &anthropic.Message{ Content: unmarshalBlocks(t, `[ {"type":"text","text":"Just a normal response."} ]`), StopReason: anthropic.StopReasonEndTurn, } result := parseResponse(resp) if result.Reasoning != "" { t.Errorf("Reasoning = %q, want empty", result.Reasoning) } if result.Content != "Just a normal response." { t.Errorf("Content = %q, want text content", result.Content) } } func TestBuildParams_NoThinkingKeepsTemperature(t *testing.T) { msgs := []Message{{Role: "user", Content: "hello"}} opts := map[string]any{ "temperature": 0.8, } params, err := buildParams(msgs, nil, "claude-sonnet-4-6", opts) if err != nil { t.Fatal(err) } if !params.Temperature.Valid() { t.Error("temperature should be preserved when thinking is not set") } if params.Temperature.Value != 0.8 { t.Errorf("temperature = %f, want 0.8", params.Temperature.Value) } } ================================================ FILE: pkg/providers/anthropic_messages/provider.go ================================================ // PicoClaw - Ultra-lightweight personal AI agent // License: MIT // // Copyright (c) 2026 PicoClaw contributors package anthropicmessages import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) type ( ToolCall = protocoltypes.ToolCall FunctionCall = protocoltypes.FunctionCall LLMResponse = protocoltypes.LLMResponse UsageInfo = protocoltypes.UsageInfo Message = protocoltypes.Message ToolDefinition = protocoltypes.ToolDefinition ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition ) const ( defaultAPIVersion = "2023-06-01" defaultBaseURL = "https://api.anthropic.com/v1" defaultRequestTimeout = 120 * time.Second ) // Provider implements Anthropic Messages API via HTTP (without SDK). // It supports custom endpoints that use Anthropic's native message format. type Provider struct { apiKey string apiBase string httpClient *http.Client } // NewProvider creates a new Anthropic Messages API provider. func NewProvider(apiKey, apiBase string) *Provider { return NewProviderWithTimeout(apiKey, apiBase, 0) } // NewProviderWithTimeout creates a provider with custom request timeout. func NewProviderWithTimeout(apiKey, apiBase string, timeoutSeconds int) *Provider { baseURL := normalizeBaseURL(apiBase) timeout := defaultRequestTimeout if timeoutSeconds > 0 { timeout = time.Duration(timeoutSeconds) * time.Second } return &Provider{ apiKey: apiKey, apiBase: baseURL, httpClient: &http.Client{ Timeout: timeout, }, } } // Chat sends messages to the Anthropic Messages API and returns the response. func (p *Provider) Chat( ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, ) (*LLMResponse, error) { if p.apiKey == "" { return nil, fmt.Errorf("API key not configured") } // Build request body requestBody, err := buildRequestBody(messages, tools, model, options) if err != nil { return nil, fmt.Errorf("building request body: %w", err) } // Serialize to JSON jsonBody, err := json.Marshal(requestBody) if err != nil { return nil, fmt.Errorf("serializing request body: %w", err) } // Build request URL endpointURL, err := url.JoinPath(p.apiBase, "messages") if err != nil { return nil, fmt.Errorf("building endpoint URL: %w", err) } // Create HTTP request req, err := http.NewRequestWithContext(ctx, "POST", endpointURL, bytes.NewReader(jsonBody)) if err != nil { return nil, fmt.Errorf("creating HTTP request: %w", err) } // Set headers req.Header.Set("Content-Type", "application/json") req.Header.Set("X-API-Key", p.apiKey) //nolint:canonicalheader // Anthropic API requires exact header name req.Header.Set("Anthropic-Version", defaultAPIVersion) // Execute request resp, err := p.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("executing HTTP request: %w", err) } defer resp.Body.Close() // Read response body body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading response body: %w", err) } // Check for HTTP errors with detailed messages switch resp.StatusCode { case http.StatusUnauthorized: return nil, fmt.Errorf("authentication failed (401): check your API key") case http.StatusTooManyRequests: return nil, fmt.Errorf("rate limited (429): %s", string(body)) case http.StatusBadRequest: return nil, fmt.Errorf("bad request (400): %s", string(body)) case http.StatusNotFound: return nil, fmt.Errorf("endpoint not found (404): %s", string(body)) case http.StatusInternalServerError: return nil, fmt.Errorf("internal server error (500): %s", string(body)) case http.StatusServiceUnavailable: return nil, fmt.Errorf("service unavailable (503): %s", string(body)) default: if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) } } // Parse response return parseResponseBody(body) } // GetDefaultModel returns the default model for this provider. func (p *Provider) GetDefaultModel() string { return "claude-sonnet-4.6" } // buildRequestBody converts internal message format to Anthropic Messages API format. func buildRequestBody( messages []Message, tools []ToolDefinition, model string, options map[string]any, ) (map[string]any, error) { // max_tokens is required and guaranteed by agent loop maxTokens, ok := asInt(options["max_tokens"]) if !ok { return nil, fmt.Errorf("max_tokens is required in options") } result := map[string]any{ "model": model, "max_tokens": int64(maxTokens), "messages": []any{}, } // Set temperature from options if temp, ok := asFloat(options["temperature"]); ok { result["temperature"] = temp } // Process messages var systemPrompt string var apiMessages []any for _, msg := range messages { switch msg.Role { case "system": // Accumulate system messages if systemPrompt != "" { systemPrompt += "\n\n" + msg.Content } else { systemPrompt = msg.Content } case "user": if msg.ToolCallID != "" { // Tool result message content := []map[string]any{ { "type": "tool_result", "tool_use_id": msg.ToolCallID, "content": msg.Content, }, } apiMessages = append(apiMessages, map[string]any{ "role": "user", "content": content, }) } else { // Regular user message apiMessages = append(apiMessages, map[string]any{ "role": "user", "content": msg.Content, }) } case "assistant": content := []any{} // Add text content if present if msg.Content != "" { content = append(content, map[string]any{ "type": "text", "text": msg.Content, }) } // Add tool_use blocks for _, tc := range msg.ToolCalls { if strings.TrimSpace(tc.Name) == "" { continue } // Handle nil Arguments (GLM-4 may return null input) input := tc.Arguments if input == nil { input = map[string]any{} } toolUse := map[string]any{ "type": "tool_use", "id": tc.ID, "name": tc.Name, "input": input, } content = append(content, toolUse) } apiMessages = append(apiMessages, map[string]any{ "role": "assistant", "content": content, }) case "tool": // Tool result (alternative format) content := []map[string]any{ { "type": "tool_result", "tool_use_id": msg.ToolCallID, "content": msg.Content, }, } apiMessages = append(apiMessages, map[string]any{ "role": "user", "content": content, }) } } result["messages"] = apiMessages // Set system prompt if present if systemPrompt != "" { result["system"] = systemPrompt } // Add tools if present if len(tools) > 0 { result["tools"] = buildTools(tools) } return result, nil } // buildTools converts tool definitions to Anthropic format. func buildTools(tools []ToolDefinition) []any { result := make([]any, len(tools)) for i, tool := range tools { toolDef := map[string]any{ "name": tool.Function.Name, "description": tool.Function.Description, "input_schema": tool.Function.Parameters, } result[i] = toolDef } return result } // parseResponseBody parses Anthropic Messages API response. func parseResponseBody(body []byte) (*LLMResponse, error) { var resp anthropicMessageResponse if err := json.Unmarshal(body, &resp); err != nil { return nil, fmt.Errorf("parsing JSON response: %w", err) } // Extract content and tool calls var content strings.Builder toolCalls := make([]ToolCall, 0) // Initialize as empty slice (not nil) for consistent JSON serialization for _, block := range resp.Content { switch block.Type { case "text": content.WriteString(block.Text) case "tool_use": argsJSON, _ := json.Marshal(block.Input) toolCalls = append(toolCalls, ToolCall{ ID: block.ID, Name: block.Name, Arguments: block.Input, Function: &FunctionCall{ Name: block.Name, Arguments: string(argsJSON), }, }) } } // Map stop_reason finishReason := "stop" switch resp.StopReason { case "tool_use": finishReason = "tool_calls" case "max_tokens": finishReason = "length" case "end_turn": finishReason = "stop" case "stop_sequence": finishReason = "stop" } return &LLMResponse{ Content: content.String(), ToolCalls: toolCalls, FinishReason: finishReason, Usage: &UsageInfo{ PromptTokens: int(resp.Usage.InputTokens), CompletionTokens: int(resp.Usage.OutputTokens), TotalTokens: int(resp.Usage.InputTokens + resp.Usage.OutputTokens), }, }, nil } // normalizeBaseURL ensures the base URL is properly formatted. // It removes /v1 suffix if present (to avoid duplication) and always appends /v1. // This handles edge cases like "https://api.example.com/v1/proxy" correctly. func normalizeBaseURL(apiBase string) string { base := strings.TrimSpace(apiBase) if base == "" { return defaultBaseURL } // Remove trailing slashes base = strings.TrimRight(base, "/") // Remove /v1 suffix if present (will be re-added) // This prevents duplication for URLs like "https://api.example.com/v1/proxy" if before, ok := strings.CutSuffix(base, "/v1"); ok { base = before } // Ensure we don't have an empty string after cutting if base == "" { return defaultBaseURL } // Add /v1 suffix (required by Anthropic Messages API) return base + "/v1" } // Helper functions for type conversion func asInt(v any) (int, bool) { switch val := v.(type) { case int: return val, true case float64: return int(val), true case int64: return int(val), true default: return 0, false } } func asFloat(v any) (float64, bool) { switch val := v.(type) { case float64: return val, true case int: return float64(val), true case int64: return float64(val), true default: return 0, false } } // Anthropic API response structures type anthropicMessageResponse struct { ID string `json:"id"` Type string `json:"type"` Role string `json:"role"` Content []contentBlock `json:"content"` StopReason string `json:"stop_reason"` Model string `json:"model"` Usage usageInfo `json:"usage"` } type contentBlock struct { Type string `json:"type"` Text string `json:"text,omitempty"` ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Input map[string]any `json:"input,omitempty"` } type usageInfo struct { InputTokens int64 `json:"input_tokens"` OutputTokens int64 `json:"output_tokens"` } ================================================ FILE: pkg/providers/anthropic_messages/provider_test.go ================================================ // PicoClaw - Ultra-lightweight personal AI agent // License: MIT // // Copyright (c) 2026 PicoClaw contributors package anthropicmessages import ( "context" "encoding/json" "reflect" "strings" "testing" ) func TestBuildRequestBody(t *testing.T) { tests := []struct { name string messages []Message tools []ToolDefinition model string options map[string]any want map[string]any wantErr bool }{ { name: "basic user message", messages: []Message{ {Role: "user", Content: "Hello, world!"}, }, model: "test-model", options: map[string]any{ "max_tokens": 8192, }, want: map[string]any{ "model": "test-model", "max_tokens": int64(8192), "messages": []any{ map[string]any{ "role": "user", "content": "Hello, world!", }, }, }, }, { name: "user and assistant messages", messages: []Message{ {Role: "user", Content: "What is 2+2?"}, {Role: "assistant", Content: "4"}, }, model: "test-model", options: map[string]any{ "max_tokens": 8192, }, want: map[string]any{ "model": "test-model", "max_tokens": int64(8192), "messages": []any{ map[string]any{ "role": "user", "content": "What is 2+2?", }, map[string]any{ "role": "assistant", "content": []any{ map[string]any{ "type": "text", "text": "4", }, }, }, }, }, }, { name: "with system message", messages: []Message{ {Role: "system", Content: "You are a helpful assistant."}, {Role: "user", Content: "Hello"}, }, model: "test-model", options: map[string]any{ "max_tokens": 8192, }, want: map[string]any{ "model": "test-model", "max_tokens": int64(8192), "system": "You are a helpful assistant.", "messages": []any{ map[string]any{ "role": "user", "content": "Hello", }, }, }, }, { name: "with custom max_tokens and temperature", messages: []Message{ {Role: "user", Content: "Test"}, }, model: "test-model", options: map[string]any{ "max_tokens": 2048, "temperature": 0.5, }, want: map[string]any{ "model": "test-model", "max_tokens": int64(2048), "temperature": 0.5, "messages": []any{ map[string]any{ "role": "user", "content": "Test", }, }, }, }, { name: "missing max_tokens returns error", messages: []Message{ {Role: "user", Content: "Test"}, }, model: "test-model", options: map[string]any{}, want: nil, wantErr: true, }, { name: "with tools", messages: []Message{ {Role: "user", Content: "What's the weather?"}, }, tools: []ToolDefinition{ { Function: ToolFunctionDefinition{ Name: "get_weather", Description: "Get current weather", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "location": map[string]any{ "type": "string", "description": "City name", }, }, }, }, }, }, model: "test-model", options: map[string]any{ "max_tokens": 8192, }, want: map[string]any{ "model": "test-model", "max_tokens": int64(8192), "messages": []any{ map[string]any{ "role": "user", "content": "What's the weather?", }, }, "tools": []any{ map[string]any{ "name": "get_weather", "description": "Get current weather", "input_schema": map[string]any{ "type": "object", "properties": map[string]any{ "location": map[string]any{ "type": "string", "description": "City name", }, }, }, }, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := buildRequestBody(tt.messages, tt.tools, tt.model, tt.options) if (err != nil) != tt.wantErr { t.Errorf("buildRequestBody() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { gotJSON, _ := json.MarshalIndent(got, "", " ") wantJSON, _ := json.MarshalIndent(tt.want, "", " ") t.Errorf("buildRequestBody() mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON) } }) } } func TestParseResponseBody(t *testing.T) { tests := []struct { name string body []byte want *LLMResponse wantErr bool }{ { name: "basic text response", body: []byte(`{ "id": "msg-123", "type": "message", "role": "assistant", "content": [ {"type": "text", "text": "Hello, how can I help?"} ], "stop_reason": "end_turn", "model": "test-model", "usage": { "input_tokens": 10, "output_tokens": 5 } }`), want: &LLMResponse{ Content: "Hello, how can I help?", ToolCalls: []ToolCall{}, FinishReason: "stop", Usage: &UsageInfo{ PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15, }, Reasoning: "", ReasoningDetails: nil, }, wantErr: false, }, { name: "response with tool use", body: []byte(`{ "id": "msg-456", "type": "message", "role": "assistant", "content": [ {"type": "text", "text": "I'll check the weather for you."}, { "type": "tool_use", "id": "toolu-123", "name": "get_weather", "input": {"location": "Tokyo"} } ], "stop_reason": "tool_use", "model": "test-model", "usage": { "input_tokens": 20, "output_tokens": 15 } }`), want: &LLMResponse{ Content: "I'll check the weather for you.", ToolCalls: []ToolCall{ { ID: "toolu-123", Name: "get_weather", Arguments: map[string]any{ "location": "Tokyo", }, Function: &FunctionCall{ Name: "get_weather", Arguments: `{"location":"Tokyo"}`, }, }, }, FinishReason: "tool_calls", Usage: &UsageInfo{ PromptTokens: 20, CompletionTokens: 15, TotalTokens: 35, }, Reasoning: "", ReasoningDetails: nil, }, wantErr: false, }, { name: "invalid JSON", body: []byte(`invalid json`), want: nil, wantErr: true, }, { name: "max_tokens stop reason", body: []byte(`{ "id": "msg-789", "type": "message", "role": "assistant", "content": [ {"type": "text", "text": "Partial response"} ], "stop_reason": "max_tokens", "model": "test-model", "usage": { "input_tokens": 100, "output_tokens": 4096 } }`), want: &LLMResponse{ Content: "Partial response", ToolCalls: []ToolCall{}, FinishReason: "length", Usage: &UsageInfo{ PromptTokens: 100, CompletionTokens: 4096, TotalTokens: 4196, }, Reasoning: "", ReasoningDetails: nil, }, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := parseResponseBody(tt.body) if (err != nil) != tt.wantErr { t.Errorf("parseResponseBody() error = %v, wantErr %v", err, tt.wantErr) return } if err != nil { return } // Compare individual fields if got.Content != tt.want.Content { t.Errorf("Content = %q, want %q", got.Content, tt.want.Content) } if got.FinishReason != tt.want.FinishReason { t.Errorf("FinishReason = %q, want %q", got.FinishReason, tt.want.FinishReason) } if got.Usage == nil && tt.want.Usage != nil { t.Errorf("Usage = nil, want non-nil") } else if got.Usage != nil && tt.want.Usage == nil { t.Errorf("Usage = non-nil, want nil") } else if got.Usage != nil && tt.want.Usage != nil { if got.Usage.PromptTokens != tt.want.Usage.PromptTokens { t.Errorf("Usage.PromptTokens = %d, want %d", got.Usage.PromptTokens, tt.want.Usage.PromptTokens) } if got.Usage.CompletionTokens != tt.want.Usage.CompletionTokens { t.Errorf("Usage.CompletionTokens = %d, want %d", got.Usage.CompletionTokens, tt.want.Usage.CompletionTokens) } if got.Usage.TotalTokens != tt.want.Usage.TotalTokens { t.Errorf("Usage.TotalTokens = %d, want %d", got.Usage.TotalTokens, tt.want.Usage.TotalTokens) } } if len(got.ToolCalls) != len(tt.want.ToolCalls) { t.Errorf("ToolCalls length = %d, want %d", len(got.ToolCalls), len(tt.want.ToolCalls)) } else { for i := range got.ToolCalls { if got.ToolCalls[i].ID != tt.want.ToolCalls[i].ID { t.Errorf("ToolCalls[%d].ID = %q, want %q", i, got.ToolCalls[i].ID, tt.want.ToolCalls[i].ID) } if got.ToolCalls[i].Name != tt.want.ToolCalls[i].Name { t.Errorf("ToolCalls[%d].Name = %q, want %q", i, got.ToolCalls[i].Name, tt.want.ToolCalls[i].Name) } } } }) } } func TestNormalizeBaseURL(t *testing.T) { tests := []struct { name string apiBase string expected string }{ { name: "empty string defaults to official API", apiBase: "", expected: "https://api.anthropic.com/v1", }, { name: "URL without /v1 gets it appended", apiBase: "https://api.example.com/anthropic", expected: "https://api.example.com/anthropic/v1", }, { name: "URL with /v1 remains unchanged", apiBase: "https://api.example.com/v1", expected: "https://api.example.com/v1", }, { name: "URL with trailing slash gets cleaned", apiBase: "https://api.example.com/anthropic/", expected: "https://api.example.com/anthropic/v1", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := normalizeBaseURL(tt.apiBase) if got != tt.expected { t.Errorf("normalizeBaseURL(%q) = %q, want %q", tt.apiBase, got, tt.expected) } }) } } func TestNewProvider(t *testing.T) { provider := NewProvider("test-key", "https://api.example.com") if provider == nil { t.Fatal("NewProvider() returned nil") } if provider.apiKey != "test-key" { t.Errorf("provider.apiKey = %q, want %q", provider.apiKey, "test-key") } if provider.apiBase != "https://api.example.com/v1" { t.Errorf("provider.apiBase = %q, want %q", provider.apiBase, "https://api.example.com/v1") } } func TestGetDefaultModel(t *testing.T) { provider := NewProvider("test-key", "") got := provider.GetDefaultModel() expected := "claude-sonnet-4.6" if got != expected { t.Errorf("GetDefaultModel() = %q, want %q", got, expected) } } // TestBuildRequestBodyEdgeCases tests edge cases for buildRequestBody. func TestBuildRequestBodyEdgeCases(t *testing.T) { tests := []struct { name string messages []Message tools []ToolDefinition model string options map[string]any wantErr bool }{ { name: "empty message list", messages: []Message{}, model: "test-model", options: map[string]any{ "max_tokens": 8192, }, wantErr: false, }, { name: "very long system message", messages: []Message{ {Role: "system", Content: strings.Repeat("This is a very long system prompt. ", 1000)}, {Role: "user", Content: "Hello"}, }, model: "test-model", options: map[string]any{ "max_tokens": 8192, }, wantErr: false, }, { name: "multiple consecutive system messages", messages: []Message{ {Role: "system", Content: "First system message"}, {Role: "system", Content: "Second system message"}, {Role: "system", Content: "Third system message"}, {Role: "user", Content: "Hello"}, }, model: "test-model", options: map[string]any{ "max_tokens": 8192, }, wantErr: false, }, { name: "tool result without tool call", messages: []Message{ {Role: "user", Content: "Use a tool"}, {Role: "assistant", Content: "", ToolCalls: []ToolCall{ {ID: "tool-1", Name: "test_tool", Arguments: map[string]any{"arg": "value"}}, }}, {Role: "user", ToolCallID: "tool-1", Content: "Tool result"}, }, model: "test-model", options: map[string]any{ "max_tokens": 8192, }, wantErr: false, }, { name: "skip tool calls with empty names", messages: []Message{ {Role: "assistant", Content: "Calling tool", ToolCalls: []ToolCall{ {ID: "tool-empty", Name: "", Arguments: map[string]any{"ignored": true}}, {ID: "tool-valid", Name: "test_tool", Arguments: map[string]any{"arg": "value"}}, }}, }, model: "test-model", options: map[string]any{ "max_tokens": 8192, }, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := buildRequestBody(tt.messages, tt.tools, tt.model, tt.options) if (err != nil) != tt.wantErr { t.Errorf("buildRequestBody() error = %v, wantErr %v", err, tt.wantErr) return } if err != nil { return } // Verify basic structure if got == nil { t.Error("buildRequestBody() returned nil") return } if got["model"] != tt.model { t.Errorf("model = %v, want %v", got["model"], tt.model) } if tt.name == "skip tool calls with empty names" { messages, ok := got["messages"].([]any) if !ok || len(messages) != 1 { t.Fatalf("messages = %#v, want single assistant message", got["messages"]) } assistantMsg, ok := messages[0].(map[string]any) if !ok { t.Fatalf("assistant message = %#v, want map", messages[0]) } content, ok := assistantMsg["content"].([]any) if !ok { t.Fatalf("assistant content = %#v, want []any", assistantMsg["content"]) } if len(content) != 2 { t.Fatalf("assistant content length = %d, want 2", len(content)) } toolUse, ok := content[1].(map[string]any) if !ok { t.Fatalf("tool_use block = %#v, want map", content[1]) } if gotName := toolUse["name"]; gotName != "test_tool" { t.Fatalf("tool_use name = %v, want %q", gotName, "test_tool") } if gotID := toolUse["id"]; gotID != "tool-valid" { t.Fatalf("tool_use id = %v, want %q", gotID, "tool-valid") } } }) } } // TestParseResponseBodyEdgeCases tests edge cases for parseResponseBody. func TestParseResponseBodyEdgeCases(t *testing.T) { tests := []struct { name string body []byte wantErr bool check func(*testing.T, *LLMResponse) }{ { name: "empty content blocks", body: []byte(`{ "id": "msg-empty", "type": "message", "role": "assistant", "content": [], "stop_reason": "end_turn", "model": "test-model", "usage": {"input_tokens": 5, "output_tokens": 0} }`), wantErr: false, check: func(t *testing.T, resp *LLMResponse) { if resp.Content != "" { t.Errorf("Content = %q, want empty string", resp.Content) } if len(resp.ToolCalls) != 0 { t.Errorf("ToolCalls length = %d, want 0", len(resp.ToolCalls)) } }, }, { name: "multiple tool use blocks", body: []byte(`{ "id": "msg-multi", "type": "message", "role": "assistant", "content": [ {"type": "tool_use", "id": "tool-1", "name": "func1", "input": {"arg": "val1"}}, {"type": "tool_use", "id": "tool-2", "name": "func2", "input": {"arg": "val2"}} ], "stop_reason": "tool_use", "model": "test-model", "usage": {"input_tokens": 10, "output_tokens": 20} }`), wantErr: false, check: func(t *testing.T, resp *LLMResponse) { if len(resp.ToolCalls) != 2 { t.Errorf("ToolCalls length = %d, want 2", len(resp.ToolCalls)) } }, }, { name: "malformed JSON response", body: []byte(`{invalid json`), wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := parseResponseBody(tt.body) if (err != nil) != tt.wantErr { t.Errorf("parseResponseBody() error = %v, wantErr %v", err, tt.wantErr) return } if tt.check != nil && err == nil { tt.check(t, got) } }) } } // TestProviderChatErrors tests error handling in Chat. // Note: apiBase check removed as it's dead code - normalizeBaseURL() always provides a default. func TestProviderChatErrors(t *testing.T) { tests := []struct { name string apiKey string messages []Message wantErrMsg string }{ { name: "missing API key", apiKey: "", messages: []Message{{Role: "user", Content: "Test"}}, wantErrMsg: "API key not configured", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create provider using constructor to ensure proper initialization provider := NewProvider(tt.apiKey, "https://api.example.com") _, err := provider.Chat(context.Background(), tt.messages, nil, "test-model", nil) if err == nil { t.Fatal("Chat() expected error, got nil") } if err.Error() != tt.wantErrMsg { t.Errorf("Chat() error = %q, want %q", err.Error(), tt.wantErrMsg) } }) } } ================================================ FILE: pkg/providers/antigravity_provider.go ================================================ package providers import ( "bufio" "bytes" "context" "encoding/json" "fmt" "io" "math/rand" "net/http" "strings" "time" "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/logger" ) const ( antigravityBaseURL = "https://cloudcode-pa.googleapis.com" antigravityDefaultModel = "gemini-3-flash" antigravityUserAgent = "antigravity" antigravityXGoogClient = "google-cloud-sdk vscode_cloudshelleditor/0.1" antigravityVersion = "1.15.8" ) // AntigravityProvider implements LLMProvider using Google's Cloud Code Assist (Antigravity) API. // This provider authenticates via Google OAuth and provides access to models like Claude and Gemini // through Google's infrastructure. type AntigravityProvider struct { tokenSource func() (string, string, error) // Returns (accessToken, projectID, error) httpClient *http.Client } // NewAntigravityProvider creates a new Antigravity provider using stored auth credentials. func NewAntigravityProvider() *AntigravityProvider { return &AntigravityProvider{ tokenSource: createAntigravityTokenSource(), httpClient: &http.Client{ Timeout: 120 * time.Second, }, } } // Chat implements LLMProvider.Chat using the Cloud Code Assist v1internal API. // The v1internal endpoint wraps the standard Gemini request in an envelope with // project, model, request, requestType, userAgent, and requestId fields. func (p *AntigravityProvider) Chat( ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, ) (*LLMResponse, error) { accessToken, projectID, err := p.tokenSource() if err != nil { return nil, fmt.Errorf("antigravity auth: %w", err) } if model == "" || model == "antigravity" || model == "google-antigravity" { model = antigravityDefaultModel } // Strip provider prefixes if present model = strings.TrimPrefix(model, "google-antigravity/") model = strings.TrimPrefix(model, "antigravity/") logger.DebugCF("provider.antigravity", "Starting chat", map[string]any{ "model": model, "project": projectID, "requestId": fmt.Sprintf("agent-%d-%s", time.Now().UnixMilli(), randomString(9)), }) // Build the inner Gemini-format request innerRequest := p.buildRequest(messages, tools, model, options) // Wrap in v1internal envelope (matches pi-ai SDK format) envelope := map[string]any{ "project": projectID, "model": model, "request": innerRequest, "requestType": "agent", "userAgent": antigravityUserAgent, "requestId": fmt.Sprintf("agent-%d-%s", time.Now().UnixMilli(), randomString(9)), } bodyBytes, err := json.Marshal(envelope) if err != nil { return nil, fmt.Errorf("marshaling request: %w", err) } // Build API URL — uses Cloud Code Assist v1internal streaming endpoint apiURL := fmt.Sprintf("%s/v1internal:streamGenerateContent?alt=sse", antigravityBaseURL) req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(bodyBytes)) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } // Headers matching the pi-ai SDK antigravity format clientMetadata, _ := json.Marshal(map[string]string{ "ideType": "IDE_UNSPECIFIED", "platform": "PLATFORM_UNSPECIFIED", "pluginType": "GEMINI", }) req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "text/event-stream") req.Header.Set("User-Agent", fmt.Sprintf("antigravity/%s linux/amd64", antigravityVersion)) req.Header.Set("X-Goog-Api-Client", antigravityXGoogClient) req.Header.Set("Client-Metadata", string(clientMetadata)) resp, err := p.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("antigravity API call: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading response: %w", err) } if resp.StatusCode != http.StatusOK { logger.ErrorCF("provider.antigravity", "API call failed", map[string]any{ "status_code": resp.StatusCode, "response": string(respBody), "model": model, }) return nil, p.parseAntigravityError(resp.StatusCode, respBody) } // Response is always SSE from streamGenerateContent — each line is "data: {...}" // with a "response" wrapper containing the standard Gemini response llmResp, err := p.parseSSEResponse(string(respBody)) if err != nil { return nil, err } // Check for empty response (some models might return valid success but empty text) if llmResp.Content == "" && len(llmResp.ToolCalls) == 0 { return nil, fmt.Errorf( "antigravity: model returned an empty response (this model might be invalid or restricted)", ) } return llmResp, nil } // GetDefaultModel returns the default model identifier. func (p *AntigravityProvider) GetDefaultModel() string { return antigravityDefaultModel } // --- Request building --- type antigravityRequest struct { Contents []antigravityContent `json:"contents"` Tools []antigravityTool `json:"tools,omitempty"` SystemPrompt *antigravitySystemPrompt `json:"systemInstruction,omitempty"` Config *antigravityGenConfig `json:"generationConfig,omitempty"` } type antigravityContent struct { Role string `json:"role"` Parts []antigravityPart `json:"parts"` } type antigravityPart struct { Text string `json:"text,omitempty"` ThoughtSignature string `json:"thoughtSignature,omitempty"` ThoughtSignatureSnake string `json:"thought_signature,omitempty"` FunctionCall *antigravityFunctionCall `json:"functionCall,omitempty"` FunctionResponse *antigravityFunctionResponse `json:"functionResponse,omitempty"` } type antigravityFunctionCall struct { Name string `json:"name"` Args map[string]any `json:"args"` } type antigravityFunctionResponse struct { Name string `json:"name"` Response map[string]any `json:"response"` } type antigravityTool struct { FunctionDeclarations []antigravityFuncDecl `json:"functionDeclarations"` } type antigravityFuncDecl struct { Name string `json:"name"` Description string `json:"description,omitempty"` Parameters any `json:"parameters,omitempty"` } type antigravitySystemPrompt struct { Parts []antigravityPart `json:"parts"` } type antigravityGenConfig struct { MaxOutputTokens int `json:"maxOutputTokens,omitempty"` Temperature float64 `json:"temperature,omitempty"` } func (p *AntigravityProvider) buildRequest( messages []Message, tools []ToolDefinition, model string, options map[string]any, ) antigravityRequest { req := antigravityRequest{} toolCallNames := make(map[string]string) // Build contents from messages for _, msg := range messages { switch msg.Role { case "system": req.SystemPrompt = &antigravitySystemPrompt{ Parts: []antigravityPart{{Text: msg.Content}}, } case "user": if msg.ToolCallID != "" { toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames) // Tool result req.Contents = append(req.Contents, antigravityContent{ Role: "user", Parts: []antigravityPart{{ FunctionResponse: &antigravityFunctionResponse{ Name: toolName, Response: map[string]any{ "result": msg.Content, }, }, }}, }) } else { req.Contents = append(req.Contents, antigravityContent{ Role: "user", Parts: []antigravityPart{{Text: msg.Content}}, }) } case "assistant": content := antigravityContent{ Role: "model", } if msg.Content != "" { content.Parts = append(content.Parts, antigravityPart{Text: msg.Content}) } for _, tc := range msg.ToolCalls { toolName, toolArgs, thoughtSignature := normalizeStoredToolCall(tc) if toolName == "" { logger.WarnCF( "provider.antigravity", "Skipping tool call with empty name in history", map[string]any{ "tool_call_id": tc.ID, }, ) continue } if tc.ID != "" { toolCallNames[tc.ID] = toolName } content.Parts = append(content.Parts, antigravityPart{ ThoughtSignature: thoughtSignature, ThoughtSignatureSnake: thoughtSignature, FunctionCall: &antigravityFunctionCall{ Name: toolName, Args: toolArgs, }, }) } if len(content.Parts) > 0 { req.Contents = append(req.Contents, content) } case "tool": toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames) req.Contents = append(req.Contents, antigravityContent{ Role: "user", Parts: []antigravityPart{{ FunctionResponse: &antigravityFunctionResponse{ Name: toolName, Response: map[string]any{ "result": msg.Content, }, }, }}, }) } } // Build tools (sanitize schemas for Gemini compatibility) if len(tools) > 0 { var funcDecls []antigravityFuncDecl for _, t := range tools { if t.Type != "function" { continue } params := sanitizeSchemaForGemini(t.Function.Parameters) funcDecls = append(funcDecls, antigravityFuncDecl{ Name: t.Function.Name, Description: t.Function.Description, Parameters: params, }) } if len(funcDecls) > 0 { req.Tools = []antigravityTool{{FunctionDeclarations: funcDecls}} } } // Generation config config := &antigravityGenConfig{} if val, ok := options["max_tokens"]; ok { if maxTokens, ok := val.(int); ok && maxTokens > 0 { config.MaxOutputTokens = maxTokens } else if maxTokens, ok := val.(float64); ok && maxTokens > 0 { config.MaxOutputTokens = int(maxTokens) } } if temp, ok := options["temperature"].(float64); ok { config.Temperature = temp } if config.MaxOutputTokens > 0 || config.Temperature > 0 { req.Config = config } return req } func normalizeStoredToolCall(tc ToolCall) (string, map[string]any, string) { name := tc.Name args := tc.Arguments thoughtSignature := "" if name == "" && tc.Function != nil { name = tc.Function.Name thoughtSignature = tc.Function.ThoughtSignature } else if tc.Function != nil { thoughtSignature = tc.Function.ThoughtSignature } if args == nil { args = map[string]any{} } if len(args) == 0 && tc.Function != nil && tc.Function.Arguments != "" { var parsed map[string]any if err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil && parsed != nil { args = parsed } } return name, args, thoughtSignature } func resolveToolResponseName(toolCallID string, toolCallNames map[string]string) string { if toolCallID == "" { return "" } if name, ok := toolCallNames[toolCallID]; ok && name != "" { return name } return inferToolNameFromCallID(toolCallID) } func inferToolNameFromCallID(toolCallID string) string { if !strings.HasPrefix(toolCallID, "call_") { return toolCallID } rest := strings.TrimPrefix(toolCallID, "call_") if idx := strings.LastIndex(rest, "_"); idx > 0 { candidate := rest[:idx] if candidate != "" { return candidate } } return toolCallID } // --- Response parsing --- type antigravityJSONResponse struct { Candidates []struct { Content struct { Parts []struct { Text string `json:"text,omitempty"` ThoughtSignature string `json:"thoughtSignature,omitempty"` ThoughtSignatureSnake string `json:"thought_signature,omitempty"` FunctionCall *antigravityFunctionCall `json:"functionCall,omitempty"` } `json:"parts"` Role string `json:"role"` } `json:"content"` FinishReason string `json:"finishReason"` } `json:"candidates"` UsageMetadata struct { PromptTokenCount int `json:"promptTokenCount"` CandidatesTokenCount int `json:"candidatesTokenCount"` TotalTokenCount int `json:"totalTokenCount"` } `json:"usageMetadata"` } func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error) { var contentParts []string var toolCalls []ToolCall var usage *UsageInfo var finishReason string scanner := bufio.NewScanner(strings.NewReader(body)) for scanner.Scan() { line := scanner.Text() if !strings.HasPrefix(line, "data: ") { continue } data := strings.TrimPrefix(line, "data: ") if data == "[DONE]" { break } // v1internal SSE wraps the Gemini response in a "response" field var sseChunk struct { Response antigravityJSONResponse `json:"response"` } if err := json.Unmarshal([]byte(data), &sseChunk); err != nil { continue } resp := sseChunk.Response for _, candidate := range resp.Candidates { for _, part := range candidate.Content.Parts { if part.Text != "" { contentParts = append(contentParts, part.Text) } if part.FunctionCall != nil { argumentsJSON, _ := json.Marshal(part.FunctionCall.Args) toolCalls = append(toolCalls, ToolCall{ ID: fmt.Sprintf("call_%s_%d", part.FunctionCall.Name, time.Now().UnixNano()), Name: part.FunctionCall.Name, Arguments: part.FunctionCall.Args, Function: &FunctionCall{ Name: part.FunctionCall.Name, Arguments: string(argumentsJSON), ThoughtSignature: extractPartThoughtSignature( part.ThoughtSignature, part.ThoughtSignatureSnake, ), }, }) } } if candidate.FinishReason != "" { finishReason = candidate.FinishReason } } if resp.UsageMetadata.TotalTokenCount > 0 { usage = &UsageInfo{ PromptTokens: resp.UsageMetadata.PromptTokenCount, CompletionTokens: resp.UsageMetadata.CandidatesTokenCount, TotalTokens: resp.UsageMetadata.TotalTokenCount, } } } mappedFinish := "stop" if len(toolCalls) > 0 { mappedFinish = "tool_calls" } if finishReason == "MAX_TOKENS" { mappedFinish = "length" } return &LLMResponse{ Content: strings.Join(contentParts, ""), ToolCalls: toolCalls, FinishReason: mappedFinish, Usage: usage, }, nil } func extractPartThoughtSignature(thoughtSignature string, thoughtSignatureSnake string) string { if thoughtSignature != "" { return thoughtSignature } if thoughtSignatureSnake != "" { return thoughtSignatureSnake } return "" } // --- Schema sanitization --- // Google/Gemini doesn't support many JSON Schema keywords that other providers accept. var geminiUnsupportedKeywords = map[string]bool{ "patternProperties": true, "additionalProperties": true, "$schema": true, "$id": true, "$ref": true, "$defs": true, "definitions": true, "examples": true, "minLength": true, "maxLength": true, "minimum": true, "maximum": true, "multipleOf": true, "pattern": true, "format": true, "minItems": true, "maxItems": true, "uniqueItems": true, "minProperties": true, "maxProperties": true, } func sanitizeSchemaForGemini(schema map[string]any) map[string]any { if schema == nil { return nil } result := make(map[string]any) for k, v := range schema { if geminiUnsupportedKeywords[k] { continue } // Recursively sanitize nested objects switch val := v.(type) { case map[string]any: result[k] = sanitizeSchemaForGemini(val) case []any: sanitized := make([]any, len(val)) for i, item := range val { if m, ok := item.(map[string]any); ok { sanitized[i] = sanitizeSchemaForGemini(m) } else { sanitized[i] = item } } result[k] = sanitized default: result[k] = v } } // Ensure top-level has type: "object" if properties are present if _, hasProps := result["properties"]; hasProps { if _, hasType := result["type"]; !hasType { result["type"] = "object" } } return result } // --- Token source --- func createAntigravityTokenSource() func() (string, string, error) { return func() (string, string, error) { cred, err := auth.GetCredential("google-antigravity") if err != nil { return "", "", fmt.Errorf("loading auth credentials: %w", err) } if cred == nil { return "", "", fmt.Errorf( "no credentials for google-antigravity. Run: picoclaw auth login --provider google-antigravity", ) } // Refresh if needed if cred.NeedsRefresh() && cred.RefreshToken != "" { oauthCfg := auth.GoogleAntigravityOAuthConfig() refreshed, err := auth.RefreshAccessToken(cred, oauthCfg) if err != nil { return "", "", fmt.Errorf("refreshing token: %w", err) } refreshed.Email = cred.Email if refreshed.ProjectID == "" { refreshed.ProjectID = cred.ProjectID } if err := auth.SetCredential("google-antigravity", refreshed); err != nil { return "", "", fmt.Errorf("saving refreshed token: %w", err) } cred = refreshed } if cred.IsExpired() { return "", "", fmt.Errorf( "antigravity credentials expired. Run: picoclaw auth login --provider google-antigravity", ) } projectID := cred.ProjectID if projectID == "" { // Try to fetch project ID from API fetchedID, err := FetchAntigravityProjectID(cred.AccessToken) if err != nil { logger.WarnCF("provider.antigravity", "Could not fetch project ID, using fallback", map[string]any{ "error": err.Error(), }) projectID = "rising-fact-p41fc" // Default fallback (same as OpenCode) } else { projectID = fetchedID cred.ProjectID = projectID _ = auth.SetCredential("google-antigravity", cred) } } return cred.AccessToken, projectID, nil } } // FetchAntigravityProjectID retrieves the Google Cloud project ID from the loadCodeAssist endpoint. func FetchAntigravityProjectID(accessToken string) (string, error) { reqBody, _ := json.Marshal(map[string]any{ "metadata": map[string]any{ "ideType": "IDE_UNSPECIFIED", "platform": "PLATFORM_UNSPECIFIED", "pluginType": "GEMINI", }, }) req, err := http.NewRequest("POST", antigravityBaseURL+"/v1internal:loadCodeAssist", bytes.NewReader(reqBody)) if err != nil { return "", err } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", antigravityUserAgent) req.Header.Set("X-Goog-Api-Client", antigravityXGoogClient) client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("reading loadCodeAssist response: %w", err) } if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("loadCodeAssist failed: %s", string(body)) } var result struct { CloudAICompanionProject string `json:"cloudaicompanionProject"` } if err := json.Unmarshal(body, &result); err != nil { return "", err } if result.CloudAICompanionProject == "" { return "", fmt.Errorf("no project ID in loadCodeAssist response") } return result.CloudAICompanionProject, nil } // FetchAntigravityModels fetches available models from the Cloud Code Assist API. func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelInfo, error) { reqBody, _ := json.Marshal(map[string]any{ "project": projectID, }) req, err := http.NewRequest("POST", antigravityBaseURL+"/v1internal:fetchAvailableModels", bytes.NewReader(reqBody)) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", antigravityUserAgent) req.Header.Set("X-Goog-Api-Client", antigravityXGoogClient) client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading fetchAvailableModels response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf( "fetchAvailableModels failed (HTTP %d): %s", resp.StatusCode, truncateString(string(body), 200), ) } var result struct { Models map[string]struct { DisplayName string `json:"displayName"` QuotaInfo struct { RemainingFraction any `json:"remainingFraction"` ResetTime string `json:"resetTime"` IsExhausted bool `json:"isExhausted"` } `json:"quotaInfo"` } `json:"models"` } if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("parsing models response: %w", err) } var models []AntigravityModelInfo for id, info := range result.Models { models = append(models, AntigravityModelInfo{ ID: id, DisplayName: info.DisplayName, IsExhausted: info.QuotaInfo.IsExhausted, }) } // Ensure gemini-3-flash-preview and gemini-3-flash are in the list if they aren't already hasFlashPreview := false hasFlash := false for _, m := range models { if m.ID == "gemini-3-flash-preview" { hasFlashPreview = true } if m.ID == "gemini-3-flash" { hasFlash = true } } if !hasFlashPreview { models = append(models, AntigravityModelInfo{ ID: "gemini-3-flash-preview", DisplayName: "Gemini 3 Flash (Preview)", }) } if !hasFlash { models = append(models, AntigravityModelInfo{ ID: "gemini-3-flash", DisplayName: "Gemini 3 Flash", }) } return models, nil } type AntigravityModelInfo struct { ID string `json:"id"` DisplayName string `json:"display_name"` IsExhausted bool `json:"is_exhausted"` } // --- Helpers --- func truncateString(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen] + "..." } func randomString(n int) string { const letters = "abcdefghijklmnopqrstuvwxyz0123456789" b := make([]byte, n) for i := range b { b[i] = letters[rand.Intn(len(letters))] } return string(b) } func (p *AntigravityProvider) parseAntigravityError(statusCode int, body []byte) error { var errResp struct { Error struct { Code int `json:"code"` Message string `json:"message"` Status string `json:"status"` Details []map[string]any `json:"details"` } `json:"error"` } if err := json.Unmarshal(body, &errResp); err != nil { return fmt.Errorf("antigravity API error (HTTP %d): %s", statusCode, truncateString(string(body), 500)) } msg := errResp.Error.Message if statusCode == 429 { // Try to extract quota reset info for _, detail := range errResp.Error.Details { if typeVal, ok := detail["@type"].(string); ok && strings.HasSuffix(typeVal, "ErrorInfo") { if metadata, ok := detail["metadata"].(map[string]any); ok { if delay, ok := metadata["quotaResetDelay"].(string); ok { return fmt.Errorf("antigravity rate limit exceeded: %s (reset in %s)", msg, delay) } } } } return fmt.Errorf("antigravity rate limit exceeded: %s", msg) } return fmt.Errorf("antigravity API error (%s): %s", errResp.Error.Status, msg) } ================================================ FILE: pkg/providers/antigravity_provider_test.go ================================================ package providers import "testing" func TestBuildRequestUsesFunctionFieldsWhenToolCallNameMissing(t *testing.T) { p := &AntigravityProvider{} messages := []Message{ { Role: "assistant", ToolCalls: []ToolCall{{ ID: "call_read_file_123", Function: &FunctionCall{ Name: "read_file", Arguments: `{"path":"README.md"}`, }, }}, }, { Role: "tool", ToolCallID: "call_read_file_123", Content: "ok", }, } req := p.buildRequest(messages, nil, "", nil) if len(req.Contents) != 2 { t.Fatalf("expected 2 contents, got %d", len(req.Contents)) } modelPart := req.Contents[0].Parts[0] if modelPart.FunctionCall == nil { t.Fatal("expected functionCall in assistant message") } if modelPart.FunctionCall.Name != "read_file" { t.Fatalf("expected functionCall name read_file, got %q", modelPart.FunctionCall.Name) } if got := modelPart.FunctionCall.Args["path"]; got != "README.md" { t.Fatalf("expected functionCall args[path] to be README.md, got %v", got) } toolPart := req.Contents[1].Parts[0] if toolPart.FunctionResponse == nil { t.Fatal("expected functionResponse in tool message") } if toolPart.FunctionResponse.Name != "read_file" { t.Fatalf("expected functionResponse name read_file, got %q", toolPart.FunctionResponse.Name) } } func TestResolveToolResponseNameInfersNameFromGeneratedCallID(t *testing.T) { got := resolveToolResponseName("call_search_docs_999", map[string]string{}) if got != "search_docs" { t.Fatalf("expected inferred tool name search_docs, got %q", got) } } ================================================ FILE: pkg/providers/azure/provider.go ================================================ package azure import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/url" "strings" "time" "github.com/sipeed/picoclaw/pkg/providers/common" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) type ( LLMResponse = protocoltypes.LLMResponse Message = protocoltypes.Message ToolDefinition = protocoltypes.ToolDefinition ) const ( // azureAPIVersion is the Azure OpenAI API version used for all requests. azureAPIVersion = "2024-10-21" defaultRequestTimeout = common.DefaultRequestTimeout ) // Provider implements the LLM provider interface for Azure OpenAI endpoints. // It handles Azure-specific authentication (api-key header), URL construction // (deployment-based), and request body formatting (max_completion_tokens, no model field). type Provider struct { apiKey string apiBase string httpClient *http.Client } // Option configures the Azure Provider. type Option func(*Provider) // WithRequestTimeout sets the HTTP request timeout. func WithRequestTimeout(timeout time.Duration) Option { return func(p *Provider) { if timeout > 0 { p.httpClient.Timeout = timeout } } } // NewProvider creates a new Azure OpenAI provider. func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider { p := &Provider{ apiKey: apiKey, apiBase: strings.TrimRight(apiBase, "/"), httpClient: common.NewHTTPClient(proxy), } for _, opt := range opts { if opt != nil { opt(p) } } return p } // NewProviderWithTimeout creates a new Azure OpenAI provider with a custom request timeout in seconds. func NewProviderWithTimeout(apiKey, apiBase, proxy string, requestTimeoutSeconds int) *Provider { return NewProvider( apiKey, apiBase, proxy, WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second), ) } // Chat sends a chat completion request to the Azure OpenAI endpoint. // The model parameter is used as the Azure deployment name in the URL. func (p *Provider) Chat( ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, ) (*LLMResponse, error) { if p.apiBase == "" { return nil, fmt.Errorf("Azure API base not configured") } // model is the deployment name for Azure OpenAI deployment := model // Build Azure-specific URL safely using url.JoinPath and query encoding // to prevent path traversal or query injection via deployment names. base, err := url.JoinPath(p.apiBase, "openai/deployments", deployment, "chat/completions") if err != nil { return nil, fmt.Errorf("failed to build Azure request URL: %w", err) } requestURL := base + "?api-version=" + azureAPIVersion // Build request body — no "model" field (Azure infers from deployment URL) requestBody := map[string]any{ "messages": common.SerializeMessages(messages), } if len(tools) > 0 { requestBody["tools"] = tools requestBody["tool_choice"] = "auto" } // Azure OpenAI always uses max_completion_tokens if maxTokens, ok := common.AsInt(options["max_tokens"]); ok { requestBody["max_completion_tokens"] = maxTokens } if temperature, ok := common.AsFloat(options["temperature"]); ok { requestBody["temperature"] = temperature } jsonData, err := json.Marshal(requestBody) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", requestURL, bytes.NewReader(jsonData)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } // Azure uses api-key header instead of Authorization: Bearer req.Header.Set("Content-Type", "application/json") if p.apiKey != "" { req.Header.Set("Api-Key", p.apiKey) } resp, err := p.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, common.HandleErrorResponse(resp, p.apiBase) } return common.ReadAndParseResponse(resp, p.apiBase) } // GetDefaultModel returns an empty string as Azure deployments are user-configured. func (p *Provider) GetDefaultModel() string { return "" } ================================================ FILE: pkg/providers/azure/provider_test.go ================================================ package azure import ( "encoding/json" "net/http" "net/http/httptest" "testing" "time" ) // writeValidResponse writes a minimal valid Azure OpenAI chat completion response. func writeValidResponse(w http.ResponseWriter) { resp := map[string]any{ "choices": []map[string]any{ { "message": map[string]any{"content": "ok"}, "finish_reason": "stop", }, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } func TestProviderChat_AzureURLConstruction(t *testing.T) { var capturedPath string var capturedAPIVersion string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { capturedPath = r.URL.Path capturedAPIVersion = r.URL.Query().Get("api-version") writeValidResponse(w) })) defer server.Close() p := NewProvider("test-key", server.URL, "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "my-gpt5-deployment", nil) if err != nil { t.Fatalf("Chat() error = %v", err) } wantPath := "/openai/deployments/my-gpt5-deployment/chat/completions" if capturedPath != wantPath { t.Errorf("URL path = %q, want %q", capturedPath, wantPath) } if capturedAPIVersion != azureAPIVersion { t.Errorf("api-version = %q, want %q", capturedAPIVersion, azureAPIVersion) } } func TestProviderChat_AzureAuthHeader(t *testing.T) { var capturedAPIKey string var capturedAuth string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { capturedAPIKey = r.Header.Get("Api-Key") capturedAuth = r.Header.Get("Authorization") writeValidResponse(w) })) defer server.Close() p := NewProvider("test-azure-key", server.URL, "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) if err != nil { t.Fatalf("Chat() error = %v", err) } if capturedAPIKey != "test-azure-key" { t.Errorf("api-key header = %q, want %q", capturedAPIKey, "test-azure-key") } if capturedAuth != "" { t.Errorf("Authorization header should be empty, got %q", capturedAuth) } } func TestProviderChat_AzureOmitsModelFromBody(t *testing.T) { var requestBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewDecoder(r.Body).Decode(&requestBody) writeValidResponse(w) })) defer server.Close() p := NewProvider("test-key", server.URL, "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) if err != nil { t.Fatalf("Chat() error = %v", err) } if _, exists := requestBody["model"]; exists { t.Error("request body should not contain 'model' field for Azure OpenAI") } } func TestProviderChat_AzureUsesMaxCompletionTokens(t *testing.T) { var requestBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewDecoder(r.Body).Decode(&requestBody) writeValidResponse(w) })) defer server.Close() p := NewProvider("test-key", server.URL, "") _, err := p.Chat( t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", map[string]any{"max_tokens": 2048}, ) if err != nil { t.Fatalf("Chat() error = %v", err) } if _, exists := requestBody["max_completion_tokens"]; !exists { t.Error("request body should contain 'max_completion_tokens'") } if _, exists := requestBody["max_tokens"]; exists { t.Error("request body should not contain 'max_tokens'") } } func TestProviderChat_AzureHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) })) defer server.Close() p := NewProvider("bad-key", server.URL, "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) if err == nil { t.Fatal("expected error, got nil") } } func TestProviderChat_AzureParseToolCalls(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := map[string]any{ "choices": []map[string]any{ { "message": map[string]any{ "content": "", "tool_calls": []map[string]any{ { "id": "call_1", "type": "function", "function": map[string]any{ "name": "get_weather", "arguments": `{"city":"Seattle"}`, }, }, }, }, "finish_reason": "tool_calls", }, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) })) defer server.Close() p := NewProvider("test-key", server.URL, "") out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "weather?"}}, nil, "deployment", nil) if err != nil { t.Fatalf("Chat() error = %v", err) } if len(out.ToolCalls) != 1 { t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) } if out.ToolCalls[0].Name != "get_weather" { t.Errorf("ToolCalls[0].Name = %q, want %q", out.ToolCalls[0].Name, "get_weather") } } func TestProvider_AzureEmptyAPIBase(t *testing.T) { p := NewProvider("test-key", "", "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) if err == nil { t.Fatal("expected error for empty API base") } } func TestProvider_AzureRequestTimeoutDefault(t *testing.T) { p := NewProvider("test-key", "https://example.com", "") if p.httpClient.Timeout != defaultRequestTimeout { t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout) } } func TestProvider_AzureRequestTimeoutOverride(t *testing.T) { p := NewProvider("test-key", "https://example.com", "", WithRequestTimeout(300*time.Second)) if p.httpClient.Timeout != 300*time.Second { t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, 300*time.Second) } } func TestProvider_AzureNewProviderWithTimeout(t *testing.T) { p := NewProviderWithTimeout("test-key", "https://example.com", "", 180) if p.httpClient.Timeout != 180*time.Second { t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, 180*time.Second) } } func TestProviderChat_AzureDeploymentNameEscaped(t *testing.T) { var capturedPath string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { capturedPath = r.URL.RawPath // use RawPath to see percent-encoding if capturedPath == "" { capturedPath = r.URL.Path } writeValidResponse(w) })) defer server.Close() p := NewProvider("test-key", server.URL, "") // Deployment name with characters that could cause path injection _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "my deploy/../../admin", nil) if err != nil { t.Fatalf("Chat() error = %v", err) } // The slash and special chars in the deployment name must be escaped, not treated as path separators if capturedPath == "/openai/deployments/my deploy/../../admin/chat/completions" { t.Fatal("deployment name was interpolated without escaping — path injection possible") } } ================================================ FILE: pkg/providers/claude_cli_provider.go ================================================ package providers import ( "bytes" "context" "encoding/json" "fmt" "os/exec" "strings" ) // ClaudeCliProvider implements LLMProvider using the claude CLI as a subprocess. type ClaudeCliProvider struct { command string workspace string } // NewClaudeCliProvider creates a new Claude CLI provider. func NewClaudeCliProvider(workspace string) *ClaudeCliProvider { return &ClaudeCliProvider{ command: "claude", workspace: workspace, } } // Chat implements LLMProvider.Chat by executing the claude CLI. func (p *ClaudeCliProvider) Chat( ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, ) (*LLMResponse, error) { systemPrompt := p.buildSystemPrompt(messages, tools) prompt := p.messagesToPrompt(messages) args := []string{"-p", "--output-format", "json", "--dangerously-skip-permissions", "--no-chrome"} if systemPrompt != "" { args = append(args, "--system-prompt", systemPrompt) } if model != "" && model != "claude-code" { args = append(args, "--model", model) } args = append(args, "-") // read from stdin cmd := exec.CommandContext(ctx, p.command, args...) if p.workspace != "" { cmd.Dir = p.workspace } cmd.Stdin = bytes.NewReader([]byte(prompt)) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { stderrStr := strings.TrimSpace(stderr.String()) stdoutStr := strings.TrimSpace(stdout.String()) switch { case stderrStr != "" && stdoutStr != "": return nil, fmt.Errorf("claude cli error: %w\nstderr: %s\nstdout: %s", err, stderrStr, stdoutStr) case stderrStr != "": return nil, fmt.Errorf("claude cli error: %s", stderrStr) case stdoutStr != "": return nil, fmt.Errorf("claude cli error: %w\noutput: %s", err, stdoutStr) default: return nil, fmt.Errorf("claude cli error: %w", err) } } return p.parseClaudeCliResponse(stdout.String()) } // GetDefaultModel returns the default model identifier. func (p *ClaudeCliProvider) GetDefaultModel() string { return "claude-code" } // messagesToPrompt converts messages to a CLI-compatible prompt string. func (p *ClaudeCliProvider) messagesToPrompt(messages []Message) string { var parts []string for _, msg := range messages { switch msg.Role { case "system": // handled via --system-prompt flag case "user": parts = append(parts, "User: "+msg.Content) case "assistant": parts = append(parts, "Assistant: "+msg.Content) case "tool": parts = append(parts, fmt.Sprintf("[Tool Result for %s]: %s", msg.ToolCallID, msg.Content)) } } // Simplify single user message if len(parts) == 1 && strings.HasPrefix(parts[0], "User: ") { return strings.TrimPrefix(parts[0], "User: ") } return strings.Join(parts, "\n") } // buildSystemPrompt combines system messages and tool definitions. func (p *ClaudeCliProvider) buildSystemPrompt(messages []Message, tools []ToolDefinition) string { var parts []string for _, msg := range messages { if msg.Role == "system" { parts = append(parts, msg.Content) } } if len(tools) > 0 { parts = append(parts, buildCLIToolsPrompt(tools)) } return strings.Join(parts, "\n\n") } // parseClaudeCliResponse parses the JSON output from the claude CLI. func (p *ClaudeCliProvider) parseClaudeCliResponse(output string) (*LLMResponse, error) { var resp claudeCliJSONResponse if err := json.Unmarshal([]byte(output), &resp); err != nil { return nil, fmt.Errorf("failed to parse claude cli response: %w", err) } if resp.IsError { return nil, fmt.Errorf("claude cli returned error: %s", resp.Result) } toolCalls := p.extractToolCalls(resp.Result) finishReason := "stop" content := resp.Result if len(toolCalls) > 0 { finishReason = "tool_calls" content = p.stripToolCallsJSON(resp.Result) } var usage *UsageInfo if resp.Usage.InputTokens > 0 || resp.Usage.OutputTokens > 0 { usage = &UsageInfo{ PromptTokens: resp.Usage.InputTokens + resp.Usage.CacheCreationInputTokens + resp.Usage.CacheReadInputTokens, CompletionTokens: resp.Usage.OutputTokens, TotalTokens: resp.Usage.InputTokens + resp.Usage.CacheCreationInputTokens + resp.Usage.CacheReadInputTokens + resp.Usage.OutputTokens, } } return &LLMResponse{ Content: strings.TrimSpace(content), ToolCalls: toolCalls, FinishReason: finishReason, Usage: usage, }, nil } // extractToolCalls delegates to the shared extractToolCallsFromText function. func (p *ClaudeCliProvider) extractToolCalls(text string) []ToolCall { return extractToolCallsFromText(text) } // stripToolCallsJSON delegates to the shared stripToolCallsFromText function. func (p *ClaudeCliProvider) stripToolCallsJSON(text string) string { return stripToolCallsFromText(text) } // findMatchingBrace finds the index after the closing brace matching the opening brace at pos. func findMatchingBrace(text string, pos int) int { depth := 0 for i := pos; i < len(text); i++ { if text[i] == '{' { depth++ } else if text[i] == '}' { depth-- if depth == 0 { return i + 1 } } } return pos } // claudeCliJSONResponse represents the JSON output from the claude CLI. // Matches the real claude CLI v2.x output format. type claudeCliJSONResponse struct { Type string `json:"type"` Subtype string `json:"subtype"` IsError bool `json:"is_error"` Result string `json:"result"` SessionID string `json:"session_id"` TotalCostUSD float64 `json:"total_cost_usd"` DurationMS int `json:"duration_ms"` DurationAPI int `json:"duration_api_ms"` NumTurns int `json:"num_turns"` Usage claudeCliUsageInfo `json:"usage"` } // claudeCliUsageInfo represents token usage from the claude CLI response. type claudeCliUsageInfo struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` CacheCreationInputTokens int `json:"cache_creation_input_tokens"` CacheReadInputTokens int `json:"cache_read_input_tokens"` } ================================================ FILE: pkg/providers/claude_cli_provider_integration_test.go ================================================ //go:build integration package providers import ( "context" exec "os/exec" "strings" "testing" "time" ) // TestIntegration_RealClaudeCLI tests the ClaudeCliProvider with a real claude CLI. // Run with: go test -tags=integration ./pkg/providers/... func TestIntegration_RealClaudeCLI(t *testing.T) { // Check if claude CLI is available path, err := exec.LookPath("claude") if err != nil { t.Skip("claude CLI not found in PATH, skipping integration test") } t.Logf("Using claude CLI at: %s", path) p := NewClaudeCliProvider(t.TempDir()) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() resp, err := p.Chat(ctx, []Message{ {Role: "user", Content: "Respond with only the word 'pong'. Nothing else."}, }, nil, "", nil) if err != nil { t.Fatalf("Chat() with real CLI error = %v", err) } // Verify response structure if resp.Content == "" { t.Error("Content is empty") } if resp.FinishReason != "stop" { t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") } if resp.Usage == nil { t.Error("Usage should not be nil from real CLI") } else { if resp.Usage.PromptTokens == 0 { t.Error("PromptTokens should be > 0") } if resp.Usage.CompletionTokens == 0 { t.Error("CompletionTokens should be > 0") } t.Logf("Usage: prompt=%d, completion=%d, total=%d", resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.TotalTokens) } t.Logf("Response content: %q", resp.Content) // Loose check - should contain "pong" somewhere (model might capitalize or add punctuation) if !strings.Contains(strings.ToLower(resp.Content), "pong") { t.Errorf("Content = %q, expected to contain 'pong'", resp.Content) } } func TestIntegration_RealClaudeCLI_WithSystemPrompt(t *testing.T) { if _, err := exec.LookPath("claude"); err != nil { t.Skip("claude CLI not found in PATH") } p := NewClaudeCliProvider(t.TempDir()) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() resp, err := p.Chat(ctx, []Message{ {Role: "system", Content: "You are a calculator. Only respond with numbers. No text."}, {Role: "user", Content: "What is 2+2?"}, }, nil, "", nil) if err != nil { t.Fatalf("Chat() error = %v", err) } t.Logf("Response: %q", resp.Content) if !strings.Contains(resp.Content, "4") { t.Errorf("Content = %q, expected to contain '4'", resp.Content) } } func TestIntegration_RealClaudeCLI_ParsesRealJSON(t *testing.T) { if _, err := exec.LookPath("claude"); err != nil { t.Skip("claude CLI not found in PATH") } // Run claude directly and verify our parser handles real output cmd := exec.Command("claude", "-p", "--output-format", "json", "--dangerously-skip-permissions", "--no-chrome", "--no-session-persistence", "-") cmd.Stdin = strings.NewReader("Say hi") cmd.Dir = t.TempDir() output, err := cmd.Output() if err != nil { t.Fatalf("claude CLI failed: %v", err) } t.Logf("Raw CLI output: %s", string(output)) // Verify our parser can handle real output p := NewClaudeCliProvider("") resp, err := p.parseClaudeCliResponse(string(output)) if err != nil { t.Fatalf("parseClaudeCliResponse() failed on real CLI output: %v", err) } if resp.Content == "" { t.Error("parsed Content is empty") } if resp.FinishReason != "stop" { t.Errorf("FinishReason = %q, want stop", resp.FinishReason) } if resp.Usage == nil { t.Error("Usage should not be nil") } t.Logf("Parsed: content=%q, finish=%s, usage=%+v", resp.Content, resp.FinishReason, resp.Usage) } ================================================ FILE: pkg/providers/claude_cli_provider_test.go ================================================ package providers import ( "context" "fmt" "os" "path/filepath" "runtime" "strings" "testing" "time" "github.com/sipeed/picoclaw/pkg/config" ) // --- Compile-time interface check --- var _ LLMProvider = (*ClaudeCliProvider)(nil) // --- Helper: create mock CLI scripts --- // createMockCLI creates a temporary script that simulates the claude CLI. // Uses files for stdout/stderr to avoid shell quoting issues with JSON. func createMockCLI(t *testing.T, stdout, stderr string, exitCode int) string { t.Helper() if runtime.GOOS == "windows" { t.Skip("mock CLI scripts not supported on Windows") } dir := t.TempDir() if stdout != "" { if err := os.WriteFile(filepath.Join(dir, "stdout.txt"), []byte(stdout), 0o644); err != nil { t.Fatal(err) } } if stderr != "" { if err := os.WriteFile(filepath.Join(dir, "stderr.txt"), []byte(stderr), 0o644); err != nil { t.Fatal(err) } } var sb strings.Builder sb.WriteString("#!/bin/sh\n") if stderr != "" { sb.WriteString(fmt.Sprintf("cat '%s/stderr.txt' >&2\n", dir)) } if stdout != "" { sb.WriteString(fmt.Sprintf("cat '%s/stdout.txt'\n", dir)) } sb.WriteString(fmt.Sprintf("exit %d\n", exitCode)) script := filepath.Join(dir, "claude") if err := os.WriteFile(script, []byte(sb.String()), 0o755); err != nil { t.Fatal(err) } return script } // createSlowMockCLI creates a script that sleeps before responding (for context cancellation tests). func createSlowMockCLI(t *testing.T, sleepSeconds int) string { t.Helper() if runtime.GOOS == "windows" { t.Skip("mock CLI scripts not supported on Windows") } dir := t.TempDir() script := filepath.Join(dir, "claude") content := fmt.Sprintf("#!/bin/sh\nsleep %d\necho '{\"type\":\"result\",\"result\":\"late\"}'\n", sleepSeconds) if err := os.WriteFile(script, []byte(content), 0o755); err != nil { t.Fatal(err) } return script } // createArgCaptureCLI creates a script that captures CLI args to a file, then outputs JSON. func createArgCaptureCLI(t *testing.T, argsFile string) string { t.Helper() if runtime.GOOS == "windows" { t.Skip("mock CLI scripts not supported on Windows") } dir := t.TempDir() script := filepath.Join(dir, "claude") content := fmt.Sprintf(`#!/bin/sh echo "$@" > '%s' cat <<'EOFMOCK' {"type":"result","result":"ok","session_id":"test"} EOFMOCK `, argsFile) if err := os.WriteFile(script, []byte(content), 0o755); err != nil { t.Fatal(err) } return script } // --- Constructor tests --- func TestNewClaudeCliProvider(t *testing.T) { p := NewClaudeCliProvider("/test/workspace") if p == nil { t.Fatal("NewClaudeCliProvider returned nil") } if p.workspace != "/test/workspace" { t.Errorf("workspace = %q, want %q", p.workspace, "/test/workspace") } if p.command != "claude" { t.Errorf("command = %q, want %q", p.command, "claude") } } func TestNewClaudeCliProvider_EmptyWorkspace(t *testing.T) { p := NewClaudeCliProvider("") if p.workspace != "" { t.Errorf("workspace = %q, want empty", p.workspace) } } // --- GetDefaultModel tests --- func TestClaudeCliProvider_GetDefaultModel(t *testing.T) { p := NewClaudeCliProvider("/workspace") if got := p.GetDefaultModel(); got != "claude-code" { t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-code") } } // --- Chat() tests --- func TestChat_Success(t *testing.T) { mockJSON := `{"type":"result","subtype":"success","is_error":false,"result":"Hello from mock!","session_id":"sess_123","total_cost_usd":0.005,"duration_ms":200,"duration_api_ms":150,"num_turns":1,"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":100,"cache_read_input_tokens":0}}` script := createMockCLI(t, mockJSON, "", 0) p := NewClaudeCliProvider(t.TempDir()) p.command = script resp, err := p.Chat(context.Background(), []Message{ {Role: "user", Content: "Hello"}, }, nil, "", nil) if err != nil { t.Fatalf("Chat() error = %v", err) } if resp.Content != "Hello from mock!" { t.Errorf("Content = %q, want %q", resp.Content, "Hello from mock!") } if resp.FinishReason != "stop" { t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") } if len(resp.ToolCalls) != 0 { t.Errorf("ToolCalls len = %d, want 0", len(resp.ToolCalls)) } if resp.Usage == nil { t.Fatal("Usage should not be nil") } if resp.Usage.PromptTokens != 110 { // 10 + 100 + 0 t.Errorf("PromptTokens = %d, want 110", resp.Usage.PromptTokens) } if resp.Usage.CompletionTokens != 5 { t.Errorf("CompletionTokens = %d, want 5", resp.Usage.CompletionTokens) } if resp.Usage.TotalTokens != 115 { // 110 + 5 t.Errorf("TotalTokens = %d, want 115", resp.Usage.TotalTokens) } } func TestChat_IsErrorResponse(t *testing.T) { mockJSON := `{"type":"result","subtype":"error","is_error":true,"result":"Rate limit exceeded","session_id":"s1","total_cost_usd":0}` script := createMockCLI(t, mockJSON, "", 0) p := NewClaudeCliProvider(t.TempDir()) p.command = script _, err := p.Chat(context.Background(), []Message{ {Role: "user", Content: "Hello"}, }, nil, "", nil) if err == nil { t.Fatal("Chat() expected error when is_error=true") } if !strings.Contains(err.Error(), "Rate limit exceeded") { t.Errorf("error = %q, want to contain 'Rate limit exceeded'", err.Error()) } } func TestChat_WithToolCallsInResponse(t *testing.T) { mockJSON := `{"type":"result","subtype":"success","is_error":false,"result":"Checking weather.\n{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"location\\\":\\\"NYC\\\"}\"}}]}","session_id":"s1","total_cost_usd":0.01,"usage":{"input_tokens":5,"output_tokens":20,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}` script := createMockCLI(t, mockJSON, "", 0) p := NewClaudeCliProvider(t.TempDir()) p.command = script resp, err := p.Chat(context.Background(), []Message{ {Role: "user", Content: "What's the weather?"}, }, nil, "", nil) if err != nil { t.Fatalf("Chat() error = %v", err) } if resp.FinishReason != "tool_calls" { t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls") } if len(resp.ToolCalls) != 1 { t.Fatalf("ToolCalls len = %d, want 1", len(resp.ToolCalls)) } if resp.ToolCalls[0].Name != "get_weather" { t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "get_weather") } if resp.ToolCalls[0].Arguments["location"] != "NYC" { t.Errorf("ToolCalls[0].Arguments[location] = %v, want NYC", resp.ToolCalls[0].Arguments["location"]) } } func TestChat_StderrError(t *testing.T) { script := createMockCLI(t, "", "Error: rate limited", 1) p := NewClaudeCliProvider(t.TempDir()) p.command = script _, err := p.Chat(context.Background(), []Message{ {Role: "user", Content: "Hello"}, }, nil, "", nil) if err == nil { t.Fatal("Chat() expected error") } if !strings.Contains(err.Error(), "rate limited") { t.Errorf("error = %q, want to contain 'rate limited'", err.Error()) } } func TestChat_NonZeroExitNoStderr(t *testing.T) { script := createMockCLI(t, "", "", 1) p := NewClaudeCliProvider(t.TempDir()) p.command = script _, err := p.Chat(context.Background(), []Message{ {Role: "user", Content: "Hello"}, }, nil, "", nil) if err == nil { t.Fatal("Chat() expected error for non-zero exit") } if !strings.Contains(err.Error(), "claude cli error") { t.Errorf("error = %q, want to contain 'claude cli error'", err.Error()) } } func TestChat_CommandNotFound(t *testing.T) { p := NewClaudeCliProvider(t.TempDir()) p.command = "/nonexistent/claude-binary-that-does-not-exist" _, err := p.Chat(context.Background(), []Message{ {Role: "user", Content: "Hello"}, }, nil, "", nil) if err == nil { t.Fatal("Chat() expected error for missing command") } } func TestChat_InvalidResponseJSON(t *testing.T) { script := createMockCLI(t, "not valid json at all", "", 0) p := NewClaudeCliProvider(t.TempDir()) p.command = script _, err := p.Chat(context.Background(), []Message{ {Role: "user", Content: "Hello"}, }, nil, "", nil) if err == nil { t.Fatal("Chat() expected error for invalid JSON") } if !strings.Contains(err.Error(), "failed to parse claude cli response") { t.Errorf("error = %q, want to contain 'failed to parse claude cli response'", err.Error()) } } func TestChat_ContextCancellation(t *testing.T) { script := createSlowMockCLI(t, 2) // sleep 2s p := NewClaudeCliProvider(t.TempDir()) p.command = script ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() start := time.Now() _, err := p.Chat(ctx, []Message{ {Role: "user", Content: "Hello"}, }, nil, "", nil) elapsed := time.Since(start) if err == nil { t.Fatal("Chat() expected error on context cancellation") } // Should fail well before the full 2s sleep completes if elapsed > 3*time.Second { t.Errorf("Chat() took %v, expected to fail faster via context cancellation", elapsed) } } func TestChat_PassesSystemPromptFlag(t *testing.T) { argsFile := filepath.Join(t.TempDir(), "args.txt") script := createArgCaptureCLI(t, argsFile) p := NewClaudeCliProvider(t.TempDir()) p.command = script _, err := p.Chat(context.Background(), []Message{ {Role: "system", Content: "Be helpful."}, {Role: "user", Content: "Hi"}, }, nil, "", nil) if err != nil { t.Fatalf("Chat() error = %v", err) } argsBytes, err := os.ReadFile(argsFile) if err != nil { t.Fatalf("failed to read args file: %v", err) } args := string(argsBytes) if !strings.Contains(args, "--system-prompt") { t.Errorf("CLI args missing --system-prompt, got: %s", args) } } func TestChat_PassesModelFlag(t *testing.T) { argsFile := filepath.Join(t.TempDir(), "args.txt") script := createArgCaptureCLI(t, argsFile) p := NewClaudeCliProvider(t.TempDir()) p.command = script _, err := p.Chat(context.Background(), []Message{ {Role: "user", Content: "Hi"}, }, nil, "claude-sonnet-4.6", nil) if err != nil { t.Fatalf("Chat() error = %v", err) } argsBytes, _ := os.ReadFile(argsFile) args := string(argsBytes) if !strings.Contains(args, "--model") { t.Errorf("CLI args missing --model, got: %s", args) } if !strings.Contains(args, "claude-sonnet-4.6") { t.Errorf("CLI args missing model name, got: %s", args) } } func TestChat_SkipsModelFlagForClaudeCode(t *testing.T) { argsFile := filepath.Join(t.TempDir(), "args.txt") script := createArgCaptureCLI(t, argsFile) p := NewClaudeCliProvider(t.TempDir()) p.command = script _, err := p.Chat(context.Background(), []Message{ {Role: "user", Content: "Hi"}, }, nil, "claude-code", nil) if err != nil { t.Fatalf("Chat() error = %v", err) } argsBytes, _ := os.ReadFile(argsFile) args := string(argsBytes) if strings.Contains(args, "--model") { t.Errorf("CLI args should NOT contain --model for claude-code, got: %s", args) } } func TestChat_SkipsModelFlagForEmptyModel(t *testing.T) { argsFile := filepath.Join(t.TempDir(), "args.txt") script := createArgCaptureCLI(t, argsFile) p := NewClaudeCliProvider(t.TempDir()) p.command = script _, err := p.Chat(context.Background(), []Message{ {Role: "user", Content: "Hi"}, }, nil, "", nil) if err != nil { t.Fatalf("Chat() error = %v", err) } argsBytes, _ := os.ReadFile(argsFile) args := string(argsBytes) if strings.Contains(args, "--model") { t.Errorf("CLI args should NOT contain --model for empty model, got: %s", args) } } func TestChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) { mockJSON := `{"type":"result","result":"ok","session_id":"s"}` script := createMockCLI(t, mockJSON, "", 0) p := NewClaudeCliProvider("") p.command = script resp, err := p.Chat(context.Background(), []Message{ {Role: "user", Content: "Hello"}, }, nil, "", nil) if err != nil { t.Fatalf("Chat() with empty workspace error = %v", err) } if resp.Content != "ok" { t.Errorf("Content = %q, want %q", resp.Content, "ok") } } // --- CreateProvider factory tests --- func TestCreateProvider_ClaudeCli(t *testing.T) { cfg := config.DefaultConfig() cfg.ModelList = []config.ModelConfig{ {ModelName: "claude-sonnet-4.6", Model: "claude-cli/claude-sonnet-4.6", Workspace: "/test/ws"}, } cfg.Agents.Defaults.Model = "claude-sonnet-4.6" provider, _, err := CreateProvider(cfg) if err != nil { t.Fatalf("CreateProvider(claude-cli) error = %v", err) } cliProvider, ok := provider.(*ClaudeCliProvider) if !ok { t.Fatalf("CreateProvider(claude-cli) returned %T, want *ClaudeCliProvider", provider) } if cliProvider.workspace != "/test/ws" { t.Errorf("workspace = %q, want %q", cliProvider.workspace, "/test/ws") } } func TestCreateProvider_ClaudeCode(t *testing.T) { cfg := config.DefaultConfig() cfg.ModelList = []config.ModelConfig{ {ModelName: "claude-code", Model: "claude-cli/claude-code"}, } cfg.Agents.Defaults.Model = "claude-code" provider, _, err := CreateProvider(cfg) if err != nil { t.Fatalf("CreateProvider(claude-code) error = %v", err) } if _, ok := provider.(*ClaudeCliProvider); !ok { t.Fatalf("CreateProvider(claude-code) returned %T, want *ClaudeCliProvider", provider) } } func TestCreateProvider_ClaudeCodec(t *testing.T) { cfg := config.DefaultConfig() cfg.ModelList = []config.ModelConfig{ {ModelName: "claudecode", Model: "claude-cli/claudecode"}, } cfg.Agents.Defaults.Model = "claudecode" provider, _, err := CreateProvider(cfg) if err != nil { t.Fatalf("CreateProvider(claudecode) error = %v", err) } if _, ok := provider.(*ClaudeCliProvider); !ok { t.Fatalf("CreateProvider(claudecode) returned %T, want *ClaudeCliProvider", provider) } } func TestCreateProvider_ClaudeCliDefaultWorkspace(t *testing.T) { cfg := config.DefaultConfig() cfg.ModelList = []config.ModelConfig{ {ModelName: "claude-cli", Model: "claude-cli/claude-sonnet"}, } cfg.Agents.Defaults.Model = "claude-cli" cfg.Agents.Defaults.Workspace = "" provider, _, err := CreateProvider(cfg) if err != nil { t.Fatalf("CreateProvider error = %v", err) } cliProvider, ok := provider.(*ClaudeCliProvider) if !ok { t.Fatalf("returned %T, want *ClaudeCliProvider", provider) } if cliProvider.workspace != "." { t.Errorf("workspace = %q, want %q (default)", cliProvider.workspace, ".") } } // --- messagesToPrompt tests --- func TestMessagesToPrompt_SingleUser(t *testing.T) { p := NewClaudeCliProvider("/workspace") messages := []Message{ {Role: "user", Content: "Hello"}, } got := p.messagesToPrompt(messages) want := "Hello" if got != want { t.Errorf("messagesToPrompt() = %q, want %q", got, want) } } func TestMessagesToPrompt_Conversation(t *testing.T) { p := NewClaudeCliProvider("/workspace") messages := []Message{ {Role: "user", Content: "Hi"}, {Role: "assistant", Content: "Hello!"}, {Role: "user", Content: "How are you?"}, } got := p.messagesToPrompt(messages) want := "User: Hi\nAssistant: Hello!\nUser: How are you?" if got != want { t.Errorf("messagesToPrompt() = %q, want %q", got, want) } } func TestMessagesToPrompt_WithSystemMessage(t *testing.T) { p := NewClaudeCliProvider("/workspace") messages := []Message{ {Role: "system", Content: "You are helpful."}, {Role: "user", Content: "Hello"}, } got := p.messagesToPrompt(messages) want := "Hello" if got != want { t.Errorf("messagesToPrompt() = %q, want %q", got, want) } } func TestMessagesToPrompt_WithToolResults(t *testing.T) { p := NewClaudeCliProvider("/workspace") messages := []Message{ {Role: "user", Content: "What's the weather?"}, {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_123"}, } got := p.messagesToPrompt(messages) if !strings.Contains(got, "[Tool Result for call_123]") { t.Errorf("messagesToPrompt() missing tool result marker, got %q", got) } if !strings.Contains(got, `{"temp": 72}`) { t.Errorf("messagesToPrompt() missing tool result content, got %q", got) } } func TestMessagesToPrompt_EmptyMessages(t *testing.T) { p := NewClaudeCliProvider("/workspace") got := p.messagesToPrompt(nil) if got != "" { t.Errorf("messagesToPrompt(nil) = %q, want empty", got) } } func TestMessagesToPrompt_OnlySystemMessages(t *testing.T) { p := NewClaudeCliProvider("/workspace") messages := []Message{ {Role: "system", Content: "System 1"}, {Role: "system", Content: "System 2"}, } got := p.messagesToPrompt(messages) if got != "" { t.Errorf("messagesToPrompt() with only system msgs = %q, want empty", got) } } // --- buildSystemPrompt tests --- func TestBuildSystemPrompt_NoSystemNoTools(t *testing.T) { p := NewClaudeCliProvider("/workspace") messages := []Message{ {Role: "user", Content: "Hi"}, } got := p.buildSystemPrompt(messages, nil) if got != "" { t.Errorf("buildSystemPrompt() = %q, want empty", got) } } func TestBuildSystemPrompt_SystemOnly(t *testing.T) { p := NewClaudeCliProvider("/workspace") messages := []Message{ {Role: "system", Content: "You are helpful."}, {Role: "user", Content: "Hi"}, } got := p.buildSystemPrompt(messages, nil) if got != "You are helpful." { t.Errorf("buildSystemPrompt() = %q, want %q", got, "You are helpful.") } } func TestBuildSystemPrompt_MultipleSystemMessages(t *testing.T) { p := NewClaudeCliProvider("/workspace") messages := []Message{ {Role: "system", Content: "You are helpful."}, {Role: "system", Content: "Be concise."}, {Role: "user", Content: "Hi"}, } got := p.buildSystemPrompt(messages, nil) if !strings.Contains(got, "You are helpful.") { t.Error("missing first system message") } if !strings.Contains(got, "Be concise.") { t.Error("missing second system message") } // Should be joined with double newline want := "You are helpful.\n\nBe concise." if got != want { t.Errorf("buildSystemPrompt() = %q, want %q", got, want) } } func TestBuildSystemPrompt_WithTools(t *testing.T) { p := NewClaudeCliProvider("/workspace") messages := []Message{ {Role: "system", Content: "You are helpful."}, } tools := []ToolDefinition{ { Type: "function", Function: ToolFunctionDefinition{ Name: "get_weather", Description: "Get weather for a location", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "location": map[string]any{"type": "string"}, }, }, }, }, } got := p.buildSystemPrompt(messages, tools) if !strings.Contains(got, "You are helpful.") { t.Error("buildSystemPrompt() missing system message") } if !strings.Contains(got, "get_weather") { t.Error("buildSystemPrompt() missing tool definition") } if !strings.Contains(got, "Available Tools") { t.Error("buildSystemPrompt() missing tools header") } } func TestBuildSystemPrompt_ToolsOnlyNoSystem(t *testing.T) { p := NewClaudeCliProvider("/workspace") tools := []ToolDefinition{ { Type: "function", Function: ToolFunctionDefinition{ Name: "test_tool", Description: "A test tool", }, }, } got := p.buildSystemPrompt(nil, tools) if !strings.Contains(got, "test_tool") { t.Error("should include tool definitions even without system messages") } } // --- buildToolsPrompt tests --- func TestBuildToolsPrompt_SkipsNonFunction(t *testing.T) { tools := []ToolDefinition{ {Type: "other", Function: ToolFunctionDefinition{Name: "skip_me"}}, {Type: "function", Function: ToolFunctionDefinition{Name: "include_me", Description: "Included"}}, } got := buildCLIToolsPrompt(tools) if strings.Contains(got, "skip_me") { t.Error("buildToolsPrompt() should skip non-function tools") } if !strings.Contains(got, "include_me") { t.Error("buildToolsPrompt() should include function tools") } } func TestBuildToolsPrompt_NoDescription(t *testing.T) { tools := []ToolDefinition{ {Type: "function", Function: ToolFunctionDefinition{Name: "bare_tool"}}, } got := buildCLIToolsPrompt(tools) if !strings.Contains(got, "bare_tool") { t.Error("should include tool name") } if strings.Contains(got, "Description:") { t.Error("should not include Description: line when empty") } } func TestBuildToolsPrompt_NoParameters(t *testing.T) { tools := []ToolDefinition{ {Type: "function", Function: ToolFunctionDefinition{ Name: "no_params_tool", Description: "A tool with no parameters", }}, } got := buildCLIToolsPrompt(tools) if strings.Contains(got, "Parameters:") { t.Error("should not include Parameters: section when nil") } } // --- parseClaudeCliResponse tests --- func TestParseClaudeCliResponse_TextOnly(t *testing.T) { p := NewClaudeCliProvider("/workspace") output := `{"type":"result","subtype":"success","is_error":false,"result":"Hello, world!","session_id":"abc123","total_cost_usd":0.01,"duration_ms":500,"usage":{"input_tokens":10,"output_tokens":20,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}` resp, err := p.parseClaudeCliResponse(output) if err != nil { t.Fatalf("parseClaudeCliResponse() error = %v", err) } if resp.Content != "Hello, world!" { t.Errorf("Content = %q, want %q", resp.Content, "Hello, world!") } if resp.FinishReason != "stop" { t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") } if len(resp.ToolCalls) != 0 { t.Errorf("ToolCalls = %d, want 0", len(resp.ToolCalls)) } if resp.Usage == nil { t.Fatal("Usage should not be nil") } if resp.Usage.PromptTokens != 10 { t.Errorf("PromptTokens = %d, want 10", resp.Usage.PromptTokens) } if resp.Usage.CompletionTokens != 20 { t.Errorf("CompletionTokens = %d, want 20", resp.Usage.CompletionTokens) } } func TestParseClaudeCliResponse_EmptyResult(t *testing.T) { p := NewClaudeCliProvider("/workspace") output := `{"type":"result","subtype":"success","is_error":false,"result":"","session_id":"abc"}` resp, err := p.parseClaudeCliResponse(output) if err != nil { t.Fatalf("error = %v", err) } if resp.Content != "" { t.Errorf("Content = %q, want empty", resp.Content) } if resp.FinishReason != "stop" { t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") } } func TestParseClaudeCliResponse_IsError(t *testing.T) { p := NewClaudeCliProvider("/workspace") output := `{"type":"result","subtype":"error","is_error":true,"result":"Something went wrong","session_id":"abc"}` _, err := p.parseClaudeCliResponse(output) if err == nil { t.Fatal("expected error when is_error=true") } if !strings.Contains(err.Error(), "Something went wrong") { t.Errorf("error = %q, want to contain 'Something went wrong'", err.Error()) } } func TestParseClaudeCliResponse_NoUsage(t *testing.T) { p := NewClaudeCliProvider("/workspace") output := `{"type":"result","subtype":"success","is_error":false,"result":"hi","session_id":"s"}` resp, err := p.parseClaudeCliResponse(output) if err != nil { t.Fatalf("error = %v", err) } if resp.Usage != nil { t.Errorf("Usage should be nil when no tokens, got %+v", resp.Usage) } } func TestParseClaudeCliResponse_InvalidJSON(t *testing.T) { p := NewClaudeCliProvider("/workspace") _, err := p.parseClaudeCliResponse("not json") if err == nil { t.Fatal("expected error for invalid JSON") } if !strings.Contains(err.Error(), "failed to parse claude cli response") { t.Errorf("error = %q, want to contain 'failed to parse claude cli response'", err.Error()) } } func TestParseClaudeCliResponse_WithToolCalls(t *testing.T) { p := NewClaudeCliProvider("/workspace") output := `{"type":"result","subtype":"success","is_error":false,"result":"Let me check.\n{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"location\\\":\\\"Tokyo\\\"}\"}}]}","session_id":"abc123","total_cost_usd":0.01}` resp, err := p.parseClaudeCliResponse(output) if err != nil { t.Fatalf("error = %v", err) } if resp.FinishReason != "tool_calls" { t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls") } if len(resp.ToolCalls) != 1 { t.Fatalf("ToolCalls = %d, want 1", len(resp.ToolCalls)) } tc := resp.ToolCalls[0] if tc.Name != "get_weather" { t.Errorf("Name = %q, want %q", tc.Name, "get_weather") } if tc.Function == nil { t.Fatal("Function is nil") } if tc.Function.Name != "get_weather" { t.Errorf("Function.Name = %q, want %q", tc.Function.Name, "get_weather") } if tc.Arguments["location"] != "Tokyo" { t.Errorf("Arguments[location] = %v, want Tokyo", tc.Arguments["location"]) } if strings.Contains(resp.Content, "tool_calls") { t.Errorf("Content should not contain tool_calls JSON, got %q", resp.Content) } if resp.Content != "Let me check." { t.Errorf("Content = %q, want %q", resp.Content, "Let me check.") } } func TestParseClaudeCliResponse_WhitespaceResult(t *testing.T) { p := NewClaudeCliProvider("/workspace") output := `{"type":"result","subtype":"success","is_error":false,"result":" hello \n ","session_id":"s"}` resp, err := p.parseClaudeCliResponse(output) if err != nil { t.Fatalf("error = %v", err) } if resp.Content != "hello" { t.Errorf("Content = %q, want %q (should be trimmed)", resp.Content, "hello") } } // --- extractToolCalls tests --- func TestExtractToolCalls_NoToolCalls(t *testing.T) { p := NewClaudeCliProvider("/workspace") got := p.extractToolCalls("Just a regular response.") if len(got) != 0 { t.Errorf("extractToolCalls() = %d, want 0", len(got)) } } func TestExtractToolCalls_WithToolCalls(t *testing.T) { p := NewClaudeCliProvider("/workspace") text := `Here's the result: {"tool_calls":[{"id":"call_1","type":"function","function":{"name":"test","arguments":"{}"}}]}` got := p.extractToolCalls(text) if len(got) != 1 { t.Fatalf("extractToolCalls() = %d, want 1", len(got)) } if got[0].ID != "call_1" { t.Errorf("ID = %q, want %q", got[0].ID, "call_1") } if got[0].Name != "test" { t.Errorf("Name = %q, want %q", got[0].Name, "test") } if got[0].Type != "function" { t.Errorf("Type = %q, want %q", got[0].Type, "function") } } func TestExtractToolCalls_InvalidJSON(t *testing.T) { p := NewClaudeCliProvider("/workspace") got := p.extractToolCalls(`{"tool_calls":invalid}`) if len(got) != 0 { t.Errorf("extractToolCalls() with invalid JSON = %d, want 0", len(got)) } } func TestExtractToolCalls_MultipleToolCalls(t *testing.T) { p := NewClaudeCliProvider("/workspace") text := `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"/tmp/test\"}"}},{"id":"call_2","type":"function","function":{"name":"write_file","arguments":"{\"path\":\"/tmp/out\",\"content\":\"hello\"}"}}]}` got := p.extractToolCalls(text) if len(got) != 2 { t.Fatalf("extractToolCalls() = %d, want 2", len(got)) } if got[0].Name != "read_file" { t.Errorf("[0].Name = %q, want %q", got[0].Name, "read_file") } if got[1].Name != "write_file" { t.Errorf("[1].Name = %q, want %q", got[1].Name, "write_file") } // Verify arguments were parsed if got[0].Arguments["path"] != "/tmp/test" { t.Errorf("[0].Arguments[path] = %v, want /tmp/test", got[0].Arguments["path"]) } if got[1].Arguments["content"] != "hello" { t.Errorf("[1].Arguments[content] = %v, want hello", got[1].Arguments["content"]) } } func TestExtractToolCalls_UnmatchedBrace(t *testing.T) { p := NewClaudeCliProvider("/workspace") got := p.extractToolCalls(`{"tool_calls":[{"id":"call_1"`) if len(got) != 0 { t.Errorf("extractToolCalls() with unmatched brace = %d, want 0", len(got)) } } func TestExtractToolCalls_ToolCallArgumentsParsing(t *testing.T) { p := NewClaudeCliProvider("/workspace") text := `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"fn","arguments":"{\"num\":42,\"flag\":true,\"name\":\"test\"}"}}]}` got := p.extractToolCalls(text) if len(got) != 1 { t.Fatalf("len = %d, want 1", len(got)) } // Verify different argument types if got[0].Arguments["num"] != float64(42) { t.Errorf("Arguments[num] = %v (%T), want 42", got[0].Arguments["num"], got[0].Arguments["num"]) } if got[0].Arguments["flag"] != true { t.Errorf("Arguments[flag] = %v, want true", got[0].Arguments["flag"]) } if got[0].Arguments["name"] != "test" { t.Errorf("Arguments[name] = %v, want test", got[0].Arguments["name"]) } // Verify raw arguments string is preserved in FunctionCall if got[0].Function.Arguments == "" { t.Error("Function.Arguments should contain raw JSON string") } } // --- stripToolCallsJSON tests --- func TestStripToolCallsJSON(t *testing.T) { p := NewClaudeCliProvider("/workspace") text := `Let me check the weather. {"tool_calls":[{"id":"call_1","type":"function","function":{"name":"test","arguments":"{}"}}]} Done.` got := p.stripToolCallsJSON(text) if strings.Contains(got, "tool_calls") { t.Errorf("should remove tool_calls JSON, got %q", got) } if !strings.Contains(got, "Let me check the weather.") { t.Errorf("should keep text before, got %q", got) } if !strings.Contains(got, "Done.") { t.Errorf("should keep text after, got %q", got) } } func TestStripToolCallsJSON_NoToolCalls(t *testing.T) { p := NewClaudeCliProvider("/workspace") text := "Just regular text." got := p.stripToolCallsJSON(text) if got != text { t.Errorf("stripToolCallsJSON() = %q, want %q", got, text) } } func TestStripToolCallsJSON_OnlyToolCalls(t *testing.T) { p := NewClaudeCliProvider("/workspace") text := `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"fn","arguments":"{}"}}]}` got := p.stripToolCallsJSON(text) if got != "" { t.Errorf("stripToolCallsJSON() = %q, want empty", got) } } // --- findMatchingBrace tests --- func TestFindMatchingBrace(t *testing.T) { tests := []struct { text string pos int want int }{ {`{"a":1}`, 0, 7}, {`{"a":{"b":2}}`, 0, 13}, {`text {"a":1} more`, 5, 12}, {`{unclosed`, 0, 0}, // no match returns pos {`{}`, 0, 2}, // empty object {`{{{}}}`, 0, 6}, // deeply nested {`{"a":"b{c}d"}`, 0, 13}, // braces in strings (simplified matcher) } for _, tt := range tests { got := findMatchingBrace(tt.text, tt.pos) if got != tt.want { t.Errorf("findMatchingBrace(%q, %d) = %d, want %d", tt.text, tt.pos, got, tt.want) } } } ================================================ FILE: pkg/providers/claude_provider.go ================================================ package providers import ( "context" "fmt" anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic" ) type ClaudeProvider struct { delegate *anthropicprovider.Provider } func NewClaudeProvider(token string) *ClaudeProvider { return &ClaudeProvider{ delegate: anthropicprovider.NewProvider(token), } } func NewClaudeProviderWithBaseURL(token, apiBase string) *ClaudeProvider { return &ClaudeProvider{ delegate: anthropicprovider.NewProviderWithBaseURL(token, apiBase), } } func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string, error)) *ClaudeProvider { return &ClaudeProvider{ delegate: anthropicprovider.NewProviderWithTokenSource(token, tokenSource), } } func NewClaudeProviderWithTokenSourceAndBaseURL( token string, tokenSource func() (string, error), apiBase string, ) *ClaudeProvider { return &ClaudeProvider{ delegate: anthropicprovider.NewProviderWithTokenSourceAndBaseURL(token, tokenSource, apiBase), } } func newClaudeProviderWithDelegate(delegate *anthropicprovider.Provider) *ClaudeProvider { return &ClaudeProvider{delegate: delegate} } func (p *ClaudeProvider) Chat( ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, ) (*LLMResponse, error) { resp, err := p.delegate.Chat(ctx, messages, tools, model, options) if err != nil { return nil, err } return resp, nil } func (p *ClaudeProvider) GetDefaultModel() string { return p.delegate.GetDefaultModel() } func createClaudeTokenSource() func() (string, error) { return func() (string, error) { cred, err := getCredential("anthropic") if err != nil { return "", fmt.Errorf("loading auth credentials: %w", err) } if cred == nil { return "", fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic") } return cred.AccessToken, nil } } ================================================ FILE: pkg/providers/claude_provider_test.go ================================================ package providers import ( "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/anthropics/anthropic-sdk-go" anthropicoption "github.com/anthropics/anthropic-sdk-go/option" anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic" ) func TestClaudeProvider_ChatRoundTrip(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/messages" { http.Error(w, "not found", http.StatusNotFound) return } if r.Header.Get("Authorization") != "Bearer test-token" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var reqBody map[string]any json.NewDecoder(r.Body).Decode(&reqBody) resp := map[string]any{ "id": "msg_test", "type": "message", "role": "assistant", "model": reqBody["model"], "stop_reason": "end_turn", "content": []map[string]any{ {"type": "text", "text": "Hello! How can I help you?"}, }, "usage": map[string]any{ "input_tokens": 15, "output_tokens": 8, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) })) defer server.Close() delegate := anthropicprovider.NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token")) provider := newClaudeProviderWithDelegate(delegate) messages := []Message{{Role: "user", Content: "Hello"}} resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]any{"max_tokens": 1024}) if err != nil { t.Fatalf("Chat() error: %v", err) } if resp.Content != "Hello! How can I help you?" { t.Errorf("Content = %q, want %q", resp.Content, "Hello! How can I help you?") } if resp.FinishReason != "stop" { t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") } if resp.Usage.PromptTokens != 15 { t.Errorf("PromptTokens = %d, want 15", resp.Usage.PromptTokens) } } func TestClaudeProvider_GetDefaultModel(t *testing.T) { p := NewClaudeProvider("test-token") if got := p.GetDefaultModel(); got != "claude-sonnet-4.6" { t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-sonnet-4.6") } } func createAnthropicTestClient(baseURL, token string) *anthropic.Client { c := anthropic.NewClient( anthropicoption.WithAuthToken(token), anthropicoption.WithBaseURL(baseURL), ) return &c } ================================================ FILE: pkg/providers/codex_cli_credentials.go ================================================ package providers import ( "encoding/json" "fmt" "os" "path/filepath" "time" ) // CodexHomeEnvVar is the environment variable that overrides the Codex CLI // home directory when resolving the codex auth.json credentials file. // Default: ~/.codex const CodexHomeEnvVar = "CODEX_HOME" // CodexCliAuth represents the ~/.codex/auth.json file structure. type CodexCliAuth struct { Tokens struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` AccountID string `json:"account_id"` } `json:"tokens"` } // ReadCodexCliCredentials reads OAuth tokens from the Codex CLI's auth.json file. // Expiry is estimated as file modification time + 1 hour (same approach as moltbot). func ReadCodexCliCredentials() (accessToken, accountID string, expiresAt time.Time, err error) { authPath, err := resolveCodexAuthPath() if err != nil { return "", "", time.Time{}, err } data, err := os.ReadFile(authPath) if err != nil { return "", "", time.Time{}, fmt.Errorf("reading %s: %w", authPath, err) } var auth CodexCliAuth if err = json.Unmarshal(data, &auth); err != nil { return "", "", time.Time{}, fmt.Errorf("parsing %s: %w", authPath, err) } if auth.Tokens.AccessToken == "" { return "", "", time.Time{}, fmt.Errorf("no access_token in %s", authPath) } stat, err := os.Stat(authPath) if err != nil { expiresAt = time.Now().Add(time.Hour) } else { expiresAt = stat.ModTime().Add(time.Hour) } return auth.Tokens.AccessToken, auth.Tokens.AccountID, expiresAt, nil } // CreateCodexCliTokenSource creates a token source that reads from ~/.codex/auth.json. // This allows the existing CodexProvider to reuse Codex CLI credentials. func CreateCodexCliTokenSource() func() (string, string, error) { return func() (string, string, error) { token, accountID, expiresAt, err := ReadCodexCliCredentials() if err != nil { return "", "", fmt.Errorf("reading codex cli credentials: %w", err) } if time.Now().After(expiresAt) { return "", "", fmt.Errorf( "codex cli credentials expired (auth.json last modified > 1h ago). Run: codex login", ) } return token, accountID, nil } } func resolveCodexAuthPath() (string, error) { codexHome := os.Getenv(CodexHomeEnvVar) if codexHome == "" { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("getting home dir: %w", err) } codexHome = filepath.Join(home, ".codex") } return filepath.Join(codexHome, "auth.json"), nil } ================================================ FILE: pkg/providers/codex_cli_credentials_test.go ================================================ package providers import ( "os" "path/filepath" "testing" "time" ) func TestReadCodexCliCredentials_Valid(t *testing.T) { tmpDir := t.TempDir() authPath := filepath.Join(tmpDir, "auth.json") authJSON := `{ "tokens": { "access_token": "test-access-token", "refresh_token": "test-refresh-token", "account_id": "org-test123" } }` if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil { t.Fatal(err) } t.Setenv("CODEX_HOME", tmpDir) token, accountID, expiresAt, err := ReadCodexCliCredentials() if err != nil { t.Fatalf("ReadCodexCliCredentials() error: %v", err) } if token != "test-access-token" { t.Errorf("token = %q, want %q", token, "test-access-token") } if accountID != "org-test123" { t.Errorf("accountID = %q, want %q", accountID, "org-test123") } // Expiry should be within ~1 hour from now (file was just written) if expiresAt.Before(time.Now()) { t.Errorf("expiresAt = %v, should be in the future", expiresAt) } if expiresAt.After(time.Now().Add(2 * time.Hour)) { t.Errorf("expiresAt = %v, should be within ~1 hour", expiresAt) } } // readCodexCliCredentialsErr calls ReadCodexCliCredentials and returns only the // error, for tests that only need to assert on failure. func readCodexCliCredentialsErr() error { _, _, _, err := ReadCodexCliCredentials() //nolint:dogsled return err } func TestReadCodexCliCredentials_MissingFile(t *testing.T) { tmpDir := t.TempDir() t.Setenv("CODEX_HOME", tmpDir) if err := readCodexCliCredentialsErr(); err == nil { t.Fatal("expected error for missing auth.json") } } func TestReadCodexCliCredentials_EmptyToken(t *testing.T) { tmpDir := t.TempDir() authPath := filepath.Join(tmpDir, "auth.json") authJSON := `{"tokens": {"access_token": "", "refresh_token": "r", "account_id": "a"}}` if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil { t.Fatal(err) } t.Setenv("CODEX_HOME", tmpDir) if err := readCodexCliCredentialsErr(); err == nil { t.Fatal("expected error for empty access_token") } } func TestReadCodexCliCredentials_InvalidJSON(t *testing.T) { tmpDir := t.TempDir() authPath := filepath.Join(tmpDir, "auth.json") if err := os.WriteFile(authPath, []byte("not json"), 0o600); err != nil { t.Fatal(err) } t.Setenv("CODEX_HOME", tmpDir) if err := readCodexCliCredentialsErr(); err == nil { t.Fatal("expected error for invalid JSON") } } func TestReadCodexCliCredentials_NoAccountID(t *testing.T) { tmpDir := t.TempDir() authPath := filepath.Join(tmpDir, "auth.json") authJSON := `{"tokens": {"access_token": "tok123", "refresh_token": "ref456"}}` if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil { t.Fatal(err) } t.Setenv("CODEX_HOME", tmpDir) token, accountID, _, err := ReadCodexCliCredentials() if err != nil { t.Fatalf("unexpected error: %v", err) } if token != "tok123" { t.Errorf("token = %q, want %q", token, "tok123") } if accountID != "" { t.Errorf("accountID = %q, want empty", accountID) } } func TestReadCodexCliCredentials_CodexHomeEnv(t *testing.T) { tmpDir := t.TempDir() customDir := filepath.Join(tmpDir, "custom-codex") if err := os.MkdirAll(customDir, 0o755); err != nil { t.Fatal(err) } authJSON := `{"tokens": {"access_token": "custom-token", "refresh_token": "r"}}` if err := os.WriteFile(filepath.Join(customDir, "auth.json"), []byte(authJSON), 0o600); err != nil { t.Fatal(err) } t.Setenv("CODEX_HOME", customDir) token, _, _, err := ReadCodexCliCredentials() if err != nil { t.Fatalf("unexpected error: %v", err) } if token != "custom-token" { t.Errorf("token = %q, want %q", token, "custom-token") } } func TestCreateCodexCliTokenSource_Valid(t *testing.T) { tmpDir := t.TempDir() authPath := filepath.Join(tmpDir, "auth.json") authJSON := `{"tokens": {"access_token": "fresh-token", "refresh_token": "r", "account_id": "acc"}}` if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil { t.Fatal(err) } t.Setenv("CODEX_HOME", tmpDir) source := CreateCodexCliTokenSource() token, accountID, err := source() if err != nil { t.Fatalf("token source error: %v", err) } if token != "fresh-token" { t.Errorf("token = %q, want %q", token, "fresh-token") } if accountID != "acc" { t.Errorf("accountID = %q, want %q", accountID, "acc") } } func TestCreateCodexCliTokenSource_Expired(t *testing.T) { tmpDir := t.TempDir() authPath := filepath.Join(tmpDir, "auth.json") authJSON := `{"tokens": {"access_token": "old-token", "refresh_token": "r"}}` if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil { t.Fatal(err) } // Set file modification time to 2 hours ago oldTime := time.Now().Add(-2 * time.Hour) if err := os.Chtimes(authPath, oldTime, oldTime); err != nil { t.Fatal(err) } t.Setenv("CODEX_HOME", tmpDir) source := CreateCodexCliTokenSource() _, _, err := source() if err == nil { t.Fatal("expected error for expired credentials") } } ================================================ FILE: pkg/providers/codex_cli_provider.go ================================================ package providers import ( "bufio" "bytes" "context" "encoding/json" "fmt" "os/exec" "strings" ) // CodexCliProvider implements LLMProvider by wrapping the codex CLI as a subprocess. type CodexCliProvider struct { command string workspace string } // NewCodexCliProvider creates a new Codex CLI provider. func NewCodexCliProvider(workspace string) *CodexCliProvider { return &CodexCliProvider{ command: "codex", workspace: workspace, } } // Chat implements LLMProvider.Chat by executing the codex CLI in non-interactive mode. func (p *CodexCliProvider) Chat( ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, ) (*LLMResponse, error) { if p.command == "" { return nil, fmt.Errorf("codex command not configured") } prompt := p.buildPrompt(messages, tools) args := []string{ "exec", "--json", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "--color", "never", } if model != "" && model != "codex-cli" { args = append(args, "-m", model) } if p.workspace != "" { args = append(args, "-C", p.workspace) } args = append(args, "-") // read prompt from stdin cmd := exec.CommandContext(ctx, p.command, args...) cmd.Stdin = bytes.NewReader([]byte(prompt)) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() // Parse JSONL from stdout even if exit code is non-zero, // because codex writes diagnostic noise to stderr (e.g. rollout errors) // but still produces valid JSONL output. if stdoutStr := stdout.String(); stdoutStr != "" { resp, parseErr := p.parseJSONLEvents(stdoutStr) if parseErr == nil && resp != nil && (resp.Content != "" || len(resp.ToolCalls) > 0) { return resp, nil } } if err != nil { if ctx.Err() == context.Canceled { return nil, ctx.Err() } if stderrStr := stderr.String(); stderrStr != "" { return nil, fmt.Errorf("codex cli error: %s", stderrStr) } return nil, fmt.Errorf("codex cli error: %w", err) } return p.parseJSONLEvents(stdout.String()) } // GetDefaultModel returns the default model identifier. func (p *CodexCliProvider) GetDefaultModel() string { return "codex-cli" } // buildPrompt converts messages to a prompt string for the Codex CLI. // System messages are prepended as instructions since Codex CLI has no --system-prompt flag. func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinition) string { var systemParts []string var conversationParts []string for _, msg := range messages { switch msg.Role { case "system": systemParts = append(systemParts, msg.Content) case "user": conversationParts = append(conversationParts, msg.Content) case "assistant": conversationParts = append(conversationParts, "Assistant: "+msg.Content) case "tool": conversationParts = append(conversationParts, fmt.Sprintf("[Tool Result for %s]: %s", msg.ToolCallID, msg.Content)) } } var sb strings.Builder if len(systemParts) > 0 { sb.WriteString("## System Instructions\n\n") sb.WriteString(strings.Join(systemParts, "\n\n")) sb.WriteString("\n\n## Task\n\n") } if len(tools) > 0 { sb.WriteString(buildCLIToolsPrompt(tools)) sb.WriteString("\n\n") } // Simplify single user message (no prefix) if len(conversationParts) == 1 && len(systemParts) == 0 && len(tools) == 0 { return conversationParts[0] } sb.WriteString(strings.Join(conversationParts, "\n")) return sb.String() } // codexEvent represents a single JSONL event from `codex exec --json`. type codexEvent struct { Type string `json:"type"` ThreadID string `json:"thread_id,omitempty"` Message string `json:"message,omitempty"` Item *codexEventItem `json:"item,omitempty"` Usage *codexUsage `json:"usage,omitempty"` Error *codexEventErr `json:"error,omitempty"` } type codexEventItem struct { ID string `json:"id"` Type string `json:"type"` Text string `json:"text,omitempty"` Command string `json:"command,omitempty"` Status string `json:"status,omitempty"` ExitCode *int `json:"exit_code,omitempty"` Output string `json:"output,omitempty"` } type codexUsage struct { InputTokens int `json:"input_tokens"` CachedInputTokens int `json:"cached_input_tokens"` OutputTokens int `json:"output_tokens"` } type codexEventErr struct { Message string `json:"message"` } // parseJSONLEvents processes the JSONL output from codex exec --json. func (p *CodexCliProvider) parseJSONLEvents(output string) (*LLMResponse, error) { var contentParts []string var usage *UsageInfo var lastError string scanner := bufio.NewScanner(strings.NewReader(output)) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } var event codexEvent if err := json.Unmarshal([]byte(line), &event); err != nil { continue // skip malformed lines } switch event.Type { case "item.completed": if event.Item != nil && event.Item.Type == "agent_message" && event.Item.Text != "" { contentParts = append(contentParts, event.Item.Text) } case "turn.completed": if event.Usage != nil { promptTokens := event.Usage.InputTokens + event.Usage.CachedInputTokens usage = &UsageInfo{ PromptTokens: promptTokens, CompletionTokens: event.Usage.OutputTokens, TotalTokens: promptTokens + event.Usage.OutputTokens, } } case "error": lastError = event.Message case "turn.failed": if event.Error != nil { lastError = event.Error.Message } } } if lastError != "" && len(contentParts) == 0 { return nil, fmt.Errorf("codex cli: %s", lastError) } content := strings.Join(contentParts, "\n") // Extract tool calls from response text (same pattern as ClaudeCliProvider) toolCalls := extractToolCallsFromText(content) finishReason := "stop" if len(toolCalls) > 0 { finishReason = "tool_calls" content = stripToolCallsFromText(content) } return &LLMResponse{ Content: strings.TrimSpace(content), ToolCalls: toolCalls, FinishReason: finishReason, Usage: usage, }, nil } ================================================ FILE: pkg/providers/codex_cli_provider_integration_test.go ================================================ //go:build integration package providers import ( "context" exec "os/exec" "strings" "testing" "time" ) // TestIntegration_RealCodexCLI tests the CodexCliProvider with a real codex CLI. // Run with: go test -tags=integration ./pkg/providers/... func TestIntegration_RealCodexCLI(t *testing.T) { path, err := exec.LookPath("codex") if err != nil { t.Skip("codex CLI not found in PATH, skipping integration test") } t.Logf("Using codex CLI at: %s", path) p := NewCodexCliProvider(t.TempDir()) ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() resp, err := p.Chat(ctx, []Message{ {Role: "user", Content: "Respond with only the word 'pong'. Nothing else."}, }, nil, "", nil) if err != nil { t.Fatalf("Chat() with real CLI error = %v", err) } if resp.Content == "" { t.Error("Content is empty") } if resp.FinishReason != "stop" { t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") } if resp.Usage != nil { t.Logf("Usage: prompt=%d, completion=%d, total=%d", resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.TotalTokens) } t.Logf("Response content: %q", resp.Content) if !strings.Contains(strings.ToLower(resp.Content), "pong") { t.Errorf("Content = %q, expected to contain 'pong'", resp.Content) } } func TestIntegration_RealCodexCLI_WithSystemPrompt(t *testing.T) { if _, err := exec.LookPath("codex"); err != nil { t.Skip("codex CLI not found in PATH") } p := NewCodexCliProvider(t.TempDir()) ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() resp, err := p.Chat(ctx, []Message{ {Role: "system", Content: "You are a calculator. Only respond with numbers. No text."}, {Role: "user", Content: "What is 2+2?"}, }, nil, "", nil) if err != nil { t.Fatalf("Chat() error = %v", err) } t.Logf("Response: %q", resp.Content) if !strings.Contains(resp.Content, "4") { t.Errorf("Content = %q, expected to contain '4'", resp.Content) } } func TestIntegration_RealCodexCLI_ParsesRealJSONL(t *testing.T) { if _, err := exec.LookPath("codex"); err != nil { t.Skip("codex CLI not found in PATH") } // Run codex directly and verify our parser handles real output cmd := exec.Command("codex", "exec", "--json", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "--color", "never", "-C", t.TempDir(), "-") cmd.Stdin = strings.NewReader("Say hi") output, err := cmd.Output() if err != nil { // codex may write diagnostic noise to stderr but still produce valid output if len(output) == 0 { t.Fatalf("codex CLI failed: %v", err) } } t.Logf("Raw CLI output (first 500 chars): %s", string(output[:min(len(output), 500)])) // Verify our parser can handle real output p := NewCodexCliProvider("") resp, err := p.parseJSONLEvents(string(output)) if err != nil { t.Fatalf("parseJSONLEvents() failed on real CLI output: %v", err) } if resp.Content == "" { t.Error("parsed Content is empty") } if resp.FinishReason != "stop" { t.Errorf("FinishReason = %q, want stop", resp.FinishReason) } t.Logf("Parsed: content=%q, finish=%s, usage=%+v", resp.Content, resp.FinishReason, resp.Usage) } ================================================ FILE: pkg/providers/codex_cli_provider_test.go ================================================ package providers import ( "context" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "testing" ) // --- JSONL Event Parsing Tests --- func TestParseJSONLEvents_AgentMessage(t *testing.T) { p := &CodexCliProvider{} events := `{"type":"thread.started","thread_id":"abc-123"} {"type":"turn.started"} {"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Hello from Codex!"}} {"type":"turn.completed","usage":{"input_tokens":100,"cached_input_tokens":50,"output_tokens":20}}` resp, err := p.parseJSONLEvents(events) if err != nil { t.Fatalf("parseJSONLEvents() error: %v", err) } if resp.Content != "Hello from Codex!" { t.Errorf("Content = %q, want %q", resp.Content, "Hello from Codex!") } if resp.FinishReason != "stop" { t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") } if resp.Usage == nil { t.Fatal("Usage should not be nil") } if resp.Usage.PromptTokens != 150 { t.Errorf("PromptTokens = %d, want 150", resp.Usage.PromptTokens) } if resp.Usage.CompletionTokens != 20 { t.Errorf("CompletionTokens = %d, want 20", resp.Usage.CompletionTokens) } if resp.Usage.TotalTokens != 170 { t.Errorf("TotalTokens = %d, want 170", resp.Usage.TotalTokens) } if len(resp.ToolCalls) != 0 { t.Errorf("ToolCalls should be empty, got %d", len(resp.ToolCalls)) } } func TestParseJSONLEvents_ToolCallExtraction(t *testing.T) { p := &CodexCliProvider{} toolCallText := `Let me read that file. {"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"/tmp/test.txt\"}"}}]}` // Build valid JSONL by marshaling the event item := codexEvent{ Type: "item.completed", Item: &codexEventItem{ID: "item_1", Type: "agent_message", Text: toolCallText}, } itemJSON, _ := json.Marshal(item) usageEvt := `{"type":"turn.completed","usage":{"input_tokens":50,"cached_input_tokens":0,"output_tokens":20}}` events := `{"type":"turn.started"}` + "\n" + string(itemJSON) + "\n" + usageEvt resp, err := p.parseJSONLEvents(events) if err != nil { t.Fatalf("parseJSONLEvents() error: %v", err) } if resp.FinishReason != "tool_calls" { t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls") } if len(resp.ToolCalls) != 1 { t.Fatalf("ToolCalls count = %d, want 1", len(resp.ToolCalls)) } if resp.ToolCalls[0].Name != "read_file" { t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "read_file") } if resp.ToolCalls[0].ID != "call_1" { t.Errorf("ToolCalls[0].ID = %q, want %q", resp.ToolCalls[0].ID, "call_1") } if resp.ToolCalls[0].Function.Arguments != `{"path":"/tmp/test.txt"}` { t.Errorf("ToolCalls[0].Function.Arguments = %q", resp.ToolCalls[0].Function.Arguments) } // Content should have the tool call JSON stripped if strings.Contains(resp.Content, "tool_calls") { t.Errorf("Content should not contain tool_calls JSON, got: %q", resp.Content) } } func TestParseJSONLEvents_MultipleToolCalls(t *testing.T) { p := &CodexCliProvider{} toolCallText := `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"a.txt\"}"}},{"id":"call_2","type":"function","function":{"name":"write_file","arguments":"{\"path\":\"b.txt\",\"content\":\"hello\"}"}}]}` item := codexEvent{ Type: "item.completed", Item: &codexEventItem{ID: "item_1", Type: "agent_message", Text: toolCallText}, } itemJSON, _ := json.Marshal(item) events := `{"type":"turn.started"}` + "\n" + string(itemJSON) + "\n" + `{"type":"turn.completed"}` resp, err := p.parseJSONLEvents(events) if err != nil { t.Fatalf("parseJSONLEvents() error: %v", err) } if len(resp.ToolCalls) != 2 { t.Fatalf("ToolCalls count = %d, want 2", len(resp.ToolCalls)) } if resp.ToolCalls[0].Name != "read_file" { t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "read_file") } if resp.ToolCalls[1].Name != "write_file" { t.Errorf("ToolCalls[1].Name = %q, want %q", resp.ToolCalls[1].Name, "write_file") } if resp.FinishReason != "tool_calls" { t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls") } } func TestParseJSONLEvents_MultipleMessages(t *testing.T) { p := &CodexCliProvider{} events := `{"type":"turn.started"} {"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"First part."}} {"type":"item.completed","item":{"id":"item_2","type":"command_execution","command":"ls","status":"completed"}} {"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"Second part."}} {"type":"turn.completed"}` resp, err := p.parseJSONLEvents(events) if err != nil { t.Fatalf("parseJSONLEvents() error: %v", err) } if resp.Content != "First part.\nSecond part." { t.Errorf("Content = %q, want %q", resp.Content, "First part.\nSecond part.") } } func TestParseJSONLEvents_ErrorEvent(t *testing.T) { p := &CodexCliProvider{} events := `{"type":"thread.started","thread_id":"abc"} {"type":"turn.started"} {"type":"error","message":"token expired"} {"type":"turn.failed","error":{"message":"token expired"}}` _, err := p.parseJSONLEvents(events) if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "token expired") { t.Errorf("error = %q, want to contain 'token expired'", err.Error()) } } func TestParseJSONLEvents_TurnFailed(t *testing.T) { p := &CodexCliProvider{} events := `{"type":"turn.started"} {"type":"turn.failed","error":{"message":"rate limit exceeded"}}` _, err := p.parseJSONLEvents(events) if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "rate limit exceeded") { t.Errorf("error = %q, want to contain 'rate limit exceeded'", err.Error()) } } func TestParseJSONLEvents_ErrorWithContent(t *testing.T) { p := &CodexCliProvider{} // If there's an error but also content, return the content (partial success) events := `{"type":"turn.started"} {"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Partial result."}} {"type":"error","message":"connection reset"} {"type":"turn.failed","error":{"message":"connection reset"}}` resp, err := p.parseJSONLEvents(events) if err != nil { t.Fatalf("should not error when content exists: %v", err) } if resp.Content != "Partial result." { t.Errorf("Content = %q, want %q", resp.Content, "Partial result.") } } func TestParseJSONLEvents_EmptyOutput(t *testing.T) { p := &CodexCliProvider{} resp, err := p.parseJSONLEvents("") if err != nil { t.Fatalf("empty output should not error: %v", err) } if resp.Content != "" { t.Errorf("Content = %q, want empty", resp.Content) } } func TestParseJSONLEvents_MalformedLines(t *testing.T) { p := &CodexCliProvider{} events := `not json at all {"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Good line."}} another bad line {"type":"turn.completed","usage":{"input_tokens":10,"output_tokens":5}}` resp, err := p.parseJSONLEvents(events) if err != nil { t.Fatalf("should skip malformed lines: %v", err) } if resp.Content != "Good line." { t.Errorf("Content = %q, want %q", resp.Content, "Good line.") } if resp.Usage == nil || resp.Usage.TotalTokens != 15 { t.Errorf("Usage.TotalTokens = %v, want 15", resp.Usage) } } func TestParseJSONLEvents_CommandExecution(t *testing.T) { p := &CodexCliProvider{} events := `{"type":"turn.started"} {"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","status":"in_progress"}} {"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","status":"completed","exit_code":0,"output":"file1.go\nfile2.go"}} {"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"Found 2 files."}} {"type":"turn.completed"}` resp, err := p.parseJSONLEvents(events) if err != nil { t.Fatalf("parseJSONLEvents() error: %v", err) } // command_execution items should be skipped; only agent_message text is returned if resp.Content != "Found 2 files." { t.Errorf("Content = %q, want %q", resp.Content, "Found 2 files.") } } func TestParseJSONLEvents_NoUsage(t *testing.T) { p := &CodexCliProvider{} events := `{"type":"turn.started"} {"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"No usage info."}} {"type":"turn.completed"}` resp, err := p.parseJSONLEvents(events) if err != nil { t.Fatalf("parseJSONLEvents() error: %v", err) } if resp.Usage != nil { t.Errorf("Usage should be nil when turn.completed has no usage, got %+v", resp.Usage) } } // --- Prompt Building Tests --- func TestBuildPrompt_SystemAsInstructions(t *testing.T) { p := &CodexCliProvider{} messages := []Message{ {Role: "system", Content: "You are helpful."}, {Role: "user", Content: "Hi there"}, } prompt := p.buildPrompt(messages, nil) if !strings.Contains(prompt, "## System Instructions") { t.Error("prompt should contain '## System Instructions'") } if !strings.Contains(prompt, "You are helpful.") { t.Error("prompt should contain system content") } if !strings.Contains(prompt, "## Task") { t.Error("prompt should contain '## Task'") } if !strings.Contains(prompt, "Hi there") { t.Error("prompt should contain user message") } } func TestBuildPrompt_NoSystem(t *testing.T) { p := &CodexCliProvider{} messages := []Message{ {Role: "user", Content: "Just a question"}, } prompt := p.buildPrompt(messages, nil) if strings.Contains(prompt, "## System Instructions") { t.Error("prompt should not contain system instructions header") } if prompt != "Just a question" { t.Errorf("prompt = %q, want %q", prompt, "Just a question") } } func TestBuildPrompt_WithTools(t *testing.T) { p := &CodexCliProvider{} messages := []Message{ {Role: "user", Content: "Get weather"}, } tools := []ToolDefinition{ { Type: "function", Function: ToolFunctionDefinition{ Name: "get_weather", Description: "Get current weather", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "city": map[string]any{"type": "string"}, }, }, }, }, } prompt := p.buildPrompt(messages, tools) if !strings.Contains(prompt, "## Available Tools") { t.Error("prompt should contain tools section") } if !strings.Contains(prompt, "get_weather") { t.Error("prompt should contain tool name") } if !strings.Contains(prompt, "Get current weather") { t.Error("prompt should contain tool description") } } func TestBuildPrompt_MultipleMessages(t *testing.T) { p := &CodexCliProvider{} messages := []Message{ {Role: "user", Content: "Hello"}, {Role: "assistant", Content: "Hi! How can I help?"}, {Role: "user", Content: "Tell me about Go"}, } prompt := p.buildPrompt(messages, nil) if !strings.Contains(prompt, "Hello") { t.Error("prompt should contain first user message") } if !strings.Contains(prompt, "Assistant: Hi! How can I help?") { t.Error("prompt should contain assistant message with prefix") } if !strings.Contains(prompt, "Tell me about Go") { t.Error("prompt should contain second user message") } } func TestBuildPrompt_ToolResults(t *testing.T) { p := &CodexCliProvider{} messages := []Message{ {Role: "user", Content: "Weather?"}, {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"}, } prompt := p.buildPrompt(messages, nil) if !strings.Contains(prompt, "[Tool Result for call_1]") { t.Error("prompt should contain tool result") } if !strings.Contains(prompt, `{"temp": 72}`) { t.Error("prompt should contain tool result content") } } func TestBuildPrompt_SystemAndTools(t *testing.T) { p := &CodexCliProvider{} messages := []Message{ {Role: "system", Content: "Be concise."}, {Role: "user", Content: "Do something"}, } tools := []ToolDefinition{ { Type: "function", Function: ToolFunctionDefinition{ Name: "my_tool", Description: "A tool", }, }, } prompt := p.buildPrompt(messages, tools) // System instructions should come first sysIdx := strings.Index(prompt, "## System Instructions") toolIdx := strings.Index(prompt, "## Available Tools") taskIdx := strings.Index(prompt, "## Task") if sysIdx == -1 || toolIdx == -1 || taskIdx == -1 { t.Fatal("prompt should contain all sections") } if sysIdx >= taskIdx { t.Error("system instructions should come before task") } if taskIdx >= toolIdx { t.Error("task section should come before tools in the output") } } // --- CLI Argument Tests --- func TestCodexCliProvider_GetDefaultModel(t *testing.T) { p := NewCodexCliProvider("") if got := p.GetDefaultModel(); got != "codex-cli" { t.Errorf("GetDefaultModel() = %q, want %q", got, "codex-cli") } } // --- Mock CLI Integration Test --- func createMockCodexCLI(t *testing.T, events []string) string { t.Helper() tmpDir := t.TempDir() scriptPath := filepath.Join(tmpDir, "codex") var sb strings.Builder sb.WriteString("#!/bin/bash\n") for _, event := range events { sb.WriteString(fmt.Sprintf("echo '%s'\n", event)) } if err := os.WriteFile(scriptPath, []byte(sb.String()), 0o755); err != nil { t.Fatal(err) } return scriptPath } func TestCodexCliProvider_MockCLI_Success(t *testing.T) { scriptPath := createMockCodexCLI(t, []string{ `{"type":"thread.started","thread_id":"test-123"}`, `{"type":"turn.started"}`, `{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Mock response from Codex CLI"}}`, `{"type":"turn.completed","usage":{"input_tokens":50,"cached_input_tokens":10,"output_tokens":15}}`, }) p := &CodexCliProvider{ command: scriptPath, workspace: "", } messages := []Message{{Role: "user", Content: "Hello"}} resp, err := p.Chat(context.Background(), messages, nil, "", nil) if err != nil { t.Fatalf("Chat() error: %v", err) } if resp.Content != "Mock response from Codex CLI" { t.Errorf("Content = %q, want %q", resp.Content, "Mock response from Codex CLI") } if resp.Usage == nil { t.Fatal("Usage should not be nil") } if resp.Usage.PromptTokens != 60 { t.Errorf("PromptTokens = %d, want 60", resp.Usage.PromptTokens) } if resp.Usage.CompletionTokens != 15 { t.Errorf("CompletionTokens = %d, want 15", resp.Usage.CompletionTokens) } } func TestCodexCliProvider_MockCLI_Error(t *testing.T) { scriptPath := createMockCodexCLI(t, []string{ `{"type":"thread.started","thread_id":"test-err"}`, `{"type":"turn.started"}`, `{"type":"error","message":"auth token expired"}`, `{"type":"turn.failed","error":{"message":"auth token expired"}}`, }) p := &CodexCliProvider{ command: scriptPath, workspace: "", } messages := []Message{{Role: "user", Content: "Hello"}} _, err := p.Chat(context.Background(), messages, nil, "", nil) if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "auth token expired") { t.Errorf("error = %q, want to contain 'auth token expired'", err.Error()) } } func TestCodexCliProvider_MockCLI_WithModel(t *testing.T) { // Mock script that captures args to verify model flag is passed tmpDir := t.TempDir() scriptPath := filepath.Join(tmpDir, "codex") script := `#!/bin/bash # Write args to a file for verification echo "$@" > "` + filepath.Join(tmpDir, "args.txt") + `" echo '{"type":"item.completed","item":{"id":"1","type":"agent_message","text":"ok"}}' echo '{"type":"turn.completed"}'` if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { t.Fatal(err) } p := &CodexCliProvider{ command: scriptPath, workspace: "/tmp/test-workspace", } messages := []Message{{Role: "user", Content: "test"}} _, err := p.Chat(context.Background(), messages, nil, "gpt-5.3-codex", nil) if err != nil { t.Fatalf("Chat() error: %v", err) } // Verify the args argsData, err := os.ReadFile(filepath.Join(tmpDir, "args.txt")) if err != nil { t.Fatalf("reading args: %v", err) } args := string(argsData) if !strings.Contains(args, "-m gpt-5.3-codex") { t.Errorf("args should contain model flag, got: %s", args) } if !strings.Contains(args, "-C /tmp/test-workspace") { t.Errorf("args should contain workspace flag, got: %s", args) } if !strings.Contains(args, "--json") { t.Errorf("args should contain --json, got: %s", args) } if !strings.Contains(args, "--dangerously-bypass-approvals-and-sandbox") { t.Errorf("args should contain bypass flag, got: %s", args) } } func TestCodexCliProvider_MockCLI_ContextCancel(t *testing.T) { // Script that sleeps forever tmpDir := t.TempDir() scriptPath := filepath.Join(tmpDir, "codex") script := "#!/bin/bash\nsleep 60" if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { t.Fatal(err) } p := &CodexCliProvider{ command: scriptPath, workspace: "", } ctx, cancel := context.WithCancel(context.Background()) cancel() // cancel immediately messages := []Message{{Role: "user", Content: "test"}} _, err := p.Chat(ctx, messages, nil, "", nil) if err == nil { t.Fatal("expected error on canceled context") } } func TestCodexCliProvider_EmptyCommand(t *testing.T) { p := &CodexCliProvider{command: ""} messages := []Message{{Role: "user", Content: "test"}} _, err := p.Chat(context.Background(), messages, nil, "", nil) if err == nil { t.Fatal("expected error for empty command") } } // --- Integration Test (requires real codex CLI with valid auth) --- func TestCodexCliProvider_Integration(t *testing.T) { if os.Getenv("PICOCLAW_INTEGRATION_TESTS") == "" { t.Skip("skipping integration test (set PICOCLAW_INTEGRATION_TESTS=1 to enable)") } // Verify codex is available codexPath, err := exec.LookPath("codex") if err != nil { t.Skip("codex CLI not found in PATH") } p := &CodexCliProvider{ command: codexPath, workspace: "", } messages := []Message{ {Role: "user", Content: "Respond with just the word 'hello' and nothing else."}, } resp, err := p.Chat(context.Background(), messages, nil, "", nil) if err != nil { t.Fatalf("Chat() error: %v", err) } lower := strings.ToLower(strings.TrimSpace(resp.Content)) if !strings.Contains(lower, "hello") { t.Errorf("Content = %q, expected to contain 'hello'", resp.Content) } } ================================================ FILE: pkg/providers/codex_provider.go ================================================ package providers import ( "context" "encoding/json" "errors" "fmt" "strings" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" "github.com/openai/openai-go/v3/responses" "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/logger" ) const ( codexDefaultModel = "gpt-5.3-codex" codexDefaultInstructions = "You are Codex, a coding assistant." ) type CodexProvider struct { client *openai.Client accountID string tokenSource func() (string, string, error) enableWebSearch bool } const defaultCodexInstructions = "You are Codex, a coding assistant." func NewCodexProvider(token, accountID string) *CodexProvider { opts := []option.RequestOption{ option.WithBaseURL("https://chatgpt.com/backend-api/codex"), option.WithAPIKey(token), option.WithHeader("originator", "codex_cli_rs"), option.WithHeader("OpenAI-Beta", "responses=experimental"), } if accountID != "" { opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID)) } client := openai.NewClient(opts...) return &CodexProvider{ client: &client, accountID: accountID, enableWebSearch: true, } } func NewCodexProviderWithTokenSource( token, accountID string, tokenSource func() (string, string, error), ) *CodexProvider { p := NewCodexProvider(token, accountID) p.tokenSource = tokenSource return p } func (p *CodexProvider) Chat( ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, ) (*LLMResponse, error) { var opts []option.RequestOption accountID := p.accountID resolvedModel, fallbackReason := resolveCodexModel(model) if fallbackReason != "" { logger.WarnCF( "provider.codex", "Requested model is not compatible with Codex backend, using fallback", map[string]any{ "requested_model": model, "resolved_model": resolvedModel, "reason": fallbackReason, }, ) } if p.tokenSource != nil { tok, accID, err := p.tokenSource() if err != nil { return nil, fmt.Errorf("refreshing token: %w", err) } opts = append(opts, option.WithAPIKey(tok)) if accID != "" { accountID = accID } } if accountID != "" { opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID)) } else { logger.WarnCF( "provider.codex", "No account id found for Codex request; backend may reject with 400", map[string]any{ "requested_model": model, "resolved_model": resolvedModel, }, ) } // Respect tools.web.prefer_native: only inject native search when the agent // loop requested it (options["native_search"]), so prefer_native: false useNativeSearch := p.enableWebSearch && (options["native_search"] == true) params := buildCodexParams(messages, tools, resolvedModel, options, useNativeSearch) stream := p.client.Responses.NewStreaming(ctx, params, opts...) defer stream.Close() var resp *responses.Response for stream.Next() { evt := stream.Current() if evt.Type == "response.completed" || evt.Type == "response.failed" || evt.Type == "response.incomplete" { evtResp := evt.Response if evtResp.ID != "" { evtRespCopy := evtResp resp = &evtRespCopy } } } err := stream.Err() if err != nil { fields := map[string]any{ "requested_model": model, "resolved_model": resolvedModel, "messages_count": len(messages), "tools_count": len(tools), "account_id_present": accountID != "", "error": err.Error(), } var apiErr *openai.Error if errors.As(err, &apiErr) { fields["status_code"] = apiErr.StatusCode fields["api_type"] = apiErr.Type fields["api_code"] = apiErr.Code fields["api_param"] = apiErr.Param fields["api_message"] = apiErr.Message if apiErr.StatusCode == 400 { fields["hint"] = "verify account id header and model compatibility for codex backend" } if apiErr.Response != nil { fields["request_id"] = apiErr.Response.Header.Get("x-request-id") } } logger.ErrorCF("provider.codex", "Codex API call failed", fields) return nil, fmt.Errorf("codex API call: %w", err) } if resp == nil { fields := map[string]any{ "requested_model": model, "resolved_model": resolvedModel, "messages_count": len(messages), "tools_count": len(tools), "account_id_present": accountID != "", } logger.ErrorCF("provider.codex", "Codex stream ended without completed response event", fields) return nil, fmt.Errorf("codex API call: stream ended without completed response") } return parseCodexResponse(resp), nil } func (p *CodexProvider) GetDefaultModel() string { return codexDefaultModel } func (p *CodexProvider) SupportsNativeSearch() bool { return p.enableWebSearch } func resolveCodexModel(model string) (string, string) { m := strings.ToLower(strings.TrimSpace(model)) if m == "" { return codexDefaultModel, "empty model" } if after, ok := strings.CutPrefix(m, "openai/"); ok { m = after } else if strings.Contains(m, "/") { return codexDefaultModel, "non-openai model namespace" } unsupportedPrefixes := []string{ "glm", "claude", "anthropic", "gemini", "google", "moonshot", "kimi", "qwen", "deepseek", "llama", "meta-llama", "mistral", "grok", "xai", "zhipu", } for _, prefix := range unsupportedPrefixes { if strings.HasPrefix(m, prefix) { return codexDefaultModel, "unsupported model prefix" } } if strings.HasPrefix(m, "gpt-") || strings.HasPrefix(m, "o3") || strings.HasPrefix(m, "o4") { return m, "" } return codexDefaultModel, "unsupported model family" } func buildCodexParams( messages []Message, tools []ToolDefinition, model string, options map[string]any, enableWebSearch bool, ) responses.ResponseNewParams { var inputItems responses.ResponseInputParam var instructions string for _, msg := range messages { switch msg.Role { case "system": // Use the full concatenated system prompt (static + dynamic + summary) // as instructions. This keeps behavior consistent with Anthropic and // OpenAI-compat adapters where the complete system context lives in // one place. Prefix caching is handled by prompt_cache_key below, // not by splitting content across instructions vs input messages. instructions = msg.Content case "user": if msg.ToolCallID != "" { inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{ CallID: msg.ToolCallID, Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{ OfString: openai.Opt(msg.Content), }, }, }) } else { inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ OfMessage: &responses.EasyInputMessageParam{ Role: responses.EasyInputMessageRoleUser, Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)}, }, }) } case "assistant": if len(msg.ToolCalls) > 0 { if msg.Content != "" { inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ OfMessage: &responses.EasyInputMessageParam{ Role: responses.EasyInputMessageRoleAssistant, Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)}, }, }) } for _, tc := range msg.ToolCalls { name, args, ok := resolveCodexToolCall(tc) if !ok { logger.WarnCF("provider.codex", "Skipping invalid tool call in history", map[string]any{ "call_id": tc.ID, }) continue } inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ OfFunctionCall: &responses.ResponseFunctionToolCallParam{ CallID: tc.ID, Name: name, Arguments: args, }, }) } } else { inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ OfMessage: &responses.EasyInputMessageParam{ Role: responses.EasyInputMessageRoleAssistant, Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)}, }, }) } case "tool": inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{ CallID: msg.ToolCallID, Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{ OfString: openai.Opt(msg.Content), }, }, }) } } params := responses.ResponseNewParams{ Model: model, Input: responses.ResponseNewParamsInputUnion{ OfInputItemList: inputItems, }, Instructions: openai.Opt(instructions), Store: openai.Opt(false), } if instructions != "" { params.Instructions = openai.Opt(instructions) } else { // ChatGPT Codex backend requires instructions to be present. params.Instructions = openai.Opt(defaultCodexInstructions) } // Prompt caching: pass a stable cache key so OpenAI can bucket requests // and reuse prefix KV cache across calls with the same key. // See: https://platform.openai.com/docs/guides/prompt-caching if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" { params.PromptCacheKey = openai.Opt(cacheKey) } if len(tools) > 0 || enableWebSearch { params.Tools = translateToolsForCodex(tools, enableWebSearch) } return params } func resolveCodexToolCall(tc ToolCall) (name string, arguments string, ok bool) { name = tc.Name if name == "" && tc.Function != nil { name = tc.Function.Name } if name == "" { return "", "", false } if len(tc.Arguments) > 0 { argsJSON, err := json.Marshal(tc.Arguments) if err != nil { return "", "", false } return name, string(argsJSON), true } if tc.Function != nil && tc.Function.Arguments != "" { return name, tc.Function.Arguments, true } return name, "{}", true } func translateToolsForCodex(tools []ToolDefinition, enableWebSearch bool) []responses.ToolUnionParam { capHint := len(tools) if enableWebSearch { capHint++ } result := make([]responses.ToolUnionParam, 0, capHint) for _, t := range tools { if t.Type != "function" { continue } if enableWebSearch && strings.EqualFold(t.Function.Name, "web_search") { continue } ft := responses.FunctionToolParam{ Name: t.Function.Name, Parameters: t.Function.Parameters, Strict: openai.Opt(false), } if t.Function.Description != "" { ft.Description = openai.Opt(t.Function.Description) } result = append(result, responses.ToolUnionParam{OfFunction: &ft}) } if enableWebSearch { result = append(result, responses.ToolParamOfWebSearch(responses.WebSearchToolTypeWebSearch)) } return result } func parseCodexResponse(resp *responses.Response) *LLMResponse { var content strings.Builder var toolCalls []ToolCall for _, item := range resp.Output { switch item.Type { case "message": for _, c := range item.Content { if c.Type == "output_text" { content.WriteString(c.Text) } } case "function_call": var args map[string]any if err := json.Unmarshal([]byte(item.Arguments), &args); err != nil { args = map[string]any{"raw": item.Arguments} } toolCalls = append(toolCalls, ToolCall{ ID: item.CallID, Name: item.Name, Arguments: args, }) } } finishReason := "stop" if len(toolCalls) > 0 { finishReason = "tool_calls" } if resp.Status == "incomplete" { finishReason = "length" } var usage *UsageInfo if resp.Usage.TotalTokens > 0 { usage = &UsageInfo{ PromptTokens: int(resp.Usage.InputTokens), CompletionTokens: int(resp.Usage.OutputTokens), TotalTokens: int(resp.Usage.TotalTokens), } } return &LLMResponse{ Content: content.String(), ToolCalls: toolCalls, FinishReason: finishReason, Usage: usage, } } func createCodexTokenSource() func() (string, string, error) { return func() (string, string, error) { cred, err := auth.GetCredential("openai") if err != nil { return "", "", fmt.Errorf("loading auth credentials: %w", err) } if cred == nil { return "", "", fmt.Errorf("no credentials for openai. Run: picoclaw auth login --provider openai") } if cred.AuthMethod == "oauth" && cred.NeedsRefresh() && cred.RefreshToken != "" { oauthCfg := auth.OpenAIOAuthConfig() refreshed, err := auth.RefreshAccessToken(cred, oauthCfg) if err != nil { return "", "", fmt.Errorf("refreshing token: %w", err) } if refreshed.AccountID == "" { refreshed.AccountID = cred.AccountID } if err := auth.SetCredential("openai", refreshed); err != nil { return "", "", fmt.Errorf("saving refreshed token: %w", err) } return refreshed.AccessToken, refreshed.AccountID, nil } return cred.AccessToken, cred.AccountID, nil } } ================================================ FILE: pkg/providers/codex_provider_test.go ================================================ package providers import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "github.com/openai/openai-go/v3" openaiopt "github.com/openai/openai-go/v3/option" "github.com/openai/openai-go/v3/responses" ) func TestBuildCodexParams_BasicMessage(t *testing.T) { messages := []Message{ {Role: "user", Content: "Hello"}, } params := buildCodexParams(messages, nil, "gpt-4o", map[string]any{ "max_tokens": 2048, "temperature": 0.7, }, true) if params.Model != "gpt-4o" { t.Errorf("Model = %q, want %q", params.Model, "gpt-4o") } if !params.Instructions.Valid() { t.Fatal("Instructions should be set") } if params.Instructions.Or("") != defaultCodexInstructions { t.Errorf("Instructions = %q, want %q", params.Instructions.Or(""), defaultCodexInstructions) } if params.MaxOutputTokens.Valid() { t.Fatalf("MaxOutputTokens should not be set for Codex backend") } } func TestBuildCodexParams_SystemAsInstructions(t *testing.T) { messages := []Message{ {Role: "system", Content: "You are helpful"}, {Role: "user", Content: "Hi"}, } params := buildCodexParams(messages, nil, "gpt-4o", map[string]any{}, true) if !params.Instructions.Valid() { t.Fatal("Instructions should be set") } if params.Instructions.Or("") != "You are helpful" { t.Errorf("Instructions = %q, want %q", params.Instructions.Or(""), "You are helpful") } } func TestBuildCodexParams_ToolCallConversation(t *testing.T) { messages := []Message{ {Role: "user", Content: "What's the weather?"}, { Role: "assistant", ToolCalls: []ToolCall{ {ID: "call_1", Name: "get_weather", Arguments: map[string]any{"city": "SF"}}, }, }, {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"}, } params := buildCodexParams(messages, nil, "gpt-4o", map[string]any{}, false) if params.Input.OfInputItemList == nil { t.Fatal("Input.OfInputItemList should not be nil") } if len(params.Input.OfInputItemList) != 3 { t.Errorf("len(Input items) = %d, want 3", len(params.Input.OfInputItemList)) } } func TestBuildCodexParams_ToolCallFunctionFallback(t *testing.T) { messages := []Message{ {Role: "user", Content: "Read a file"}, { Role: "assistant", ToolCalls: []ToolCall{ { ID: "call_1", Type: "function", Function: &FunctionCall{ Name: "read_file", Arguments: `{"path":"README.md"}`, }, }, }, }, {Role: "tool", Content: "ok", ToolCallID: "call_1"}, } params := buildCodexParams(messages, nil, "gpt-4o", map[string]any{}, false) if params.Input.OfInputItemList == nil { t.Fatal("Input.OfInputItemList should not be nil") } if len(params.Input.OfInputItemList) != 3 { t.Fatalf("len(Input items) = %d, want 3", len(params.Input.OfInputItemList)) } fc := params.Input.OfInputItemList[1].OfFunctionCall if fc == nil { t.Fatal("assistant tool call should be converted to function_call input item") } if fc.Name != "read_file" { t.Errorf("Function call name = %q, want %q", fc.Name, "read_file") } if fc.Arguments != `{"path":"README.md"}` { t.Errorf("Function call arguments = %q, want %q", fc.Arguments, `{"path":"README.md"}`) } } func TestBuildCodexParams_WithTools(t *testing.T) { tools := []ToolDefinition{ { Type: "function", Function: ToolFunctionDefinition{ Name: "get_weather", Description: "Get weather", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "city": map[string]any{"type": "string"}, }, }, }, }, } params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]any{}, false) if len(params.Tools) != 1 { t.Fatalf("len(Tools) = %d, want 1", len(params.Tools)) } if params.Tools[0].OfFunction == nil { t.Fatal("Tool should be a function tool") } if params.Tools[0].OfFunction.Name != "get_weather" { t.Errorf("Tool name = %q, want %q", params.Tools[0].OfFunction.Name, "get_weather") } } func TestBuildCodexParams_StoreIsFalse(t *testing.T) { params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]any{}, false) if !params.Store.Valid() || params.Store.Or(true) != false { t.Error("Store should be explicitly set to false") } } func TestBuildCodexParams_DefaultWebSearchEnabled(t *testing.T) { params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]any{}, true) if len(params.Tools) != 1 { t.Fatalf("len(Tools) = %d, want 1", len(params.Tools)) } if params.Tools[0].OfWebSearch == nil { t.Fatal("Tool should include built-in web_search") } if params.Tools[0].OfWebSearch.Type != responses.WebSearchToolTypeWebSearch { t.Errorf( "Web search tool type = %q, want %q", params.Tools[0].OfWebSearch.Type, responses.WebSearchToolTypeWebSearch, ) } } func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) { tools := []ToolDefinition{ { Type: "function", Function: ToolFunctionDefinition{ Name: "web_search", Description: "local web search", Parameters: map[string]any{ "type": "object", }, }, }, { Type: "function", Function: ToolFunctionDefinition{ Name: "read_file", Description: "read file", Parameters: map[string]any{ "type": "object", }, }, }, } params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]any{}, true) if len(params.Tools) != 2 { t.Fatalf("len(Tools) = %d, want 2", len(params.Tools)) } if params.Tools[0].OfFunction == nil || params.Tools[0].OfFunction.Name != "read_file" { t.Fatalf("first tool should be function read_file, got %#v", params.Tools[0]) } if params.Tools[1].OfWebSearch == nil { t.Fatalf("second tool should be built-in web_search, got %#v", params.Tools[1]) } } func TestParseCodexResponse_TextOutput(t *testing.T) { respJSON := `{ "id": "resp_test", "object": "response", "status": "completed", "output": [ { "id": "msg_1", "type": "message", "role": "assistant", "status": "completed", "content": [ {"type": "output_text", "text": "Hello there!"} ] } ], "usage": { "input_tokens": 10, "output_tokens": 5, "total_tokens": 15, "input_tokens_details": {"cached_tokens": 0}, "output_tokens_details": {"reasoning_tokens": 0} } }` var resp responses.Response if err := json.Unmarshal([]byte(respJSON), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } result := parseCodexResponse(&resp) if result.Content != "Hello there!" { t.Errorf("Content = %q, want %q", result.Content, "Hello there!") } if result.FinishReason != "stop" { t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop") } if result.Usage.TotalTokens != 15 { t.Errorf("TotalTokens = %d, want 15", result.Usage.TotalTokens) } } func TestParseCodexResponse_FunctionCall(t *testing.T) { respJSON := `{ "id": "resp_test", "object": "response", "status": "completed", "output": [ { "id": "fc_1", "type": "function_call", "call_id": "call_abc", "name": "get_weather", "arguments": "{\"city\":\"SF\"}", "status": "completed" } ], "usage": { "input_tokens": 10, "output_tokens": 8, "total_tokens": 18, "input_tokens_details": {"cached_tokens": 0}, "output_tokens_details": {"reasoning_tokens": 0} } }` var resp responses.Response if err := json.Unmarshal([]byte(respJSON), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } result := parseCodexResponse(&resp) if len(result.ToolCalls) != 1 { t.Fatalf("len(ToolCalls) = %d, want 1", len(result.ToolCalls)) } tc := result.ToolCalls[0] if tc.Name != "get_weather" { t.Errorf("ToolCall.Name = %q, want %q", tc.Name, "get_weather") } if tc.ID != "call_abc" { t.Errorf("ToolCall.ID = %q, want %q", tc.ID, "call_abc") } if tc.Arguments["city"] != "SF" { t.Errorf("ToolCall.Arguments[city] = %v, want SF", tc.Arguments["city"]) } if result.FinishReason != "tool_calls" { t.Errorf("FinishReason = %q, want %q", result.FinishReason, "tool_calls") } } func TestCodexProvider_ChatRoundTrip(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/responses" { http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound) return } if r.Header.Get("Authorization") != "Bearer test-token" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if r.Header.Get("Chatgpt-Account-Id") != "acc-123" { http.Error(w, "missing account id", http.StatusBadRequest) return } var reqBody map[string]any if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } if reqBody["stream"] != true { http.Error(w, "stream must be true", http.StatusBadRequest) return } if _, ok := reqBody["max_output_tokens"]; ok { http.Error(w, "max_output_tokens is not supported", http.StatusBadRequest) return } toolsAny, ok := reqBody["tools"].([]any) if !ok || len(toolsAny) != 1 { http.Error(w, "missing default web search tool", http.StatusBadRequest) return } toolObj, ok := toolsAny[0].(map[string]any) if !ok || toolObj["type"] != "web_search" { http.Error(w, "expected web_search tool", http.StatusBadRequest) return } resp := map[string]any{ "id": "resp_test", "object": "response", "status": "completed", "output": []map[string]any{ { "id": "msg_1", "type": "message", "role": "assistant", "status": "completed", "content": []map[string]any{ {"type": "output_text", "text": "Hi from Codex!"}, }, }, }, "usage": map[string]any{ "input_tokens": 12, "output_tokens": 6, "total_tokens": 18, "input_tokens_details": map[string]any{"cached_tokens": 0}, "output_tokens_details": map[string]any{"reasoning_tokens": 0}, }, } writeCompletedSSE(w, resp) })) defer server.Close() provider := NewCodexProvider("test-token", "acc-123") provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123") messages := []Message{{Role: "user", Content: "Hello"}} // Pass native_search so Codex injects built-in web search (mirrors agent loop when prefer_native is true). opts := map[string]any{"max_tokens": 1024, "native_search": true} resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", opts) if err != nil { t.Fatalf("Chat() error: %v", err) } if resp.Content != "Hi from Codex!" { t.Errorf("Content = %q, want %q", resp.Content, "Hi from Codex!") } if resp.FinishReason != "stop" { t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") } if resp.Usage.TotalTokens != 18 { t.Errorf("TotalTokens = %d, want 18", resp.Usage.TotalTokens) } } func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/responses" { http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound) return } var reqBody map[string]any if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } if _, ok := reqBody["tools"]; ok { http.Error(w, "tools should be absent when web search disabled", http.StatusBadRequest) return } resp := map[string]any{ "id": "resp_test", "object": "response", "status": "completed", "output": []map[string]any{ { "id": "msg_1", "type": "message", "role": "assistant", "status": "completed", "content": []map[string]any{ {"type": "output_text", "text": "Hi from Codex!"}, }, }, }, "usage": map[string]any{ "input_tokens": 4, "output_tokens": 3, "total_tokens": 7, "input_tokens_details": map[string]any{"cached_tokens": 0}, "output_tokens_details": map[string]any{"reasoning_tokens": 0}, }, } writeCompletedSSE(w, resp) })) defer server.Close() provider := NewCodexProvider("test-token", "acc-123") provider.enableWebSearch = false provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123") messages := []Message{{Role: "user", Content: "Hello"}} resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]any{}) if err != nil { t.Fatalf("Chat() error: %v", err) } if resp.Content != "Hi from Codex!" { t.Errorf("Content = %q, want %q", resp.Content, "Hi from Codex!") } } func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/responses" { http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound) return } if r.Header.Get("Authorization") != "Bearer refreshed-token" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if r.Header.Get("Chatgpt-Account-Id") != "acc-123" { http.Error(w, "missing account id", http.StatusBadRequest) return } var reqBody map[string]any if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } if _, ok := reqBody["instructions"]; !ok { http.Error(w, "missing instructions", http.StatusBadRequest) return } if reqBody["instructions"] == "" { http.Error(w, "instructions must not be empty", http.StatusBadRequest) return } if _, ok := reqBody["temperature"]; ok { http.Error(w, "temperature is not supported", http.StatusBadRequest) return } if _, ok := reqBody["max_output_tokens"]; ok { http.Error(w, "max_output_tokens is not supported", http.StatusBadRequest) return } if reqBody["stream"] != true { http.Error(w, "stream must be true", http.StatusBadRequest) return } resp := map[string]any{ "id": "resp_test", "object": "response", "status": "completed", "output": []map[string]any{ { "id": "msg_1", "type": "message", "role": "assistant", "status": "completed", "content": []map[string]any{ {"type": "output_text", "text": "Hi from Codex!"}, }, }, }, "usage": map[string]any{ "input_tokens": 8, "output_tokens": 4, "total_tokens": 12, "input_tokens_details": map[string]any{"cached_tokens": 0}, "output_tokens_details": map[string]any{"reasoning_tokens": 0}, }, } writeCompletedSSE(w, resp) })) defer server.Close() provider := NewCodexProvider("stale-token", "acc-123") provider.client = createOpenAITestClient(server.URL, "stale-token", "") provider.tokenSource = func() (string, string, error) { return "refreshed-token", "", nil } messages := []Message{{Role: "user", Content: "Hello"}} resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]any{"temperature": 0.7}) if err != nil { t.Fatalf("Chat() error: %v", err) } if resp.Content != "Hi from Codex!" { t.Errorf("Content = %q, want %q", resp.Content, "Hi from Codex!") } } func TestCodexProvider_ChatRoundTrip_ModelFallbackFromUnsupported(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/responses" { http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound) return } var reqBody map[string]any if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } if reqBody["model"] != codexDefaultModel { http.Error(w, "unsupported model", http.StatusBadRequest) return } if reqBody["stream"] != true { http.Error(w, "stream must be true", http.StatusBadRequest) return } if reqBody["instructions"] != codexDefaultInstructions { http.Error(w, "missing default instructions", http.StatusBadRequest) return } resp := map[string]any{ "id": "resp_test", "object": "response", "status": "completed", "output": []map[string]any{ { "id": "msg_1", "type": "message", "role": "assistant", "status": "completed", "content": []map[string]any{ {"type": "output_text", "text": "Hi from Codex!"}, }, }, }, "usage": map[string]any{ "input_tokens": 8, "output_tokens": 4, "total_tokens": 12, "input_tokens_details": map[string]any{"cached_tokens": 0}, "output_tokens_details": map[string]any{"reasoning_tokens": 0}, }, } writeCompletedSSE(w, resp) })) defer server.Close() provider := NewCodexProvider("test-token", "acc-123") provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123") messages := []Message{{Role: "user", Content: "Hello"}} resp, err := provider.Chat(t.Context(), messages, nil, "gpt-5.3-codex", nil) if err != nil { t.Fatalf("Chat() error: %v", err) } if resp.Content != "Hi from Codex!" { t.Errorf("Content = %q, want %q", resp.Content, "Hi from Codex!") } } func TestCodexProvider_GetDefaultModel(t *testing.T) { p := NewCodexProvider("test-token", "") if got := p.GetDefaultModel(); got != codexDefaultModel { t.Errorf("GetDefaultModel() = %q, want %q", got, codexDefaultModel) } } func TestResolveCodexModel(t *testing.T) { tests := []struct { name string input string wantModel string wantFallback bool }{ {name: "empty", input: "", wantModel: codexDefaultModel, wantFallback: true}, { name: "unsupported namespace", input: "anthropic/claude-3.5", wantModel: codexDefaultModel, wantFallback: true, }, {name: "non-openai prefixed", input: "glm-4.7", wantModel: codexDefaultModel, wantFallback: true}, {name: "openai prefix", input: "openai/gpt-5.3-codex", wantModel: "gpt-5.3-codex", wantFallback: false}, {name: "direct gpt", input: "gpt-4o", wantModel: "gpt-4o", wantFallback: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotModel, reason := resolveCodexModel(tt.input) if gotModel != tt.wantModel { t.Fatalf("resolveCodexModel(%q) model = %q, want %q", tt.input, gotModel, tt.wantModel) } if tt.wantFallback && reason == "" { t.Fatalf("resolveCodexModel(%q) expected fallback reason", tt.input) } if !tt.wantFallback && reason != "" { t.Fatalf("resolveCodexModel(%q) unexpected fallback reason: %q", tt.input, reason) } }) } } func createOpenAITestClient(baseURL, token, accountID string) *openai.Client { opts := []openaiopt.RequestOption{ openaiopt.WithBaseURL(baseURL), openaiopt.WithAPIKey(token), } if accountID != "" { opts = append(opts, openaiopt.WithHeader("Chatgpt-Account-Id", accountID)) } c := openai.NewClient(opts...) return &c } func writeCompletedSSE(w http.ResponseWriter, response map[string]any) { event := map[string]any{ "type": "response.completed", "sequence_number": 1, "response": response, } b, _ := json.Marshal(event) w.Header().Set("Content-Type", "text/event-stream") fmt.Fprintf(w, "event: response.completed\n") fmt.Fprintf(w, "data: %s\n\n", string(b)) fmt.Fprintf(w, "data: [DONE]\n\n") } ================================================ FILE: pkg/providers/common/common.go ================================================ // PicoClaw - Ultra-lightweight personal AI agent // License: MIT // // Copyright (c) 2026 PicoClaw contributors // Package common provides shared utilities used by multiple LLM provider // implementations (openai_compat, azure, etc.). package common import ( "bufio" "bytes" "encoding/json" "fmt" "io" "log" "net/http" "net/url" "strings" "time" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) // Re-export protocol types used across providers. type ( ToolCall = protocoltypes.ToolCall FunctionCall = protocoltypes.FunctionCall LLMResponse = protocoltypes.LLMResponse UsageInfo = protocoltypes.UsageInfo Message = protocoltypes.Message ToolDefinition = protocoltypes.ToolDefinition ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition ExtraContent = protocoltypes.ExtraContent GoogleExtra = protocoltypes.GoogleExtra ReasoningDetail = protocoltypes.ReasoningDetail ) const DefaultRequestTimeout = 120 * time.Second // NewHTTPClient creates an *http.Client with an optional proxy and the default timeout. func NewHTTPClient(proxy string) *http.Client { client := &http.Client{ Timeout: DefaultRequestTimeout, } if proxy != "" { parsed, err := url.Parse(proxy) if err == nil { // Preserve http.DefaultTransport settings (TLS, HTTP/2, timeouts, etc.) if base, ok := http.DefaultTransport.(*http.Transport); ok { tr := base.Clone() tr.Proxy = http.ProxyURL(parsed) client.Transport = tr } else { // Fallback: minimal transport if DefaultTransport is not *http.Transport. client.Transport = &http.Transport{ Proxy: http.ProxyURL(parsed), } } } else { log.Printf("common: invalid proxy URL %q: %v", proxy, err) } } return client } // --- Message serialization --- // openaiMessage is the wire-format message for OpenAI-compatible APIs. // It mirrors protocoltypes.Message but omits SystemParts, which is an // internal field that would be unknown to third-party endpoints. type openaiMessage struct { Role string `json:"role"` Content string `json:"content"` ReasoningContent string `json:"reasoning_content,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` } // SerializeMessages converts internal Message structs to the OpenAI wire format. // - Strips SystemParts (unknown to third-party endpoints) // - Converts messages with Media to multipart content format (text + image_url parts) // - Preserves ToolCallID, ToolCalls, and ReasoningContent for all messages func SerializeMessages(messages []Message) []any { out := make([]any, 0, len(messages)) for _, m := range messages { if len(m.Media) == 0 { out = append(out, openaiMessage{ Role: m.Role, Content: m.Content, ReasoningContent: m.ReasoningContent, ToolCalls: m.ToolCalls, ToolCallID: m.ToolCallID, }) continue } // Multipart content format for messages with media parts := make([]map[string]any, 0, 1+len(m.Media)) if m.Content != "" { parts = append(parts, map[string]any{ "type": "text", "text": m.Content, }) } for _, mediaURL := range m.Media { if strings.HasPrefix(mediaURL, "data:image/") { parts = append(parts, map[string]any{ "type": "image_url", "image_url": map[string]any{ "url": mediaURL, }, }) } } msg := map[string]any{ "role": m.Role, "content": parts, } if m.ToolCallID != "" { msg["tool_call_id"] = m.ToolCallID } if len(m.ToolCalls) > 0 { msg["tool_calls"] = m.ToolCalls } if m.ReasoningContent != "" { msg["reasoning_content"] = m.ReasoningContent } out = append(out, msg) } return out } // --- Response parsing --- // ParseResponse parses a JSON chat completion response body into an LLMResponse. func ParseResponse(body io.Reader) (*LLMResponse, error) { var apiResponse struct { Choices []struct { Message struct { Content string `json:"content"` ReasoningContent string `json:"reasoning_content"` Reasoning string `json:"reasoning"` ReasoningDetails []ReasoningDetail `json:"reasoning_details"` ToolCalls []struct { ID string `json:"id"` Type string `json:"type"` Function *struct { Name string `json:"name"` Arguments json.RawMessage `json:"arguments"` } `json:"function"` ExtraContent *struct { Google *struct { ThoughtSignature string `json:"thought_signature"` } `json:"google"` } `json:"extra_content"` } `json:"tool_calls"` } `json:"message"` FinishReason string `json:"finish_reason"` } `json:"choices"` Usage *UsageInfo `json:"usage"` } if err := json.NewDecoder(body).Decode(&apiResponse); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } if len(apiResponse.Choices) == 0 { return &LLMResponse{ Content: "", FinishReason: "stop", }, nil } choice := apiResponse.Choices[0] toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls)) for _, tc := range choice.Message.ToolCalls { arguments := make(map[string]any) name := "" // Extract thought_signature from Gemini/Google-specific extra content thoughtSignature := "" if tc.ExtraContent != nil && tc.ExtraContent.Google != nil { thoughtSignature = tc.ExtraContent.Google.ThoughtSignature } if tc.Function != nil { name = tc.Function.Name arguments = DecodeToolCallArguments(tc.Function.Arguments, name) } toolCall := ToolCall{ ID: tc.ID, Name: name, Arguments: arguments, ThoughtSignature: thoughtSignature, } if thoughtSignature != "" { toolCall.ExtraContent = &ExtraContent{ Google: &GoogleExtra{ ThoughtSignature: thoughtSignature, }, } } toolCalls = append(toolCalls, toolCall) } return &LLMResponse{ Content: choice.Message.Content, ReasoningContent: choice.Message.ReasoningContent, Reasoning: choice.Message.Reasoning, ReasoningDetails: choice.Message.ReasoningDetails, ToolCalls: toolCalls, FinishReason: choice.FinishReason, Usage: apiResponse.Usage, }, nil } // DecodeToolCallArguments decodes a tool call's arguments from raw JSON. func DecodeToolCallArguments(raw json.RawMessage, name string) map[string]any { arguments := make(map[string]any) raw = bytes.TrimSpace(raw) if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { return arguments } var decoded any if err := json.Unmarshal(raw, &decoded); err != nil { log.Printf("common: failed to decode tool call arguments payload for %q: %v", name, err) arguments["raw"] = string(raw) return arguments } switch v := decoded.(type) { case string: if strings.TrimSpace(v) == "" { return arguments } if err := json.Unmarshal([]byte(v), &arguments); err != nil { log.Printf("common: failed to decode tool call arguments for %q: %v", name, err) arguments["raw"] = v } return arguments case map[string]any: return v default: log.Printf("common: unsupported tool call arguments type for %q: %T", name, decoded) arguments["raw"] = string(raw) return arguments } } // --- HTTP response helpers --- // HandleErrorResponse reads a non-200 response body and returns an appropriate error. func HandleErrorResponse(resp *http.Response, apiBase string) error { contentType := resp.Header.Get("Content-Type") body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256)) if readErr != nil { return fmt.Errorf("failed to read response: %w", readErr) } if LooksLikeHTML(body, contentType) { return WrapHTMLResponseError(resp.StatusCode, body, contentType, apiBase) } return fmt.Errorf( "API request failed:\n Status: %d\n Body: %s", resp.StatusCode, ResponsePreview(body, 128), ) } // ReadAndParseResponse peeks at the response body to detect HTML errors, // then parses the JSON response into an LLMResponse. func ReadAndParseResponse(resp *http.Response, apiBase string) (*LLMResponse, error) { contentType := resp.Header.Get("Content-Type") reader := bufio.NewReader(resp.Body) prefix, err := reader.Peek(256) if err != nil && err != io.EOF && err != bufio.ErrBufferFull { return nil, fmt.Errorf("failed to inspect response: %w", err) } if LooksLikeHTML(prefix, contentType) { return nil, WrapHTMLResponseError(resp.StatusCode, prefix, contentType, apiBase) } out, err := ParseResponse(reader) if err != nil { return nil, fmt.Errorf("failed to parse JSON response: %w", err) } return out, nil } // LooksLikeHTML checks if the response body appears to be HTML. func LooksLikeHTML(body []byte, contentType string) bool { contentType = strings.ToLower(strings.TrimSpace(contentType)) if strings.Contains(contentType, "text/html") || strings.Contains(contentType, "application/xhtml+xml") { return true } prefix := bytes.ToLower(leadingTrimmedPrefix(body, 128)) return bytes.HasPrefix(prefix, []byte("" } if len(trimmed) <= maxLen { return string(trimmed) } return string(trimmed[:maxLen]) + "..." } func leadingTrimmedPrefix(body []byte, maxLen int) []byte { i := 0 for i < len(body) { switch body[i] { case ' ', '\t', '\n', '\r', '\f', '\v': i++ default: end := i + maxLen if end > len(body) { end = len(body) } return body[i:end] } } return nil } // --- Numeric helpers --- // AsInt converts various numeric types to int. func AsInt(v any) (int, bool) { switch val := v.(type) { case int: return val, true case int64: return int(val), true case float64: return int(val), true case float32: return int(val), true default: return 0, false } } // AsFloat converts various numeric types to float64. func AsFloat(v any) (float64, bool) { switch val := v.(type) { case float64: return val, true case float32: return float64(val), true case int: return float64(val), true case int64: return float64(val), true default: return 0, false } } ================================================ FILE: pkg/providers/common/common_test.go ================================================ package common import ( "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "testing" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) // --- NewHTTPClient tests --- func TestNewHTTPClient_DefaultTimeout(t *testing.T) { client := NewHTTPClient("") if client.Timeout != DefaultRequestTimeout { t.Errorf("timeout = %v, want %v", client.Timeout, DefaultRequestTimeout) } } func TestNewHTTPClient_WithProxy(t *testing.T) { client := NewHTTPClient("http://127.0.0.1:8080") transport, ok := client.Transport.(*http.Transport) if !ok || transport == nil { t.Fatalf("expected http.Transport with proxy, got %T", client.Transport) } req := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.example.com"}} gotProxy, err := transport.Proxy(req) if err != nil { t.Fatalf("proxy function error: %v", err) } if gotProxy == nil || gotProxy.String() != "http://127.0.0.1:8080" { t.Errorf("proxy = %v, want http://127.0.0.1:8080", gotProxy) } } func TestNewHTTPClient_NoProxy(t *testing.T) { client := NewHTTPClient("") if client.Transport != nil { t.Errorf("expected nil transport without proxy, got %T", client.Transport) } } func TestNewHTTPClient_InvalidProxy(t *testing.T) { // Should not panic, just log and return client without proxy client := NewHTTPClient("://bad-url") if client == nil { t.Fatal("expected non-nil client even with invalid proxy") } } // --- SerializeMessages tests --- func TestSerializeMessages_PlainText(t *testing.T) { messages := []Message{ {Role: "user", Content: "hello"}, {Role: "assistant", Content: "hi", ReasoningContent: "thinking..."}, } result := SerializeMessages(messages) data, _ := json.Marshal(result) var msgs []map[string]any json.Unmarshal(data, &msgs) if msgs[0]["content"] != "hello" { t.Errorf("expected plain string content, got %v", msgs[0]["content"]) } if msgs[1]["reasoning_content"] != "thinking..." { t.Errorf("reasoning_content not preserved, got %v", msgs[1]["reasoning_content"]) } } func TestSerializeMessages_WithMedia(t *testing.T) { messages := []Message{ {Role: "user", Content: "describe this", Media: []string{"data:image/png;base64,abc123"}}, } result := SerializeMessages(messages) data, _ := json.Marshal(result) var msgs []map[string]any json.Unmarshal(data, &msgs) content, ok := msgs[0]["content"].([]any) if !ok { t.Fatalf("expected array content for media message, got %T", msgs[0]["content"]) } if len(content) != 2 { t.Fatalf("expected 2 content parts, got %d", len(content)) } } func TestSerializeMessages_MediaWithToolCallID(t *testing.T) { messages := []Message{ {Role: "tool", Content: "result", Media: []string{"data:image/png;base64,xyz"}, ToolCallID: "call_1"}, } result := SerializeMessages(messages) data, _ := json.Marshal(result) var msgs []map[string]any json.Unmarshal(data, &msgs) if msgs[0]["tool_call_id"] != "call_1" { t.Errorf("tool_call_id not preserved, got %v", msgs[0]["tool_call_id"]) } } func TestSerializeMessages_StripsSystemParts(t *testing.T) { messages := []Message{ { Role: "system", Content: "you are helpful", SystemParts: []protocoltypes.ContentBlock{ {Type: "text", Text: "you are helpful"}, }, }, } result := SerializeMessages(messages) data, _ := json.Marshal(result) if strings.Contains(string(data), "system_parts") { t.Error("system_parts should not appear in serialized output") } } // --- ParseResponse tests --- func TestParseResponse_BasicContent(t *testing.T) { body := `{"choices":[{"message":{"content":"hello world"},"finish_reason":"stop"}]}` out, err := ParseResponse(strings.NewReader(body)) if err != nil { t.Fatalf("ParseResponse() error = %v", err) } if out.Content != "hello world" { t.Errorf("Content = %q, want %q", out.Content, "hello world") } if out.FinishReason != "stop" { t.Errorf("FinishReason = %q, want %q", out.FinishReason, "stop") } } func TestParseResponse_EmptyChoices(t *testing.T) { body := `{"choices":[]}` out, err := ParseResponse(strings.NewReader(body)) if err != nil { t.Fatalf("ParseResponse() error = %v", err) } if out.Content != "" { t.Errorf("Content = %q, want empty", out.Content) } if out.FinishReason != "stop" { t.Errorf("FinishReason = %q, want %q", out.FinishReason, "stop") } } func TestParseResponse_WithToolCalls(t *testing.T) { body := `{"choices":[{"message":{"content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"SF\"}"}}]},"finish_reason":"tool_calls"}]}` out, err := ParseResponse(strings.NewReader(body)) if err != nil { t.Fatalf("ParseResponse() error = %v", err) } if len(out.ToolCalls) != 1 { t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) } if out.ToolCalls[0].Name != "get_weather" { t.Errorf("ToolCalls[0].Name = %q, want %q", out.ToolCalls[0].Name, "get_weather") } if out.ToolCalls[0].Arguments["city"] != "SF" { t.Errorf("ToolCalls[0].Arguments[city] = %v, want SF", out.ToolCalls[0].Arguments["city"]) } } func TestParseResponse_WithUsage(t *testing.T) { body := `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}` out, err := ParseResponse(strings.NewReader(body)) if err != nil { t.Fatalf("ParseResponse() error = %v", err) } if out.Usage == nil { t.Fatal("Usage is nil") } if out.Usage.PromptTokens != 10 { t.Errorf("PromptTokens = %d, want 10", out.Usage.PromptTokens) } } func TestParseResponse_WithReasoningContent(t *testing.T) { body := `{"choices":[{"message":{"content":"2","reasoning_content":"Let me think... 1+1=2"},"finish_reason":"stop"}]}` out, err := ParseResponse(strings.NewReader(body)) if err != nil { t.Fatalf("ParseResponse() error = %v", err) } if out.ReasoningContent != "Let me think... 1+1=2" { t.Errorf("ReasoningContent = %q, want %q", out.ReasoningContent, "Let me think... 1+1=2") } } func TestParseResponse_InvalidJSON(t *testing.T) { _, err := ParseResponse(strings.NewReader("not json")) if err == nil { t.Fatal("expected error for invalid JSON") } } // --- DecodeToolCallArguments tests --- func TestDecodeToolCallArguments_ObjectJSON(t *testing.T) { raw := json.RawMessage(`{"city":"Seattle","units":"metric"}`) args := DecodeToolCallArguments(raw, "test") if args["city"] != "Seattle" { t.Errorf("city = %v, want Seattle", args["city"]) } if args["units"] != "metric" { t.Errorf("units = %v, want metric", args["units"]) } } func TestDecodeToolCallArguments_StringJSON(t *testing.T) { raw := json.RawMessage(`"{\"city\":\"SF\"}"`) args := DecodeToolCallArguments(raw, "test") if args["city"] != "SF" { t.Errorf("city = %v, want SF", args["city"]) } } func TestDecodeToolCallArguments_EmptyInput(t *testing.T) { args := DecodeToolCallArguments(nil, "test") if len(args) != 0 { t.Errorf("expected empty map, got %v", args) } } func TestDecodeToolCallArguments_NullInput(t *testing.T) { args := DecodeToolCallArguments(json.RawMessage(`null`), "test") if len(args) != 0 { t.Errorf("expected empty map, got %v", args) } } func TestDecodeToolCallArguments_InvalidJSON(t *testing.T) { args := DecodeToolCallArguments(json.RawMessage(`not-json`), "test") if _, ok := args["raw"]; !ok { t.Error("expected 'raw' fallback key for invalid JSON") } } func TestDecodeToolCallArguments_EmptyStringJSON(t *testing.T) { args := DecodeToolCallArguments(json.RawMessage(`" "`), "test") if len(args) != 0 { t.Errorf("expected empty map for whitespace string, got %v", args) } } // --- HandleErrorResponse tests --- func TestHandleErrorResponse_JSONError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"error":"bad request"}`)) })) defer server.Close() resp, err := http.Get(server.URL) if err != nil { t.Fatalf("http.Get() error = %v", err) } defer resp.Body.Close() err = HandleErrorResponse(resp, server.URL) if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "400") { t.Errorf("error should contain status code, got %v", err) } if strings.Contains(err.Error(), "HTML") { t.Errorf("should not mention HTML for JSON error, got %v", err) } } func TestHandleErrorResponse_HTMLError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusBadGateway) w.Write([]byte("bad gateway")) })) defer server.Close() resp, err := http.Get(server.URL) if err != nil { t.Fatalf("http.Get() error = %v", err) } defer resp.Body.Close() err = HandleErrorResponse(resp, server.URL) if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "HTML instead of JSON") { t.Errorf("expected HTML error message, got %v", err) } } // --- ReadAndParseResponse tests --- func TestReadAndParseResponse_ValidJSON(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`)) })) defer server.Close() resp, err := http.Get(server.URL) if err != nil { t.Fatalf("http.Get() error = %v", err) } defer resp.Body.Close() out, err := ReadAndParseResponse(resp, server.URL) if err != nil { t.Fatalf("ReadAndParseResponse() error = %v", err) } if out.Content != "ok" { t.Errorf("Content = %q, want %q", out.Content, "ok") } } func TestReadAndParseResponse_HTMLResponse(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") w.Write([]byte("login page")) })) defer server.Close() resp, err := http.Get(server.URL) if err != nil { t.Fatalf("http.Get() error = %v", err) } defer resp.Body.Close() _, err = ReadAndParseResponse(resp, server.URL) if err == nil { t.Fatal("expected error for HTML response") } if !strings.Contains(err.Error(), "HTML instead of JSON") { t.Errorf("expected HTML error, got %v", err) } } // --- LooksLikeHTML tests --- func TestLooksLikeHTML_ContentTypeHTML(t *testing.T) { if !LooksLikeHTML(nil, "text/html; charset=utf-8") { t.Error("expected true for text/html content type") } } func TestLooksLikeHTML_ContentTypeXHTML(t *testing.T) { if !LooksLikeHTML(nil, "application/xhtml+xml") { t.Error("expected true for xhtml content type") } } func TestLooksLikeHTML_BodyPrefix(t *testing.T) { tests := []struct { name string body string }{ {"doctype", ""}, {"html tag", ""}, {"head tag", ""}, {"body tag", "<body>content"}, {"whitespace before", " \n\t<!DOCTYPE html>"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if !LooksLikeHTML([]byte(tt.body), "application/json") { t.Errorf("expected true for body %q", tt.body) } }) } } func TestLooksLikeHTML_NotHTML(t *testing.T) { if LooksLikeHTML([]byte(`{"error":"bad"}`), "application/json") { t.Error("expected false for JSON body") } } // --- ResponsePreview tests --- func TestResponsePreview_Short(t *testing.T) { got := ResponsePreview([]byte("hello"), 128) if got != "hello" { t.Errorf("got %q, want %q", got, "hello") } } func TestResponsePreview_Truncated(t *testing.T) { body := strings.Repeat("a", 200) got := ResponsePreview([]byte(body), 128) if len(got) != 131 { // 128 + "..." t.Errorf("len = %d, want 131", len(got)) } if !strings.HasSuffix(got, "...") { t.Error("expected ... suffix") } } func TestResponsePreview_Empty(t *testing.T) { got := ResponsePreview([]byte(""), 128) if got != "<empty>" { t.Errorf("got %q, want %q", got, "<empty>") } } func TestResponsePreview_Whitespace(t *testing.T) { got := ResponsePreview([]byte(" \n\t "), 128) if got != "<empty>" { t.Errorf("got %q, want %q for whitespace-only body", got, "<empty>") } } // --- AsInt tests --- func TestAsInt(t *testing.T) { tests := []struct { name string val any want int ok bool }{ {"int", 42, 42, true}, {"int64", int64(99), 99, true}, {"float64", float64(512), 512, true}, {"float32", float32(256), 256, true}, {"string", "nope", 0, false}, {"nil", nil, 0, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, ok := AsInt(tt.val) if ok != tt.ok || got != tt.want { t.Errorf("AsInt(%v) = (%d, %v), want (%d, %v)", tt.val, got, ok, tt.want, tt.ok) } }) } } // --- AsFloat tests --- func TestAsFloat(t *testing.T) { tests := []struct { name string val any want float64 ok bool }{ {"float64", float64(0.7), 0.7, true}, {"float32", float32(0.5), float64(float32(0.5)), true}, {"int", 1, 1.0, true}, {"int64", int64(100), 100.0, true}, {"string", "nope", 0, false}, {"nil", nil, 0, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, ok := AsFloat(tt.val) if ok != tt.ok || got != tt.want { t.Errorf("AsFloat(%v) = (%f, %v), want (%f, %v)", tt.val, got, ok, tt.want, tt.ok) } }) } } // --- WrapHTMLResponseError tests --- func TestWrapHTMLResponseError(t *testing.T) { err := WrapHTMLResponseError(502, []byte("<html>bad</html>"), "text/html", "https://api.example.com") if err == nil { t.Fatal("expected error") } msg := err.Error() if !strings.Contains(msg, "502") { t.Errorf("expected status code in error, got %v", msg) } if !strings.Contains(msg, "https://api.example.com") { t.Errorf("expected api base in error, got %v", msg) } if !strings.Contains(msg, "HTML instead of JSON") { t.Errorf("expected HTML mention in error, got %v", msg) } } // --- HandleErrorResponse with read failure --- func TestHandleErrorResponse_EmptyBody(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) // empty body })) defer server.Close() resp, err := http.Get(server.URL) if err != nil { t.Fatalf("http.Get() error = %v", err) } defer resp.Body.Close() err = HandleErrorResponse(resp, server.URL) if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "500") { t.Errorf("expected status code, got %v", err) } } // --- ReadAndParseResponse with invalid JSON --- func TestReadAndParseResponse_InvalidJSON(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte("not valid json")) })) defer server.Close() resp, err := http.Get(server.URL) if err != nil { t.Fatalf("http.Get() error = %v", err) } defer resp.Body.Close() _, err = ReadAndParseResponse(resp, server.URL) if err == nil { t.Fatal("expected error for invalid JSON") } } // --- ParseResponse with thought_signature (Google/Gemini) --- func TestParseResponse_WithThoughtSignature(t *testing.T) { body := `{"choices":[{"message":{"content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"test_tool","arguments":"{}"},"extra_content":{"google":{"thought_signature":"sig123"}}}]},"finish_reason":"tool_calls"}]}` out, err := ParseResponse(strings.NewReader(body)) if err != nil { t.Fatalf("ParseResponse() error = %v", err) } if len(out.ToolCalls) != 1 { t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) } if out.ToolCalls[0].ThoughtSignature != "sig123" { t.Errorf("ThoughtSignature = %q, want %q", out.ToolCalls[0].ThoughtSignature, "sig123") } if out.ToolCalls[0].ExtraContent == nil || out.ToolCalls[0].ExtraContent.Google == nil { t.Fatal("ExtraContent.Google is nil") } if out.ToolCalls[0].ExtraContent.Google.ThoughtSignature != "sig123" { t.Errorf("ExtraContent.Google.ThoughtSignature = %q, want %q", out.ToolCalls[0].ExtraContent.Google.ThoughtSignature, "sig123") } } ================================================ FILE: pkg/providers/cooldown.go ================================================ package providers import ( "math" "sync" "time" ) const ( defaultFailureWindow = 24 * time.Hour ) // CooldownTracker manages per-provider cooldown state for the fallback chain. // Thread-safe via sync.RWMutex. In-memory only (resets on restart). type CooldownTracker struct { mu sync.RWMutex entries map[string]*cooldownEntry failureWindow time.Duration nowFunc func() time.Time // for testing } type cooldownEntry struct { ErrorCount int FailureCounts map[FailoverReason]int CooldownEnd time.Time // standard cooldown expiry DisabledUntil time.Time // billing-specific disable expiry DisabledReason FailoverReason // reason for disable (billing) LastFailure time.Time } // NewCooldownTracker creates a tracker with default 24h failure window. func NewCooldownTracker() *CooldownTracker { return &CooldownTracker{ entries: make(map[string]*cooldownEntry), failureWindow: defaultFailureWindow, nowFunc: time.Now, } } // MarkFailure records a failure for a provider and sets appropriate cooldown. // Resets error counts if last failure was more than failureWindow ago. func (ct *CooldownTracker) MarkFailure(provider string, reason FailoverReason) { ct.mu.Lock() defer ct.mu.Unlock() now := ct.nowFunc() entry := ct.getOrCreate(provider) // 24h failure window reset: if no failure in failureWindow, reset counters. if !entry.LastFailure.IsZero() && now.Sub(entry.LastFailure) > ct.failureWindow { entry.ErrorCount = 0 entry.FailureCounts = make(map[FailoverReason]int) } entry.ErrorCount++ entry.FailureCounts[reason]++ entry.LastFailure = now if reason == FailoverBilling { billingCount := entry.FailureCounts[FailoverBilling] entry.DisabledUntil = now.Add(calculateBillingCooldown(billingCount)) entry.DisabledReason = FailoverBilling } else { entry.CooldownEnd = now.Add(calculateStandardCooldown(entry.ErrorCount)) } } // MarkSuccess resets all counters and cooldowns for a provider. func (ct *CooldownTracker) MarkSuccess(provider string) { ct.mu.Lock() defer ct.mu.Unlock() entry := ct.entries[provider] if entry == nil { return } entry.ErrorCount = 0 entry.FailureCounts = make(map[FailoverReason]int) entry.CooldownEnd = time.Time{} entry.DisabledUntil = time.Time{} entry.DisabledReason = "" } // IsAvailable returns true if the provider is not in cooldown or disabled. func (ct *CooldownTracker) IsAvailable(provider string) bool { ct.mu.RLock() defer ct.mu.RUnlock() entry := ct.entries[provider] if entry == nil { return true } now := ct.nowFunc() // Billing disable takes precedence (longer cooldown). if !entry.DisabledUntil.IsZero() && now.Before(entry.DisabledUntil) { return false } // Standard cooldown. if !entry.CooldownEnd.IsZero() && now.Before(entry.CooldownEnd) { return false } return true } // CooldownRemaining returns how long until the provider becomes available. // Returns 0 if already available. func (ct *CooldownTracker) CooldownRemaining(provider string) time.Duration { ct.mu.RLock() defer ct.mu.RUnlock() entry := ct.entries[provider] if entry == nil { return 0 } now := ct.nowFunc() var remaining time.Duration if !entry.DisabledUntil.IsZero() && now.Before(entry.DisabledUntil) { d := entry.DisabledUntil.Sub(now) if d > remaining { remaining = d } } if !entry.CooldownEnd.IsZero() && now.Before(entry.CooldownEnd) { d := entry.CooldownEnd.Sub(now) if d > remaining { remaining = d } } return remaining } // ErrorCount returns the current error count for a provider. func (ct *CooldownTracker) ErrorCount(provider string) int { ct.mu.RLock() defer ct.mu.RUnlock() entry := ct.entries[provider] if entry == nil { return 0 } return entry.ErrorCount } // FailureCount returns the failure count for a specific reason. func (ct *CooldownTracker) FailureCount(provider string, reason FailoverReason) int { ct.mu.RLock() defer ct.mu.RUnlock() entry := ct.entries[provider] if entry == nil { return 0 } return entry.FailureCounts[reason] } func (ct *CooldownTracker) getOrCreate(provider string) *cooldownEntry { entry := ct.entries[provider] if entry == nil { entry = &cooldownEntry{ FailureCounts: make(map[FailoverReason]int), } ct.entries[provider] = entry } return entry } // calculateStandardCooldown computes standard exponential backoff. // Formula from OpenClaw: min(1h, 1min * 5^min(n-1, 3)) // // 1 error → 1 min // 2 errors → 5 min // 3 errors → 25 min // 4+ errors → 1 hour (cap) func calculateStandardCooldown(errorCount int) time.Duration { n := max(1, errorCount) exp := min(n-1, 3) ms := 60_000 * int(math.Pow(5, float64(exp))) ms = min(3_600_000, ms) // cap at 1 hour return time.Duration(ms) * time.Millisecond } // calculateBillingCooldown computes billing-specific exponential backoff. // Formula from OpenClaw: min(24h, 5h * 2^min(n-1, 10)) // // 1 error → 5 hours // 2 errors → 10 hours // 3 errors → 20 hours // 4+ errors → 24 hours (cap) func calculateBillingCooldown(billingErrorCount int) time.Duration { const baseMs = 5 * 60 * 60 * 1000 // 5 hours const maxMs = 24 * 60 * 60 * 1000 // 24 hours n := max(1, billingErrorCount) exp := min(n-1, 10) raw := float64(baseMs) * math.Pow(2, float64(exp)) ms := int(math.Min(float64(maxMs), raw)) return time.Duration(ms) * time.Millisecond } ================================================ FILE: pkg/providers/cooldown_test.go ================================================ package providers import ( "sync" "testing" "time" ) func newTestTracker(now time.Time) (*CooldownTracker, *time.Time) { current := now ct := NewCooldownTracker() ct.nowFunc = func() time.Time { return current } return ct, ¤t } func TestCooldown_InitiallyAvailable(t *testing.T) { ct := NewCooldownTracker() if !ct.IsAvailable("openai") { t.Error("new provider should be available") } if ct.ErrorCount("openai") != 0 { t.Error("new provider should have 0 errors") } } func TestCooldown_StandardEscalation(t *testing.T) { now := time.Now() ct, current := newTestTracker(now) // 1st error → 1 min cooldown ct.MarkFailure("openai", FailoverRateLimit) if ct.IsAvailable("openai") { t.Error("should be in cooldown after 1st error") } // Advance 61 seconds → available *current = now.Add(61 * time.Second) if !ct.IsAvailable("openai") { t.Error("should be available after 1 min cooldown") } // 2nd error → 5 min cooldown ct.MarkFailure("openai", FailoverRateLimit) *current = now.Add(61*time.Second + 4*time.Minute) if ct.IsAvailable("openai") { t.Error("should be in cooldown (5 min) after 2nd error") } *current = now.Add(61*time.Second + 6*time.Minute) if !ct.IsAvailable("openai") { t.Error("should be available after 5 min cooldown") } } func TestCooldown_StandardCap(t *testing.T) { // Verify formula: 1m, 5m, 25m, 1h, 1h, 1h... expected := []time.Duration{ 1 * time.Minute, 5 * time.Minute, 25 * time.Minute, 1 * time.Hour, 1 * time.Hour, } for i, want := range expected { got := calculateStandardCooldown(i + 1) if got != want { t.Errorf("calculateStandardCooldown(%d) = %v, want %v", i+1, got, want) } } } func TestCooldown_BillingEscalation(t *testing.T) { now := time.Now() ct, current := newTestTracker(now) // 1st billing error → 5h cooldown ct.MarkFailure("openai", FailoverBilling) if ct.IsAvailable("openai") { t.Error("should be disabled after billing error") } // Advance 4h → still disabled *current = now.Add(4 * time.Hour) if ct.IsAvailable("openai") { t.Error("should still be disabled (5h cooldown)") } // Advance 5h + 1s → available *current = now.Add(5*time.Hour + 1*time.Second) if !ct.IsAvailable("openai") { t.Error("should be available after 5h billing cooldown") } } func TestCooldown_BillingCap(t *testing.T) { expected := []time.Duration{ 5 * time.Hour, 10 * time.Hour, 20 * time.Hour, 24 * time.Hour, 24 * time.Hour, } for i, want := range expected { got := calculateBillingCooldown(i + 1) if got != want { t.Errorf("calculateBillingCooldown(%d) = %v, want %v", i+1, got, want) } } } func TestCooldown_SuccessReset(t *testing.T) { ct := NewCooldownTracker() ct.MarkFailure("openai", FailoverRateLimit) ct.MarkFailure("openai", FailoverBilling) if ct.ErrorCount("openai") != 2 { t.Errorf("error count = %d, want 2", ct.ErrorCount("openai")) } ct.MarkSuccess("openai") if ct.ErrorCount("openai") != 0 { t.Errorf("error count after success = %d, want 0", ct.ErrorCount("openai")) } if !ct.IsAvailable("openai") { t.Error("should be available after success") } if ct.FailureCount("openai", FailoverRateLimit) != 0 { t.Error("failure counts should be reset after success") } if ct.FailureCount("openai", FailoverBilling) != 0 { t.Error("billing failure count should be reset after success") } } func TestCooldown_FailureWindowReset(t *testing.T) { now := time.Now() ct, current := newTestTracker(now) // 4 errors → 1h cooldown for range 4 { ct.MarkFailure("openai", FailoverRateLimit) *current = current.Add(2 * time.Second) // small advance between errors } if ct.ErrorCount("openai") != 4 { t.Errorf("error count = %d, want 4", ct.ErrorCount("openai")) } // Advance 25 hours (past 24h failure window) *current = now.Add(25 * time.Hour) // Next error should reset counters first, then increment to 1 ct.MarkFailure("openai", FailoverRateLimit) if ct.ErrorCount("openai") != 1 { t.Errorf("error count after window reset = %d, want 1 (reset + 1)", ct.ErrorCount("openai")) } } func TestCooldown_PerReasonTracking(t *testing.T) { ct := NewCooldownTracker() ct.MarkFailure("openai", FailoverRateLimit) ct.MarkFailure("openai", FailoverRateLimit) ct.MarkFailure("openai", FailoverBilling) ct.MarkFailure("openai", FailoverAuth) if ct.FailureCount("openai", FailoverRateLimit) != 2 { t.Errorf("rate_limit count = %d, want 2", ct.FailureCount("openai", FailoverRateLimit)) } if ct.FailureCount("openai", FailoverBilling) != 1 { t.Errorf("billing count = %d, want 1", ct.FailureCount("openai", FailoverBilling)) } if ct.FailureCount("openai", FailoverAuth) != 1 { t.Errorf("auth count = %d, want 1", ct.FailureCount("openai", FailoverAuth)) } if ct.ErrorCount("openai") != 4 { t.Errorf("total error count = %d, want 4", ct.ErrorCount("openai")) } } func TestCooldown_BillingTakesPrecedence(t *testing.T) { now := time.Now() ct, current := newTestTracker(now) // Standard cooldown (1 min) + billing disable (5h) ct.MarkFailure("openai", FailoverRateLimit) // 1 min cooldown ct.MarkFailure("openai", FailoverBilling) // 5h disable // After 2 min: standard cooldown expired but billing still active *current = now.Add(2 * time.Minute) if ct.IsAvailable("openai") { t.Error("billing disable should take precedence over standard cooldown") } // After 5h + 1s: both expired *current = now.Add(5*time.Hour + 1*time.Second) if !ct.IsAvailable("openai") { t.Error("should be available after all cooldowns expire") } } func TestCooldown_CooldownRemaining(t *testing.T) { now := time.Now() ct, current := newTestTracker(now) // No failures → 0 remaining if ct.CooldownRemaining("openai") != 0 { t.Error("expected 0 remaining for new provider") } ct.MarkFailure("openai", FailoverRateLimit) *current = now.Add(30 * time.Second) remaining := ct.CooldownRemaining("openai") if remaining <= 0 || remaining > 1*time.Minute { t.Errorf("remaining = %v, expected ~30s", remaining) } } func TestCooldown_SuccessOnUnknownProvider(t *testing.T) { ct := NewCooldownTracker() // Should not panic ct.MarkSuccess("nonexistent") if !ct.IsAvailable("nonexistent") { t.Error("nonexistent provider should be available") } } func TestCooldown_ConcurrentAccess(t *testing.T) { ct := NewCooldownTracker() var wg sync.WaitGroup for range 100 { wg.Add(3) go func() { defer wg.Done() ct.MarkFailure("openai", FailoverRateLimit) }() go func() { defer wg.Done() ct.IsAvailable("openai") }() go func() { defer wg.Done() ct.MarkSuccess("openai") }() } wg.Wait() // If we got here without panic, concurrent access is safe } func TestCooldown_MultipleProviders(t *testing.T) { ct := NewCooldownTracker() ct.MarkFailure("openai", FailoverRateLimit) ct.MarkFailure("anthropic", FailoverBilling) if ct.IsAvailable("openai") { t.Error("openai should be in cooldown") } if ct.IsAvailable("anthropic") { t.Error("anthropic should be in cooldown") } // groq was never touched if !ct.IsAvailable("groq") { t.Error("groq should be available") } } ================================================ FILE: pkg/providers/error_classifier.go ================================================ package providers import ( "context" "regexp" "strings" ) // Common patterns in Go HTTP error messages var httpStatusPatterns = []*regexp.Regexp{ regexp.MustCompile(`status[:\s]+(\d{3})`), regexp.MustCompile(`http[/\s]+\d*\.?\d*\s+(\d{3})`), regexp.MustCompile(`\b([3-5]\d{2})\b`), } // errorPattern defines a single pattern (string or regex) for error classification. type errorPattern struct { substring string regex *regexp.Regexp } func substr(s string) errorPattern { return errorPattern{substring: s} } func rxp(r string) errorPattern { return errorPattern{regex: regexp.MustCompile("(?i)" + r)} } // Error patterns organized by FailoverReason, matching OpenClaw production (~40 patterns). var ( rateLimitPatterns = []errorPattern{ rxp(`rate[_ ]limit`), substr("too many requests"), substr("429"), substr("exceeded your current quota"), rxp(`exceeded.*quota`), rxp(`resource has been exhausted`), rxp(`resource.*exhausted`), substr("resource_exhausted"), substr("quota exceeded"), substr("usage limit"), } overloadedPatterns = []errorPattern{ rxp(`overloaded_error`), rxp(`"type"\s*:\s*"overloaded_error"`), substr("overloaded"), } timeoutPatterns = []errorPattern{ substr("timeout"), substr("timed out"), substr("deadline exceeded"), substr("context deadline exceeded"), } billingPatterns = []errorPattern{ rxp(`\b402\b`), substr("payment required"), substr("insufficient credits"), substr("credit balance"), substr("plans & billing"), substr("insufficient balance"), } authPatterns = []errorPattern{ rxp(`invalid[_ ]?api[_ ]?key`), substr("incorrect api key"), substr("invalid token"), substr("authentication"), substr("re-authenticate"), substr("oauth token refresh failed"), substr("unauthorized"), substr("forbidden"), substr("access denied"), substr("expired"), substr("token has expired"), rxp(`\b401\b`), rxp(`\b403\b`), substr("no credentials found"), substr("no api key found"), } formatPatterns = []errorPattern{ substr("string should match pattern"), substr("tool_use.id"), substr("tool_use_id"), substr("messages.1.content.1.tool_use.id"), substr("invalid request format"), } imageDimensionPatterns = []errorPattern{ rxp(`image dimensions exceed max`), } imageSizePatterns = []errorPattern{ rxp(`image exceeds.*mb`), } // Transient HTTP status codes that map to timeout (server-side failures). transientStatusCodes = map[int]bool{ 500: true, 502: true, 503: true, 521: true, 522: true, 523: true, 524: true, 529: true, } ) // ClassifyError classifies an error into a FailoverError with reason. // Returns nil if the error is not classifiable (unknown errors should not trigger fallback). func ClassifyError(err error, provider, model string) *FailoverError { if err == nil { return nil } // Context cancellation: user abort, never fallback. if err == context.Canceled { return nil } // Context deadline exceeded: treat as timeout, always fallback. if err == context.DeadlineExceeded { return &FailoverError{ Reason: FailoverTimeout, Provider: provider, Model: model, Wrapped: err, } } msg := strings.ToLower(err.Error()) // Image dimension/size errors: non-retriable, non-fallback. if IsImageDimensionError(msg) || IsImageSizeError(msg) { return &FailoverError{ Reason: FailoverFormat, Provider: provider, Model: model, Wrapped: err, } } // Try HTTP status code extraction first. if status := extractHTTPStatus(msg); status > 0 { if reason := classifyByStatus(status); reason != "" { return &FailoverError{ Reason: reason, Provider: provider, Model: model, Status: status, Wrapped: err, } } } // Message pattern matching (priority order from OpenClaw). if reason := classifyByMessage(msg); reason != "" { return &FailoverError{ Reason: reason, Provider: provider, Model: model, Wrapped: err, } } return nil } // classifyByStatus maps HTTP status codes to FailoverReason. func classifyByStatus(status int) FailoverReason { switch { case status == 401 || status == 403: return FailoverAuth case status == 402: return FailoverBilling case status == 408: return FailoverTimeout case status == 429: return FailoverRateLimit case status == 400: return FailoverFormat case transientStatusCodes[status]: return FailoverTimeout } return "" } // classifyByMessage matches error messages against patterns. // Priority order matters (from OpenClaw classifyFailoverReason). func classifyByMessage(msg string) FailoverReason { if matchesAny(msg, rateLimitPatterns) { return FailoverRateLimit } if matchesAny(msg, overloadedPatterns) { return FailoverRateLimit // Overloaded treated as rate_limit } if matchesAny(msg, billingPatterns) { return FailoverBilling } if matchesAny(msg, timeoutPatterns) { return FailoverTimeout } if matchesAny(msg, authPatterns) { return FailoverAuth } if matchesAny(msg, formatPatterns) { return FailoverFormat } return "" } // extractHTTPStatus extracts an HTTP status code from an error message. // Looks for patterns like "status: 429", "status 429", "http/1.1 429", "http 429", or standalone "429". func extractHTTPStatus(msg string) int { for _, p := range httpStatusPatterns { if m := p.FindStringSubmatch(msg); len(m) > 1 { return parseDigits(m[1]) } } return 0 } // IsImageDimensionError returns true if the message indicates an image dimension error. func IsImageDimensionError(msg string) bool { return matchesAny(msg, imageDimensionPatterns) } // IsImageSizeError returns true if the message indicates an image file size error. func IsImageSizeError(msg string) bool { return matchesAny(msg, imageSizePatterns) } // matchesAny checks if msg matches any of the patterns. func matchesAny(msg string, patterns []errorPattern) bool { for _, p := range patterns { if p.regex != nil { if p.regex.MatchString(msg) { return true } } else if p.substring != "" { if strings.Contains(msg, p.substring) { return true } } } return false } // parseDigits converts a string of digits to an int. func parseDigits(s string) int { n := 0 for _, c := range s { if c >= '0' && c <= '9' { n = n*10 + int(c-'0') } } return n } ================================================ FILE: pkg/providers/error_classifier_test.go ================================================ package providers import ( "context" "errors" "fmt" "testing" ) func TestClassifyError_Nil(t *testing.T) { result := ClassifyError(nil, "openai", "gpt-4") if result != nil { t.Errorf("expected nil for nil error, got %+v", result) } } func TestClassifyError_ContextCanceled(t *testing.T) { result := ClassifyError(context.Canceled, "openai", "gpt-4") if result != nil { t.Errorf("expected nil for context.Canceled (user abort), got %+v", result) } } func TestClassifyError_ContextDeadlineExceeded(t *testing.T) { result := ClassifyError(context.DeadlineExceeded, "openai", "gpt-4") if result == nil { t.Fatal("expected non-nil for deadline exceeded") } if result.Reason != FailoverTimeout { t.Errorf("reason = %q, want timeout", result.Reason) } } func TestClassifyError_StatusCodes(t *testing.T) { tests := []struct { status int reason FailoverReason }{ {401, FailoverAuth}, {403, FailoverAuth}, {402, FailoverBilling}, {408, FailoverTimeout}, {429, FailoverRateLimit}, {400, FailoverFormat}, {500, FailoverTimeout}, {502, FailoverTimeout}, {503, FailoverTimeout}, {521, FailoverTimeout}, {522, FailoverTimeout}, {523, FailoverTimeout}, {524, FailoverTimeout}, {529, FailoverTimeout}, } for _, tt := range tests { err := fmt.Errorf("API error: status: %d something went wrong", tt.status) result := ClassifyError(err, "test", "model") if result == nil { t.Errorf("status %d: expected non-nil", tt.status) continue } if result.Reason != tt.reason { t.Errorf("status %d: reason = %q, want %q", tt.status, result.Reason, tt.reason) } } } func TestClassifyError_RateLimitPatterns(t *testing.T) { patterns := []string{ "rate limit exceeded", "rate_limit reached", "too many requests", "exceeded your current quota", "resource has been exhausted", "resource_exhausted", "quota exceeded", "usage limit reached", } for _, msg := range patterns { err := errors.New(msg) result := ClassifyError(err, "openai", "gpt-4") if result == nil { t.Errorf("pattern %q: expected non-nil", msg) continue } if result.Reason != FailoverRateLimit { t.Errorf("pattern %q: reason = %q, want rate_limit", msg, result.Reason) } } } func TestClassifyError_OverloadedPatterns(t *testing.T) { patterns := []string{ "overloaded_error", `{"type": "overloaded_error"}`, "server is overloaded", } for _, msg := range patterns { err := errors.New(msg) result := ClassifyError(err, "anthropic", "claude") if result == nil { t.Errorf("pattern %q: expected non-nil", msg) continue } // Overloaded is treated as rate_limit if result.Reason != FailoverRateLimit { t.Errorf("pattern %q: reason = %q, want rate_limit", msg, result.Reason) } } } func TestClassifyError_BillingPatterns(t *testing.T) { patterns := []string{ "payment required", "insufficient credits", "credit balance too low", "plans & billing page", "insufficient balance", } for _, msg := range patterns { err := errors.New(msg) result := ClassifyError(err, "openai", "gpt-4") if result == nil { t.Errorf("pattern %q: expected non-nil", msg) continue } if result.Reason != FailoverBilling { t.Errorf("pattern %q: reason = %q, want billing", msg, result.Reason) } } } func TestClassifyError_TimeoutPatterns(t *testing.T) { patterns := []string{ "request timeout", "connection timed out", "deadline exceeded", "context deadline exceeded", } for _, msg := range patterns { err := errors.New(msg) result := ClassifyError(err, "openai", "gpt-4") if result == nil { t.Errorf("pattern %q: expected non-nil", msg) continue } if result.Reason != FailoverTimeout { t.Errorf("pattern %q: reason = %q, want timeout", msg, result.Reason) } } } func TestClassifyError_AuthPatterns(t *testing.T) { patterns := []string{ "invalid api key", "invalid_api_key", "incorrect api key", "invalid token", "authentication failed", "re-authenticate", "oauth token refresh failed", "unauthorized access", "forbidden", "access denied", "expired", "token has expired", "no credentials found", "no api key found", } for _, msg := range patterns { err := errors.New(msg) result := ClassifyError(err, "openai", "gpt-4") if result == nil { t.Errorf("pattern %q: expected non-nil", msg) continue } if result.Reason != FailoverAuth { t.Errorf("pattern %q: reason = %q, want auth", msg, result.Reason) } } } func TestClassifyError_FormatPatterns(t *testing.T) { patterns := []string{ "string should match pattern", "tool_use.id is required", "invalid tool_use_id", "messages.1.content.1.tool_use.id must be valid", "invalid request format", } for _, msg := range patterns { err := errors.New(msg) result := ClassifyError(err, "anthropic", "claude") if result == nil { t.Errorf("pattern %q: expected non-nil", msg) continue } if result.Reason != FailoverFormat { t.Errorf("pattern %q: reason = %q, want format", msg, result.Reason) } } } func TestClassifyError_ImageDimensionError(t *testing.T) { err := errors.New("image dimensions exceed max allowed 2048x2048") result := ClassifyError(err, "openai", "gpt-4o") if result == nil { t.Fatal("expected non-nil for image dimension error") } if result.Reason != FailoverFormat { t.Errorf("reason = %q, want format", result.Reason) } if result.IsRetriable() { t.Error("image dimension error should not be retriable") } } func TestClassifyError_ImageSizeError(t *testing.T) { err := errors.New("image exceeds 20 mb limit") result := ClassifyError(err, "openai", "gpt-4o") if result == nil { t.Fatal("expected non-nil for image size error") } if result.Reason != FailoverFormat { t.Errorf("reason = %q, want format", result.Reason) } } func TestClassifyError_UnknownError(t *testing.T) { err := errors.New("some completely random error") result := ClassifyError(err, "openai", "gpt-4") if result != nil { t.Errorf("expected nil for unknown error, got %+v", result) } } func TestClassifyError_ProviderModelPropagation(t *testing.T) { err := errors.New("rate limit exceeded") result := ClassifyError(err, "my-provider", "my-model") if result == nil { t.Fatal("expected non-nil") } if result.Provider != "my-provider" { t.Errorf("provider = %q, want my-provider", result.Provider) } if result.Model != "my-model" { t.Errorf("model = %q, want my-model", result.Model) } } func TestFailoverError_IsRetriable(t *testing.T) { tests := []struct { reason FailoverReason retriable bool }{ {FailoverAuth, true}, {FailoverRateLimit, true}, {FailoverBilling, true}, {FailoverTimeout, true}, {FailoverOverloaded, true}, {FailoverFormat, false}, {FailoverUnknown, true}, } for _, tt := range tests { fe := &FailoverError{Reason: tt.reason} if fe.IsRetriable() != tt.retriable { t.Errorf("IsRetriable(%q) = %v, want %v", tt.reason, fe.IsRetriable(), tt.retriable) } } } func TestFailoverError_ErrorString(t *testing.T) { fe := &FailoverError{ Reason: FailoverRateLimit, Provider: "openai", Model: "gpt-4", Status: 429, Wrapped: errors.New("too many requests"), } s := fe.Error() if s == "" { t.Error("expected non-empty error string") } } func TestFailoverError_Unwrap(t *testing.T) { inner := errors.New("inner error") fe := &FailoverError{Reason: FailoverTimeout, Wrapped: inner} if fe.Unwrap() != inner { t.Error("Unwrap should return wrapped error") } } func TestExtractHTTPStatus(t *testing.T) { tests := []struct { msg string want int }{ {"status: 429 rate limited", 429}, {"status 401 unauthorized", 401}, {"http/1.1 502 bad gateway", 502}, {"error 429", 429}, {"no status code here", 0}, {"random number 12345", 0}, } for _, tt := range tests { got := extractHTTPStatus(tt.msg) if got != tt.want { t.Errorf("extractHTTPStatus(%q) = %d, want %d", tt.msg, got, tt.want) } } } func TestIsImageDimensionError(t *testing.T) { if !IsImageDimensionError("image dimensions exceed max 4096x4096") { t.Error("should match image dimensions exceed max") } if IsImageDimensionError("normal error message") { t.Error("should not match normal error") } } func TestIsImageSizeError(t *testing.T) { if !IsImageSizeError("image exceeds 20 mb") { t.Error("should match image exceeds mb") } if IsImageSizeError("normal error message") { t.Error("should not match normal error") } } ================================================ FILE: pkg/providers/factory.go ================================================ package providers import ( "fmt" "strings" "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" ) const defaultAnthropicAPIBase = "https://api.anthropic.com/v1" var getCredential = auth.GetCredential type providerType int const ( providerTypeHTTPCompat providerType = iota providerTypeClaudeAuth providerTypeCodexAuth providerTypeCodexCLIToken providerTypeClaudeCLI providerTypeCodexCLI providerTypeGitHubCopilot ) type providerSelection struct { providerType providerType apiKey string apiBase string proxy string model string workspace string connectMode string enableWebSearch bool } func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { model := cfg.Agents.Defaults.GetModelName() providerName := strings.ToLower(cfg.Agents.Defaults.Provider) lowerModel := strings.ToLower(model) if providerName == "" && model == "" { return providerSelection{}, fmt.Errorf("no model configured: agents.defaults.model is empty") } sel := providerSelection{ providerType: providerTypeHTTPCompat, model: model, } // First, prefer explicit provider configuration. if providerName != "" { switch providerName { case "groq": if cfg.Providers.Groq.APIKey != "" { sel.apiKey = cfg.Providers.Groq.APIKey sel.apiBase = cfg.Providers.Groq.APIBase sel.proxy = cfg.Providers.Groq.Proxy if sel.apiBase == "" { sel.apiBase = "https://api.groq.com/openai/v1" } } case "openai", "gpt": if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" { sel.enableWebSearch = cfg.Providers.OpenAI.WebSearch if cfg.Providers.OpenAI.AuthMethod == "codex-cli" { sel.providerType = providerTypeCodexCLIToken return sel, nil } if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" { sel.providerType = providerTypeCodexAuth return sel, nil } sel.apiKey = cfg.Providers.OpenAI.APIKey sel.apiBase = cfg.Providers.OpenAI.APIBase sel.proxy = cfg.Providers.OpenAI.Proxy if sel.apiBase == "" { sel.apiBase = "https://api.openai.com/v1" } } case "anthropic", "claude": if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" { if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { sel.apiBase = cfg.Providers.Anthropic.APIBase if sel.apiBase == "" { sel.apiBase = defaultAnthropicAPIBase } sel.providerType = providerTypeClaudeAuth return sel, nil } sel.apiKey = cfg.Providers.Anthropic.APIKey sel.apiBase = cfg.Providers.Anthropic.APIBase sel.proxy = cfg.Providers.Anthropic.Proxy if sel.apiBase == "" { sel.apiBase = defaultAnthropicAPIBase } } case "openrouter": if cfg.Providers.OpenRouter.APIKey != "" { sel.apiKey = cfg.Providers.OpenRouter.APIKey sel.proxy = cfg.Providers.OpenRouter.Proxy if cfg.Providers.OpenRouter.APIBase != "" { sel.apiBase = cfg.Providers.OpenRouter.APIBase } else { sel.apiBase = "https://openrouter.ai/api/v1" } } case "litellm": if cfg.Providers.LiteLLM.APIKey != "" || cfg.Providers.LiteLLM.APIBase != "" { sel.apiKey = cfg.Providers.LiteLLM.APIKey sel.apiBase = cfg.Providers.LiteLLM.APIBase sel.proxy = cfg.Providers.LiteLLM.Proxy if sel.apiBase == "" { sel.apiBase = "http://localhost:4000/v1" } } case "zhipu", "glm": if cfg.Providers.Zhipu.APIKey != "" { sel.apiKey = cfg.Providers.Zhipu.APIKey sel.apiBase = cfg.Providers.Zhipu.APIBase sel.proxy = cfg.Providers.Zhipu.Proxy if sel.apiBase == "" { sel.apiBase = "https://open.bigmodel.cn/api/paas/v4" } } case "gemini", "google": if cfg.Providers.Gemini.APIKey != "" { sel.apiKey = cfg.Providers.Gemini.APIKey sel.apiBase = cfg.Providers.Gemini.APIBase sel.proxy = cfg.Providers.Gemini.Proxy if sel.apiBase == "" { sel.apiBase = "https://generativelanguage.googleapis.com/v1beta" } } case "vllm": if cfg.Providers.VLLM.APIBase != "" { sel.apiKey = cfg.Providers.VLLM.APIKey sel.apiBase = cfg.Providers.VLLM.APIBase sel.proxy = cfg.Providers.VLLM.Proxy } case "shengsuanyun": if cfg.Providers.ShengSuanYun.APIKey != "" { sel.apiKey = cfg.Providers.ShengSuanYun.APIKey sel.apiBase = cfg.Providers.ShengSuanYun.APIBase sel.proxy = cfg.Providers.ShengSuanYun.Proxy if sel.apiBase == "" { sel.apiBase = "https://router.shengsuanyun.com/api/v1" } } case "nvidia": if cfg.Providers.Nvidia.APIKey != "" { sel.apiKey = cfg.Providers.Nvidia.APIKey sel.apiBase = cfg.Providers.Nvidia.APIBase sel.proxy = cfg.Providers.Nvidia.Proxy if sel.apiBase == "" { sel.apiBase = "https://integrate.api.nvidia.com/v1" } } case "vivgrid": if cfg.Providers.Vivgrid.APIKey != "" { sel.apiKey = cfg.Providers.Vivgrid.APIKey sel.apiBase = cfg.Providers.Vivgrid.APIBase sel.proxy = cfg.Providers.Vivgrid.Proxy if sel.apiBase == "" { sel.apiBase = "https://api.vivgrid.com/v1" } } case "claude-cli", "claude-code", "claudecode": workspace := cfg.WorkspacePath() if workspace == "" { workspace = "." } sel.providerType = providerTypeClaudeCLI sel.workspace = workspace return sel, nil case "codex-cli", "codex-code": workspace := cfg.WorkspacePath() if workspace == "" { workspace = "." } sel.providerType = providerTypeCodexCLI sel.workspace = workspace return sel, nil case "deepseek": if cfg.Providers.DeepSeek.APIKey != "" { sel.apiKey = cfg.Providers.DeepSeek.APIKey sel.apiBase = cfg.Providers.DeepSeek.APIBase sel.proxy = cfg.Providers.DeepSeek.Proxy if sel.apiBase == "" { sel.apiBase = "https://api.deepseek.com/v1" } if model != "deepseek-chat" && model != "deepseek-reasoner" { sel.model = "deepseek-chat" } } case "avian": if cfg.Providers.Avian.APIKey != "" { sel.apiKey = cfg.Providers.Avian.APIKey sel.apiBase = cfg.Providers.Avian.APIBase sel.proxy = cfg.Providers.Avian.Proxy if sel.apiBase == "" { sel.apiBase = "https://api.avian.io/v1" } } case "mistral": if cfg.Providers.Mistral.APIKey != "" { sel.apiKey = cfg.Providers.Mistral.APIKey sel.apiBase = cfg.Providers.Mistral.APIBase sel.proxy = cfg.Providers.Mistral.Proxy if sel.apiBase == "" { sel.apiBase = "https://api.mistral.ai/v1" } } case "minimax": if cfg.Providers.Minimax.APIKey != "" { sel.apiKey = cfg.Providers.Minimax.APIKey sel.apiBase = cfg.Providers.Minimax.APIBase sel.proxy = cfg.Providers.Minimax.Proxy if sel.apiBase == "" { sel.apiBase = "https://api.minimaxi.com/v1" } } case "longcat": if cfg.Providers.LongCat.APIKey != "" { sel.apiKey = cfg.Providers.LongCat.APIKey sel.apiBase = cfg.Providers.LongCat.APIBase sel.proxy = cfg.Providers.LongCat.Proxy if sel.apiBase == "" { sel.apiBase = "https://api.longcat.chat/openai" } } case "github_copilot", "copilot": sel.providerType = providerTypeGitHubCopilot if cfg.Providers.GitHubCopilot.APIBase != "" { sel.apiBase = cfg.Providers.GitHubCopilot.APIBase } else { sel.apiBase = "localhost:4321" } sel.connectMode = cfg.Providers.GitHubCopilot.ConnectMode return sel, nil } } // Fallback: infer provider from model and configured keys. if sel.apiKey == "" && sel.apiBase == "" { switch { case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "": sel.apiKey = cfg.Providers.Moonshot.APIKey sel.apiBase = cfg.Providers.Moonshot.APIBase sel.proxy = cfg.Providers.Moonshot.Proxy if sel.apiBase == "" { sel.apiBase = "https://api.moonshot.cn/v1" } case strings.HasPrefix(model, "openrouter/") || strings.HasPrefix(model, "anthropic/") || strings.HasPrefix(model, "openai/") || strings.HasPrefix(model, "meta-llama/") || strings.HasPrefix(model, "deepseek/") || strings.HasPrefix(model, "google/"): sel.apiKey = cfg.Providers.OpenRouter.APIKey sel.proxy = cfg.Providers.OpenRouter.Proxy if cfg.Providers.OpenRouter.APIBase != "" { sel.apiBase = cfg.Providers.OpenRouter.APIBase } else { sel.apiBase = "https://openrouter.ai/api/v1" } case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""): if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { sel.apiBase = cfg.Providers.Anthropic.APIBase if sel.apiBase == "" { sel.apiBase = defaultAnthropicAPIBase } sel.providerType = providerTypeClaudeAuth return sel, nil } sel.apiKey = cfg.Providers.Anthropic.APIKey sel.apiBase = cfg.Providers.Anthropic.APIBase sel.proxy = cfg.Providers.Anthropic.Proxy if sel.apiBase == "" { sel.apiBase = defaultAnthropicAPIBase } case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""): sel.enableWebSearch = cfg.Providers.OpenAI.WebSearch if cfg.Providers.OpenAI.AuthMethod == "codex-cli" { sel.providerType = providerTypeCodexCLIToken return sel, nil } if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" { sel.providerType = providerTypeCodexAuth return sel, nil } sel.apiKey = cfg.Providers.OpenAI.APIKey sel.apiBase = cfg.Providers.OpenAI.APIBase sel.proxy = cfg.Providers.OpenAI.Proxy if sel.apiBase == "" { sel.apiBase = "https://api.openai.com/v1" } case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "": sel.apiKey = cfg.Providers.Gemini.APIKey sel.apiBase = cfg.Providers.Gemini.APIBase sel.proxy = cfg.Providers.Gemini.Proxy if sel.apiBase == "" { sel.apiBase = "https://generativelanguage.googleapis.com/v1beta" } case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "": sel.apiKey = cfg.Providers.Zhipu.APIKey sel.apiBase = cfg.Providers.Zhipu.APIBase sel.proxy = cfg.Providers.Zhipu.Proxy if sel.apiBase == "" { sel.apiBase = "https://open.bigmodel.cn/api/paas/v4" } case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "": sel.apiKey = cfg.Providers.Groq.APIKey sel.apiBase = cfg.Providers.Groq.APIBase sel.proxy = cfg.Providers.Groq.Proxy if sel.apiBase == "" { sel.apiBase = "https://api.groq.com/openai/v1" } case (strings.Contains(lowerModel, "nvidia") || strings.HasPrefix(model, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "": sel.apiKey = cfg.Providers.Nvidia.APIKey sel.apiBase = cfg.Providers.Nvidia.APIBase sel.proxy = cfg.Providers.Nvidia.Proxy if sel.apiBase == "" { sel.apiBase = "https://integrate.api.nvidia.com/v1" } case strings.HasPrefix(model, "vivgrid/") && cfg.Providers.Vivgrid.APIKey != "": sel.apiKey = cfg.Providers.Vivgrid.APIKey sel.apiBase = cfg.Providers.Vivgrid.APIBase sel.proxy = cfg.Providers.Vivgrid.Proxy if sel.apiBase == "" { sel.apiBase = "https://api.vivgrid.com/v1" } case (strings.Contains(lowerModel, "ollama") || strings.HasPrefix(model, "ollama/")) && cfg.Providers.Ollama.APIKey != "": sel.apiKey = cfg.Providers.Ollama.APIKey sel.apiBase = cfg.Providers.Ollama.APIBase sel.proxy = cfg.Providers.Ollama.Proxy if sel.apiBase == "" { sel.apiBase = "http://localhost:11434/v1" } case (strings.Contains(lowerModel, "mistral") || strings.HasPrefix(model, "mistral/")) && cfg.Providers.Mistral.APIKey != "": sel.apiKey = cfg.Providers.Mistral.APIKey sel.apiBase = cfg.Providers.Mistral.APIBase sel.proxy = cfg.Providers.Mistral.Proxy if sel.apiBase == "" { sel.apiBase = "https://api.mistral.ai/v1" } case (strings.Contains(lowerModel, "minimax") || strings.HasPrefix(model, "minimax/")) && cfg.Providers.Minimax.APIKey != "": sel.apiKey = cfg.Providers.Minimax.APIKey sel.apiBase = cfg.Providers.Minimax.APIBase sel.proxy = cfg.Providers.Minimax.Proxy if sel.apiBase == "" { sel.apiBase = "https://api.minimaxi.com/v1" } case strings.HasPrefix(model, "avian/") && cfg.Providers.Avian.APIKey != "": sel.apiKey = cfg.Providers.Avian.APIKey sel.apiBase = cfg.Providers.Avian.APIBase sel.proxy = cfg.Providers.Avian.Proxy if sel.apiBase == "" { sel.apiBase = "https://api.avian.io/v1" } case (strings.Contains(lowerModel, "longcat") || strings.HasPrefix(model, "longcat/")) && cfg.Providers.LongCat.APIKey != "": sel.apiKey = cfg.Providers.LongCat.APIKey sel.apiBase = cfg.Providers.LongCat.APIBase sel.proxy = cfg.Providers.LongCat.Proxy if sel.apiBase == "" { sel.apiBase = "https://api.longcat.chat/openai" } case cfg.Providers.VLLM.APIBase != "": sel.apiKey = cfg.Providers.VLLM.APIKey sel.apiBase = cfg.Providers.VLLM.APIBase sel.proxy = cfg.Providers.VLLM.Proxy default: if cfg.Providers.OpenRouter.APIKey != "" { sel.apiKey = cfg.Providers.OpenRouter.APIKey sel.proxy = cfg.Providers.OpenRouter.Proxy if cfg.Providers.OpenRouter.APIBase != "" { sel.apiBase = cfg.Providers.OpenRouter.APIBase } else { sel.apiBase = "https://openrouter.ai/api/v1" } } else { return providerSelection{}, fmt.Errorf("no API key configured for model: %s", model) } } } if sel.providerType == providerTypeHTTPCompat { if sel.apiKey == "" && !strings.HasPrefix(model, "bedrock/") { return providerSelection{}, fmt.Errorf("no API key configured for provider (model: %s)", model) } if sel.apiBase == "" { return providerSelection{}, fmt.Errorf("no API base configured for provider (model: %s)", model) } } return sel, nil } ================================================ FILE: pkg/providers/factory_provider.go ================================================ // PicoClaw - Ultra-lightweight personal AI agent // License: MIT // // Copyright (c) 2026 PicoClaw contributors package providers import ( "fmt" "strings" "github.com/sipeed/picoclaw/pkg/config" anthropicmessages "github.com/sipeed/picoclaw/pkg/providers/anthropic_messages" "github.com/sipeed/picoclaw/pkg/providers/azure" ) // createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store. func createClaudeAuthProvider() (LLMProvider, error) { cred, err := getCredential("anthropic") if err != nil { return nil, fmt.Errorf("loading auth credentials: %w", err) } if cred == nil { return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic") } return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil } // createCodexAuthProvider creates a Codex provider using OAuth credentials from auth store. func createCodexAuthProvider() (LLMProvider, error) { cred, err := getCredential("openai") if err != nil { return nil, fmt.Errorf("loading auth credentials: %w", err) } if cred == nil { return nil, fmt.Errorf("no credentials for openai. Run: picoclaw auth login --provider openai") } return NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil } // ExtractProtocol extracts the protocol prefix and model identifier from a model string. // If no prefix is specified, it defaults to "openai". // Examples: // - "openai/gpt-4o" -> ("openai", "gpt-4o") // - "anthropic/claude-sonnet-4.6" -> ("anthropic", "claude-sonnet-4.6") // - "gpt-4o" -> ("openai", "gpt-4o") // default protocol func ExtractProtocol(model string) (protocol, modelID string) { model = strings.TrimSpace(model) protocol, modelID, found := strings.Cut(model, "/") if !found { return "openai", model } return protocol, modelID } // CreateProviderFromConfig creates a provider based on the ModelConfig. // It uses the protocol prefix in the Model field to determine which provider to create. // Supported protocols: openai, litellm, novita, anthropic, anthropic-messages, // antigravity, claude-cli, codex-cli, github-copilot // Returns the provider, the model ID (without protocol prefix), and any error. func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) { if cfg == nil { return nil, "", fmt.Errorf("config is nil") } if cfg.Model == "" { return nil, "", fmt.Errorf("model is required") } protocol, modelID := ExtractProtocol(cfg.Model) switch protocol { case "openai": // OpenAI with OAuth/token auth (Codex-style) if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" { provider, err := createCodexAuthProvider() if err != nil { return nil, "", err } return provider, modelID, nil } // OpenAI with API key if cfg.APIKey == "" && cfg.APIBase == "" { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) } apiBase := cfg.APIBase if apiBase == "" { apiBase = getDefaultAPIBase(protocol) } return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField, cfg.RequestTimeout, ), modelID, nil case "azure", "azure-openai": // Azure OpenAI uses deployment-based URLs, api-key header auth, // and always sends max_completion_tokens. if cfg.APIKey == "" { return nil, "", fmt.Errorf("api_key is required for azure protocol") } if cfg.APIBase == "" { return nil, "", fmt.Errorf( "api_base is required for azure protocol (e.g., https://your-resource.openai.azure.com)", ) } return azure.NewProviderWithTimeout( cfg.APIKey, cfg.APIBase, cfg.Proxy, cfg.RequestTimeout, ), modelID, nil case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl", "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita", "coding-plan", "alibaba-coding", "qwen-coding": // All other OpenAI-compatible HTTP providers if cfg.APIKey == "" && cfg.APIBase == "" { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) } apiBase := cfg.APIBase if apiBase == "" { apiBase = getDefaultAPIBase(protocol) } return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField, cfg.RequestTimeout, ), modelID, nil case "anthropic": if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" { // Use OAuth credentials from auth store provider, err := createClaudeAuthProvider() if err != nil { return nil, "", err } return provider, modelID, nil } // Use API key with HTTP API apiBase := cfg.APIBase if apiBase == "" { apiBase = "https://api.anthropic.com/v1" } if cfg.APIKey == "" { return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model) } return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField, cfg.RequestTimeout, ), modelID, nil case "anthropic-messages": // Anthropic Messages API with native format (HTTP-based, no SDK) apiBase := cfg.APIBase if apiBase == "" { apiBase = "https://api.anthropic.com/v1" } if cfg.APIKey == "" { return nil, "", fmt.Errorf("api_key is required for anthropic-messages protocol (model: %s)", cfg.Model) } return anthropicmessages.NewProviderWithTimeout( cfg.APIKey, apiBase, cfg.RequestTimeout, ), modelID, nil case "coding-plan-anthropic", "alibaba-coding-anthropic": // Alibaba Coding Plan with Anthropic-compatible API apiBase := cfg.APIBase if apiBase == "" { apiBase = getDefaultAPIBase(protocol) } if cfg.APIKey == "" { return nil, "", fmt.Errorf("api_key is required for %q protocol (model: %s)", protocol, cfg.Model) } return anthropicmessages.NewProviderWithTimeout( cfg.APIKey, apiBase, cfg.RequestTimeout, ), modelID, nil case "antigravity": return NewAntigravityProvider(), modelID, nil case "claude-cli", "claudecli": workspace := cfg.Workspace if workspace == "" { workspace = "." } return NewClaudeCliProvider(workspace), modelID, nil case "codex-cli", "codexcli": workspace := cfg.Workspace if workspace == "" { workspace = "." } return NewCodexCliProvider(workspace), modelID, nil case "github-copilot", "copilot": apiBase := cfg.APIBase if apiBase == "" { apiBase = "localhost:4321" } connectMode := cfg.ConnectMode if connectMode == "" { connectMode = "grpc" } provider, err := NewGitHubCopilotProvider(apiBase, connectMode, modelID) if err != nil { return nil, "", err } return provider, modelID, nil default: return nil, "", fmt.Errorf("unknown protocol %q in model %q", protocol, cfg.Model) } } // getDefaultAPIBase returns the default API base URL for a given protocol. func getDefaultAPIBase(protocol string) string { switch protocol { case "openai": return "https://api.openai.com/v1" case "openrouter": return "https://openrouter.ai/api/v1" case "litellm": return "http://localhost:4000/v1" case "novita": return "https://api.novita.ai/openai" case "groq": return "https://api.groq.com/openai/v1" case "zhipu": return "https://open.bigmodel.cn/api/paas/v4" case "gemini": return "https://generativelanguage.googleapis.com/v1beta" case "nvidia": return "https://integrate.api.nvidia.com/v1" case "ollama": return "http://localhost:11434/v1" case "moonshot": return "https://api.moonshot.cn/v1" case "shengsuanyun": return "https://router.shengsuanyun.com/api/v1" case "deepseek": return "https://api.deepseek.com/v1" case "cerebras": return "https://api.cerebras.ai/v1" case "vivgrid": return "https://api.vivgrid.com/v1" case "volcengine": return "https://ark.cn-beijing.volces.com/api/v3" case "qwen": return "https://dashscope.aliyuncs.com/compatible-mode/v1" case "qwen-intl", "qwen-international", "dashscope-intl": return "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" case "qwen-us", "dashscope-us": return "https://dashscope-us.aliyuncs.com/compatible-mode/v1" case "coding-plan", "alibaba-coding", "qwen-coding": return "https://coding-intl.dashscope.aliyuncs.com/v1" case "coding-plan-anthropic", "alibaba-coding-anthropic": return "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" case "vllm": return "http://localhost:8000/v1" case "mistral": return "https://api.mistral.ai/v1" case "avian": return "https://api.avian.io/v1" case "minimax": return "https://api.minimaxi.com/v1" case "longcat": return "https://api.longcat.chat/openai" case "modelscope": return "https://api-inference.modelscope.cn/v1" default: return "" } } ================================================ FILE: pkg/providers/factory_provider_test.go ================================================ // PicoClaw - Ultra-lightweight personal AI agent // License: MIT // // Copyright (c) 2026 PicoClaw contributors package providers import ( "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/sipeed/picoclaw/pkg/config" ) func TestExtractProtocol(t *testing.T) { tests := []struct { name string model string wantProtocol string wantModelID string }{ { name: "openai with prefix", model: "openai/gpt-4o", wantProtocol: "openai", wantModelID: "gpt-4o", }, { name: "anthropic with prefix", model: "anthropic/claude-sonnet-4.6", wantProtocol: "anthropic", wantModelID: "claude-sonnet-4.6", }, { name: "no prefix - defaults to openai", model: "gpt-4o", wantProtocol: "openai", wantModelID: "gpt-4o", }, { name: "groq with prefix", model: "groq/llama-3.1-70b", wantProtocol: "groq", wantModelID: "llama-3.1-70b", }, { name: "empty string", model: "", wantProtocol: "openai", wantModelID: "", }, { name: "with whitespace", model: " openai/gpt-4 ", wantProtocol: "openai", wantModelID: "gpt-4", }, { name: "multiple slashes", model: "nvidia/meta/llama-3.1-8b", wantProtocol: "nvidia", wantModelID: "meta/llama-3.1-8b", }, { name: "azure with prefix", model: "azure/my-gpt5-deployment", wantProtocol: "azure", wantModelID: "my-gpt5-deployment", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { protocol, modelID := ExtractProtocol(tt.model) if protocol != tt.wantProtocol { t.Errorf("ExtractProtocol(%q) protocol = %q, want %q", tt.model, protocol, tt.wantProtocol) } if modelID != tt.wantModelID { t.Errorf("ExtractProtocol(%q) modelID = %q, want %q", tt.model, modelID, tt.wantModelID) } }) } } func TestCreateProviderFromConfig_OpenAI(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-openai", Model: "openai/gpt-4o", APIKey: "test-key", APIBase: "https://api.example.com/v1", } provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { t.Fatalf("CreateProviderFromConfig() error = %v", err) } if provider == nil { t.Fatal("CreateProviderFromConfig() returned nil provider") } if modelID != "gpt-4o" { t.Errorf("modelID = %q, want %q", modelID, "gpt-4o") } } func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) { tests := []struct { name string protocol string }{ {"openai", "openai"}, {"groq", "groq"}, {"novita", "novita"}, {"openrouter", "openrouter"}, {"cerebras", "cerebras"}, {"vivgrid", "vivgrid"}, {"qwen", "qwen"}, {"vllm", "vllm"}, {"deepseek", "deepseek"}, {"ollama", "ollama"}, {"longcat", "longcat"}, {"modelscope", "modelscope"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-" + tt.protocol, Model: tt.protocol + "/test-model", APIKey: "test-key", } provider, _, err := CreateProviderFromConfig(cfg) if err != nil { t.Fatalf("CreateProviderFromConfig() error = %v", err) } // Verify we got an HTTPProvider for all these protocols if _, ok := provider.(*HTTPProvider); !ok { t.Fatalf("expected *HTTPProvider, got %T", provider) } }) } } func TestGetDefaultAPIBase_LiteLLM(t *testing.T) { if got := getDefaultAPIBase("litellm"); got != "http://localhost:4000/v1" { t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "litellm", got, "http://localhost:4000/v1") } } func TestCreateProviderFromConfig_LiteLLM(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-litellm", Model: "litellm/my-proxy-alias", APIKey: "test-key", APIBase: "http://localhost:4000/v1", } provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { t.Fatalf("CreateProviderFromConfig() error = %v", err) } if provider == nil { t.Fatal("CreateProviderFromConfig() returned nil provider") } if modelID != "my-proxy-alias" { t.Errorf("modelID = %q, want %q", modelID, "my-proxy-alias") } } func TestCreateProviderFromConfig_LongCat(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-longcat", Model: "longcat/LongCat-Flash-Thinking", APIKey: "test-key", APIBase: "https://api.longcat.chat/openai", } provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { t.Fatalf("CreateProviderFromConfig() error = %v", err) } if provider == nil { t.Fatal("CreateProviderFromConfig() returned nil provider") } if modelID != "LongCat-Flash-Thinking" { t.Errorf("modelID = %q, want %q", modelID, "LongCat-Flash-Thinking") } if _, ok := provider.(*HTTPProvider); !ok { t.Fatalf("expected *HTTPProvider, got %T", provider) } } func TestCreateProviderFromConfig_ModelScope(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-modelscope", Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507", APIKey: "test-key", APIBase: "https://api-inference.modelscope.cn/v1", } provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { t.Fatalf("CreateProviderFromConfig() error = %v", err) } if provider == nil { t.Fatal("CreateProviderFromConfig() returned nil provider") } if modelID != "Qwen/Qwen3-235B-A22B-Instruct-2507" { t.Errorf("modelID = %q, want %q", modelID, "Qwen/Qwen3-235B-A22B-Instruct-2507") } if _, ok := provider.(*HTTPProvider); !ok { t.Fatalf("expected *HTTPProvider, got %T", provider) } } func TestGetDefaultAPIBase_ModelScope(t *testing.T) { if got := getDefaultAPIBase("modelscope"); got != "https://api-inference.modelscope.cn/v1" { t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "modelscope", got, "https://api-inference.modelscope.cn/v1") } } func TestCreateProviderFromConfig_Novita(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-novita", Model: "novita/deepseek/deepseek-v3.2", APIKey: "test-key", } provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { t.Fatalf("CreateProviderFromConfig() error = %v", err) } if provider == nil { t.Fatal("CreateProviderFromConfig() returned nil provider") } if modelID != "deepseek/deepseek-v3.2" { t.Errorf("modelID = %q, want %q", modelID, "deepseek/deepseek-v3.2") } if _, ok := provider.(*HTTPProvider); !ok { t.Fatalf("expected *HTTPProvider, got %T", provider) } } func TestGetDefaultAPIBase_Novita(t *testing.T) { if got := getDefaultAPIBase("novita"); got != "https://api.novita.ai/openai" { t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "novita", got, "https://api.novita.ai/openai") } } func TestCreateProviderFromConfig_Anthropic(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-anthropic", Model: "anthropic/claude-sonnet-4.6", APIKey: "test-key", } provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { t.Fatalf("CreateProviderFromConfig() error = %v", err) } if provider == nil { t.Fatal("CreateProviderFromConfig() returned nil provider") } if modelID != "claude-sonnet-4.6" { t.Errorf("modelID = %q, want %q", modelID, "claude-sonnet-4.6") } } func TestCreateProviderFromConfig_Antigravity(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-antigravity", Model: "antigravity/gemini-2.0-flash", } provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { t.Fatalf("CreateProviderFromConfig() error = %v", err) } if provider == nil { t.Fatal("CreateProviderFromConfig() returned nil provider") } if modelID != "gemini-2.0-flash" { t.Errorf("modelID = %q, want %q", modelID, "gemini-2.0-flash") } } func TestCreateProviderFromConfig_ClaudeCLI(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-claude-cli", Model: "claude-cli/claude-sonnet-4.6", } provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { t.Fatalf("CreateProviderFromConfig() error = %v", err) } if provider == nil { t.Fatal("CreateProviderFromConfig() returned nil provider") } if modelID != "claude-sonnet-4.6" { t.Errorf("modelID = %q, want %q", modelID, "claude-sonnet-4.6") } } func TestCreateProviderFromConfig_CodexCLI(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-codex-cli", Model: "codex-cli/codex", } provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { t.Fatalf("CreateProviderFromConfig() error = %v", err) } if provider == nil { t.Fatal("CreateProviderFromConfig() returned nil provider") } if modelID != "codex" { t.Errorf("modelID = %q, want %q", modelID, "codex") } } func TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-no-key", Model: "openai/gpt-4o", } _, _, err := CreateProviderFromConfig(cfg) if err == nil { t.Fatal("CreateProviderFromConfig() expected error for missing API key") } } func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-unknown", Model: "unknown-protocol/model", APIKey: "test-key", } _, _, err := CreateProviderFromConfig(cfg) if err == nil { t.Fatal("CreateProviderFromConfig() expected error for unknown protocol") } } func TestCreateProviderFromConfig_NilConfig(t *testing.T) { _, _, err := CreateProviderFromConfig(nil) if err == nil { t.Fatal("CreateProviderFromConfig(nil) expected error") } } func TestCreateProviderFromConfig_EmptyModel(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-empty", Model: "", } _, _, err := CreateProviderFromConfig(cfg) if err == nil { t.Fatal("CreateProviderFromConfig() expected error for empty model") } } func TestCreateProviderFromConfig_RequestTimeoutPropagation(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(1500 * time.Millisecond) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`)) })) defer server.Close() cfg := &config.ModelConfig{ ModelName: "test-timeout", Model: "openai/gpt-4o", APIBase: server.URL, RequestTimeout: 1, } provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { t.Fatalf("CreateProviderFromConfig() error = %v", err) } if modelID != "gpt-4o" { t.Fatalf("modelID = %q, want %q", modelID, "gpt-4o") } _, err = provider.Chat( t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, modelID, nil, ) if err == nil { t.Fatal("Chat() expected timeout error, got nil") } errMsg := err.Error() if !strings.Contains(errMsg, "context deadline exceeded") && !strings.Contains(errMsg, "Client.Timeout exceeded") { t.Fatalf("Chat() error = %q, want timeout-related error", errMsg) } } func TestCreateProviderFromConfig_Azure(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "azure-gpt5", Model: "azure/my-gpt5-deployment", APIKey: "test-azure-key", APIBase: "https://my-resource.openai.azure.com", } provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { t.Fatalf("CreateProviderFromConfig() error = %v", err) } if provider == nil { t.Fatal("CreateProviderFromConfig() returned nil provider") } if modelID != "my-gpt5-deployment" { t.Errorf("modelID = %q, want %q", modelID, "my-gpt5-deployment") } } func TestCreateProviderFromConfig_AzureOpenAIAlias(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "azure-gpt4", Model: "azure-openai/my-deployment", APIKey: "test-azure-key", APIBase: "https://my-resource.openai.azure.com", } provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { t.Fatalf("CreateProviderFromConfig() error = %v", err) } if provider == nil { t.Fatal("CreateProviderFromConfig() returned nil provider") } if modelID != "my-deployment" { t.Errorf("modelID = %q, want %q", modelID, "my-deployment") } } func TestCreateProviderFromConfig_AzureMissingAPIKey(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "azure-gpt5", Model: "azure/my-gpt5-deployment", APIBase: "https://my-resource.openai.azure.com", } _, _, err := CreateProviderFromConfig(cfg) if err == nil { t.Fatal("CreateProviderFromConfig() expected error for missing API key") } } func TestCreateProviderFromConfig_AzureMissingAPIBase(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "azure-gpt5", Model: "azure/my-gpt5-deployment", APIKey: "test-azure-key", } _, _, err := CreateProviderFromConfig(cfg) if err == nil { t.Fatal("CreateProviderFromConfig() expected error for missing API base") } } func TestCreateProviderFromConfig_QwenInternationalAlias(t *testing.T) { tests := []struct { name string protocol string }{ {"qwen-international", "qwen-international"}, {"dashscope-intl", "dashscope-intl"}, {"qwen-intl", "qwen-intl"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-" + tt.protocol, Model: tt.protocol + "/qwen-max", APIKey: "test-key", } provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { t.Fatalf("CreateProviderFromConfig() error = %v", err) } if provider == nil { t.Fatal("CreateProviderFromConfig() returned nil provider") } if modelID != "qwen-max" { t.Errorf("modelID = %q, want %q", modelID, "qwen-max") } if _, ok := provider.(*HTTPProvider); !ok { t.Fatalf("expected *HTTPProvider, got %T", provider) } }) } } func TestCreateProviderFromConfig_QwenUSAlias(t *testing.T) { tests := []struct { name string protocol string }{ {"qwen-us", "qwen-us"}, {"dashscope-us", "dashscope-us"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-" + tt.protocol, Model: tt.protocol + "/qwen-max", APIKey: "test-key", } provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { t.Fatalf("CreateProviderFromConfig() error = %v", err) } if provider == nil { t.Fatal("CreateProviderFromConfig() returned nil provider") } if modelID != "qwen-max" { t.Errorf("modelID = %q, want %q", modelID, "qwen-max") } if _, ok := provider.(*HTTPProvider); !ok { t.Fatalf("expected *HTTPProvider, got %T", provider) } }) } } func TestCreateProviderFromConfig_CodingPlanAnthropic(t *testing.T) { tests := []struct { name string protocol string }{ {"coding-plan-anthropic", "coding-plan-anthropic"}, {"alibaba-coding-anthropic", "alibaba-coding-anthropic"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-" + tt.protocol, Model: tt.protocol + "/claude-sonnet-4-20250514", APIKey: "test-key", } provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { t.Fatalf("CreateProviderFromConfig() error = %v", err) } if provider == nil { t.Fatal("CreateProviderFromConfig() returned nil provider") } if modelID != "claude-sonnet-4-20250514" { t.Errorf("modelID = %q, want %q", modelID, "claude-sonnet-4-20250514") } // coding-plan-anthropic uses Anthropic Messages provider // Verify it's the anthropic messages provider by checking interface var _ LLMProvider = provider }) } } func TestGetDefaultAPIBase_CodingPlanAnthropic(t *testing.T) { expectedURL := "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" if got := getDefaultAPIBase("coding-plan-anthropic"); got != expectedURL { t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "coding-plan-anthropic", got, expectedURL) } if got := getDefaultAPIBase("alibaba-coding-anthropic"); got != expectedURL { t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "alibaba-coding-anthropic", got, expectedURL) } } func TestGetDefaultAPIBase_QwenIntlAliases(t *testing.T) { expectedURL := "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" for _, protocol := range []string{"qwen-intl", "qwen-international", "dashscope-intl"} { if got := getDefaultAPIBase(protocol); got != expectedURL { t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", protocol, got, expectedURL) } } } func TestGetDefaultAPIBase_QwenUSAliases(t *testing.T) { expectedURL := "https://dashscope-us.aliyuncs.com/compatible-mode/v1" for _, protocol := range []string{"qwen-us", "dashscope-us"} { if got := getDefaultAPIBase(protocol); got != expectedURL { t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", protocol, got, expectedURL) } } } ================================================ FILE: pkg/providers/factory_test.go ================================================ package providers import ( "strings" "testing" "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" ) func TestResolveProviderSelection(t *testing.T) { tests := []struct { name string setup func(*config.Config) wantType providerType wantAPIBase string wantProxy string wantErrSubstr string }{ { name: "explicit litellm provider uses configured base", setup: func(cfg *config.Config) { cfg.Agents.Defaults.Provider = "litellm" cfg.Providers.LiteLLM.APIKey = "litellm-key" cfg.Providers.LiteLLM.APIBase = "http://localhost:4000/v1" cfg.Providers.LiteLLM.Proxy = "http://127.0.0.1:7890" }, wantType: providerTypeHTTPCompat, wantAPIBase: "http://localhost:4000/v1", wantProxy: "http://127.0.0.1:7890", }, { name: "explicit litellm provider defaults base when only key is configured", setup: func(cfg *config.Config) { cfg.Agents.Defaults.Provider = "litellm" cfg.Providers.LiteLLM.APIKey = "litellm-key" }, wantType: providerTypeHTTPCompat, wantAPIBase: "http://localhost:4000/v1", }, { name: "explicit claude-cli provider routes to cli provider type", setup: func(cfg *config.Config) { cfg.Agents.Defaults.Provider = "claude-cli" cfg.Agents.Defaults.Workspace = "/tmp/ws" }, wantType: providerTypeClaudeCLI, }, { name: "explicit copilot provider routes to github copilot type", setup: func(cfg *config.Config) { cfg.Agents.Defaults.Provider = "copilot" }, wantType: providerTypeGitHubCopilot, wantAPIBase: "localhost:4321", }, { name: "explicit deepseek provider uses deepseek defaults", setup: func(cfg *config.Config) { cfg.Agents.Defaults.Provider = "deepseek" cfg.Agents.Defaults.Model = "deepseek/deepseek-chat" cfg.Providers.DeepSeek.APIKey = "deepseek-key" cfg.Providers.DeepSeek.Proxy = "http://127.0.0.1:7890" }, wantType: providerTypeHTTPCompat, wantAPIBase: "https://api.deepseek.com/v1", wantProxy: "http://127.0.0.1:7890", }, { name: "explicit shengsuanyun provider uses defaults", setup: func(cfg *config.Config) { cfg.Agents.Defaults.Provider = "shengsuanyun" cfg.Providers.ShengSuanYun.APIKey = "ssy-key" cfg.Providers.ShengSuanYun.Proxy = "http://127.0.0.1:7890" }, wantType: providerTypeHTTPCompat, wantAPIBase: "https://router.shengsuanyun.com/api/v1", wantProxy: "http://127.0.0.1:7890", }, { name: "explicit nvidia provider uses defaults", setup: func(cfg *config.Config) { cfg.Agents.Defaults.Provider = "nvidia" cfg.Providers.Nvidia.APIKey = "nvapi-test" cfg.Providers.Nvidia.Proxy = "http://127.0.0.1:7890" }, wantType: providerTypeHTTPCompat, wantAPIBase: "https://integrate.api.nvidia.com/v1", wantProxy: "http://127.0.0.1:7890", }, { name: "explicit vivgrid provider uses defaults", setup: func(cfg *config.Config) { cfg.Agents.Defaults.Provider = "vivgrid" cfg.Providers.Vivgrid.APIKey = "vivgrid-key" cfg.Providers.Vivgrid.Proxy = "http://127.0.0.1:7890" }, wantType: providerTypeHTTPCompat, wantAPIBase: "https://api.vivgrid.com/v1", wantProxy: "http://127.0.0.1:7890", }, { name: "openrouter model uses openrouter defaults", setup: func(cfg *config.Config) { cfg.Agents.Defaults.Model = "openrouter/auto" cfg.Providers.OpenRouter.APIKey = "sk-or-test" }, wantType: providerTypeHTTPCompat, wantAPIBase: "https://openrouter.ai/api/v1", }, { name: "anthropic oauth routes to claude auth provider", setup: func(cfg *config.Config) { cfg.Agents.Defaults.Model = "claude-sonnet-4.6" cfg.Providers.Anthropic.AuthMethod = "oauth" }, wantType: providerTypeClaudeAuth, }, { name: "openai oauth routes to codex auth provider", setup: func(cfg *config.Config) { cfg.Agents.Defaults.Model = "gpt-4o" cfg.Providers.OpenAI.AuthMethod = "oauth" }, wantType: providerTypeCodexAuth, }, { name: "openai codex-cli auth routes to codex cli token provider", setup: func(cfg *config.Config) { cfg.Agents.Defaults.Model = "gpt-4o" cfg.Providers.OpenAI.AuthMethod = "codex-cli" }, wantType: providerTypeCodexCLIToken, }, { name: "explicit codex-code provider routes to codex cli provider type", setup: func(cfg *config.Config) { cfg.Agents.Defaults.Provider = "codex-code" cfg.Agents.Defaults.Workspace = "/tmp/ws" }, wantType: providerTypeCodexCLI, }, { name: "zhipu model uses zhipu base default", setup: func(cfg *config.Config) { cfg.Agents.Defaults.Model = "glm-4.7" cfg.Providers.Zhipu.APIKey = "zhipu-key" }, wantType: providerTypeHTTPCompat, wantAPIBase: "https://open.bigmodel.cn/api/paas/v4", }, { name: "groq model uses groq base default", setup: func(cfg *config.Config) { cfg.Agents.Defaults.Model = "groq/llama-3.3-70b" cfg.Providers.Groq.APIKey = "gsk-key" }, wantType: providerTypeHTTPCompat, wantAPIBase: "https://api.groq.com/openai/v1", }, { name: "ollama model uses ollama base default", setup: func(cfg *config.Config) { cfg.Agents.Defaults.Model = "ollama/qwen2.5:14b" cfg.Providers.Ollama.APIKey = "ollama-key" }, wantType: providerTypeHTTPCompat, wantAPIBase: "http://localhost:11434/v1", }, { name: "moonshot model keeps proxy and default base", setup: func(cfg *config.Config) { cfg.Agents.Defaults.Model = "moonshot/kimi-k2.5" cfg.Providers.Moonshot.APIKey = "moonshot-key" cfg.Providers.Moonshot.Proxy = "http://127.0.0.1:7890" }, wantType: providerTypeHTTPCompat, wantAPIBase: "https://api.moonshot.cn/v1", wantProxy: "http://127.0.0.1:7890", }, { name: "explicit longcat provider uses defaults", setup: func(cfg *config.Config) { cfg.Agents.Defaults.Provider = "longcat" cfg.Providers.LongCat.APIKey = "longcat-key" cfg.Providers.LongCat.Proxy = "http://127.0.0.1:7890" }, wantType: providerTypeHTTPCompat, wantAPIBase: "https://api.longcat.chat/openai", wantProxy: "http://127.0.0.1:7890", }, { name: "longcat model fallback uses longcat base default", setup: func(cfg *config.Config) { cfg.Agents.Defaults.Model = "longcat/LongCat-Flash-Thinking" cfg.Providers.LongCat.APIKey = "longcat-key" }, wantType: providerTypeHTTPCompat, wantAPIBase: "https://api.longcat.chat/openai", }, { name: "missing keys returns model config error", setup: func(cfg *config.Config) { cfg.Agents.Defaults.Model = "custom-model" }, wantErrSubstr: "no API key configured for model", }, { name: "openrouter prefix without key returns provider key error", setup: func(cfg *config.Config) { cfg.Agents.Defaults.Model = "openrouter/auto" }, wantErrSubstr: "no API key configured for provider", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := config.DefaultConfig() tt.setup(cfg) got, err := resolveProviderSelection(cfg) if tt.wantErrSubstr != "" { if err == nil { t.Fatalf("expected error containing %q, got nil", tt.wantErrSubstr) } if !strings.Contains(err.Error(), tt.wantErrSubstr) { t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErrSubstr) } return } if err != nil { t.Fatalf("resolveProviderSelection() error = %v", err) } if got.providerType != tt.wantType { t.Fatalf("providerType = %v, want %v", got.providerType, tt.wantType) } if tt.wantAPIBase != "" && got.apiBase != tt.wantAPIBase { t.Fatalf("apiBase = %q, want %q", got.apiBase, tt.wantAPIBase) } if tt.wantProxy != "" && got.proxy != tt.wantProxy { t.Fatalf("proxy = %q, want %q", got.proxy, tt.wantProxy) } }) } } func TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) { cfg := config.DefaultConfig() cfg.Agents.Defaults.Model = "test-openrouter" cfg.ModelList = []config.ModelConfig{ { ModelName: "test-openrouter", Model: "openrouter/auto", APIKey: "sk-or-test", APIBase: "https://openrouter.ai/api/v1", }, } provider, _, err := CreateProvider(cfg) if err != nil { t.Fatalf("CreateProvider() error = %v", err) } if _, ok := provider.(*HTTPProvider); !ok { t.Fatalf("provider type = %T, want *HTTPProvider", provider) } } func TestCreateProviderReturnsCodexCliProviderForCodexCode(t *testing.T) { cfg := config.DefaultConfig() cfg.Agents.Defaults.Model = "test-codex" cfg.ModelList = []config.ModelConfig{ { ModelName: "test-codex", Model: "codex-cli/codex-model", Workspace: "/tmp/workspace", }, } provider, _, err := CreateProvider(cfg) if err != nil { t.Fatalf("CreateProvider() error = %v", err) } if _, ok := provider.(*CodexCliProvider); !ok { t.Fatalf("provider type = %T, want *CodexCliProvider", provider) } } func TestCreateProviderReturnsClaudeCliProviderForClaudeCli(t *testing.T) { cfg := config.DefaultConfig() cfg.Agents.Defaults.Model = "test-claude-cli" cfg.ModelList = []config.ModelConfig{ { ModelName: "test-claude-cli", Model: "claude-cli/claude-sonnet", Workspace: "/tmp/workspace", }, } provider, _, err := CreateProvider(cfg) if err != nil { t.Fatalf("CreateProvider() error = %v", err) } if _, ok := provider.(*ClaudeCliProvider); !ok { t.Fatalf("provider type = %T, want *ClaudeCliProvider", provider) } } func TestCreateProviderReturnsClaudeProviderForAnthropicOAuth(t *testing.T) { originalGetCredential := getCredential t.Cleanup(func() { getCredential = originalGetCredential }) getCredential = func(provider string) (*auth.AuthCredential, error) { if provider != "anthropic" { t.Fatalf("provider = %q, want anthropic", provider) } return &auth.AuthCredential{ AccessToken: "anthropic-token", }, nil } cfg := config.DefaultConfig() cfg.Agents.Defaults.Model = "test-claude-oauth" cfg.ModelList = []config.ModelConfig{ { ModelName: "test-claude-oauth", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "oauth", }, } provider, _, err := CreateProvider(cfg) if err != nil { t.Fatalf("CreateProvider() error = %v", err) } if _, ok := provider.(*ClaudeProvider); !ok { t.Fatalf("provider type = %T, want *ClaudeProvider", provider) } // TODO: Test custom APIBase when createClaudeAuthProvider supports it } func TestCreateProviderReturnsCodexProviderForOpenAIOAuth(t *testing.T) { // TODO: This test requires openai protocol to support auth_method: "oauth" // which is not yet implemented in the new factory_provider.go t.Skip("OpenAI OAuth via model_list not yet implemented") } ================================================ FILE: pkg/providers/fallback.go ================================================ package providers import ( "context" "fmt" "strings" "time" ) // FallbackChain orchestrates model fallback across multiple candidates. type FallbackChain struct { cooldown *CooldownTracker } // FallbackCandidate represents one model/provider to try. type FallbackCandidate struct { Provider string Model string } // FallbackResult contains the successful response and metadata about all attempts. type FallbackResult struct { Response *LLMResponse Provider string Model string Attempts []FallbackAttempt } // FallbackAttempt records one attempt in the fallback chain. type FallbackAttempt struct { Provider string Model string Error error Reason FailoverReason Duration time.Duration Skipped bool // true if skipped due to cooldown } // NewFallbackChain creates a new fallback chain with the given cooldown tracker. func NewFallbackChain(cooldown *CooldownTracker) *FallbackChain { return &FallbackChain{cooldown: cooldown} } // ResolveCandidates parses model config into a deduplicated candidate list. func ResolveCandidates(cfg ModelConfig, defaultProvider string) []FallbackCandidate { return ResolveCandidatesWithLookup(cfg, defaultProvider, nil) } func ResolveCandidatesWithLookup( cfg ModelConfig, defaultProvider string, lookup func(raw string) (resolved string, ok bool), ) []FallbackCandidate { seen := make(map[string]bool) var candidates []FallbackCandidate addCandidate := func(raw string) { candidateRaw := strings.TrimSpace(raw) if lookup != nil { if resolved, ok := lookup(candidateRaw); ok { candidateRaw = resolved } } ref := ParseModelRef(candidateRaw, defaultProvider) if ref == nil { return } key := ModelKey(ref.Provider, ref.Model) if seen[key] { return } seen[key] = true candidates = append(candidates, FallbackCandidate{ Provider: ref.Provider, Model: ref.Model, }) } // Primary first. addCandidate(cfg.Primary) // Then fallbacks. for _, fb := range cfg.Fallbacks { addCandidate(fb) } return candidates } // Execute runs the fallback chain for text/chat requests. // It tries each candidate in order, respecting cooldowns and error classification. // // Behavior: // - Candidates in cooldown are skipped (logged as skipped attempt). // - context.Canceled aborts immediately (user abort, no fallback). // - Non-retriable errors (format) abort immediately. // - Retriable errors trigger fallback to next candidate. // - Success marks provider as good (resets cooldown). // - If all fail, returns aggregate error with all attempts. func (fc *FallbackChain) Execute( ctx context.Context, candidates []FallbackCandidate, run func(ctx context.Context, provider, model string) (*LLMResponse, error), ) (*FallbackResult, error) { if len(candidates) == 0 { return nil, fmt.Errorf("fallback: no candidates configured") } result := &FallbackResult{ Attempts: make([]FallbackAttempt, 0, len(candidates)), } for i, candidate := range candidates { // Check context before each attempt. if ctx.Err() == context.Canceled { return nil, context.Canceled } // Check cooldown (per provider/model, not just provider). // This allows multi-key failover where different keys use different model names. cooldownKey := ModelKey(candidate.Provider, candidate.Model) if !fc.cooldown.IsAvailable(cooldownKey) { remaining := fc.cooldown.CooldownRemaining(cooldownKey) result.Attempts = append(result.Attempts, FallbackAttempt{ Provider: candidate.Provider, Model: candidate.Model, Skipped: true, Reason: FailoverRateLimit, Error: fmt.Errorf( "%s in cooldown (%s remaining)", cooldownKey, remaining.Round(time.Second), ), }) continue } // Execute the run function. start := time.Now() resp, err := run(ctx, candidate.Provider, candidate.Model) elapsed := time.Since(start) if err == nil { // Success. fc.cooldown.MarkSuccess(cooldownKey) result.Response = resp result.Provider = candidate.Provider result.Model = candidate.Model return result, nil } // Context cancellation: abort immediately, no fallback. if ctx.Err() == context.Canceled { result.Attempts = append(result.Attempts, FallbackAttempt{ Provider: candidate.Provider, Model: candidate.Model, Error: err, Duration: elapsed, }) return nil, context.Canceled } // Classify the error. failErr := ClassifyError(err, candidate.Provider, candidate.Model) if failErr == nil { // Unclassifiable error: do not fallback, return immediately. result.Attempts = append(result.Attempts, FallbackAttempt{ Provider: candidate.Provider, Model: candidate.Model, Error: err, Duration: elapsed, }) return nil, fmt.Errorf("fallback: unclassified error from %s/%s: %w", candidate.Provider, candidate.Model, err) } // Non-retriable error: abort immediately. if !failErr.IsRetriable() { result.Attempts = append(result.Attempts, FallbackAttempt{ Provider: candidate.Provider, Model: candidate.Model, Error: failErr, Reason: failErr.Reason, Duration: elapsed, }) return nil, failErr } // Retriable error: mark failure and continue to next candidate. fc.cooldown.MarkFailure(cooldownKey, failErr.Reason) result.Attempts = append(result.Attempts, FallbackAttempt{ Provider: candidate.Provider, Model: candidate.Model, Error: failErr, Reason: failErr.Reason, Duration: elapsed, }) // If this was the last candidate, return aggregate error. if i == len(candidates)-1 { return nil, &FallbackExhaustedError{Attempts: result.Attempts} } } // All candidates were skipped (all in cooldown). return nil, &FallbackExhaustedError{Attempts: result.Attempts} } // ExecuteImage runs the fallback chain for image/vision requests. // Simpler than Execute: no cooldown checks (image endpoints have different rate limits). // Image dimension/size errors abort immediately (non-retriable). func (fc *FallbackChain) ExecuteImage( ctx context.Context, candidates []FallbackCandidate, run func(ctx context.Context, provider, model string) (*LLMResponse, error), ) (*FallbackResult, error) { if len(candidates) == 0 { return nil, fmt.Errorf("image fallback: no candidates configured") } result := &FallbackResult{ Attempts: make([]FallbackAttempt, 0, len(candidates)), } for i, candidate := range candidates { if ctx.Err() == context.Canceled { return nil, context.Canceled } start := time.Now() resp, err := run(ctx, candidate.Provider, candidate.Model) elapsed := time.Since(start) if err == nil { result.Response = resp result.Provider = candidate.Provider result.Model = candidate.Model return result, nil } if ctx.Err() == context.Canceled { result.Attempts = append(result.Attempts, FallbackAttempt{ Provider: candidate.Provider, Model: candidate.Model, Error: err, Duration: elapsed, }) return nil, context.Canceled } // Image dimension/size errors are non-retriable. errMsg := strings.ToLower(err.Error()) if IsImageDimensionError(errMsg) || IsImageSizeError(errMsg) { result.Attempts = append(result.Attempts, FallbackAttempt{ Provider: candidate.Provider, Model: candidate.Model, Error: err, Reason: FailoverFormat, Duration: elapsed, }) return nil, &FailoverError{ Reason: FailoverFormat, Provider: candidate.Provider, Model: candidate.Model, Wrapped: err, } } // Any other error: record and try next. result.Attempts = append(result.Attempts, FallbackAttempt{ Provider: candidate.Provider, Model: candidate.Model, Error: err, Duration: elapsed, }) if i == len(candidates)-1 { return nil, &FallbackExhaustedError{Attempts: result.Attempts} } } return nil, &FallbackExhaustedError{Attempts: result.Attempts} } // FallbackExhaustedError indicates all fallback candidates were tried and failed. type FallbackExhaustedError struct { Attempts []FallbackAttempt } func (e *FallbackExhaustedError) Error() string { var sb strings.Builder sb.WriteString(fmt.Sprintf("fallback: all %d candidates failed:", len(e.Attempts))) for i, a := range e.Attempts { if a.Skipped { sb.WriteString(fmt.Sprintf("\n [%d] %s/%s: skipped (cooldown)", i+1, a.Provider, a.Model)) } else { sb.WriteString(fmt.Sprintf("\n [%d] %s/%s: %v (reason=%s, %s)", i+1, a.Provider, a.Model, a.Error, a.Reason, a.Duration.Round(time.Millisecond))) } } return sb.String() } ================================================ FILE: pkg/providers/fallback_multikey_test.go ================================================ package providers import ( "context" "errors" "testing" ) // TestMultiKeyFailover tests the complete failover flow with multiple API keys. // This simulates the config expansion scenario where api_keys: ["key1", "key2", "key3"] // is expanded into primary + fallbacks. func TestMultiKeyFailover(t *testing.T) { // Simulate expanded config: primary with 2 fallbacks // This is what ExpandMultiKeyModels would produce for api_keys: ["key1", "key2", "key3"] cfg := ModelConfig{ Primary: "glm-4.7", Fallbacks: []string{"glm-4.7__key_1", "glm-4.7__key_2"}, } candidates := ResolveCandidates(cfg, "zhipu") if len(candidates) != 3 { t.Fatalf("expected 3 candidates, got %d: %v", len(candidates), candidates) } // Create fallback chain cooldown := NewCooldownTracker() chain := NewFallbackChain(cooldown) // Mock run function: first call fails with 429, second succeeds callCount := 0 mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) { callCount++ if callCount == 1 { // First call: simulate rate limit return nil, errors.New("http error: status 429 - rate limit exceeded") } // Second call: success return &LLMResponse{ Content: "Hello from key2!", }, nil } // Execute fallback chain result, err := chain.Execute(context.Background(), candidates, mockRun) if err != nil { t.Fatalf("expected success after failover, got error: %v", err) } if result == nil { t.Fatal("expected result, got nil") } if result.Response.Content != "Hello from key2!" { t.Errorf("expected response from key2, got: %s", result.Response.Content) } if callCount != 2 { t.Errorf("expected 2 calls (1 fail + 1 success), got %d", callCount) } // Verify first attempt was recorded if len(result.Attempts) != 1 { t.Errorf("expected 1 failed attempt recorded, got %d", len(result.Attempts)) } if result.Attempts[0].Reason != FailoverRateLimit { t.Errorf( "expected first attempt reason to be rate_limit, got: %s", result.Attempts[0].Reason, ) } } // TestMultiKeyFailoverAllFail tests when all keys hit rate limit func TestMultiKeyFailoverAllFail(t *testing.T) { cfg := ModelConfig{ Primary: "glm-4.7", Fallbacks: []string{"glm-4.7__key_1", "glm-4.7__key_2"}, } candidates := ResolveCandidates(cfg, "zhipu") cooldown := NewCooldownTracker() chain := NewFallbackChain(cooldown) // Mock run function: all calls fail with rate limit callCount := 0 mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) { callCount++ return nil, errors.New("status: 429 - too many requests") } // Execute fallback chain result, err := chain.Execute(context.Background(), candidates, mockRun) if err == nil { t.Fatal("expected error when all keys fail, got nil") } if result != nil { t.Errorf("expected nil result on failure, got: %v", result) } if callCount != 3 { t.Errorf("expected 3 calls (all fail), got %d", callCount) } // Verify error type var exhausted *FallbackExhaustedError if !errors.As(err, &exhausted) { t.Errorf("expected FallbackExhaustedError, got: %T - %v", err, err) } if len(exhausted.Attempts) != 3 { t.Errorf("expected 3 attempts in exhausted error, got %d", len(exhausted.Attempts)) } } // TestMultiKeyFailoverCooldown tests that a key in cooldown is skipped func TestMultiKeyFailoverCooldown(t *testing.T) { cfg := ModelConfig{ Primary: "glm-4.7", Fallbacks: []string{"glm-4.7__key_1"}, } candidates := ResolveCandidates(cfg, "zhipu") cooldown := NewCooldownTracker() chain := NewFallbackChain(cooldown) // Put the first model in cooldown (using ModelKey now, not just provider) cooldownKey := ModelKey(candidates[0].Provider, candidates[0].Model) cooldown.MarkFailure(cooldownKey, FailoverRateLimit) // Verify it's not available if cooldown.IsAvailable(cooldownKey) { t.Fatal("expected first model to be in cooldown") } // Mock run function: only second should be called callCount := 0 calledProviders := []string{} mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) { callCount++ calledProviders = append(calledProviders, provider+"/"+model) return &LLMResponse{Content: "success"}, nil } result, err := chain.Execute(context.Background(), candidates, mockRun) if err != nil { t.Fatalf("expected success, got error: %v", err) } // First provider should have been skipped if callCount != 1 { t.Errorf("expected 1 call (first skipped due to cooldown), got %d", callCount) } // Should have called the second provider/model if len(calledProviders) != 1 || calledProviders[0] != candidates[1].Provider+"/"+candidates[1].Model { t.Errorf("expected second model to be called, got: %v", calledProviders) } // Verify first attempt was recorded as skipped if len(result.Attempts) != 1 { t.Fatalf("expected 1 attempt (skipped), got %d", len(result.Attempts)) } if !result.Attempts[0].Skipped { t.Error("expected first attempt to be marked as skipped") } } // TestMultiKeyFailoverWithFormatError tests that format errors are non-retriable func TestMultiKeyFailoverWithFormatError(t *testing.T) { cfg := ModelConfig{ Primary: "glm-4.7", Fallbacks: []string{"glm-4.7__key_1"}, } candidates := ResolveCandidates(cfg, "zhipu") cooldown := NewCooldownTracker() chain := NewFallbackChain(cooldown) // Mock run function: first call fails with format error (bad request) callCount := 0 mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) { callCount++ return nil, errors.New("invalid request format: tool_use.id missing") } // Execute fallback chain result, err := chain.Execute(context.Background(), candidates, mockRun) if err == nil { t.Fatal("expected error for format failure, got nil") } // Format errors should NOT trigger failover (non-retriable) // So we should only have 1 call if callCount != 1 { t.Errorf("expected 1 call (format error is non-retriable), got %d", callCount) } // Verify the error is a FailoverError with format reason var failoverErr *FailoverError if !errors.As(err, &failoverErr) { t.Errorf("expected FailoverError, got: %T - %v", err, err) } if failoverErr.Reason != FailoverFormat { t.Errorf("expected FailoverFormat reason, got: %s", failoverErr.Reason) } _ = result // result should be nil } // TestMultiKeyWithModelFallback tests multi-key failover combined with model fallback. // This simulates the scenario: api_keys: ["k1", "k2"] + fallbacks: ["minimax"] // Expected failover order: glm-4.7 (k1) → glm-4.7__key_1 (k2) → minimax func TestMultiKeyWithModelFallback(t *testing.T) { // Simulate expanded config from: // { "model_name": "glm-4.7", "api_keys": ["k1", "k2"], "fallbacks": ["minimax"] } // After ExpandMultiKeyModels, primaryEntry.Fallbacks = ["glm-4.7__key_1", "minimax"] // Note: In production, "minimax" would be resolved via model lookup to "minimax/minimax" // In this test, we use the full format to avoid needing a lookup function. cfg := ModelConfig{ Primary: "glm-4.7", Fallbacks: []string{"glm-4.7__key_1", "minimax/minimax"}, } candidates := ResolveCandidates(cfg, "zhipu") // Should have 3 candidates: glm-4.7 (zhipu), glm-4.7__key_1 (zhipu), minimax (minimax) if len(candidates) != 3 { t.Fatalf("expected 3 candidates, got %d: %v", len(candidates), candidates) } // Verify candidate order if candidates[0].Model != "glm-4.7" || candidates[0].Provider != "zhipu" { t.Errorf( "expected first candidate to be zhipu/glm-4.7, got: %s/%s", candidates[0].Provider, candidates[0].Model, ) } if candidates[1].Model != "glm-4.7__key_1" || candidates[1].Provider != "zhipu" { t.Errorf( "expected second candidate to be zhipu/glm-4.7__key_1, got: %s/%s", candidates[1].Provider, candidates[1].Model, ) } if candidates[2].Model != "minimax" || candidates[2].Provider != "minimax" { t.Errorf( "expected third candidate to be minimax/minimax, got: %s/%s", candidates[2].Provider, candidates[2].Model, ) } cooldown := NewCooldownTracker() chain := NewFallbackChain(cooldown) // Mock run function: first two fail, third succeeds (model fallback) callCount := 0 calledModels := []string{} mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) { callCount++ calledModels = append(calledModels, provider+"/"+model) switch callCount { case 1: // k1: rate limit return nil, errors.New("status: 429 - rate limit") case 2: // k2: also rate limit (all zhipu keys exhausted) return nil, errors.New("status: 429 - rate limit") case 3: // minimax: success return &LLMResponse{Content: "success from minimax"}, nil default: return nil, errors.New("unexpected call") } } result, err := chain.Execute(context.Background(), candidates, mockRun) if err != nil { t.Fatalf("expected success after failover to model fallback, got error: %v", err) } if callCount != 3 { t.Errorf("expected 3 calls (k1 fail + k2 fail + minimax success), got %d", callCount) } if result.Response.Content != "success from minimax" { t.Errorf("expected response from minimax, got: %s", result.Response.Content) } // Verify call order if len(calledModels) != 3 { t.Fatalf("expected 3 called models, got %d", len(calledModels)) } if calledModels[0] != "zhipu/glm-4.7" { t.Errorf("expected first call to zhipu/glm-4.7, got: %s", calledModels[0]) } if calledModels[1] != "zhipu/glm-4.7__key_1" { t.Errorf("expected second call to zhipu/glm-4.7__key_1, got: %s", calledModels[1]) } if calledModels[2] != "minimax/minimax" { t.Errorf("expected third call to minimax/minimax, got: %s", calledModels[2]) } // Verify 2 failed attempts recorded if len(result.Attempts) != 2 { t.Errorf("expected 2 failed attempts, got %d", len(result.Attempts)) } // Both should be rate limit for i, attempt := range result.Attempts { if attempt.Reason != FailoverRateLimit { t.Errorf("expected attempt %d to be rate_limit, got: %s", i, attempt.Reason) } } } // TestMultiKeyFailoverMixedErrors tests failover with different error types func TestMultiKeyFailoverMixedErrors(t *testing.T) { cfg := ModelConfig{ Primary: "glm-4.7", Fallbacks: []string{"glm-4.7__key_1", "glm-4.7__key_2"}, } candidates := ResolveCandidates(cfg, "zhipu") cooldown := NewCooldownTracker() chain := NewFallbackChain(cooldown) // Mock run function: different errors for each key callCount := 0 mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) { callCount++ switch callCount { case 1: // First: rate limit (retriable) return nil, errors.New("status: 429 - rate limit") case 2: // Second: timeout (retriable) return nil, errors.New("context deadline exceeded") case 3: // Third: success return &LLMResponse{Content: "success from key3"}, nil default: return nil, errors.New("unexpected call") } } result, err := chain.Execute(context.Background(), candidates, mockRun) if err != nil { t.Fatalf("expected success after 2 failovers, got error: %v", err) } if callCount != 3 { t.Errorf("expected 3 calls, got %d", callCount) } // Verify both failed attempts were recorded if len(result.Attempts) != 2 { t.Errorf("expected 2 failed attempts, got %d", len(result.Attempts)) } // First should be rate limit if result.Attempts[0].Reason != FailoverRateLimit { t.Errorf("expected first attempt to be rate_limit, got: %s", result.Attempts[0].Reason) } // Second should be timeout if result.Attempts[1].Reason != FailoverTimeout { t.Errorf("expected second attempt to be timeout, got: %s", result.Attempts[1].Reason) } } ================================================ FILE: pkg/providers/fallback_test.go ================================================ package providers import ( "context" "errors" "testing" "time" ) func makeCandidate(provider, model string) FallbackCandidate { return FallbackCandidate{Provider: provider, Model: model} } func successRun(content string) func(ctx context.Context, provider, model string) (*LLMResponse, error) { return func(ctx context.Context, provider, model string) (*LLMResponse, error) { return &LLMResponse{Content: content, FinishReason: "stop"}, nil } } func TestFallback_SingleCandidate_Success(t *testing.T) { ct := NewCooldownTracker() fc := NewFallbackChain(ct) candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")} result, err := fc.Execute(context.Background(), candidates, successRun("hello")) if err != nil { t.Fatalf("unexpected error: %v", err) } if result.Response.Content != "hello" { t.Errorf("content = %q, want hello", result.Response.Content) } if result.Provider != "openai" || result.Model != "gpt-4" { t.Errorf("provider/model = %s/%s, want openai/gpt-4", result.Provider, result.Model) } } func TestFallback_SecondCandidateSuccess(t *testing.T) { ct := NewCooldownTracker() fc := NewFallbackChain(ct) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4"), makeCandidate("anthropic", "claude-opus"), } attempt := 0 run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { attempt++ if attempt == 1 { return nil, errors.New("rate limit exceeded") } return &LLMResponse{Content: "from claude", FinishReason: "stop"}, nil } result, err := fc.Execute(context.Background(), candidates, run) if err != nil { t.Fatalf("unexpected error: %v", err) } if result.Provider != "anthropic" { t.Errorf("provider = %q, want anthropic", result.Provider) } if result.Response.Content != "from claude" { t.Errorf("content = %q, want 'from claude'", result.Response.Content) } if len(result.Attempts) != 1 { t.Errorf("attempts = %d, want 1 (failed attempt recorded)", len(result.Attempts)) } } func TestFallback_AllFail(t *testing.T) { ct := NewCooldownTracker() fc := NewFallbackChain(ct) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4"), makeCandidate("anthropic", "claude"), makeCandidate("groq", "llama"), } run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { return nil, errors.New("rate limit exceeded") } _, err := fc.Execute(context.Background(), candidates, run) if err == nil { t.Fatal("expected error when all candidates fail") } var exhausted *FallbackExhaustedError if !errors.As(err, &exhausted) { t.Errorf("expected FallbackExhaustedError, got %T: %v", err, err) } if len(exhausted.Attempts) != 3 { t.Errorf("attempts = %d, want 3", len(exhausted.Attempts)) } } func TestFallback_ContextCanceled(t *testing.T) { ct := NewCooldownTracker() fc := NewFallbackChain(ct) ctx, cancel := context.WithCancel(context.Background()) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4"), makeCandidate("anthropic", "claude"), } attempt := 0 run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { attempt++ if attempt == 1 { cancel() // cancel context return nil, context.Canceled } t.Error("should not reach second candidate after cancel") return nil, nil } _, err := fc.Execute(ctx, candidates, run) if err != context.Canceled { t.Errorf("expected context.Canceled, got %v", err) } } func TestFallback_NonRetriableError(t *testing.T) { ct := NewCooldownTracker() fc := NewFallbackChain(ct) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4"), makeCandidate("anthropic", "claude"), } attempt := 0 run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { attempt++ return nil, errors.New("string should match pattern") } _, err := fc.Execute(context.Background(), candidates, run) if err == nil { t.Fatal("expected error for non-retriable") } var fe *FailoverError if !errors.As(err, &fe) { t.Fatalf("expected FailoverError, got %T", err) } if fe.Reason != FailoverFormat { t.Errorf("reason = %q, want format", fe.Reason) } if attempt != 1 { t.Errorf("attempt = %d, want 1 (non-retriable should not try next)", attempt) } } func TestFallback_CooldownSkip(t *testing.T) { now := time.Now() ct, _ := newTestTracker(now) fc := NewFallbackChain(ct) // Put openai/gpt-4 in cooldown (using ModelKey now) ct.MarkFailure(ModelKey("openai", "gpt-4"), FailoverRateLimit) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4"), makeCandidate("anthropic", "claude"), } run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { if provider == "openai" { t.Error("should not call openai (in cooldown)") } return &LLMResponse{Content: "claude response", FinishReason: "stop"}, nil } result, err := fc.Execute(context.Background(), candidates, run) if err != nil { t.Fatalf("unexpected error: %v", err) } if result.Provider != "anthropic" { t.Errorf("provider = %q, want anthropic", result.Provider) } // Should have 1 skipped attempt skipped := 0 for _, a := range result.Attempts { if a.Skipped { skipped++ } } if skipped != 1 { t.Errorf("skipped = %d, want 1", skipped) } } func TestFallback_AllInCooldown(t *testing.T) { ct := NewCooldownTracker() fc := NewFallbackChain(ct) // Put all models in cooldown (using ModelKey now) ct.MarkFailure(ModelKey("openai", "gpt-4"), FailoverRateLimit) ct.MarkFailure(ModelKey("anthropic", "claude"), FailoverBilling) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4"), makeCandidate("anthropic", "claude"), } _, err := fc.Execute(context.Background(), candidates, func(ctx context.Context, provider, model string) (*LLMResponse, error) { t.Error("should not call any provider (all in cooldown)") return nil, nil }) if err == nil { t.Fatal("expected error when all in cooldown") } var exhausted *FallbackExhaustedError if !errors.As(err, &exhausted) { t.Fatalf("expected FallbackExhaustedError, got %T", err) } } func TestFallback_NoCandidates(t *testing.T) { ct := NewCooldownTracker() fc := NewFallbackChain(ct) _, err := fc.Execute(context.Background(), nil, successRun("ok")) if err == nil { t.Error("expected error for empty candidates") } } func TestFallback_EmptyFallbacks(t *testing.T) { // Single primary, no fallbacks: should work like direct call ct := NewCooldownTracker() fc := NewFallbackChain(ct) candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")} result, err := fc.Execute(context.Background(), candidates, successRun("ok")) if err != nil { t.Fatalf("unexpected error: %v", err) } if result.Response.Content != "ok" { t.Error("expected success with single candidate") } } func TestFallback_UnclassifiedError(t *testing.T) { ct := NewCooldownTracker() fc := NewFallbackChain(ct) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4"), makeCandidate("anthropic", "claude"), } attempt := 0 run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { attempt++ return nil, errors.New("completely unknown internal error") } _, err := fc.Execute(context.Background(), candidates, run) if err == nil { t.Fatal("expected error for unclassified error") } if attempt != 1 { t.Errorf("attempt = %d, want 1 (should not fallback on unclassified)", attempt) } } func TestFallback_SuccessResetsCooldown(t *testing.T) { ct := NewCooldownTracker() fc := NewFallbackChain(ct) candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")} modelKey := ModelKey("openai", "gpt-4") attempt := 0 run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { attempt++ if attempt == 1 { ct.MarkFailure(modelKey, FailoverRateLimit) // simulate failure tracked elsewhere } return &LLMResponse{Content: "ok", FinishReason: "stop"}, nil } _, err := fc.Execute(context.Background(), candidates, run) if err != nil { t.Fatalf("unexpected error: %v", err) } if !ct.IsAvailable(modelKey) { t.Error("success should reset cooldown") } } // --- Image Fallback Tests --- func TestImageFallback_Success(t *testing.T) { ct := NewCooldownTracker() fc := NewFallbackChain(ct) candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4o")} result, err := fc.ExecuteImage(context.Background(), candidates, successRun("image result")) if err != nil { t.Fatalf("unexpected error: %v", err) } if result.Response.Content != "image result" { t.Error("expected image result") } } func TestImageFallback_DimensionError(t *testing.T) { ct := NewCooldownTracker() fc := NewFallbackChain(ct) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4o"), makeCandidate("anthropic", "claude"), } attempt := 0 run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { attempt++ return nil, errors.New("image dimensions exceed max 4096x4096") } _, err := fc.ExecuteImage(context.Background(), candidates, run) if err == nil { t.Fatal("expected error for image dimension error") } if attempt != 1 { t.Errorf("attempt = %d, want 1 (image dimension error should not retry)", attempt) } } func TestImageFallback_SizeError(t *testing.T) { ct := NewCooldownTracker() fc := NewFallbackChain(ct) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4o"), makeCandidate("anthropic", "claude"), } attempt := 0 run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { attempt++ return nil, errors.New("image exceeds 20 mb") } _, err := fc.ExecuteImage(context.Background(), candidates, run) if err == nil { t.Fatal("expected error for image size error") } if attempt != 1 { t.Errorf("attempt = %d, want 1 (image size error should not retry)", attempt) } } func TestImageFallback_RetryOnOtherErrors(t *testing.T) { ct := NewCooldownTracker() fc := NewFallbackChain(ct) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4o"), makeCandidate("anthropic", "claude-sonnet"), } attempt := 0 run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { attempt++ if attempt == 1 { return nil, errors.New("rate limit exceeded") } return &LLMResponse{Content: "image ok", FinishReason: "stop"}, nil } result, err := fc.ExecuteImage(context.Background(), candidates, run) if err != nil { t.Fatalf("unexpected error: %v", err) } if result.Provider != "anthropic" { t.Errorf("provider = %q, want anthropic", result.Provider) } } func TestImageFallback_NoCandidates(t *testing.T) { ct := NewCooldownTracker() fc := NewFallbackChain(ct) _, err := fc.ExecuteImage(context.Background(), nil, successRun("ok")) if err == nil { t.Error("expected error for empty candidates") } } // --- ResolveCandidates Tests --- func TestResolveCandidates_Simple(t *testing.T) { cfg := ModelConfig{ Primary: "gpt-4", Fallbacks: []string{"anthropic/claude-opus", "groq/llama-3"}, } candidates := ResolveCandidates(cfg, "openai") if len(candidates) != 3 { t.Fatalf("candidates = %d, want 3", len(candidates)) } if candidates[0].Provider != "openai" || candidates[0].Model != "gpt-4" { t.Errorf("candidate[0] = %s/%s, want openai/gpt-4", candidates[0].Provider, candidates[0].Model) } if candidates[1].Provider != "anthropic" || candidates[1].Model != "claude-opus" { t.Errorf("candidate[1] = %s/%s, want anthropic/claude-opus", candidates[1].Provider, candidates[1].Model) } if candidates[2].Provider != "groq" || candidates[2].Model != "llama-3" { t.Errorf("candidate[2] = %s/%s, want groq/llama-3", candidates[2].Provider, candidates[2].Model) } } func TestResolveCandidates_Deduplication(t *testing.T) { cfg := ModelConfig{ Primary: "openai/gpt-4", Fallbacks: []string{"openai/gpt-4", "anthropic/claude"}, } candidates := ResolveCandidates(cfg, "default") if len(candidates) != 2 { t.Errorf("candidates = %d, want 2 (duplicate removed)", len(candidates)) } } func TestResolveCandidates_EmptyFallbacks(t *testing.T) { cfg := ModelConfig{ Primary: "gpt-4", Fallbacks: nil, } candidates := ResolveCandidates(cfg, "openai") if len(candidates) != 1 { t.Errorf("candidates = %d, want 1", len(candidates)) } } func TestResolveCandidates_EmptyPrimary(t *testing.T) { cfg := ModelConfig{ Primary: "", Fallbacks: []string{"anthropic/claude"}, } candidates := ResolveCandidates(cfg, "openai") if len(candidates) != 1 { t.Errorf("candidates = %d, want 1", len(candidates)) } } func TestResolveCandidatesWithLookup_AliasResolvesToNestedModel(t *testing.T) { cfg := ModelConfig{ Primary: "step-3.5-flash", Fallbacks: nil, } lookup := func(raw string) (string, bool) { if raw == "step-3.5-flash" { return "openrouter/stepfun/step-3.5-flash:free", true } return "", false } candidates := ResolveCandidatesWithLookup(cfg, "", lookup) if len(candidates) != 1 { t.Fatalf("candidates = %d, want 1", len(candidates)) } if candidates[0].Provider != "openrouter" { t.Fatalf("provider = %q, want openrouter", candidates[0].Provider) } if candidates[0].Model != "stepfun/step-3.5-flash:free" { t.Fatalf("model = %q, want stepfun/step-3.5-flash:free", candidates[0].Model) } } func TestResolveCandidatesWithLookup_DeduplicateAfterLookup(t *testing.T) { cfg := ModelConfig{ Primary: "step-3.5-flash", Fallbacks: []string{"openrouter/stepfun/step-3.5-flash:free"}, } lookup := func(raw string) (string, bool) { if raw == "step-3.5-flash" { return "openrouter/stepfun/step-3.5-flash:free", true } return "", false } candidates := ResolveCandidatesWithLookup(cfg, "", lookup) if len(candidates) != 1 { t.Fatalf("candidates = %d, want 1", len(candidates)) } } func TestResolveCandidatesWithLookup_AliasWithoutProtocolUsesDefaultProvider(t *testing.T) { cfg := ModelConfig{ Primary: "glm-5", Fallbacks: nil, } lookup := func(raw string) (string, bool) { if raw == "glm-5" { return "glm-5", true } return "", false } candidates := ResolveCandidatesWithLookup(cfg, "openai", lookup) if len(candidates) != 1 { t.Fatalf("candidates = %d, want 1", len(candidates)) } if candidates[0].Provider != "openai" { t.Fatalf("provider = %q, want openai", candidates[0].Provider) } if candidates[0].Model != "glm-5" { t.Fatalf("model = %q, want glm-5", candidates[0].Model) } } func TestFallbackExhaustedError_Message(t *testing.T) { e := &FallbackExhaustedError{ Attempts: []FallbackAttempt{ { Provider: "openai", Model: "gpt-4", Error: errors.New("rate limited"), Reason: FailoverRateLimit, Duration: 500 * time.Millisecond, }, {Provider: "anthropic", Model: "claude", Skipped: true}, }, } msg := e.Error() if msg == "" { t.Error("expected non-empty error message") } } ================================================ FILE: pkg/providers/github_copilot_provider.go ================================================ package providers import ( "context" "encoding/json" "fmt" "sync" copilot "github.com/github/copilot-sdk/go" ) type GitHubCopilotProvider struct { uri string connectMode string // "stdio" or "grpc" client *copilot.Client session *copilot.Session mu sync.Mutex } func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) { if connectMode == "" { connectMode = "grpc" } switch connectMode { case "stdio": // TODO: Implement stdio mode for GitHub Copilot provider // See https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md for details return nil, fmt.Errorf("stdio mode not implemented for GitHub Copilot provider; please use 'grpc' mode instead") case "grpc": client := copilot.NewClient(&copilot.ClientOptions{ CLIUrl: uri, }) if err := client.Start(context.Background()); err != nil { return nil, fmt.Errorf( "can't connect to Github Copilot: %w; `https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server` for details", err, ) } session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ Model: model, Hooks: &copilot.SessionHooks{}, }) if err != nil { client.Stop() return nil, fmt.Errorf("create session failed: %w", err) } return &GitHubCopilotProvider{ uri: uri, connectMode: connectMode, client: client, session: session, }, nil default: return nil, fmt.Errorf("unknown connect mode: %s", connectMode) } } func (p *GitHubCopilotProvider) Close() { p.mu.Lock() defer p.mu.Unlock() if p.client != nil { p.client.Stop() p.client = nil p.session = nil } } func (p *GitHubCopilotProvider) Chat( ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, ) (*LLMResponse, error) { type tempMessage struct { Role string `json:"role"` Content string `json:"content"` } out := make([]tempMessage, 0, len(messages)) for _, msg := range messages { out = append(out, tempMessage{ Role: msg.Role, Content: msg.Content, }) } fullcontent, err := json.Marshal(out) if err != nil { return nil, fmt.Errorf("marshal messages: %w", err) } p.mu.Lock() session := p.session p.mu.Unlock() if session == nil { return nil, fmt.Errorf("provider closed") } resp, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: string(fullcontent), }) if err != nil { return nil, fmt.Errorf("failed to send message to copilot: %w", err) } if resp == nil { return nil, fmt.Errorf("empty response from copilot") } if resp.Data.Content == nil { return nil, fmt.Errorf("no content in copilot response") } content := *resp.Data.Content return &LLMResponse{ FinishReason: "stop", Content: content, }, nil } func (p *GitHubCopilotProvider) GetDefaultModel() string { return "gpt-4.1" } ================================================ FILE: pkg/providers/http_provider.go ================================================ // PicoClaw - Ultra-lightweight personal AI agent // Inspired by and based on nanobot: https://github.com/HKUDS/nanobot // License: MIT // // Copyright (c) 2026 PicoClaw contributors package providers import ( "context" "time" "github.com/sipeed/picoclaw/pkg/providers/openai_compat" ) type HTTPProvider struct { delegate *openai_compat.Provider } func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider { return &HTTPProvider{ delegate: openai_compat.NewProvider(apiKey, apiBase, proxy), } } func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider { return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0) } func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( apiKey, apiBase, proxy, maxTokensField string, requestTimeoutSeconds int, ) *HTTPProvider { return &HTTPProvider{ delegate: openai_compat.NewProvider( apiKey, apiBase, proxy, openai_compat.WithMaxTokensField(maxTokensField), openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second), ), } } func (p *HTTPProvider) Chat( ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, ) (*LLMResponse, error) { return p.delegate.Chat(ctx, messages, tools, model, options) } func (p *HTTPProvider) GetDefaultModel() string { return "" } func (p *HTTPProvider) SupportsNativeSearch() bool { return p.delegate.SupportsNativeSearch() } ================================================ FILE: pkg/providers/legacy_provider.go ================================================ // PicoClaw - Ultra-lightweight personal AI agent // License: MIT // // Copyright (c) 2026 PicoClaw contributors package providers import ( "fmt" "github.com/sipeed/picoclaw/pkg/config" ) // CreateProvider creates a provider based on the configuration. // It uses the model_list configuration (new format) to create providers. // The old providers config is automatically converted to model_list during config loading. // Returns the provider, the model ID to use, and any error. func CreateProvider(cfg *config.Config) (LLMProvider, string, error) { model := cfg.Agents.Defaults.GetModelName() // Ensure model_list is populated from providers config if needed // This handles two cases: // 1. ModelList is empty - convert all providers // 2. ModelList has some entries but not all providers - merge missing ones if cfg.HasProvidersConfig() { providerModels := config.ConvertProvidersToModelList(cfg) existingModelNames := make(map[string]bool) for _, m := range cfg.ModelList { existingModelNames[m.ModelName] = true } for _, pm := range providerModels { if !existingModelNames[pm.ModelName] { cfg.ModelList = append(cfg.ModelList, pm) } } } // Must have model_list at this point if len(cfg.ModelList) == 0 { return nil, "", fmt.Errorf("no providers configured. Please add entries to model_list in your config") } // Get model config from model_list modelCfg, err := cfg.GetModelConfig(model) if err != nil { return nil, "", fmt.Errorf("model %q not found in model_list: %w", model, err) } // Inject global workspace if not set in model config if modelCfg.Workspace == "" { modelCfg.Workspace = cfg.WorkspacePath() } // Use factory to create provider provider, modelID, err := CreateProviderFromConfig(modelCfg) if err != nil { return nil, "", fmt.Errorf("failed to create provider for model %q: %w", model, err) } return provider, modelID, nil } ================================================ FILE: pkg/providers/model_ref.go ================================================ package providers import "strings" // ModelRef represents a parsed model reference with provider and model name. type ModelRef struct { Provider string Model string } // ParseModelRef parses "anthropic/claude-opus" into {Provider: "anthropic", Model: "claude-opus"}. // If no slash present, uses defaultProvider. // Returns nil for empty input. func ParseModelRef(raw string, defaultProvider string) *ModelRef { raw = strings.TrimSpace(raw) if raw == "" { return nil } if idx := strings.Index(raw, "/"); idx > 0 { provider := NormalizeProvider(raw[:idx]) model := strings.TrimSpace(raw[idx+1:]) if model == "" { return nil } return &ModelRef{Provider: provider, Model: model} } return &ModelRef{ Provider: NormalizeProvider(defaultProvider), Model: raw, } } // NormalizeProvider normalizes provider identifiers to canonical form. func NormalizeProvider(provider string) string { p := strings.ToLower(strings.TrimSpace(provider)) switch p { case "z.ai", "z-ai": return "zai" case "opencode-zen": return "opencode" case "qwen": return "qwen-portal" case "kimi-code": return "kimi-coding" case "gpt": return "openai" case "claude": return "anthropic" case "glm": return "zhipu" case "google": return "gemini" case "alibaba-coding", "qwen-coding": return "coding-plan" case "alibaba-coding-anthropic": return "coding-plan-anthropic" case "qwen-international", "dashscope-intl": return "qwen-intl" case "dashscope-us": return "qwen-us" } return p } // ModelKey returns a canonical "provider/model" key for deduplication. func ModelKey(provider, model string) string { return NormalizeProvider(provider) + "/" + strings.ToLower(strings.TrimSpace(model)) } ================================================ FILE: pkg/providers/model_ref_test.go ================================================ package providers import "testing" func TestParseModelRef_WithSlash(t *testing.T) { ref := ParseModelRef("anthropic/claude-opus", "openai") if ref == nil { t.Fatal("expected non-nil ref") } if ref.Provider != "anthropic" { t.Errorf("provider = %q, want anthropic", ref.Provider) } if ref.Model != "claude-opus" { t.Errorf("model = %q, want claude-opus", ref.Model) } } func TestParseModelRef_WithoutSlash(t *testing.T) { ref := ParseModelRef("gpt-4", "openai") if ref == nil { t.Fatal("expected non-nil ref") } if ref.Provider != "openai" { t.Errorf("provider = %q, want openai", ref.Provider) } if ref.Model != "gpt-4" { t.Errorf("model = %q, want gpt-4", ref.Model) } } func TestParseModelRef_Empty(t *testing.T) { ref := ParseModelRef("", "openai") if ref != nil { t.Errorf("expected nil for empty string, got %+v", ref) } } func TestParseModelRef_EmptyModelAfterSlash(t *testing.T) { ref := ParseModelRef("openai/", "default") if ref != nil { t.Errorf("expected nil for empty model, got %+v", ref) } } func TestParseModelRef_WhitespaceHandling(t *testing.T) { ref := ParseModelRef(" anthropic / claude-opus ", "openai") if ref == nil { t.Fatal("expected non-nil ref") } if ref.Provider != "anthropic" { t.Errorf("provider = %q, want anthropic", ref.Provider) } if ref.Model != "claude-opus" { t.Errorf("model = %q, want claude-opus", ref.Model) } } func TestNormalizeProvider(t *testing.T) { tests := []struct { input string want string }{ {"OpenAI", "openai"}, {"ANTHROPIC", "anthropic"}, {"z.ai", "zai"}, {"z-ai", "zai"}, {"Z.AI", "zai"}, {"opencode-zen", "opencode"}, {"qwen", "qwen-portal"}, {"kimi-code", "kimi-coding"}, {"gpt", "openai"}, {"claude", "anthropic"}, {"glm", "zhipu"}, {"google", "gemini"}, {"groq", "groq"}, // Alibaba Coding Plan aliases {"alibaba-coding", "coding-plan"}, {"qwen-coding", "coding-plan"}, {"alibaba-coding-anthropic", "coding-plan-anthropic"}, // Qwen international aliases {"qwen-international", "qwen-intl"}, {"dashscope-intl", "qwen-intl"}, {"dashscope-us", "qwen-us"}, {"", ""}, } for _, tt := range tests { got := NormalizeProvider(tt.input) if got != tt.want { t.Errorf("NormalizeProvider(%q) = %q, want %q", tt.input, got, tt.want) } } } func TestModelKey(t *testing.T) { tests := []struct { provider string model string want string }{ {"openai", "gpt-4", "openai/gpt-4"}, {"Anthropic", "Claude-Opus", "anthropic/claude-opus"}, {"claude", "sonnet", "anthropic/sonnet"}, {"z.ai", "Model-X", "zai/model-x"}, } for _, tt := range tests { got := ModelKey(tt.provider, tt.model) if got != tt.want { t.Errorf("ModelKey(%q, %q) = %q, want %q", tt.provider, tt.model, got, tt.want) } } } func TestParseModelRef_ProviderNormalization(t *testing.T) { ref := ParseModelRef("Z.AI/model-x", "default") if ref == nil { t.Fatal("expected non-nil ref") } if ref.Provider != "zai" { t.Errorf("provider = %q, want zai", ref.Provider) } } func TestParseModelRef_DefaultProviderNormalization(t *testing.T) { ref := ParseModelRef("gpt-4o", "GPT") if ref == nil { t.Fatal("expected non-nil ref") } if ref.Provider != "openai" { t.Errorf("provider = %q, want openai (normalized from GPT)", ref.Provider) } } ================================================ FILE: pkg/providers/openai_compat/provider.go ================================================ package openai_compat import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/url" "strings" "time" "github.com/sipeed/picoclaw/pkg/providers/common" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) type ( ToolCall = protocoltypes.ToolCall FunctionCall = protocoltypes.FunctionCall LLMResponse = protocoltypes.LLMResponse UsageInfo = protocoltypes.UsageInfo Message = protocoltypes.Message ToolDefinition = protocoltypes.ToolDefinition ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition ExtraContent = protocoltypes.ExtraContent GoogleExtra = protocoltypes.GoogleExtra ReasoningDetail = protocoltypes.ReasoningDetail ) type Provider struct { apiKey string apiBase string maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models) httpClient *http.Client } type Option func(*Provider) const defaultRequestTimeout = common.DefaultRequestTimeout func WithMaxTokensField(maxTokensField string) Option { return func(p *Provider) { p.maxTokensField = maxTokensField } } func WithRequestTimeout(timeout time.Duration) Option { return func(p *Provider) { if timeout > 0 { p.httpClient.Timeout = timeout } } } func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider { p := &Provider{ apiKey: apiKey, apiBase: strings.TrimRight(apiBase, "/"), httpClient: common.NewHTTPClient(proxy), } for _, opt := range opts { if opt != nil { opt(p) } } return p } func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *Provider { return NewProvider(apiKey, apiBase, proxy, WithMaxTokensField(maxTokensField)) } func NewProviderWithMaxTokensFieldAndTimeout( apiKey, apiBase, proxy, maxTokensField string, requestTimeoutSeconds int, ) *Provider { return NewProvider( apiKey, apiBase, proxy, WithMaxTokensField(maxTokensField), WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second), ) } func (p *Provider) Chat( ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, ) (*LLMResponse, error) { if p.apiBase == "" { return nil, fmt.Errorf("API base not configured") } model = normalizeModel(model, p.apiBase) requestBody := map[string]any{ "model": model, "messages": common.SerializeMessages(messages), } // When fallback uses a different provider (e.g. DeepSeek), that provider must not inject web_search_preview. nativeSearch, _ := options["native_search"].(bool) nativeSearch = nativeSearch && isNativeSearchHost(p.apiBase) if len(tools) > 0 || nativeSearch { requestBody["tools"] = buildToolsList(tools, nativeSearch) requestBody["tool_choice"] = "auto" } if maxTokens, ok := common.AsInt(options["max_tokens"]); ok { // Use configured maxTokensField if specified, otherwise fallback to model-based detection fieldName := p.maxTokensField if fieldName == "" { // Fallback: detect from model name for backward compatibility lowerModel := strings.ToLower(model) if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") || strings.Contains(lowerModel, "gpt-5") { fieldName = "max_completion_tokens" } else { fieldName = "max_tokens" } } requestBody[fieldName] = maxTokens } if temperature, ok := common.AsFloat(options["temperature"]); ok { lowerModel := strings.ToLower(model) // Kimi k2 models only support temperature=1. if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") { requestBody["temperature"] = 1.0 } else { requestBody["temperature"] = temperature } } // Prompt caching: pass a stable cache key so OpenAI can bucket requests // with the same key and reuse prefix KV cache across calls. // The key is typically the agent ID — stable per agent, shared across requests. // See: https://platform.openai.com/docs/guides/prompt-caching // Prompt caching is only supported by OpenAI-native endpoints. // Non-OpenAI providers (Mistral, Gemini, DeepSeek, etc.) reject unknown // fields with 422 errors, so only include it for OpenAI APIs. if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" { if supportsPromptCacheKey(p.apiBase) { requestBody["prompt_cache_key"] = cacheKey } } jsonData, err := json.Marshal(requestBody) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") if p.apiKey != "" { req.Header.Set("Authorization", "Bearer "+p.apiKey) } resp, err := p.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, common.HandleErrorResponse(resp, p.apiBase) } return common.ReadAndParseResponse(resp, p.apiBase) } func normalizeModel(model, apiBase string) string { before, after, ok := strings.Cut(model, "/") if !ok { return model } if strings.Contains(strings.ToLower(apiBase), "openrouter.ai") { return model } prefix := strings.ToLower(before) switch prefix { case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu", "mistral", "vivgrid", "minimax", "novita": return after default: return model } } func buildToolsList(tools []ToolDefinition, nativeSearch bool) []any { result := make([]any, 0, len(tools)+1) for _, t := range tools { if nativeSearch && strings.EqualFold(t.Function.Name, "web_search") { continue } result = append(result, t) } if nativeSearch { result = append(result, map[string]any{"type": "web_search_preview"}) } return result } func (p *Provider) SupportsNativeSearch() bool { return isNativeSearchHost(p.apiBase) } func isNativeSearchHost(apiBase string) bool { u, err := url.Parse(apiBase) if err != nil { return false } host := u.Hostname() return host == "api.openai.com" || strings.HasSuffix(host, ".openai.azure.com") } // supportsPromptCacheKey reports whether the given API base is known to // support the prompt_cache_key request field. Currently only OpenAI's own // API and Azure OpenAI support this. All other OpenAI-compatible providers // (Mistral, Gemini, DeepSeek, Groq, etc.) reject unknown fields with 422 errors. func supportsPromptCacheKey(apiBase string) bool { u, err := url.Parse(apiBase) if err != nil { return false } host := u.Hostname() return host == "api.openai.com" || strings.HasSuffix(host, ".openai.azure.com") } ================================================ FILE: pkg/providers/openai_compat/provider_test.go ================================================ package openai_compat import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "github.com/sipeed/picoclaw/pkg/providers/common" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) { var requestBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/chat/completions" { http.Error(w, "not found", http.StatusNotFound) return } if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } resp := map[string]any{ "choices": []map[string]any{ { "message": map[string]any{"content": "ok"}, "finish_reason": "stop", }, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) })) defer server.Close() p := NewProvider("key", server.URL, "") _, err := p.Chat( t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "glm-4.7", map[string]any{"max_tokens": 1234}, ) if err != nil { t.Fatalf("Chat() error = %v", err) } if _, ok := requestBody["max_completion_tokens"]; !ok { t.Fatalf("expected max_completion_tokens in request body") } if _, ok := requestBody["max_tokens"]; ok { t.Fatalf("did not expect max_tokens key for glm model") } } func TestProviderChat_ParsesToolCalls(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := map[string]any{ "choices": []map[string]any{ { "message": map[string]any{ "content": "", "tool_calls": []map[string]any{ { "id": "call_1", "type": "function", "function": map[string]any{ "name": "get_weather", "arguments": "{\"city\":\"SF\"}", }, }, }, }, "finish_reason": "tool_calls", }, }, "usage": map[string]any{ "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) })) defer server.Close() p := NewProvider("key", server.URL, "") out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) if err != nil { t.Fatalf("Chat() error = %v", err) } if len(out.ToolCalls) != 1 { t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) } if out.ToolCalls[0].Name != "get_weather" { t.Fatalf("ToolCalls[0].Name = %q, want %q", out.ToolCalls[0].Name, "get_weather") } if out.ToolCalls[0].Arguments["city"] != "SF" { t.Fatalf("ToolCalls[0].Arguments[city] = %v, want SF", out.ToolCalls[0].Arguments["city"]) } } func TestProviderChat_ParsesToolCallsWithObjectArguments(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := map[string]any{ "choices": []map[string]any{ { "message": map[string]any{ "content": "", "tool_calls": []map[string]any{ { "id": "call_1", "type": "function", "function": map[string]any{ "name": "get_weather", "arguments": map[string]any{ "city": "SF", "metric": true, }, }, }, }, }, "finish_reason": "tool_calls", }, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) })) defer server.Close() p := NewProvider("key", server.URL, "") out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) if err != nil { t.Fatalf("Chat() error = %v", err) } if len(out.ToolCalls) != 1 { t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) } if out.ToolCalls[0].Name != "get_weather" { t.Fatalf("ToolCalls[0].Name = %q, want %q", out.ToolCalls[0].Name, "get_weather") } if out.ToolCalls[0].Arguments["city"] != "SF" { t.Fatalf("ToolCalls[0].Arguments[city] = %v, want SF", out.ToolCalls[0].Arguments["city"]) } if out.ToolCalls[0].Arguments["metric"] != true { t.Fatalf("ToolCalls[0].Arguments[metric] = %v, want true", out.ToolCalls[0].Arguments["metric"]) } } func TestProviderChat_ParsesReasoningContent(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := map[string]any{ "choices": []map[string]any{ { "message": map[string]any{ "content": "The answer is 2", "reasoning_content": "Let me think step by step... 1+1=2", "tool_calls": []map[string]any{ { "id": "call_1", "type": "function", "function": map[string]any{ "name": "calculator", "arguments": "{\"expr\":\"1+1\"}", }, }, }, }, "finish_reason": "tool_calls", }, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) })) defer server.Close() p := NewProvider("key", server.URL, "") out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "1+1=?"}}, nil, "kimi-k2.5", nil) if err != nil { t.Fatalf("Chat() error = %v", err) } if out.ReasoningContent != "Let me think step by step... 1+1=2" { t.Fatalf("ReasoningContent = %q, want %q", out.ReasoningContent, "Let me think step by step... 1+1=2") } if out.Content != "The answer is 2" { t.Fatalf("Content = %q, want %q", out.Content, "The answer is 2") } if len(out.ToolCalls) != 1 { t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) } } func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) { var requestBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } resp := map[string]any{ "choices": []map[string]any{ { "message": map[string]any{"content": "ok"}, "finish_reason": "stop", }, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) })) defer server.Close() p := NewProvider("key", server.URL, "") // Simulate a multi-turn conversation where the assistant's previous // reply included reasoning_content (e.g. from kimi-k2.5). messages := []Message{ {Role: "user", Content: "What is 1+1?"}, {Role: "assistant", Content: "2", ReasoningContent: "Let me think... 1+1=2"}, {Role: "user", Content: "What about 2+2?"}, } _, err := p.Chat(t.Context(), messages, nil, "kimi-k2.5", nil) if err != nil { t.Fatalf("Chat() error = %v", err) } // Verify reasoning_content is preserved in the serialized request. reqMessages, ok := requestBody["messages"].([]any) if !ok { t.Fatalf("messages is not []any: %T", requestBody["messages"]) } assistantMsg, ok := reqMessages[1].(map[string]any) if !ok { t.Fatalf("assistant message is not map[string]any: %T", reqMessages[1]) } if assistantMsg["reasoning_content"] != "Let me think... 1+1=2" { t.Errorf("reasoning_content not preserved in request, got %v", assistantMsg["reasoning_content"]) } } func TestProviderChat_HTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad request", http.StatusBadRequest) })) defer server.Close() p := NewProvider("key", server.URL, "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) if err == nil { t.Fatal("expected error, got nil") } } func TestProviderChat_JSONHTTPErrorDoesNotReportHTML(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"error":"bad request"}`)) })) defer server.Close() p := NewProvider("key", server.URL, "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "Status: 400") { t.Fatalf("expected status code in error, got %v", err) } if strings.Contains(err.Error(), "returned HTML instead of JSON") { t.Fatalf("expected non-HTML http error, got %v", err) } } func TestProviderChat_HTMLResponsesReturnHelpfulError(t *testing.T) { tests := []struct { name string contentType string statusCode int body string }{ { name: "html success response", contentType: "text/html; charset=utf-8", statusCode: http.StatusOK, body: "<!DOCTYPE html><html><body>gateway login</body></html>", }, { name: "html error response", contentType: "text/html; charset=utf-8", statusCode: http.StatusBadGateway, body: "<!DOCTYPE html><html><body>bad gateway</body></html>", }, { name: "mislabeled html success response", contentType: "application/json", statusCode: http.StatusOK, body: " \r\n\t<!DOCTYPE html><html><body>gateway login</body></html>", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", tt.contentType) w.WriteHeader(tt.statusCode) _, _ = w.Write([]byte(tt.body)) })) defer server.Close() p := NewProvider("key", server.URL, "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), fmt.Sprintf("Status: %d", tt.statusCode)) { t.Fatalf("expected status code in error, got %v", err) } if !strings.Contains(err.Error(), "returned HTML instead of JSON") { t.Fatalf("expected helpful HTML error, got %v", err) } if !strings.Contains(err.Error(), "check api_base or proxy configuration") { t.Fatalf("expected configuration hint, got %v", err) } }) } } func TestProviderChat_SuccessResponseUsesStreamingDecoder(t *testing.T) { content := strings.Repeat("a", 1024) body := `{"choices":[{"message":{"content":"` + content + `"},"finish_reason":"stop"}]}` p := NewProvider("key", "https://example.com/v1", "") p.httpClient = &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: &errAfterDataReadCloser{ data: []byte(body), chunkSize: 64, }, }, nil }), } out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) if err != nil { t.Fatalf("Chat() error = %v", err) } if out.Content != content { t.Fatalf("Content = %q, want %q", out.Content, content) } } func TestProviderChat_LargeHTMLResponsePreviewIsTruncated(t *testing.T) { body := append([]byte("<!DOCTYPE html><html><body>"), bytes.Repeat([]byte("A"), 2048)...) body = append(body, []byte("</body></html>")...) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusBadGateway) _, _ = w.Write(body) })) defer server.Close() p := NewProvider("key", server.URL, "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "Body: <!DOCTYPE html><html><body>") { t.Fatalf("expected html preview in error, got %v", err) } if !strings.Contains(err.Error(), "...") { t.Fatalf("expected truncated preview, got %v", err) } } func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testing.T) { var requestBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } resp := map[string]any{ "choices": []map[string]any{ { "message": map[string]any{"content": "ok"}, "finish_reason": "stop", }, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) })) defer server.Close() p := NewProvider("key", server.URL, "") _, err := p.Chat( t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "moonshot/kimi-k2.5", map[string]any{"temperature": 0.3}, ) if err != nil { t.Fatalf("Chat() error = %v", err) } if requestBody["model"] != "kimi-k2.5" { t.Fatalf("model = %v, want kimi-k2.5", requestBody["model"]) } if requestBody["temperature"] != 1.0 { t.Fatalf("temperature = %v, want 1.0", requestBody["temperature"]) } } func TestProviderChat_StripsGroqOllamaDeepseekVivgridNovitaPrefixes(t *testing.T) { var requestBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } resp := map[string]any{ "choices": []map[string]any{ { "message": map[string]any{"content": "ok"}, "finish_reason": "stop", }, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) })) defer server.Close() p := NewProvider("key", server.URL, "") tests := []struct { name string input string wantModel string }{ { name: "strips litellm prefix and preserves proxy model name", input: "litellm/my-proxy-alias", wantModel: "my-proxy-alias", }, { name: "strips groq prefix and keeps nested model", input: "groq/openai/gpt-oss-120b", wantModel: "openai/gpt-oss-120b", }, { name: "strips ollama prefix", input: "ollama/qwen2.5:14b", wantModel: "qwen2.5:14b", }, { name: "strips deepseek prefix", input: "deepseek/deepseek-chat", wantModel: "deepseek-chat", }, { name: "strips vivgrid prefix", input: "vivgrid/auto", wantModel: "auto", }, { name: "strips novita prefix deepseek model", input: "novita/deepseek/deepseek-v3.2", wantModel: "deepseek/deepseek-v3.2", }, { name: "strips novita prefix zai model", input: "novita/zai-org/glm-5", wantModel: "zai-org/glm-5", }, { name: "strips novita prefix minimax model", input: "novita/minimax/minimax-m2.5", wantModel: "minimax/minimax-m2.5", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, tt.input, nil) if err != nil { t.Fatalf("Chat() error = %v", err) } if requestBody["model"] != tt.wantModel { t.Fatalf("model = %v, want %s", requestBody["model"], tt.wantModel) } }) } } func TestProvider_ProxyConfigured(t *testing.T) { proxyURL := "http://127.0.0.1:8080" p := NewProvider("key", "https://example.com", proxyURL) transport, ok := p.httpClient.Transport.(*http.Transport) if !ok || transport == nil { t.Fatalf("expected http transport with proxy, got %T", p.httpClient.Transport) } req := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.example.com"}} gotProxy, err := transport.Proxy(req) if err != nil { t.Fatalf("proxy function returned error: %v", err) } if gotProxy == nil || gotProxy.String() != proxyURL { t.Fatalf("proxy = %v, want %s", gotProxy, proxyURL) } } func TestProviderChat_AcceptsNumericOptionTypes(t *testing.T) { var requestBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } resp := map[string]any{ "choices": []map[string]any{ { "message": map[string]any{"content": "ok"}, "finish_reason": "stop", }, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) })) defer server.Close() p := NewProvider("key", server.URL, "") _, err := p.Chat( t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", map[string]any{"max_tokens": float64(512), "temperature": 1}, ) if err != nil { t.Fatalf("Chat() error = %v", err) } if requestBody["max_tokens"] != float64(512) { t.Fatalf("max_tokens = %v, want 512", requestBody["max_tokens"]) } if requestBody["temperature"] != float64(1) { t.Fatalf("temperature = %v, want 1", requestBody["temperature"]) } } func TestNormalizeModel_UsesAPIBase(t *testing.T) { if got := normalizeModel("deepseek/deepseek-chat", "https://api.deepseek.com/v1"); got != "deepseek-chat" { t.Fatalf("normalizeModel(deepseek) = %q, want %q", got, "deepseek-chat") } if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" { t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto") } if got := normalizeModel("vivgrid/managed", "https://api.vivgrid.com/v1"); got != "managed" { t.Fatalf("normalizeModel(vivgrid) = %q, want %q", got, "managed") } if got := normalizeModel("vivgrid/auto", "https://api.vivgrid.com/v1"); got != "auto" { t.Fatalf("normalizeModel(vivgrid auto) = %q, want %q", got, "auto") } if got := normalizeModel( "novita/deepseek/deepseek-v3.2", "https://api.novita.ai/openai", ); got != "deepseek/deepseek-v3.2" { t.Fatalf("normalizeModel(novita) = %q, want %q", got, "deepseek/deepseek-v3.2") } } func TestProvider_RequestTimeoutDefault(t *testing.T) { p := NewProviderWithMaxTokensFieldAndTimeout("key", "https://example.com/v1", "", "", 0) if p.httpClient.Timeout != defaultRequestTimeout { t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout) } } func TestProvider_RequestTimeoutOverride(t *testing.T) { p := NewProviderWithMaxTokensFieldAndTimeout("key", "https://example.com/v1", "", "", 300) if p.httpClient.Timeout != 300*time.Second { t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, 300*time.Second) } } type roundTripperFunc func(*http.Request) (*http.Response, error) func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } type errAfterDataReadCloser struct { data []byte chunkSize int offset int } func (r *errAfterDataReadCloser) Read(p []byte) (int, error) { if r.offset >= len(r.data) { return 0, io.ErrUnexpectedEOF } n := r.chunkSize if n <= 0 || n > len(p) { n = len(p) } remaining := len(r.data) - r.offset if n > remaining { n = remaining } copy(p, r.data[r.offset:r.offset+n]) r.offset += n return n, nil } func (r *errAfterDataReadCloser) Close() error { return nil } func TestProvider_FunctionalOptionMaxTokensField(t *testing.T) { p := NewProvider("key", "https://example.com/v1", "", WithMaxTokensField("max_completion_tokens")) if p.maxTokensField != "max_completion_tokens" { t.Fatalf("maxTokensField = %q, want %q", p.maxTokensField, "max_completion_tokens") } } func TestProvider_FunctionalOptionRequestTimeout(t *testing.T) { p := NewProvider("key", "https://example.com/v1", "", WithRequestTimeout(45*time.Second)) if p.httpClient.Timeout != 45*time.Second { t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, 45*time.Second) } } func TestProvider_FunctionalOptionRequestTimeoutNonPositive(t *testing.T) { p := NewProvider("key", "https://example.com/v1", "", WithRequestTimeout(-1*time.Second)) if p.httpClient.Timeout != defaultRequestTimeout { t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout) } } func TestSerializeMessages_PlainText(t *testing.T) { messages := []protocoltypes.Message{ {Role: "user", Content: "hello"}, {Role: "assistant", Content: "hi", ReasoningContent: "thinking..."}, } result := common.SerializeMessages(messages) data, err := json.Marshal(result) if err != nil { t.Fatal(err) } var msgs []map[string]any json.Unmarshal(data, &msgs) if msgs[0]["content"] != "hello" { t.Fatalf("expected plain string content, got %v", msgs[0]["content"]) } if msgs[1]["reasoning_content"] != "thinking..." { t.Fatalf("reasoning_content not preserved, got %v", msgs[1]["reasoning_content"]) } } func TestSerializeMessages_WithMedia(t *testing.T) { messages := []protocoltypes.Message{ {Role: "user", Content: "describe this", Media: []string{"data:image/png;base64,abc123"}}, } result := common.SerializeMessages(messages) data, _ := json.Marshal(result) var msgs []map[string]any json.Unmarshal(data, &msgs) content, ok := msgs[0]["content"].([]any) if !ok { t.Fatalf("expected array content for media message, got %T", msgs[0]["content"]) } if len(content) != 2 { t.Fatalf("expected 2 content parts, got %d", len(content)) } textPart := content[0].(map[string]any) if textPart["type"] != "text" || textPart["text"] != "describe this" { t.Fatalf("text part mismatch: %v", textPart) } imgPart := content[1].(map[string]any) if imgPart["type"] != "image_url" { t.Fatalf("expected image_url type, got %v", imgPart["type"]) } imgURL := imgPart["image_url"].(map[string]any) if imgURL["url"] != "data:image/png;base64,abc123" { t.Fatalf("image url mismatch: %v", imgURL["url"]) } } func TestSerializeMessages_MediaWithToolCallID(t *testing.T) { messages := []protocoltypes.Message{ {Role: "tool", Content: "image result", Media: []string{"data:image/png;base64,xyz"}, ToolCallID: "call_1"}, } result := common.SerializeMessages(messages) data, _ := json.Marshal(result) var msgs []map[string]any json.Unmarshal(data, &msgs) if msgs[0]["tool_call_id"] != "call_1" { t.Fatalf("tool_call_id not preserved with media, got %v", msgs[0]["tool_call_id"]) } // Content should be multipart array if _, ok := msgs[0]["content"].([]any); !ok { t.Fatalf("expected array content, got %T", msgs[0]["content"]) } } // chatWithCacheKey sets up a test server, sends a Chat request with prompt_cache_key, // and returns the decoded request body for assertion. func chatWithCacheKey(t *testing.T, apiBase string) map[string]any { t.Helper() var requestBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } resp := map[string]any{ "choices": []map[string]any{ { "message": map[string]any{"content": "ok"}, "finish_reason": "stop", }, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) })) defer server.Close() p := NewProvider("key", server.URL, "") p.apiBase = apiBase p.httpClient = &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { r.URL, _ = url.Parse(server.URL + r.URL.Path) return http.DefaultTransport.RoundTrip(r) }), } _, err := p.Chat( t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "test-model", map[string]any{"prompt_cache_key": "agent-main"}, ) if err != nil { t.Fatalf("Chat() error = %v", err) } return requestBody } func TestProviderChat_PromptCacheKeySentToOpenAI(t *testing.T) { body := chatWithCacheKey(t, "https://api.openai.com/v1") if body["prompt_cache_key"] != "agent-main" { t.Fatalf("prompt_cache_key = %v, want %q", body["prompt_cache_key"], "agent-main") } } func TestProviderChat_PromptCacheKeyOmittedForNonOpenAI(t *testing.T) { tests := []struct { name string apiBase string }{ {"mistral", "https://api.mistral.ai/v1"}, {"gemini", "https://generativelanguage.googleapis.com/v1beta"}, {"deepseek", "https://api.deepseek.com/v1"}, {"groq", "https://api.groq.com/openai/v1"}, {"minimax", "https://api.minimaxi.com/v1"}, {"ollama_local", "http://localhost:11434/v1"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { body := chatWithCacheKey(t, tt.apiBase) if _, exists := body["prompt_cache_key"]; exists { t.Fatalf("prompt_cache_key should NOT be sent to %s, but was included in request", tt.name) } }) } } func TestSupportsPromptCacheKey(t *testing.T) { tests := []struct { apiBase string want bool }{ {"https://api.openai.com/v1", true}, {"https://api.openai.com/v1/", true}, {"https://myresource.openai.azure.com/openai/deployments/gpt-4", true}, {"https://eastus.openai.azure.com/v1", true}, {"https://api.mistral.ai/v1", false}, {"https://generativelanguage.googleapis.com/v1beta", false}, {"https://api.deepseek.com/v1", false}, {"https://api.groq.com/openai/v1", false}, {"http://localhost:11434/v1", false}, {"https://openrouter.ai/api/v1", false}, // Edge cases: proxy URLs with openai.com in path should NOT match {"https://my-proxy.com/api.openai.com/v1", false}, {"https://proxy.example.com/openai.azure.com/v1", false}, // Malformed or empty {"", false}, {"not-a-url", false}, } for _, tt := range tests { if got := supportsPromptCacheKey(tt.apiBase); got != tt.want { t.Errorf("supportsPromptCacheKey(%q) = %v, want %v", tt.apiBase, got, tt.want) } } } func TestBuildToolsList_NativeSearchAddsWebSearchPreview(t *testing.T) { tools := []ToolDefinition{ {Type: "function", Function: ToolFunctionDefinition{Name: "read_file", Description: "read"}}, } result := buildToolsList(tools, true) if len(result) != 2 { t.Fatalf("len(result) = %d, want 2", len(result)) } wsEntry, ok := result[1].(map[string]any) if !ok { t.Fatalf("web search entry is %T, want map[string]any", result[1]) } if wsEntry["type"] != "web_search_preview" { t.Fatalf("type = %v, want web_search_preview", wsEntry["type"]) } } func TestBuildToolsList_NativeSearchFiltersClientWebSearch(t *testing.T) { tools := []ToolDefinition{ {Type: "function", Function: ToolFunctionDefinition{Name: "web_search", Description: "search"}}, {Type: "function", Function: ToolFunctionDefinition{Name: "read_file", Description: "read"}}, } result := buildToolsList(tools, true) for _, entry := range result { if td, ok := entry.(ToolDefinition); ok && strings.EqualFold(td.Function.Name, "web_search") { t.Fatal("client-side web_search should be filtered out when native search is enabled") } } if len(result) != 2 { // read_file + web_search_preview t.Fatalf("len(result) = %d, want 2 (read_file + web_search_preview)", len(result)) } } func TestBuildToolsList_NoNativeSearchPassesThrough(t *testing.T) { tools := []ToolDefinition{ {Type: "function", Function: ToolFunctionDefinition{Name: "web_search", Description: "search"}}, {Type: "function", Function: ToolFunctionDefinition{Name: "read_file", Description: "read"}}, } result := buildToolsList(tools, false) if len(result) != 2 { t.Fatalf("len(result) = %d, want 2", len(result)) } } func TestIsNativeSearchHost(t *testing.T) { tests := []struct { apiBase string want bool }{ {"https://api.openai.com/v1", true}, {"https://myresource.openai.azure.com/openai/deployments/gpt-4", true}, {"https://api.mistral.ai/v1", false}, {"https://api.deepseek.com/v1", false}, {"https://api.groq.com/openai/v1", false}, {"http://localhost:11434/v1", false}, {"", false}, } for _, tt := range tests { if got := isNativeSearchHost(tt.apiBase); got != tt.want { t.Errorf("isNativeSearchHost(%q) = %v, want %v", tt.apiBase, got, tt.want) } } } func TestSupportsNativeSearch_OpenAI(t *testing.T) { p := NewProvider("key", "https://api.openai.com/v1", "") if !p.SupportsNativeSearch() { t.Fatal("OpenAI provider should support native search") } } func TestSupportsNativeSearch_NonOpenAI(t *testing.T) { p := NewProvider("key", "https://api.deepseek.com/v1", "") if p.SupportsNativeSearch() { t.Fatal("DeepSeek provider should not support native search") } } func TestProviderChat_NativeSearchToolInjected(t *testing.T) { var requestBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } resp := map[string]any{ "choices": []map[string]any{ { "message": map[string]any{"content": "ok"}, "finish_reason": "stop", }, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) })) defer server.Close() p := NewProvider("key", server.URL, "") p.apiBase = "https://api.openai.com/v1" p.httpClient = &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { r.URL, _ = url.Parse(server.URL + r.URL.Path) return http.DefaultTransport.RoundTrip(r) }), } tools := []ToolDefinition{ {Type: "function", Function: ToolFunctionDefinition{Name: "read_file", Description: "read"}}, } _, err := p.Chat( t.Context(), []Message{{Role: "user", Content: "hi"}}, tools, "gpt-5.4", map[string]any{"native_search": true}, ) if err != nil { t.Fatalf("Chat() error = %v", err) } toolsRaw, ok := requestBody["tools"].([]any) if !ok { t.Fatalf("tools is %T, want []any", requestBody["tools"]) } if len(toolsRaw) != 2 { t.Fatalf("len(tools) = %d, want 2 (read_file + web_search_preview)", len(toolsRaw)) } lastTool, ok := toolsRaw[1].(map[string]any) if !ok { t.Fatalf("last tool is %T, want map[string]any", toolsRaw[1]) } if lastTool["type"] != "web_search_preview" { t.Fatalf("last tool type = %v, want web_search_preview", lastTool["type"]) } } func TestProviderChat_NativeSearchNotInjectedWithoutOption(t *testing.T) { var requestBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } resp := map[string]any{ "choices": []map[string]any{ { "message": map[string]any{"content": "ok"}, "finish_reason": "stop", }, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) })) defer server.Close() p := NewProvider("key", server.URL, "") tools := []ToolDefinition{ {Type: "function", Function: ToolFunctionDefinition{Name: "web_search", Description: "search"}}, } _, err := p.Chat( t.Context(), []Message{{Role: "user", Content: "hi"}}, tools, "gpt-5.4", map[string]any{}, ) if err != nil { t.Fatalf("Chat() error = %v", err) } toolsRaw, ok := requestBody["tools"].([]any) if !ok { t.Fatalf("tools is %T, want []any", requestBody["tools"]) } if len(toolsRaw) != 1 { t.Fatalf("len(tools) = %d, want 1 (web_search only)", len(toolsRaw)) } } // TestProviderChat_NativeSearchIgnoredOnNonOpenAI verifies that when native_search // is true in options but the provider's apiBase is not OpenAI (e.g. fallback to DeepSeek), // we do not inject web_search_preview to avoid API errors. func TestProviderChat_NativeSearchIgnoredOnNonOpenAI(t *testing.T) { var requestBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } resp := map[string]any{ "choices": []map[string]any{ { "message": map[string]any{"content": "ok"}, "finish_reason": "stop", }, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) })) defer server.Close() // Use server.URL so host is not api.openai.com — simulates DeepSeek/other provider p := NewProvider("key", server.URL, "") _, err := p.Chat( t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deepseek-chat", map[string]any{"native_search": true}, ) if err != nil { t.Fatalf("Chat() error = %v", err) } // Should not have tools at all (no tools passed, and we must not add web_search_preview) if toolsRaw, ok := requestBody["tools"]; ok { t.Fatalf("tools should be omitted for non-OpenAI when only native_search was requested, got %v", toolsRaw) } } func TestSerializeMessages_StripsSystemParts(t *testing.T) { messages := []protocoltypes.Message{ { Role: "system", Content: "you are helpful", SystemParts: []protocoltypes.ContentBlock{ {Type: "text", Text: "you are helpful"}, }, }, } result := common.SerializeMessages(messages) data, _ := json.Marshal(result) raw := string(data) if strings.Contains(raw, "system_parts") { t.Fatal("system_parts should not appear in serialized output") } } ================================================ FILE: pkg/providers/protocoltypes/types.go ================================================ package protocoltypes type ToolCall struct { ID string `json:"id"` Type string `json:"type,omitempty"` Function *FunctionCall `json:"function,omitempty"` Name string `json:"-"` Arguments map[string]any `json:"-"` ThoughtSignature string `json:"-"` // Internal use only ExtraContent *ExtraContent `json:"extra_content,omitempty"` } type ExtraContent struct { Google *GoogleExtra `json:"google,omitempty"` } type GoogleExtra struct { ThoughtSignature string `json:"thought_signature,omitempty"` } type FunctionCall struct { Name string `json:"name"` Arguments string `json:"arguments"` ThoughtSignature string `json:"thought_signature,omitempty"` } type LLMResponse struct { Content string `json:"content"` ReasoningContent string `json:"reasoning_content,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` FinishReason string `json:"finish_reason"` Usage *UsageInfo `json:"usage,omitempty"` Reasoning string `json:"reasoning"` ReasoningDetails []ReasoningDetail `json:"reasoning_details"` } type ReasoningDetail struct { Format string `json:"format"` Index int `json:"index"` Type string `json:"type"` Text string `json:"text"` } type UsageInfo struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` TotalTokens int `json:"total_tokens"` } // CacheControl marks a content block for LLM-side prefix caching. // Currently only "ephemeral" is supported (used by Anthropic). type CacheControl struct { Type string `json:"type"` // "ephemeral" } // ContentBlock represents a structured segment of a system message. // Adapters that understand SystemParts can use these blocks to set // per-block cache control (e.g. Anthropic's cache_control: ephemeral). type ContentBlock struct { Type string `json:"type"` // "text" Text string `json:"text"` CacheControl *CacheControl `json:"cache_control,omitempty"` } type Message struct { Role string `json:"role"` Content string `json:"content"` Media []string `json:"media,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"` SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` } type ToolDefinition struct { Type string `json:"type"` Function ToolFunctionDefinition `json:"function"` } type ToolFunctionDefinition struct { Name string `json:"name"` Description string `json:"description"` Parameters map[string]any `json:"parameters"` } ================================================ FILE: pkg/providers/tool_call_extract.go ================================================ package providers import ( "encoding/json" "strings" ) // extractToolCallsFromText parses tool call JSON from response text. // Both ClaudeCliProvider and CodexCliProvider use this to extract // tool calls that the model outputs in its response text. func extractToolCallsFromText(text string) []ToolCall { start := strings.Index(text, `{"tool_calls"`) if start == -1 { return nil } end := findMatchingBrace(text, start) if end == start { return nil } jsonStr := text[start:end] var wrapper struct { ToolCalls []struct { ID string `json:"id"` Type string `json:"type"` Function struct { Name string `json:"name"` Arguments string `json:"arguments"` } `json:"function"` } `json:"tool_calls"` } if err := json.Unmarshal([]byte(jsonStr), &wrapper); err != nil { return nil } var result []ToolCall for _, tc := range wrapper.ToolCalls { var args map[string]any json.Unmarshal([]byte(tc.Function.Arguments), &args) result = append(result, ToolCall{ ID: tc.ID, Type: tc.Type, Name: tc.Function.Name, Arguments: args, Function: &FunctionCall{ Name: tc.Function.Name, Arguments: tc.Function.Arguments, }, }) } return result } // stripToolCallsFromText removes tool call JSON from response text. func stripToolCallsFromText(text string) string { start := strings.Index(text, `{"tool_calls"`) if start == -1 { return text } end := findMatchingBrace(text, start) if end == start { return text } return strings.TrimSpace(text[:start] + text[end:]) } ================================================ FILE: pkg/providers/toolcall_utils.go ================================================ // PicoClaw - Ultra-lightweight personal AI agent // License: MIT // // Copyright (c) 2026 PicoClaw contributors package providers import ( "encoding/json" "fmt" "strings" ) // buildCLIToolsPrompt creates the tool definitions section for a CLI provider system prompt. func buildCLIToolsPrompt(tools []ToolDefinition) string { var sb strings.Builder sb.WriteString("## Available Tools\n\n") sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n") sb.WriteString("```json\n") sb.WriteString( `{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`, ) sb.WriteString("\n```\n\n") sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n") sb.WriteString("### Tool Definitions:\n\n") for _, tool := range tools { if tool.Type != "function" { continue } sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name)) if tool.Function.Description != "" { sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description)) } if len(tool.Function.Parameters) > 0 { paramsJSON, _ := json.Marshal(tool.Function.Parameters) sb.WriteString(fmt.Sprintf("Parameters:\n```json\n%s\n```\n", string(paramsJSON))) } sb.WriteString("\n") } return sb.String() } // NormalizeToolCall normalizes a ToolCall to ensure all fields are properly populated. // It handles cases where Name/Arguments might be in different locations (top-level vs Function) // and ensures both are populated consistently. func NormalizeToolCall(tc ToolCall) ToolCall { normalized := tc // Ensure Name is populated from Function if not set if normalized.Name == "" && normalized.Function != nil { normalized.Name = normalized.Function.Name } // Ensure Arguments is not nil if normalized.Arguments == nil { normalized.Arguments = map[string]any{} } // Parse Arguments from Function.Arguments if not already set if len(normalized.Arguments) == 0 && normalized.Function != nil && normalized.Function.Arguments != "" { var parsed map[string]any if err := json.Unmarshal([]byte(normalized.Function.Arguments), &parsed); err == nil && parsed != nil { normalized.Arguments = parsed } } // Ensure Function is populated with consistent values argsJSON, _ := json.Marshal(normalized.Arguments) if normalized.Function == nil { normalized.Function = &FunctionCall{ Name: normalized.Name, Arguments: string(argsJSON), } } else { if normalized.Function.Name == "" { normalized.Function.Name = normalized.Name } if normalized.Name == "" { normalized.Name = normalized.Function.Name } if normalized.Function.Arguments == "" { normalized.Function.Arguments = string(argsJSON) } } return normalized } ================================================ FILE: pkg/providers/types.go ================================================ package providers import ( "context" "fmt" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) type ( ToolCall = protocoltypes.ToolCall FunctionCall = protocoltypes.FunctionCall LLMResponse = protocoltypes.LLMResponse UsageInfo = protocoltypes.UsageInfo Message = protocoltypes.Message ToolDefinition = protocoltypes.ToolDefinition ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition ExtraContent = protocoltypes.ExtraContent GoogleExtra = protocoltypes.GoogleExtra ContentBlock = protocoltypes.ContentBlock CacheControl = protocoltypes.CacheControl ) type LLMProvider interface { Chat( ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, ) (*LLMResponse, error) GetDefaultModel() string } type StatefulProvider interface { LLMProvider Close() } // ThinkingCapable is an optional interface for providers that support // extended thinking (e.g. Anthropic). Used by the agent loop to warn // when thinking_level is configured but the active provider cannot use it. type ThinkingCapable interface { SupportsThinking() bool } // NativeSearchCapable is an optional interface for providers that support // built-in web search during LLM inference (e.g. OpenAI web_search_preview, // xAI Grok search). When the active provider implements this interface and // returns true, the agent loop can hide the client-side web_search tool to // avoid duplicate search surfaces and use the provider's native search instead. type NativeSearchCapable interface { SupportsNativeSearch() bool } // FailoverReason classifies why an LLM request failed for fallback decisions. type FailoverReason string const ( FailoverAuth FailoverReason = "auth" FailoverRateLimit FailoverReason = "rate_limit" FailoverBilling FailoverReason = "billing" FailoverTimeout FailoverReason = "timeout" FailoverFormat FailoverReason = "format" FailoverOverloaded FailoverReason = "overloaded" FailoverUnknown FailoverReason = "unknown" ) // FailoverError wraps an LLM provider error with classification metadata. type FailoverError struct { Reason FailoverReason Provider string Model string Status int Wrapped error } func (e *FailoverError) Error() string { return fmt.Sprintf("failover(%s): provider=%s model=%s status=%d: %v", e.Reason, e.Provider, e.Model, e.Status, e.Wrapped) } func (e *FailoverError) Unwrap() error { return e.Wrapped } // IsRetriable returns true if this error should trigger fallback to next candidate. // Non-retriable: Format errors (bad request structure, image dimension/size). func (e *FailoverError) IsRetriable() bool { return e.Reason != FailoverFormat } // ModelConfig holds primary model and fallback list. type ModelConfig struct { Primary string Fallbacks []string } ================================================ FILE: pkg/routing/agent_id.go ================================================ package routing import ( "regexp" "strings" ) const ( DefaultAgentID = "main" DefaultMainKey = "main" DefaultAccountID = "default" MaxAgentIDLength = 64 ) var ( validIDRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`) invalidCharsRe = regexp.MustCompile(`[^a-z0-9_-]+`) leadingDashRe = regexp.MustCompile(`^-+`) trailingDashRe = regexp.MustCompile(`-+$`) ) // NormalizeAgentID sanitizes an agent ID to [a-z0-9][a-z0-9_-]{0,63}. // Invalid characters are collapsed to "-". Leading/trailing dashes stripped. // Empty input returns DefaultAgentID ("main"). func NormalizeAgentID(id string) string { trimmed := strings.TrimSpace(id) if trimmed == "" { return DefaultAgentID } lower := strings.ToLower(trimmed) if validIDRe.MatchString(lower) { return lower } result := invalidCharsRe.ReplaceAllString(lower, "-") result = leadingDashRe.ReplaceAllString(result, "") result = trailingDashRe.ReplaceAllString(result, "") if len(result) > MaxAgentIDLength { result = result[:MaxAgentIDLength] } if result == "" { return DefaultAgentID } return result } // NormalizeAccountID sanitizes an account ID. Empty returns DefaultAccountID. func NormalizeAccountID(id string) string { trimmed := strings.TrimSpace(id) if trimmed == "" { return DefaultAccountID } lower := strings.ToLower(trimmed) if validIDRe.MatchString(lower) { return lower } result := invalidCharsRe.ReplaceAllString(lower, "-") result = leadingDashRe.ReplaceAllString(result, "") result = trailingDashRe.ReplaceAllString(result, "") if len(result) > MaxAgentIDLength { result = result[:MaxAgentIDLength] } if result == "" { return DefaultAccountID } return result } ================================================ FILE: pkg/routing/agent_id_test.go ================================================ package routing import ( "strings" "testing" ) func TestNormalizeAgentID_Empty(t *testing.T) { if got := NormalizeAgentID(""); got != DefaultAgentID { t.Errorf("NormalizeAgentID('') = %q, want %q", got, DefaultAgentID) } } func TestNormalizeAgentID_Whitespace(t *testing.T) { if got := NormalizeAgentID(" "); got != DefaultAgentID { t.Errorf("NormalizeAgentID(' ') = %q, want %q", got, DefaultAgentID) } } func TestNormalizeAgentID_Valid(t *testing.T) { tests := []struct { input, want string }{ {"main", "main"}, {"Main", "main"}, {"SALES", "sales"}, {"support-bot", "support-bot"}, {"agent_1", "agent_1"}, {"a", "a"}, {"0test", "0test"}, } for _, tt := range tests { if got := NormalizeAgentID(tt.input); got != tt.want { t.Errorf("NormalizeAgentID(%q) = %q, want %q", tt.input, got, tt.want) } } } func TestNormalizeAgentID_InvalidChars(t *testing.T) { tests := []struct { input, want string }{ {"Hello World", "hello-world"}, {"agent@123", "agent-123"}, {"foo.bar.baz", "foo-bar-baz"}, {"--leading", "leading"}, {"--both--", "both"}, } for _, tt := range tests { if got := NormalizeAgentID(tt.input); got != tt.want { t.Errorf("NormalizeAgentID(%q) = %q, want %q", tt.input, got, tt.want) } } } func TestNormalizeAgentID_AllInvalid(t *testing.T) { if got := NormalizeAgentID("@@@"); got != DefaultAgentID { t.Errorf("NormalizeAgentID('@@@') = %q, want %q", got, DefaultAgentID) } } func TestNormalizeAgentID_TruncatesAt64(t *testing.T) { var long strings.Builder for range 100 { long.WriteString("a") } got := NormalizeAgentID(long.String()) if len(got) > MaxAgentIDLength { t.Errorf("length = %d, want <= %d", len(got), MaxAgentIDLength) } } func TestNormalizeAccountID_Empty(t *testing.T) { if got := NormalizeAccountID(""); got != DefaultAccountID { t.Errorf("NormalizeAccountID('') = %q, want %q", got, DefaultAccountID) } } func TestNormalizeAccountID_Valid(t *testing.T) { if got := NormalizeAccountID("MyBot"); got != "mybot" { t.Errorf("NormalizeAccountID('MyBot') = %q, want 'mybot'", got) } } func TestNormalizeAccountID_InvalidChars(t *testing.T) { if got := NormalizeAccountID("bot@home"); got != "bot-home" { t.Errorf("NormalizeAccountID('bot@home') = %q, want 'bot-home'", got) } } ================================================ FILE: pkg/routing/classifier.go ================================================ package routing // Classifier evaluates a feature set and returns a complexity score in [0, 1]. // A higher score indicates a more complex task that benefits from a heavy model. // The score is compared against the configured threshold: score >= threshold selects // the primary (heavy) model; score < threshold selects the light model. // // Classifier is an interface so that future implementations (ML-based, embedding-based, // or any other approach) can be swapped in without changing routing infrastructure. type Classifier interface { Score(f Features) float64 } // RuleClassifier is the v1 implementation. // It uses a weighted sum of structural signals with no external dependencies, // no API calls, and sub-microsecond latency. The raw sum is capped at 1.0 so // that the returned score always falls within the [0, 1] contract. // // Individual weights (multiple signals can fire simultaneously): // // token > 200 (≈600 chars): 0.35 — very long prompts are almost always complex // token 50-200: 0.15 — medium length; may or may not be complex // code block present: 0.40 — coding tasks need the heavy model // tool calls > 3 (recent): 0.25 — dense tool usage signals an agentic workflow // tool calls 1-3 (recent): 0.10 — some tool activity // conversation depth > 10: 0.10 — long sessions carry implicit complexity // attachments present: 1.00 — hard gate; multi-modal always needs heavy model // // Default threshold is 0.35, so: // - Pure greetings / trivial Q&A: 0.00 → light ✓ // - Medium prose message (50–200 tokens): 0.15 → light ✓ // - Message with code block: 0.40 → heavy ✓ // - Long message (>200 tokens): 0.35 → heavy ✓ // - Active tool session + medium message: 0.25 → light (acceptable) // - Any message with an image/audio attachment: 1.00 → heavy ✓ type RuleClassifier struct{} // Score computes the complexity score for the given feature set. // The returned value is in [0, 1]. Attachments short-circuit to 1.0. func (c *RuleClassifier) Score(f Features) float64 { // Hard gate: multi-modal inputs always require the heavy model. if f.HasAttachments { return 1.0 } var score float64 // Token estimate — primary verbosity signal switch { case f.TokenEstimate > 200: score += 0.35 case f.TokenEstimate > 50: score += 0.15 } // Fenced code blocks — strongest indicator of a coding/technical task if f.CodeBlockCount > 0 { score += 0.40 } // Recent tool call density — indicates an ongoing agentic workflow switch { case f.RecentToolCalls > 3: score += 0.25 case f.RecentToolCalls > 0: score += 0.10 } // Conversation depth — accumulated context implies compound task if f.ConversationDepth > 10 { score += 0.10 } // Cap at 1.0 to honor the [0, 1] contract even when multiple signals fire // simultaneously (e.g., long message + code block + tool chain = 1.10 raw). if score > 1.0 { score = 1.0 } return score } ================================================ FILE: pkg/routing/features.go ================================================ package routing import ( "strings" "unicode/utf8" "github.com/sipeed/picoclaw/pkg/providers" ) // lookbackWindow is the number of recent history entries scanned for tool calls. // Six entries covers roughly one full tool-use round-trip (user → assistant+tool_call → tool_result → assistant). const lookbackWindow = 6 // Features holds the structural signals extracted from a message and its session context. // Every dimension is language-agnostic by construction — no keyword or pattern matching // against natural-language content. This ensures consistent routing for all locales. type Features struct { // TokenEstimate is a proxy for token count. // CJK runes count as 1 token each; non-CJK runes as 0.25 tokens each. // This avoids API calls while giving accurate estimates for all scripts. TokenEstimate int // CodeBlockCount is the number of fenced code blocks (``` pairs) in the message. // Coding tasks almost always require the heavy model. CodeBlockCount int // RecentToolCalls is the count of tool_call messages in the last lookbackWindow // history entries. A high density indicates an active agentic workflow. RecentToolCalls int // ConversationDepth is the total number of messages in the session history. // Deep sessions tend to carry implicit complexity built up over many turns. ConversationDepth int // HasAttachments is true when the message appears to contain media (images, // audio, video). Multi-modal inputs require vision-capable heavy models. HasAttachments bool } // ExtractFeatures computes the structural feature vector for a message. // It is a pure function with no side effects and zero allocations beyond // the returned struct. func ExtractFeatures(msg string, history []providers.Message) Features { return Features{ TokenEstimate: estimateTokens(msg), CodeBlockCount: countCodeBlocks(msg), RecentToolCalls: countRecentToolCalls(history), ConversationDepth: len(history), HasAttachments: hasAttachments(msg), } } // estimateTokens returns a token count proxy that handles both CJK and Latin text. // CJK runes (U+2E80–U+9FFF, U+F900–U+FAFF, U+AC00–U+D7AF) map to roughly one // token each, while non-CJK runes average ~0.25 tokens/rune (≈4 chars per token // for English). Splitting the count this way avoids the 3x underestimation that a // flat rune_count/3 would produce for Chinese, Japanese, and Korean text. func estimateTokens(msg string) int { total := utf8.RuneCountInString(msg) if total == 0 { return 0 } cjk := 0 for _, r := range msg { if r >= 0x2E80 && r <= 0x9FFF || r >= 0xF900 && r <= 0xFAFF || r >= 0xAC00 && r <= 0xD7AF { cjk++ } } return cjk + (total-cjk)/4 } // countCodeBlocks counts the number of complete fenced code blocks. // Each ``` delimiter increments a counter; pairs of delimiters form one block. // An unclosed opening fence (odd count) is treated as zero complete blocks // since it may just be an inline code span or a typo. func countCodeBlocks(msg string) int { n := strings.Count(msg, "```") return n / 2 } // countRecentToolCalls counts messages with tool calls in the last lookbackWindow // entries of history. It examines the ToolCalls field rather than parsing // the content string, so it is robust to any message format. func countRecentToolCalls(history []providers.Message) int { start := len(history) - lookbackWindow if start < 0 { start = 0 } count := 0 for _, msg := range history[start:] { if len(msg.ToolCalls) > 0 { count += len(msg.ToolCalls) } } return count } // hasAttachments returns true when the message content contains embedded media. // It checks for base64 data URIs (data:image/, data:audio/, data:video/) and // common image/audio URL extensions. This is intentionally conservative — // false negatives (missing an attachment) just mean the routing falls back to // the primary model anyway. func hasAttachments(msg string) bool { lower := strings.ToLower(msg) // Base64 data URIs embedded directly in the message if strings.Contains(lower, "data:image/") || strings.Contains(lower, "data:audio/") || strings.Contains(lower, "data:video/") { return true } // Common image/audio extensions in URLs or file references mediaExts := []string{ ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".mp4", ".avi", ".mov", ".webm", } for _, ext := range mediaExts { if strings.Contains(lower, ext) { return true } } return false } ================================================ FILE: pkg/routing/route.go ================================================ package routing import ( "strings" "github.com/sipeed/picoclaw/pkg/config" ) // RouteInput contains the routing context from an inbound message. type RouteInput struct { Channel string AccountID string Peer *RoutePeer ParentPeer *RoutePeer GuildID string TeamID string } // ResolvedRoute is the result of agent routing. type ResolvedRoute struct { AgentID string Channel string AccountID string SessionKey string MainSessionKey string MatchedBy string // "binding.peer", "binding.peer.parent", "binding.guild", "binding.team", "binding.account", "binding.channel", "default" } // RouteResolver determines which agent handles a message based on config bindings. type RouteResolver struct { cfg *config.Config } // NewRouteResolver creates a new route resolver. func NewRouteResolver(cfg *config.Config) *RouteResolver { return &RouteResolver{cfg: cfg} } // ResolveRoute determines which agent handles the message and constructs session keys. // Implements the 7-level priority cascade: // peer > parent_peer > guild > team > account > channel_wildcard > default func (r *RouteResolver) ResolveRoute(input RouteInput) ResolvedRoute { channel := strings.ToLower(strings.TrimSpace(input.Channel)) accountID := NormalizeAccountID(input.AccountID) peer := input.Peer dmScope := DMScope(r.cfg.Session.DMScope) if dmScope == "" { dmScope = DMScopeMain } identityLinks := r.cfg.Session.IdentityLinks bindings := r.filterBindings(channel, accountID) choose := func(agentID string, matchedBy string) ResolvedRoute { resolvedAgentID := r.pickAgentID(agentID) sessionKey := strings.ToLower(BuildAgentPeerSessionKey(SessionKeyParams{ AgentID: resolvedAgentID, Channel: channel, AccountID: accountID, Peer: peer, DMScope: dmScope, IdentityLinks: identityLinks, })) mainSessionKey := strings.ToLower(BuildAgentMainSessionKey(resolvedAgentID)) return ResolvedRoute{ AgentID: resolvedAgentID, Channel: channel, AccountID: accountID, SessionKey: sessionKey, MainSessionKey: mainSessionKey, MatchedBy: matchedBy, } } // Priority 1: Peer binding if peer != nil && strings.TrimSpace(peer.ID) != "" { if match := r.findPeerMatch(bindings, peer); match != nil { return choose(match.AgentID, "binding.peer") } } // Priority 2: Parent peer binding parentPeer := input.ParentPeer if parentPeer != nil && strings.TrimSpace(parentPeer.ID) != "" { if match := r.findPeerMatch(bindings, parentPeer); match != nil { return choose(match.AgentID, "binding.peer.parent") } } // Priority 3: Guild binding guildID := strings.TrimSpace(input.GuildID) if guildID != "" { if match := r.findGuildMatch(bindings, guildID); match != nil { return choose(match.AgentID, "binding.guild") } } // Priority 4: Team binding teamID := strings.TrimSpace(input.TeamID) if teamID != "" { if match := r.findTeamMatch(bindings, teamID); match != nil { return choose(match.AgentID, "binding.team") } } // Priority 5: Account binding if match := r.findAccountMatch(bindings); match != nil { return choose(match.AgentID, "binding.account") } // Priority 6: Channel wildcard binding if match := r.findChannelWildcardMatch(bindings); match != nil { return choose(match.AgentID, "binding.channel") } // Priority 7: Default agent return choose(r.resolveDefaultAgentID(), "default") } func (r *RouteResolver) filterBindings(channel, accountID string) []config.AgentBinding { var filtered []config.AgentBinding for _, b := range r.cfg.Bindings { matchChannel := strings.ToLower(strings.TrimSpace(b.Match.Channel)) if matchChannel == "" || matchChannel != channel { continue } if !matchesAccountID(b.Match.AccountID, accountID) { continue } filtered = append(filtered, b) } return filtered } func matchesAccountID(matchAccountID, actual string) bool { trimmed := strings.TrimSpace(matchAccountID) if trimmed == "" { return actual == DefaultAccountID } if trimmed == "*" { return true } return strings.ToLower(trimmed) == strings.ToLower(actual) } func (r *RouteResolver) findPeerMatch(bindings []config.AgentBinding, peer *RoutePeer) *config.AgentBinding { for i := range bindings { b := &bindings[i] if b.Match.Peer == nil { continue } peerKind := strings.ToLower(strings.TrimSpace(b.Match.Peer.Kind)) peerID := strings.TrimSpace(b.Match.Peer.ID) if peerKind == "" || peerID == "" { continue } if peerKind == strings.ToLower(peer.Kind) && peerID == peer.ID { return b } } return nil } func (r *RouteResolver) findGuildMatch(bindings []config.AgentBinding, guildID string) *config.AgentBinding { for i := range bindings { b := &bindings[i] matchGuild := strings.TrimSpace(b.Match.GuildID) if matchGuild != "" && matchGuild == guildID { return &bindings[i] } } return nil } func (r *RouteResolver) findTeamMatch(bindings []config.AgentBinding, teamID string) *config.AgentBinding { for i := range bindings { b := &bindings[i] matchTeam := strings.TrimSpace(b.Match.TeamID) if matchTeam != "" && matchTeam == teamID { return &bindings[i] } } return nil } func (r *RouteResolver) findAccountMatch(bindings []config.AgentBinding) *config.AgentBinding { for i := range bindings { b := &bindings[i] accountID := strings.TrimSpace(b.Match.AccountID) if accountID == "*" { continue } if b.Match.Peer != nil || b.Match.GuildID != "" || b.Match.TeamID != "" { continue } return &bindings[i] } return nil } func (r *RouteResolver) findChannelWildcardMatch(bindings []config.AgentBinding) *config.AgentBinding { for i := range bindings { b := &bindings[i] accountID := strings.TrimSpace(b.Match.AccountID) if accountID != "*" { continue } if b.Match.Peer != nil || b.Match.GuildID != "" || b.Match.TeamID != "" { continue } return &bindings[i] } return nil } func (r *RouteResolver) pickAgentID(agentID string) string { trimmed := strings.TrimSpace(agentID) if trimmed == "" { return NormalizeAgentID(r.resolveDefaultAgentID()) } normalized := NormalizeAgentID(trimmed) agents := r.cfg.Agents.List if len(agents) == 0 { return normalized } for _, a := range agents { if NormalizeAgentID(a.ID) == normalized { return normalized } } return NormalizeAgentID(r.resolveDefaultAgentID()) } func (r *RouteResolver) resolveDefaultAgentID() string { agents := r.cfg.Agents.List if len(agents) == 0 { return DefaultAgentID } for _, a := range agents { if a.Default { id := strings.TrimSpace(a.ID) if id != "" { return NormalizeAgentID(id) } } } if id := strings.TrimSpace(agents[0].ID); id != "" { return NormalizeAgentID(id) } return DefaultAgentID } ================================================ FILE: pkg/routing/route_test.go ================================================ package routing import ( "testing" "github.com/sipeed/picoclaw/pkg/config" ) func testConfig(agents []config.AgentConfig, bindings []config.AgentBinding) *config.Config { return &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: "/tmp/picoclaw-test", Model: "gpt-4", }, List: agents, }, Bindings: bindings, Session: config.SessionConfig{ DMScope: "per-peer", }, } } func TestResolveRoute_DefaultAgent_NoBindings(t *testing.T) { cfg := testConfig(nil, nil) r := NewRouteResolver(cfg) route := r.ResolveRoute(RouteInput{ Channel: "telegram", Peer: &RoutePeer{Kind: "direct", ID: "user1"}, }) if route.AgentID != DefaultAgentID { t.Errorf("AgentID = %q, want %q", route.AgentID, DefaultAgentID) } if route.MatchedBy != "default" { t.Errorf("MatchedBy = %q, want 'default'", route.MatchedBy) } } func TestResolveRoute_PeerBinding(t *testing.T) { agents := []config.AgentConfig{ {ID: "sales", Default: true}, {ID: "support"}, } bindings := []config.AgentBinding{ { AgentID: "support", Match: config.BindingMatch{ Channel: "telegram", AccountID: "*", Peer: &config.PeerMatch{Kind: "direct", ID: "user123"}, }, }, } cfg := testConfig(agents, bindings) r := NewRouteResolver(cfg) route := r.ResolveRoute(RouteInput{ Channel: "telegram", Peer: &RoutePeer{Kind: "direct", ID: "user123"}, }) if route.AgentID != "support" { t.Errorf("AgentID = %q, want 'support'", route.AgentID) } if route.MatchedBy != "binding.peer" { t.Errorf("MatchedBy = %q, want 'binding.peer'", route.MatchedBy) } } func TestResolveRoute_GuildBinding(t *testing.T) { agents := []config.AgentConfig{ {ID: "general", Default: true}, {ID: "gaming"}, } bindings := []config.AgentBinding{ { AgentID: "gaming", Match: config.BindingMatch{ Channel: "discord", AccountID: "*", GuildID: "guild-abc", }, }, } cfg := testConfig(agents, bindings) r := NewRouteResolver(cfg) route := r.ResolveRoute(RouteInput{ Channel: "discord", GuildID: "guild-abc", Peer: &RoutePeer{Kind: "channel", ID: "ch1"}, }) if route.AgentID != "gaming" { t.Errorf("AgentID = %q, want 'gaming'", route.AgentID) } if route.MatchedBy != "binding.guild" { t.Errorf("MatchedBy = %q, want 'binding.guild'", route.MatchedBy) } } func TestResolveRoute_TeamBinding(t *testing.T) { agents := []config.AgentConfig{ {ID: "general", Default: true}, {ID: "work"}, } bindings := []config.AgentBinding{ { AgentID: "work", Match: config.BindingMatch{ Channel: "slack", AccountID: "*", TeamID: "T12345", }, }, } cfg := testConfig(agents, bindings) r := NewRouteResolver(cfg) route := r.ResolveRoute(RouteInput{ Channel: "slack", TeamID: "T12345", Peer: &RoutePeer{Kind: "channel", ID: "C001"}, }) if route.AgentID != "work" { t.Errorf("AgentID = %q, want 'work'", route.AgentID) } if route.MatchedBy != "binding.team" { t.Errorf("MatchedBy = %q, want 'binding.team'", route.MatchedBy) } } func TestResolveRoute_AccountBinding(t *testing.T) { agents := []config.AgentConfig{ {ID: "default-agent", Default: true}, {ID: "premium"}, } bindings := []config.AgentBinding{ { AgentID: "premium", Match: config.BindingMatch{ Channel: "telegram", AccountID: "bot2", }, }, } cfg := testConfig(agents, bindings) r := NewRouteResolver(cfg) route := r.ResolveRoute(RouteInput{ Channel: "telegram", AccountID: "bot2", Peer: &RoutePeer{Kind: "direct", ID: "user1"}, }) if route.AgentID != "premium" { t.Errorf("AgentID = %q, want 'premium'", route.AgentID) } if route.MatchedBy != "binding.account" { t.Errorf("MatchedBy = %q, want 'binding.account'", route.MatchedBy) } } func TestResolveRoute_ChannelWildcard(t *testing.T) { agents := []config.AgentConfig{ {ID: "main", Default: true}, {ID: "telegram-bot"}, } bindings := []config.AgentBinding{ { AgentID: "telegram-bot", Match: config.BindingMatch{ Channel: "telegram", AccountID: "*", }, }, } cfg := testConfig(agents, bindings) r := NewRouteResolver(cfg) route := r.ResolveRoute(RouteInput{ Channel: "telegram", Peer: &RoutePeer{Kind: "direct", ID: "user1"}, }) if route.AgentID != "telegram-bot" { t.Errorf("AgentID = %q, want 'telegram-bot'", route.AgentID) } if route.MatchedBy != "binding.channel" { t.Errorf("MatchedBy = %q, want 'binding.channel'", route.MatchedBy) } } func TestResolveRoute_PriorityOrder_PeerBeatsGuild(t *testing.T) { agents := []config.AgentConfig{ {ID: "general", Default: true}, {ID: "vip"}, {ID: "gaming"}, } bindings := []config.AgentBinding{ { AgentID: "vip", Match: config.BindingMatch{ Channel: "discord", AccountID: "*", Peer: &config.PeerMatch{Kind: "direct", ID: "user-vip"}, }, }, { AgentID: "gaming", Match: config.BindingMatch{ Channel: "discord", AccountID: "*", GuildID: "guild-1", }, }, } cfg := testConfig(agents, bindings) r := NewRouteResolver(cfg) route := r.ResolveRoute(RouteInput{ Channel: "discord", GuildID: "guild-1", Peer: &RoutePeer{Kind: "direct", ID: "user-vip"}, }) if route.AgentID != "vip" { t.Errorf("AgentID = %q, want 'vip' (peer should beat guild)", route.AgentID) } if route.MatchedBy != "binding.peer" { t.Errorf("MatchedBy = %q, want 'binding.peer'", route.MatchedBy) } } func TestResolveRoute_InvalidAgentFallsToDefault(t *testing.T) { agents := []config.AgentConfig{ {ID: "main", Default: true}, } bindings := []config.AgentBinding{ { AgentID: "nonexistent", Match: config.BindingMatch{ Channel: "telegram", AccountID: "*", }, }, } cfg := testConfig(agents, bindings) r := NewRouteResolver(cfg) route := r.ResolveRoute(RouteInput{ Channel: "telegram", }) if route.AgentID != "main" { t.Errorf("AgentID = %q, want 'main' (invalid agent should fall to default)", route.AgentID) } } func TestResolveRoute_DefaultAgentSelection(t *testing.T) { agents := []config.AgentConfig{ {ID: "alpha"}, {ID: "beta", Default: true}, {ID: "gamma"}, } cfg := testConfig(agents, nil) r := NewRouteResolver(cfg) route := r.ResolveRoute(RouteInput{ Channel: "cli", }) if route.AgentID != "beta" { t.Errorf("AgentID = %q, want 'beta' (marked as default)", route.AgentID) } } func TestResolveRoute_NoDefaultUsesFirst(t *testing.T) { agents := []config.AgentConfig{ {ID: "alpha"}, {ID: "beta"}, } cfg := testConfig(agents, nil) r := NewRouteResolver(cfg) route := r.ResolveRoute(RouteInput{ Channel: "cli", }) if route.AgentID != "alpha" { t.Errorf("AgentID = %q, want 'alpha' (first in list)", route.AgentID) } } ================================================ FILE: pkg/routing/router.go ================================================ package routing import ( "github.com/sipeed/picoclaw/pkg/providers" ) // defaultThreshold is used when the config threshold is zero or negative. // At 0.35 a message needs at least one strong signal (code block, long text, // or an attachment) before the heavy model is chosen. const defaultThreshold = 0.35 // RouterConfig holds the validated model routing settings. // It mirrors config.RoutingConfig but lives in pkg/routing to keep the // dependency graph simple: pkg/agent resolves config → routing, not the reverse. type RouterConfig struct { // LightModel is the model_name (from model_list) used for simple tasks. LightModel string // Threshold is the complexity score cutoff in [0, 1]. // score >= Threshold → primary (heavy) model. // score < Threshold → light model. Threshold float64 } // Router selects the appropriate model tier for each incoming message. // It is safe for concurrent use from multiple goroutines. type Router struct { cfg RouterConfig classifier Classifier } // New creates a Router with the given config and the default RuleClassifier. // If cfg.Threshold is zero or negative, defaultThreshold (0.35) is used. func New(cfg RouterConfig) *Router { if cfg.Threshold <= 0 { cfg.Threshold = defaultThreshold } return &Router{ cfg: cfg, classifier: &RuleClassifier{}, } } // newWithClassifier creates a Router with a custom Classifier. // Intended for unit tests that need to inject a deterministic scorer. func newWithClassifier(cfg RouterConfig, c Classifier) *Router { if cfg.Threshold <= 0 { cfg.Threshold = defaultThreshold } return &Router{cfg: cfg, classifier: c} } // SelectModel returns the model to use for this conversation turn along with // the computed complexity score (for logging and debugging). // // - If score < cfg.Threshold: returns (cfg.LightModel, true, score) // - Otherwise: returns (primaryModel, false, score) // // The caller is responsible for resolving the returned model name into // provider candidates (see AgentInstance.LightCandidates). func (r *Router) SelectModel( msg string, history []providers.Message, primaryModel string, ) (model string, usedLight bool, score float64) { features := ExtractFeatures(msg, history) score = r.classifier.Score(features) if score < r.cfg.Threshold { return r.cfg.LightModel, true, score } return primaryModel, false, score } // LightModel returns the configured light model name. func (r *Router) LightModel() string { return r.cfg.LightModel } // Threshold returns the complexity threshold in use. func (r *Router) Threshold() float64 { return r.cfg.Threshold } ================================================ FILE: pkg/routing/router_test.go ================================================ package routing import ( "strings" "testing" "github.com/sipeed/picoclaw/pkg/providers" ) // ── ExtractFeatures ────────────────────────────────────────────────────────── func TestExtractFeatures_EmptyMessage(t *testing.T) { f := ExtractFeatures("", nil) if f.TokenEstimate != 0 { t.Errorf("TokenEstimate: got %d, want 0", f.TokenEstimate) } if f.CodeBlockCount != 0 { t.Errorf("CodeBlockCount: got %d, want 0", f.CodeBlockCount) } if f.RecentToolCalls != 0 { t.Errorf("RecentToolCalls: got %d, want 0", f.RecentToolCalls) } if f.ConversationDepth != 0 { t.Errorf("ConversationDepth: got %d, want 0", f.ConversationDepth) } if f.HasAttachments { t.Error("HasAttachments: got true, want false") } } func TestExtractFeatures_TokenEstimate(t *testing.T) { // 30 ASCII runes: 0 CJK + 30/4 = 7 tokens msg := strings.Repeat("a", 30) f := ExtractFeatures(msg, nil) if f.TokenEstimate != 7 { t.Errorf("TokenEstimate: got %d, want 7", f.TokenEstimate) } } func TestExtractFeatures_TokenEstimate_CJK(t *testing.T) { // 9 CJK runes → 9 tokens (each CJK rune ≈ 1 token). // Using a rune slice literal avoids CJK string literals in source. msg := string([]rune{ 0x4F60, 0x597D, 0x4E16, 0x754C, 0x4F60, 0x597D, 0x4E16, 0x754C, 0x4F60, }) f := ExtractFeatures(msg, nil) if f.TokenEstimate != 9 { t.Errorf("CJK TokenEstimate: got %d, want 9", f.TokenEstimate) } } func TestExtractFeatures_TokenEstimate_Mixed(t *testing.T) { // Mixed: 4 CJK runes + 8 ASCII runes → 4 + 8/4 = 6 tokens. msg := string([]rune{0x4F60, 0x597D, 0x4E16, 0x754C}) + "hello ok" f := ExtractFeatures(msg, nil) if f.TokenEstimate != 6 { t.Errorf("Mixed TokenEstimate: got %d, want 6", f.TokenEstimate) } } func TestExtractFeatures_CodeBlocks(t *testing.T) { cases := []struct { msg string want int }{ {"no code here", 0}, {"```go\nfmt.Println()\n```", 1}, {"```python\npass\n```\n```js\nconsole.log()\n```", 2}, {"```unclosed", 0}, // odd number of fences = 0 complete blocks } for _, tc := range cases { f := ExtractFeatures(tc.msg, nil) if f.CodeBlockCount != tc.want { t.Errorf("msg=%q: CodeBlockCount got %d, want %d", tc.msg, f.CodeBlockCount, tc.want) } } } func TestExtractFeatures_RecentToolCalls(t *testing.T) { // History longer than lookbackWindow — only last lookbackWindow entries count. history := make([]providers.Message, 10) // Put 2 tool calls at positions 8 and 9 (within the last 6) history[8] = providers.Message{Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "exec"}}} history[9] = providers.Message{ Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "read_file"}, {Name: "write_file"}}, } // Position 3 is outside the lookback window and must NOT be counted history[3] = providers.Message{Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "old_tool"}}} f := ExtractFeatures("test", history) // 1 (position 8) + 2 (position 9) = 3 if f.RecentToolCalls != 3 { t.Errorf("RecentToolCalls: got %d, want 3", f.RecentToolCalls) } } func TestExtractFeatures_ConversationDepth(t *testing.T) { history := make([]providers.Message, 7) f := ExtractFeatures("msg", history) if f.ConversationDepth != 7 { t.Errorf("ConversationDepth: got %d, want 7", f.ConversationDepth) } } func TestExtractFeatures_HasAttachments_DataURI(t *testing.T) { cases := []struct { msg string want bool }{ {"plain text", false}, {"here is an image: data:image/png;base64,abc123", true}, {"audio: data:audio/mp3;base64,xyz", true}, {"video: data:video/mp4;base64,xyz", true}, } for _, tc := range cases { f := ExtractFeatures(tc.msg, nil) if f.HasAttachments != tc.want { t.Errorf("msg=%q: HasAttachments got %v, want %v", tc.msg, f.HasAttachments, tc.want) } } } func TestExtractFeatures_HasAttachments_Extension(t *testing.T) { cases := []struct { msg string want bool }{ {"check out photo.jpg", true}, {"see screenshot.png", true}, {"listen to audio.mp3", true}, {"watch clip.mp4", true}, {"just a .go file", false}, {"document.pdf", false}, // pdf is not in the media list } for _, tc := range cases { f := ExtractFeatures(tc.msg, nil) if f.HasAttachments != tc.want { t.Errorf("msg=%q: HasAttachments got %v, want %v", tc.msg, f.HasAttachments, tc.want) } } } // ── RuleClassifier ─────────────────────────────────────────────────────────── func TestRuleClassifier_ZeroFeatures(t *testing.T) { c := &RuleClassifier{} score := c.Score(Features{}) if score != 0.0 { t.Errorf("zero features: got %f, want 0.0", score) } } func TestRuleClassifier_AttachmentsHardGate(t *testing.T) { c := &RuleClassifier{} score := c.Score(Features{HasAttachments: true}) if score != 1.0 { t.Errorf("attachments: got %f, want 1.0", score) } } func TestRuleClassifier_CodeBlockAlone(t *testing.T) { c := &RuleClassifier{} // Code block alone = 0.40, above default threshold 0.35 score := c.Score(Features{CodeBlockCount: 1}) if score < 0.35 { t.Errorf("code block: score %f is below default threshold 0.35", score) } } func TestRuleClassifier_LongMessage(t *testing.T) { c := &RuleClassifier{} // >200 tokens = 0.35, exactly at default threshold → heavy score := c.Score(Features{TokenEstimate: 250}) if score < 0.35 { t.Errorf("long message: score %f is below default threshold 0.35", score) } } func TestRuleClassifier_MediumMessage(t *testing.T) { c := &RuleClassifier{} // 50-200 tokens = 0.15, below threshold → light score := c.Score(Features{TokenEstimate: 100}) if score >= 0.35 { t.Errorf("medium message: score %f should be below default threshold 0.35", score) } } func TestRuleClassifier_ShortMessage(t *testing.T) { c := &RuleClassifier{} // <50 tokens, no other signals = 0.0 → light score := c.Score(Features{TokenEstimate: 10}) if score != 0.0 { t.Errorf("short message: got %f, want 0.0", score) } } func TestRuleClassifier_ToolCallDensity(t *testing.T) { c := &RuleClassifier{} scoreNone := c.Score(Features{RecentToolCalls: 0}) scoreLow := c.Score(Features{RecentToolCalls: 2}) scoreHigh := c.Score(Features{RecentToolCalls: 5}) if scoreNone != 0.0 { t.Errorf("no tools: got %f, want 0.0", scoreNone) } if scoreLow <= scoreNone { t.Errorf("low tools should score higher than none: %f vs %f", scoreLow, scoreNone) } if scoreHigh <= scoreLow { t.Errorf("high tools should score higher than low: %f vs %f", scoreHigh, scoreLow) } } func TestRuleClassifier_DeepConversation(t *testing.T) { c := &RuleClassifier{} shallow := c.Score(Features{ConversationDepth: 5}) deep := c.Score(Features{ConversationDepth: 15}) if deep <= shallow { t.Errorf("deep conversation should score higher: %f vs %f", deep, shallow) } } func TestRuleClassifier_ScoreDoesNotExceedOne(t *testing.T) { c := &RuleClassifier{} // Max all signals simultaneously f := Features{ TokenEstimate: 500, CodeBlockCount: 3, RecentToolCalls: 10, ConversationDepth: 20, } score := c.Score(f) if score > 1.0 { t.Errorf("score %f exceeds 1.0", score) } } // ── Router ─────────────────────────────────────────────────────────────────── func TestRouter_DefaultThreshold(t *testing.T) { r := New(RouterConfig{LightModel: "gemini-flash"}) if r.Threshold() != defaultThreshold { t.Errorf("default threshold: got %f, want %f", r.Threshold(), defaultThreshold) } } func TestRouter_NegativeThresholdFallsBackToDefault(t *testing.T) { r := New(RouterConfig{LightModel: "gemini-flash", Threshold: -0.1}) if r.Threshold() != defaultThreshold { t.Errorf("negative threshold: got %f, want %f", r.Threshold(), defaultThreshold) } } func TestRouter_SelectModel_SimpleMessageUsesLight(t *testing.T) { r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35}) msg := "hi" model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") if !usedLight { t.Error("simple message: expected light model to be selected") } if model != "gemini-flash" { t.Errorf("simple message: model got %q, want %q", model, "gemini-flash") } } func TestRouter_SelectModel_CodeBlockUsesPrimary(t *testing.T) { r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35}) msg := "```go\nfmt.Println(\"hello\")\n```" model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") if usedLight { t.Error("code block: expected primary model to be selected") } if model != "claude-sonnet-4-6" { t.Errorf("code block: model got %q, want %q", model, "claude-sonnet-4-6") } } func TestRouter_SelectModel_AttachmentUsesPrimary(t *testing.T) { r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35}) msg := "can you analyze this? data:image/png;base64,abc123" model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") if usedLight { t.Error("attachment: expected primary model to be selected") } if model != "claude-sonnet-4-6" { t.Errorf("attachment: model got %q, want %q", model, "claude-sonnet-4-6") } } func TestRouter_SelectModel_LongMessageUsesPrimary(t *testing.T) { r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35}) // >200 token estimate: 210 * 3 = 630 chars msg := strings.Repeat("word ", 210) model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") if usedLight { t.Error("long message: expected primary model to be selected") } if model != "claude-sonnet-4-6" { t.Errorf("long message: model got %q, want %q", model, "claude-sonnet-4-6") } } func TestRouter_SelectModel_DeepToolChainUsesLight(t *testing.T) { // Tool calls alone (0.25) don't cross the 0.35 threshold — acceptable behavior. // Routing is conservative: only promote to heavy when the signal is unambiguous. r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35}) history := []providers.Message{ {Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "read_file"}, {Name: "write_file"}}}, {Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "exec"}, {Name: "search"}}}, } msg := "ok" _, usedLight, _ := r.SelectModel(msg, history, "claude-sonnet-4-6") if !usedLight { t.Error("short message + moderate tool calls: expected light model (score 0.20 < 0.35)") } } func TestRouter_SelectModel_ToolChainPlusMediumUsesHeavy(t *testing.T) { // Tool calls (0.25) + medium message (0.15) = 0.40 >= 0.35 → heavy r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35}) history := []providers.Message{ {Role: "assistant", ToolCalls: []providers.ToolCall{ {Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "d"}, }}, } // ~55 tokens * 3 = 165 chars msg := strings.Repeat("word ", 55) _, usedLight, _ := r.SelectModel(msg, history, "claude-sonnet-4-6") if usedLight { t.Error("tool chain + medium message: expected primary model (score >= 0.35)") } } func TestRouter_SelectModel_CustomThreshold(t *testing.T) { // Very low threshold: even a short message triggers heavy model r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.05}) msg := strings.Repeat("word ", 55) // medium message → 0.15 >= 0.05 _, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") if usedLight { t.Error("low threshold: medium message should use primary model") } } func TestRouter_SelectModel_HighThreshold(t *testing.T) { // Very high threshold: even code blocks route to light r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.99}) msg := "```go\nfmt.Println()\n```" _, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") if !usedLight { t.Error("very high threshold: code block (0.40) should route to light model") } } func TestRouter_LightModel(t *testing.T) { r := New(RouterConfig{LightModel: "my-fast-model", Threshold: 0.35}) if r.LightModel() != "my-fast-model" { t.Errorf("LightModel: got %q, want %q", r.LightModel(), "my-fast-model") } } // ── newWithClassifier (internal testing hook) ───────────────────────────────── type fixedScoreClassifier struct{ score float64 } func (f *fixedScoreClassifier) Score(_ Features) float64 { return f.score } func TestRouter_CustomClassifier_LowScore_SelectsLight(t *testing.T) { r := newWithClassifier( RouterConfig{LightModel: "light", Threshold: 0.5}, &fixedScoreClassifier{score: 0.2}, ) _, usedLight, _ := r.SelectModel("anything", nil, "heavy") if !usedLight { t.Error("low score with custom classifier: expected light model") } } func TestRouter_CustomClassifier_HighScore_SelectsPrimary(t *testing.T) { r := newWithClassifier( RouterConfig{LightModel: "light", Threshold: 0.5}, &fixedScoreClassifier{score: 0.8}, ) _, usedLight, _ := r.SelectModel("anything", nil, "heavy") if usedLight { t.Error("high score with custom classifier: expected primary model") } } func TestRouter_CustomClassifier_ExactThreshold_SelectsPrimary(t *testing.T) { // score == threshold → primary (uses >= comparison) r := newWithClassifier( RouterConfig{LightModel: "light", Threshold: 0.5}, &fixedScoreClassifier{score: 0.5}, ) _, usedLight, _ := r.SelectModel("anything", nil, "heavy") if usedLight { t.Error("score == threshold: expected primary model (>= threshold → primary)") } } func TestRouter_SelectModel_ReturnsScore(t *testing.T) { r := newWithClassifier( RouterConfig{LightModel: "light", Threshold: 0.5}, &fixedScoreClassifier{score: 0.42}, ) _, _, score := r.SelectModel("anything", nil, "heavy") if score != 0.42 { t.Errorf("score: got %f, want 0.42", score) } } ================================================ FILE: pkg/routing/session_key.go ================================================ package routing import ( "fmt" "strings" ) // DMScope controls DM session isolation granularity. type DMScope string const ( DMScopeMain DMScope = "main" DMScopePerPeer DMScope = "per-peer" DMScopePerChannelPeer DMScope = "per-channel-peer" DMScopePerAccountChannelPeer DMScope = "per-account-channel-peer" ) // RoutePeer represents a chat peer with kind and ID. type RoutePeer struct { Kind string // "direct", "group", "channel" ID string } // SessionKeyParams holds all inputs for session key construction. type SessionKeyParams struct { AgentID string Channel string AccountID string Peer *RoutePeer DMScope DMScope IdentityLinks map[string][]string } // ParsedSessionKey is the result of parsing an agent-scoped session key. type ParsedSessionKey struct { AgentID string Rest string } // BuildAgentMainSessionKey returns "agent:<agentId>:main". func BuildAgentMainSessionKey(agentID string) string { return fmt.Sprintf("agent:%s:%s", NormalizeAgentID(agentID), DefaultMainKey) } // BuildAgentPeerSessionKey constructs a session key based on agent, channel, peer, and DM scope. func BuildAgentPeerSessionKey(params SessionKeyParams) string { agentID := NormalizeAgentID(params.AgentID) peer := params.Peer if peer == nil { peer = &RoutePeer{Kind: "direct"} } peerKind := strings.TrimSpace(peer.Kind) if peerKind == "" { peerKind = "direct" } if peerKind == "direct" { dmScope := params.DMScope if dmScope == "" { dmScope = DMScopeMain } peerID := strings.TrimSpace(peer.ID) // Resolve identity links (cross-platform collapse) if dmScope != DMScopeMain && peerID != "" { if linked := resolveLinkedPeerID(params.IdentityLinks, params.Channel, peerID); linked != "" { peerID = linked } } peerID = strings.ToLower(peerID) switch dmScope { case DMScopePerAccountChannelPeer: if peerID != "" { channel := normalizeChannel(params.Channel) accountID := NormalizeAccountID(params.AccountID) return fmt.Sprintf("agent:%s:%s:%s:direct:%s", agentID, channel, accountID, peerID) } case DMScopePerChannelPeer: if peerID != "" { channel := normalizeChannel(params.Channel) return fmt.Sprintf("agent:%s:%s:direct:%s", agentID, channel, peerID) } case DMScopePerPeer: if peerID != "" { return fmt.Sprintf("agent:%s:direct:%s", agentID, peerID) } } return BuildAgentMainSessionKey(agentID) } // Group/channel peers always get per-peer sessions channel := normalizeChannel(params.Channel) peerID := strings.ToLower(strings.TrimSpace(peer.ID)) if peerID == "" { peerID = "unknown" } return fmt.Sprintf("agent:%s:%s:%s:%s", agentID, channel, peerKind, peerID) } // ParseAgentSessionKey extracts agentId and rest from "agent:<agentId>:<rest>". func ParseAgentSessionKey(sessionKey string) *ParsedSessionKey { raw := strings.TrimSpace(sessionKey) if raw == "" { return nil } parts := strings.SplitN(raw, ":", 3) if len(parts) < 3 { return nil } if parts[0] != "agent" { return nil } agentID := strings.TrimSpace(parts[1]) rest := parts[2] if agentID == "" || rest == "" { return nil } return &ParsedSessionKey{AgentID: agentID, Rest: rest} } // IsSubagentSessionKey returns true if the session key represents a subagent. func IsSubagentSessionKey(sessionKey string) bool { raw := strings.TrimSpace(sessionKey) if raw == "" { return false } if strings.HasPrefix(strings.ToLower(raw), "subagent:") { return true } parsed := ParseAgentSessionKey(raw) if parsed == nil { return false } return strings.HasPrefix(strings.ToLower(parsed.Rest), "subagent:") } func normalizeChannel(channel string) string { c := strings.TrimSpace(strings.ToLower(channel)) if c == "" { return "unknown" } return c } func resolveLinkedPeerID(identityLinks map[string][]string, channel, peerID string) string { if len(identityLinks) == 0 { return "" } peerID = strings.TrimSpace(peerID) if peerID == "" { return "" } candidates := make(map[string]bool) rawCandidate := strings.ToLower(peerID) if rawCandidate != "" { candidates[rawCandidate] = true } channel = strings.ToLower(strings.TrimSpace(channel)) if channel != "" { scopedCandidate := fmt.Sprintf("%s:%s", channel, strings.ToLower(peerID)) candidates[scopedCandidate] = true } // If peerID is already in canonical "platform:id" format, also add the // bare ID part as a candidate for backward compatibility with identity_links // that use raw IDs (e.g. "123" instead of "telegram:123"). if idx := strings.Index(rawCandidate, ":"); idx > 0 && idx < len(rawCandidate)-1 { bareID := rawCandidate[idx+1:] candidates[bareID] = true } if len(candidates) == 0 { return "" } for canonical, ids := range identityLinks { canonicalName := strings.TrimSpace(canonical) if canonicalName == "" { continue } for _, id := range ids { normalized := strings.ToLower(strings.TrimSpace(id)) if normalized != "" && candidates[normalized] { return canonicalName } } } return "" } ================================================ FILE: pkg/routing/session_key_test.go ================================================ package routing import "testing" func TestBuildAgentMainSessionKey(t *testing.T) { got := BuildAgentMainSessionKey("sales") want := "agent:sales:main" if got != want { t.Errorf("BuildAgentMainSessionKey('sales') = %q, want %q", got, want) } } func TestBuildAgentMainSessionKey_Normalizes(t *testing.T) { got := BuildAgentMainSessionKey("Sales Bot") want := "agent:sales-bot:main" if got != want { t.Errorf("BuildAgentMainSessionKey('Sales Bot') = %q, want %q", got, want) } } func TestBuildAgentPeerSessionKey_DMScopeMain(t *testing.T) { got := BuildAgentPeerSessionKey(SessionKeyParams{ AgentID: "main", Channel: "telegram", Peer: &RoutePeer{Kind: "direct", ID: "user123"}, DMScope: DMScopeMain, }) want := "agent:main:main" if got != want { t.Errorf("DMScopeMain = %q, want %q", got, want) } } func TestBuildAgentPeerSessionKey_DMScopePerPeer(t *testing.T) { got := BuildAgentPeerSessionKey(SessionKeyParams{ AgentID: "main", Channel: "telegram", Peer: &RoutePeer{Kind: "direct", ID: "user123"}, DMScope: DMScopePerPeer, }) want := "agent:main:direct:user123" if got != want { t.Errorf("DMScopePerPeer = %q, want %q", got, want) } } func TestBuildAgentPeerSessionKey_DMScopePerChannelPeer(t *testing.T) { got := BuildAgentPeerSessionKey(SessionKeyParams{ AgentID: "main", Channel: "telegram", Peer: &RoutePeer{Kind: "direct", ID: "user123"}, DMScope: DMScopePerChannelPeer, }) want := "agent:main:telegram:direct:user123" if got != want { t.Errorf("DMScopePerChannelPeer = %q, want %q", got, want) } } func TestBuildAgentPeerSessionKey_DMScopePerAccountChannelPeer(t *testing.T) { got := BuildAgentPeerSessionKey(SessionKeyParams{ AgentID: "main", Channel: "telegram", AccountID: "bot1", Peer: &RoutePeer{Kind: "direct", ID: "User123"}, DMScope: DMScopePerAccountChannelPeer, }) want := "agent:main:telegram:bot1:direct:user123" if got != want { t.Errorf("DMScopePerAccountChannelPeer = %q, want %q", got, want) } } func TestBuildAgentPeerSessionKey_GroupPeer(t *testing.T) { got := BuildAgentPeerSessionKey(SessionKeyParams{ AgentID: "main", Channel: "telegram", Peer: &RoutePeer{Kind: "group", ID: "chat456"}, DMScope: DMScopePerPeer, }) want := "agent:main:telegram:group:chat456" if got != want { t.Errorf("GroupPeer = %q, want %q", got, want) } } func TestBuildAgentPeerSessionKey_NilPeer(t *testing.T) { got := BuildAgentPeerSessionKey(SessionKeyParams{ AgentID: "main", Channel: "telegram", Peer: nil, DMScope: DMScopePerPeer, }) // nil peer defaults to direct with empty ID, falls to main want := "agent:main:main" if got != want { t.Errorf("NilPeer = %q, want %q", got, want) } } func TestBuildAgentPeerSessionKey_IdentityLink(t *testing.T) { links := map[string][]string{ "john": {"telegram:user123", "discord:john#1234"}, } got := BuildAgentPeerSessionKey(SessionKeyParams{ AgentID: "main", Channel: "telegram", Peer: &RoutePeer{Kind: "direct", ID: "user123"}, DMScope: DMScopePerPeer, IdentityLinks: links, }) want := "agent:main:direct:john" if got != want { t.Errorf("IdentityLink = %q, want %q", got, want) } } func TestResolveLinkedPeerID_CanonicalPeerID(t *testing.T) { // When peerID is already in canonical "platform:id" format, // it should match identity_links that use the bare ID. links := map[string][]string{ "john": {"123"}, } got := resolveLinkedPeerID(links, "telegram", "telegram:123") if got != "john" { t.Errorf("resolveLinkedPeerID with canonical peerID = %q, want %q", got, "john") } } func TestResolveLinkedPeerID_CanonicalInLinks(t *testing.T) { // When identity_links contain canonical IDs and peerID is canonical too links := map[string][]string{ "john": {"telegram:123", "discord:456"}, } got := resolveLinkedPeerID(links, "telegram", "telegram:123") if got != "john" { t.Errorf("resolveLinkedPeerID canonical in links = %q, want %q", got, "john") } } func TestResolveLinkedPeerID_BarePeerIDMatchesCanonicalLink(t *testing.T) { // When peerID is bare "123" and links have "telegram:123", // the scoped candidate "telegram:123" should match. links := map[string][]string{ "john": {"telegram:123"}, } got := resolveLinkedPeerID(links, "telegram", "123") if got != "john" { t.Errorf("resolveLinkedPeerID bare peer matches canonical link = %q, want %q", got, "john") } } func TestResolveLinkedPeerID_NoMatch(t *testing.T) { links := map[string][]string{ "john": {"telegram:123"}, } got := resolveLinkedPeerID(links, "discord", "999") if got != "" { t.Errorf("resolveLinkedPeerID no match = %q, want empty", got) } } func TestParseAgentSessionKey_Valid(t *testing.T) { parsed := ParseAgentSessionKey("agent:sales:telegram:direct:user123") if parsed == nil { t.Fatal("expected non-nil result") } if parsed.AgentID != "sales" { t.Errorf("AgentID = %q, want 'sales'", parsed.AgentID) } if parsed.Rest != "telegram:direct:user123" { t.Errorf("Rest = %q, want 'telegram:direct:user123'", parsed.Rest) } } func TestParseAgentSessionKey_Invalid(t *testing.T) { tests := []string{ "", "foo:bar", "notprefix:sales:main", "agent::main", "agent:sales:", } for _, input := range tests { if got := ParseAgentSessionKey(input); got != nil { t.Errorf("ParseAgentSessionKey(%q) = %+v, want nil", input, got) } } } func TestIsSubagentSessionKey(t *testing.T) { tests := []struct { input string want bool }{ {"subagent:task-1", true}, {"agent:main:subagent:task-1", true}, {"agent:main:main", false}, {"agent:main:telegram:direct:user123", false}, {"", false}, } for _, tt := range tests { if got := IsSubagentSessionKey(tt.input); got != tt.want { t.Errorf("IsSubagentSessionKey(%q) = %v, want %v", tt.input, got, tt.want) } } } ================================================ FILE: pkg/session/jsonl_backend.go ================================================ package session import ( "context" "log" "github.com/sipeed/picoclaw/pkg/memory" "github.com/sipeed/picoclaw/pkg/providers" ) // JSONLBackend adapts a memory.Store into the SessionStore interface. // Write errors are logged rather than returned, matching the fire-and-forget // contract of SessionManager that the agent loop relies on. type JSONLBackend struct { store memory.Store } // NewJSONLBackend wraps a memory.Store for use as a SessionStore. func NewJSONLBackend(store memory.Store) *JSONLBackend { return &JSONLBackend{store: store} } func (b *JSONLBackend) AddMessage(sessionKey, role, content string) { if err := b.store.AddMessage(context.Background(), sessionKey, role, content); err != nil { log.Printf("session: add message: %v", err) } } func (b *JSONLBackend) AddFullMessage(sessionKey string, msg providers.Message) { if err := b.store.AddFullMessage(context.Background(), sessionKey, msg); err != nil { log.Printf("session: add full message: %v", err) } } func (b *JSONLBackend) GetHistory(key string) []providers.Message { msgs, err := b.store.GetHistory(context.Background(), key) if err != nil { log.Printf("session: get history: %v", err) return []providers.Message{} } return msgs } func (b *JSONLBackend) GetSummary(key string) string { summary, err := b.store.GetSummary(context.Background(), key) if err != nil { log.Printf("session: get summary: %v", err) return "" } return summary } func (b *JSONLBackend) SetSummary(key, summary string) { if err := b.store.SetSummary(context.Background(), key, summary); err != nil { log.Printf("session: set summary: %v", err) } } func (b *JSONLBackend) SetHistory(key string, history []providers.Message) { if err := b.store.SetHistory(context.Background(), key, history); err != nil { log.Printf("session: set history: %v", err) } } func (b *JSONLBackend) TruncateHistory(key string, keepLast int) { if err := b.store.TruncateHistory(context.Background(), key, keepLast); err != nil { log.Printf("session: truncate history: %v", err) } } // Save persists session state. Since the JSONL store fsyncs every write // immediately, the data is already durable. Save runs compaction to reclaim // space from logically truncated messages (no-op when there are none). func (b *JSONLBackend) Save(key string) error { return b.store.Compact(context.Background(), key) } // Close releases resources held by the underlying store. func (b *JSONLBackend) Close() error { return b.store.Close() } ================================================ FILE: pkg/session/jsonl_backend_test.go ================================================ package session_test import ( "fmt" "testing" "github.com/sipeed/picoclaw/pkg/memory" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/session" ) // Compile-time interface satisfaction checks. var ( _ session.SessionStore = (*session.SessionManager)(nil) _ session.SessionStore = (*session.JSONLBackend)(nil) ) func newBackend(t *testing.T) *session.JSONLBackend { t.Helper() store, err := memory.NewJSONLStore(t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { store.Close() }) return session.NewJSONLBackend(store) } func TestJSONLBackend_AddAndGetHistory(t *testing.T) { b := newBackend(t) b.AddMessage("s1", "user", "hello") b.AddMessage("s1", "assistant", "hi") history := b.GetHistory("s1") if len(history) != 2 { t.Fatalf("got %d messages, want 2", len(history)) } if history[0].Role != "user" || history[0].Content != "hello" { t.Errorf("msg[0] = %+v", history[0]) } if history[1].Role != "assistant" || history[1].Content != "hi" { t.Errorf("msg[1] = %+v", history[1]) } } func TestJSONLBackend_AddFullMessage(t *testing.T) { b := newBackend(t) msg := providers.Message{ Role: "assistant", Content: "done", ToolCalls: []providers.ToolCall{ {ID: "tc1", Function: &providers.FunctionCall{Name: "read_file", Arguments: `{"path":"x"}`}}, }, } b.AddFullMessage("s1", msg) history := b.GetHistory("s1") if len(history) != 1 { t.Fatalf("got %d, want 1", len(history)) } if len(history[0].ToolCalls) != 1 || history[0].ToolCalls[0].ID != "tc1" { t.Errorf("tool calls = %+v", history[0].ToolCalls) } } func TestJSONLBackend_Summary(t *testing.T) { b := newBackend(t) if got := b.GetSummary("s1"); got != "" { t.Errorf("got %q, want empty", got) } b.SetSummary("s1", "test summary") if got := b.GetSummary("s1"); got != "test summary" { t.Errorf("got %q, want %q", got, "test summary") } } func TestJSONLBackend_TruncateAndSave(t *testing.T) { b := newBackend(t) for i := 0; i < 10; i++ { b.AddMessage("s1", "user", fmt.Sprintf("msg %d", i)) } b.TruncateHistory("s1", 3) history := b.GetHistory("s1") if len(history) != 3 { t.Fatalf("got %d, want 3", len(history)) } if history[0].Content != "msg 7" { t.Errorf("got %q, want %q", history[0].Content, "msg 7") } // Save triggers compaction. if err := b.Save("s1"); err != nil { t.Fatal(err) } // Messages still accessible after compaction. history = b.GetHistory("s1") if len(history) != 3 { t.Fatalf("after save: got %d, want 3", len(history)) } } func TestJSONLBackend_SetHistory(t *testing.T) { b := newBackend(t) b.AddMessage("s1", "user", "old") b.SetHistory("s1", []providers.Message{ {Role: "user", Content: "new1"}, {Role: "assistant", Content: "new2"}, }) history := b.GetHistory("s1") if len(history) != 2 { t.Fatalf("got %d, want 2", len(history)) } if history[0].Content != "new1" { t.Errorf("got %q, want %q", history[0].Content, "new1") } } func TestJSONLBackend_EmptySession(t *testing.T) { b := newBackend(t) history := b.GetHistory("nonexistent") if history == nil { t.Fatal("got nil, want empty slice") } if len(history) != 0 { t.Errorf("got %d, want 0", len(history)) } } func TestJSONLBackend_SessionIsolation(t *testing.T) { b := newBackend(t) b.AddMessage("s1", "user", "session1") b.AddMessage("s2", "user", "session2") h1 := b.GetHistory("s1") h2 := b.GetHistory("s2") if len(h1) != 1 || h1[0].Content != "session1" { t.Errorf("s1: %+v", h1) } if len(h2) != 1 || h2[0].Content != "session2" { t.Errorf("s2: %+v", h2) } } func TestJSONLBackend_SummarizeFlow(t *testing.T) { // Simulates the real summarization flow in the agent loop: // SetSummary → TruncateHistory → Save b := newBackend(t) for i := 0; i < 20; i++ { b.AddMessage("s1", "user", fmt.Sprintf("msg %d", i)) } b.SetSummary("s1", "conversation about testing") b.TruncateHistory("s1", 4) if err := b.Save("s1"); err != nil { t.Fatal(err) } if got := b.GetSummary("s1"); got != "conversation about testing" { t.Errorf("summary = %q", got) } history := b.GetHistory("s1") if len(history) != 4 { t.Fatalf("got %d messages, want 4", len(history)) } if history[0].Content != "msg 16" { t.Errorf("first message = %q, want %q", history[0].Content, "msg 16") } } ================================================ FILE: pkg/session/manager.go ================================================ package session import ( "encoding/json" "os" "path/filepath" "strings" "sync" "time" "github.com/sipeed/picoclaw/pkg/providers" ) type Session struct { Key string `json:"key"` Messages []providers.Message `json:"messages"` Summary string `json:"summary,omitempty"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` } type SessionManager struct { sessions map[string]*Session mu sync.RWMutex storage string } func NewSessionManager(storage string) *SessionManager { sm := &SessionManager{ sessions: make(map[string]*Session), storage: storage, } if storage != "" { os.MkdirAll(storage, 0o700) sm.loadSessions() } return sm } func (sm *SessionManager) GetOrCreate(key string) *Session { sm.mu.Lock() defer sm.mu.Unlock() session, ok := sm.sessions[key] if ok { return session } session = &Session{ Key: key, Messages: []providers.Message{}, Created: time.Now(), Updated: time.Now(), } sm.sessions[key] = session return session } func (sm *SessionManager) AddMessage(sessionKey, role, content string) { sm.AddFullMessage(sessionKey, providers.Message{ Role: role, Content: content, }) } // AddFullMessage adds a complete message with tool calls and tool call ID to the session. // This is used to save the full conversation flow including tool calls and tool results. func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Message) { sm.mu.Lock() defer sm.mu.Unlock() session, ok := sm.sessions[sessionKey] if !ok { session = &Session{ Key: sessionKey, Messages: []providers.Message{}, Created: time.Now(), } sm.sessions[sessionKey] = session } session.Messages = append(session.Messages, msg) session.Updated = time.Now() } func (sm *SessionManager) GetHistory(key string) []providers.Message { sm.mu.RLock() defer sm.mu.RUnlock() session, ok := sm.sessions[key] if !ok { return []providers.Message{} } history := make([]providers.Message, len(session.Messages)) copy(history, session.Messages) return history } func (sm *SessionManager) GetSummary(key string) string { sm.mu.RLock() defer sm.mu.RUnlock() session, ok := sm.sessions[key] if !ok { return "" } return session.Summary } func (sm *SessionManager) SetSummary(key string, summary string) { sm.mu.Lock() defer sm.mu.Unlock() session, ok := sm.sessions[key] if ok { session.Summary = summary session.Updated = time.Now() } } func (sm *SessionManager) TruncateHistory(key string, keepLast int) { sm.mu.Lock() defer sm.mu.Unlock() session, ok := sm.sessions[key] if !ok { return } if keepLast <= 0 { session.Messages = []providers.Message{} session.Updated = time.Now() return } if len(session.Messages) <= keepLast { return } session.Messages = session.Messages[len(session.Messages)-keepLast:] session.Updated = time.Now() } // sanitizeFilename converts a session key into a cross-platform safe filename. // Replaces ':' with '_' (session key separator) and '/' and '\' with '_' so // composite IDs (e.g. Telegram forum "chatID/threadID") do not create // subdirectories or break on Windows. The original key is preserved inside // the JSON file, so loadSessions still maps back to the right in-memory key. func sanitizeFilename(key string) string { s := strings.ReplaceAll(key, ":", "_") s = strings.ReplaceAll(s, "/", "_") s = strings.ReplaceAll(s, "\\", "_") return s } func (sm *SessionManager) Save(key string) error { if sm.storage == "" { return nil } filename := sanitizeFilename(key) // filepath.IsLocal rejects empty names, "..", absolute paths, and // OS-reserved device names (NUL, COM1 … on Windows). sanitizeFilename // already replaced '/' and '\' with '_', so no subdirs are created. if filename == "." || !filepath.IsLocal(filename) { return os.ErrInvalid } // Snapshot under read lock, then perform slow file I/O after unlock. sm.mu.RLock() stored, ok := sm.sessions[key] if !ok { sm.mu.RUnlock() return nil } snapshot := Session{ Key: stored.Key, Summary: stored.Summary, Created: stored.Created, Updated: stored.Updated, } if len(stored.Messages) > 0 { snapshot.Messages = make([]providers.Message, len(stored.Messages)) copy(snapshot.Messages, stored.Messages) } else { snapshot.Messages = []providers.Message{} } sm.mu.RUnlock() data, err := json.MarshalIndent(snapshot, "", " ") if err != nil { return err } sessionPath := filepath.Join(sm.storage, filename+".json") tmpFile, err := os.CreateTemp(sm.storage, "session-*.tmp") if err != nil { return err } tmpPath := tmpFile.Name() cleanup := true defer func() { if cleanup { _ = os.Remove(tmpPath) } }() if _, err := tmpFile.Write(data); err != nil { _ = tmpFile.Close() return err } if err := tmpFile.Chmod(0o600); err != nil { _ = tmpFile.Close() return err } if err := tmpFile.Sync(); err != nil { _ = tmpFile.Close() return err } if err := tmpFile.Close(); err != nil { return err } if err := os.Rename(tmpPath, sessionPath); err != nil { return err } cleanup = false return nil } func (sm *SessionManager) loadSessions() error { files, err := os.ReadDir(sm.storage) if err != nil { return err } for _, file := range files { if file.IsDir() { continue } if filepath.Ext(file.Name()) != ".json" { continue } sessionPath := filepath.Join(sm.storage, file.Name()) data, err := os.ReadFile(sessionPath) if err != nil { continue } var session Session if err := json.Unmarshal(data, &session); err != nil { continue } sm.sessions[session.Key] = &session } return nil } // Close is a no-op for the in-memory SessionManager; it satisfies the // SessionStore interface so callers can release resources uniformly. func (sm *SessionManager) Close() error { return nil } // SetHistory updates the messages of a session. func (sm *SessionManager) SetHistory(key string, history []providers.Message) { sm.mu.Lock() defer sm.mu.Unlock() session, ok := sm.sessions[key] if ok { // Create a deep copy to strictly isolate internal state // from the caller's slice. msgs := make([]providers.Message, len(history)) copy(msgs, history) session.Messages = msgs session.Updated = time.Now() } } ================================================ FILE: pkg/session/manager_test.go ================================================ package session import ( "os" "path/filepath" "testing" ) func TestSanitizeFilename(t *testing.T) { tests := []struct { input string expected string }{ {"simple", "simple"}, {"telegram:123456", "telegram_123456"}, {"discord:987654321", "discord_987654321"}, {"slack:C01234", "slack_C01234"}, {"no-colons-here", "no-colons-here"}, {"multiple:colons:here", "multiple_colons_here"}, {"agent:main:telegram:group:-1003822706455/12", "agent_main_telegram_group_-1003822706455_12"}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { got := sanitizeFilename(tt.input) if got != tt.expected { t.Errorf("sanitizeFilename(%q) = %q, want %q", tt.input, got, tt.expected) } }) } } func TestSave_WithColonInKey(t *testing.T) { tmpDir := t.TempDir() sm := NewSessionManager(tmpDir) // Create a session with a key containing colon (typical channel session key). key := "telegram:123456" sm.GetOrCreate(key) sm.AddMessage(key, "user", "hello") // Save should succeed even though the key contains ':' if err := sm.Save(key); err != nil { t.Fatalf("Save(%q) failed: %v", key, err) } // The file on disk should use sanitized name. expectedFile := filepath.Join(tmpDir, "telegram_123456.json") if _, err := os.Stat(expectedFile); os.IsNotExist(err) { t.Fatalf("expected session file %s to exist", expectedFile) } // Load into a fresh manager and verify the session round-trips. sm2 := NewSessionManager(tmpDir) history := sm2.GetHistory(key) if len(history) != 1 { t.Fatalf("expected 1 message after reload, got %d", len(history)) } if history[0].Content != "hello" { t.Errorf("expected message content %q, got %q", "hello", history[0].Content) } } func TestSave_RejectsPathTraversal(t *testing.T) { tmpDir := t.TempDir() sm := NewSessionManager(tmpDir) // Invalid names that must still be rejected. badKeys := []string{"", ".", ".."} for _, key := range badKeys { sm.GetOrCreate(key) if err := sm.Save(key); err == nil { t.Errorf("Save(%q) should have failed but didn't", key) } } // Keys containing path separators are sanitized (no subdirs created). sm.GetOrCreate("foo/bar") if err := sm.Save("foo/bar"); err != nil { t.Fatalf("Save(\"foo/bar\") after sanitize should succeed: %v", err) } if _, err := os.Stat(filepath.Join(tmpDir, "foo_bar.json")); os.IsNotExist(err) { t.Errorf("expected foo_bar.json in storage (sanitized from foo/bar)") } } ================================================ FILE: pkg/session/session_store.go ================================================ package session import "github.com/sipeed/picoclaw/pkg/providers" // SessionStore defines the persistence operations used by the agent loop. // Both SessionManager (legacy JSON backend) and JSONLBackend satisfy this // interface, allowing the storage layer to be swapped without touching the // agent loop code. // // Write methods (Add*, Set*, Truncate*) are fire-and-forget: they do not // return errors. Implementations should log failures internally. This // matches the original SessionManager contract that the agent loop relies on. type SessionStore interface { // AddMessage appends a simple role/content message to the session. AddMessage(sessionKey, role, content string) // AddFullMessage appends a complete message including tool calls. AddFullMessage(sessionKey string, msg providers.Message) // GetHistory returns the full message history for the session. GetHistory(key string) []providers.Message // GetSummary returns the conversation summary, or "" if none. GetSummary(key string) string // SetSummary replaces the conversation summary. SetSummary(key, summary string) // SetHistory replaces the full message history. SetHistory(key string, history []providers.Message) // TruncateHistory keeps only the last keepLast messages. TruncateHistory(key string, keepLast int) // Save persists any pending state to durable storage. Save(key string) error // Close releases resources held by the store. Close() error } ================================================ FILE: pkg/skills/clawhub_registry.go ================================================ package skills import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "time" "github.com/sipeed/picoclaw/pkg/utils" ) const ( defaultClawHubTimeout = 30 * time.Second defaultMaxZipSize = 50 * 1024 * 1024 // 50 MB defaultMaxResponseSize = 2 * 1024 * 1024 // 2 MB ) // ClawHubRegistry implements SkillRegistry for the ClawHub platform. type ClawHubRegistry struct { baseURL string authToken string // Optional - for elevated rate limits searchPath string // Search API skillsPath string // For retrieving skill metadata downloadPath string // For fetching ZIP files for download maxZipSize int maxResponseSize int client *http.Client } // NewClawHubRegistry creates a new ClawHub registry client from config. func NewClawHubRegistry(cfg ClawHubConfig) *ClawHubRegistry { baseURL := cfg.BaseURL if baseURL == "" { baseURL = "https://clawhub.ai" } searchPath := cfg.SearchPath if searchPath == "" { searchPath = "/api/v1/search" } skillsPath := cfg.SkillsPath if skillsPath == "" { skillsPath = "/api/v1/skills" } downloadPath := cfg.DownloadPath if downloadPath == "" { downloadPath = "/api/v1/download" } timeout := defaultClawHubTimeout if cfg.Timeout > 0 { timeout = time.Duration(cfg.Timeout) * time.Second } maxZip := defaultMaxZipSize if cfg.MaxZipSize > 0 { maxZip = cfg.MaxZipSize } maxResp := defaultMaxResponseSize if cfg.MaxResponseSize > 0 { maxResp = cfg.MaxResponseSize } return &ClawHubRegistry{ baseURL: baseURL, authToken: cfg.AuthToken, searchPath: searchPath, skillsPath: skillsPath, downloadPath: downloadPath, maxZipSize: maxZip, maxResponseSize: maxResp, client: &http.Client{ Timeout: timeout, Transport: &http.Transport{ MaxIdleConns: 5, IdleConnTimeout: 30 * time.Second, TLSHandshakeTimeout: 10 * time.Second, }, }, } } func (c *ClawHubRegistry) Name() string { return "clawhub" } // --- Search --- type clawhubSearchResponse struct { Results []clawhubSearchResult `json:"results"` } type clawhubSearchResult struct { Score float64 `json:"score"` Slug *string `json:"slug"` DisplayName *string `json:"displayName"` Summary *string `json:"summary"` Version *string `json:"version"` } func (c *ClawHubRegistry) Search(ctx context.Context, query string, limit int) ([]SearchResult, error) { u, err := url.Parse(c.baseURL + c.searchPath) if err != nil { return nil, fmt.Errorf("invalid base URL: %w", err) } q := u.Query() q.Set("q", query) if limit > 0 { q.Set("limit", fmt.Sprintf("%d", limit)) } u.RawQuery = q.Encode() body, err := c.doGet(ctx, u.String()) if err != nil { return nil, fmt.Errorf("search request failed: %w", err) } var resp clawhubSearchResponse if err := json.Unmarshal(body, &resp); err != nil { return nil, fmt.Errorf("failed to parse search response: %w", err) } results := make([]SearchResult, 0, len(resp.Results)) for _, r := range resp.Results { slug := utils.DerefStr(r.Slug, "") if slug == "" { continue } summary := utils.DerefStr(r.Summary, "") if summary == "" { continue } displayName := utils.DerefStr(r.DisplayName, "") if displayName == "" { displayName = slug } results = append(results, SearchResult{ Score: r.Score, Slug: slug, DisplayName: displayName, Summary: summary, Version: utils.DerefStr(r.Version, ""), RegistryName: c.Name(), }) } return results, nil } // --- GetSkillMeta --- type clawhubSkillResponse struct { Slug string `json:"slug"` DisplayName string `json:"displayName"` Summary string `json:"summary"` LatestVersion *clawhubVersionInfo `json:"latestVersion"` Moderation *clawhubModerationInfo `json:"moderation"` } type clawhubVersionInfo struct { Version string `json:"version"` } type clawhubModerationInfo struct { IsMalwareBlocked bool `json:"isMalwareBlocked"` IsSuspicious bool `json:"isSuspicious"` } func (c *ClawHubRegistry) GetSkillMeta(ctx context.Context, slug string) (*SkillMeta, error) { if err := utils.ValidateSkillIdentifier(slug); err != nil { return nil, fmt.Errorf("invalid slug %q: error: %s", slug, err.Error()) } u := c.baseURL + c.skillsPath + "/" + url.PathEscape(slug) body, err := c.doGet(ctx, u) if err != nil { return nil, fmt.Errorf("skill metadata request failed: %w", err) } var resp clawhubSkillResponse if err := json.Unmarshal(body, &resp); err != nil { return nil, fmt.Errorf("failed to parse skill metadata: %w", err) } meta := &SkillMeta{ Slug: resp.Slug, DisplayName: resp.DisplayName, Summary: resp.Summary, RegistryName: c.Name(), } if resp.LatestVersion != nil { meta.LatestVersion = resp.LatestVersion.Version } if resp.Moderation != nil { meta.IsMalwareBlocked = resp.Moderation.IsMalwareBlocked meta.IsSuspicious = resp.Moderation.IsSuspicious } return meta, nil } // --- DownloadAndInstall --- // DownloadAndInstall fetches metadata (with fallback), resolves version, // downloads the skill ZIP, and extracts it to targetDir. // Returns an InstallResult for the caller to use for moderation decisions. func (c *ClawHubRegistry) DownloadAndInstall( ctx context.Context, slug, version, targetDir string, ) (*InstallResult, error) { if err := utils.ValidateSkillIdentifier(slug); err != nil { return nil, fmt.Errorf("invalid slug %q: error: %s", slug, err.Error()) } // Step 1: Fetch metadata (with fallback). result := &InstallResult{} meta, err := c.GetSkillMeta(ctx, slug) if err != nil { // Fallback: proceed without metadata. meta = nil } if meta != nil { result.IsMalwareBlocked = meta.IsMalwareBlocked result.IsSuspicious = meta.IsSuspicious result.Summary = meta.Summary } // Step 2: Resolve version. installVersion := version if installVersion == "" && meta != nil { installVersion = meta.LatestVersion } if installVersion == "" { installVersion = "latest" } result.Version = installVersion // Step 3: Download ZIP to temp file (streams in ~32KB chunks). u, err := url.Parse(c.baseURL + c.downloadPath) if err != nil { return nil, fmt.Errorf("invalid base URL: %w", err) } q := u.Query() q.Set("slug", slug) if installVersion != "latest" { q.Set("version", installVersion) } u.RawQuery = q.Encode() tmpPath, err := c.downloadToTempFileWithRetry(ctx, u.String()) if err != nil { return nil, fmt.Errorf("download failed: %w", err) } defer os.Remove(tmpPath) // Step 4: Extract from file on disk. if err := utils.ExtractZipFile(tmpPath, targetDir); err != nil { return nil, err } return result, nil } // --- HTTP helper --- func (c *ClawHubRegistry) doGet(ctx context.Context, urlStr string) ([]byte, error) { req, err := c.newGetRequest(ctx, urlStr, "application/json") if err != nil { return nil, err } resp, err := utils.DoRequestWithRetry(c.client, req) if err != nil { return nil, err } defer resp.Body.Close() // Limit response body read to prevent memory issues. body, err := io.ReadAll(io.LimitReader(resp.Body, int64(c.maxResponseSize))) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) } return body, nil } func (c *ClawHubRegistry) newGetRequest(ctx context.Context, urlStr, accept string) (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil) if err != nil { return nil, err } req.Header.Set("Accept", accept) if c.authToken != "" { req.Header.Set("Authorization", "Bearer "+c.authToken) } return req, nil } func (c *ClawHubRegistry) downloadToTempFileWithRetry(ctx context.Context, urlStr string) (string, error) { req, err := c.newGetRequest(ctx, urlStr, "application/zip") if err != nil { return "", err } resp, err := utils.DoRequestWithRetry(c.client, req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { errBody := make([]byte, 512) n, _ := io.ReadFull(resp.Body, errBody) return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(errBody[:n])) } tmpFile, err := os.CreateTemp("", "picoclaw-dl-*") if err != nil { return "", fmt.Errorf("failed to create temp file: %w", err) } tmpPath := tmpFile.Name() cleanup := func() { _ = tmpFile.Close() _ = os.Remove(tmpPath) } src := io.LimitReader(resp.Body, int64(c.maxZipSize)+1) written, err := io.Copy(tmpFile, src) if err != nil { cleanup() return "", fmt.Errorf("download write failed: %w", err) } if written > int64(c.maxZipSize) { cleanup() return "", fmt.Errorf("download too large: %d bytes (max %d)", written, c.maxZipSize) } if err := tmpFile.Close(); err != nil { _ = os.Remove(tmpPath) return "", fmt.Errorf("failed to close temp file: %w", err) } return tmpPath, nil } ================================================ FILE: pkg/skills/clawhub_registry_test.go ================================================ package skills import ( "archive/zip" "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/sipeed/picoclaw/pkg/utils" ) func newTestRegistry(serverURL, authToken string) *ClawHubRegistry { return NewClawHubRegistry(ClawHubConfig{ Enabled: true, BaseURL: serverURL, AuthToken: authToken, }) } func TestClawHubRegistrySearch(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/api/v1/search", r.URL.Path) assert.Equal(t, "github", r.URL.Query().Get("q")) slug := "github" name := "GitHub Integration" summary := "Interact with GitHub repos" version := "1.0.0" json.NewEncoder(w).Encode(clawhubSearchResponse{ Results: []clawhubSearchResult{ {Score: 0.95, Slug: &slug, DisplayName: &name, Summary: &summary, Version: &version}, }, }) })) defer srv.Close() reg := newTestRegistry(srv.URL, "") results, err := reg.Search(context.Background(), "github", 5) require.NoError(t, err) require.Len(t, results, 1) assert.Equal(t, "github", results[0].Slug) assert.Equal(t, "GitHub Integration", results[0].DisplayName) assert.InDelta(t, 0.95, results[0].Score, 0.001) assert.Equal(t, "clawhub", results[0].RegistryName) } func TestClawHubRegistrySearchRetries429(t *testing.T) { attempts := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts++ if attempts == 1 { w.Header().Set("Retry-After", "0") w.WriteHeader(http.StatusTooManyRequests) w.Write([]byte("rate limited")) return } slug := "github" name := "GitHub Integration" summary := "Interact with GitHub repos" version := "1.0.0" json.NewEncoder(w).Encode(clawhubSearchResponse{ Results: []clawhubSearchResult{ {Score: 0.95, Slug: &slug, DisplayName: &name, Summary: &summary, Version: &version}, }, }) })) defer srv.Close() reg := newTestRegistry(srv.URL, "") results, err := reg.Search(context.Background(), "github", 5) require.NoError(t, err) require.Len(t, results, 1) assert.Equal(t, 2, attempts) assert.Equal(t, "github", results[0].Slug) } func TestClawHubRegistryGetSkillMeta(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/api/v1/skills/github", r.URL.Path) json.NewEncoder(w).Encode(clawhubSkillResponse{ Slug: "github", DisplayName: "GitHub Integration", Summary: "Full GitHub API integration", LatestVersion: &clawhubVersionInfo{ Version: "2.1.0", }, Moderation: &clawhubModerationInfo{ IsMalwareBlocked: false, IsSuspicious: true, }, }) })) defer srv.Close() reg := newTestRegistry(srv.URL, "") meta, err := reg.GetSkillMeta(context.Background(), "github") require.NoError(t, err) assert.Equal(t, "github", meta.Slug) assert.Equal(t, "2.1.0", meta.LatestVersion) assert.False(t, meta.IsMalwareBlocked) assert.True(t, meta.IsSuspicious) } func TestClawHubRegistryGetSkillMetaUnsafeSlug(t *testing.T) { reg := newTestRegistry("https://example.com", "") _, err := reg.GetSkillMeta(context.Background(), "../etc/passwd") assert.Error(t, err) assert.Contains(t, err.Error(), "invalid slug") } func TestClawHubRegistryDownloadAndInstall(t *testing.T) { // Create a valid ZIP in memory. zipBuf := createTestZip(t, map[string]string{ "SKILL.md": "---\nname: test-skill\ndescription: A test\n---\nHello skill", "README.md": "# Test Skill\n", }) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v1/skills/test-skill": // Metadata endpoint. json.NewEncoder(w).Encode(clawhubSkillResponse{ Slug: "test-skill", DisplayName: "Test Skill", Summary: "A test skill", LatestVersion: &clawhubVersionInfo{Version: "1.0.0"}, }) case "/api/v1/download": assert.Equal(t, "test-skill", r.URL.Query().Get("slug")) w.Header().Set("Content-Type", "application/zip") w.Write(zipBuf) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() tmpDir := t.TempDir() targetDir := filepath.Join(tmpDir, "test-skill") reg := newTestRegistry(srv.URL, "") result, err := reg.DownloadAndInstall(context.Background(), "test-skill", "1.0.0", targetDir) require.NoError(t, err) assert.Equal(t, "1.0.0", result.Version) assert.False(t, result.IsMalwareBlocked) // Verify extracted files. skillContent, err := os.ReadFile(filepath.Join(targetDir, "SKILL.md")) require.NoError(t, err) assert.Contains(t, string(skillContent), "Hello skill") readmeContent, err := os.ReadFile(filepath.Join(targetDir, "README.md")) require.NoError(t, err) assert.Contains(t, string(readmeContent), "# Test Skill") } func TestClawHubRegistryDownloadAndInstallRetries429(t *testing.T) { zipBuf := createTestZip(t, map[string]string{ "SKILL.md": "---\nname: retry-skill\ndescription: A test\n---\nHello skill", }) downloadAttempts := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v1/skills/retry-skill": json.NewEncoder(w).Encode(clawhubSkillResponse{ Slug: "retry-skill", DisplayName: "Retry Skill", Summary: "A retry test skill", LatestVersion: &clawhubVersionInfo{Version: "1.0.0"}, }) case "/api/v1/download": downloadAttempts++ if downloadAttempts == 1 { w.Header().Set("Retry-After", "0") w.WriteHeader(http.StatusTooManyRequests) w.Write([]byte("rate limited")) return } assert.Equal(t, "retry-skill", r.URL.Query().Get("slug")) w.Header().Set("Content-Type", "application/zip") w.Write(zipBuf) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() tmpDir := t.TempDir() targetDir := filepath.Join(tmpDir, "retry-skill") reg := newTestRegistry(srv.URL, "") result, err := reg.DownloadAndInstall(context.Background(), "retry-skill", "", targetDir) require.NoError(t, err) require.NotNil(t, result) assert.Equal(t, "1.0.0", result.Version) assert.Equal(t, 2, downloadAttempts) skillContent, err := os.ReadFile(filepath.Join(targetDir, "SKILL.md")) require.NoError(t, err) assert.Contains(t, string(skillContent), "Hello skill") } func TestClawHubRegistryAuthToken(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") assert.Equal(t, "Bearer test-token-123", authHeader) json.NewEncoder(w).Encode(clawhubSearchResponse{Results: nil}) })) defer srv.Close() reg := newTestRegistry(srv.URL, "test-token-123") _, _ = reg.Search(context.Background(), "test", 5) } func TestExtractZipPathTraversal(t *testing.T) { // Create a ZIP with a path traversal entry. var buf bytes.Buffer zw := zip.NewWriter(&buf) // Malicious entry trying to escape directory. w, err := zw.Create("../../etc/passwd") require.NoError(t, err) w.Write([]byte("malicious")) zw.Close() // Write to temp file for extractZipFile. tmpZip := filepath.Join(t.TempDir(), "bad.zip") require.NoError(t, os.WriteFile(tmpZip, buf.Bytes(), 0o644)) tmpDir := t.TempDir() err = utils.ExtractZipFile(tmpZip, tmpDir) assert.Error(t, err) assert.Contains(t, err.Error(), "unsafe path") } func TestExtractZipWithSubdirectories(t *testing.T) { zipBuf := createTestZip(t, map[string]string{ "SKILL.md": "root file", "scripts/helper.sh": "#!/bin/bash\necho hello", "examples/demo.yaml": "key: value", }) // Write to temp file for extractZipFile. tmpZip := filepath.Join(t.TempDir(), "test.zip") require.NoError(t, os.WriteFile(tmpZip, zipBuf, 0o644)) tmpDir := t.TempDir() targetDir := filepath.Join(tmpDir, "my-skill") err := utils.ExtractZipFile(tmpZip, targetDir) require.NoError(t, err) // Verify nested file. data, err := os.ReadFile(filepath.Join(targetDir, "scripts", "helper.sh")) require.NoError(t, err) assert.Contains(t, string(data), "#!/bin/bash") } func TestClawHubRegistrySearchHTTPError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("Internal Server Error")) })) defer srv.Close() reg := newTestRegistry(srv.URL, "") _, err := reg.Search(context.Background(), "test", 5) assert.Error(t, err) assert.Contains(t, err.Error(), "500") } func TestClawHubRegistrySearchNullableFields(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { validSlug := "valid-slug" validSummary := "valid summary" // Return results with various null/empty fields json.NewEncoder(w).Encode(clawhubSearchResponse{ Results: []clawhubSearchResult{ // Case 1: Null Slug -> Skip {Score: 0.1, Slug: nil, DisplayName: nil, Summary: nil, Version: nil}, // Case 2: Valid Slug, Null Summary -> Skip {Score: 0.2, Slug: &validSlug, DisplayName: nil, Summary: nil, Version: nil}, // Case 3: Valid Slug, Valid Summary, Null Name -> Keep, Name=Slug {Score: 0.8, Slug: &validSlug, DisplayName: nil, Summary: &validSummary, Version: nil}, }, }) })) defer srv.Close() reg := newTestRegistry(srv.URL, "") results, err := reg.Search(context.Background(), "test", 5) require.NoError(t, err) require.Len(t, results, 1, "should only return 1 valid result") r := results[0] assert.Equal(t, "valid-slug", r.Slug) assert.Equal(t, "valid-slug", r.DisplayName, "should fallback name to slug") assert.Equal(t, "valid summary", r.Summary) } // --- helpers --- func createTestZip(t *testing.T, files map[string]string) []byte { t.Helper() var buf bytes.Buffer zw := zip.NewWriter(&buf) for name, content := range files { w, err := zw.Create(name) require.NoError(t, err) _, err = w.Write([]byte(content)) require.NoError(t, err) } require.NoError(t, zw.Close()) return buf.Bytes() } ================================================ FILE: pkg/skills/installer.go ================================================ package skills import ( "context" "encoding/json" "fmt" "net/http" "net/url" "os" "path" "path/filepath" "strings" "time" "github.com/sipeed/picoclaw/pkg/utils" ) // GitHubContent represents a file or directory in GitHub API response type GitHubContent struct { Name string `json:"name"` Path string `json:"path"` Type string `json:"type"` // "file" or "dir" DownloadURL string `json:"download_url"` URL string `json:"url"` // API URL for subdirectories } // GitHubRef represents a parsed GitHub reference type GitHubRef struct { Owner string // Repository owner RepoName string // Repository name Ref string // Git reference (branch, tag, or commit) SubPath string // Path within the repository } type SkillInstaller struct { workspace string client *http.Client githubToken string proxy string } // NewSkillInstaller creates a new skill installer. // proxy is an optional HTTP/HTTPS/SOCKS5 proxy URL for downloading skills. func NewSkillInstaller(workspace, githubToken, proxy string) (*SkillInstaller, error) { client, err := utils.CreateHTTPClient(proxy, 15*time.Second) if err != nil { return nil, fmt.Errorf("failed to create HTTP client: %w", err) } return &SkillInstaller{ workspace: workspace, client: client, githubToken: githubToken, proxy: proxy, }, nil } // parseGitHubRef parses a GitHub reference. // Supports: "owner/repo", "owner/repo/path", or full URL like "https://github.com/owner/repo/tree/ref/path" func parseGitHubRef(repo string) (GitHubRef, error) { repo = strings.TrimSpace(repo) // Handle full URL if strings.HasPrefix(repo, "http://") || strings.HasPrefix(repo, "https://") { u, err := url.Parse(repo) if err != nil { return GitHubRef{}, fmt.Errorf("invalid URL: %w", err) } parts := strings.Split(strings.Trim(u.Path, "/"), "/") if len(parts) < 2 { return GitHubRef{}, fmt.Errorf("invalid GitHub URL") } ref := GitHubRef{ Owner: parts[0], RepoName: parts[1], Ref: "main", } // Look for /tree/ or /blob/ in the path for i := 2; i < len(parts); i++ { if parts[i] == "tree" || parts[i] == "blob" { if i+1 < len(parts) { ref.Ref = parts[i+1] ref.SubPath = strings.Join(parts[i+2:], "/") } break } } return ref, nil } // Handle shorthand format parts := strings.Split(strings.Trim(repo, "/"), "/") if len(parts) < 2 { return GitHubRef{}, fmt.Errorf("invalid format %q: expected 'owner/repo'", repo) } ref := GitHubRef{ Owner: parts[0], RepoName: parts[1], Ref: "main", } if len(parts) > 2 { ref.SubPath = strings.Join(parts[2:], "/") } return ref, nil } func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) error { ref, err := parseGitHubRef(repo) if err != nil { return err } skillName := ref.RepoName if ref.SubPath != "" { skillName = filepath.Base(ref.SubPath) } skillDirectory := filepath.Join(si.workspace, "skills", skillName) if _, err := os.Stat(skillDirectory); err == nil { return fmt.Errorf("skill '%s' already exists", skillName) } // Build GitHub API URL apiPath := path.Join(ref.Owner, ref.RepoName, "contents") if ref.SubPath != "" { apiPath = path.Join(apiPath, ref.SubPath) } apiURL := fmt.Sprintf("https://api.github.com/repos/%s?ref=%s", apiPath, ref.Ref) if err := si.getGithubDirAllFiles(ctx, apiURL, skillDirectory, true); err != nil { // Fallback to raw download return si.downloadRaw(ctx, ref.Owner, ref.RepoName, ref.Ref, ref.SubPath, skillDirectory) } if _, err := os.Stat(filepath.Join(skillDirectory, "SKILL.md")); err != nil { return fmt.Errorf("SKILL.md not found in repository") } return nil } // downloadDir recursively downloads a directory from GitHub API // isRoot: true if this is the skill root directory (only download SKILL.md at root) func (si *SkillInstaller) getGithubDirAllFiles(ctx context.Context, apiURL, localDir string, isRoot bool) error { req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) if err != nil { return err } if si.githubToken != "" { req.Header.Set("Authorization", "Bearer "+si.githubToken) } resp, err := utils.DoRequestWithRetry(si.client, req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("HTTP %d", resp.StatusCode) } var items []GitHubContent if err := json.NewDecoder(resp.Body).Decode(&items); err != nil { return err } for _, item := range items { localPath := filepath.Join(localDir, item.Name) switch item.Type { case "file": if !shouldDownload(item.Name, isRoot) { continue } if err := si.downloadFile(ctx, item.DownloadURL, localPath); err != nil { return fmt.Errorf("download %s: %w", item.Name, err) } case "dir": if !isSkillDirectory(item.Name) { continue } if err := si.getGithubDirAllFiles(ctx, item.URL, localPath, false); err != nil { return err } } } return nil } // downloadRaw is a fallback that downloads just SKILL.md from raw.githubusercontent.com func (si *SkillInstaller) downloadRaw(ctx context.Context, owner, repo, ref, subPath, localDir string) error { urlPath := path.Join(owner, repo, ref) if subPath != "" { urlPath = path.Join(urlPath, subPath) } url := fmt.Sprintf("https://raw.githubusercontent.com/%s/SKILL.md", urlPath) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) } // Use chunked download to temporary file. tmpPath, err := utils.DownloadToFile(ctx, si.client, req, 0) if err != nil { return fmt.Errorf("failed to fetch skill: %w", err) } defer os.Remove(tmpPath) if err := os.MkdirAll(localDir, 0o755); err != nil { return fmt.Errorf("failed to create skill directory: %w", err) } localPath := filepath.Join(localDir, "SKILL.md") // Atomic move from temp to final location. if err := os.Rename(tmpPath, localPath); err != nil { return fmt.Errorf("failed to write skill file: %w", err) } return os.Chmod(localPath, 0o600) } func (si *SkillInstaller) downloadFile(ctx context.Context, url, localPath string) error { req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return err } // Use chunked download to temporary file, then move atomically to target. tmpPath, err := utils.DownloadToFile(ctx, si.client, req, 0) if err != nil { return err } defer os.Remove(tmpPath) if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil { return err } // Atomic move from temp to final location. if err := os.Rename(tmpPath, localPath); err != nil { return fmt.Errorf("failed to move downloaded file: %w", err) } return os.Chmod(localPath, 0o600) } // shouldDownload determines if a file should be downloaded // root: true if we're at the skill root directory func shouldDownload(name string, root bool) bool { if root { return name == "SKILL.md" } return true } // isSkillDir checks if a directory is a standard skill resource directory func isSkillDirectory(name string) bool { switch name { case "scripts", "references", "assets", "templates", "docs": return true } return false } func (si *SkillInstaller) Uninstall(skillName string) error { parts := strings.Split(skillName, "/") var finalSkillName string for i := len(parts) - 1; i >= 0; i-- { if parts[i] != "" { finalSkillName = parts[i] break } } if finalSkillName == "" { finalSkillName = skillName } skillDir := filepath.Join(si.workspace, "skills", finalSkillName) if _, err := os.Stat(skillDir); os.IsNotExist(err) { return fmt.Errorf("skill '%s' not found (processed as '%s')", skillName, finalSkillName) } if err := os.RemoveAll(skillDir); err != nil { return fmt.Errorf("failed to remove skill '%s': %w", finalSkillName, err) } return nil } ================================================ FILE: pkg/skills/installer_test.go ================================================ package skills import ( "context" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" ) func TestParseGitHubRef(t *testing.T) { tests := []struct { name string repo string wantOwner string wantRepoName string wantRef string wantSubPath string wantErr bool wantErrContain string }{ { name: "simple owner/repo", repo: "sipeed/picoclaw", wantOwner: "sipeed", wantRepoName: "picoclaw", wantRef: "main", wantSubPath: "", }, { name: "owner/repo with subpath", repo: "sipeed/picoclaw/skills/test", wantOwner: "sipeed", wantRepoName: "picoclaw", wantRef: "main", wantSubPath: "skills/test", }, { name: "full URL with tree", repo: "https://github.com/sipeed/picoclaw/tree/dev/skills/test", wantOwner: "sipeed", wantRepoName: "picoclaw", wantRef: "dev", wantSubPath: "skills/test", }, { name: "full URL with blob", repo: "https://github.com/sipeed/picoclaw/blob/main/README.md", wantOwner: "sipeed", wantRepoName: "picoclaw", wantRef: "main", wantSubPath: "README.md", }, { name: "full URL without ref", repo: "https://github.com/sipeed/picoclaw", wantOwner: "sipeed", wantRepoName: "picoclaw", wantRef: "main", wantSubPath: "", }, { name: "invalid format - single part", repo: "sipeed", wantErr: true, wantErrContain: "expected 'owner/repo'", }, { name: "invalid URL", repo: "http://[invalid", wantErr: true, wantErrContain: "invalid URL", }, { name: "invalid GitHub URL - only one path part", repo: "https://github.com/sipeed", wantErr: true, wantErrContain: "invalid GitHub URL", }, { name: "with whitespace", repo: " sipeed/picoclaw ", wantOwner: "sipeed", wantRepoName: "picoclaw", wantRef: "main", wantSubPath: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ref, err := parseGitHubRef(tt.repo) if tt.wantErr { if err == nil { t.Errorf("parseGitHubRef() error = nil, wantErr = true") return } if tt.wantErrContain != "" && !strings.Contains(err.Error(), tt.wantErrContain) { t.Errorf("parseGitHubRef() error = %v, want error containing %v", err, tt.wantErrContain) } return } if err != nil { t.Errorf("parseGitHubRef() unexpected error = %v", err) return } if ref.Owner != tt.wantOwner { t.Errorf("parseGitHubRef() owner = %v, want %v", ref.Owner, tt.wantOwner) } if ref.RepoName != tt.wantRepoName { t.Errorf("parseGitHubRef() repoName = %v, want %v", ref.RepoName, tt.wantRepoName) } if ref.Ref != tt.wantRef { t.Errorf("parseGitHubRef() ref = %v, want %v", ref.Ref, tt.wantRef) } if ref.SubPath != tt.wantSubPath { t.Errorf("parseGitHubRef() subPath = %v, want %v", ref.SubPath, tt.wantSubPath) } }) } } func TestShouldDownload(t *testing.T) { tests := []struct { name string file string root bool want bool }{ {"SKILL.md at root", "SKILL.md", true, true}, {"other file at root", "README.md", true, false}, {"script at root", "script.py", true, false}, {"SKILL.md not at root", "SKILL.md", false, true}, {"any file not at root", "any.txt", false, true}, {"script not at root", "script.py", false, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := shouldDownload(tt.file, tt.root) if got != tt.want { t.Errorf("shouldDownload(%q, %v) = %v, want %v", tt.file, tt.root, got, tt.want) } }) } } func TestIsSkillDirectory(t *testing.T) { tests := []struct { name string dir string want bool }{ {"scripts dir", "scripts", true}, {"references dir", "references", true}, {"assets dir", "assets", true}, {"templates dir", "templates", true}, {"docs dir", "docs", true}, {"other dir", "other", false}, {"src dir", "src", false}, {"empty string", "", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := isSkillDirectory(tt.dir) if got != tt.want { t.Errorf("isSkillDirectory(%q) = %v, want %v", tt.dir, got, tt.want) } }) } } func TestNewSkillInstaller(t *testing.T) { tmpDir := t.TempDir() installer, err := NewSkillInstaller(tmpDir, "test-token", "") if err != nil { t.Fatalf("NewSkillInstaller() error = %v", err) } if installer == nil { t.Fatal("NewSkillInstaller() returned nil") } if installer.workspace != tmpDir { t.Errorf("workspace = %v, want %v", installer.workspace, tmpDir) } if installer.githubToken != "test-token" { t.Errorf("githubToken = %v, want 'test-token'", installer.githubToken) } if installer.proxy != "" { t.Errorf("proxy = %v, want empty", installer.proxy) } if installer.client == nil { t.Error("client is nil") } else if installer.client.Timeout != 15*time.Second { t.Errorf("client.Timeout = %v, want 15s", installer.client.Timeout) } } func TestNewSkillInstaller_WithProxy(t *testing.T) { tmpDir := t.TempDir() installer, err := NewSkillInstaller(tmpDir, "test-token", "http://127.0.0.1:7890") if err != nil { t.Fatalf("NewSkillInstaller() error = %v", err) } if installer.proxy != "http://127.0.0.1:7890" { t.Errorf("proxy = %v, want 'http://127.0.0.1:7890'", installer.proxy) } if installer.client == nil { t.Fatal("client is nil") } // Verify the transport has proxy configured transport, ok := installer.client.Transport.(*http.Transport) if !ok { t.Fatal("client.Transport is not *http.Transport") } if transport.Proxy == nil { t.Error("transport.Proxy is nil, expected non-nil") } } func TestNewSkillInstaller_InvalidProxy(t *testing.T) { tmpDir := t.TempDir() installer, err := NewSkillInstaller(tmpDir, "test-token", "://invalid-proxy") if err == nil { t.Error("NewSkillInstaller() expected error for invalid proxy, got nil") } if installer != nil { t.Error("expected nil installer on error") } } func TestSkillInstaller_DownloadFile(t *testing.T) { // Create a test server that serves files content := "test file content for skill download" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) } w.WriteHeader(http.StatusOK) w.Write([]byte(content)) })) defer server.Close() tmpDir := t.TempDir() installer, err := NewSkillInstaller(tmpDir, "", "") if err != nil { t.Fatalf("NewSkillInstaller() error = %v", err) } t.Run("successful download", func(t *testing.T) { localPath := filepath.Join(tmpDir, "test-skill", "SKILL.md") err := installer.downloadFile(context.Background(), server.URL, localPath) if err != nil { t.Errorf("downloadFile() error = %v", err) return } // Verify file was downloaded data, err := os.ReadFile(localPath) if err != nil { t.Errorf("failed to read downloaded file: %v", err) return } if string(data) != content { t.Errorf("downloaded content = %q, want %q", string(data), content) } // Check file permissions info, err := os.Stat(localPath) if err != nil { t.Errorf("failed to stat file: %v", err) return } if info.Mode().Perm() != 0o600 { t.Errorf("file permissions = %o, want %o", info.Mode().Perm(), 0o600) } }) t.Run("http error", func(t *testing.T) { errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) w.Write([]byte("not found")) })) defer errorServer.Close() localPath := filepath.Join(tmpDir, "error-test", "SKILL.md") err := installer.downloadFile(context.Background(), errorServer.URL, localPath) if err == nil { t.Error("downloadFile() expected error for 404, got nil") } }) } func TestSkillInstaller_DownloadRaw(t *testing.T) { content := "raw skill content" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte(content)) })) defer server.Close() tmpDir := t.TempDir() installer, err := NewSkillInstaller(tmpDir, "", "") if err != nil { t.Fatalf("NewSkillInstaller() error = %v", err) } // Replace the client with one that points to our test server // We need to modify the URL in the function, so we'll test indirectly localDir := filepath.Join(tmpDir, "raw-test") ctx := context.Background() // Create a simple test by calling downloadFile directly since downloadRaw // constructs its own URL testFile := filepath.Join(localDir, "SKILL.md") err = installer.downloadFile(ctx, server.URL, testFile) if err != nil { t.Errorf("downloadFile() error = %v", err) } // Verify file content data, err := os.ReadFile(testFile) if err != nil { t.Errorf("failed to read file: %v", err) return } if string(data) != content { t.Errorf("content = %q, want %q", string(data), content) } } func TestSkillInstaller_Uninstall(t *testing.T) { tmpDir := t.TempDir() skillsDir := filepath.Join(tmpDir, "skills") os.MkdirAll(skillsDir, 0o755) installer, err := NewSkillInstaller(tmpDir, "", "") if err != nil { t.Fatalf("NewSkillInstaller() error = %v", err) } t.Run("uninstall existing skill", func(t *testing.T) { skillName := "test-skill" skillDir := filepath.Join(skillsDir, skillName) // Create skill directory with a file os.MkdirAll(skillDir, 0o755) os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("test"), 0o644) if err := installer.Uninstall(skillName); err != nil { t.Errorf("Uninstall() error = %v", err) } // Verify directory was removed if _, err := os.Stat(skillDir); !os.IsNotExist(err) { t.Error("skill directory still exists after uninstall") } }) t.Run("uninstall non-existent skill", func(t *testing.T) { if err := installer.Uninstall("non-existent-skill"); err == nil { t.Error("Uninstall() expected error for non-existent skill, got nil") } else if !strings.Contains(err.Error(), "not found") { t.Errorf("error message = %q, want 'not found'", err.Error()) } }) t.Run("uninstall with path separator", func(t *testing.T) { skillName := "owner/repo/skill-name" skillDir := filepath.Join(skillsDir, "skill-name") // Create skill directory os.MkdirAll(skillDir, 0o755) os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("test"), 0o644) if err := installer.Uninstall(skillName); err != nil { t.Errorf("Uninstall() error = %v", err) } if _, err := os.Stat(skillDir); !os.IsNotExist(err) { t.Error("skill directory still exists after uninstall") } }) t.Run("uninstall with trailing slash", func(t *testing.T) { skillName := "skill-name/" skillDir := filepath.Join(skillsDir, "skill-name") // Create skill directory os.MkdirAll(skillDir, 0o755) os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("test"), 0o644) if err := installer.Uninstall(skillName); err != nil { t.Errorf("Uninstall() error = %v", err) } if _, err := os.Stat(skillDir); !os.IsNotExist(err) { t.Error("skill directory still exists after uninstall") } }) } func TestSkillInstaller_InstallFromGitHub_SkillAlreadyExists(t *testing.T) { tmpDir := t.TempDir() skillsDir := filepath.Join(tmpDir, "skills") os.MkdirAll(skillsDir, 0o755) installer, err := NewSkillInstaller(tmpDir, "", "") if err != nil { t.Fatalf("NewSkillInstaller() error = %v", err) } // Create an existing skill directory existingSkill := filepath.Join(skillsDir, "picoclaw") os.MkdirAll(existingSkill, 0o755) os.WriteFile(filepath.Join(existingSkill, "SKILL.md"), []byte("existing"), 0o644) // Try to install the same skill - should fail err = installer.InstallFromGitHub(context.Background(), "sipeed/picoclaw") if err == nil { t.Error("InstallFromGitHub() expected error for existing skill, got nil") } if !strings.Contains(err.Error(), "already exists") { t.Errorf("error message = %q, want 'already exists'", err.Error()) } } func TestGitHubContent_Struct(t *testing.T) { // Test that GitHubContent struct can be properly unmarshaled jsonData := `{ "name": "test.md", "path": "skills/test.md", "type": "file", "download_url": "https://example.com/download", "url": "https://api.github.com/contents/skills/test.md" }` var content GitHubContent err := json.Unmarshal([]byte(jsonData), &content) if err != nil { t.Errorf("failed to unmarshal GitHubContent: %v", err) } if content.Name != "test.md" { t.Errorf("Name = %q, want 'test.md'", content.Name) } if content.Type != "file" { t.Errorf("Type = %q, want 'file'", content.Type) } if content.DownloadURL != "https://example.com/download" { t.Errorf("DownloadURL = %q, want 'https://example.com/download'", content.DownloadURL) } } func TestSkillInstaller_GetGithubDirAllFiles(t *testing.T) { tmpDir := t.TempDir() installer, err := NewSkillInstaller(tmpDir, "", "") if err != nil { t.Fatalf("NewSkillInstaller() error = %v", err) } // Create a test server that mimics GitHub API fileContent := "skill file content" var serverURL string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Check for authorization header authHeader := r.Header.Get("Authorization") if authHeader != "" && !strings.HasPrefix(authHeader, "Bearer ") { t.Errorf("expected Bearer token, got: %s", authHeader) } // Return different responses based on path if strings.Contains(r.URL.Path, "/contents") { // API response for directory listing w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) items := []map[string]any{ { "name": "SKILL.md", "path": "SKILL.md", "type": "file", "download_url": serverURL + "/download/SKILL.md", }, { "name": "scripts", "path": "scripts", "type": "dir", "url": serverURL + "/api/scripts", }, } json.NewEncoder(w).Encode(items) } else if strings.Contains(r.URL.Path, "/api/scripts") { // API response for scripts subdirectory w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) items := []map[string]any{ { "name": "test.py", "path": "scripts/test.py", "type": "file", "download_url": serverURL + "/download/test.py", }, } json.NewEncoder(w).Encode(items) } else if strings.Contains(r.URL.Path, "/download/") { // Raw file download w.WriteHeader(http.StatusOK) w.Write([]byte(fileContent)) } else { w.WriteHeader(http.StatusNotFound) } })) serverURL = server.URL defer server.Close() localDir := filepath.Join(tmpDir, "test-skill") t.Run("download from GitHub API", func(t *testing.T) { err := installer.getGithubDirAllFiles(context.Background(), server.URL+"/contents", localDir, true) if err != nil { t.Errorf("getGithubDirAllFiles() error = %v", err) return } // Verify SKILL.md was downloaded skillMd := filepath.Join(localDir, "SKILL.md") data, err := os.ReadFile(skillMd) if err != nil { t.Errorf("failed to read SKILL.md: %v", err) return } if string(data) != fileContent { t.Errorf("SKILL.md content = %q, want %q", string(data), fileContent) } // Verify scripts directory and file scriptFile := filepath.Join(localDir, "scripts", "test.py") data, err = os.ReadFile(scriptFile) if err != nil { t.Errorf("failed to read test.py: %v", err) return } if string(data) != fileContent { t.Errorf("test.py content = %q, want %q", string(data), fileContent) } }) t.Run("http error response", func(t *testing.T) { errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) })) defer errorServer.Close() err := installer.getGithubDirAllFiles( context.Background(), errorServer.URL, filepath.Join(tmpDir, "error-test"), true, ) if err == nil { t.Error("getGithubDirAllFiles() expected error for 403, got nil") } }) } func TestSkillInstaller_InstallFromGitHub_WithToken(t *testing.T) { tmpDir := t.TempDir() skillsDir := filepath.Join(tmpDir, "skills") os.MkdirAll(skillsDir, 0o755) var serverURL string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Capture the authorization header authHeader := r.Header.Get("Authorization") if authHeader != "" { tokenReceived := strings.TrimPrefix(authHeader, "Bearer ") t.Fatalf("github token is %s", tokenReceived) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) items := []map[string]any{ { "name": "SKILL.md", "path": "SKILL.md", "type": "file", "download_url": serverURL + "/download/SKILL.md", }, } json.NewEncoder(w).Encode(items) })) serverURL = server.URL defer server.Close() installer, err := NewSkillInstaller(tmpDir, "test-github-token", "") if err != nil { t.Fatalf("NewSkillInstaller() error = %v", err) } // We need to test the token is passed - the actual install will fail // because we're not fully mocking the download, but we can verify // the token is sent in the request // Use a simple context with timeout ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // The install will fail because download URL isn't properly set up, // but the token should be sent in the API request _ = installer.InstallFromGitHub(ctx, "owner/repo") // Note: We can't easily intercept the download request since it's a different URL, // but the fact that the API request was made verifies the token flow // In a real scenario, the token would be sent to both API and raw downloads } func TestSkillInstaller_ContextCancellation(t *testing.T) { tmpDir := t.TempDir() installer, err := NewSkillInstaller(tmpDir, "", "") if err != nil { t.Fatalf("NewSkillInstaller() error = %v", err) } // Create a slow server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(100 * time.Millisecond) w.WriteHeader(http.StatusOK) w.Write([]byte("response")) })) defer server.Close() // Create a canceled context ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately localPath := filepath.Join(tmpDir, "cancel-test", "file.txt") err = installer.downloadFile(ctx, server.URL, localPath) if err == nil { t.Error("downloadFile() expected error for canceled context, got nil") } } ================================================ FILE: pkg/skills/loader.go ================================================ package skills import ( "encoding/json" "errors" "fmt" "log/slog" "os" "path/filepath" "regexp" "strings" "github.com/gomarkdown/markdown" "github.com/gomarkdown/markdown/ast" "github.com/gomarkdown/markdown/parser" "gopkg.in/yaml.v3" "github.com/sipeed/picoclaw/pkg/logger" ) var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`) const ( MaxNameLength = 64 MaxDescriptionLength = 1024 ) type SkillMetadata struct { Name string `json:"name"` Description string `json:"description"` } type SkillInfo struct { Name string `json:"name"` Path string `json:"path"` Source string `json:"source"` Description string `json:"description"` } func (info SkillInfo) validate() error { var errs error if info.Name == "" { errs = errors.Join(errs, errors.New("name is required")) } else { if len(info.Name) > MaxNameLength { errs = errors.Join(errs, fmt.Errorf("name exceeds %d characters", MaxNameLength)) } if !namePattern.MatchString(info.Name) { errs = errors.Join(errs, errors.New("name must be alphanumeric with hyphens")) } } if info.Description == "" { errs = errors.Join(errs, errors.New("description is required")) } else if len(info.Description) > MaxDescriptionLength { errs = errors.Join(errs, fmt.Errorf("description exceeds %d character", MaxDescriptionLength)) } return errs } type SkillsLoader struct { workspace string workspaceSkills string // workspace skills (project-level) globalSkills string // global skills (~/.picoclaw/skills) builtinSkills string // builtin skills } // SkillRoots returns all unique skill root directories used by this loader. // The order follows resolution priority: workspace > global > builtin. func (sl *SkillsLoader) SkillRoots() []string { roots := []string{sl.workspaceSkills, sl.globalSkills, sl.builtinSkills} seen := make(map[string]struct{}, len(roots)) out := make([]string, 0, len(roots)) for _, root := range roots { trimmed := strings.TrimSpace(root) if trimmed == "" { continue } clean := filepath.Clean(trimmed) if _, ok := seen[clean]; ok { continue } seen[clean] = struct{}{} out = append(out, clean) } return out } func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string) *SkillsLoader { return &SkillsLoader{ workspace: workspace, workspaceSkills: filepath.Join(workspace, "skills"), globalSkills: globalSkills, // ~/.picoclaw/skills builtinSkills: builtinSkills, } } func (sl *SkillsLoader) ListSkills() []SkillInfo { skills := make([]SkillInfo, 0) seen := make(map[string]bool) addSkills := func(dir, source string) { if dir == "" { return } dirs, err := os.ReadDir(dir) if err != nil { return } for _, d := range dirs { if !d.IsDir() { continue } skillFile := filepath.Join(dir, d.Name(), "SKILL.md") if _, err := os.Stat(skillFile); err != nil { continue } info := SkillInfo{ Name: d.Name(), Path: skillFile, Source: source, } metadata := sl.getSkillMetadata(skillFile) if metadata != nil { info.Description = metadata.Description info.Name = metadata.Name } if err := info.validate(); err != nil { slog.Warn("invalid skill from "+source, "name", info.Name, "error", err) continue } if seen[info.Name] { continue } seen[info.Name] = true skills = append(skills, info) } } // Priority: workspace > global > builtin addSkills(sl.workspaceSkills, "workspace") addSkills(sl.globalSkills, "global") addSkills(sl.builtinSkills, "builtin") return skills } func (sl *SkillsLoader) LoadSkill(name string) (string, bool) { // 1. load from workspace skills first (project-level) if sl.workspaceSkills != "" { skillFile := filepath.Join(sl.workspaceSkills, name, "SKILL.md") if content, err := os.ReadFile(skillFile); err == nil { return sl.stripFrontmatter(string(content)), true } } // 2. then load from global skills (~/.picoclaw/skills) if sl.globalSkills != "" { skillFile := filepath.Join(sl.globalSkills, name, "SKILL.md") if content, err := os.ReadFile(skillFile); err == nil { return sl.stripFrontmatter(string(content)), true } } // 3. finally load from builtin skills if sl.builtinSkills != "" { skillFile := filepath.Join(sl.builtinSkills, name, "SKILL.md") if content, err := os.ReadFile(skillFile); err == nil { return sl.stripFrontmatter(string(content)), true } } return "", false } func (sl *SkillsLoader) LoadSkillsForContext(skillNames []string) string { if len(skillNames) == 0 { return "" } var parts []string for _, name := range skillNames { content, ok := sl.LoadSkill(name) if ok { parts = append(parts, fmt.Sprintf("### Skill: %s\n\n%s", name, content)) } } return strings.Join(parts, "\n\n---\n\n") } func (sl *SkillsLoader) BuildSkillsSummary() string { allSkills := sl.ListSkills() if len(allSkills) == 0 { return "" } var lines []string lines = append(lines, "<skills>") for _, s := range allSkills { escapedName := escapeXML(s.Name) escapedDesc := escapeXML(s.Description) escapedPath := escapeXML(s.Path) lines = append(lines, fmt.Sprintf(" <skill>")) lines = append(lines, fmt.Sprintf(" <name>%s</name>", escapedName)) lines = append(lines, fmt.Sprintf(" <description>%s</description>", escapedDesc)) lines = append(lines, fmt.Sprintf(" <location>%s</location>", escapedPath)) lines = append(lines, fmt.Sprintf(" <source>%s</source>", s.Source)) lines = append(lines, " </skill>") } lines = append(lines, "</skills>") return strings.Join(lines, "\n") } func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata { content, err := os.ReadFile(skillPath) if err != nil { logger.WarnCF("skills", "Failed to read skill metadata", map[string]any{ "skill_path": skillPath, "error": err.Error(), }) return nil } frontmatter, bodyContent := splitFrontmatter(string(content)) dirName := filepath.Base(filepath.Dir(skillPath)) title, bodyDescription := extractMarkdownMetadata(bodyContent) metadata := &SkillMetadata{ Name: dirName, Description: bodyDescription, } if title != "" && namePattern.MatchString(title) && len(title) <= MaxNameLength { metadata.Name = title } if frontmatter == "" { return metadata } // Try JSON first (for backward compatibility) var jsonMeta struct { Name string `json:"name"` Description string `json:"description"` } if err := json.Unmarshal([]byte(frontmatter), &jsonMeta); err == nil { if jsonMeta.Name != "" { metadata.Name = jsonMeta.Name } if jsonMeta.Description != "" { metadata.Description = jsonMeta.Description } return metadata } // Fall back to simple YAML parsing yamlMeta := sl.parseSimpleYAML(frontmatter) if name := yamlMeta["name"]; name != "" { metadata.Name = name } if description := yamlMeta["description"]; description != "" { metadata.Description = description } return metadata } func extractMarkdownMetadata(content string) (title, description string) { p := parser.NewWithExtensions(parser.CommonExtensions) doc := markdown.Parse([]byte(content), p) if doc == nil { return "", "" } ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus { if !entering { return ast.GoToNext } switch n := node.(type) { case *ast.Heading: if title == "" && n.Level == 1 { title = nodeText(n) if title != "" && description != "" { return ast.Terminate } } case *ast.Paragraph: if description == "" { description = nodeText(n) if title != "" && description != "" { return ast.Terminate } } } return ast.GoToNext }) return title, description } func nodeText(n ast.Node) string { var b strings.Builder ast.WalkFunc(n, func(node ast.Node, entering bool) ast.WalkStatus { if !entering { return ast.GoToNext } switch t := node.(type) { case *ast.Text: b.Write(t.Literal) case *ast.Code: b.Write(t.Literal) case *ast.Softbreak, *ast.Hardbreak, *ast.NonBlockingSpace: b.WriteByte(' ') } return ast.GoToNext }) return strings.Join(strings.Fields(b.String()), " ") } // parseSimpleYAML parses YAML frontmatter and extracts known metadata fields. func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string { result := make(map[string]string) var meta struct { Name string `yaml:"name"` Description string `yaml:"description"` } if err := yaml.Unmarshal([]byte(content), &meta); err != nil { return result } if meta.Name != "" { result["name"] = meta.Name } if meta.Description != "" { result["description"] = meta.Description } return result } func (sl *SkillsLoader) extractFrontmatter(content string) string { frontmatter, _ := splitFrontmatter(content) return frontmatter } func (sl *SkillsLoader) stripFrontmatter(content string) string { _, body := splitFrontmatter(content) return body } func splitFrontmatter(content string) (frontmatter, body string) { normalized := string(parser.NormalizeNewlines([]byte(content))) lines := strings.Split(normalized, "\n") if len(lines) == 0 || lines[0] != "---" { return "", content } end := -1 for i := 1; i < len(lines); i++ { if lines[i] == "---" { end = i break } } if end == -1 { return "", content } frontmatter = strings.Join(lines[1:end], "\n") body = strings.Join(lines[end+1:], "\n") body = strings.TrimLeft(body, "\n") return frontmatter, body } func escapeXML(s string) string { s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") return s } ================================================ FILE: pkg/skills/loader_test.go ================================================ package skills import ( "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestSkillsInfoValidate(t *testing.T) { testcases := []struct { name string skillName string description string wantErr bool errContains []string }{ { name: "valid-skill", skillName: "valid-skill", description: "a valid skill description", wantErr: false, }, { name: "empty-name", skillName: "", description: "description without name", wantErr: true, errContains: []string{"name is required"}, }, { name: "empty-description", skillName: "skill-without-description", description: "", wantErr: true, errContains: []string{"description is required"}, }, { name: "empty-both", skillName: "", description: "", wantErr: true, errContains: []string{"name is required", "description is required"}, }, { name: "name-with-spaces", skillName: "skill with spaces", description: "invalid name with spaces", wantErr: true, errContains: []string{"name must be alphanumeric with hyphens"}, }, { name: "name-with-underscore", skillName: "skill_underscore", description: "invalid name with underscore", wantErr: true, errContains: []string{"name must be alphanumeric with hyphens"}, }, } for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { info := SkillInfo{ Name: tc.skillName, Description: tc.description, } err := info.validate() if tc.wantErr { assert.Error(t, err) for _, msg := range tc.errContains { assert.ErrorContains(t, err, msg) } } else { assert.NoError(t, err) } }) } } func TestExtractFrontmatter(t *testing.T) { sl := &SkillsLoader{} testcases := []struct { name string content string expectedName string expectedDesc string lineEndingType string }{ { name: "unix-line-endings", lineEndingType: "Unix (\\n)", content: "---\nname: test-skill\ndescription: A test skill\n---\n\n# Skill Content", expectedName: "test-skill", expectedDesc: "A test skill", }, { name: "windows-line-endings", lineEndingType: "Windows (\\r\\n)", content: "---\r\nname: test-skill\r\ndescription: A test skill\r\n---\r\n\r\n# Skill Content", expectedName: "test-skill", expectedDesc: "A test skill", }, { name: "classic-mac-line-endings", lineEndingType: "Classic Mac (\\r)", content: "---\rname: test-skill\rdescription: A test skill\r---\r\r# Skill Content", expectedName: "test-skill", expectedDesc: "A test skill", }, } for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { // Extract frontmatter frontmatter := sl.extractFrontmatter(tc.content) assert.NotEmpty(t, frontmatter, "Frontmatter should be extracted for %s line endings", tc.lineEndingType) // Parse YAML to get name and description (parseSimpleYAML now handles all line ending types) yamlMeta := sl.parseSimpleYAML(frontmatter) assert.Equal( t, tc.expectedName, yamlMeta["name"], "Name should be correctly parsed from frontmatter with %s line endings", tc.lineEndingType, ) assert.Equal( t, tc.expectedDesc, yamlMeta["description"], "Description should be correctly parsed from frontmatter with %s line endings", tc.lineEndingType, ) }) } } // createSkillDir creates a skill directory with a SKILL.md file containing the given frontmatter. func createSkillDir(t *testing.T, base, dirName, name, description string) { t.Helper() dir := filepath.Join(base, dirName) require.NoError(t, os.MkdirAll(dir, 0o755)) content := "---\nname: " + name + "\ndescription: " + description + "\n---\n\n# " + name require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644)) } func TestListSkillsWorkspaceOverridesGlobal(t *testing.T) { tmp := t.TempDir() ws := filepath.Join(tmp, "workspace") global := filepath.Join(tmp, "global") createSkillDir(t, filepath.Join(ws, "skills"), "my-skill", "my-skill", "workspace version") createSkillDir(t, global, "my-skill", "my-skill", "global version") sl := NewSkillsLoader(ws, global, "") skills := sl.ListSkills() assert.Len(t, skills, 1) assert.Equal(t, "workspace", skills[0].Source) assert.Equal(t, "workspace version", skills[0].Description) } func TestListSkillsGlobalOverridesBuiltin(t *testing.T) { tmp := t.TempDir() ws := filepath.Join(tmp, "workspace") global := filepath.Join(tmp, "global") builtin := filepath.Join(tmp, "builtin") createSkillDir(t, global, "my-skill", "my-skill", "global version") createSkillDir(t, builtin, "my-skill", "my-skill", "builtin version") sl := NewSkillsLoader(ws, global, builtin) skills := sl.ListSkills() assert.Len(t, skills, 1) assert.Equal(t, "global", skills[0].Source) assert.Equal(t, "global version", skills[0].Description) } func TestListSkillsMetadataNameDedup(t *testing.T) { tmp := t.TempDir() ws := filepath.Join(tmp, "workspace") global := filepath.Join(tmp, "global") // Different directory names but same metadata name createSkillDir(t, filepath.Join(ws, "skills"), "dir-a", "shared-name", "workspace version") createSkillDir(t, global, "dir-b", "shared-name", "global version") sl := NewSkillsLoader(ws, global, "") skills := sl.ListSkills() assert.Len(t, skills, 1) assert.Equal(t, "shared-name", skills[0].Name) assert.Equal(t, "workspace", skills[0].Source) } func TestListSkillsMultipleDistinctSkills(t *testing.T) { tmp := t.TempDir() ws := filepath.Join(tmp, "workspace") global := filepath.Join(tmp, "global") builtin := filepath.Join(tmp, "builtin") createSkillDir(t, filepath.Join(ws, "skills"), "skill-a", "skill-a", "desc a") createSkillDir(t, global, "skill-b", "skill-b", "desc b") createSkillDir(t, builtin, "skill-c", "skill-c", "desc c") sl := NewSkillsLoader(ws, global, builtin) skills := sl.ListSkills() assert.Len(t, skills, 3) names := map[string]string{} for _, s := range skills { names[s.Name] = s.Source } assert.Equal(t, "workspace", names["skill-a"]) assert.Equal(t, "global", names["skill-b"]) assert.Equal(t, "builtin", names["skill-c"]) } func TestListSkillsInvalidSkillSkipped(t *testing.T) { tmp := t.TempDir() ws := filepath.Join(tmp, "workspace") global := filepath.Join(tmp, "global") // Invalid name (underscore) createSkillDir(t, filepath.Join(ws, "skills"), "bad_skill", "bad_skill", "desc") // Valid skill createSkillDir(t, global, "good-skill", "good-skill", "desc") sl := NewSkillsLoader(ws, global, "") skills := sl.ListSkills() assert.Len(t, skills, 1) assert.Equal(t, "good-skill", skills[0].Name) } func TestListSkillsEmptyAndNonexistentDirs(t *testing.T) { tmp := t.TempDir() ws := filepath.Join(tmp, "workspace") emptyDir := filepath.Join(tmp, "empty") require.NoError(t, os.MkdirAll(emptyDir, 0o755)) sl := NewSkillsLoader(ws, emptyDir, filepath.Join(tmp, "nonexistent")) skills := sl.ListSkills() assert.Empty(t, skills) } func TestListSkillsDirWithoutSkillMD(t *testing.T) { tmp := t.TempDir() ws := filepath.Join(tmp, "workspace") global := filepath.Join(tmp, "global") // Directory exists but has no SKILL.md require.NoError(t, os.MkdirAll(filepath.Join(global, "no-skillmd"), 0o755)) // Valid skill alongside createSkillDir(t, global, "real-skill", "real-skill", "desc") sl := NewSkillsLoader(ws, global, "") skills := sl.ListSkills() assert.Len(t, skills, 1) assert.Equal(t, "real-skill", skills[0].Name) } func TestStripFrontmatter(t *testing.T) { sl := &SkillsLoader{} testcases := []struct { name string content string expectedContent string lineEndingType string }{ { name: "unix-line-endings", lineEndingType: "Unix (\\n)", content: "---\nname: test-skill\ndescription: A test skill\n---\n\n# Skill Content", expectedContent: "# Skill Content", }, { name: "windows-line-endings", lineEndingType: "Windows (\\r\\n)", content: "---\r\nname: test-skill\r\ndescription: A test skill\r\n---\r\n\r\n# Skill Content", expectedContent: "# Skill Content", }, { name: "classic-mac-line-endings", lineEndingType: "Classic Mac (\\r)", content: "---\rname: test-skill\rdescription: A test skill\r---\r\r# Skill Content", expectedContent: "# Skill Content", }, { name: "unix-line-endings-without-trailing-newline", lineEndingType: "Unix (\\n) without trailing newline", content: "---\nname: test-skill\ndescription: A test skill\n---\n# Skill Content", expectedContent: "# Skill Content", }, { name: "windows-line-endings-without-trailing-newline", lineEndingType: "Windows (\\r\\n) without trailing newline", content: "---\r\nname: test-skill\r\ndescription: A test skill\r\n---\r\n# Skill Content", expectedContent: "# Skill Content", }, { name: "no-frontmatter", lineEndingType: "No frontmatter", content: "# Skill Content\n\nSome content here.", expectedContent: "# Skill Content\n\nSome content here.", }, } for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { result := sl.stripFrontmatter(tc.content) assert.Equal( t, tc.expectedContent, result, "Frontmatter should be stripped correctly for %s", tc.lineEndingType, ) }) } } func TestSkillRootsTrimsWhitespaceAndDedups(t *testing.T) { tmp := t.TempDir() workspace := filepath.Join(tmp, "workspace") global := filepath.Join(tmp, "global") builtin := filepath.Join(tmp, "builtin") sl := NewSkillsLoader(workspace, " "+global+" ", "\t"+builtin+"\n") roots := sl.SkillRoots() assert.Equal(t, []string{ filepath.Join(workspace, "skills"), global, builtin, }, roots) } func TestGetSkillMetadata_UsesMarkdownParagraphWhenNoFrontmatter(t *testing.T) { tmp := t.TempDir() skillDir := filepath.Join(tmp, "workspace", "skills", "plain-skill") require.NoError(t, os.MkdirAll(skillDir, 0o755)) content := "# Plain Skill\n\nThis is parsed from markdown paragraph.\n" require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) sl := &SkillsLoader{} meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md")) require.NotNil(t, meta) assert.Equal(t, "plain-skill", meta.Name) assert.Equal(t, "This is parsed from markdown paragraph.", meta.Description) } func TestGetSkillMetadata_FrontmatterOverridesMarkdown(t *testing.T) { tmp := t.TempDir() skillDir := filepath.Join(tmp, "workspace", "skills", "plain-skill") require.NoError(t, os.MkdirAll(skillDir, 0o755)) content := "---\nname: frontmatter-skill\ndescription: frontmatter description\n---\n\n# Plain Skill\n\nBody description.\n" require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) sl := &SkillsLoader{} meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md")) require.NotNil(t, meta) assert.Equal(t, "frontmatter-skill", meta.Name) assert.Equal(t, "frontmatter description", meta.Description) } func TestGetSkillMetadata_YAMLMultilineDescription(t *testing.T) { tmp := t.TempDir() skillDir := filepath.Join(tmp, "workspace", "skills", "plain-skill") require.NoError(t, os.MkdirAll(skillDir, 0o755)) content := "---\nname: frontmatter-skill\ndescription: |\n line 1: with colon\n line 2\n---\n\n# Plain Skill\n\nBody description.\n" require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) sl := &SkillsLoader{} meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md")) require.NotNil(t, meta) assert.Equal(t, "frontmatter-skill", meta.Name) assert.Equal(t, "line 1: with colon\nline 2", meta.Description) } func TestGetSkillMetadata_InvalidHeadingNameFallsBackToDirName(t *testing.T) { tmp := t.TempDir() skillDir := filepath.Join(tmp, "workspace", "skills", "valid-name") require.NoError(t, os.MkdirAll(skillDir, 0o755)) content := "# Invalid Heading Name\n\nBody description.\n" require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) sl := &SkillsLoader{} meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md")) require.NotNil(t, meta) assert.Equal(t, "valid-name", meta.Name) assert.Equal(t, "Body description.", meta.Description) } func TestGetSkillMetadata_IgnoresHTMLCommentBlocks(t *testing.T) { tmp := t.TempDir() skillDir := filepath.Join(tmp, "workspace", "skills", "biomed-skill") require.NoError(t, os.MkdirAll(skillDir, 0o755)) content := "<!--\n# COPYRIGHT NOTICE\n# This file is part of the \"Universal Biomedical Skills\" project.\n# Copyright (c) 2026 MD BABU MIA, PhD <md.babu.mia@mssm.edu>\n# All Rights Reserved.\n#\n# This code is proprietary and confidential.\n# Unauthorized copying of this file, via any medium is strictly prohibited.\n#\n# Provenance: Authenticated by MD BABU MIA\n\n-->\n\n# Biomed Skill\n\nSummarize biomedical papers.\n" require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) sl := &SkillsLoader{} meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md")) require.NotNil(t, meta) assert.Equal(t, "biomed-skill", meta.Name) assert.Equal(t, "Summarize biomedical papers.", meta.Description) } ================================================ FILE: pkg/skills/registry.go ================================================ package skills import ( "context" "fmt" "log/slog" "sync" "time" ) const ( defaultMaxConcurrentSearches = 2 ) // SearchResult represents a single result from a skill registry search. type SearchResult struct { Score float64 `json:"score"` Slug string `json:"slug"` DisplayName string `json:"display_name"` Summary string `json:"summary"` Version string `json:"version"` RegistryName string `json:"registry_name"` } // SkillMeta holds metadata about a skill from a registry. type SkillMeta struct { Slug string `json:"slug"` DisplayName string `json:"display_name"` Summary string `json:"summary"` LatestVersion string `json:"latest_version"` IsMalwareBlocked bool `json:"is_malware_blocked"` IsSuspicious bool `json:"is_suspicious"` RegistryName string `json:"registry_name"` } // InstallResult is returned by DownloadAndInstall to carry metadata // back to the caller for moderation and user messaging. type InstallResult struct { Version string IsMalwareBlocked bool IsSuspicious bool Summary string } // SkillRegistry is the interface that all skill registries must implement. // Each registry represents a different source of skills (e.g., clawhub.ai) type SkillRegistry interface { // Name returns the unique name of this registry (e.g., "clawhub"). Name() string // Search searches the registry for skills matching the query. Search(ctx context.Context, query string, limit int) ([]SearchResult, error) // GetSkillMeta retrieves metadata for a specific skill by slug. GetSkillMeta(ctx context.Context, slug string) (*SkillMeta, error) // DownloadAndInstall fetches metadata, resolves the version, downloads and // installs the skill to targetDir. Returns an InstallResult with metadata // for the caller to use for moderation and user messaging. DownloadAndInstall(ctx context.Context, slug, version, targetDir string) (*InstallResult, error) } // RegistryConfig holds configuration for all skill registries. // This is the input to NewRegistryManagerFromConfig. type RegistryConfig struct { ClawHub ClawHubConfig MaxConcurrentSearches int } // ClawHubConfig configures the ClawHub registry. type ClawHubConfig struct { Enabled bool BaseURL string AuthToken string SearchPath string // e.g. "/api/v1/search" SkillsPath string // e.g. "/api/v1/skills" DownloadPath string // e.g. "/api/v1/download" Timeout int // seconds, 0 = default (30s) MaxZipSize int // bytes, 0 = default (50MB) MaxResponseSize int // bytes, 0 = default (2MB) } // RegistryManager coordinates multiple skill registries. // It fans out search requests and routes installs to the correct registry. type RegistryManager struct { registries []SkillRegistry maxConcurrent int mu sync.RWMutex } // NewRegistryManager creates an empty RegistryManager. func NewRegistryManager() *RegistryManager { return &RegistryManager{ registries: make([]SkillRegistry, 0), maxConcurrent: defaultMaxConcurrentSearches, } } // NewRegistryManagerFromConfig builds a RegistryManager from config, // instantiating only the enabled registries. func NewRegistryManagerFromConfig(cfg RegistryConfig) *RegistryManager { rm := NewRegistryManager() if cfg.MaxConcurrentSearches > 0 { rm.maxConcurrent = cfg.MaxConcurrentSearches } if cfg.ClawHub.Enabled { rm.AddRegistry(NewClawHubRegistry(cfg.ClawHub)) } return rm } // AddRegistry adds a registry to the manager. func (rm *RegistryManager) AddRegistry(r SkillRegistry) { rm.mu.Lock() defer rm.mu.Unlock() rm.registries = append(rm.registries, r) } // GetRegistry returns a registry by name, or nil if not found. func (rm *RegistryManager) GetRegistry(name string) SkillRegistry { rm.mu.RLock() defer rm.mu.RUnlock() for _, r := range rm.registries { if r.Name() == name { return r } } return nil } // SearchAll fans out the query to all registries concurrently // and merges results sorted by score descending. func (rm *RegistryManager) SearchAll(ctx context.Context, query string, limit int) ([]SearchResult, error) { rm.mu.RLock() regs := make([]SkillRegistry, len(rm.registries)) copy(regs, rm.registries) rm.mu.RUnlock() if len(regs) == 0 { return nil, fmt.Errorf("no registries configured") } type regResult struct { results []SearchResult err error } // Semaphore: limit concurrency. sem := make(chan struct{}, rm.maxConcurrent) resultsCh := make(chan regResult, len(regs)) var wg sync.WaitGroup for _, reg := range regs { wg.Add(1) go func(r SkillRegistry) { defer wg.Done() // Acquire semaphore slot. select { case sem <- struct{}{}: defer func() { <-sem }() case <-ctx.Done(): resultsCh <- regResult{err: ctx.Err()} return } searchCtx, cancel := context.WithTimeout(ctx, 1*time.Minute) defer cancel() results, err := r.Search(searchCtx, query, limit) if err != nil { slog.Warn("registry search failed", "registry", r.Name(), "error", err) resultsCh <- regResult{err: err} return } resultsCh <- regResult{results: results} }(reg) } // Close results channel after all goroutines complete. go func() { wg.Wait() close(resultsCh) }() var merged []SearchResult var lastErr error var anyRegistrySucceeded bool for rr := range resultsCh { if rr.err != nil { lastErr = rr.err continue } anyRegistrySucceeded = true merged = append(merged, rr.results...) } // If all registries failed, return the last error. if !anyRegistrySucceeded && lastErr != nil { return nil, fmt.Errorf("all registries failed: %w", lastErr) } // Sort by score descending. sortByScoreDesc(merged) // Clamp to limit. if limit > 0 && len(merged) > limit { merged = merged[:limit] } return merged, nil } // sortByScoreDesc sorts SearchResults by Score in descending order (insertion sort — small slices). func sortByScoreDesc(results []SearchResult) { for i := 1; i < len(results); i++ { key := results[i] j := i - 1 for j >= 0 && results[j].Score < key.Score { results[j+1] = results[j] j-- } results[j+1] = key } } ================================================ FILE: pkg/skills/registry_test.go ================================================ package skills import ( "context" "fmt" "testing" "time" "github.com/stretchr/testify/assert" "github.com/sipeed/picoclaw/pkg/utils" ) // mockRegistry is a test double implementing SkillRegistry. type mockRegistry struct { name string searchResults []SearchResult searchErr error meta *SkillMeta metaErr error installResult *InstallResult installErr error } func (m *mockRegistry) Name() string { return m.name } func (m *mockRegistry) Search(_ context.Context, _ string, _ int) ([]SearchResult, error) { return m.searchResults, m.searchErr } func (m *mockRegistry) GetSkillMeta(_ context.Context, _ string) (*SkillMeta, error) { return m.meta, m.metaErr } func (m *mockRegistry) DownloadAndInstall(_ context.Context, _, _, _ string) (*InstallResult, error) { return m.installResult, m.installErr } func TestRegistryManagerSearchAllSingle(t *testing.T) { mgr := NewRegistryManager() mgr.AddRegistry(&mockRegistry{ name: "test", searchResults: []SearchResult{ {Slug: "skill-a", Score: 0.9, RegistryName: "test"}, {Slug: "skill-b", Score: 0.5, RegistryName: "test"}, }, }) results, err := mgr.SearchAll(context.Background(), "test query", 10) assert.NoError(t, err) assert.Len(t, results, 2) assert.Equal(t, "skill-a", results[0].Slug) } func TestRegistryManagerSearchAllMultiple(t *testing.T) { mgr := NewRegistryManager() mgr.AddRegistry(&mockRegistry{ name: "alpha", searchResults: []SearchResult{ {Slug: "skill-a", Score: 0.8, RegistryName: "alpha"}, }, }) mgr.AddRegistry(&mockRegistry{ name: "beta", searchResults: []SearchResult{ {Slug: "skill-b", Score: 0.95, RegistryName: "beta"}, }, }) results, err := mgr.SearchAll(context.Background(), "test query", 10) assert.NoError(t, err) assert.Len(t, results, 2) // Should be sorted by score descending assert.Equal(t, "skill-b", results[0].Slug) assert.Equal(t, "skill-a", results[1].Slug) } func TestRegistryManagerSearchAllOneFailsGracefully(t *testing.T) { mgr := NewRegistryManager() mgr.AddRegistry(&mockRegistry{ name: "failing", searchErr: fmt.Errorf("network error"), }) mgr.AddRegistry(&mockRegistry{ name: "working", searchResults: []SearchResult{ {Slug: "skill-a", Score: 0.8, RegistryName: "working"}, }, }) results, err := mgr.SearchAll(context.Background(), "test query", 10) assert.NoError(t, err) assert.Len(t, results, 1) assert.Equal(t, "skill-a", results[0].Slug) } func TestRegistryManagerSearchAllAllFail(t *testing.T) { mgr := NewRegistryManager() mgr.AddRegistry(&mockRegistry{ name: "fail-1", searchErr: fmt.Errorf("error 1"), }) _, err := mgr.SearchAll(context.Background(), "test query", 10) assert.Error(t, err) } func TestRegistryManagerSearchAllNoRegistries(t *testing.T) { mgr := NewRegistryManager() _, err := mgr.SearchAll(context.Background(), "test query", 10) assert.Error(t, err) } func TestRegistryManagerGetRegistry(t *testing.T) { mgr := NewRegistryManager() mock := &mockRegistry{name: "clawhub"} mgr.AddRegistry(mock) got := mgr.GetRegistry("clawhub") assert.NotNil(t, got) assert.Equal(t, "clawhub", got.Name()) got = mgr.GetRegistry("nonexistent") assert.Nil(t, got) } func TestRegistryManagerSearchAllRespectLimit(t *testing.T) { mgr := NewRegistryManager() results := make([]SearchResult, 20) for i := range results { results[i] = SearchResult{Slug: fmt.Sprintf("skill-%d", i), Score: float64(20 - i)} } mgr.AddRegistry(&mockRegistry{ name: "test", searchResults: results, }) got, err := mgr.SearchAll(context.Background(), "test", 5) assert.NoError(t, err) assert.Len(t, got, 5) // Top scores first assert.Equal(t, "skill-0", got[0].Slug) } func TestRegistryManagerSearchAllTimeout(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) defer cancel() time.Sleep(5 * time.Millisecond) // Let context expire. mgr := NewRegistryManager() mgr.AddRegistry(&mockRegistry{ name: "slow", searchErr: fmt.Errorf("context deadline exceeded"), }) _, err := mgr.SearchAll(ctx, "test", 5) assert.Error(t, err) } func TestSortByScoreDesc(t *testing.T) { results := []SearchResult{ {Slug: "c", Score: 0.3}, {Slug: "a", Score: 0.9}, {Slug: "b", Score: 0.5}, } sortByScoreDesc(results) assert.Equal(t, "a", results[0].Slug) assert.Equal(t, "b", results[1].Slug) assert.Equal(t, "c", results[2].Slug) } func TestIsSafeSlug(t *testing.T) { assert.NoError(t, utils.ValidateSkillIdentifier("github")) assert.NoError(t, utils.ValidateSkillIdentifier("docker-compose")) assert.Error(t, utils.ValidateSkillIdentifier("")) assert.Error(t, utils.ValidateSkillIdentifier("../etc/passwd")) assert.Error(t, utils.ValidateSkillIdentifier("path/traversal")) assert.Error(t, utils.ValidateSkillIdentifier("path\\traversal")) } ================================================ FILE: pkg/skills/search_cache.go ================================================ package skills import ( "slices" "strings" "sync" "time" ) // SearchCache provides lightweight caching for search results. // It uses trigram-based similarity to match similar queries to cached results, // avoiding redundant API calls. Thread-safe for concurrent access. type SearchCache struct { mu sync.RWMutex entries map[string]*cacheEntry order []string // LRU order: oldest first. maxEntries int ttl time.Duration } type cacheEntry struct { query string trigrams []uint32 results []SearchResult createdAt time.Time } // similarityThreshold is the minimum trigram Jaccard similarity for a cache hit. const similarityThreshold = 0.7 // NewSearchCache creates a new search cache. // maxEntries is the maximum number of cached queries (excess evicts LRU). // ttl is how long each entry lives before expiration. func NewSearchCache(maxEntries int, ttl time.Duration) *SearchCache { if maxEntries <= 0 { maxEntries = 50 } if ttl <= 0 { ttl = 5 * time.Minute } return &SearchCache{ entries: make(map[string]*cacheEntry), order: make([]string, 0), maxEntries: maxEntries, ttl: ttl, } } // Get looks up results for a query. Returns cached results and true if found // (either exact or similar match above threshold). Returns nil, false on miss. func (sc *SearchCache) Get(query string) ([]SearchResult, bool) { normalized := normalizeQuery(query) if normalized == "" { return nil, false } sc.mu.Lock() defer sc.mu.Unlock() // Exact match first. if entry, ok := sc.entries[normalized]; ok { if time.Since(entry.createdAt) < sc.ttl { sc.moveToEndLocked(normalized) return copyResults(entry.results), true } } // Similarity match. queryTrigrams := buildTrigrams(normalized) var bestEntry *cacheEntry var bestSim float64 for _, entry := range sc.entries { if time.Since(entry.createdAt) >= sc.ttl { continue // Skip expired. } sim := jaccardSimilarity(queryTrigrams, entry.trigrams) if sim > bestSim { bestSim = sim bestEntry = entry } } if bestSim >= similarityThreshold && bestEntry != nil { sc.moveToEndLocked(bestEntry.query) return copyResults(bestEntry.results), true } return nil, false } // Put stores results for a query. Evicts the oldest entry if at capacity. func (sc *SearchCache) Put(query string, results []SearchResult) { normalized := normalizeQuery(query) if normalized == "" { return } sc.mu.Lock() defer sc.mu.Unlock() // Evict expired entries first. sc.evictExpiredLocked() // If already exists, update. if _, ok := sc.entries[normalized]; ok { sc.entries[normalized] = &cacheEntry{ query: normalized, trigrams: buildTrigrams(normalized), results: copyResults(results), createdAt: time.Now(), } // Move to end of LRU order. sc.moveToEndLocked(normalized) return } // Evict LRU if at capacity. for len(sc.entries) >= sc.maxEntries && len(sc.order) > 0 { oldest := sc.order[0] sc.order = sc.order[1:] delete(sc.entries, oldest) } // Insert new entry. sc.entries[normalized] = &cacheEntry{ query: normalized, trigrams: buildTrigrams(normalized), results: copyResults(results), createdAt: time.Now(), } sc.order = append(sc.order, normalized) } // Len returns the number of entries (for testing). func (sc *SearchCache) Len() int { sc.mu.RLock() defer sc.mu.RUnlock() return len(sc.entries) } // --- internal --- func (sc *SearchCache) evictExpiredLocked() { now := time.Now() newOrder := make([]string, 0, len(sc.order)) for _, key := range sc.order { entry, ok := sc.entries[key] if !ok || now.Sub(entry.createdAt) >= sc.ttl { delete(sc.entries, key) continue } newOrder = append(newOrder, key) } sc.order = newOrder } func (sc *SearchCache) moveToEndLocked(key string) { for i, k := range sc.order { if k == key { sc.order = append(sc.order[:i], sc.order[i+1:]...) break } } sc.order = append(sc.order, key) } func normalizeQuery(q string) string { return strings.ToLower(strings.TrimSpace(q)) } // buildTrigrams generates hash of trigrams from a string. // Example: "hello" → {"hel", "ell", "llo"} // "hel" -> 0x0068656c -> 4 bytes; compared to 16 bytes of a string func buildTrigrams(s string) []uint32 { if len(s) < 3 { return nil } trigrams := make([]uint32, 0, len(s)-2) for i := 0; i <= len(s)-3; i++ { trigrams = append(trigrams, uint32(s[i])<<16|uint32(s[i+1])<<8|uint32(s[i+2])) } // Sort and Deduplication slices.Sort(trigrams) n := 1 for i := 1; i < len(trigrams); i++ { if trigrams[i] != trigrams[i-1] { trigrams[n] = trigrams[i] n++ } } return trigrams[:n] } // jaccardSimilarity computes |A ∩ B| / |A ∪ B|. func jaccardSimilarity(a, b []uint32) float64 { if len(a) == 0 && len(b) == 0 { return 1 } i, j := 0, 0 intersection := 0 for i < len(a) && j < len(b) { if a[i] == b[j] { intersection++ i++ j++ } else if a[i] < b[j] { i++ } else { j++ } } union := len(a) + len(b) - intersection return float64(intersection) / float64(union) } func copyResults(results []SearchResult) []SearchResult { if results == nil { return nil } cp := make([]SearchResult, len(results)) copy(cp, results) return cp } ================================================ FILE: pkg/skills/search_cache_test.go ================================================ package skills import ( "testing" "time" "github.com/stretchr/testify/assert" ) func TestSearchCacheExactHit(t *testing.T) { cache := NewSearchCache(10, 5*time.Minute) results := []SearchResult{ {Slug: "github", Score: 0.9, RegistryName: "clawhub"}, {Slug: "docker", Score: 0.7, RegistryName: "clawhub"}, } cache.Put("github integration", results) got, hit := cache.Get("github integration") assert.True(t, hit) assert.Len(t, got, 2) assert.Equal(t, "github", got[0].Slug) } func TestSearchCacheExactHitCaseInsensitive(t *testing.T) { cache := NewSearchCache(10, 5*time.Minute) results := []SearchResult{{Slug: "github", Score: 0.9}} cache.Put("GitHub Integration", results) got, hit := cache.Get("github integration") assert.True(t, hit) assert.Len(t, got, 1) } func TestSearchCacheSimilarHit(t *testing.T) { cache := NewSearchCache(10, 5*time.Minute) results := []SearchResult{{Slug: "github", Score: 0.9}} cache.Put("github integration tool", results) // "github integration" is very similar to "github integration tool" got, hit := cache.Get("github integration") assert.True(t, hit) assert.Len(t, got, 1) } func TestSearchCacheDissimilarMiss(t *testing.T) { cache := NewSearchCache(10, 5*time.Minute) results := []SearchResult{{Slug: "github", Score: 0.9}} cache.Put("github integration", results) // Completely unrelated query _, hit := cache.Get("database management") assert.False(t, hit) } func TestSearchCacheTTLExpiration(t *testing.T) { cache := NewSearchCache(10, 50*time.Millisecond) results := []SearchResult{{Slug: "github", Score: 0.9}} cache.Put("github integration", results) // Immediately should hit _, hit := cache.Get("github integration") assert.True(t, hit) // Wait for expiration time.Sleep(100 * time.Millisecond) _, hit = cache.Get("github integration") assert.False(t, hit) } func TestSearchCacheLRUEviction(t *testing.T) { cache := NewSearchCache(3, 5*time.Minute) cache.Put("query-1", []SearchResult{{Slug: "a"}}) cache.Put("query-2", []SearchResult{{Slug: "b"}}) cache.Put("query-3", []SearchResult{{Slug: "c"}}) assert.Equal(t, 3, cache.Len()) // Adding a 4th should evict query-1 (oldest) cache.Put("query-4", []SearchResult{{Slug: "d"}}) assert.Equal(t, 3, cache.Len()) _, hit := cache.Get("query-1") assert.False(t, hit, "oldest entry should be evicted") got, hit := cache.Get("query-4") assert.True(t, hit) assert.Equal(t, "d", got[0].Slug) } func TestSearchCacheEmptyQuery(t *testing.T) { cache := NewSearchCache(10, 5*time.Minute) _, hit := cache.Get("") assert.False(t, hit) _, hit = cache.Get(" ") assert.False(t, hit) } func TestSearchCacheResultsCopied(t *testing.T) { cache := NewSearchCache(10, 5*time.Minute) original := []SearchResult{{Slug: "github", Score: 0.9}} cache.Put("test", original) // Mutate original after putting original[0].Slug = "mutated" got, hit := cache.Get("test") assert.True(t, hit) assert.Equal(t, "github", got[0].Slug, "cache should hold a copy, not a reference") } func TestBuildTrigrams(t *testing.T) { trigrams := buildTrigrams("hello") assert.Contains(t, trigrams, uint32('h')<<16|uint32('e')<<8|uint32('l')) assert.Contains(t, trigrams, uint32('e')<<16|uint32('l')<<8|uint32('l')) assert.Contains(t, trigrams, uint32('l')<<16|uint32('l')<<8|uint32('o')) assert.Len(t, trigrams, 3) } func TestJaccardSimilarity(t *testing.T) { a := buildTrigrams("github integration") b := buildTrigrams("github integration tool") sim := jaccardSimilarity(a, b) assert.Greater(t, sim, 0.5, "similar strings should have high sim") c := buildTrigrams("completely different query about databases") sim2 := jaccardSimilarity(a, c) assert.Less(t, sim2, 0.3, "dissimilar strings should have low sim") } func TestJaccardSimilarityEdgeCases(t *testing.T) { empty := buildTrigrams("") nonempty := buildTrigrams("hello") assert.Equal(t, 1.0, jaccardSimilarity(empty, empty)) assert.Equal(t, 0.0, jaccardSimilarity(empty, nonempty)) assert.Equal(t, 0.0, jaccardSimilarity(nonempty, empty)) } func TestSearchCacheConcurrency(t *testing.T) { cache := NewSearchCache(50, 5*time.Minute) done := make(chan struct{}) // Concurrent writes go func() { for i := range 100 { cache.Put("query-write-"+string(rune('a'+i%26)), []SearchResult{{Slug: "x"}}) } done <- struct{}{} }() // Concurrent reads go func() { for range 100 { cache.Get("query-write-a") } done <- struct{}{} }() <-done } func TestSearchCacheLRUUpdateOnGet(t *testing.T) { // Capacity 3 cache := NewSearchCache(3, time.Hour) // Fill cache: query-A, query-B, query-C // Use longer strings to ensure trigrams are generated and avoid false positive similarity cache.Put("query-A", []SearchResult{{Slug: "A"}}) cache.Put("query-B", []SearchResult{{Slug: "B"}}) cache.Put("query-C", []SearchResult{{Slug: "C"}}) // Access query-A (should make it most recently used) if _, found := cache.Get("query-A"); !found { t.Fatal("query-A should be in cache") } // Add query-D. Should evict query-B (LRU) instead of query-A (which was refreshed) cache.Put("query-D", []SearchResult{{Slug: "D"}}) // Check if query-A is still there if _, found := cache.Get("query-A"); !found { t.Fatalf("query-A was evicted! valid LRU should have kept query-A and evicted query-B.") } // Check if query-B is evicted if _, found := cache.Get("query-B"); found { t.Fatal("query-B should have been evicted") } } ================================================ FILE: pkg/state/state.go ================================================ package state import ( "encoding/json" "fmt" "log" "os" "path/filepath" "sync" "time" "github.com/sipeed/picoclaw/pkg/fileutil" ) // State represents the persistent state for a workspace. // It includes information about the last active channel/chat. type State struct { // LastChannel is the last channel used for communication LastChannel string `json:"last_channel,omitempty"` // LastChatID is the last chat ID used for communication LastChatID string `json:"last_chat_id,omitempty"` // Timestamp is the last time this state was updated Timestamp time.Time `json:"timestamp"` } // Manager manages persistent state with atomic saves. type Manager struct { workspace string state *State mu sync.RWMutex stateFile string } // NewManager creates a new state manager for the given workspace. func NewManager(workspace string) *Manager { stateDir := filepath.Join(workspace, "state") stateFile := filepath.Join(stateDir, "state.json") oldStateFile := filepath.Join(workspace, "state.json") // Create state directory if it doesn't exist if err := os.MkdirAll(stateDir, 0o700); err != nil { log.Printf("[WARN] state: failed to create state directory %s: %v", stateDir, err) } sm := &Manager{ workspace: workspace, stateFile: stateFile, state: &State{}, } // Try to load from new location first if _, err := os.Stat(stateFile); os.IsNotExist(err) { // New file doesn't exist, try migrating from old location if data, err := os.ReadFile(oldStateFile); err == nil { if err := json.Unmarshal(data, sm.state); err == nil { // Migrate to new location if err := sm.saveAtomic(); err != nil { log.Printf("[WARN] state: failed to save state: %v", err) } log.Printf("[INFO] state: migrated state from %s to %s", oldStateFile, stateFile) } } } else { // Load from new location if err := sm.load(); err != nil { log.Printf("[WARN] state: failed to load state: %v", err) } } return sm } // SetLastChannel atomically updates the last channel and saves the state. // This method uses a temp file + rename pattern for atomic writes, // ensuring that the state file is never corrupted even if the process crashes. func (sm *Manager) SetLastChannel(channel string) error { sm.mu.Lock() defer sm.mu.Unlock() // Update state sm.state.LastChannel = channel sm.state.Timestamp = time.Now() // Atomic save using temp file + rename if err := sm.saveAtomic(); err != nil { return fmt.Errorf("failed to save state atomically: %w", err) } return nil } // SetLastChatID atomically updates the last chat ID and saves the state. func (sm *Manager) SetLastChatID(chatID string) error { sm.mu.Lock() defer sm.mu.Unlock() // Update state sm.state.LastChatID = chatID sm.state.Timestamp = time.Now() // Atomic save using temp file + rename if err := sm.saveAtomic(); err != nil { return fmt.Errorf("failed to save state atomically: %w", err) } return nil } // GetLastChannel returns the last channel from the state. func (sm *Manager) GetLastChannel() string { sm.mu.RLock() defer sm.mu.RUnlock() return sm.state.LastChannel } // GetLastChatID returns the last chat ID from the state. func (sm *Manager) GetLastChatID() string { sm.mu.RLock() defer sm.mu.RUnlock() return sm.state.LastChatID } // GetTimestamp returns the timestamp of the last state update. func (sm *Manager) GetTimestamp() time.Time { sm.mu.RLock() defer sm.mu.RUnlock() return sm.state.Timestamp } // saveAtomic performs an atomic save using temp file + rename. // This ensures that the state file is never corrupted: // 1. Write to a temp file // 2. Sync to disk (critical for SD cards/flash storage) // 3. Rename temp file to target (atomic on POSIX systems) // 4. If rename fails, cleanup the temp file // // Must be called with the lock held. func (sm *Manager) saveAtomic() error { // Use unified atomic write utility with explicit sync for flash storage reliability. // Using 0o600 (owner read/write only) for secure default permissions. data, err := json.MarshalIndent(sm.state, "", " ") if err != nil { return fmt.Errorf("failed to marshal state: %w", err) } return fileutil.WriteFileAtomic(sm.stateFile, data, 0o600) } // load loads the state from disk. func (sm *Manager) load() error { data, err := os.ReadFile(sm.stateFile) if err != nil { // File doesn't exist yet, that's OK if os.IsNotExist(err) { return nil } return fmt.Errorf("failed to read state file: %w", err) } if err := json.Unmarshal(data, sm.state); err != nil { return fmt.Errorf("failed to unmarshal state: %w", err) } return nil } ================================================ FILE: pkg/state/state_test.go ================================================ package state import ( "encoding/json" "fmt" "os" "os/exec" "path/filepath" "testing" ) func TestAtomicSave(t *testing.T) { // Create temp workspace tmpDir, err := os.MkdirTemp("", "state-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) sm := NewManager(tmpDir) // Test SetLastChannel err = sm.SetLastChannel("test-channel") if err != nil { t.Fatalf("SetLastChannel failed: %v", err) } // Verify the channel was saved lastChannel := sm.GetLastChannel() if lastChannel != "test-channel" { t.Errorf("Expected channel 'test-channel', got '%s'", lastChannel) } // Verify timestamp was updated if sm.GetTimestamp().IsZero() { t.Error("Expected timestamp to be updated") } // Verify state file exists stateFile := filepath.Join(tmpDir, "state", "state.json") if _, err := os.Stat(stateFile); os.IsNotExist(err) { t.Error("Expected state file to exist") } // Create a new manager to verify persistence sm2 := NewManager(tmpDir) if sm2.GetLastChannel() != "test-channel" { t.Errorf("Expected persistent channel 'test-channel', got '%s'", sm2.GetLastChannel()) } } func TestSetLastChatID(t *testing.T) { tmpDir, err := os.MkdirTemp("", "state-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) sm := NewManager(tmpDir) // Test SetLastChatID err = sm.SetLastChatID("test-chat-id") if err != nil { t.Fatalf("SetLastChatID failed: %v", err) } // Verify the chat ID was saved lastChatID := sm.GetLastChatID() if lastChatID != "test-chat-id" { t.Errorf("Expected chat ID 'test-chat-id', got '%s'", lastChatID) } // Verify timestamp was updated if sm.GetTimestamp().IsZero() { t.Error("Expected timestamp to be updated") } // Create a new manager to verify persistence sm2 := NewManager(tmpDir) if sm2.GetLastChatID() != "test-chat-id" { t.Errorf("Expected persistent chat ID 'test-chat-id', got '%s'", sm2.GetLastChatID()) } } func TestAtomicity_NoCorruptionOnInterrupt(t *testing.T) { tmpDir, err := os.MkdirTemp("", "state-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) sm := NewManager(tmpDir) // Write initial state err = sm.SetLastChannel("initial-channel") if err != nil { t.Fatalf("SetLastChannel failed: %v", err) } // Simulate a crash scenario by manually creating a corrupted temp file tempFile := filepath.Join(tmpDir, "state", "state.json.tmp") err = os.WriteFile(tempFile, []byte("corrupted data"), 0o644) if err != nil { t.Fatalf("Failed to create temp file: %v", err) } // Verify that the original state is still intact lastChannel := sm.GetLastChannel() if lastChannel != "initial-channel" { t.Errorf("Expected channel 'initial-channel' after corrupted temp file, got '%s'", lastChannel) } // Clean up the temp file manually os.Remove(tempFile) // Now do a proper save err = sm.SetLastChannel("new-channel") if err != nil { t.Fatalf("SetLastChannel failed: %v", err) } // Verify the new state was saved if sm.GetLastChannel() != "new-channel" { t.Errorf("Expected channel 'new-channel', got '%s'", sm.GetLastChannel()) } } func TestConcurrentAccess(t *testing.T) { tmpDir, err := os.MkdirTemp("", "state-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) sm := NewManager(tmpDir) // Test concurrent writes done := make(chan bool, 10) for i := range 10 { go func(idx int) { channel := fmt.Sprintf("channel-%d", idx) sm.SetLastChannel(channel) done <- true }(i) } // Wait for all goroutines to complete for range 10 { <-done } // Verify the final state is consistent lastChannel := sm.GetLastChannel() if lastChannel == "" { t.Error("Expected non-empty channel after concurrent writes") } // Verify state file is valid JSON stateFile := filepath.Join(tmpDir, "state", "state.json") data, err := os.ReadFile(stateFile) if err != nil { t.Fatalf("Failed to read state file: %v", err) } var state State if err := json.Unmarshal(data, &state); err != nil { t.Errorf("State file contains invalid JSON: %v", err) } } func TestNewManager_ExistingState(t *testing.T) { tmpDir, err := os.MkdirTemp("", "state-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) // Create initial state sm1 := NewManager(tmpDir) sm1.SetLastChannel("existing-channel") sm1.SetLastChatID("existing-chat-id") // Create new manager with same workspace sm2 := NewManager(tmpDir) // Verify state was loaded if sm2.GetLastChannel() != "existing-channel" { t.Errorf("Expected channel 'existing-channel', got '%s'", sm2.GetLastChannel()) } if sm2.GetLastChatID() != "existing-chat-id" { t.Errorf("Expected chat ID 'existing-chat-id', got '%s'", sm2.GetLastChatID()) } } func TestNewManager_EmptyWorkspace(t *testing.T) { tmpDir, err := os.MkdirTemp("", "state-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) sm := NewManager(tmpDir) // Verify default state if sm.GetLastChannel() != "" { t.Errorf("Expected empty channel, got '%s'", sm.GetLastChannel()) } if sm.GetLastChatID() != "" { t.Errorf("Expected empty chat ID, got '%s'", sm.GetLastChatID()) } if !sm.GetTimestamp().IsZero() { t.Error("Expected zero timestamp for new state") } } func TestNewManager_MkdirFailureDoesNotCrash(t *testing.T) { if os.Getenv("BE_CRASHER") == "1" { tmpDir := os.Getenv("CRASH_DIR") statePath := filepath.Join(tmpDir, "state") if err := os.WriteFile(statePath, []byte("I'm a file, not a folder"), 0o644); err != nil { fmt.Printf("setup failed: %v", err) os.Exit(0) } NewManager(tmpDir) os.Exit(0) } tmpDir, err := os.MkdirTemp("", "state-crash-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) cmd := exec.Command(os.Args[0], "-test.run=TestNewManager_MkdirFailureDoesNotCrash") cmd.Env = append(os.Environ(), "BE_CRASHER=1", "CRASH_DIR="+tmpDir) err = cmd.Run() if err != nil { t.Fatalf("NewManager should not crash when state dir creation fails, got: %v", err) } } ================================================ FILE: pkg/tools/base.go ================================================ package tools import "context" // Tool is the interface that all tools must implement. type Tool interface { Name() string Description() string Parameters() map[string]any Execute(ctx context.Context, args map[string]any) *ToolResult } // --- Request-scoped tool context (channel / chatID) --- // // Carried via context.Value so that concurrent tool calls each receive // their own immutable copy — no mutable state on singleton tool instances. // // Keys are unexported pointer-typed vars — guaranteed collision-free, // and only accessible through the helper functions below. type toolCtxKey struct{ name string } var ( ctxKeyChannel = &toolCtxKey{"channel"} ctxKeyChatID = &toolCtxKey{"chatID"} ) // WithToolContext returns a child context carrying channel and chatID. func WithToolContext(ctx context.Context, channel, chatID string) context.Context { ctx = context.WithValue(ctx, ctxKeyChannel, channel) ctx = context.WithValue(ctx, ctxKeyChatID, chatID) return ctx } // ToolChannel extracts the channel from ctx, or "" if unset. func ToolChannel(ctx context.Context) string { v, _ := ctx.Value(ctxKeyChannel).(string) return v } // ToolChatID extracts the chatID from ctx, or "" if unset. func ToolChatID(ctx context.Context) string { v, _ := ctx.Value(ctxKeyChatID).(string) return v } // AsyncCallback is a function type that async tools use to notify completion. // When an async tool finishes its work, it calls this callback with the result. // // The ctx parameter allows the callback to be canceled if the agent is shutting down. // The result parameter contains the tool's execution result. type AsyncCallback func(ctx context.Context, result *ToolResult) // AsyncExecutor is an optional interface that tools can implement to support // asynchronous execution with completion callbacks. // // Unlike the old AsyncTool pattern (SetCallback + Execute), AsyncExecutor // receives the callback as a parameter of ExecuteAsync. This eliminates the // data race where concurrent calls could overwrite each other's callbacks // on a shared tool instance. // // This is useful for: // - Long-running operations that shouldn't block the agent loop // - Subagent spawns that complete independently // - Background tasks that need to report results later // // Example: // // func (t *SpawnTool) ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult { // go func() { // result := t.runSubagent(ctx, args) // if cb != nil { cb(ctx, result) } // }() // return AsyncResult("Subagent spawned, will report back") // } type AsyncExecutor interface { Tool // ExecuteAsync runs the tool asynchronously. The callback cb will be // invoked (possibly from another goroutine) when the async operation // completes. cb is guaranteed to be non-nil by the caller (registry). ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult } func ToolToSchema(tool Tool) map[string]any { return map[string]any{ "type": "function", "function": map[string]any{ "name": tool.Name(), "description": tool.Description(), "parameters": tool.Parameters(), }, } } ================================================ FILE: pkg/tools/cron.go ================================================ package tools import ( "context" "fmt" "strings" "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/cron" "github.com/sipeed/picoclaw/pkg/utils" ) // JobExecutor is the interface for executing cron jobs through the agent type JobExecutor interface { ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error) } // CronTool provides scheduling capabilities for the agent type CronTool struct { cronService *cron.CronService executor JobExecutor msgBus *bus.MessageBus execTool *ExecTool allowCommand bool execEnabled bool } // NewCronTool creates a new CronTool // execTimeout: 0 means no timeout, >0 sets the timeout duration func NewCronTool( cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, config *config.Config, ) (*CronTool, error) { allowCommand := true execEnabled := true if config != nil { allowCommand = config.Tools.Cron.AllowCommand execEnabled = config.Tools.Exec.Enabled } var execTool *ExecTool if execEnabled { var err error execTool, err = NewExecToolWithConfig(workspace, restrict, config) if err != nil { return nil, fmt.Errorf("unable to configure exec tool: %w", err) } } if execTool != nil { execTool.SetTimeout(execTimeout) } return &CronTool{ cronService: cronService, executor: executor, msgBus: msgBus, execTool: execTool, allowCommand: allowCommand, execEnabled: execEnabled, }, nil } // Name returns the tool name func (t *CronTool) Name() string { return "cron" } // Description returns the tool description func (t *CronTool) Description() string { return "Schedule reminders, tasks, or system commands. IMPORTANT: When user asks to be reminded or scheduled, you MUST call this tool. Use 'at_seconds' for one-time reminders (e.g., 'remind me in 10 minutes' → at_seconds=600). Use 'every_seconds' ONLY for recurring tasks (e.g., 'every 2 hours' → every_seconds=7200). Use 'cron_expr' for complex recurring schedules. Use 'command' to execute shell commands directly." } // Parameters returns the tool parameters schema func (t *CronTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "action": map[string]any{ "type": "string", "enum": []string{"add", "list", "remove", "enable", "disable"}, "description": "Action to perform. Use 'add' when user wants to schedule a reminder or task.", }, "message": map[string]any{ "type": "string", "description": "The reminder/task message to display when triggered. If 'command' is used, this describes what the command does.", }, "command": map[string]any{ "type": "string", "description": "Optional: Shell command to execute directly (e.g., 'df -h'). If set, the agent will run this command and report output instead of just showing the message. 'deliver' will be forced to false for commands.", }, "command_confirm": map[string]any{ "type": "boolean", "description": "Optional explicit confirmation flag for scheduling a shell command. Command execution must also be enabled via tools.cron.allow_command.", }, "at_seconds": map[string]any{ "type": "integer", "description": "One-time reminder: seconds from now when to trigger (e.g., 600 for 10 minutes later). Use this for one-time reminders like 'remind me in 10 minutes'.", }, "every_seconds": map[string]any{ "type": "integer", "description": "Recurring interval in seconds (e.g., 3600 for every hour). Use this ONLY for recurring tasks like 'every 2 hours' or 'daily reminder'.", }, "cron_expr": map[string]any{ "type": "string", "description": "Cron expression for complex recurring schedules (e.g., '0 9 * * *' for daily at 9am). Use this for complex recurring schedules.", }, "job_id": map[string]any{ "type": "string", "description": "Job ID (for remove/enable/disable)", }, "deliver": map[string]any{ "type": "boolean", "description": "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: false", }, }, "required": []string{"action"}, } } // Execute runs the tool with the given arguments func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult { action, ok := args["action"].(string) if !ok { return ErrorResult("action is required") } switch action { case "add": return t.addJob(ctx, args) case "list": return t.listJobs() case "remove": return t.removeJob(args) case "enable": return t.enableJob(args, true) case "disable": return t.enableJob(args, false) default: return ErrorResult(fmt.Sprintf("unknown action: %s", action)) } } func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult { channel := ToolChannel(ctx) chatID := ToolChatID(ctx) if channel == "" || chatID == "" { return ErrorResult("no session context (channel/chat_id not set). Use this tool in an active conversation.") } message, ok := args["message"].(string) if !ok || message == "" { return ErrorResult("message is required for add") } var schedule cron.CronSchedule // Check for at_seconds (one-time), every_seconds (recurring), or cron_expr atSeconds, hasAt := args["at_seconds"].(float64) everySeconds, hasEvery := args["every_seconds"].(float64) cronExpr, hasCron := args["cron_expr"].(string) // Fix: type assertions return true for zero values, need additional validity checks // This prevents LLMs that fill unused optional parameters with defaults (0) from triggering wrong type hasAt = hasAt && atSeconds > 0 hasEvery = hasEvery && everySeconds > 0 hasCron = hasCron && cronExpr != "" // Priority: at_seconds > every_seconds > cron_expr if hasAt { atMS := time.Now().UnixMilli() + int64(atSeconds)*1000 schedule = cron.CronSchedule{ Kind: "at", AtMS: &atMS, } } else if hasEvery { everyMS := int64(everySeconds) * 1000 schedule = cron.CronSchedule{ Kind: "every", EveryMS: &everyMS, } } else if hasCron { schedule = cron.CronSchedule{ Kind: "cron", Expr: cronExpr, } } else { return ErrorResult("one of at_seconds, every_seconds, or cron_expr is required") } // Read deliver parameter, default to false so scheduled tasks execute through the agent deliver := false if d, ok := args["deliver"].(bool); ok { deliver = d } // GHSA-pv8c-p6jf-3fpp: command scheduling requires internal channel. When // allow_command is disabled, explicit confirmation is required as an override. // Non-command reminders remain open to all channels. command, _ := args["command"].(string) commandConfirm, _ := args["command_confirm"].(bool) if command != "" { if !t.execEnabled { return ErrorResult("command execution is disabled") } if !constants.IsInternalChannel(channel) { return ErrorResult("scheduling command execution is restricted to internal channels") } if !t.allowCommand && !commandConfirm { return ErrorResult("command_confirm=true is required when allow_command is disabled") } deliver = false } // Truncate message for job name (max 30 chars) messagePreview := utils.Truncate(message, 30) job, err := t.cronService.AddJob( messagePreview, schedule, message, deliver, channel, chatID, ) if err != nil { return ErrorResult(fmt.Sprintf("Error adding job: %v", err)) } if command != "" { job.Payload.Command = command // Need to save the updated payload t.cronService.UpdateJob(job) } return SilentResult(fmt.Sprintf("Cron job added: %s (id: %s)", job.Name, job.ID)) } func (t *CronTool) listJobs() *ToolResult { jobs := t.cronService.ListJobs(false) if len(jobs) == 0 { return SilentResult("No scheduled jobs") } var result strings.Builder result.WriteString("Scheduled jobs:\n") for _, j := range jobs { var scheduleInfo string if j.Schedule.Kind == "every" && j.Schedule.EveryMS != nil { scheduleInfo = fmt.Sprintf("every %ds", *j.Schedule.EveryMS/1000) } else if j.Schedule.Kind == "cron" { scheduleInfo = j.Schedule.Expr } else if j.Schedule.Kind == "at" { scheduleInfo = "one-time" } else { scheduleInfo = "unknown" } result.WriteString(fmt.Sprintf("- %s (id: %s, %s)\n", j.Name, j.ID, scheduleInfo)) } return SilentResult(result.String()) } func (t *CronTool) removeJob(args map[string]any) *ToolResult { jobID, ok := args["job_id"].(string) if !ok || jobID == "" { return ErrorResult("job_id is required for remove") } if t.cronService.RemoveJob(jobID) { return SilentResult(fmt.Sprintf("Cron job removed: %s", jobID)) } return ErrorResult(fmt.Sprintf("Job %s not found", jobID)) } func (t *CronTool) enableJob(args map[string]any, enable bool) *ToolResult { jobID, ok := args["job_id"].(string) if !ok || jobID == "" { return ErrorResult("job_id is required for enable/disable") } job := t.cronService.EnableJob(jobID, enable) if job == nil { return ErrorResult(fmt.Sprintf("Job %s not found", jobID)) } status := "enabled" if !enable { status = "disabled" } return SilentResult(fmt.Sprintf("Cron job '%s' %s", job.Name, status)) } // ExecuteJob executes a cron job through the agent func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { // Get channel/chatID from job payload channel := job.Payload.Channel chatID := job.Payload.To // Default values if not set if channel == "" { channel = "cli" } if chatID == "" { chatID = "direct" } // Execute command if present if job.Payload.Command != "" { if !t.execEnabled || t.execTool == nil { output := "Error executing scheduled command: command execution is disabled" pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) defer pubCancel() t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: channel, ChatID: chatID, Content: output, }) return "ok" } args := map[string]any{ "command": job.Payload.Command, "__channel": channel, "__chat_id": chatID, } result := t.execTool.Execute(ctx, args) var output string if result.IsError { output = fmt.Sprintf("Error executing scheduled command: %s", result.ForLLM) } else { output = fmt.Sprintf("Scheduled command '%s' executed:\n%s", job.Payload.Command, result.ForLLM) } pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) defer pubCancel() t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: channel, ChatID: chatID, Content: output, }) return "ok" } // If deliver=true, send message directly without agent processing if job.Payload.Deliver { pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) defer pubCancel() t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: channel, ChatID: chatID, Content: job.Payload.Message, }) return "ok" } // For deliver=false, process through agent (for complex tasks) sessionKey := fmt.Sprintf("cron-%s", job.ID) // Call agent with job's message response, err := t.executor.ProcessDirectWithChannel( ctx, job.Payload.Message, sessionKey, channel, chatID, ) if err != nil { return fmt.Sprintf("Error: %v", err) } // Response is automatically sent via MessageBus by AgentLoop _ = response // Will be sent by AgentLoop return "ok" } ================================================ FILE: pkg/tools/cron_test.go ================================================ package tools import ( "context" "path/filepath" "strings" "testing" "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/cron" ) func newTestCronToolWithConfig(t *testing.T, cfg *config.Config) *CronTool { t.Helper() storePath := filepath.Join(t.TempDir(), "cron.json") cronService := cron.NewCronService(storePath, nil) msgBus := bus.NewMessageBus() tool, err := NewCronTool(cronService, nil, msgBus, t.TempDir(), true, 0, cfg) if err != nil { t.Fatalf("NewCronTool() error: %v", err) } return tool } func newTestCronTool(t *testing.T) *CronTool { t.Helper() return newTestCronToolWithConfig(t, config.DefaultConfig()) } // TestCronTool_CommandBlockedFromRemoteChannel verifies command scheduling is restricted to internal channels func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) { tool := newTestCronTool(t) ctx := WithToolContext(context.Background(), "telegram", "chat-1") result := tool.Execute(ctx, map[string]any{ "action": "add", "message": "check disk", "command": "df -h", "command_confirm": true, "at_seconds": float64(60), }) if !result.IsError { t.Fatal("expected command scheduling to be blocked from remote channel") } if !strings.Contains(result.ForLLM, "restricted to internal channels") { t.Errorf("expected 'restricted to internal channels', got: %s", result.ForLLM) } } func TestCronTool_CommandDoesNotRequireConfirmByDefault(t *testing.T) { tool := newTestCronTool(t) ctx := WithToolContext(context.Background(), "cli", "direct") result := tool.Execute(ctx, map[string]any{ "action": "add", "message": "check disk", "command": "df -h", "at_seconds": float64(60), }) if result.IsError { t.Fatalf("expected command scheduling without confirm to succeed by default, got: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "Cron job added") { t.Errorf("expected 'Cron job added', got: %s", result.ForLLM) } } func TestCronTool_CommandRequiresConfirmWhenAllowCommandDisabled(t *testing.T) { cfg := config.DefaultConfig() cfg.Tools.Cron.AllowCommand = false tool := newTestCronToolWithConfig(t, cfg) ctx := WithToolContext(context.Background(), "cli", "direct") result := tool.Execute(ctx, map[string]any{ "action": "add", "message": "check disk", "command": "df -h", "at_seconds": float64(60), }) if !result.IsError { t.Fatal("expected command scheduling to require confirm when allow_command is disabled") } if !strings.Contains(result.ForLLM, "command_confirm=true") { t.Errorf("expected command_confirm requirement message, got: %s", result.ForLLM) } } func TestCronTool_CommandAllowedWithConfirmWhenAllowCommandDisabled(t *testing.T) { cfg := config.DefaultConfig() cfg.Tools.Cron.AllowCommand = false tool := newTestCronToolWithConfig(t, cfg) ctx := WithToolContext(context.Background(), "cli", "direct") result := tool.Execute(ctx, map[string]any{ "action": "add", "message": "check disk", "command": "df -h", "command_confirm": true, "at_seconds": float64(60), }) if result.IsError { t.Fatalf( "expected command scheduling with confirm to succeed when allow_command is disabled, got: %s", result.ForLLM, ) } if !strings.Contains(result.ForLLM, "Cron job added") { t.Errorf("expected 'Cron job added', got: %s", result.ForLLM) } } func TestCronTool_CommandBlockedWhenExecDisabled(t *testing.T) { cfg := config.DefaultConfig() cfg.Tools.Exec.Enabled = false tool := newTestCronToolWithConfig(t, cfg) ctx := WithToolContext(context.Background(), "cli", "direct") result := tool.Execute(ctx, map[string]any{ "action": "add", "message": "check disk", "command": "df -h", "command_confirm": true, "at_seconds": float64(60), }) if !result.IsError { t.Fatal("expected command scheduling to be blocked when exec is disabled") } if !strings.Contains(result.ForLLM, "command execution is disabled") { t.Errorf("expected exec disabled message, got: %s", result.ForLLM) } } // TestCronTool_CommandAllowedFromInternalChannel verifies command scheduling works from internal channels func TestCronTool_CommandAllowedFromInternalChannel(t *testing.T) { tool := newTestCronTool(t) ctx := WithToolContext(context.Background(), "cli", "direct") result := tool.Execute(ctx, map[string]any{ "action": "add", "message": "check disk", "command": "df -h", "command_confirm": true, "at_seconds": float64(60), }) if result.IsError { t.Fatalf("expected command scheduling to succeed from internal channel, got: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "Cron job added") { t.Errorf("expected 'Cron job added', got: %s", result.ForLLM) } } // TestCronTool_AddJobRequiresSessionContext verifies fail-closed when channel/chatID missing func TestCronTool_AddJobRequiresSessionContext(t *testing.T) { tool := newTestCronTool(t) result := tool.Execute(context.Background(), map[string]any{ "action": "add", "message": "reminder", "at_seconds": float64(60), }) if !result.IsError { t.Fatal("expected error when session context is missing") } if !strings.Contains(result.ForLLM, "no session context") { t.Errorf("expected 'no session context' message, got: %s", result.ForLLM) } } // TestCronTool_NonCommandJobAllowedFromRemoteChannel verifies regular reminders work from any channel func TestCronTool_NonCommandJobAllowedFromRemoteChannel(t *testing.T) { tool := newTestCronTool(t) ctx := WithToolContext(context.Background(), "telegram", "chat-1") result := tool.Execute(ctx, map[string]any{ "action": "add", "message": "time to stretch", "at_seconds": float64(600), }) if result.IsError { t.Fatalf("expected non-command reminder to succeed from remote channel, got: %s", result.ForLLM) } } func TestCronTool_NonCommandJobDefaultsDeliverToFalse(t *testing.T) { tool := newTestCronTool(t) ctx := WithToolContext(context.Background(), "telegram", "chat-1") result := tool.Execute(ctx, map[string]any{ "action": "add", "message": "send me a poem", "at_seconds": float64(600), }) if result.IsError { t.Fatalf("expected non-command reminder to succeed, got: %s", result.ForLLM) } jobs := tool.cronService.ListJobs(false) if len(jobs) != 1 { t.Fatalf("expected 1 job, got %d", len(jobs)) } if jobs[0].Payload.Deliver { t.Fatal("expected deliver=false by default for non-command jobs") } } func TestCronTool_ExecuteJobPublishesErrorWhenExecDisabled(t *testing.T) { cfg := config.DefaultConfig() cfg.Tools.Exec.Enabled = false tool := newTestCronToolWithConfig(t, cfg) job := &cron.CronJob{} job.Payload.Channel = "cli" job.Payload.To = "direct" job.Payload.Command = "df -h" if got := tool.ExecuteJob(context.Background(), job); got != "ok" { t.Fatalf("ExecuteJob() = %q, want ok", got) } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() var msg bus.OutboundMessage select { case msg = <-tool.msgBus.OutboundChan(): // got message case <-ctx.Done(): t.Fatal("timeout waiting for outbound message") } if !strings.Contains(msg.Content, "command execution is disabled") { t.Fatalf("expected exec disabled message, got: %s", msg.Content) } } ================================================ FILE: pkg/tools/edit.go ================================================ package tools import ( "context" "errors" "fmt" "io/fs" "regexp" "strings" ) // EditFileTool edits a file by replacing old_text with new_text. // The old_text must exist exactly in the file. type EditFileTool struct { fs fileSystem } // NewEditFileTool creates a new EditFileTool with optional directory restriction. func NewEditFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *EditFileTool { var patterns []*regexp.Regexp if len(allowPaths) > 0 { patterns = allowPaths[0] } return &EditFileTool{fs: buildFs(workspace, restrict, patterns)} } func (t *EditFileTool) Name() string { return "edit_file" } func (t *EditFileTool) Description() string { return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file." } func (t *EditFileTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "path": map[string]any{ "type": "string", "description": "The file path to edit", }, "old_text": map[string]any{ "type": "string", "description": "The exact text to find and replace", }, "new_text": map[string]any{ "type": "string", "description": "The text to replace with", }, }, "required": []string{"path", "old_text", "new_text"}, } } func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, ok := args["path"].(string) if !ok { return ErrorResult("path is required") } oldText, ok := args["old_text"].(string) if !ok { return ErrorResult("old_text is required") } newText, ok := args["new_text"].(string) if !ok { return ErrorResult("new_text is required") } if err := editFile(t.fs, path, oldText, newText); err != nil { return ErrorResult(err.Error()) } return SilentResult(fmt.Sprintf("File edited: %s", path)) } type AppendFileTool struct { fs fileSystem } func NewAppendFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *AppendFileTool { var patterns []*regexp.Regexp if len(allowPaths) > 0 { patterns = allowPaths[0] } return &AppendFileTool{fs: buildFs(workspace, restrict, patterns)} } func (t *AppendFileTool) Name() string { return "append_file" } func (t *AppendFileTool) Description() string { return "Append content to the end of a file" } func (t *AppendFileTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "path": map[string]any{ "type": "string", "description": "The file path to append to", }, "content": map[string]any{ "type": "string", "description": "The content to append", }, }, "required": []string{"path", "content"}, } } func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, ok := args["path"].(string) if !ok { return ErrorResult("path is required") } content, ok := args["content"].(string) if !ok { return ErrorResult("content is required") } if err := appendFile(t.fs, path, content); err != nil { return ErrorResult(err.Error()) } return SilentResult(fmt.Sprintf("Appended to %s", path)) } // editFile reads the file via sysFs, performs the replacement, and writes back. // It uses a fileSystem interface, allowing the same logic for both restricted and unrestricted modes. func editFile(sysFs fileSystem, path, oldText, newText string) error { content, err := sysFs.ReadFile(path) if err != nil { return err } newContent, err := replaceEditContent(content, oldText, newText) if err != nil { return err } return sysFs.WriteFile(path, newContent) } // appendFile reads the existing content (if any) via sysFs, appends new content, and writes back. func appendFile(sysFs fileSystem, path, appendContent string) error { content, err := sysFs.ReadFile(path) if err != nil && !errors.Is(err, fs.ErrNotExist) { return err } newContent := append(content, []byte(appendContent)...) return sysFs.WriteFile(path, newContent) } // replaceEditContent handles the core logic of finding and replacing a single occurrence of oldText. func replaceEditContent(content []byte, oldText, newText string) ([]byte, error) { contentStr := string(content) if !strings.Contains(contentStr, oldText) { return nil, fmt.Errorf("old_text not found in file. Make sure it matches exactly") } count := strings.Count(contentStr, oldText) if count > 1 { return nil, fmt.Errorf("old_text appears %d times. Please provide more context to make it unique", count) } newContent := strings.Replace(contentStr, oldText, newText, 1) return []byte(newContent), nil } ================================================ FILE: pkg/tools/edit_test.go ================================================ package tools import ( "context" "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/assert" ) // TestEditTool_EditFile_Success verifies successful file editing func TestEditTool_EditFile_Success(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test.txt") os.WriteFile(testFile, []byte("Hello World\nThis is a test"), 0o644) tool := NewEditFileTool(tmpDir, true) ctx := context.Background() args := map[string]any{ "path": testFile, "old_text": "World", "new_text": "Universe", } result := tool.Execute(ctx, args) // Success should not be an error if result.IsError { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } // Should return SilentResult if !result.Silent { t.Errorf("Expected Silent=true for EditFile, got false") } // ForUser should be empty (silent result) if result.ForUser != "" { t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser) } // Verify file was actually edited content, err := os.ReadFile(testFile) if err != nil { t.Fatalf("Failed to read edited file: %v", err) } contentStr := string(content) if !strings.Contains(contentStr, "Hello Universe") { t.Errorf("Expected file to contain 'Hello Universe', got: %s", contentStr) } if strings.Contains(contentStr, "Hello World") { t.Errorf("Expected 'Hello World' to be replaced, got: %s", contentStr) } } // TestEditTool_EditFile_NotFound verifies error handling for non-existent file func TestEditTool_EditFile_NotFound(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "nonexistent.txt") tool := NewEditFileTool(tmpDir, true) ctx := context.Background() args := map[string]any{ "path": testFile, "old_text": "old", "new_text": "new", } result := tool.Execute(ctx, args) // Should return error result if !result.IsError { t.Errorf("Expected error for non-existent file") } // Should mention file not found if !strings.Contains(result.ForLLM, "not found") && !strings.Contains(result.ForUser, "not found") { t.Errorf("Expected 'file not found' message, got ForLLM: %s", result.ForLLM) } } // TestEditTool_EditFile_OldTextNotFound verifies error when old_text doesn't exist func TestEditTool_EditFile_OldTextNotFound(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test.txt") os.WriteFile(testFile, []byte("Hello World"), 0o644) tool := NewEditFileTool(tmpDir, true) ctx := context.Background() args := map[string]any{ "path": testFile, "old_text": "Goodbye", "new_text": "Hello", } result := tool.Execute(ctx, args) // Should return error result if !result.IsError { t.Errorf("Expected error when old_text not found") } // Should mention old_text not found if !strings.Contains(result.ForLLM, "not found") && !strings.Contains(result.ForUser, "not found") { t.Errorf("Expected 'not found' message, got ForLLM: %s", result.ForLLM) } } // TestEditTool_EditFile_MultipleMatches verifies error when old_text appears multiple times func TestEditTool_EditFile_MultipleMatches(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test.txt") os.WriteFile(testFile, []byte("test test test"), 0o644) tool := NewEditFileTool(tmpDir, true) ctx := context.Background() args := map[string]any{ "path": testFile, "old_text": "test", "new_text": "done", } result := tool.Execute(ctx, args) // Should return error result if !result.IsError { t.Errorf("Expected error when old_text appears multiple times") } // Should mention multiple occurrences if !strings.Contains(result.ForLLM, "times") && !strings.Contains(result.ForUser, "times") { t.Errorf("Expected 'multiple times' message, got ForLLM: %s", result.ForLLM) } } // TestEditTool_EditFile_OutsideAllowedDir verifies error when path is outside allowed directory func TestEditTool_EditFile_OutsideAllowedDir(t *testing.T) { tmpDir := t.TempDir() otherDir := t.TempDir() testFile := filepath.Join(otherDir, "test.txt") os.WriteFile(testFile, []byte("content"), 0o644) tool := NewEditFileTool(tmpDir, true) // Restrict to tmpDir ctx := context.Background() args := map[string]any{ "path": testFile, "old_text": "content", "new_text": "new", } result := tool.Execute(ctx, args) // Should return error result assert.True(t, result.IsError, "Expected error when path is outside allowed directory") // Should mention outside allowed directory // Note: ErrorResult only sets ForLLM by default, so ForUser might be empty. // We check ForLLM as it's the primary error channel. assert.True( t, strings.Contains(result.ForLLM, "outside") || strings.Contains(result.ForLLM, "access denied") || strings.Contains(result.ForLLM, "escapes"), "Expected 'outside allowed' or 'access denied' message, got ForLLM: %s", result.ForLLM, ) } // TestEditTool_EditFile_MissingPath verifies error handling for missing path func TestEditTool_EditFile_MissingPath(t *testing.T) { tool := NewEditFileTool("", false) ctx := context.Background() args := map[string]any{ "old_text": "old", "new_text": "new", } result := tool.Execute(ctx, args) // Should return error result if !result.IsError { t.Errorf("Expected error when path is missing") } } // TestEditTool_EditFile_MissingOldText verifies error handling for missing old_text func TestEditTool_EditFile_MissingOldText(t *testing.T) { tool := NewEditFileTool("", false) ctx := context.Background() args := map[string]any{ "path": "/tmp/test.txt", "new_text": "new", } result := tool.Execute(ctx, args) // Should return error result if !result.IsError { t.Errorf("Expected error when old_text is missing") } } // TestEditTool_EditFile_MissingNewText verifies error handling for missing new_text func TestEditTool_EditFile_MissingNewText(t *testing.T) { tool := NewEditFileTool("", false) ctx := context.Background() args := map[string]any{ "path": "/tmp/test.txt", "old_text": "old", } result := tool.Execute(ctx, args) // Should return error result if !result.IsError { t.Errorf("Expected error when new_text is missing") } } // TestEditTool_AppendFile_Success verifies successful file appending func TestEditTool_AppendFile_Success(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test.txt") os.WriteFile(testFile, []byte("Initial content"), 0o644) tool := NewAppendFileTool("", false) ctx := context.Background() args := map[string]any{ "path": testFile, "content": "\nAppended content", } result := tool.Execute(ctx, args) // Success should not be an error if result.IsError { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } // Should return SilentResult if !result.Silent { t.Errorf("Expected Silent=true for AppendFile, got false") } // ForUser should be empty (silent result) if result.ForUser != "" { t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser) } // Verify content was actually appended content, err := os.ReadFile(testFile) if err != nil { t.Fatalf("Failed to read file: %v", err) } contentStr := string(content) if !strings.Contains(contentStr, "Initial content") { t.Errorf("Expected original content to remain, got: %s", contentStr) } if !strings.Contains(contentStr, "Appended content") { t.Errorf("Expected appended content, got: %s", contentStr) } } // TestEditTool_AppendFile_MissingPath verifies error handling for missing path func TestEditTool_AppendFile_MissingPath(t *testing.T) { tool := NewAppendFileTool("", false) ctx := context.Background() args := map[string]any{ "content": "test", } result := tool.Execute(ctx, args) // Should return error result if !result.IsError { t.Errorf("Expected error when path is missing") } } // TestEditTool_AppendFile_MissingContent verifies error handling for missing content func TestEditTool_AppendFile_MissingContent(t *testing.T) { tool := NewAppendFileTool("", false) ctx := context.Background() args := map[string]any{ "path": "/tmp/test.txt", } result := tool.Execute(ctx, args) // Should return error result if !result.IsError { t.Errorf("Expected error when content is missing") } } // TestReplaceEditContent verifies the helper function replaceEditContent func TestReplaceEditContent(t *testing.T) { tests := []struct { name string content []byte oldText string newText string expected []byte expectError bool }{ { name: "successful replacement", content: []byte("hello world"), oldText: "world", newText: "universe", expected: []byte("hello universe"), expectError: false, }, { name: "old text not found", content: []byte("hello world"), oldText: "golang", newText: "rust", expected: nil, expectError: true, }, { name: "multiple matches found", content: []byte("test text test"), oldText: "test", newText: "done", expected: nil, expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := replaceEditContent(tt.content, tt.oldText, tt.newText) if tt.expectError { assert.Error(t, err) } else { assert.NoError(t, err) assert.Equal(t, tt.expected, result) } }) } } // TestAppendFileTool_AppendToNonExistent_Restricted verifies that AppendFileTool in restricted mode // can append to a file that does not yet exist — it should silently create the file. // This exercises the errors.Is(err, fs.ErrNotExist) path in appendFileWithRW + rootRW. func TestAppendFileTool_AppendToNonExistent_Restricted(t *testing.T) { workspace := t.TempDir() tool := NewAppendFileTool(workspace, true) ctx := context.Background() args := map[string]any{ "path": "brand_new_file.txt", "content": "first content", } result := tool.Execute(ctx, args) assert.False( t, result.IsError, "Expected success when appending to non-existent file in restricted mode, got: %s", result.ForLLM, ) // Verify the file was created with correct content data, err := os.ReadFile(filepath.Join(workspace, "brand_new_file.txt")) assert.NoError(t, err) assert.Equal(t, "first content", string(data)) } // TestAppendFileTool_Restricted_Success verifies that AppendFileTool in restricted mode // correctly appends to an existing file within the sandbox. func TestAppendFileTool_Restricted_Success(t *testing.T) { workspace := t.TempDir() testFile := "existing.txt" err := os.WriteFile(filepath.Join(workspace, testFile), []byte("initial"), 0o644) assert.NoError(t, err) tool := NewAppendFileTool(workspace, true) ctx := context.Background() args := map[string]any{ "path": testFile, "content": " appended", } result := tool.Execute(ctx, args) assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM) assert.True(t, result.Silent) data, err := os.ReadFile(filepath.Join(workspace, testFile)) assert.NoError(t, err) assert.Equal(t, "initial appended", string(data)) } // TestEditFileTool_Restricted_InPlaceEdit verifies that EditFileTool in restricted mode // correctly edits a file using the single-open editFileInRoot path. func TestEditFileTool_Restricted_InPlaceEdit(t *testing.T) { workspace := t.TempDir() testFile := "edit_target.txt" err := os.WriteFile(filepath.Join(workspace, testFile), []byte("Hello World"), 0o644) assert.NoError(t, err) tool := NewEditFileTool(workspace, true) ctx := context.Background() args := map[string]any{ "path": testFile, "old_text": "World", "new_text": "Go", } result := tool.Execute(ctx, args) assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM) assert.True(t, result.Silent) data, err := os.ReadFile(filepath.Join(workspace, testFile)) assert.NoError(t, err) assert.Equal(t, "Hello Go", string(data)) } // TestEditFileTool_Restricted_FileNotFound verifies that editFileInRoot returns a proper // error message when the target file does not exist. func TestEditFileTool_Restricted_FileNotFound(t *testing.T) { workspace := t.TempDir() tool := NewEditFileTool(workspace, true) ctx := context.Background() args := map[string]any{ "path": "no_such_file.txt", "old_text": "old", "new_text": "new", } result := tool.Execute(ctx, args) assert.True(t, result.IsError) assert.Contains(t, result.ForLLM, "not found") } ================================================ FILE: pkg/tools/filesystem.go ================================================ package tools import ( "context" "errors" "fmt" "io" "io/fs" "math" "os" "path/filepath" "regexp" "strconv" "strings" "time" "github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/logger" ) const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow func validatePathWithAllowPaths(path, workspace string, restrict bool, patterns []*regexp.Regexp) (string, error) { if workspace == "" { return path, fmt.Errorf("workspace is not defined") } absWorkspace, err := filepath.Abs(workspace) if err != nil { return "", fmt.Errorf("failed to resolve workspace path: %w", err) } var absPath string if filepath.IsAbs(path) { absPath = filepath.Clean(path) } else { absPath, err = filepath.Abs(filepath.Join(absWorkspace, path)) if err != nil { return "", fmt.Errorf("failed to resolve file path: %w", err) } } if restrict { if isAllowedPath(absPath, patterns) { return absPath, nil } if !isWithinWorkspace(absPath, absWorkspace) { return "", fmt.Errorf("access denied: path is outside the workspace") } var resolved string workspaceReal := absWorkspace if resolved, err = filepath.EvalSymlinks(absWorkspace); err == nil { workspaceReal = resolved } if resolved, err = filepath.EvalSymlinks(absPath); err == nil { if !isWithinWorkspace(resolved, workspaceReal) { return "", fmt.Errorf("access denied: symlink resolves outside workspace") } } else if os.IsNotExist(err) { var parentResolved string if parentResolved, err = resolveExistingAncestor(filepath.Dir(absPath)); err == nil { if !isWithinWorkspace(parentResolved, workspaceReal) { return "", fmt.Errorf("access denied: symlink resolves outside workspace") } } else if !os.IsNotExist(err) { return "", fmt.Errorf("failed to resolve path: %w", err) } } else { return "", fmt.Errorf("failed to resolve path: %w", err) } } return absPath, nil } func isAllowedPath(path string, patterns []*regexp.Regexp) bool { if len(patterns) == 0 { return false } cleaned := filepath.Clean(path) if !filepath.IsAbs(cleaned) { return false } if !matchesAllowedPath(cleaned, patterns) { return false } resolved, err := resolvePathAgainstExistingAncestor(cleaned) if err != nil { return false } return matchesAllowedPath(resolved, patterns) } func matchesAllowedPath(path string, patterns []*regexp.Regexp) bool { cleaned := filepath.Clean(path) for _, pattern := range patterns { if pattern.MatchString(cleaned) { return true } if root, ok := extractAllowedPathRoot(pattern); ok && isWithinAllowedRoot(cleaned, root) { return true } } return false } func extractAllowedPathRoot(pattern *regexp.Regexp) (string, bool) { raw := pattern.String() if !strings.HasPrefix(raw, "^") { return "", false } literal := strings.TrimPrefix(raw, "^") // Recognize the common "directory prefix" form: ^<literal>(?:/|$) literal = strings.TrimSuffix(literal, "(?:/|$)") literal = strings.TrimSuffix(literal, `(?:\\|$)`) // Reject patterns that still contain regex operators after removing the // optional anchored-directory suffix. That keeps arbitrary regex behavior // unchanged and only enables normalized prefix matching for literal paths. if containsUnescapedRegexMeta(literal) { return "", false } unescaped, ok := unescapeRegexLiteral(literal) if !ok || unescaped == "" { return "", false } return filepath.Clean(unescaped), filepath.IsAbs(unescaped) } func appendUniquePath(paths []string, path string) []string { for _, existing := range paths { if existing == path { return paths } } return append(paths, path) } func containsUnescapedRegexMeta(s string) bool { escaped := false for _, r := range s { if escaped { escaped = false continue } if r == '\\' { escaped = true continue } switch r { case '.', '+', '*', '?', '(', ')', '[', ']', '{', '}', '|': return true } } return escaped } func unescapeRegexLiteral(s string) (string, bool) { var b strings.Builder b.Grow(len(s)) escaped := false for _, r := range s { if escaped { b.WriteRune(r) escaped = false continue } if r == '\\' { escaped = true continue } b.WriteRune(r) } if escaped { return "", false } return b.String(), true } func isWithinAllowedRoot(path, root string) bool { candidate := filepath.Clean(path) allowedVariants := []string{filepath.Clean(root)} if resolvedRoot, err := resolvePathAgainstExistingAncestor(root); err == nil { allowedVariants = appendUniquePath(allowedVariants, filepath.Clean(resolvedRoot)) } for _, allowedRoot := range allowedVariants { if isWithinWorkspace(candidate, allowedRoot) { return true } } return false } func resolveExistingAncestor(path string) (string, error) { for current := filepath.Clean(path); ; current = filepath.Dir(current) { if resolved, err := filepath.EvalSymlinks(current); err == nil { return resolved, nil } else if !os.IsNotExist(err) { return "", err } if filepath.Dir(current) == current { return "", os.ErrNotExist } } } func resolvePathAgainstExistingAncestor(path string) (string, error) { cleaned := filepath.Clean(path) for current := cleaned; ; current = filepath.Dir(current) { resolved, err := filepath.EvalSymlinks(current) if err == nil { suffix, relErr := filepath.Rel(current, cleaned) if relErr != nil { return "", relErr } if suffix == "." { return filepath.Clean(resolved), nil } return filepath.Clean(filepath.Join(resolved, suffix)), nil } if !os.IsNotExist(err) { return "", err } if filepath.Dir(current) == current { return "", os.ErrNotExist } } } func isWithinWorkspace(candidate, workspace string) bool { rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate)) return err == nil && (rel == "." || filepath.IsLocal(rel)) } type ReadFileTool struct { fs fileSystem maxSize int64 } func NewReadFileTool( workspace string, restrict bool, maxReadFileSize int, allowPaths ...[]*regexp.Regexp, ) *ReadFileTool { var patterns []*regexp.Regexp if len(allowPaths) > 0 { patterns = allowPaths[0] } maxSize := int64(maxReadFileSize) if maxSize <= 0 { maxSize = MaxReadFileSize } return &ReadFileTool{ fs: buildFs(workspace, restrict, patterns), maxSize: maxSize, } } func (t *ReadFileTool) Name() string { return "read_file" } func (t *ReadFileTool) Description() string { return "Read the contents of a file. Supports pagination via `offset` and `length`." } func (t *ReadFileTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "path": map[string]any{ "type": "string", "description": "Path to the file to read.", }, "offset": map[string]any{ "type": "integer", "description": "Byte offset to start reading from.", "default": 0, }, "length": map[string]any{ "type": "integer", "description": "Maximum number of bytes to read.", "default": t.maxSize, }, }, "required": []string{"path"}, } } func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, ok := args["path"].(string) if !ok { return ErrorResult("path is required") } // offset (optional, default 0) offset, err := getInt64Arg(args, "offset", 0) if err != nil { return ErrorResult(err.Error()) } if offset < 0 { return ErrorResult("offset must be >= 0") } // length (optional, capped at MaxReadFileSize) length, err := getInt64Arg(args, "length", t.maxSize) if err != nil { return ErrorResult(err.Error()) } if length <= 0 { return ErrorResult("length must be > 0") } if length > t.maxSize { length = t.maxSize } file, err := t.fs.Open(path) if err != nil { return ErrorResult(err.Error()) } defer file.Close() // measure total size totalSize := int64(-1) // -1 means unknown if info, statErr := file.Stat(); statErr == nil { totalSize = info.Size() } // sniff the first 512 bytes to detect binary content before loading // it into the LLM context. Seeking back to 0 afterwards restores state. sniff := make([]byte, 512) sniffN, _ := file.Read(sniff) // Reset read position to beginning before applying the caller's offset. if seeker, ok := file.(io.Seeker); ok { _, err = seeker.Seek(0, io.SeekStart) if err != nil { return ErrorResult(fmt.Sprintf("failed to reset file position after sniff: %v", err)) } } else { // Non-seekable: we consumed sniffN bytes above; account for them when // discarding to reach the requested offset below. // If offset < sniffN the data we already read covers it, which we // cannot replay on a non-seekable stream — return a clear error. if offset < int64(sniffN) && offset > 0 { return ErrorResult( "non-seekable file: cannot seek to an offset within the first 512 bytes after binary detection", ) } } // Seek to the requested offset. if seeker, ok := file.(io.Seeker); ok { _, err = seeker.Seek(offset, io.SeekStart) if err != nil { return ErrorResult(fmt.Sprintf("failed to seek to offset %d: %v", offset, err)) } } else if offset > 0 { // Fallback for non-seekable streams: discard leading bytes. // sniffN bytes were already consumed above, so subtract them. remaining := offset - int64(sniffN) if remaining > 0 { _, err = io.CopyN(io.Discard, file, remaining) if err != nil { return ErrorResult(fmt.Sprintf("failed to advance to offset %d: %v", offset, err)) } } } // read length+1 bytes to reliably detect whether more content exists // without relying on totalSize (which may be -1 for non-seekable streams). // This avoids the false-positive TRUNCATED message on the last page. probe := make([]byte, length+1) n, err := io.ReadFull(file, probe) // FIX: io.ReadFull returns io.ErrUnexpectedEOF for partial reads (0 < n < len), // and io.EOF only when n == 0. Both are normal terminal conditions — only // other errors are genuine failures. if err != nil && err != io.EOF && !errors.Is(err, io.ErrUnexpectedEOF) { return ErrorResult(fmt.Sprintf("failed to read file content: %v", err)) } // hasMore is true only when we actually got the extra probe byte. hasMore := int64(n) > length data := probe[:min(int64(n), length)] if len(data) == 0 { return NewToolResult("[END OF FILE - no content at this offset]") } // Build metadata header. // use filepath.Base(path) instead of the raw path to avoid leaking // internal filesystem structure into the LLM context. readEnd := offset + int64(len(data)) // use ASCII hyphen-minus instead of en-dash (U+2013) to keep the // header parseable by downstream tools and log processors. readRange := fmt.Sprintf("bytes %d-%d", offset, readEnd-1) displayPath := filepath.Base(path) var header string if totalSize >= 0 { header = fmt.Sprintf( "[file: %s | total: %d bytes | read: %s]", displayPath, totalSize, readRange, ) } else { header = fmt.Sprintf( "[file: %s | read: %s | total size unknown]", displayPath, readRange, ) } if hasMore { header += fmt.Sprintf( "\n[TRUNCATED - file has more content. Call read_file again with offset=%d to continue.]", readEnd, ) } else { header += "\n[END OF FILE - no further content.]" } logger.DebugCF("tool", "ReadFileTool execution completed successfully", map[string]any{ "path": path, "bytes_read": len(data), "has_more": hasMore, }) return NewToolResult(header + "\n\n" + string(data)) } // getInt64Arg extracts an integer argument from the args map, returning the // provided default if the key is absent. func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, error) { raw, exists := args[key] if !exists { return defaultVal, nil } switch v := raw.(type) { case float64: if v != math.Trunc(v) { return 0, fmt.Errorf("%s must be an integer, got float %v", key, v) } if v > math.MaxInt64 || v < math.MinInt64 { return 0, fmt.Errorf("%s value %v overflows int64", key, v) } return int64(v), nil case int: return int64(v), nil case int64: return v, nil case string: parsed, err := strconv.ParseInt(v, 10, 64) if err != nil { return 0, fmt.Errorf("invalid integer format for %s parameter: %w", key, err) } return parsed, nil default: return 0, fmt.Errorf("unsupported type %T for %s parameter", raw, key) } } type WriteFileTool struct { fs fileSystem } func NewWriteFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *WriteFileTool { var patterns []*regexp.Regexp if len(allowPaths) > 0 { patterns = allowPaths[0] } return &WriteFileTool{fs: buildFs(workspace, restrict, patterns)} } func (t *WriteFileTool) Name() string { return "write_file" } func (t *WriteFileTool) Description() string { return "Write content to a file. If the file already exists, you must set overwrite=true to replace it." } func (t *WriteFileTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "path": map[string]any{ "type": "string", "description": "Path to the file to write", }, "content": map[string]any{ "type": "string", "description": "Content to write to the file", }, "overwrite": map[string]any{ "type": "boolean", "description": "Must be set to true to overwrite an existing file.", "default": false, }, }, "required": []string{"path", "content"}, } } func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, ok := args["path"].(string) if !ok { return ErrorResult("path is required") } content, ok := args["content"].(string) if !ok { return ErrorResult("content is required") } overwrite, _ := args["overwrite"].(bool) if !overwrite { if _, err := t.fs.Open(path); err == nil { return ErrorResult(fmt.Sprintf("file: %s already exists. Set overwrite=true to replace.", path)) } } if err := t.fs.WriteFile(path, []byte(content)); err != nil { return ErrorResult(err.Error()) } return SilentResult(fmt.Sprintf("File written: %s", path)) } type ListDirTool struct { fs fileSystem } func NewListDirTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *ListDirTool { var patterns []*regexp.Regexp if len(allowPaths) > 0 { patterns = allowPaths[0] } return &ListDirTool{fs: buildFs(workspace, restrict, patterns)} } func (t *ListDirTool) Name() string { return "list_dir" } func (t *ListDirTool) Description() string { return "List files and directories in a path" } func (t *ListDirTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "path": map[string]any{ "type": "string", "description": "Path to list", }, }, "required": []string{"path"}, } } func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, ok := args["path"].(string) if !ok { path = "." } entries, err := t.fs.ReadDir(path) if err != nil { return ErrorResult(fmt.Sprintf("failed to read directory: %v", err)) } return formatDirEntries(entries) } func formatDirEntries(entries []os.DirEntry) *ToolResult { var result strings.Builder for _, entry := range entries { if entry.IsDir() { result.WriteString("DIR: " + entry.Name() + "\n") } else { result.WriteString("FILE: " + entry.Name() + "\n") } } return NewToolResult(result.String()) } // fileSystem abstracts reading, writing, and listing files, allowing both // unrestricted (host filesystem) and sandbox (os.Root) implementations to share the same polymorphic interface. type fileSystem interface { ReadFile(path string) ([]byte, error) WriteFile(path string, data []byte) error ReadDir(path string) ([]os.DirEntry, error) Open(path string) (fs.File, error) } // hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem. type hostFs struct{} func (h *hostFs) ReadFile(path string) ([]byte, error) { content, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil, fmt.Errorf("failed to read file: file not found: %w", err) } if os.IsPermission(err) { return nil, fmt.Errorf("failed to read file: access denied: %w", err) } return nil, fmt.Errorf("failed to read file: %w", err) } return content, nil } func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) { return os.ReadDir(path) } func (h *hostFs) WriteFile(path string, data []byte) error { // Use unified atomic write utility with explicit sync for flash storage reliability. // Using 0o600 (owner read/write only) for secure default permissions. return fileutil.WriteFileAtomic(path, data, 0o600) } func (h *hostFs) Open(path string) (fs.File, error) { f, err := os.Open(path) if err != nil { if os.IsNotExist(err) { return nil, fmt.Errorf("failed to open file: file not found: %w", err) } if os.IsPermission(err) { return nil, fmt.Errorf("failed to open file: access denied: %w", err) } return nil, fmt.Errorf("failed to open file: %w", err) } return f, nil } // sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root. type sandboxFs struct { workspace string } func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string) error) error { if r.workspace == "" { return fmt.Errorf("workspace is not defined") } root, err := os.OpenRoot(r.workspace) if err != nil { return fmt.Errorf("failed to open workspace: %w", err) } defer root.Close() relPath, err := getSafeRelPath(r.workspace, path) if err != nil { return err } return fn(root, relPath) } func (r *sandboxFs) ReadFile(path string) ([]byte, error) { var content []byte err := r.execute(path, func(root *os.Root, relPath string) error { fileContent, err := root.ReadFile(relPath) if err != nil { if os.IsNotExist(err) { return fmt.Errorf("failed to read file: file not found: %w", err) } // os.Root returns "escapes from parent" for paths outside the root if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") || strings.Contains(err.Error(), "permission denied") { return fmt.Errorf("failed to read file: access denied: %w", err) } return fmt.Errorf("failed to read file: %w", err) } content = fileContent return nil }) return content, err } func (r *sandboxFs) WriteFile(path string, data []byte) error { return r.execute(path, func(root *os.Root, relPath string) error { dir := filepath.Dir(relPath) if dir != "." && dir != "/" { if err := root.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("failed to create parent directories: %w", err) } } // Use atomic write pattern with explicit sync for flash storage reliability. // Using 0o600 (owner read/write only) for secure default permissions. tmpRelPath := fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano()) tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { root.Remove(tmpRelPath) return fmt.Errorf("failed to open temp file: %w", err) } if _, err := tmpFile.Write(data); err != nil { tmpFile.Close() root.Remove(tmpRelPath) return fmt.Errorf("failed to write temp file: %w", err) } // CRITICAL: Force sync to storage medium before rename. // This ensures data is physically written to disk, not just cached. if err := tmpFile.Sync(); err != nil { tmpFile.Close() root.Remove(tmpRelPath) return fmt.Errorf("failed to sync temp file: %w", err) } if err := tmpFile.Close(); err != nil { root.Remove(tmpRelPath) return fmt.Errorf("failed to close temp file: %w", err) } if err := root.Rename(tmpRelPath, relPath); err != nil { root.Remove(tmpRelPath) return fmt.Errorf("failed to rename temp file over target: %w", err) } // Sync directory to ensure rename is durable if dirFile, err := root.Open("."); err == nil { _ = dirFile.Sync() dirFile.Close() } return nil }) } func (r *sandboxFs) ReadDir(path string) ([]os.DirEntry, error) { var entries []os.DirEntry err := r.execute(path, func(root *os.Root, relPath string) error { dirEntries, err := fs.ReadDir(root.FS(), relPath) if err != nil { return err } entries = dirEntries return nil }) return entries, err } func (r *sandboxFs) Open(path string) (fs.File, error) { var f fs.File err := r.execute(path, func(root *os.Root, relPath string) error { file, err := root.Open(relPath) if err != nil { if os.IsNotExist(err) { return fmt.Errorf("failed to open file: file not found: %w", err) } if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") || strings.Contains(err.Error(), "permission denied") { return fmt.Errorf("failed to open file: access denied: %w", err) } return fmt.Errorf("failed to open file: %w", err) } f = file return nil }) return f, err } // whitelistFs wraps a sandboxFs and allows access to specific paths outside // the workspace when they match any of the provided patterns. type whitelistFs struct { sandbox *sandboxFs host hostFs patterns []*regexp.Regexp } func (w *whitelistFs) matches(path string) bool { return isAllowedPath(path, w.patterns) } func (w *whitelistFs) ReadFile(path string) ([]byte, error) { if w.matches(path) { return w.host.ReadFile(path) } return w.sandbox.ReadFile(path) } func (w *whitelistFs) WriteFile(path string, data []byte) error { if w.matches(path) { return w.host.WriteFile(path, data) } return w.sandbox.WriteFile(path, data) } func (w *whitelistFs) ReadDir(path string) ([]os.DirEntry, error) { if w.matches(path) { return w.host.ReadDir(path) } return w.sandbox.ReadDir(path) } func (w *whitelistFs) Open(path string) (fs.File, error) { if w.matches(path) { return w.host.Open(path) } return w.sandbox.Open(path) } // buildFs returns the appropriate fileSystem implementation based on restriction // settings and optional path whitelist patterns. func buildFs(workspace string, restrict bool, patterns []*regexp.Regexp) fileSystem { if !restrict { return &hostFs{} } sandbox := &sandboxFs{workspace: workspace} if len(patterns) > 0 { return &whitelistFs{sandbox: sandbox, patterns: patterns} } return sandbox } // Helper to get a safe relative path for os.Root usage func getSafeRelPath(workspace, path string) (string, error) { if workspace == "" { return "", fmt.Errorf("workspace is not defined") } rel := filepath.Clean(path) if filepath.IsAbs(rel) { var err error rel, err = filepath.Rel(workspace, rel) if err != nil { return "", fmt.Errorf("failed to calculate relative path: %w", err) } } if !filepath.IsLocal(rel) { return "", fmt.Errorf("path escapes workspace: %s", path) } return rel, nil } ================================================ FILE: pkg/tools/filesystem_test.go ================================================ package tools import ( "context" "io" "os" "path/filepath" "regexp" "strings" "testing" "github.com/stretchr/testify/assert" ) // TestFilesystemTool_ReadFile_Success verifies successful file reading func TestFilesystemTool_ReadFile_Success(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test.txt") os.WriteFile(testFile, []byte("test content"), 0o644) tool := NewReadFileTool("", false, MaxReadFileSize) ctx := context.Background() args := map[string]any{ "path": testFile, } result := tool.Execute(ctx, args) // Success should not be an error if result.IsError { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } // ForLLM should contain file content if !strings.Contains(result.ForLLM, "test content") { t.Errorf("Expected ForLLM to contain 'test content', got: %s", result.ForLLM) } // ReadFile returns NewToolResult which only sets ForLLM, not ForUser // This is the expected behavior - file content goes to LLM, not directly to user if result.ForUser != "" { t.Errorf("Expected ForUser to be empty for NewToolResult, got: %s", result.ForUser) } } // TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file func TestFilesystemTool_ReadFile_NotFound(t *testing.T) { tool := NewReadFileTool("", false, MaxReadFileSize) ctx := context.Background() args := map[string]any{ "path": "/nonexistent_file_12345.txt", } result := tool.Execute(ctx, args) // Failure should be marked as error if !result.IsError { t.Errorf("Expected error for missing file, got IsError=false") } // Should contain error message if !strings.Contains(result.ForLLM, "failed to open file") && !strings.Contains(result.ForUser, "failed to read") { t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) } } // TestFilesystemTool_ReadFile_MissingPath verifies error handling for missing path func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) { tool := &ReadFileTool{} ctx := context.Background() args := map[string]any{} result := tool.Execute(ctx, args) // Should return error result if !result.IsError { t.Errorf("Expected error when path is missing") } // Should mention required parameter if !strings.Contains(result.ForLLM, "path is required") && !strings.Contains(result.ForUser, "path is required") { t.Errorf("Expected 'path is required' message, got ForLLM: %s", result.ForLLM) } } // TestFilesystemTool_WriteFile_Success verifies successful file writing func TestFilesystemTool_WriteFile_Success(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "newfile.txt") tool := NewWriteFileTool("", false) ctx := context.Background() args := map[string]any{ "path": testFile, "content": "hello world", } result := tool.Execute(ctx, args) // Success should not be an error if result.IsError { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } // WriteFile returns SilentResult if !result.Silent { t.Errorf("Expected Silent=true for WriteFile, got false") } // ForUser should be empty (silent result) if result.ForUser != "" { t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser) } // Verify file was actually written content, err := os.ReadFile(testFile) if err != nil { t.Fatalf("Failed to read written file: %v", err) } if string(content) != "hello world" { t.Errorf("Expected file content 'hello world', got: %s", string(content)) } } // TestFilesystemTool_WriteFile_CreateDir verifies directory creation func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "subdir", "newfile.txt") tool := NewWriteFileTool("", false) ctx := context.Background() args := map[string]any{ "path": testFile, "content": "test", } result := tool.Execute(ctx, args) // Success should not be an error if result.IsError { t.Errorf("Expected success with directory creation, got IsError=true: %s", result.ForLLM) } // Verify directory was created and file written content, err := os.ReadFile(testFile) if err != nil { t.Fatalf("Failed to read written file: %v", err) } if string(content) != "test" { t.Errorf("Expected file content 'test', got: %s", string(content)) } } // TestFilesystemTool_WriteFile_MissingPath verifies error handling for missing path func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) { tool := NewWriteFileTool("", false) ctx := context.Background() args := map[string]any{ "content": "test", } result := tool.Execute(ctx, args) // Should return error result if !result.IsError { t.Errorf("Expected error when path is missing") } } // TestFilesystemTool_WriteFile_MissingContent verifies error handling for missing content func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) { tool := NewWriteFileTool("", false) ctx := context.Background() args := map[string]any{ "path": "/tmp/test.txt", } result := tool.Execute(ctx, args) // Should return error result if !result.IsError { t.Errorf("Expected error when content is missing") } // Should mention required parameter if !strings.Contains(result.ForLLM, "content is required") && !strings.Contains(result.ForUser, "content is required") { t.Errorf("Expected 'content is required' message, got ForLLM: %s", result.ForLLM) } } // TestFilesystemTool_WriteFile_OverwriteDefaultBlocked verifies that writing to an // existing file without overwrite=true returns an error. func TestFilesystemTool_WriteFile_OverwriteDefaultBlocked(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "existing.txt") os.WriteFile(testFile, []byte("original"), 0o644) tool := NewWriteFileTool("", false) result := tool.Execute(context.Background(), map[string]any{ "path": testFile, "content": "new content", }) assert.True(t, result.IsError, "expected error when overwriting without overwrite=true") assert.Contains(t, result.ForLLM, "already exists") assert.Contains(t, result.ForLLM, "overwrite=true") // Original content must be untouched data, err := os.ReadFile(testFile) assert.NoError(t, err) assert.Equal(t, "original", string(data)) } // TestFilesystemTool_WriteFile_OverwriteExplicitAllowed verifies that setting // overwrite=true replaces the existing file. func TestFilesystemTool_WriteFile_OverwriteExplicitAllowed(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "existing.txt") os.WriteFile(testFile, []byte("original"), 0o644) tool := NewWriteFileTool("", false) result := tool.Execute(context.Background(), map[string]any{ "path": testFile, "content": "replaced", "overwrite": true, }) assert.False(t, result.IsError, "expected success with overwrite=true, got: %s", result.ForLLM) data, err := os.ReadFile(testFile) assert.NoError(t, err) assert.Equal(t, "replaced", string(data)) } // TestFilesystemTool_WriteFile_NewFileNoOverwriteFlag verifies that a new (non-existing) // file can be written without setting overwrite=true. func TestFilesystemTool_WriteFile_NewFileNoOverwriteFlag(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "newfile.txt") tool := NewWriteFileTool("", false) result := tool.Execute(context.Background(), map[string]any{ "path": testFile, "content": "brand new", }) assert.False(t, result.IsError, "expected success for new file, got: %s", result.ForLLM) data, err := os.ReadFile(testFile) assert.NoError(t, err) assert.Equal(t, "brand new", string(data)) } // TestFilesystemTool_WriteFile_OverwriteFalseExplicitBlocked verifies that // explicitly passing overwrite=false also blocks overwriting. func TestFilesystemTool_WriteFile_OverwriteFalseExplicitBlocked(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "existing.txt") os.WriteFile(testFile, []byte("original"), 0o644) tool := NewWriteFileTool("", false) result := tool.Execute(context.Background(), map[string]any{ "path": testFile, "content": "new content", "overwrite": false, }) assert.True(t, result.IsError, "expected error when overwrite=false") assert.Contains(t, result.ForLLM, "already exists") data, err := os.ReadFile(testFile) assert.NoError(t, err) assert.Equal(t, "original", string(data)) } // TestFilesystemTool_WriteFile_OverwriteSandboxed verifies the overwrite guard // works correctly in restricted (sandbox) mode. func TestFilesystemTool_WriteFile_OverwriteSandboxed(t *testing.T) { workspace := t.TempDir() testFile := "file.txt" os.WriteFile(filepath.Join(workspace, testFile), []byte("original"), 0o644) tool := NewWriteFileTool(workspace, true) // Without overwrite=true → blocked result := tool.Execute(context.Background(), map[string]any{ "path": testFile, "content": "new content", }) assert.True(t, result.IsError, "expected error in sandbox mode without overwrite=true") assert.Contains(t, result.ForLLM, "already exists") // With overwrite=true → allowed result = tool.Execute(context.Background(), map[string]any{ "path": testFile, "content": "replaced in sandbox", "overwrite": true, }) assert.False(t, result.IsError, "expected success in sandbox mode with overwrite=true, got: %s", result.ForLLM) data, err := os.ReadFile(filepath.Join(workspace, testFile)) assert.NoError(t, err) assert.Equal(t, "replaced in sandbox", string(data)) } // TestFilesystemTool_ListDir_Success verifies successful directory listing func TestFilesystemTool_ListDir_Success(t *testing.T) { tmpDir := t.TempDir() os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("content"), 0o644) os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content"), 0o644) os.Mkdir(filepath.Join(tmpDir, "subdir"), 0o755) tool := NewListDirTool("", false) ctx := context.Background() args := map[string]any{ "path": tmpDir, } result := tool.Execute(ctx, args) // Success should not be an error if result.IsError { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } // Should list files and directories if !strings.Contains(result.ForLLM, "file1.txt") || !strings.Contains(result.ForLLM, "file2.txt") { t.Errorf("Expected files in listing, got: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "subdir") { t.Errorf("Expected subdir in listing, got: %s", result.ForLLM) } } // TestFilesystemTool_ListDir_NotFound verifies error handling for non-existent directory func TestFilesystemTool_ListDir_NotFound(t *testing.T) { tool := NewListDirTool("", false) ctx := context.Background() args := map[string]any{ "path": "/nonexistent_directory_12345", } result := tool.Execute(ctx, args) // Failure should be marked as error if !result.IsError { t.Errorf("Expected error for non-existent directory, got IsError=false") } // Should contain error message if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") { t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) } } // TestFilesystemTool_ListDir_DefaultPath verifies default to current directory func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) { tool := NewListDirTool("", false) ctx := context.Background() args := map[string]any{} result := tool.Execute(ctx, args) // Should use "." as default path if result.IsError { t.Errorf("Expected success with default path '.', got IsError=true: %s", result.ForLLM) } } // Block paths that look inside workspace but point outside via symlink. func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) { root := t.TempDir() workspace := filepath.Join(root, "workspace") if err := os.MkdirAll(workspace, 0o755); err != nil { t.Fatalf("failed to create workspace: %v", err) } secret := filepath.Join(root, "secret.txt") if err := os.WriteFile(secret, []byte("top secret"), 0o644); err != nil { t.Fatalf("failed to write secret file: %v", err) } link := filepath.Join(workspace, "leak.txt") if err := os.Symlink(secret, link); err != nil { t.Skipf("symlink not supported in this environment: %v", err) } tool := NewReadFileTool(workspace, true, MaxReadFileSize) result := tool.Execute(context.Background(), map[string]any{ "path": link, }) if !result.IsError { t.Fatalf("expected symlink escape to be blocked") } // os.Root might return different errors depending on platform/implementation // but it definitely should error. // Our wrapper returns "access denied or file not found" if !strings.Contains(result.ForLLM, "access denied") && !strings.Contains(result.ForLLM, "file not found") && !strings.Contains(result.ForLLM, "no such file") { t.Fatalf("expected symlink escape error, got: %s", result.ForLLM) } } func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) { tool := NewReadFileTool("", true, MaxReadFileSize) // restrict=true but workspace="" // Try to read a sensitive file (simulated by a temp file outside workspace) tmpDir := t.TempDir() secretFile := filepath.Join(tmpDir, "shadow") os.WriteFile(secretFile, []byte("secret data"), 0o600) result := tool.Execute(context.Background(), map[string]any{ "path": secretFile, }) // We EXPECT IsError=true (access blocked due to empty workspace) assert.True(t, result.IsError, "Security Regression: Empty workspace allowed access! content: %s", result.ForLLM) // Verify it failed for the right reason assert.Contains(t, result.ForLLM, "workspace is not defined", "Expected 'workspace is not defined' error") } // TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases: // single dir, deeply nested dirs, already-existing dirs, and a file blocking a directory path. func TestRootMkdirAll(t *testing.T) { workspace := t.TempDir() root, err := os.OpenRoot(workspace) if err != nil { t.Fatalf("failed to open root: %v", err) } defer root.Close() // Case 1: Single directory err = root.MkdirAll("dir1", 0o755) assert.NoError(t, err) _, err = os.Stat(filepath.Join(workspace, "dir1")) assert.NoError(t, err) // Case 2: Deeply nested directory err = root.MkdirAll("a/b/c/d", 0o755) assert.NoError(t, err) _, err = os.Stat(filepath.Join(workspace, "a/b/c/d")) assert.NoError(t, err) // Case 3: Already exists — must be idempotent err = root.MkdirAll("a/b/c/d", 0o755) assert.NoError(t, err) // Case 4: A regular file blocks directory creation — must error err = os.WriteFile(filepath.Join(workspace, "file_exists"), []byte("data"), 0o644) assert.NoError(t, err) err = root.MkdirAll("file_exists", 0o755) assert.Error(t, err, "expected error when a file exists at the directory path") } func TestFilesystemTool_WriteFile_Restricted_CreateDir(t *testing.T) { workspace := t.TempDir() tool := NewWriteFileTool(workspace, true) ctx := context.Background() testFile := "deep/nested/path/to/file.txt" content := "deep content" args := map[string]any{ "path": testFile, "content": content, } result := tool.Execute(ctx, args) assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM) // Verify file content actualPath := filepath.Join(workspace, testFile) data, err := os.ReadFile(actualPath) assert.NoError(t, err) assert.Equal(t, content, string(data)) } // TestHostRW_Read_PermissionDenied verifies that hostRW.Read surfaces access denied errors. func TestHostRW_Read_PermissionDenied(t *testing.T) { if os.Getuid() == 0 { t.Skip("skipping permission test: running as root") } tmpDir := t.TempDir() protected := filepath.Join(tmpDir, "protected.txt") err := os.WriteFile(protected, []byte("secret"), 0o000) assert.NoError(t, err) defer os.Chmod(protected, 0o644) // ensure cleanup _, err = (&hostFs{}).ReadFile(protected) assert.Error(t, err) assert.Contains(t, err.Error(), "access denied") } // TestHostRW_Read_Directory verifies that hostRW.Read returns an error when given a directory path. func TestHostRW_Read_Directory(t *testing.T) { tmpDir := t.TempDir() _, err := (&hostFs{}).ReadFile(tmpDir) assert.Error(t, err, "expected error when reading a directory as a file") } // TestRootRW_Read_Directory verifies that rootRW.Read returns an error when given a directory. func TestRootRW_Read_Directory(t *testing.T) { workspace := t.TempDir() root, err := os.OpenRoot(workspace) assert.NoError(t, err) defer root.Close() // Create a subdirectory err = root.Mkdir("subdir", 0o755) assert.NoError(t, err) _, err = (&sandboxFs{workspace: workspace}).ReadFile("subdir") assert.Error(t, err, "expected error when reading a directory as a file") } // TestHostRW_Write_ParentDirMissing verifies that hostRW.Write creates parent dirs automatically. func TestHostRW_Write_ParentDirMissing(t *testing.T) { tmpDir := t.TempDir() target := filepath.Join(tmpDir, "a", "b", "c", "file.txt") err := (&hostFs{}).WriteFile(target, []byte("hello")) assert.NoError(t, err) data, err := os.ReadFile(target) assert.NoError(t, err) assert.Equal(t, "hello", string(data)) } // TestRootRW_Write_ParentDirMissing verifies that rootRW.Write creates // nested parent directories automatically within the sandbox. func TestRootRW_Write_ParentDirMissing(t *testing.T) { workspace := t.TempDir() relPath := "x/y/z/file.txt" err := (&sandboxFs{workspace: workspace}).WriteFile(relPath, []byte("nested")) assert.NoError(t, err) data, err := os.ReadFile(filepath.Join(workspace, relPath)) assert.NoError(t, err) assert.Equal(t, "nested", string(data)) } // TestHostRW_Write verifies the hostRW.Write helper function func TestHostRW_Write(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "atomic_test.txt") testData := []byte("atomic test content") err := (&hostFs{}).WriteFile(testFile, testData) assert.NoError(t, err) content, err := os.ReadFile(testFile) assert.NoError(t, err) assert.Equal(t, testData, content) // Verify it overwrites correctly newData := []byte("new atomic content") err = (&hostFs{}).WriteFile(testFile, newData) assert.NoError(t, err) content, err = os.ReadFile(testFile) assert.NoError(t, err) assert.Equal(t, newData, content) } // TestRootRW_Write verifies the rootRW.Write helper function func TestRootRW_Write(t *testing.T) { tmpDir := t.TempDir() relPath := "atomic_root_test.txt" testData := []byte("atomic root test content") erw := &sandboxFs{workspace: tmpDir} err := erw.WriteFile(relPath, testData) assert.NoError(t, err) root, err := os.OpenRoot(tmpDir) assert.NoError(t, err) defer root.Close() f, err := root.Open(relPath) assert.NoError(t, err) defer f.Close() content, err := io.ReadAll(f) assert.NoError(t, err) assert.Equal(t, testData, content) // Verify it overwrites correctly newData := []byte("new root atomic content") err = erw.WriteFile(relPath, newData) assert.NoError(t, err) f2, err := root.Open(relPath) assert.NoError(t, err) defer f2.Close() content, err = io.ReadAll(f2) assert.NoError(t, err) assert.Equal(t, newData, content) } // TestWhitelistFs_AllowsMatchingPaths verifies that whitelistFs allows access to // paths matching the whitelist patterns while blocking non-matching paths. func TestWhitelistFs_AllowsMatchingPaths(t *testing.T) { workspace := t.TempDir() outsideDir := t.TempDir() outsideFile := filepath.Join(outsideDir, "allowed.txt") os.WriteFile(outsideFile, []byte("outside content"), 0o644) // Pattern allows access to the outsideDir. patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(outsideDir))} tool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns) // Read from whitelisted path should succeed. result := tool.Execute(context.Background(), map[string]any{"path": outsideFile}) if result.IsError { t.Errorf("expected whitelisted path to be readable, got: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "outside content") { t.Errorf("expected file content, got: %s", result.ForLLM) } // Read from non-whitelisted path outside workspace should fail. otherDir := t.TempDir() otherFile := filepath.Join(otherDir, "blocked.txt") os.WriteFile(otherFile, []byte("blocked"), 0o644) result = tool.Execute(context.Background(), map[string]any{"path": otherFile}) if !result.IsError { t.Errorf("expected non-whitelisted path to be blocked, got: %s", result.ForLLM) } } func TestWhitelistFs_BlocksSymlinkEscapeInAllowedDir(t *testing.T) { workspace := t.TempDir() allowedDir := t.TempDir() secretDir := t.TempDir() secretFile := filepath.Join(secretDir, "secret.txt") if err := os.WriteFile(secretFile, []byte("top secret"), 0o644); err != nil { t.Fatalf("WriteFile(secretFile) error = %v", err) } linkPath := filepath.Join(allowedDir, "link_out") if err := os.Symlink(secretDir, linkPath); err != nil { t.Skipf("symlink not supported in this environment: %v", err) } patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(allowedDir))} tool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns) result := tool.Execute(context.Background(), map[string]any{"path": filepath.Join(linkPath, "secret.txt")}) if !result.IsError { t.Fatalf("expected symlink escape from allowed dir to be blocked, got: %s", result.ForLLM) } } func TestWhitelistFs_WriteAllowsNewFileUnderAllowedDir(t *testing.T) { workspace := t.TempDir() rootDir := t.TempDir() allowedDir := filepath.Join(rootDir, "allowed") targetFile := filepath.Join(allowedDir, "nested", "file.txt") patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(allowedDir))} tool := NewWriteFileTool(workspace, true, patterns) result := tool.Execute(context.Background(), map[string]any{ "path": targetFile, "content": "outside write", }) if result.IsError { t.Fatalf("expected whitelisted write to succeed, got: %s", result.ForLLM) } data, err := os.ReadFile(targetFile) if err != nil { t.Fatalf("ReadFile(targetFile) error = %v", err) } if string(data) != "outside write" { t.Fatalf("target file content = %q, want %q", string(data), "outside write") } } func TestWhitelistFs_AllowsResolvedAllowedRootAlias(t *testing.T) { workspace := t.TempDir() realDir := t.TempDir() linkParent := t.TempDir() allowedAlias := filepath.Join(linkParent, "allowed-link") if err := os.Symlink(realDir, allowedAlias); err != nil { t.Skipf("symlink not supported in this environment: %v", err) } targetFile := filepath.Join(allowedAlias, "nested", "alias.txt") if err := os.MkdirAll(filepath.Dir(targetFile), 0o755); err != nil { t.Fatalf("MkdirAll(targetFile dir) error = %v", err) } if err := os.WriteFile(targetFile, []byte("through alias"), 0o644); err != nil { t.Fatalf("WriteFile(targetFile) error = %v", err) } patterns := []*regexp.Regexp{ regexp.MustCompile( "^" + regexp.QuoteMeta(filepath.Clean(allowedAlias)) + "(?:" + regexp.QuoteMeta(string(os.PathSeparator)) + "|$)", ), } tool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns) result := tool.Execute(context.Background(), map[string]any{"path": targetFile}) if result.IsError { t.Fatalf("expected symlink-backed allowed root to be readable, got: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "through alias") { t.Fatalf("expected file content, got: %s", result.ForLLM) } } // TestReadFileTool_ChunkedReading verifies the pagination logic of the tool // by reading a file in multiple chunks using 'offset' and 'length'. func TestReadFileTool_ChunkedReading(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "pagination_test.txt") // Create a test file with exactly 26 bytes of content fullContent := "abcdefghijklmnopqrstuvwxyz" err := os.WriteFile(testFile, []byte(fullContent), 0o644) if err != nil { t.Fatalf("Failed to write test file: %v", err) } tool := NewReadFileTool(tmpDir, false, MaxReadFileSize) ctx := context.Background() // --- Step 1: Read the first chunk (10 bytes) --- args1 := map[string]any{ "path": testFile, "offset": 0, "length": 10, } result1 := tool.Execute(ctx, args1) if result1.IsError { t.Fatalf("Chunk 1 failed: %s", result1.ForLLM) } // Expect the first 10 characters if !strings.Contains(result1.ForLLM, "abcdefghij") { t.Errorf("Chunk 1 should contain 'abcdefghij', got: %s", result1.ForLLM) } // Expect the header to indicate the file is truncated if !strings.Contains(result1.ForLLM, "[TRUNCATED") { t.Errorf("Chunk 1 header should indicate truncation, got: %s", result1.ForLLM) } // Expect the header to suggest the next offset (10) if !strings.Contains(result1.ForLLM, "offset=10") { t.Errorf("Chunk 1 header should suggest next offset=10, got: %s", result1.ForLLM) } // Step 2: Read the second chunk (10 bytes) --- args2 := map[string]any{ "path": testFile, "offset": 10, "length": 10, } result2 := tool.Execute(ctx, args2) if result2.IsError { t.Fatalf("Chunk 2 failed: %s", result2.ForLLM) } // Expect the next 10 characters if !strings.Contains(result2.ForLLM, "klmnopqrst") { t.Errorf("Chunk 2 should contain 'klmnopqrst', got: %s", result2.ForLLM) } // Expect the header to suggest the next offset (20) if !strings.Contains(result2.ForLLM, "offset=20") { t.Errorf("Chunk 2 header should suggest next offset=20, got: %s", result2.ForLLM) } // Step 3: Read the final chunk (remaining 6 bytes) --- // We ask for 10 bytes, but only 6 are left in the file args3 := map[string]any{ "path": testFile, "offset": 20, "length": 10, } result3 := tool.Execute(ctx, args3) if result3.IsError { t.Fatalf("Chunk 3 failed: %s", result3.ForLLM) } // Expect the last 6 characters if !strings.Contains(result3.ForLLM, "uvwxyz") { t.Errorf("Chunk 3 should contain 'uvwxyz', got: %s", result3.ForLLM) } // Expect the header to indicate the end of the file if !strings.Contains(result3.ForLLM, "[END OF FILE") { t.Errorf("Chunk 3 header should indicate end of file, got: %s", result3.ForLLM) } // Ensure no TRUNCATED message is present in the final chunk if strings.Contains(result3.ForLLM, "[TRUNCATED") { t.Errorf("Chunk 3 header should NOT indicate truncation, got: %s", result3.ForLLM) } } // TestReadFileTool_OffsetBeyondEOF checks the behavior when requesting // An offset that exceeds the total file size. func TestReadFileTool_OffsetBeyondEOF(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "short.txt") // create a file of only 5 bytes err := os.WriteFile(testFile, []byte("12345"), 0o644) if err != nil { t.Fatalf("Failed to write test file: %v", err) } tool := NewReadFileTool(tmpDir, false, MaxReadFileSize) ctx := context.Background() args := map[string]any{ "path": testFile, "offset": int64(100), // Offset beyond the end of the file } result := tool.Execute(ctx, args) // It should not be classified as a tool execution error if result.IsError { t.Errorf("A mistake was not expected, obtained IsError=true: %s", result.ForLLM) } // Must return EXACTLY the string provided in the code expectedMsg := "[END OF FILE - no content at this offset]" if result.ForLLM != expectedMsg { t.Errorf("The message %q was expected, obtained: %q", expectedMsg, result.ForLLM) } } ================================================ FILE: pkg/tools/i2c.go ================================================ package tools import ( "context" "encoding/json" "fmt" "path/filepath" "regexp" "runtime" ) // I2CTool provides I2C bus interaction for reading sensors and controlling peripherals. type I2CTool struct{} func NewI2CTool() *I2CTool { return &I2CTool{} } func (t *I2CTool) Name() string { return "i2c" } func (t *I2CTool) Description() string { return "Interact with I2C bus devices for reading sensors and controlling peripherals. Actions: detect (list buses), scan (find devices on a bus), read (read bytes from device), write (send bytes to device). Linux only." } func (t *I2CTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "action": map[string]any{ "type": "string", "enum": []string{"detect", "scan", "read", "write"}, "description": "Action to perform: detect (list available I2C buses), scan (find devices on a bus), read (read bytes from a device), write (send bytes to a device)", }, "bus": map[string]any{ "type": "string", "description": "I2C bus number (e.g. \"1\" for /dev/i2c-1). Required for scan/read/write.", }, "address": map[string]any{ "type": "integer", "description": "7-bit I2C device address (0x03-0x77). Required for read/write.", }, "register": map[string]any{ "type": "integer", "description": "Register address to read from or write to. If set, sends register byte before read/write.", }, "data": map[string]any{ "type": "array", "items": map[string]any{"type": "integer"}, "description": "Bytes to write (0-255 each). Required for write action.", }, "length": map[string]any{ "type": "integer", "description": "Number of bytes to read (1-256). Default: 1. Used with read action.", }, "confirm": map[string]any{ "type": "boolean", "description": "Must be true for write operations. Safety guard to prevent accidental writes.", }, }, "required": []string{"action"}, } } func (t *I2CTool) Execute(ctx context.Context, args map[string]any) *ToolResult { if runtime.GOOS != "linux" { return ErrorResult("I2C is only supported on Linux. This tool requires /dev/i2c-* device files.") } action, ok := args["action"].(string) if !ok { return ErrorResult("action is required") } switch action { case "detect": return t.detect() case "scan": return t.scan(args) case "read": return t.readDevice(args) case "write": return t.writeDevice(args) default: return ErrorResult(fmt.Sprintf("unknown action: %s (valid: detect, scan, read, write)", action)) } } // detect lists available I2C buses by globbing /dev/i2c-* func (t *I2CTool) detect() *ToolResult { matches, err := filepath.Glob("/dev/i2c-*") if err != nil { return ErrorResult(fmt.Sprintf("failed to scan for I2C buses: %v", err)) } if len(matches) == 0 { return SilentResult( "No I2C buses found. You may need to:\n1. Load the i2c-dev module: modprobe i2c-dev\n2. Check that I2C is enabled in device tree\n3. Configure pinmux for your board (see hardware skill)", ) } type busInfo struct { Path string `json:"path"` Bus string `json:"bus"` } buses := make([]busInfo, 0, len(matches)) re := regexp.MustCompile(`/dev/i2c-(\d+)`) for _, m := range matches { if sub := re.FindStringSubmatch(m); sub != nil { buses = append(buses, busInfo{Path: m, Bus: sub[1]}) } } result, _ := json.MarshalIndent(buses, "", " ") return SilentResult(fmt.Sprintf("Found %d I2C bus(es):\n%s", len(buses), string(result))) } // Helper functions for I2C operations (used by platform-specific implementations) // isValidBusID checks that a bus identifier is a simple number (prevents path injection) // //nolint:unused // Used by i2c_linux.go func isValidBusID(id string) bool { matched, _ := regexp.MatchString(`^\d+$`, id) return matched } // parseI2CAddress extracts and validates an I2C address from args // //nolint:unused // Used by i2c_linux.go func parseI2CAddress(args map[string]any) (int, *ToolResult) { addrFloat, ok := args["address"].(float64) if !ok { return 0, ErrorResult("address is required (e.g. 0x38 for AHT20)") } addr := int(addrFloat) if addr < 0x03 || addr > 0x77 { return 0, ErrorResult("address must be in valid 7-bit range (0x03-0x77)") } return addr, nil } // parseI2CBus extracts and validates an I2C bus from args // //nolint:unused // Used by i2c_linux.go func parseI2CBus(args map[string]any) (string, *ToolResult) { bus, ok := args["bus"].(string) if !ok || bus == "" { return "", ErrorResult("bus is required (e.g. \"1\" for /dev/i2c-1)") } if !isValidBusID(bus) { return "", ErrorResult("invalid bus identifier: must be a number (e.g. \"1\")") } return bus, nil } ================================================ FILE: pkg/tools/i2c_linux.go ================================================ package tools import ( "encoding/json" "fmt" "syscall" "unsafe" ) // I2C ioctl constants from Linux kernel headers (<linux/i2c-dev.h>, <linux/i2c.h>) const ( i2cSlave = 0x0703 // Set slave address (fails if in use by driver) i2cFuncs = 0x0705 // Query adapter functionality bitmask i2cSmbus = 0x0720 // Perform SMBus transaction // I2C_FUNC capability bits i2cFuncSmbusQuick = 0x00010000 i2cFuncSmbusReadByte = 0x00020000 // SMBus transaction types i2cSmbusRead = 0 i2cSmbusWrite = 1 // SMBus protocol sizes i2cSmbusQuick = 0 i2cSmbusByte = 1 ) // i2cSmbusData matches the kernel union i2c_smbus_data (34 bytes max). // For quick and byte transactions only the first byte is used (if at all). type i2cSmbusData [34]byte // i2cSmbusArgs matches the kernel struct i2c_smbus_ioctl_data. type i2cSmbusArgs struct { readWrite uint8 command uint8 size uint32 data *i2cSmbusData } // smbusProbe performs a single SMBus probe at the given address. // Uses SMBus Quick Write (safest) or falls back to SMBus Read Byte for // EEPROM address ranges where quick write can corrupt AT24RF08 chips. // This matches i2cdetect's MODE_AUTO behavior. func smbusProbe(fd int, addr int, hasQuick bool) bool { // EEPROM ranges: use read byte (quick write can corrupt AT24RF08) useReadByte := (addr >= 0x30 && addr <= 0x37) || (addr >= 0x50 && addr <= 0x5F) if !useReadByte && hasQuick { // SMBus Quick Write: [START] [ADDR|W] [ACK/NACK] [STOP] // Safest probe — no data transferred args := i2cSmbusArgs{ readWrite: i2cSmbusWrite, command: 0, size: i2cSmbusQuick, data: nil, } _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSmbus, uintptr(unsafe.Pointer(&args))) return errno == 0 } // SMBus Read Byte: [START] [ADDR|R] [ACK/NACK] [DATA] [STOP] var data i2cSmbusData args := i2cSmbusArgs{ readWrite: i2cSmbusRead, command: 0, size: i2cSmbusByte, data: &data, } _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSmbus, uintptr(unsafe.Pointer(&args))) return errno == 0 } // scan probes valid 7-bit addresses on a bus for connected devices. // Uses the same hybrid probe strategy as i2cdetect's MODE_AUTO: // SMBus Quick Write for most addresses, SMBus Read Byte for EEPROM ranges. func (t *I2CTool) scan(args map[string]any) *ToolResult { bus, errResult := parseI2CBus(args) if errResult != nil { return errResult } devPath := fmt.Sprintf("/dev/i2c-%s", bus) fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) if err != nil { return ErrorResult(fmt.Sprintf("failed to open %s: %v (check permissions and i2c-dev module)", devPath, err)) } defer syscall.Close(fd) // Query adapter capabilities to determine available probe methods. // I2C_FUNCS writes an unsigned long, which is word-sized on Linux. var funcs uintptr _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cFuncs, uintptr(unsafe.Pointer(&funcs))) if errno != 0 { return ErrorResult(fmt.Sprintf("failed to query I2C adapter capabilities on %s: %v", devPath, errno)) } hasQuick := funcs&i2cFuncSmbusQuick != 0 hasReadByte := funcs&i2cFuncSmbusReadByte != 0 if !hasQuick && !hasReadByte { return ErrorResult( fmt.Sprintf("I2C adapter %s supports neither SMBus Quick nor Read Byte — cannot probe safely", devPath), ) } type deviceEntry struct { Address string `json:"address"` Status string `json:"status,omitempty"` } var found []deviceEntry // Scan 0x08-0x77, skipping I2C reserved addresses 0x00-0x07 for addr := 0x08; addr <= 0x77; addr++ { // Set slave address — EBUSY means a kernel driver owns this address _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr)) if errno != 0 { if errno == syscall.EBUSY { found = append(found, deviceEntry{ Address: fmt.Sprintf("0x%02x", addr), Status: "busy (in use by kernel driver)", }) } continue } if smbusProbe(fd, addr, hasQuick) { found = append(found, deviceEntry{ Address: fmt.Sprintf("0x%02x", addr), }) } } if len(found) == 0 { return SilentResult(fmt.Sprintf("No devices found on %s. Check wiring and pull-up resistors.", devPath)) } result, _ := json.MarshalIndent(map[string]any{ "bus": devPath, "devices": found, "count": len(found), }, "", " ") return SilentResult(fmt.Sprintf("Scan of %s:\n%s", devPath, string(result))) } // readDevice reads bytes from an I2C device, optionally at a specific register func (t *I2CTool) readDevice(args map[string]any) *ToolResult { bus, errResult := parseI2CBus(args) if errResult != nil { return errResult } addr, errResult := parseI2CAddress(args) if errResult != nil { return errResult } length := 1 if l, ok := args["length"].(float64); ok { length = int(l) } if length < 1 || length > 256 { return ErrorResult("length must be between 1 and 256") } devPath := fmt.Sprintf("/dev/i2c-%s", bus) fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) if err != nil { return ErrorResult(fmt.Sprintf("failed to open %s: %v", devPath, err)) } defer syscall.Close(fd) // Set slave address _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr)) if errno != 0 { return ErrorResult(fmt.Sprintf("failed to set I2C address 0x%02x: %v", addr, errno)) } // If register is specified, write it first if regFloat, ok := args["register"].(float64); ok { reg := int(regFloat) if reg < 0 || reg > 255 { return ErrorResult("register must be between 0x00 and 0xFF") } _, err = syscall.Write(fd, []byte{byte(reg)}) if err != nil { return ErrorResult(fmt.Sprintf("failed to write register 0x%02x: %v", reg, err)) } } // Read data buf := make([]byte, length) n, err := syscall.Read(fd, buf) if err != nil { return ErrorResult(fmt.Sprintf("failed to read from device 0x%02x: %v", addr, err)) } // Format as hex bytes hexBytes := make([]string, n) intBytes := make([]int, n) for i := 0; i < n; i++ { hexBytes[i] = fmt.Sprintf("0x%02x", buf[i]) intBytes[i] = int(buf[i]) } result, _ := json.MarshalIndent(map[string]any{ "bus": devPath, "address": fmt.Sprintf("0x%02x", addr), "bytes": intBytes, "hex": hexBytes, "length": n, }, "", " ") return SilentResult(string(result)) } // writeDevice writes bytes to an I2C device, optionally at a specific register func (t *I2CTool) writeDevice(args map[string]any) *ToolResult { confirm, _ := args["confirm"].(bool) if !confirm { return ErrorResult( "write operations require confirm: true. Please confirm with the user before writing to I2C devices, as incorrect writes can misconfigure hardware.", ) } bus, errResult := parseI2CBus(args) if errResult != nil { return errResult } addr, errResult := parseI2CAddress(args) if errResult != nil { return errResult } dataRaw, ok := args["data"].([]any) if !ok || len(dataRaw) == 0 { return ErrorResult("data is required for write (array of byte values 0-255)") } if len(dataRaw) > 256 { return ErrorResult("data too long: maximum 256 bytes per I2C transaction") } data := make([]byte, 0, len(dataRaw)+1) // If register is specified, prepend it to the data if regFloat, ok := args["register"].(float64); ok { reg := int(regFloat) if reg < 0 || reg > 255 { return ErrorResult("register must be between 0x00 and 0xFF") } data = append(data, byte(reg)) } for i, v := range dataRaw { f, ok := v.(float64) if !ok { return ErrorResult(fmt.Sprintf("data[%d] is not a valid byte value", i)) } b := int(f) if b < 0 || b > 255 { return ErrorResult(fmt.Sprintf("data[%d] = %d is out of byte range (0-255)", i, b)) } data = append(data, byte(b)) } devPath := fmt.Sprintf("/dev/i2c-%s", bus) fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) if err != nil { return ErrorResult(fmt.Sprintf("failed to open %s: %v", devPath, err)) } defer syscall.Close(fd) // Set slave address _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr)) if errno != 0 { return ErrorResult(fmt.Sprintf("failed to set I2C address 0x%02x: %v", addr, errno)) } // Write data n, err := syscall.Write(fd, data) if err != nil { return ErrorResult(fmt.Sprintf("failed to write to device 0x%02x: %v", addr, err)) } return SilentResult(fmt.Sprintf("Wrote %d byte(s) to device 0x%02x on %s", n, addr, devPath)) } ================================================ FILE: pkg/tools/i2c_other.go ================================================ //go:build !linux package tools // scan is a stub for non-Linux platforms. func (t *I2CTool) scan(args map[string]any) *ToolResult { return ErrorResult("I2C is only supported on Linux") } // readDevice is a stub for non-Linux platforms. func (t *I2CTool) readDevice(args map[string]any) *ToolResult { return ErrorResult("I2C is only supported on Linux") } // writeDevice is a stub for non-Linux platforms. func (t *I2CTool) writeDevice(args map[string]any) *ToolResult { return ErrorResult("I2C is only supported on Linux") } ================================================ FILE: pkg/tools/mcp_tool.go ================================================ package tools import ( "context" "encoding/json" "fmt" "hash/fnv" "strings" "github.com/modelcontextprotocol/go-sdk/mcp" ) // MCPManager defines the interface for MCP manager operations // This allows for easier testing with mock implementations type MCPManager interface { CallTool( ctx context.Context, serverName, toolName string, arguments map[string]any, ) (*mcp.CallToolResult, error) } // MCPTool wraps an MCP tool to implement the Tool interface type MCPTool struct { manager MCPManager serverName string tool *mcp.Tool } // NewMCPTool creates a new MCP tool wrapper func NewMCPTool(manager MCPManager, serverName string, tool *mcp.Tool) *MCPTool { return &MCPTool{ manager: manager, serverName: serverName, tool: tool, } } // sanitizeIdentifierComponent normalizes a string so it can be safely used // as part of a tool/function identifier for downstream providers. // It: // - lowercases the string // - replaces any character not in [a-z0-9_-] with '_' // - collapses multiple consecutive '_' into a single '_' // - trims leading/trailing '_' // - falls back to "unnamed" if the result is empty // - truncates overly long components to a reasonable length func sanitizeIdentifierComponent(s string) string { const maxLen = 64 s = strings.ToLower(s) var b strings.Builder b.Grow(len(s)) prevUnderscore := false for _, r := range s { isAllowed := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' if !isAllowed { // Normalize any disallowed character to '_' if !prevUnderscore { b.WriteRune('_') prevUnderscore = true } continue } if r == '_' { if prevUnderscore { continue } prevUnderscore = true } else { prevUnderscore = false } b.WriteRune(r) } result := strings.Trim(b.String(), "_") if result == "" { result = "unnamed" } if len(result) > maxLen { result = result[:maxLen] } return result } // Name returns the tool name, prefixed with the server name. // The total length is capped at 64 characters (OpenAI-compatible API limit). // A short hash of the original (unsanitized) server and tool names is appended // whenever sanitization is lossy or the name is truncated, ensuring that two // names which differ only in disallowed characters remain distinct after sanitization. func (t *MCPTool) Name() string { // Prefix with server name to avoid conflicts, and sanitize components sanitizedServer := sanitizeIdentifierComponent(t.serverName) sanitizedTool := sanitizeIdentifierComponent(t.tool.Name) full := fmt.Sprintf("mcp_%s_%s", sanitizedServer, sanitizedTool) // Check if sanitization was lossless (only lowercasing, no char replacement/truncation) lossless := strings.ToLower(t.serverName) == sanitizedServer && strings.ToLower(t.tool.Name) == sanitizedTool const maxTotal = 64 if lossless && len(full) <= maxTotal { return full } // Sanitization was lossy or name too long: append hash of the ORIGINAL names // (not the sanitized names) so different originals always yield different hashes. h := fnv.New32a() _, _ = h.Write([]byte(t.serverName + "\x00" + t.tool.Name)) suffix := fmt.Sprintf("%08x", h.Sum32()) // 8 chars base := full if len(base) > maxTotal-9 { base = strings.TrimRight(full[:maxTotal-9], "_") } return base + "_" + suffix } // Description returns the tool description func (t *MCPTool) Description() string { desc := t.tool.Description if desc == "" { desc = fmt.Sprintf("MCP tool from %s server", t.serverName) } // Add server info to description return fmt.Sprintf("[MCP:%s] %s", t.serverName, desc) } // Parameters returns the tool parameters schema func (t *MCPTool) Parameters() map[string]any { // The InputSchema is already a JSON Schema object schema := t.tool.InputSchema // Handle nil schema if schema == nil { return map[string]any{ "type": "object", "properties": map[string]any{}, "required": []string{}, } } // Try direct conversion first (fast path) if schemaMap, ok := schema.(map[string]any); ok { return schemaMap } // Handle json.RawMessage and []byte - unmarshal directly var jsonData []byte if rawMsg, ok := schema.(json.RawMessage); ok { jsonData = rawMsg } else if bytes, ok := schema.([]byte); ok { jsonData = bytes } if jsonData != nil { var result map[string]any if err := json.Unmarshal(jsonData, &result); err == nil { return result } // Fallback on error return map[string]any{ "type": "object", "properties": map[string]any{}, "required": []string{}, } } // For other types (structs, etc.), convert via JSON marshal/unmarshal var err error jsonData, err = json.Marshal(schema) if err != nil { // Fallback to empty schema if marshaling fails return map[string]any{ "type": "object", "properties": map[string]any{}, "required": []string{}, } } var result map[string]any if err := json.Unmarshal(jsonData, &result); err != nil { // Fallback to empty schema if unmarshaling fails return map[string]any{ "type": "object", "properties": map[string]any{}, "required": []string{}, } } return result } // Execute executes the MCP tool func (t *MCPTool) Execute(ctx context.Context, args map[string]any) *ToolResult { result, err := t.manager.CallTool(ctx, t.serverName, t.tool.Name, args) if err != nil { return ErrorResult(fmt.Sprintf("MCP tool execution failed: %v", err)).WithError(err) } if result == nil { nilErr := fmt.Errorf("MCP tool returned nil result without error") return ErrorResult("MCP tool execution failed: nil result").WithError(nilErr) } // Handle error result from server if result.IsError { errMsg := extractContentText(result.Content) return ErrorResult(fmt.Sprintf("MCP tool returned error: %s", errMsg)). WithError(fmt.Errorf("MCP tool error: %s", errMsg)) } // Extract text content from result output := extractContentText(result.Content) return &ToolResult{ ForLLM: output, IsError: false, } } // extractContentText extracts text from MCP content array func extractContentText(content []mcp.Content) string { var parts []string for _, c := range content { switch v := c.(type) { case *mcp.TextContent: parts = append(parts, v.Text) case *mcp.ImageContent: // For images, just indicate that an image was returned parts = append(parts, fmt.Sprintf("[Image: %s]", v.MIMEType)) default: // For other content types, use string representation parts = append(parts, fmt.Sprintf("[Content: %T]", v)) } } return strings.Join(parts, "\n") } ================================================ FILE: pkg/tools/mcp_tool_test.go ================================================ package tools import ( "context" "fmt" "strings" "testing" "github.com/modelcontextprotocol/go-sdk/mcp" ) // MockMCPManager is a mock implementation of MCPManager interface for testing type MockMCPManager struct { callToolFunc func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) } func (m *MockMCPManager) CallTool( ctx context.Context, serverName, toolName string, arguments map[string]any, ) (*mcp.CallToolResult, error) { if m.callToolFunc != nil { return m.callToolFunc(ctx, serverName, toolName, arguments) } return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{Text: "mock result"}, }, IsError: false, }, nil } // TestNewMCPTool verifies MCP tool creation func TestNewMCPTool(t *testing.T) { manager := &MockMCPManager{} tool := &mcp.Tool{ Name: "test_tool", Description: "A test tool", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "input": map[string]any{ "type": "string", "description": "Test input", }, }, }, } mcpTool := NewMCPTool(manager, "test_server", tool) if mcpTool == nil { t.Fatal("NewMCPTool should not return nil") } // Verify tool properties we can access if mcpTool.Name() != "mcp_test_server_test_tool" { t.Errorf("Expected tool name with prefix, got '%s'", mcpTool.Name()) } } // TestMCPTool_Name verifies tool name with server prefix func TestMCPTool_Name(t *testing.T) { tests := []struct { name string serverName string toolName string expected string }{ { name: "simple name", serverName: "github", toolName: "create_issue", expected: "mcp_github_create_issue", }, { name: "filesystem server", serverName: "filesystem", toolName: "read_file", expected: "mcp_filesystem_read_file", }, { name: "remote server", serverName: "remote-api", toolName: "fetch_data", expected: "mcp_remote-api_fetch_data", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { manager := &MockMCPManager{} tool := &mcp.Tool{Name: tt.toolName} mcpTool := NewMCPTool(manager, tt.serverName, tool) result := mcpTool.Name() if result != tt.expected { t.Errorf("Expected name '%s', got '%s'", tt.expected, result) } }) } } // TestMCPTool_Description verifies tool description generation func TestMCPTool_Description(t *testing.T) { tests := []struct { name string serverName string toolDescription string expectContains []string }{ { name: "with description", serverName: "github", toolDescription: "Create a GitHub issue", expectContains: []string{"[MCP:github]", "Create a GitHub issue"}, }, { name: "empty description", serverName: "filesystem", toolDescription: "", expectContains: []string{"[MCP:filesystem]", "MCP tool from filesystem server"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { manager := &MockMCPManager{} tool := &mcp.Tool{ Name: "test_tool", Description: tt.toolDescription, } mcpTool := NewMCPTool(manager, tt.serverName, tool) result := mcpTool.Description() for _, expected := range tt.expectContains { if !strings.Contains(result, expected) { t.Errorf("Description should contain '%s', got: %s", expected, result) } } }) } } // TestMCPTool_Parameters verifies parameter schema conversion func TestMCPTool_Parameters(t *testing.T) { tests := []struct { name string inputSchema any expectType string checkProperty string expectProperty bool }{ { name: "map schema", inputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "query": map[string]any{ "type": "string", "description": "Search query", }, }, "required": []string{"query"}, }, expectType: "object", checkProperty: "query", expectProperty: true, }, { name: "nil schema", inputSchema: nil, expectType: "object", expectProperty: false, }, { name: "json.RawMessage schema", inputSchema: []byte(`{ "type": "object", "properties": { "repo": { "type": "string", "description": "Repository name" }, "stars": { "type": "integer", "description": "Minimum stars" } }, "required": ["repo"] }`), expectType: "object", checkProperty: "repo", expectProperty: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { manager := &MockMCPManager{} tool := &mcp.Tool{ Name: "test_tool", InputSchema: tt.inputSchema, } mcpTool := NewMCPTool(manager, "test_server", tool) params := mcpTool.Parameters() if params == nil { t.Fatal("Parameters should not be nil") } if params["type"] != tt.expectType { t.Errorf("Expected type '%s', got '%v'", tt.expectType, params["type"]) } // Check if property exists when expected if tt.checkProperty != "" { properties, ok := params["properties"].(map[string]any) if !ok && tt.expectProperty { t.Errorf("Expected properties to be a map") return } if ok { _, hasProperty := properties[tt.checkProperty] if hasProperty != tt.expectProperty { t.Errorf("Expected property '%s' existence: %v, got: %v", tt.checkProperty, tt.expectProperty, hasProperty) } } } }) } } // TestMCPTool_Execute_Success tests successful tool execution func TestMCPTool_Execute_Success(t *testing.T) { manager := &MockMCPManager{ callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { // Verify correct parameters passed if serverName != "github" { t.Errorf("Expected serverName 'github', got '%s'", serverName) } if toolName != "search_repos" { t.Errorf("Expected toolName 'search_repos', got '%s'", toolName) } return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{Text: "Found 3 repositories"}, }, IsError: false, }, nil }, } tool := &mcp.Tool{ Name: "search_repos", Description: "Search GitHub repositories", } mcpTool := NewMCPTool(manager, "github", tool) ctx := context.Background() args := map[string]any{ "query": "golang mcp", } result := mcpTool.Execute(ctx, args) if result == nil { t.Fatal("Result should not be nil") } if result.IsError { t.Errorf("Expected no error, got error: %s", result.ForLLM) } if result.ForLLM != "Found 3 repositories" { t.Errorf("Expected 'Found 3 repositories', got '%s'", result.ForLLM) } } // TestMCPTool_Execute_ManagerError tests execution when manager returns error func TestMCPTool_Execute_ManagerError(t *testing.T) { manager := &MockMCPManager{ callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { return nil, fmt.Errorf("connection failed") }, } tool := &mcp.Tool{Name: "test_tool"} mcpTool := NewMCPTool(manager, "test_server", tool) ctx := context.Background() result := mcpTool.Execute(ctx, map[string]any{}) if result == nil { t.Fatal("Result should not be nil") } if !result.IsError { t.Error("Expected IsError to be true") } if !strings.Contains(result.ForLLM, "MCP tool execution failed") { t.Errorf("Error message should mention execution failure, got: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "connection failed") { t.Errorf("Error message should include original error, got: %s", result.ForLLM) } } // TestMCPTool_Execute_ServerError tests execution when server returns error func TestMCPTool_Execute_ServerError(t *testing.T) { manager := &MockMCPManager{ callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{Text: "Invalid API key"}, }, IsError: true, }, nil }, } tool := &mcp.Tool{Name: "test_tool"} mcpTool := NewMCPTool(manager, "test_server", tool) ctx := context.Background() result := mcpTool.Execute(ctx, map[string]any{}) if result == nil { t.Fatal("Result should not be nil") } if !result.IsError { t.Error("Expected IsError to be true") } if !strings.Contains(result.ForLLM, "MCP tool returned error") { t.Errorf("Error message should mention server error, got: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "Invalid API key") { t.Errorf("Error message should include server message, got: %s", result.ForLLM) } } // TestMCPTool_Execute_MultipleContent tests execution with multiple content items func TestMCPTool_Execute_MultipleContent(t *testing.T) { manager := &MockMCPManager{ callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{Text: "First line"}, &mcp.TextContent{Text: "Second line"}, &mcp.TextContent{Text: "Third line"}, }, IsError: false, }, nil }, } tool := &mcp.Tool{Name: "multi_output"} mcpTool := NewMCPTool(manager, "test_server", tool) ctx := context.Background() result := mcpTool.Execute(ctx, map[string]any{}) if result.IsError { t.Errorf("Expected no error, got: %s", result.ForLLM) } expected := "First line\nSecond line\nThird line" if result.ForLLM != expected { t.Errorf("Expected '%s', got '%s'", expected, result.ForLLM) } } // TestExtractContentText_TextContent tests text content extraction func TestExtractContentText_TextContent(t *testing.T) { content := []mcp.Content{ &mcp.TextContent{Text: "Hello World"}, &mcp.TextContent{Text: "Second message"}, } result := extractContentText(content) expected := "Hello World\nSecond message" if result != expected { t.Errorf("Expected '%s', got '%s'", expected, result) } } // TestExtractContentText_ImageContent tests image content extraction func TestExtractContentText_ImageContent(t *testing.T) { content := []mcp.Content{ &mcp.ImageContent{ Data: []byte("base64data"), MIMEType: "image/png", }, } result := extractContentText(content) if !strings.Contains(result, "[Image:") { t.Errorf("Expected image indicator, got: %s", result) } if !strings.Contains(result, "image/png") { t.Errorf("Expected MIME type in output, got: %s", result) } } // TestExtractContentText_MixedContent tests mixed content types func TestExtractContentText_MixedContent(t *testing.T) { content := []mcp.Content{ &mcp.TextContent{Text: "Description"}, &mcp.ImageContent{ Data: []byte("data"), MIMEType: "image/jpeg", }, &mcp.TextContent{Text: "More text"}, } result := extractContentText(content) if !strings.Contains(result, "Description") { t.Errorf("Should contain text content, got: %s", result) } if !strings.Contains(result, "[Image:") { t.Errorf("Should contain image indicator, got: %s", result) } if !strings.Contains(result, "More text") { t.Errorf("Should contain second text, got: %s", result) } } // TestExtractContentText_EmptyContent tests empty content array func TestExtractContentText_EmptyContent(t *testing.T) { content := []mcp.Content{} result := extractContentText(content) if result != "" { t.Errorf("Expected empty string for empty content, got: %s", result) } } // TestMCPTool_InterfaceCompliance verifies MCPTool implements Tool interface func TestMCPTool_InterfaceCompliance(t *testing.T) { manager := &MockMCPManager{} tool := &mcp.Tool{Name: "test"} mcpTool := NewMCPTool(manager, "test_server", tool) // Verify it implements Tool interface var _ Tool = mcpTool } // TestMCPTool_Parameters_MapSchema tests schema that's already a map func TestMCPTool_Parameters_MapSchema(t *testing.T) { manager := &MockMCPManager{} schema := map[string]any{ "type": "object", "properties": map[string]any{ "name": map[string]any{ "type": "string", "description": "The name parameter", }, }, "required": []string{"name"}, } tool := &mcp.Tool{ Name: "test_tool", InputSchema: schema, } mcpTool := NewMCPTool(manager, "test_server", tool) params := mcpTool.Parameters() // Should return the schema as-is when it's already a map if params["type"] != "object" { t.Errorf("Expected type 'object', got '%v'", params["type"]) } props, ok := params["properties"].(map[string]any) if !ok { t.Error("Properties should be a map") } nameParam, ok := props["name"].(map[string]any) if !ok { t.Error("Name parameter should exist") } if nameParam["type"] != "string" { t.Errorf("Name type should be 'string', got '%v'", nameParam["type"]) } } ================================================ FILE: pkg/tools/message.go ================================================ package tools import ( "context" "fmt" "sync/atomic" ) type SendCallback func(channel, chatID, content string) error type MessageTool struct { sendCallback SendCallback sentInRound atomic.Bool // Tracks whether a message was sent in the current processing round } func NewMessageTool() *MessageTool { return &MessageTool{} } func (t *MessageTool) Name() string { return "message" } func (t *MessageTool) Description() string { return "Send a message to user on a chat channel. Use this when you want to communicate something." } func (t *MessageTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "content": map[string]any{ "type": "string", "description": "The message content to send", }, "channel": map[string]any{ "type": "string", "description": "Optional: target channel (telegram, whatsapp, etc.)", }, "chat_id": map[string]any{ "type": "string", "description": "Optional: target chat/user ID", }, }, "required": []string{"content"}, } } // ResetSentInRound resets the per-round send tracker. // Called by the agent loop at the start of each inbound message processing round. func (t *MessageTool) ResetSentInRound() { t.sentInRound.Store(false) } // HasSentInRound returns true if the message tool sent a message during the current round. func (t *MessageTool) HasSentInRound() bool { return t.sentInRound.Load() } func (t *MessageTool) SetSendCallback(callback SendCallback) { t.sendCallback = callback } func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolResult { content, ok := args["content"].(string) if !ok { return &ToolResult{ForLLM: "content is required", IsError: true} } channel, _ := args["channel"].(string) chatID, _ := args["chat_id"].(string) if channel == "" { channel = ToolChannel(ctx) } if chatID == "" { chatID = ToolChatID(ctx) } if channel == "" || chatID == "" { return &ToolResult{ForLLM: "No target channel/chat specified", IsError: true} } if t.sendCallback == nil { return &ToolResult{ForLLM: "Message sending not configured", IsError: true} } if err := t.sendCallback(channel, chatID, content); err != nil { return &ToolResult{ ForLLM: fmt.Sprintf("sending message: %v", err), IsError: true, Err: err, } } t.sentInRound.Store(true) // Silent: user already received the message directly return &ToolResult{ ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID), Silent: true, } } ================================================ FILE: pkg/tools/message_test.go ================================================ package tools import ( "context" "errors" "testing" ) func TestMessageTool_Execute_Success(t *testing.T) { tool := NewMessageTool() var sentChannel, sentChatID, sentContent string tool.SetSendCallback(func(channel, chatID, content string) error { sentChannel = channel sentChatID = chatID sentContent = content return nil }) ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id") args := map[string]any{ "content": "Hello, world!", } result := tool.Execute(ctx, args) // Verify message was sent with correct parameters if sentChannel != "test-channel" { t.Errorf("Expected channel 'test-channel', got '%s'", sentChannel) } if sentChatID != "test-chat-id" { t.Errorf("Expected chatID 'test-chat-id', got '%s'", sentChatID) } if sentContent != "Hello, world!" { t.Errorf("Expected content 'Hello, world!', got '%s'", sentContent) } // Verify ToolResult meets US-011 criteria: // - Send success returns SilentResult (Silent=true) if !result.Silent { t.Error("Expected Silent=true for successful send") } // - ForLLM contains send status description if result.ForLLM != "Message sent to test-channel:test-chat-id" { t.Errorf("Expected ForLLM 'Message sent to test-channel:test-chat-id', got '%s'", result.ForLLM) } // - ForUser is empty (user already received message directly) if result.ForUser != "" { t.Errorf("Expected ForUser to be empty, got '%s'", result.ForUser) } // - IsError should be false if result.IsError { t.Error("Expected IsError=false for successful send") } } func TestMessageTool_Execute_WithCustomChannel(t *testing.T) { tool := NewMessageTool() var sentChannel, sentChatID string tool.SetSendCallback(func(channel, chatID, content string) error { sentChannel = channel sentChatID = chatID return nil }) ctx := WithToolContext(context.Background(), "default-channel", "default-chat-id") args := map[string]any{ "content": "Test message", "channel": "custom-channel", "chat_id": "custom-chat-id", } result := tool.Execute(ctx, args) // Verify custom channel/chatID were used instead of defaults if sentChannel != "custom-channel" { t.Errorf("Expected channel 'custom-channel', got '%s'", sentChannel) } if sentChatID != "custom-chat-id" { t.Errorf("Expected chatID 'custom-chat-id', got '%s'", sentChatID) } if !result.Silent { t.Error("Expected Silent=true") } if result.ForLLM != "Message sent to custom-channel:custom-chat-id" { t.Errorf("Expected ForLLM 'Message sent to custom-channel:custom-chat-id', got '%s'", result.ForLLM) } } func TestMessageTool_Execute_SendFailure(t *testing.T) { tool := NewMessageTool() sendErr := errors.New("network error") tool.SetSendCallback(func(channel, chatID, content string) error { return sendErr }) ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id") args := map[string]any{ "content": "Test message", } result := tool.Execute(ctx, args) // Verify ToolResult for send failure: // - Send failure returns ErrorResult (IsError=true) if !result.IsError { t.Error("Expected IsError=true for failed send") } // - ForLLM contains error description expectedErrMsg := "sending message: network error" if result.ForLLM != expectedErrMsg { t.Errorf("Expected ForLLM '%s', got '%s'", expectedErrMsg, result.ForLLM) } // - Err field should contain original error if result.Err == nil { t.Error("Expected Err to be set") } if result.Err != sendErr { t.Errorf("Expected Err to be sendErr, got %v", result.Err) } } func TestMessageTool_Execute_MissingContent(t *testing.T) { tool := NewMessageTool() ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id") args := map[string]any{} // content missing result := tool.Execute(ctx, args) // Verify error result for missing content if !result.IsError { t.Error("Expected IsError=true for missing content") } if result.ForLLM != "content is required" { t.Errorf("Expected ForLLM 'content is required', got '%s'", result.ForLLM) } } func TestMessageTool_Execute_NoTargetChannel(t *testing.T) { tool := NewMessageTool() // No WithToolContext — channel/chatID are empty tool.SetSendCallback(func(channel, chatID, content string) error { return nil }) ctx := context.Background() args := map[string]any{ "content": "Test message", } result := tool.Execute(ctx, args) // Verify error when no target channel specified if !result.IsError { t.Error("Expected IsError=true when no target channel") } if result.ForLLM != "No target channel/chat specified" { t.Errorf("Expected ForLLM 'No target channel/chat specified', got '%s'", result.ForLLM) } } func TestMessageTool_Execute_NotConfigured(t *testing.T) { tool := NewMessageTool() // No SetSendCallback called ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id") args := map[string]any{ "content": "Test message", } result := tool.Execute(ctx, args) // Verify error when send callback not configured if !result.IsError { t.Error("Expected IsError=true when send callback not configured") } if result.ForLLM != "Message sending not configured" { t.Errorf("Expected ForLLM 'Message sending not configured', got '%s'", result.ForLLM) } } func TestMessageTool_Name(t *testing.T) { tool := NewMessageTool() if tool.Name() != "message" { t.Errorf("Expected name 'message', got '%s'", tool.Name()) } } func TestMessageTool_Description(t *testing.T) { tool := NewMessageTool() desc := tool.Description() if desc == "" { t.Error("Description should not be empty") } } func TestMessageTool_Parameters(t *testing.T) { tool := NewMessageTool() params := tool.Parameters() // Verify parameters structure typ, ok := params["type"].(string) if !ok || typ != "object" { t.Error("Expected type 'object'") } props, ok := params["properties"].(map[string]any) if !ok { t.Fatal("Expected properties to be a map") } // Check required properties required, ok := params["required"].([]string) if !ok || len(required) != 1 || required[0] != "content" { t.Error("Expected 'content' to be required") } // Check content property contentProp, ok := props["content"].(map[string]any) if !ok { t.Error("Expected 'content' property") } if contentProp["type"] != "string" { t.Error("Expected content type to be 'string'") } // Check channel property (optional) channelProp, ok := props["channel"].(map[string]any) if !ok { t.Error("Expected 'channel' property") } if channelProp["type"] != "string" { t.Error("Expected channel type to be 'string'") } // Check chat_id property (optional) chatIDProp, ok := props["chat_id"].(map[string]any) if !ok { t.Error("Expected 'chat_id' property") } if chatIDProp["type"] != "string" { t.Error("Expected chat_id type to be 'string'") } } ================================================ FILE: pkg/tools/registry.go ================================================ package tools import ( "context" "fmt" "sort" "sync" "sync/atomic" "time" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" ) type ToolEntry struct { Tool Tool IsCore bool TTL int } type ToolRegistry struct { tools map[string]*ToolEntry mu sync.RWMutex version atomic.Uint64 // incremented on Register/RegisterHidden for cache invalidation } func NewToolRegistry() *ToolRegistry { return &ToolRegistry{ tools: make(map[string]*ToolEntry), } } func (r *ToolRegistry) Register(tool Tool) { r.mu.Lock() defer r.mu.Unlock() name := tool.Name() if _, exists := r.tools[name]; exists { logger.WarnCF("tools", "Tool registration overwrites existing tool", map[string]any{"name": name}) } r.tools[name] = &ToolEntry{ Tool: tool, IsCore: true, TTL: 0, // Core tools do not use TTL } r.version.Add(1) logger.DebugCF("tools", "Registered core tool", map[string]any{"name": name}) } // RegisterHidden saves hidden tools (visible only via TTL) func (r *ToolRegistry) RegisterHidden(tool Tool) { r.mu.Lock() defer r.mu.Unlock() name := tool.Name() if _, exists := r.tools[name]; exists { logger.WarnCF("tools", "Hidden tool registration overwrites existing tool", map[string]any{"name": name}) } r.tools[name] = &ToolEntry{ Tool: tool, IsCore: false, TTL: 0, } r.version.Add(1) logger.DebugCF("tools", "Registered hidden tool", map[string]any{"name": name}) } // PromoteTools atomically sets the TTL for multiple non-core tools. // This prevents a concurrent TickTTL from decrementing between promotions. func (r *ToolRegistry) PromoteTools(names []string, ttl int) { r.mu.Lock() defer r.mu.Unlock() promoted := 0 for _, name := range names { if entry, exists := r.tools[name]; exists { if !entry.IsCore { entry.TTL = ttl promoted++ } } } logger.DebugCF( "tools", "PromoteTools completed", map[string]any{"requested": len(names), "promoted": promoted, "ttl": ttl}, ) } // TickTTL decreases TTL only for non-core tools func (r *ToolRegistry) TickTTL() { r.mu.Lock() defer r.mu.Unlock() for _, entry := range r.tools { if !entry.IsCore && entry.TTL > 0 { entry.TTL-- } } } // Version returns the current registry version (atomically). func (r *ToolRegistry) Version() uint64 { return r.version.Load() } // HiddenToolSnapshot holds a consistent snapshot of hidden tools and the // registry version at which it was taken. Used by BM25SearchTool cache. type HiddenToolSnapshot struct { Docs []HiddenToolDoc Version uint64 } // HiddenToolDoc is a lightweight representation of a hidden tool for search indexing. type HiddenToolDoc struct { Name string Description string } // SnapshotHiddenTools returns all non-core tools and the current registry // version under a single read-lock, guaranteeing consistency between the // two values. func (r *ToolRegistry) SnapshotHiddenTools() HiddenToolSnapshot { r.mu.RLock() defer r.mu.RUnlock() docs := make([]HiddenToolDoc, 0, len(r.tools)) for name, entry := range r.tools { if !entry.IsCore { docs = append(docs, HiddenToolDoc{ Name: name, Description: entry.Tool.Description(), }) } } return HiddenToolSnapshot{ Docs: docs, Version: r.version.Load(), } } func (r *ToolRegistry) Get(name string) (Tool, bool) { r.mu.RLock() defer r.mu.RUnlock() entry, ok := r.tools[name] if !ok { return nil, false } // Hidden tools with expired TTL are not callable. if !entry.IsCore && entry.TTL <= 0 { return nil, false } return entry.Tool, true } func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]any) *ToolResult { return r.ExecuteWithContext(ctx, name, args, "", "", nil) } // ExecuteWithContext executes a tool with channel/chatID context and optional async callback. // If the tool implements AsyncExecutor and a non-nil callback is provided, // ExecuteAsync is called instead of Execute — the callback is a parameter, // never stored as mutable state on the tool. func (r *ToolRegistry) ExecuteWithContext( ctx context.Context, name string, args map[string]any, channel, chatID string, asyncCallback AsyncCallback, ) *ToolResult { logger.InfoCF("tool", "Tool execution started", map[string]any{ "tool": name, "args": args, }) tool, ok := r.Get(name) if !ok { logger.ErrorCF("tool", "Tool not found", map[string]any{ "tool": name, }) return ErrorResult(fmt.Sprintf("tool %q not found", name)).WithError(fmt.Errorf("tool not found")) } // Inject channel/chatID into ctx so tools read them via ToolChannel(ctx)/ToolChatID(ctx). // Always inject — tools validate what they require. ctx = WithToolContext(ctx, channel, chatID) // If tool implements AsyncExecutor and callback is provided, use ExecuteAsync. // The callback is a call parameter, not mutable state on the tool instance. var result *ToolResult start := time.Now() // Use recover to catch any panics during tool execution // This prevents tool crashes from killing the entire agent func() { defer func() { if re := recover(); re != nil { errMsg := fmt.Sprintf("Tool '%s' crashed with panic: %v", name, re) logger.ErrorCF("tool", "Tool execution panic recovered", map[string]any{ "tool": name, "panic": fmt.Sprintf("%v", re), }) result = &ToolResult{ ForLLM: errMsg, ForUser: errMsg, IsError: true, Err: fmt.Errorf("panic: %v", re), } } }() if asyncExec, ok := tool.(AsyncExecutor); ok && asyncCallback != nil { logger.DebugCF("tool", "Executing async tool via ExecuteAsync", map[string]any{ "tool": name, }) result = asyncExec.ExecuteAsync(ctx, args, asyncCallback) } else { result = tool.Execute(ctx, args) } }() // Handle nil result (should not happen, but defensive) if result == nil { result = &ToolResult{ ForLLM: fmt.Sprintf("Tool '%s' returned nil result unexpectedly", name), ForUser: fmt.Sprintf("Tool '%s' returned nil result unexpectedly", name), IsError: true, Err: fmt.Errorf("nil result from tool"), } } duration := time.Since(start) // Log based on result type if result.IsError { logger.ErrorCF("tool", "Tool execution failed", map[string]any{ "tool": name, "duration": duration.Milliseconds(), "error": result.ForLLM, }) } else if result.Async { logger.InfoCF("tool", "Tool started (async)", map[string]any{ "tool": name, "duration": duration.Milliseconds(), }) } else { logger.InfoCF("tool", "Tool execution completed", map[string]any{ "tool": name, "duration_ms": duration.Milliseconds(), "result_length": len(result.ForLLM), }) } return result } // sortedToolNames returns tool names in sorted order for deterministic iteration. // This is critical for KV cache stability: non-deterministic map iteration would // produce different system prompts and tool definitions on each call, invalidating // the LLM's prefix cache even when no tools have changed. func (r *ToolRegistry) sortedToolNames() []string { names := make([]string, 0, len(r.tools)) for name := range r.tools { names = append(names, name) } sort.Strings(names) return names } func (r *ToolRegistry) GetDefinitions() []map[string]any { r.mu.RLock() defer r.mu.RUnlock() sorted := r.sortedToolNames() definitions := make([]map[string]any, 0, len(sorted)) for _, name := range sorted { entry := r.tools[name] if !entry.IsCore && entry.TTL <= 0 { continue } definitions = append(definitions, ToolToSchema(r.tools[name].Tool)) } return definitions } // ToProviderDefs converts tool definitions to provider-compatible format. // This is the format expected by LLM provider APIs. func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition { r.mu.RLock() defer r.mu.RUnlock() sorted := r.sortedToolNames() definitions := make([]providers.ToolDefinition, 0, len(sorted)) for _, name := range sorted { entry := r.tools[name] if !entry.IsCore && entry.TTL <= 0 { continue } schema := ToolToSchema(entry.Tool) // Safely extract nested values with type checks fn, ok := schema["function"].(map[string]any) if !ok { continue } name, _ := fn["name"].(string) desc, _ := fn["description"].(string) params, _ := fn["parameters"].(map[string]any) definitions = append(definitions, providers.ToolDefinition{ Type: "function", Function: providers.ToolFunctionDefinition{ Name: name, Description: desc, Parameters: params, }, }) } return definitions } // List returns a list of all registered tool names. func (r *ToolRegistry) List() []string { r.mu.RLock() defer r.mu.RUnlock() return r.sortedToolNames() } // Clone creates an independent copy of the registry containing the same tool // entries (shallow copy of each ToolEntry). This is used to give subagents a // snapshot of the parent agent's tools without sharing the same registry — // tools registered on the parent after cloning (e.g. spawn, spawn_status) // will NOT be visible to the clone, preventing recursive subagent spawning. // The version counter is reset to 0 in the clone as it's a new independent registry. func (r *ToolRegistry) Clone() *ToolRegistry { r.mu.RLock() defer r.mu.RUnlock() clone := &ToolRegistry{ tools: make(map[string]*ToolEntry, len(r.tools)), } for name, entry := range r.tools { clone.tools[name] = &ToolEntry{ Tool: entry.Tool, IsCore: entry.IsCore, TTL: entry.TTL, } } return clone } // Count returns the number of registered tools. func (r *ToolRegistry) Count() int { r.mu.RLock() defer r.mu.RUnlock() return len(r.tools) } // GetSummaries returns human-readable summaries of all registered tools. // Returns a slice of "name - description" strings. func (r *ToolRegistry) GetSummaries() []string { r.mu.RLock() defer r.mu.RUnlock() sorted := r.sortedToolNames() summaries := make([]string, 0, len(sorted)) for _, name := range sorted { entry := r.tools[name] if !entry.IsCore && entry.TTL <= 0 { continue } summaries = append(summaries, fmt.Sprintf("- `%s` - %s", entry.Tool.Name(), entry.Tool.Description())) } return summaries } ================================================ FILE: pkg/tools/registry_test.go ================================================ package tools import ( "context" "errors" "strings" "sync" "testing" "github.com/sipeed/picoclaw/pkg/providers" ) // --- mock types --- type mockRegistryTool struct { name string desc string params map[string]any result *ToolResult } func (m *mockRegistryTool) Name() string { return m.name } func (m *mockRegistryTool) Description() string { return m.desc } func (m *mockRegistryTool) Parameters() map[string]any { return m.params } func (m *mockRegistryTool) Execute(_ context.Context, _ map[string]any) *ToolResult { return m.result } type mockContextAwareTool struct { mockRegistryTool lastCtx context.Context } func (m *mockContextAwareTool) Execute(ctx context.Context, _ map[string]any) *ToolResult { m.lastCtx = ctx return m.result } type mockAsyncRegistryTool struct { mockRegistryTool lastCB AsyncCallback } func (m *mockAsyncRegistryTool) ExecuteAsync(_ context.Context, args map[string]any, cb AsyncCallback) *ToolResult { m.lastCB = cb return m.result } // --- helpers --- func newMockTool(name, desc string) *mockRegistryTool { return &mockRegistryTool{ name: name, desc: desc, params: map[string]any{"type": "object"}, result: SilentResult("ok"), } } // --- tests --- func TestNewToolRegistry(t *testing.T) { r := NewToolRegistry() if r.Count() != 0 { t.Errorf("expected empty registry, got count %d", r.Count()) } if len(r.List()) != 0 { t.Errorf("expected empty list, got %v", r.List()) } } func TestToolRegistry_RegisterAndGet(t *testing.T) { r := NewToolRegistry() tool := newMockTool("echo", "echoes input") r.Register(tool) got, ok := r.Get("echo") if !ok { t.Fatal("expected to find registered tool") } if got.Name() != "echo" { t.Errorf("expected name 'echo', got %q", got.Name()) } } func TestToolRegistry_Get_NotFound(t *testing.T) { r := NewToolRegistry() _, ok := r.Get("nonexistent") if ok { t.Error("expected ok=false for unregistered tool") } } func TestToolRegistry_RegisterOverwrite(t *testing.T) { r := NewToolRegistry() r.Register(newMockTool("dup", "first")) r.Register(newMockTool("dup", "second")) if r.Count() != 1 { t.Errorf("expected count 1 after overwrite, got %d", r.Count()) } tool, _ := r.Get("dup") if tool.Description() != "second" { t.Errorf("expected overwritten description 'second', got %q", tool.Description()) } } func TestToolRegistry_Execute_Success(t *testing.T) { r := NewToolRegistry() r.Register(&mockRegistryTool{ name: "greet", desc: "says hello", params: map[string]any{}, result: SilentResult("hello"), }) result := r.Execute(context.Background(), "greet", nil) if result.IsError { t.Errorf("expected success, got error: %s", result.ForLLM) } if result.ForLLM != "hello" { t.Errorf("expected ForLLM 'hello', got %q", result.ForLLM) } } func TestToolRegistry_Execute_NotFound(t *testing.T) { r := NewToolRegistry() result := r.Execute(context.Background(), "missing", nil) if !result.IsError { t.Error("expected error for missing tool") } if !strings.Contains(result.ForLLM, "not found") { t.Errorf("expected 'not found' in error, got %q", result.ForLLM) } if result.Err == nil { t.Error("expected Err to be set via WithError") } } func TestToolRegistry_ExecuteWithContext_InjectsToolContext(t *testing.T) { r := NewToolRegistry() ct := &mockContextAwareTool{ mockRegistryTool: *newMockTool("ctx_tool", "needs context"), } r.Register(ct) r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "telegram", "chat-42", nil) if ct.lastCtx == nil { t.Fatal("expected Execute to be called") } if got := ToolChannel(ct.lastCtx); got != "telegram" { t.Errorf("expected channel 'telegram', got %q", got) } if got := ToolChatID(ct.lastCtx); got != "chat-42" { t.Errorf("expected chatID 'chat-42', got %q", got) } } func TestToolRegistry_ExecuteWithContext_EmptyContext(t *testing.T) { r := NewToolRegistry() ct := &mockContextAwareTool{ mockRegistryTool: *newMockTool("ctx_tool", "needs context"), } r.Register(ct) r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "", "", nil) if ct.lastCtx == nil { t.Fatal("expected Execute to be called") } // Empty values are still injected; tools decide what to do with them. if got := ToolChannel(ct.lastCtx); got != "" { t.Errorf("expected empty channel, got %q", got) } if got := ToolChatID(ct.lastCtx); got != "" { t.Errorf("expected empty chatID, got %q", got) } } func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) { r := NewToolRegistry() at := &mockAsyncRegistryTool{ mockRegistryTool: *newMockTool("async_tool", "async work"), } at.result = AsyncResult("started") r.Register(at) called := false cb := func(_ context.Context, _ *ToolResult) { called = true } result := r.ExecuteWithContext(context.Background(), "async_tool", nil, "", "", cb) if at.lastCB == nil { t.Error("expected ExecuteAsync to have received a callback") } if !result.Async { t.Error("expected async result") } at.lastCB(context.Background(), SilentResult("done")) if !called { t.Error("expected callback to be invoked") } } func TestToolRegistry_GetDefinitions(t *testing.T) { r := NewToolRegistry() r.Register(newMockTool("alpha", "tool A")) defs := r.GetDefinitions() if len(defs) != 1 { t.Fatalf("expected 1 definition, got %d", len(defs)) } if defs[0]["type"] != "function" { t.Errorf("expected type 'function', got %v", defs[0]["type"]) } fn, ok := defs[0]["function"].(map[string]any) if !ok { t.Fatal("expected 'function' key to be a map") } if fn["name"] != "alpha" { t.Errorf("expected name 'alpha', got %v", fn["name"]) } if fn["description"] != "tool A" { t.Errorf("expected description 'tool A', got %v", fn["description"]) } } func TestToolRegistry_ToProviderDefs(t *testing.T) { r := NewToolRegistry() params := map[string]any{"type": "object", "properties": map[string]any{}} r.Register(&mockRegistryTool{ name: "beta", desc: "tool B", params: params, result: SilentResult("ok"), }) defs := r.ToProviderDefs() if len(defs) != 1 { t.Fatalf("expected 1 provider def, got %d", len(defs)) } want := providers.ToolDefinition{ Type: "function", Function: providers.ToolFunctionDefinition{ Name: "beta", Description: "tool B", Parameters: params, }, } got := defs[0] if got.Type != want.Type { t.Errorf("Type: want %q, got %q", want.Type, got.Type) } if got.Function.Name != want.Function.Name { t.Errorf("Name: want %q, got %q", want.Function.Name, got.Function.Name) } if got.Function.Description != want.Function.Description { t.Errorf("Description: want %q, got %q", want.Function.Description, got.Function.Description) } } func TestToolRegistry_List(t *testing.T) { r := NewToolRegistry() r.Register(newMockTool("x", "")) r.Register(newMockTool("y", "")) names := r.List() if len(names) != 2 { t.Fatalf("expected 2 names, got %d", len(names)) } nameSet := map[string]bool{} for _, n := range names { nameSet[n] = true } if !nameSet["x"] || !nameSet["y"] { t.Errorf("expected names {x, y}, got %v", names) } } func TestToolRegistry_Count(t *testing.T) { r := NewToolRegistry() if r.Count() != 0 { t.Errorf("expected 0, got %d", r.Count()) } r.Register(newMockTool("a", "")) r.Register(newMockTool("b", "")) if r.Count() != 2 { t.Errorf("expected 2, got %d", r.Count()) } r.Register(newMockTool("a", "replaced")) if r.Count() != 2 { t.Errorf("expected 2 after overwrite, got %d", r.Count()) } } func TestToolRegistry_GetSummaries(t *testing.T) { r := NewToolRegistry() r.Register(newMockTool("read_file", "Reads a file")) summaries := r.GetSummaries() if len(summaries) != 1 { t.Fatalf("expected 1 summary, got %d", len(summaries)) } if !strings.Contains(summaries[0], "`read_file`") { t.Errorf("expected backtick-quoted name in summary, got %q", summaries[0]) } if !strings.Contains(summaries[0], "Reads a file") { t.Errorf("expected description in summary, got %q", summaries[0]) } } func TestToolToSchema(t *testing.T) { tool := newMockTool("demo", "demo tool") schema := ToolToSchema(tool) if schema["type"] != "function" { t.Errorf("expected type 'function', got %v", schema["type"]) } fn, ok := schema["function"].(map[string]any) if !ok { t.Fatal("expected 'function' to be a map") } if fn["name"] != "demo" { t.Errorf("expected name 'demo', got %v", fn["name"]) } if fn["description"] != "demo tool" { t.Errorf("expected description 'demo tool', got %v", fn["description"]) } if fn["parameters"] == nil { t.Error("expected parameters to be set") } } func TestToolRegistry_Clone(t *testing.T) { r := NewToolRegistry() r.Register(newMockTool("read_file", "reads files")) r.Register(newMockTool("exec", "runs commands")) r.Register(newMockTool("web_search", "searches the web")) clone := r.Clone() // Clone should have the same tools if clone.Count() != 3 { t.Errorf("expected clone to have 3 tools, got %d", clone.Count()) } for _, name := range []string{"read_file", "exec", "web_search"} { if _, ok := clone.Get(name); !ok { t.Errorf("expected clone to have tool %q", name) } } // Registering on parent should NOT affect clone r.Register(newMockTool("spawn", "spawns subagent")) if r.Count() != 4 { t.Errorf("expected parent to have 4 tools, got %d", r.Count()) } if clone.Count() != 3 { t.Errorf("expected clone to still have 3 tools after parent mutation, got %d", clone.Count()) } if _, ok := clone.Get("spawn"); ok { t.Error("expected clone NOT to have 'spawn' tool registered on parent after cloning") } // Registering on clone should NOT affect parent clone.Register(newMockTool("custom", "custom tool")) if clone.Count() != 4 { t.Errorf("expected clone to have 4 tools, got %d", clone.Count()) } if _, ok := r.Get("custom"); ok { t.Error("expected parent NOT to have 'custom' tool registered on clone") } } func TestToolRegistry_Clone_Empty(t *testing.T) { r := NewToolRegistry() clone := r.Clone() if clone.Count() != 0 { t.Errorf("expected empty clone, got count %d", clone.Count()) } } func TestToolRegistry_Clone_PreservesHiddenToolState(t *testing.T) { r := NewToolRegistry() r.RegisterHidden(newMockTool("mcp_tool", "dynamic MCP tool")) clone := r.Clone() // Hidden tools with TTL=0 should not be gettable (same behavior as parent) if _, ok := clone.Get("mcp_tool"); ok { t.Error("expected hidden tool with TTL=0 to be invisible in clone") } // But the entry should exist (count includes hidden tools) if clone.Count() != 1 { t.Errorf("expected clone count 1 (hidden entry exists), got %d", clone.Count()) } } func TestToolRegistry_Clone_PreservesTTLValue(t *testing.T) { r := NewToolRegistry() r.RegisterHidden(newMockTool("ttl_tool", "tool with TTL")) // Manually set a non-zero TTL on the entry r.mu.RLock() if entry, ok := r.tools["ttl_tool"]; ok { entry.TTL = 5 } r.mu.RUnlock() clone := r.Clone() // Verify TTL value is preserved in the clone clone.mu.RLock() defer clone.mu.RUnlock() entry, ok := clone.tools["ttl_tool"] if !ok { t.Fatal("expected ttl_tool to exist in clone") } if entry.TTL != 5 { t.Errorf("expected TTL=5 in clone, got %d", entry.TTL) } } func TestToolRegistry_ConcurrentAccess(t *testing.T) { r := NewToolRegistry() var wg sync.WaitGroup for i := range 50 { wg.Add(1) go func(n int) { defer wg.Done() name := string(rune('A' + n%26)) r.Register(newMockTool(name, "concurrent")) r.Get(name) r.Count() r.List() r.GetDefinitions() }(i) } wg.Wait() if r.Count() == 0 { t.Error("expected tools to be registered after concurrent access") } } // --- Panic and abnormal exit tests --- // mockPanicTool is a tool that panics during execution type mockPanicTool struct { name string panicValue any } func (m *mockPanicTool) Name() string { return m.name } func (m *mockPanicTool) Description() string { return "a tool that panics" } func (m *mockPanicTool) Parameters() map[string]any { return map[string]any{"type": "object"} } func (m *mockPanicTool) Execute(_ context.Context, _ map[string]any) *ToolResult { panic(m.panicValue) } // mockNilResultTool is a tool that returns nil type mockNilResultTool struct { name string } func (m *mockNilResultTool) Name() string { return m.name } func (m *mockNilResultTool) Description() string { return "a tool that returns nil" } func (m *mockNilResultTool) Parameters() map[string]any { return map[string]any{"type": "object"} } func (m *mockNilResultTool) Execute(_ context.Context, _ map[string]any) *ToolResult { return nil } func TestToolRegistry_Execute_PanicRecovery(t *testing.T) { r := NewToolRegistry() r.Register(&mockPanicTool{ name: "panic_tool", panicValue: "something went terribly wrong", }) // Should not panic, should return error result result := r.Execute(context.Background(), "panic_tool", nil) if result == nil { t.Fatal("expected non-nil result after panic recovery") } if !result.IsError { t.Error("expected IsError=true after panic") } if !strings.Contains(result.ForLLM, "panic") { t.Errorf("expected 'panic' in error message, got %q", result.ForLLM) } if !strings.Contains(result.ForLLM, "panic_tool") { t.Errorf("expected tool name in error message, got %q", result.ForLLM) } if !strings.Contains(result.ForLLM, "something went terribly wrong") { t.Errorf("expected panic value in error message, got %q", result.ForLLM) } if result.Err == nil { t.Error("expected Err to be set") } } func TestToolRegistry_Execute_PanicRecovery_ErrorType(t *testing.T) { r := NewToolRegistry() // Test with error type panic r.Register(&mockPanicTool{ name: "error_panic_tool", panicValue: errors.New("custom error panic"), }) result := r.Execute(context.Background(), "error_panic_tool", nil) if !result.IsError { t.Error("expected IsError=true") } if !strings.Contains(result.ForLLM, "custom error panic") { t.Errorf("expected error message in ForLLM, got %q", result.ForLLM) } } func TestToolRegistry_Execute_PanicRecovery_IntType(t *testing.T) { r := NewToolRegistry() // Test with int type panic r.Register(&mockPanicTool{ name: "int_panic_tool", panicValue: 42, }) result := r.Execute(context.Background(), "int_panic_tool", nil) if !result.IsError { t.Error("expected IsError=true") } if !strings.Contains(result.ForLLM, "42") { t.Errorf("expected panic value '42' in ForLLM, got %q", result.ForLLM) } } func TestToolRegistry_Execute_NilResultHandling(t *testing.T) { r := NewToolRegistry() r.Register(&mockNilResultTool{name: "nil_tool"}) result := r.Execute(context.Background(), "nil_tool", nil) if result == nil { t.Fatal("expected non-nil result when tool returns nil") } if !result.IsError { t.Error("expected IsError=true for nil result") } if !strings.Contains(result.ForLLM, "nil_tool") { t.Errorf("expected tool name in error message, got %q", result.ForLLM) } if !strings.Contains(result.ForLLM, "nil result") { t.Errorf("expected 'nil result' in error message, got %q", result.ForLLM) } if result.Err == nil { t.Error("expected Err to be set") } } func TestToolRegistry_ExecuteWithContext_PanicRecovery(t *testing.T) { r := NewToolRegistry() r.Register(&mockPanicTool{ name: "ctx_panic_tool", panicValue: "context panic test", }) // Should not panic even with context result := r.ExecuteWithContext( context.Background(), "ctx_panic_tool", map[string]any{"key": "value"}, "telegram", "chat-123", nil, ) if result == nil { t.Fatal("expected non-nil result") } if !result.IsError { t.Error("expected IsError=true") } if !strings.Contains(result.ForLLM, "context panic test") { t.Errorf("expected panic message, got %q", result.ForLLM) } } func TestToolRegistry_Execute_PanicDoesNotAffectOtherTools(t *testing.T) { r := NewToolRegistry() r.Register(&mockPanicTool{name: "bad_tool", panicValue: "boom"}) r.Register(&mockRegistryTool{ name: "good_tool", desc: "works fine", params: map[string]any{}, result: SilentResult("success"), }) // First, trigger the panic result1 := r.Execute(context.Background(), "bad_tool", nil) if !result1.IsError { t.Error("expected error from panic tool") } // Then, verify the good tool still works result2 := r.Execute(context.Background(), "good_tool", nil) if result2.IsError { t.Errorf("expected success from good tool, got error: %s", result2.ForLLM) } if result2.ForLLM != "success" { t.Errorf("expected 'success', got %q", result2.ForLLM) } } ================================================ FILE: pkg/tools/result.go ================================================ package tools import "encoding/json" // ToolResult represents the structured return value from tool execution. // It provides clear semantics for different types of results and supports // async operations, user-facing messages, and error handling. type ToolResult struct { // ForLLM is the content sent to the LLM for context. // Required for all results. ForLLM string `json:"for_llm"` // ForUser is the content sent directly to the user. // If empty, no user message is sent. // Silent=true overrides this field. ForUser string `json:"for_user,omitempty"` // Silent suppresses sending any message to the user. // When true, ForUser is ignored even if set. Silent bool `json:"silent"` // IsError indicates whether the tool execution failed. // When true, the result should be treated as an error. IsError bool `json:"is_error"` // Async indicates whether the tool is running asynchronously. // When true, the tool will complete later and notify via callback. Async bool `json:"async"` // Err is the underlying error (not JSON serialized). // Used for internal error handling and logging. Err error `json:"-"` // Media contains media store refs produced by this tool. // When non-empty, the agent will publish these as OutboundMediaMessage. Media []string `json:"media,omitempty"` } // NewToolResult creates a basic ToolResult with content for the LLM. // Use this when you need a simple result with default behavior. // // Example: // // result := NewToolResult("File updated successfully") func NewToolResult(forLLM string) *ToolResult { return &ToolResult{ ForLLM: forLLM, } } // SilentResult creates a ToolResult that is silent (no user message). // The content is only sent to the LLM for context. // // Use this for operations that should not spam the user, such as: // - File reads/writes // - Status updates // - Background operations // // Example: // // result := SilentResult("Config file saved") func SilentResult(forLLM string) *ToolResult { return &ToolResult{ ForLLM: forLLM, Silent: true, IsError: false, Async: false, } } // AsyncResult creates a ToolResult for async operations. // The task will run in the background and complete later. // // Use this for long-running operations like: // - Subagent spawns // - Background processing // - External API calls with callbacks // // Example: // // result := AsyncResult("Subagent spawned, will report back") func AsyncResult(forLLM string) *ToolResult { return &ToolResult{ ForLLM: forLLM, Silent: false, IsError: false, Async: true, } } // ErrorResult creates a ToolResult representing an error. // Sets IsError=true and includes the error message. // // Example: // // result := ErrorResult("Failed to connect to database: connection refused") func ErrorResult(message string) *ToolResult { return &ToolResult{ ForLLM: message, Silent: false, IsError: true, Async: false, } } // UserResult creates a ToolResult with content for both LLM and user. // Both ForLLM and ForUser are set to the same content. // // Use this when the user needs to see the result directly: // - Command execution output // - Fetched web content // - Query results // // Example: // // result := UserResult("Total files found: 42") func UserResult(content string) *ToolResult { return &ToolResult{ ForLLM: content, ForUser: content, Silent: false, IsError: false, Async: false, } } // MediaResult creates a ToolResult with media refs for the user. // The agent will publish these refs as OutboundMediaMessage. // // Example: // // result := MediaResult("Image generated successfully", []string{"media://abc123"}) func MediaResult(forLLM string, mediaRefs []string) *ToolResult { return &ToolResult{ ForLLM: forLLM, Media: mediaRefs, } } // MarshalJSON implements custom JSON serialization. // The Err field is excluded from JSON output via the json:"-" tag. func (tr *ToolResult) MarshalJSON() ([]byte, error) { type Alias ToolResult return json.Marshal(&struct { *Alias }{ Alias: (*Alias)(tr), }) } // WithError sets the Err field and returns the result for chaining. // This preserves the error for logging while keeping it out of JSON. // // Example: // // result := ErrorResult("Operation failed").WithError(err) func (tr *ToolResult) WithError(err error) *ToolResult { tr.Err = err return tr } ================================================ FILE: pkg/tools/result_test.go ================================================ package tools import ( "encoding/json" "errors" "testing" ) func TestNewToolResult(t *testing.T) { result := NewToolResult("test content") if result.ForLLM != "test content" { t.Errorf("Expected ForLLM 'test content', got '%s'", result.ForLLM) } if result.Silent { t.Error("Expected Silent to be false") } if result.IsError { t.Error("Expected IsError to be false") } if result.Async { t.Error("Expected Async to be false") } } func TestSilentResult(t *testing.T) { result := SilentResult("silent operation") if result.ForLLM != "silent operation" { t.Errorf("Expected ForLLM 'silent operation', got '%s'", result.ForLLM) } if !result.Silent { t.Error("Expected Silent to be true") } if result.IsError { t.Error("Expected IsError to be false") } if result.Async { t.Error("Expected Async to be false") } } func TestAsyncResult(t *testing.T) { result := AsyncResult("async task started") if result.ForLLM != "async task started" { t.Errorf("Expected ForLLM 'async task started', got '%s'", result.ForLLM) } if result.Silent { t.Error("Expected Silent to be false") } if result.IsError { t.Error("Expected IsError to be false") } if !result.Async { t.Error("Expected Async to be true") } } func TestErrorResult(t *testing.T) { result := ErrorResult("operation failed") if result.ForLLM != "operation failed" { t.Errorf("Expected ForLLM 'operation failed', got '%s'", result.ForLLM) } if result.Silent { t.Error("Expected Silent to be false") } if !result.IsError { t.Error("Expected IsError to be true") } if result.Async { t.Error("Expected Async to be false") } } func TestUserResult(t *testing.T) { content := "user visible message" result := UserResult(content) if result.ForLLM != content { t.Errorf("Expected ForLLM '%s', got '%s'", content, result.ForLLM) } if result.ForUser != content { t.Errorf("Expected ForUser '%s', got '%s'", content, result.ForUser) } if result.Silent { t.Error("Expected Silent to be false") } if result.IsError { t.Error("Expected IsError to be false") } if result.Async { t.Error("Expected Async to be false") } } func TestToolResultJSONSerialization(t *testing.T) { tests := []struct { name string result *ToolResult }{ { name: "basic result", result: NewToolResult("basic content"), }, { name: "silent result", result: SilentResult("silent content"), }, { name: "async result", result: AsyncResult("async content"), }, { name: "error result", result: ErrorResult("error content"), }, { name: "user result", result: UserResult("user content"), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Marshal to JSON data, err := json.Marshal(tt.result) if err != nil { t.Fatalf("Failed to marshal: %v", err) } // Unmarshal back var decoded ToolResult if err := json.Unmarshal(data, &decoded); err != nil { t.Fatalf("Failed to unmarshal: %v", err) } // Verify fields match (Err should be excluded) if decoded.ForLLM != tt.result.ForLLM { t.Errorf("ForLLM mismatch: got '%s', want '%s'", decoded.ForLLM, tt.result.ForLLM) } if decoded.ForUser != tt.result.ForUser { t.Errorf("ForUser mismatch: got '%s', want '%s'", decoded.ForUser, tt.result.ForUser) } if decoded.Silent != tt.result.Silent { t.Errorf("Silent mismatch: got %v, want %v", decoded.Silent, tt.result.Silent) } if decoded.IsError != tt.result.IsError { t.Errorf("IsError mismatch: got %v, want %v", decoded.IsError, tt.result.IsError) } if decoded.Async != tt.result.Async { t.Errorf("Async mismatch: got %v, want %v", decoded.Async, tt.result.Async) } }) } } func TestToolResultWithErrors(t *testing.T) { err := errors.New("underlying error") result := ErrorResult("error message").WithError(err) if result.Err == nil { t.Error("Expected Err to be set") } if result.Err.Error() != "underlying error" { t.Errorf("Expected Err message 'underlying error', got '%s'", result.Err.Error()) } // Verify Err is not serialized data, marshalErr := json.Marshal(result) if marshalErr != nil { t.Fatalf("Failed to marshal: %v", marshalErr) } var decoded ToolResult if unmarshalErr := json.Unmarshal(data, &decoded); unmarshalErr != nil { t.Fatalf("Failed to unmarshal: %v", unmarshalErr) } if decoded.Err != nil { t.Error("Expected Err to be nil after JSON round-trip (should not be serialized)") } } func TestToolResultJSONStructure(t *testing.T) { result := UserResult("test content") data, err := json.Marshal(result) if err != nil { t.Fatalf("Failed to marshal: %v", err) } // Verify JSON structure var parsed map[string]any if err := json.Unmarshal(data, &parsed); err != nil { t.Fatalf("Failed to parse JSON: %v", err) } // Check expected keys exist if _, ok := parsed["for_llm"]; !ok { t.Error("Expected 'for_llm' key in JSON") } if _, ok := parsed["for_user"]; !ok { t.Error("Expected 'for_user' key in JSON") } if _, ok := parsed["silent"]; !ok { t.Error("Expected 'silent' key in JSON") } if _, ok := parsed["is_error"]; !ok { t.Error("Expected 'is_error' key in JSON") } if _, ok := parsed["async"]; !ok { t.Error("Expected 'async' key in JSON") } // Check that 'err' is NOT present (it should have json:"-" tag) if _, ok := parsed["err"]; ok { t.Error("Expected 'err' key to be excluded from JSON") } // Verify values if parsed["for_llm"] != "test content" { t.Errorf("Expected for_llm 'test content', got %v", parsed["for_llm"]) } if parsed["silent"] != false { t.Errorf("Expected silent false, got %v", parsed["silent"]) } } ================================================ FILE: pkg/tools/search_tool.go ================================================ package tools import ( "context" "encoding/json" "fmt" "regexp" "strings" "sync" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) const ( MaxRegexPatternLength = 200 ) type RegexSearchTool struct { registry *ToolRegistry ttl int maxSearchResults int } func NewRegexSearchTool(r *ToolRegistry, ttl int, maxSearchResults int) *RegexSearchTool { return &RegexSearchTool{registry: r, ttl: ttl, maxSearchResults: maxSearchResults} } func (t *RegexSearchTool) Name() string { return "tool_search_tool_regex" } func (t *RegexSearchTool) Description() string { return "Search available hidden tools on-demand using a regex pattern. Returns JSON schemas of discovered tools." } func (t *RegexSearchTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "pattern": map[string]any{ "type": "string", "description": "Regex pattern to match tool name or description", }, }, "required": []string{"pattern"}, } } func (t *RegexSearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { pattern, ok := args["pattern"].(string) if !ok || strings.TrimSpace(pattern) == "" { // An empty string regex (?i) will match every hidden tool, // dumping massive payloads into the context and burning tokens. return ErrorResult("Missing or invalid 'pattern' argument. Must be a non-empty string.") } if len(pattern) > MaxRegexPatternLength { logger.WarnCF("discovery", "Regex pattern rejected (too long)", map[string]any{"len": len(pattern)}) return ErrorResult(fmt.Sprintf("Pattern too long: max %d characters allowed", MaxRegexPatternLength)) } logger.DebugCF("discovery", "Regex search", map[string]any{"pattern": pattern}) res, err := t.registry.SearchRegex(pattern, t.maxSearchResults) if err != nil { logger.WarnCF("discovery", "Invalid regex pattern", map[string]any{"pattern": pattern, "error": err.Error()}) return ErrorResult(fmt.Sprintf("Invalid regex pattern syntax: %v. Please fix your regex and try again.", err)) } logger.InfoCF("discovery", "Regex search completed", map[string]any{"pattern": pattern, "results": len(res)}) return formatDiscoveryResponse(t.registry, res, t.ttl) } type BM25SearchTool struct { registry *ToolRegistry ttl int maxSearchResults int // Cache: rebuilt only when the registry version changes. cacheMu sync.Mutex cachedEngine *bm25CachedEngine cacheVersion uint64 } func NewBM25SearchTool(r *ToolRegistry, ttl int, maxSearchResults int) *BM25SearchTool { return &BM25SearchTool{registry: r, ttl: ttl, maxSearchResults: maxSearchResults} } func (t *BM25SearchTool) Name() string { return "tool_search_tool_bm25" } func (t *BM25SearchTool) Description() string { return "Search available hidden tools on-demand using natural language query describing the action you need to perform. Returns JSON schemas of discovered tools." } func (t *BM25SearchTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "query": map[string]any{ "type": "string", "description": "Search query", }, }, "required": []string{"query"}, } } func (t *BM25SearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { query, ok := args["query"].(string) if !ok || strings.TrimSpace(query) == "" { // An empty string query will match every hidden tool, // dumping massive payloads into the context and burning tokens. return ErrorResult("Missing or invalid 'query' argument. Must be a non-empty string.") } logger.DebugCF("discovery", "BM25 search", map[string]any{"query": query}) cached := t.getOrBuildEngine() if cached == nil { logger.DebugCF("discovery", "BM25 search: no hidden tools available", nil) return SilentResult("No tools found matching the query.") } ranked := cached.engine.Search(query, t.maxSearchResults) if len(ranked) == 0 { logger.DebugCF("discovery", "BM25 search: no matches", map[string]any{"query": query}) return SilentResult("No tools found matching the query.") } results := make([]ToolSearchResult, len(ranked)) for i, r := range ranked { results[i] = ToolSearchResult{ Name: r.Document.Name, Description: r.Document.Description, } } logger.InfoCF("discovery", "BM25 search completed", map[string]any{"query": query, "results": len(results)}) return formatDiscoveryResponse(t.registry, results, t.ttl) } // ToolSearchResult represents the result returned to the LLM. // Parameters are omitted from the JSON response to save context tokens; // the LLM will see full schemas via ToProviderDefs after promotion. type ToolSearchResult struct { Name string `json:"name"` Description string `json:"description"` } func (r *ToolRegistry) SearchRegex(pattern string, maxSearchResults int) ([]ToolSearchResult, error) { if maxSearchResults <= 0 { return nil, nil } regex, err := regexp.Compile("(?i)" + pattern) if err != nil { return nil, fmt.Errorf("failed to compile regex pattern %q: %w", pattern, err) } r.mu.RLock() defer r.mu.RUnlock() var results []ToolSearchResult // Iterate in sorted order for deterministic results across calls. for _, name := range r.sortedToolNames() { entry := r.tools[name] // Search only among the hidden tools (Core tools are already visible) if !entry.IsCore { // Directly call interface methods! No reflection/unmarshalling needed. desc := entry.Tool.Description() if regex.MatchString(name) || regex.MatchString(desc) { results = append(results, ToolSearchResult{ Name: name, Description: desc, }) if len(results) >= maxSearchResults { break // Stop searching once we hit the max! Saves CPU. } } } } return results, nil } func formatDiscoveryResponse(registry *ToolRegistry, results []ToolSearchResult, ttl int) *ToolResult { if len(results) == 0 { return SilentResult("No tools found matching the query.") } names := make([]string, len(results)) for i, r := range results { names[i] = r.Name } registry.PromoteTools(names, ttl) logger.InfoCF("discovery", "Promoted tools", map[string]any{"tools": names, "ttl": ttl}) b, err := json.Marshal(results) if err != nil { return ErrorResult("Failed to format search results: " + err.Error()) } msg := fmt.Sprintf( "Found %d tools:\n%s\n\nSUCCESS: These tools have been temporarily UNLOCKED as native tools! In your next response, you can call them directly just like any normal tool", len(results), string(b), ) return SilentResult(msg) } // Lightweight internal type used as corpus document for BM25. type searchDoc struct { Name string Description string } // bm25CachedEngine wraps a BM25Engine with its corpus snapshot. type bm25CachedEngine struct { engine *utils.BM25Engine[searchDoc] } // snapshotToSearchDocs converts a HiddenToolSnapshot to BM25 searchDoc slice. func snapshotToSearchDocs(snap HiddenToolSnapshot) []searchDoc { docs := make([]searchDoc, len(snap.Docs)) for i, d := range snap.Docs { docs[i] = searchDoc{Name: d.Name, Description: d.Description} } return docs } // buildBM25Engine creates a BM25Engine from a slice of searchDocs. func buildBM25Engine(docs []searchDoc) *utils.BM25Engine[searchDoc] { return utils.NewBM25Engine( docs, func(doc searchDoc) string { return doc.Name + " " + doc.Description }, ) } // getOrBuildEngine returns a cached BM25 engine, rebuilding it only when // the registry version has changed (new tools registered). func (t *BM25SearchTool) getOrBuildEngine() *bm25CachedEngine { // Fast path: optimistic check without locking. if t.cachedEngine != nil && t.cacheVersion == t.registry.Version() { return t.cachedEngine } t.cacheMu.Lock() defer t.cacheMu.Unlock() // Snapshot + version are read under a single registry RLock, // guaranteeing consistency (no TOCTOU). snap := t.registry.SnapshotHiddenTools() // Re-check: another goroutine may have rebuilt while we waited for cacheMu. if t.cachedEngine != nil && t.cacheVersion == snap.Version { return t.cachedEngine } docs := snapshotToSearchDocs(snap) if len(docs) == 0 { t.cachedEngine = nil t.cacheVersion = snap.Version return nil } cached := &bm25CachedEngine{engine: buildBM25Engine(docs)} t.cachedEngine = cached t.cacheVersion = snap.Version logger.DebugCF("discovery", "BM25 engine rebuilt", map[string]any{"docs": len(docs), "version": snap.Version}) return cached } // SearchBM25 ranks hidden tools against query using BM25 via utils.BM25Engine. // This non-cached variant rebuilds the engine on every call. Used by tests // and any code that doesn't hold a BM25SearchTool instance. func (r *ToolRegistry) SearchBM25(query string, maxSearchResults int) []ToolSearchResult { snap := r.SnapshotHiddenTools() docs := snapshotToSearchDocs(snap) if len(docs) == 0 { return nil } ranked := buildBM25Engine(docs).Search(query, maxSearchResults) if len(ranked) == 0 { return nil } out := make([]ToolSearchResult, len(ranked)) for i, r := range ranked { out[i] = ToolSearchResult{ Name: r.Document.Name, Description: r.Document.Description, } } return out } ================================================ FILE: pkg/tools/search_tools_test.go ================================================ package tools import ( "context" "fmt" "strings" "testing" ) // Dummy tool to fill the registry in our tests. type mockSearchableTool struct { name string desc string } func (m *mockSearchableTool) Name() string { return m.name } func (m *mockSearchableTool) Description() string { return m.desc } func (m *mockSearchableTool) Parameters() map[string]any { return map[string]any{"type": "object"} } func (m *mockSearchableTool) Execute(ctx context.Context, args map[string]any) *ToolResult { return SilentResult("mock executed: " + m.name) } // Helper to initialize a populated ToolRegistry func setupPopulatedRegistry() *ToolRegistry { reg := NewToolRegistry() // A core tool (NOT to be found by searches) reg.Register(&mockSearchableTool{ name: "core_search", desc: "I am a visible core tool for searching files", }) // Hidden tools (must be found by searches) reg.RegisterHidden(&mockSearchableTool{ name: "mcp_read_file", desc: "Read the contents of a system file", }) reg.RegisterHidden(&mockSearchableTool{ name: "mcp_list_dir", desc: "List directories and files in the system", }) reg.RegisterHidden(&mockSearchableTool{ name: "mcp_fetch_net", desc: "Fetch data from a network database", }) return reg } func TestRegexSearchTool_Execute(t *testing.T) { reg := setupPopulatedRegistry() tool := NewRegexSearchTool(reg, 5, 10) ctx := context.Background() t.Run("Empty Pattern Error", func(t *testing.T) { res := tool.Execute(ctx, map[string]any{}) if !res.IsError || !strings.Contains(res.ForLLM, "Missing or invalid 'pattern'") { t.Errorf("Expected missing pattern error, got: %v", res.ForLLM) } }) t.Run("Invalid Regex Syntax", func(t *testing.T) { res := tool.Execute(ctx, map[string]any{"pattern": "[unclosed"}) if !res.IsError || !strings.Contains(res.ForLLM, "Invalid regex pattern syntax") { t.Errorf("Expected regex syntax error, got: %v", res.ForLLM) } }) t.Run("No Match Found", func(t *testing.T) { res := tool.Execute(ctx, map[string]any{"pattern": "alien"}) if res.IsError || !strings.Contains(res.ForLLM, "No tools found matching") { t.Errorf("Expected 'no tools found' message, got: %v", res.ForLLM) } }) t.Run("Successful Match & Promotion", func(t *testing.T) { res := tool.Execute(ctx, map[string]any{"pattern": "system"}) if res.IsError { t.Fatalf("Unexpected error: %v", res.ForLLM) } if !strings.Contains(res.ForLLM, "SUCCESS: These tools have been temporarily UNLOCKED") { t.Errorf("Expected success string, got: %v", res.ForLLM) } if !strings.Contains(res.ForLLM, "mcp_read_file") { t.Errorf("Expected 'mcp_read_file' in results") } // Verify that the TTL has been updated for the tools found reg.mu.RLock() defer reg.mu.RUnlock() if reg.tools["mcp_read_file"].TTL != 5 { t.Errorf("Expected TTL of 'mcp_read_file' to be promoted to 5, got %d", reg.tools["mcp_read_file"].TTL) } if reg.tools["mcp_fetch_net"].TTL != 0 { t.Errorf("Expected 'mcp_fetch_net' to NOT be promoted (TTL=0)") } }) } func TestBM25SearchTool_Execute(t *testing.T) { reg := setupPopulatedRegistry() tool := NewBM25SearchTool(reg, 3, 10) ctx := context.Background() t.Run("Empty Query Error", func(t *testing.T) { res := tool.Execute(ctx, map[string]any{"query": " "}) if !res.IsError || !strings.Contains(res.ForLLM, "Missing or invalid 'query'") { t.Errorf("Expected missing query error, got: %v", res.ForLLM) } }) t.Run("No Match Found", func(t *testing.T) { res := tool.Execute(ctx, map[string]any{"query": "aliens spaceships"}) if res.IsError || !strings.Contains(res.ForLLM, "No tools found matching") { t.Errorf("Expected 'no tools found', got: %v", res.ForLLM) } }) t.Run("Successful Match & Promotion", func(t *testing.T) { res := tool.Execute(ctx, map[string]any{"query": "read files"}) if res.IsError { t.Fatalf("Unexpected error: %v", res.ForLLM) } if !strings.Contains(res.ForLLM, "mcp_read_file") { t.Errorf("Expected 'mcp_read_file' in BM25 results") } reg.mu.RLock() defer reg.mu.RUnlock() if reg.tools["mcp_read_file"].TTL != 3 { t.Errorf("Expected TTL of 'mcp_read_file' to be promoted to 3") } }) } func TestRegexSearchTool_PatternTooLong(t *testing.T) { reg := setupPopulatedRegistry() tool := NewRegexSearchTool(reg, 5, 10) ctx := context.Background() longPattern := strings.Repeat("a", MaxRegexPatternLength+1) res := tool.Execute(ctx, map[string]any{"pattern": longPattern}) if !res.IsError || !strings.Contains(res.ForLLM, "Pattern too long") { t.Errorf("Expected pattern too long error, got: %v", res.ForLLM) } } func TestSearchRegex_ZeroMaxResults(t *testing.T) { reg := setupPopulatedRegistry() res, err := reg.SearchRegex("mcp", 0) if err != nil { t.Fatalf("SearchRegex failed: %v", err) } if len(res) != 0 { t.Errorf("Expected 0 results with maxSearchResults=0, got %d", len(res)) } } func TestSearchBM25_ZeroMaxResults(t *testing.T) { reg := setupPopulatedRegistry() res := reg.SearchBM25("read file", 0) if len(res) != 0 { t.Errorf("Expected 0 results with maxSearchResults=0, got %d", len(res)) } } func TestSearchRegex_DeterministicOrder(t *testing.T) { reg := NewToolRegistry() for i := 0; i < 20; i++ { reg.RegisterHidden(&mockSearchableTool{ name: fmt.Sprintf("tool_%02d", i), desc: "searchable tool", }) } // Run the same search multiple times and verify order is stable var firstRun []string for attempt := 0; attempt < 10; attempt++ { res, err := reg.SearchRegex("searchable", 20) if err != nil { t.Fatalf("SearchRegex failed: %v", err) } names := make([]string, len(res)) for i, r := range res { names[i] = r.Name } if attempt == 0 { firstRun = names } else { for i, name := range names { if name != firstRun[i] { t.Fatalf("Non-deterministic order at attempt %d, index %d: got %q, want %q", attempt, i, name, firstRun[i]) } } } } } func TestToolRegistry_SearchLimitsAndCoreFiltering(t *testing.T) { reg := NewToolRegistry() // Add 1 Core and 10 Hidden, all containing the word "match" reg.Register(&mockSearchableTool{"core_match", "I am core with match"}) for i := 0; i < 10; i++ { reg.RegisterHidden(&mockSearchableTool{ name: fmt.Sprintf("hidden_match_%d", i), desc: "this has a match", }) } t.Run("Regex limits and core filtering", func(t *testing.T) { // Search with Regex and a limit of maxSearchResults = 4 res, err := reg.SearchRegex("match", 4) if err != nil { t.Fatalf("SearchRegex failed: %v", err) } if len(res) != 4 { t.Errorf("Expected exactly 4 results due to limit, got %d", len(res)) } for _, r := range res { if r.Name == "core_match" { t.Errorf("SearchRegex returned a Core tool, which should be excluded") } } }) t.Run("BM25 limits and core filtering", func(t *testing.T) { // Search with BM25 and a limit of maxSearchResults = 3 res := reg.SearchBM25("match", 3) if len(res) != 3 { t.Errorf("Expected exactly 3 results due to limit, got %d", len(res)) } for _, r := range res { if r.Name == "core_match" { t.Errorf("SearchBM25 returned a Core tool, which should be excluded") } } }) } func TestGet_HiddenToolTTLLifecycle(t *testing.T) { reg := NewToolRegistry() reg.RegisterHidden(&mockSearchableTool{name: "hidden_tool", desc: "test"}) // TTL=0 at registration → not gettable _, ok := reg.Get("hidden_tool") if ok { t.Error("Expected hidden tool with TTL=0 to NOT be gettable") } // Promote → gettable reg.PromoteTools([]string{"hidden_tool"}, 3) _, ok = reg.Get("hidden_tool") if !ok { t.Error("Expected promoted hidden tool to be gettable") } // Tick down to 0 → not gettable again reg.TickTTL() // 3→2 reg.TickTTL() // 2→1 reg.TickTTL() // 1→0 _, ok = reg.Get("hidden_tool") if ok { t.Error("Expected hidden tool with TTL ticked to 0 to NOT be gettable") } // Core tools remain always gettable reg.Register(&mockSearchableTool{name: "core_tool", desc: "core"}) _, ok = reg.Get("core_tool") if !ok { t.Error("Expected core tool to always be gettable") } } func TestBM25CacheInvalidation(t *testing.T) { reg := NewToolRegistry() reg.RegisterHidden(&mockSearchableTool{name: "tool_alpha", desc: "alpha functionality"}) tool := NewBM25SearchTool(reg, 5, 10) ctx := context.Background() // First search should find tool_alpha res := tool.Execute(ctx, map[string]any{"query": "alpha"}) if !strings.Contains(res.ForLLM, "tool_alpha") { t.Fatalf("Expected 'tool_alpha' in first search, got: %v", res.ForLLM) } // Register a new hidden tool reg.RegisterHidden(&mockSearchableTool{name: "tool_beta", desc: "beta functionality"}) // Cache should be invalidated; new tool should be findable res = tool.Execute(ctx, map[string]any{"query": "beta"}) if !strings.Contains(res.ForLLM, "tool_beta") { t.Errorf("Expected 'tool_beta' after cache invalidation, got: %v", res.ForLLM) } } func TestPromoteTools_ConcurrentWithTickTTL(t *testing.T) { reg := NewToolRegistry() for i := 0; i < 20; i++ { reg.RegisterHidden(&mockSearchableTool{ name: fmt.Sprintf("concurrent_tool_%d", i), desc: "concurrent test tool", }) } names := make([]string, 20) for i := 0; i < 20; i++ { names[i] = fmt.Sprintf("concurrent_tool_%d", i) } // Hammer PromoteTools and TickTTL concurrently to detect races done := make(chan struct{}) go func() { for i := 0; i < 1000; i++ { reg.PromoteTools(names, 5) } close(done) }() for i := 0; i < 1000; i++ { reg.TickTTL() } <-done } ================================================ FILE: pkg/tools/send_file.go ================================================ package tools import ( "context" "fmt" "mime" "os" "path/filepath" "regexp" "strings" "github.com/h2non/filetype" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/media" ) // SendFileTool allows the LLM to send a local file (image, document, etc.) // to the user on the current chat channel via the MediaStore pipeline. type SendFileTool struct { workspace string restrict bool maxFileSize int mediaStore media.MediaStore allowPaths []*regexp.Regexp defaultChannel string defaultChatID string } func NewSendFileTool( workspace string, restrict bool, maxFileSize int, store media.MediaStore, allowPaths ...[]*regexp.Regexp, ) *SendFileTool { if maxFileSize <= 0 { maxFileSize = config.DefaultMaxMediaSize } var patterns []*regexp.Regexp if len(allowPaths) > 0 { patterns = allowPaths[0] } return &SendFileTool{ workspace: workspace, restrict: restrict, maxFileSize: maxFileSize, mediaStore: store, allowPaths: patterns, } } func (t *SendFileTool) Name() string { return "send_file" } func (t *SendFileTool) Description() string { return "Send a local file (image, document, etc.) to the user on the current chat channel." } func (t *SendFileTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "path": map[string]any{ "type": "string", "description": "Path to the local file. Relative paths are resolved from workspace.", }, "filename": map[string]any{ "type": "string", "description": "Optional display filename. Defaults to the basename of path.", }, }, "required": []string{"path"}, } } func (t *SendFileTool) SetContext(channel, chatID string) { t.defaultChannel = channel t.defaultChatID = chatID } func (t *SendFileTool) SetMediaStore(store media.MediaStore) { t.mediaStore = store } func (t *SendFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, _ := args["path"].(string) if strings.TrimSpace(path) == "" { return ErrorResult("path is required") } // Prefer context-injected channel/chatID (set by ExecuteWithContext), fall back to SetContext values. channel := ToolChannel(ctx) if channel == "" { channel = t.defaultChannel } chatID := ToolChatID(ctx) if chatID == "" { chatID = t.defaultChatID } if channel == "" || chatID == "" { return ErrorResult("no target channel/chat available") } if t.mediaStore == nil { return ErrorResult("media store not configured") } resolved, err := validatePathWithAllowPaths(path, t.workspace, t.restrict, t.allowPaths) if err != nil { return ErrorResult(fmt.Sprintf("invalid path: %v", err)) } info, err := os.Stat(resolved) if err != nil { return ErrorResult(fmt.Sprintf("file not found: %v", err)) } if info.IsDir() { return ErrorResult("path is a directory, expected a file") } if info.Size() > int64(t.maxFileSize) { return ErrorResult(fmt.Sprintf( "file too large: %d bytes (max %d bytes)", info.Size(), t.maxFileSize, )) } filename, _ := args["filename"].(string) if filename == "" { filename = filepath.Base(resolved) } mediaType := detectMediaType(resolved) scope := fmt.Sprintf("tool:send_file:%s:%s", channel, chatID) ref, err := t.mediaStore.Store(resolved, media.MediaMeta{ Filename: filename, ContentType: mediaType, Source: "tool:send_file", }, scope) if err != nil { return ErrorResult(fmt.Sprintf("failed to register media: %v", err)) } return MediaResult(fmt.Sprintf("File %q sent to user", filename), []string{ref}) } // detectMediaType determines the MIME type of a file. // Uses magic-bytes detection (h2non/filetype) first, then falls back to // extension-based lookup via mime.TypeByExtension. func detectMediaType(path string) string { kind, err := filetype.MatchFile(path) if err == nil && kind != filetype.Unknown { return kind.MIME.Value } if ext := filepath.Ext(path); ext != "" { if t := mime.TypeByExtension(ext); t != "" { return t } } return "application/octet-stream" } ================================================ FILE: pkg/tools/send_file_test.go ================================================ package tools import ( "context" "os" "path/filepath" "regexp" "strings" "testing" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/media" ) func TestSendFileTool_MissingPath(t *testing.T) { store := media.NewFileMediaStore() tool := NewSendFileTool("/tmp", false, 0, store) tool.SetContext("feishu", "chat123") result := tool.Execute(context.Background(), map[string]any{}) if !result.IsError { t.Fatal("expected error for missing path") } } func TestSendFileTool_NoContext(t *testing.T) { store := media.NewFileMediaStore() tool := NewSendFileTool("/tmp", false, 0, store) // no SetContext call result := tool.Execute(context.Background(), map[string]any{"path": "/tmp/test.txt"}) if !result.IsError { t.Fatal("expected error when no channel context") } } func TestSendFileTool_NoMediaStore(t *testing.T) { tool := NewSendFileTool("/tmp", false, 0, nil) tool.SetContext("feishu", "chat123") result := tool.Execute(context.Background(), map[string]any{"path": "/tmp/test.txt"}) if !result.IsError { t.Fatal("expected error when no media store") } } func TestSendFileTool_Directory(t *testing.T) { store := media.NewFileMediaStore() tool := NewSendFileTool("/tmp", false, 0, store) tool.SetContext("feishu", "chat123") result := tool.Execute(context.Background(), map[string]any{"path": "/tmp"}) if !result.IsError { t.Fatal("expected error for directory path") } } func TestSendFileTool_FileTooLarge(t *testing.T) { dir := t.TempDir() testFile := filepath.Join(dir, "big.bin") // Create a file larger than the limit if err := os.WriteFile(testFile, make([]byte, 1024), 0o644); err != nil { t.Fatal(err) } store := media.NewFileMediaStore() tool := NewSendFileTool(dir, false, 512, store) // 512 byte limit tool.SetContext("feishu", "chat123") result := tool.Execute(context.Background(), map[string]any{"path": testFile}) if !result.IsError { t.Fatal("expected error for oversized file") } if !strings.Contains(result.ForLLM, "too large") { t.Errorf("expected 'too large' in error, got %q", result.ForLLM) } } func TestSendFileTool_DefaultMaxSize(t *testing.T) { tool := NewSendFileTool("/tmp", false, 0, nil) if tool.maxFileSize != config.DefaultMaxMediaSize { t.Errorf("expected default max size %d, got %d", config.DefaultMaxMediaSize, tool.maxFileSize) } } func TestSendFileTool_Success(t *testing.T) { dir := t.TempDir() testFile := filepath.Join(dir, "photo.png") if err := os.WriteFile(testFile, []byte("fake png"), 0o644); err != nil { t.Fatal(err) } store := media.NewFileMediaStore() tool := NewSendFileTool(dir, false, 0, store) tool.SetContext("feishu", "chat123") result := tool.Execute(context.Background(), map[string]any{"path": testFile}) if result.IsError { t.Fatalf("unexpected error: %s", result.ForLLM) } if len(result.Media) != 1 { t.Fatalf("expected 1 media ref, got %d", len(result.Media)) } if result.Media[0][:8] != "media://" { t.Errorf("expected media:// ref, got %q", result.Media[0]) } } func TestSendFileTool_CustomFilename(t *testing.T) { dir := t.TempDir() testFile := filepath.Join(dir, "img.jpg") if err := os.WriteFile(testFile, []byte("fake jpg"), 0o644); err != nil { t.Fatal(err) } store := media.NewFileMediaStore() tool := NewSendFileTool(dir, false, 0, store) tool.SetContext("telegram", "chat456") result := tool.Execute(context.Background(), map[string]any{ "path": testFile, "filename": "my-photo.jpg", }) if result.IsError { t.Fatalf("unexpected error: %s", result.ForLLM) } if len(result.Media) != 1 { t.Fatalf("expected 1 media ref, got %d", len(result.Media)) } } func TestSendFileTool_AllowsWhitelistedMediaTempPath(t *testing.T) { workspace := t.TempDir() mediaDir := media.TempDir() if err := os.MkdirAll(mediaDir, 0o700); err != nil { t.Fatalf("MkdirAll(mediaDir) error = %v", err) } testFile, err := os.CreateTemp(mediaDir, "send-file-*.txt") if err != nil { t.Fatalf("CreateTemp(mediaDir) error = %v", err) } testPath := testFile.Name() if _, err := testFile.WriteString("forward me"); err != nil { testFile.Close() t.Fatalf("WriteString(testFile) error = %v", err) } if err := testFile.Close(); err != nil { t.Fatalf("Close(testFile) error = %v", err) } t.Cleanup(func() { _ = os.Remove(testPath) }) pattern := regexp.MustCompile( "^" + regexp.QuoteMeta(filepath.Clean(mediaDir)) + "(?:" + regexp.QuoteMeta(string(os.PathSeparator)) + "|$)", ) store := media.NewFileMediaStore() tool := NewSendFileTool(workspace, true, 0, store, []*regexp.Regexp{pattern}) tool.SetContext("feishu", "chat123") result := tool.Execute(context.Background(), map[string]any{"path": testPath}) if result.IsError { t.Fatalf("expected whitelisted temp media file to be sendable, got: %s", result.ForLLM) } if len(result.Media) != 1 { t.Fatalf("expected 1 media ref, got %d", len(result.Media)) } } func TestDetectMediaType_MagicBytes(t *testing.T) { dir := t.TempDir() // Minimal valid PNG header pngHeader := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} pngFile := filepath.Join(dir, "image.dat") // wrong extension, but valid PNG bytes if err := os.WriteFile(pngFile, pngHeader, 0o644); err != nil { t.Fatal(err) } got := detectMediaType(pngFile) if got != "image/png" { t.Errorf("expected image/png from magic bytes, got %q", got) } } func TestDetectMediaType_FallbackToExtension(t *testing.T) { dir := t.TempDir() // File with unrecognizable content but known extension txtFile := filepath.Join(dir, "readme.txt") if err := os.WriteFile(txtFile, []byte("hello world"), 0o644); err != nil { t.Fatal(err) } got := detectMediaType(txtFile) // text/plain or similar — just verify it's not application/octet-stream if got == "application/octet-stream" { t.Errorf("expected extension-based MIME for .txt, got %q", got) } } func TestDetectMediaType_UnknownFallsToOctetStream(t *testing.T) { dir := t.TempDir() // File with no extension and random bytes unknownFile := filepath.Join(dir, "mystery") if err := os.WriteFile(unknownFile, []byte{0x00, 0x01, 0x02}, 0o644); err != nil { t.Fatal(err) } got := detectMediaType(unknownFile) if got != "application/octet-stream" { t.Errorf("expected application/octet-stream, got %q", got) } } ================================================ FILE: pkg/tools/shell.go ================================================ package tools import ( "bytes" "context" "errors" "fmt" "os" "os/exec" "path/filepath" "regexp" "runtime" "strings" "time" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" ) type ExecTool struct { workingDir string timeout time.Duration denyPatterns []*regexp.Regexp allowPatterns []*regexp.Regexp customAllowPatterns []*regexp.Regexp allowedPathPatterns []*regexp.Regexp restrictToWorkspace bool allowRemote bool } var ( defaultDenyPatterns = []*regexp.Regexp{ regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`), regexp.MustCompile(`\bdel\s+/[fq]\b`), regexp.MustCompile(`\brmdir\s+/s\b`), // Match disk wiping commands (must be followed by space/args) regexp.MustCompile( `\b(format|mkfs|diskpart)\b\s`, ), regexp.MustCompile(`\bdd\s+if=`), // Block writes to block devices (all common naming schemes). regexp.MustCompile( `>\s*/dev/(sd[a-z]|hd[a-z]|vd[a-z]|xvd[a-z]|nvme\d|mmcblk\d|loop\d|dm-\d|md\d|sr\d|nbd\d)`, ), regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`), regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), regexp.MustCompile(`\$\([^)]+\)`), regexp.MustCompile(`\$\{[^}]+\}`), regexp.MustCompile("`[^`]+`"), regexp.MustCompile(`\|\s*sh\b`), regexp.MustCompile(`\|\s*bash\b`), regexp.MustCompile(`;\s*rm\s+-[rf]`), regexp.MustCompile(`&&\s*rm\s+-[rf]`), regexp.MustCompile(`\|\|\s*rm\s+-[rf]`), regexp.MustCompile(`<<\s*EOF`), regexp.MustCompile(`\$\(\s*cat\s+`), regexp.MustCompile(`\$\(\s*curl\s+`), regexp.MustCompile(`\$\(\s*wget\s+`), regexp.MustCompile(`\$\(\s*which\s+`), regexp.MustCompile(`\bsudo\b`), regexp.MustCompile(`\bchmod\s+[0-7]{3,4}\b`), regexp.MustCompile(`\bchown\b`), regexp.MustCompile(`\bpkill\b`), regexp.MustCompile(`\bkillall\b`), regexp.MustCompile(`\bkill\b`), regexp.MustCompile(`\bcurl\b.*\|\s*(sh|bash)`), regexp.MustCompile(`\bwget\b.*\|\s*(sh|bash)`), regexp.MustCompile(`\bnpm\s+install\s+-g\b`), regexp.MustCompile(`\bpip\s+install\s+--user\b`), regexp.MustCompile(`\bapt\s+(install|remove|purge)\b`), regexp.MustCompile(`\byum\s+(install|remove)\b`), regexp.MustCompile(`\bdnf\s+(install|remove)\b`), regexp.MustCompile(`\bdocker\s+run\b`), regexp.MustCompile(`\bdocker\s+exec\b`), regexp.MustCompile(`\bgit\s+push\b`), regexp.MustCompile(`\bgit\s+force\b`), regexp.MustCompile(`\bssh\b.*@`), regexp.MustCompile(`\beval\b`), regexp.MustCompile(`\bsource\s+.*\.sh\b`), } // absolutePathPattern matches absolute file paths in commands (Unix and Windows). absolutePathPattern = regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`) // safePaths are kernel pseudo-devices that are always safe to reference in // commands, regardless of workspace restriction. They contain no user data // and cannot cause destructive writes. safePaths = map[string]bool{ "/dev/null": true, "/dev/zero": true, "/dev/random": true, "/dev/urandom": true, "/dev/stdin": true, "/dev/stdout": true, "/dev/stderr": true, } ) func NewExecTool(workingDir string, restrict bool, allowPaths ...[]*regexp.Regexp) (*ExecTool, error) { return NewExecToolWithConfig(workingDir, restrict, nil, allowPaths...) } func NewExecToolWithConfig( workingDir string, restrict bool, config *config.Config, allowPaths ...[]*regexp.Regexp, ) (*ExecTool, error) { denyPatterns := make([]*regexp.Regexp, 0) customAllowPatterns := make([]*regexp.Regexp, 0) var allowedPathPatterns []*regexp.Regexp allowRemote := true if len(allowPaths) > 0 { allowedPathPatterns = allowPaths[0] } if config != nil { execConfig := config.Tools.Exec enableDenyPatterns := execConfig.EnableDenyPatterns allowRemote = execConfig.AllowRemote if enableDenyPatterns { denyPatterns = append(denyPatterns, defaultDenyPatterns...) if len(execConfig.CustomDenyPatterns) > 0 { fmt.Printf("Using custom deny patterns: %v\n", execConfig.CustomDenyPatterns) for _, pattern := range execConfig.CustomDenyPatterns { re, err := regexp.Compile(pattern) if err != nil { return nil, fmt.Errorf("invalid custom deny pattern %q: %w", pattern, err) } denyPatterns = append(denyPatterns, re) } } } else { // If deny patterns are disabled, we won't add any patterns, allowing all commands. fmt.Println("Warning: deny patterns are disabled. All commands will be allowed.") } for _, pattern := range execConfig.CustomAllowPatterns { re, err := regexp.Compile(pattern) if err != nil { return nil, fmt.Errorf("invalid custom allow pattern %q: %w", pattern, err) } customAllowPatterns = append(customAllowPatterns, re) } } else { denyPatterns = append(denyPatterns, defaultDenyPatterns...) } timeout := 60 * time.Second if config != nil && config.Tools.Exec.TimeoutSeconds > 0 { timeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second } return &ExecTool{ workingDir: workingDir, timeout: timeout, denyPatterns: denyPatterns, allowPatterns: nil, customAllowPatterns: customAllowPatterns, allowedPathPatterns: allowedPathPatterns, restrictToWorkspace: restrict, allowRemote: allowRemote, }, nil } func (t *ExecTool) Name() string { return "exec" } func (t *ExecTool) Description() string { return "Execute a shell command and return its output. Use with caution." } func (t *ExecTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "command": map[string]any{ "type": "string", "description": "The shell command to execute", }, "working_dir": map[string]any{ "type": "string", "description": "Optional working directory for the command", }, }, "required": []string{"command"}, } } func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult { command, ok := args["command"].(string) if !ok { return ErrorResult("command is required") } // GHSA-pv8c-p6jf-3fpp: block exec from remote channels (e.g. Telegram webhooks) // unless explicitly opted-in via config. Fail-closed: empty channel = blocked. if !t.allowRemote { channel := ToolChannel(ctx) if channel == "" { channel, _ = args["__channel"].(string) } channel = strings.TrimSpace(channel) if channel == "" || !constants.IsInternalChannel(channel) { return ErrorResult("exec is restricted to internal channels") } } cwd := t.workingDir if wd, ok := args["working_dir"].(string); ok && wd != "" { if t.restrictToWorkspace && t.workingDir != "" { resolvedWD, err := validatePathWithAllowPaths(wd, t.workingDir, true, t.allowedPathPatterns) if err != nil { return ErrorResult("Command blocked by safety guard (" + err.Error() + ")") } cwd = resolvedWD } else { cwd = wd } } if cwd == "" { wd, err := os.Getwd() if err == nil { cwd = wd } } if guardError := t.guardCommand(command, cwd); guardError != "" { return ErrorResult(guardError) } // Re-resolve symlinks immediately before execution to shrink the TOCTOU window // between validation and cmd.Dir assignment. if t.restrictToWorkspace && t.workingDir != "" && cwd != t.workingDir { resolved, err := filepath.EvalSymlinks(cwd) if err != nil { return ErrorResult(fmt.Sprintf("Command blocked by safety guard (path resolution failed: %v)", err)) } if isAllowedPath(resolved, t.allowedPathPatterns) { cwd = resolved } else { absWorkspace, _ := filepath.Abs(t.workingDir) wsResolved, _ := filepath.EvalSymlinks(absWorkspace) if wsResolved == "" { wsResolved = absWorkspace } rel, err := filepath.Rel(wsResolved, resolved) if err != nil || !filepath.IsLocal(rel) { return ErrorResult("Command blocked by safety guard (working directory escaped workspace)") } cwd = resolved } } // timeout == 0 means no timeout var cmdCtx context.Context var cancel context.CancelFunc if t.timeout > 0 { cmdCtx, cancel = context.WithTimeout(ctx, t.timeout) } else { cmdCtx, cancel = context.WithCancel(ctx) } defer cancel() var cmd *exec.Cmd if runtime.GOOS == "windows" { cmd = exec.CommandContext(cmdCtx, "powershell", "-NoProfile", "-NonInteractive", "-Command", command) } else { cmd = exec.CommandContext(cmdCtx, "sh", "-c", command) } if cwd != "" { cmd.Dir = cwd } prepareCommandForTermination(cmd) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Start(); err != nil { return ErrorResult(fmt.Sprintf("failed to start command: %v", err)) } done := make(chan error, 1) go func() { done <- cmd.Wait() }() var err error select { case err = <-done: case <-cmdCtx.Done(): _ = terminateProcessTree(cmd) select { case err = <-done: case <-time.After(2 * time.Second): if cmd.Process != nil { _ = cmd.Process.Kill() } err = <-done } } output := stdout.String() if stderr.Len() > 0 { output += "\nSTDERR:\n" + stderr.String() } if err != nil { if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) { msg := fmt.Sprintf("Command timed out after %v", t.timeout) if output != "" { msg += "\n\nPartial output before timeout:\n" + output } return &ToolResult{ ForLLM: msg, ForUser: msg, IsError: true, Err: fmt.Errorf("command timeout: %w", err), } } // Extract detailed exit information var exitErr *exec.ExitError if errors.As(err, &exitErr) { exitCode := exitErr.ExitCode() output += fmt.Sprintf("\n\n[Command exited with code %d]", exitCode) // Add signal information if killed by signal (Unix) if exitCode == -1 { output += " (killed by signal)" } } else { output += fmt.Sprintf("\n\n[Command failed: %v]", err) } } if output == "" { output = "(no output)" } maxLen := 10000 if len(output) > maxLen { output = output[:maxLen] + fmt.Sprintf("\n... (truncated, %d more chars)", len(output)-maxLen) } if err != nil { return &ToolResult{ ForLLM: output, ForUser: output, IsError: true, } } return &ToolResult{ ForLLM: output, ForUser: output, IsError: false, } } func (t *ExecTool) guardCommand(command, cwd string) string { cmd := strings.TrimSpace(command) lower := strings.ToLower(cmd) // Custom allow patterns exempt a command from deny checks. explicitlyAllowed := false for _, pattern := range t.customAllowPatterns { if pattern.MatchString(lower) { explicitlyAllowed = true break } } if !explicitlyAllowed { for _, pattern := range t.denyPatterns { if pattern.MatchString(lower) { return "Command blocked by safety guard (dangerous pattern detected)" } } } if len(t.allowPatterns) > 0 { allowed := false for _, pattern := range t.allowPatterns { if pattern.MatchString(lower) { allowed = true break } } if !allowed { return "Command blocked by safety guard (not in allowlist)" } } if t.restrictToWorkspace { if strings.Contains(cmd, "..\\") || strings.Contains(cmd, "../") { return "Command blocked by safety guard (path traversal detected)" } cwdPath, err := filepath.Abs(cwd) if err != nil { return "" } // Web URL schemes whose path components (starting with //) should be exempt // from workspace sandbox checks. file: is intentionally excluded so that // file:// URIs are still validated against the workspace boundary. webSchemes := []string{"http:", "https:", "ftp:", "ftps:", "sftp:", "ssh:", "git:"} matchIndices := absolutePathPattern.FindAllStringIndex(cmd, -1) for _, loc := range matchIndices { raw := cmd[loc[0]:loc[1]] // Skip URL path components that look like they're from web URLs. // When a URL like "https://github.com" is parsed, the regex captures // "//github.com" as a match (the path portion after "https:"). // Use the exact match position (loc[0]) so that duplicate //path substrings // in the same command are each evaluated at their own position. if strings.HasPrefix(raw, "//") && loc[0] > 0 { before := cmd[:loc[0]] isWebURL := false for _, scheme := range webSchemes { if strings.HasSuffix(before, scheme) { isWebURL = true break } } if isWebURL { continue } } p, err := filepath.Abs(raw) if err != nil { continue } if safePaths[p] { continue } if isAllowedPath(p, t.allowedPathPatterns) { continue } rel, err := filepath.Rel(cwdPath, p) if err != nil { continue } if strings.HasPrefix(rel, "..") { return "Command blocked by safety guard (path outside working dir)" } } } return "" } func (t *ExecTool) SetTimeout(timeout time.Duration) { t.timeout = timeout } func (t *ExecTool) SetRestrictToWorkspace(restrict bool) { t.restrictToWorkspace = restrict } func (t *ExecTool) SetAllowPatterns(patterns []string) error { t.allowPatterns = make([]*regexp.Regexp, 0, len(patterns)) for _, p := range patterns { re, err := regexp.Compile(p) if err != nil { return fmt.Errorf("invalid allow pattern %q: %w", p, err) } t.allowPatterns = append(t.allowPatterns, re) } return nil } ================================================ FILE: pkg/tools/shell_process_unix.go ================================================ //go:build !windows package tools import ( "os/exec" "syscall" ) func prepareCommandForTermination(cmd *exec.Cmd) { if cmd == nil { return } cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} } func terminateProcessTree(cmd *exec.Cmd) error { if cmd == nil || cmd.Process == nil { return nil } pid := cmd.Process.Pid if pid <= 0 { return nil } // Kill the entire process group spawned by the shell command. _ = syscall.Kill(-pid, syscall.SIGKILL) // Fallback kill on the shell process itself. _ = cmd.Process.Kill() return nil } ================================================ FILE: pkg/tools/shell_process_windows.go ================================================ //go:build windows package tools import ( "os/exec" "strconv" ) func prepareCommandForTermination(cmd *exec.Cmd) { // no-op on Windows } func terminateProcessTree(cmd *exec.Cmd) error { if cmd == nil || cmd.Process == nil { return nil } pid := cmd.Process.Pid if pid <= 0 { return nil } _ = exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run() _ = cmd.Process.Kill() return nil } ================================================ FILE: pkg/tools/shell_test.go ================================================ package tools import ( "context" "os" "path/filepath" "strings" "testing" "time" "github.com/sipeed/picoclaw/pkg/config" ) // TestShellTool_Success verifies successful command execution func TestShellTool_Success(t *testing.T) { tool, err := NewExecTool("", false) if err != nil { t.Errorf("unable to configure exec tool: %s", err) } ctx := context.Background() args := map[string]any{ "command": "echo 'hello world'", } result := tool.Execute(ctx, args) // Success should not be an error if result.IsError { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } // ForUser should contain command output if !strings.Contains(result.ForUser, "hello world") { t.Errorf("Expected ForUser to contain 'hello world', got: %s", result.ForUser) } // ForLLM should contain full output if !strings.Contains(result.ForLLM, "hello world") { t.Errorf("Expected ForLLM to contain 'hello world', got: %s", result.ForLLM) } } // TestShellTool_Failure verifies failed command execution func TestShellTool_Failure(t *testing.T) { tool, err := NewExecTool("", false) if err != nil { t.Errorf("unable to configure exec tool: %s", err) } ctx := context.Background() args := map[string]any{ "command": "ls /nonexistent_directory_12345", } result := tool.Execute(ctx, args) // Failure should be marked as error if !result.IsError { t.Errorf("Expected error for failed command, got IsError=false") } // ForUser should contain error information if result.ForUser == "" { t.Errorf("Expected ForUser to contain error info, got empty string") } // ForLLM should contain exit code or error if !strings.Contains(result.ForLLM, "Exit code") && result.ForUser == "" { t.Errorf("Expected ForLLM to contain exit code or error, got: %s", result.ForLLM) } } // TestShellTool_Timeout verifies command timeout handling func TestShellTool_Timeout(t *testing.T) { tool, err := NewExecTool("", false) if err != nil { t.Errorf("unable to configure exec tool: %s", err) } tool.SetTimeout(100 * time.Millisecond) ctx := context.Background() args := map[string]any{ "command": "sleep 10", } result := tool.Execute(ctx, args) // Timeout should be marked as error if !result.IsError { t.Errorf("Expected error for timeout, got IsError=false") } // Should mention timeout if !strings.Contains(result.ForLLM, "timed out") && !strings.Contains(result.ForUser, "timed out") { t.Errorf("Expected timeout message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) } } // TestShellTool_WorkingDir verifies custom working directory func TestShellTool_WorkingDir(t *testing.T) { // Create temp directory tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test.txt") os.WriteFile(testFile, []byte("test content"), 0o644) tool, err := NewExecTool("", false) if err != nil { t.Errorf("unable to configure exec tool: %s", err) } ctx := context.Background() args := map[string]any{ "command": "cat test.txt", "working_dir": tmpDir, } result := tool.Execute(ctx, args) if result.IsError { t.Errorf("Expected success in custom working dir, got error: %s", result.ForLLM) } if !strings.Contains(result.ForUser, "test content") { t.Errorf("Expected output from custom dir, got: %s", result.ForUser) } } // TestShellTool_DangerousCommand verifies safety guard blocks dangerous commands func TestShellTool_DangerousCommand(t *testing.T) { tool, err := NewExecTool("", false) if err != nil { t.Errorf("unable to configure exec tool: %s", err) } ctx := context.Background() args := map[string]any{ "command": "rm -rf /", } result := tool.Execute(ctx, args) // Dangerous command should be blocked if !result.IsError { t.Errorf("Expected dangerous command to be blocked (IsError=true)") } if !strings.Contains(result.ForLLM, "blocked") && !strings.Contains(result.ForUser, "blocked") { t.Errorf("Expected 'blocked' message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) } } func TestShellTool_DangerousCommand_KillBlocked(t *testing.T) { tool, err := NewExecTool("", false) if err != nil { t.Errorf("unable to configure exec tool: %s", err) } ctx := context.Background() args := map[string]any{ "command": "kill 12345", } result := tool.Execute(ctx, args) if !result.IsError { t.Errorf("Expected kill command to be blocked") } if !strings.Contains(result.ForLLM, "blocked") && !strings.Contains(result.ForUser, "blocked") { t.Errorf("Expected blocked message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) } } // TestShellTool_MissingCommand verifies error handling for missing command func TestShellTool_MissingCommand(t *testing.T) { tool, err := NewExecTool("", false) if err != nil { t.Errorf("unable to configure exec tool: %s", err) } ctx := context.Background() args := map[string]any{} result := tool.Execute(ctx, args) // Should return error result if !result.IsError { t.Errorf("Expected error when command is missing") } } // TestShellTool_StderrCapture verifies stderr is captured and included func TestShellTool_StderrCapture(t *testing.T) { tool, err := NewExecTool("", false) if err != nil { t.Errorf("unable to configure exec tool: %s", err) } ctx := context.Background() args := map[string]any{ "command": "sh -c 'echo stdout; echo stderr >&2'", } result := tool.Execute(ctx, args) // Both stdout and stderr should be in output if !strings.Contains(result.ForLLM, "stdout") { t.Errorf("Expected stdout in output, got: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "stderr") { t.Errorf("Expected stderr in output, got: %s", result.ForLLM) } } // TestShellTool_OutputTruncation verifies long output is truncated func TestShellTool_OutputTruncation(t *testing.T) { tool, err := NewExecTool("", false) if err != nil { t.Errorf("unable to configure exec tool: %s", err) } ctx := context.Background() // Generate long output (>10000 chars) args := map[string]any{ "command": "python3 -c \"print('x' * 20000)\" || echo " + strings.Repeat("x", 20000), } result := tool.Execute(ctx, args) // Should have truncation message or be truncated if len(result.ForLLM) > 15000 { t.Errorf("Expected output to be truncated, got length: %d", len(result.ForLLM)) } } // TestShellTool_WorkingDir_OutsideWorkspace verifies that working_dir cannot escape the workspace directly func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) { root := t.TempDir() workspace := filepath.Join(root, "workspace") outsideDir := filepath.Join(root, "outside") if err := os.MkdirAll(workspace, 0o755); err != nil { t.Fatalf("failed to create workspace: %v", err) } if err := os.MkdirAll(outsideDir, 0o755); err != nil { t.Fatalf("failed to create outside dir: %v", err) } tool, err := NewExecTool(workspace, true) if err != nil { t.Errorf("unable to configure exec tool: %s", err) } result := tool.Execute(context.Background(), map[string]any{ "command": "pwd", "working_dir": outsideDir, }) if !result.IsError { t.Fatalf("expected working_dir outside workspace to be blocked, got output: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "blocked") { t.Errorf("expected 'blocked' in error, got: %s", result.ForLLM) } } // TestShellTool_WorkingDir_SymlinkEscape verifies that a symlink inside the workspace // pointing outside cannot be used as working_dir to escape the sandbox. func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) { root := t.TempDir() workspace := filepath.Join(root, "workspace") secretDir := filepath.Join(root, "secret") if err := os.MkdirAll(workspace, 0o755); err != nil { t.Fatalf("failed to create workspace: %v", err) } if err := os.MkdirAll(secretDir, 0o755); err != nil { t.Fatalf("failed to create secret dir: %v", err) } os.WriteFile(filepath.Join(secretDir, "secret.txt"), []byte("top secret"), 0o644) // symlink lives inside the workspace but resolves to secretDir outside it link := filepath.Join(workspace, "escape") if err := os.Symlink(secretDir, link); err != nil { t.Skipf("symlinks not supported in this environment: %v", err) } tool, err := NewExecTool(workspace, true) if err != nil { t.Errorf("unable to configure exec tool: %s", err) } result := tool.Execute(context.Background(), map[string]any{ "command": "cat secret.txt", "working_dir": link, }) if !result.IsError { t.Fatalf("expected symlink working_dir escape to be blocked, got output: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "blocked") { t.Errorf("expected 'blocked' in error, got: %s", result.ForLLM) } } // TestShellTool_RemoteChannelBlockedByDefault verifies exec is blocked for remote channels func TestShellTool_RemoteChannelBlockedByDefault(t *testing.T) { cfg := &config.Config{} cfg.Tools.Exec.EnableDenyPatterns = true cfg.Tools.Exec.AllowRemote = false tool, err := NewExecToolWithConfig("", false, cfg) if err != nil { t.Fatalf("NewExecToolWithConfig() error: %v", err) } ctx := WithToolContext(context.Background(), "telegram", "chat-1") result := tool.Execute(ctx, map[string]any{"command": "echo hi"}) if !result.IsError { t.Fatal("expected remote-channel exec to be blocked") } if !strings.Contains(result.ForLLM, "restricted to internal channels") { t.Errorf("expected 'restricted to internal channels' message, got: %s", result.ForLLM) } } // TestShellTool_InternalChannelAllowed verifies exec is allowed for internal channels func TestShellTool_InternalChannelAllowed(t *testing.T) { cfg := &config.Config{} cfg.Tools.Exec.EnableDenyPatterns = true cfg.Tools.Exec.AllowRemote = false tool, err := NewExecToolWithConfig("", false, cfg) if err != nil { t.Fatalf("NewExecToolWithConfig() error: %v", err) } ctx := WithToolContext(context.Background(), "cli", "direct") result := tool.Execute(ctx, map[string]any{"command": "echo hi"}) if result.IsError { t.Fatalf("expected internal channel exec to succeed, got: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "hi") { t.Errorf("expected output to contain 'hi', got: %s", result.ForLLM) } } // TestShellTool_EmptyChannelBlockedWhenNotAllowRemote verifies fail-closed when no channel context func TestShellTool_EmptyChannelBlockedWhenNotAllowRemote(t *testing.T) { cfg := &config.Config{} cfg.Tools.Exec.EnableDenyPatterns = true cfg.Tools.Exec.AllowRemote = false tool, err := NewExecToolWithConfig("", false, cfg) if err != nil { t.Fatalf("NewExecToolWithConfig() error: %v", err) } result := tool.Execute(context.Background(), map[string]any{ "command": "echo hi", }) if !result.IsError { t.Fatal("expected exec with empty channel to be blocked when allowRemote=false") } } // TestShellTool_AllowRemoteBypassesChannelCheck verifies allowRemote=true permits any channel func TestShellTool_AllowRemoteBypassesChannelCheck(t *testing.T) { cfg := &config.Config{} cfg.Tools.Exec.EnableDenyPatterns = true cfg.Tools.Exec.AllowRemote = true tool, err := NewExecToolWithConfig("", false, cfg) if err != nil { t.Fatalf("NewExecToolWithConfig() error: %v", err) } ctx := WithToolContext(context.Background(), "telegram", "chat-1") result := tool.Execute(ctx, map[string]any{"command": "echo hi"}) if result.IsError { t.Fatalf("expected allowRemote=true to permit remote channel, got: %s", result.ForLLM) } } // TestShellTool_RestrictToWorkspace verifies workspace restriction func TestShellTool_RestrictToWorkspace(t *testing.T) { tmpDir := t.TempDir() tool, err := NewExecTool(tmpDir, false) if err != nil { t.Errorf("unable to configure exec tool: %s", err) } tool.SetRestrictToWorkspace(true) ctx := context.Background() args := map[string]any{ "command": "cat ../../etc/passwd", } result := tool.Execute(ctx, args) // Path traversal should be blocked if !result.IsError { t.Errorf("Expected path traversal to be blocked with restrictToWorkspace=true") } if !strings.Contains(result.ForLLM, "blocked") && !strings.Contains(result.ForUser, "blocked") { t.Errorf( "Expected 'blocked' message for path traversal, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser, ) } } // TestShellTool_DevNullAllowed verifies that /dev/null redirections are not blocked (issue #964). func TestShellTool_DevNullAllowed(t *testing.T) { tmpDir := t.TempDir() tool, err := NewExecTool(tmpDir, true) if err != nil { t.Fatalf("unable to configure exec tool: %s", err) } commands := []string{ "echo hello 2>/dev/null", "echo hello >/dev/null", "echo hello > /dev/null", "echo hello 2> /dev/null", "echo hello >/dev/null 2>&1", "find " + tmpDir + " -name '*.go' 2>/dev/null", } for _, cmd := range commands { result := tool.Execute(context.Background(), map[string]any{"command": cmd}) if result.IsError && strings.Contains(result.ForLLM, "blocked") { t.Errorf("command should not be blocked: %s\n error: %s", cmd, result.ForLLM) } } } // TestShellTool_BlockDevices verifies that writes to block devices are blocked (issue #965). func TestShellTool_BlockDevices(t *testing.T) { tool, err := NewExecTool("", false) if err != nil { t.Fatalf("unable to configure exec tool: %s", err) } blocked := []string{ "echo x > /dev/sda", "echo x > /dev/hda", "echo x > /dev/vda", "echo x > /dev/xvda", "echo x > /dev/nvme0n1", "echo x > /dev/mmcblk0", "echo x > /dev/loop0", "echo x > /dev/dm-0", "echo x > /dev/md0", "echo x > /dev/sr0", "echo x > /dev/nbd0", } for _, cmd := range blocked { result := tool.Execute(context.Background(), map[string]any{"command": cmd}) if !result.IsError { t.Errorf("expected block device write to be blocked: %s", cmd) } } } // TestShellTool_SafePathsInWorkspaceRestriction verifies that safe kernel pseudo-devices // are allowed even when workspace restriction is active. func TestShellTool_SafePathsInWorkspaceRestriction(t *testing.T) { tmpDir := t.TempDir() tool, err := NewExecTool(tmpDir, true) if err != nil { t.Fatalf("unable to configure exec tool: %s", err) } // These reference paths outside workspace but should be allowed via safePaths. commands := []string{ "cat /dev/urandom | head -c 16 | od", "echo test > /dev/null", "dd if=/dev/zero bs=1 count=1", } for _, cmd := range commands { result := tool.Execute(context.Background(), map[string]any{"command": cmd}) if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") { t.Errorf("safe path should not be blocked by workspace check: %s\n error: %s", cmd, result.ForLLM) } } } // TestShellTool_ExitCodeDetails verifies that exit codes are captured with details func TestShellTool_ExitCodeDetails(t *testing.T) { tool, err := NewExecTool("", false) if err != nil { t.Fatalf("unable to configure exec tool: %s", err) } ctx := context.Background() args := map[string]any{ "command": "sh -c 'exit 42'", } result := tool.Execute(ctx, args) if !result.IsError { t.Error("expected error for non-zero exit code") } // Should contain the exit code in the message (new format: "exited with code 42") if !strings.Contains(result.ForLLM, "42") { t.Errorf("expected exit code 42 in error message, got: %s", result.ForLLM) } // Verify the new detailed message format if !strings.Contains(result.ForLLM, "exited with code") { t.Errorf("expected 'exited with code' in message, got: %s", result.ForLLM) } // Err field is set by the exec system (may or may not be set depending on implementation) // The important thing is that IsError=true t.Logf("Exit code result: %s", result.ForLLM) } // TestShellTool_TimeoutWithPartialOutput verifies timeout includes partial output func TestShellTool_TimeoutWithPartialOutput(t *testing.T) { tool, err := NewExecTool("", false) if err != nil { t.Fatalf("unable to configure exec tool: %s", err) } tool.SetTimeout(1 * time.Second) // Give more time for echo to complete ctx := context.Background() // Use a command that outputs immediately then sleeps args := map[string]any{ "command": "echo 'partial output before timeout' && sleep 30", } result := tool.Execute(ctx, args) if !result.IsError { t.Error("expected error for timeout") } // Should mention timeout if !strings.Contains(result.ForLLM, "timed out") { t.Errorf("expected 'timed out' in message, got: %s", result.ForLLM) } // Log the result for debugging (partial output depends on shell behavior) t.Logf("Timeout result: %s", result.ForLLM) } // TestShellTool_CustomAllowPatterns verifies that custom allow patterns exempt // commands from deny pattern checks. func TestShellTool_CustomAllowPatterns(t *testing.T) { cfg := &config.Config{ Tools: config.ToolsConfig{ Exec: config.ExecConfig{ EnableDenyPatterns: true, CustomAllowPatterns: []string{`\bgit\s+push\s+origin\b`}, }, }, } tool, err := NewExecToolWithConfig("", false, cfg) if err != nil { t.Fatalf("unable to configure exec tool: %s", err) } // "git push origin main" should be allowed by custom allow pattern. result := tool.Execute(context.Background(), map[string]any{ "command": "git push origin main", }) if result.IsError && strings.Contains(result.ForLLM, "blocked") { t.Errorf("custom allow pattern should exempt 'git push origin main', got: %s", result.ForLLM) } // "git push upstream main" should still be blocked (does not match allow pattern). result = tool.Execute(context.Background(), map[string]any{ "command": "git push upstream main", }) if !result.IsError { t.Errorf("'git push upstream main' should still be blocked by deny pattern") } } // TestShellTool_URLsNotBlocked verifies that commands containing URLs are not // incorrectly blocked by the workspace restriction safety guard (issue #1203). func TestShellTool_URLsNotBlocked(t *testing.T) { tmpDir := t.TempDir() tool, err := NewExecTool(tmpDir, true) if err != nil { t.Fatalf("unable to configure exec tool: %s", err) } // These commands contain URLs and should NOT be blocked by workspace restriction. // The URL path components (e.g., "//github.com") should be recognized as URLs, // not as file system paths. commands := []string{ "agent-browser open https://github.com", "curl https://api.example.com/data", "wget http://example.com/file", "browser open https://github.com/user/repo", "fetch ftp://ftp.example.com/file.txt", "git clone https://github.com/sipeed/picoclaw.git", } for _, cmd := range commands { result := tool.Execute(context.Background(), map[string]any{"command": cmd}) if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") { t.Errorf("command with URL should not be blocked by workspace check: %s\n error: %s", cmd, result.ForLLM) } } } // TestShellTool_FileURISandboxing verifies that file:// URIs that escape the // workspace are still blocked, even though other URLs are allowed (issue #1254). func TestShellTool_FileURISandboxing(t *testing.T) { tmpDir := t.TempDir() tool, err := NewExecTool(tmpDir, true) if err != nil { t.Fatalf("unable to configure exec tool: %s", err) } // These file:// URIs should be blocked if they reference paths outside the workspace. // Unlike web URLs (http://, https://, ftp://), file:// URIs can be used to escape the sandbox. blockedCommands := []string{ "cat file:///etc/passwd", "cat file:///etc/hosts", "cat file:///root/.ssh/id_rsa", } for _, cmd := range blockedCommands { result := tool.Execute(context.Background(), map[string]any{"command": cmd}) if !result.IsError || !strings.Contains(result.ForLLM, "path outside working dir") { t.Errorf("file:// URI outside workspace should be blocked: %s", cmd) } } // These file:// URIs should be allowed if they reference paths inside the workspace. // Create a test file inside the temp directory testFile := filepath.Join(tmpDir, "test.txt") if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil { t.Fatalf("failed to create test file: %s", err) } allowedCommands := []string{ "cat file://" + testFile, } for _, cmd := range allowedCommands { result := tool.Execute(context.Background(), map[string]any{"command": cmd}) if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") { t.Errorf("file:// URI inside workspace should be allowed: %s\n error: %s", cmd, result.ForLLM) } } } // TestShellTool_URLBypassPrevented verifies that a command cannot bypass the workspace // sandbox by smuggling a real path after a URL that contains the same //path substring. // e.g. "echo https://etc/passwd && cat //etc/passwd" must still be blocked. func TestShellTool_URLBypassPrevented(t *testing.T) { tmpDir := t.TempDir() tool, err := NewExecTool(tmpDir, true) if err != nil { t.Fatalf("unable to configure exec tool: %s", err) } // The path //etc/passwd appears twice: once as the host part of an https URL // and once as a real (escaped) absolute path. The guard must block the command // because the second occurrence is a genuine out-of-workspace path. blockedCommands := []string{ "echo https://etc/passwd && cat //etc/passwd", "curl https://host/file && ls //etc", } for _, cmd := range blockedCommands { result := tool.Execute(context.Background(), map[string]any{"command": cmd}) if !result.IsError || !strings.Contains(result.ForLLM, "path outside working dir") { t.Errorf("bypass attempt should be blocked: %q\n got: %s", cmd, result.ForLLM) } } } ================================================ FILE: pkg/tools/shell_timeout_unix_test.go ================================================ //go:build !windows package tools import ( "context" "os" "path/filepath" "strconv" "strings" "syscall" "testing" "time" ) func processExists(pid int) bool { if pid <= 0 { return false } err := syscall.Kill(pid, 0) return err == nil || err == syscall.EPERM } func TestShellTool_TimeoutKillsChildProcess(t *testing.T) { tool, err := NewExecTool(t.TempDir(), false) if err != nil { t.Errorf("unable to configure exec tool: %s", err) } tool.SetTimeout(500 * time.Millisecond) args := map[string]any{ // Spawn a child process that would outlive the shell unless process-group kill is used. "command": "sleep 60 & echo $! > child.pid; wait", } result := tool.Execute(context.Background(), args) if !result.IsError { t.Fatalf("expected timeout error, got success: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "timed out") { t.Fatalf("expected timeout message, got: %s", result.ForLLM) } childPIDPath := filepath.Join(tool.workingDir, "child.pid") data, err := os.ReadFile(childPIDPath) if err != nil { t.Fatalf("failed to read child pid file: %v", err) } childPID, err := strconv.Atoi(strings.TrimSpace(string(data))) if err != nil { t.Fatalf("failed to parse child pid: %v", err) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if !processExists(childPID) { return } time.Sleep(50 * time.Millisecond) } t.Fatalf("child process %d is still running after timeout", childPID) } ================================================ FILE: pkg/tools/skills_install.go ================================================ package tools import ( "context" "encoding/json" "fmt" "os" "path/filepath" "sync" "time" "github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/utils" ) // InstallSkillTool allows the LLM agent to install skills from registries. // It shares the same RegistryManager that FindSkillsTool uses, // so all registries configured in config are available for installation. type InstallSkillTool struct { registryMgr *skills.RegistryManager workspace string mu sync.Mutex } // NewInstallSkillTool creates a new InstallSkillTool. // registryMgr is the shared registry manager (same instance as FindSkillsTool). // workspace is the root workspace directory; skills install to {workspace}/skills/{slug}/. func NewInstallSkillTool(registryMgr *skills.RegistryManager, workspace string) *InstallSkillTool { return &InstallSkillTool{ registryMgr: registryMgr, workspace: workspace, mu: sync.Mutex{}, } } func (t *InstallSkillTool) Name() string { return "install_skill" } func (t *InstallSkillTool) Description() string { return "Install a skill from a registry by slug. Downloads and extracts the skill into the workspace. Use find_skills first to discover available skills." } func (t *InstallSkillTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "slug": map[string]any{ "type": "string", "description": "The unique slug of the skill to install (e.g., 'github', 'docker-compose')", }, "version": map[string]any{ "type": "string", "description": "Specific version to install (optional, defaults to latest)", }, "registry": map[string]any{ "type": "string", "description": "Registry to install from (required, e.g., 'clawhub')", }, "force": map[string]any{ "type": "boolean", "description": "Force reinstall if skill already exists (default false)", }, }, "required": []string{"slug", "registry"}, } } func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *ToolResult { // Install lock to prevent concurrent directory operations. // Ideally this should be done at a `slug` level, currently, its at a `workspace` level. t.mu.Lock() defer t.mu.Unlock() // Validate slug slug, _ := args["slug"].(string) if err := utils.ValidateSkillIdentifier(slug); err != nil { return ErrorResult(fmt.Sprintf("invalid slug %q: error: %s", slug, err.Error())) } // Validate registry registryName, _ := args["registry"].(string) if err := utils.ValidateSkillIdentifier(registryName); err != nil { return ErrorResult(fmt.Sprintf("invalid registry %q: error: %s", registryName, err.Error())) } version, _ := args["version"].(string) force, _ := args["force"].(bool) // Check if already installed. skillsDir := filepath.Join(t.workspace, "skills") targetDir := filepath.Join(skillsDir, slug) if !force { if _, err := os.Stat(targetDir); err == nil { return ErrorResult( fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir), ) } } else { // Force: remove existing if present. os.RemoveAll(targetDir) } // Resolve which registry to use. registry := t.registryMgr.GetRegistry(registryName) if registry == nil { return ErrorResult(fmt.Sprintf("registry %q not found", registryName)) } // Ensure skills directory exists. if err := os.MkdirAll(skillsDir, 0o755); err != nil { return ErrorResult(fmt.Sprintf("failed to create skills directory: %v", err)) } // Download and install (handles metadata, version resolution, extraction). result, err := registry.DownloadAndInstall(ctx, slug, version, targetDir) if err != nil { // Clean up partial install. rmErr := os.RemoveAll(targetDir) if rmErr != nil { logger.ErrorCF("tool", "Failed to remove partial install", map[string]any{ "tool": "install_skill", "target_dir": targetDir, "error": rmErr.Error(), }) } return ErrorResult(fmt.Sprintf("failed to install %q: %v", slug, err)) } // Moderation: block malware. if result.IsMalwareBlocked { rmErr := os.RemoveAll(targetDir) if rmErr != nil { logger.ErrorCF("tool", "Failed to remove partial install", map[string]any{ "tool": "install_skill", "target_dir": targetDir, "error": rmErr.Error(), }) } return ErrorResult(fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", slug)) } // Write origin metadata. if err := writeOriginMeta(targetDir, registry.Name(), slug, result.Version); err != nil { logger.ErrorCF("tool", "Failed to write origin metadata", map[string]any{ "tool": "install_skill", "error": err.Error(), "target": targetDir, "registry": registry.Name(), "slug": slug, "version": result.Version, }) _ = err } // Build result with moderation warning if suspicious. var output string if result.IsSuspicious { output = fmt.Sprintf("⚠️ Warning: skill %q is flagged as suspicious (may contain risky patterns).\n\n", slug) } output += fmt.Sprintf("Successfully installed skill %q v%s from %s registry.\nLocation: %s\n", slug, result.Version, registry.Name(), targetDir) if result.Summary != "" { output += fmt.Sprintf("Description: %s\n", result.Summary) } output += "\nThe skill is now available and can be loaded in the current session." return SilentResult(output) } // originMeta tracks which registry a skill was installed from. type originMeta struct { Version int `json:"version"` Registry string `json:"registry"` Slug string `json:"slug"` InstalledVersion string `json:"installed_version"` InstalledAt int64 `json:"installed_at"` } func writeOriginMeta(targetDir, registryName, slug, version string) error { meta := originMeta{ Version: 1, Registry: registryName, Slug: slug, InstalledVersion: version, InstalledAt: time.Now().UnixMilli(), } data, err := json.MarshalIndent(meta, "", " ") if err != nil { return err } // Use unified atomic write utility with explicit sync for flash storage reliability. return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600) } ================================================ FILE: pkg/tools/skills_install_test.go ================================================ package tools import ( "context" "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/sipeed/picoclaw/pkg/skills" ) func TestInstallSkillToolName(t *testing.T) { tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) assert.Equal(t, "install_skill", tool.Name()) } func TestInstallSkillToolMissingSlug(t *testing.T) { tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) result := tool.Execute(context.Background(), map[string]any{}) assert.True(t, result.IsError) assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string") } func TestInstallSkillToolEmptySlug(t *testing.T) { tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) result := tool.Execute(context.Background(), map[string]any{ "slug": " ", }) assert.True(t, result.IsError) assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string") } func TestInstallSkillToolUnsafeSlug(t *testing.T) { tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) cases := []string{ "../etc/passwd", "path/traversal", "path\\traversal", } for _, slug := range cases { result := tool.Execute(context.Background(), map[string]any{ "slug": slug, }) assert.True(t, result.IsError, "slug %q should be rejected", slug) assert.Contains(t, result.ForLLM, "invalid slug") } } func TestInstallSkillToolAlreadyExists(t *testing.T) { workspace := t.TempDir() skillDir := filepath.Join(workspace, "skills", "existing-skill") require.NoError(t, os.MkdirAll(skillDir, 0o755)) tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace) result := tool.Execute(context.Background(), map[string]any{ "slug": "existing-skill", "registry": "clawhub", }) assert.True(t, result.IsError) assert.Contains(t, result.ForLLM, "already installed") } func TestInstallSkillToolRegistryNotFound(t *testing.T) { workspace := t.TempDir() tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace) result := tool.Execute(context.Background(), map[string]any{ "slug": "some-skill", "registry": "nonexistent", }) assert.True(t, result.IsError) assert.Contains(t, result.ForLLM, "registry") assert.Contains(t, result.ForLLM, "not found") } func TestInstallSkillToolParameters(t *testing.T) { tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) params := tool.Parameters() props, ok := params["properties"].(map[string]any) assert.True(t, ok) assert.Contains(t, props, "slug") assert.Contains(t, props, "version") assert.Contains(t, props, "registry") assert.Contains(t, props, "force") required, ok := params["required"].([]string) assert.True(t, ok) assert.Contains(t, required, "slug") assert.Contains(t, required, "registry") } func TestInstallSkillToolMissingRegistry(t *testing.T) { tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) result := tool.Execute(context.Background(), map[string]any{ "slug": "some-skill", }) assert.True(t, result.IsError) assert.Contains(t, result.ForLLM, "invalid registry") } ================================================ FILE: pkg/tools/skills_search.go ================================================ package tools import ( "context" "fmt" "strings" "github.com/sipeed/picoclaw/pkg/skills" ) // FindSkillsTool allows the LLM agent to search for installable skills from registries. type FindSkillsTool struct { registryMgr *skills.RegistryManager cache *skills.SearchCache } // NewFindSkillsTool creates a new FindSkillsTool. // registryMgr is the shared registry manager (built from config in createToolRegistry). // cache is the search cache for deduplicating similar queries. func NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache) *FindSkillsTool { return &FindSkillsTool{ registryMgr: registryMgr, cache: cache, } } func (t *FindSkillsTool) Name() string { return "find_skills" } func (t *FindSkillsTool) Description() string { return "Search for installable skills from skill registries. Returns skill slugs, descriptions, versions, and relevance scores. Use this to discover skills before installing them with install_skill." } func (t *FindSkillsTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "query": map[string]any{ "type": "string", "description": "Search query describing the desired skill capability (e.g., 'github integration', 'database management')", }, "limit": map[string]any{ "type": "integer", "description": "Maximum number of results to return (1-20, default 5)", "minimum": 1.0, "maximum": 20.0, }, }, "required": []string{"query"}, } } func (t *FindSkillsTool) Execute(ctx context.Context, args map[string]any) *ToolResult { query, ok := args["query"].(string) query = strings.ToLower(strings.TrimSpace(query)) if !ok || query == "" { return ErrorResult("query is required and must be a non-empty string") } limit := 5 if l, ok := args["limit"].(float64); ok { li := int(l) if li >= 1 && li <= 20 { limit = li } } // Check cache first. if t.cache != nil { if cached, hit := t.cache.Get(query); hit { return SilentResult(formatSearchResults(query, cached, true)) } } // Search all registries. results, err := t.registryMgr.SearchAll(ctx, query, limit) if err != nil { return ErrorResult(fmt.Sprintf("skill search failed: %v", err)) } // Cache the results. if t.cache != nil && len(results) > 0 { t.cache.Put(query, results) } return SilentResult(formatSearchResults(query, results, false)) } func formatSearchResults(query string, results []skills.SearchResult, cached bool) string { if len(results) == 0 { return fmt.Sprintf("No skills found for query: %q", query) } var sb strings.Builder source := "" if cached { source = " (cached)" } sb.WriteString(fmt.Sprintf("Found %d skills for %q%s:\n\n", len(results), query, source)) for i, r := range results { sb.WriteString(fmt.Sprintf("%d. **%s**", i+1, r.Slug)) if r.Version != "" { sb.WriteString(fmt.Sprintf(" v%s", r.Version)) } sb.WriteString(fmt.Sprintf(" (score: %.3f, registry: %s)\n", r.Score, r.RegistryName)) if r.DisplayName != "" && r.DisplayName != r.Slug { sb.WriteString(fmt.Sprintf(" Name: %s\n", r.DisplayName)) } if r.Summary != "" { sb.WriteString(fmt.Sprintf(" %s\n", r.Summary)) } sb.WriteString("\n") } sb.WriteString("Use install_skill with the slug to install a skill.") return sb.String() } ================================================ FILE: pkg/tools/skills_search_test.go ================================================ package tools import ( "context" "testing" "github.com/stretchr/testify/assert" "github.com/sipeed/picoclaw/pkg/skills" ) func TestFindSkillsToolName(t *testing.T) { tool := NewFindSkillsTool(skills.NewRegistryManager(), nil) assert.Equal(t, "find_skills", tool.Name()) } func TestFindSkillsToolMissingQuery(t *testing.T) { tool := NewFindSkillsTool(skills.NewRegistryManager(), nil) result := tool.Execute(context.Background(), map[string]any{}) assert.True(t, result.IsError) assert.Contains(t, result.ForLLM, "query is required") } func TestFindSkillsToolEmptyQuery(t *testing.T) { tool := NewFindSkillsTool(skills.NewRegistryManager(), nil) result := tool.Execute(context.Background(), map[string]any{ "query": " ", }) assert.True(t, result.IsError) } func TestFindSkillsToolCacheHit(t *testing.T) { cache := skills.NewSearchCache(10, 5*60*1000*1000*1000) // 5 min cache.Put("github", []skills.SearchResult{ {Slug: "github", Score: 0.9, RegistryName: "clawhub"}, }) tool := NewFindSkillsTool(skills.NewRegistryManager(), cache) result := tool.Execute(context.Background(), map[string]any{ "query": "github", }) assert.False(t, result.IsError) assert.Contains(t, result.ForLLM, "github") assert.Contains(t, result.ForLLM, "cached") } func TestFindSkillsToolParameters(t *testing.T) { tool := NewFindSkillsTool(skills.NewRegistryManager(), nil) params := tool.Parameters() props, ok := params["properties"].(map[string]any) assert.True(t, ok) assert.Contains(t, props, "query") assert.Contains(t, props, "limit") required, ok := params["required"].([]string) assert.True(t, ok) assert.Contains(t, required, "query") } func TestFindSkillsToolDescription(t *testing.T) { tool := NewFindSkillsTool(skills.NewRegistryManager(), nil) assert.NotEmpty(t, tool.Description()) assert.Contains(t, tool.Description(), "skill") } func TestFormatSearchResultsEmpty(t *testing.T) { result := formatSearchResults("test query", nil, false) assert.Contains(t, result, "No skills found") } func TestFormatSearchResultsWithData(t *testing.T) { results := []skills.SearchResult{ { Slug: "github", Score: 0.95, DisplayName: "GitHub", Summary: "GitHub API integration", Version: "1.0.0", RegistryName: "clawhub", }, } output := formatSearchResults("github", results, false) assert.Contains(t, output, "github") assert.Contains(t, output, "v1.0.0") assert.Contains(t, output, "0.950") assert.Contains(t, output, "clawhub") assert.Contains(t, output, "install_skill") } ================================================ FILE: pkg/tools/spawn.go ================================================ package tools import ( "context" "fmt" "strings" ) type SpawnTool struct { manager *SubagentManager allowlistCheck func(targetAgentID string) bool } // Compile-time check: SpawnTool implements AsyncExecutor. var _ AsyncExecutor = (*SpawnTool)(nil) func NewSpawnTool(manager *SubagentManager) *SpawnTool { return &SpawnTool{ manager: manager, } } func (t *SpawnTool) Name() string { return "spawn" } func (t *SpawnTool) Description() string { return "Spawn a subagent to handle a task in the background. Use this for complex or time-consuming tasks that can run independently. The subagent will complete the task and report back when done." } func (t *SpawnTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "task": map[string]any{ "type": "string", "description": "The task for subagent to complete", }, "label": map[string]any{ "type": "string", "description": "Optional short label for the task (for display)", }, "agent_id": map[string]any{ "type": "string", "description": "Optional target agent ID to delegate the task to", }, }, "required": []string{"task"}, } } func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) { t.allowlistCheck = check } func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult { return t.execute(ctx, args, nil) } // ExecuteAsync implements AsyncExecutor. The callback is passed through to the // subagent manager as a call parameter — never stored on the SpawnTool instance. func (t *SpawnTool) ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult { return t.execute(ctx, args, cb) } func (t *SpawnTool) execute(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult { task, ok := args["task"].(string) if !ok || strings.TrimSpace(task) == "" { return ErrorResult("task is required and must be a non-empty string") } label, _ := args["label"].(string) agentID, _ := args["agent_id"].(string) // Check allowlist if targeting a specific agent if agentID != "" && t.allowlistCheck != nil { if !t.allowlistCheck(agentID) { return ErrorResult(fmt.Sprintf("not allowed to spawn agent '%s'", agentID)) } } if t.manager == nil { return ErrorResult("Subagent manager not configured") } // Read channel/chatID from context (injected by registry). // Fall back to "cli"/"direct" for non-conversation callers (e.g., CLI, tests) // to preserve the same defaults as the original NewSpawnTool constructor. channel := ToolChannel(ctx) if channel == "" { channel = "cli" } chatID := ToolChatID(ctx) if chatID == "" { chatID = "direct" } // Pass callback to manager for async completion notification result, err := t.manager.Spawn(ctx, task, label, agentID, channel, chatID, cb) if err != nil { return ErrorResult(fmt.Sprintf("failed to spawn subagent: %v", err)) } // Return AsyncResult since the task runs in background return AsyncResult(result) } ================================================ FILE: pkg/tools/spawn_status.go ================================================ package tools import ( "context" "fmt" "sort" "strings" "time" ) // SpawnStatusTool reports the status of subagents that were spawned via the // spawn tool. It can query a specific task by ID, or list every known task with // a summary count broken-down by status. type SpawnStatusTool struct { manager *SubagentManager } // NewSpawnStatusTool creates a SpawnStatusTool backed by the given manager. func NewSpawnStatusTool(manager *SubagentManager) *SpawnStatusTool { return &SpawnStatusTool{manager: manager} } func (t *SpawnStatusTool) Name() string { return "spawn_status" } func (t *SpawnStatusTool) Description() string { return "Get the status of spawned subagents. " + "Returns a list of all subagents and their current state " + "(running, completed, failed, or canceled), or retrieves details " + "for a specific subagent task when task_id is provided. " + "Results are scoped to the current conversation's channel and chat ID; " + "all tasks are listed only when no channel/chat context is injected " + "(e.g. direct programmatic calls via Execute)." } func (t *SpawnStatusTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "task_id": map[string]any{ "type": "string", "description": "Optional task ID (e.g. \"subagent-1\") to inspect a specific " + "subagent. When omitted, all visible subagents are listed.", }, }, "required": []string{}, } } func (t *SpawnStatusTool) Execute(ctx context.Context, args map[string]any) *ToolResult { if t.manager == nil { return ErrorResult("Subagent manager not configured") } // Derive the calling conversation's identity so we can scope results to the // current chat only — preventing cross-conversation task leakage in // multi-user deployments. callerChannel := ToolChannel(ctx) callerChatID := ToolChatID(ctx) var taskID string if rawTaskID, ok := args["task_id"]; ok && rawTaskID != nil { taskIDStr, ok := rawTaskID.(string) if !ok { return ErrorResult("task_id must be a string") } taskID = strings.TrimSpace(taskIDStr) } if taskID != "" { // GetTaskCopy returns a consistent snapshot under the manager lock, // eliminating any data race with the concurrent subagent goroutine. taskCopy, ok := t.manager.GetTaskCopy(taskID) if !ok { return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID)) } // Restrict lookup to tasks that belong to this conversation. if callerChannel != "" && taskCopy.OriginChannel != "" && taskCopy.OriginChannel != callerChannel { return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID)) } if callerChatID != "" && taskCopy.OriginChatID != "" && taskCopy.OriginChatID != callerChatID { return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID)) } return NewToolResult(spawnStatusFormatTask(&taskCopy)) } // ListTaskCopies returns consistent snapshots under the manager lock. origTasks := t.manager.ListTaskCopies() if len(origTasks) == 0 { return NewToolResult("No subagents have been spawned yet.") } tasks := make([]*SubagentTask, 0, len(origTasks)) for i := range origTasks { cpy := &origTasks[i] // Filter to tasks that originate from the current conversation only. if callerChannel != "" && cpy.OriginChannel != "" && cpy.OriginChannel != callerChannel { continue } if callerChatID != "" && cpy.OriginChatID != "" && cpy.OriginChatID != callerChatID { continue } tasks = append(tasks, cpy) } if len(tasks) == 0 { return NewToolResult("No subagents found for this conversation.") } // Order by creation time (ascending) so spawning order is preserved. // Fall back to ID string for tasks created in the same millisecond. sort.Slice(tasks, func(i, j int) bool { if tasks[i].Created != tasks[j].Created { return tasks[i].Created < tasks[j].Created } return tasks[i].ID < tasks[j].ID }) counts := map[string]int{} for _, task := range tasks { counts[task.Status]++ } var sb strings.Builder sb.WriteString(fmt.Sprintf("Subagent status report (%d total):\n", len(tasks))) for _, status := range []string{"running", "completed", "failed", "canceled"} { if n := counts[status]; n > 0 { label := strings.ToUpper(status[:1]) + status[1:] + ":" sb.WriteString(fmt.Sprintf(" %-10s %d\n", label, n)) } } sb.WriteString("\n") for _, task := range tasks { sb.WriteString(spawnStatusFormatTask(task)) sb.WriteString("\n\n") } return NewToolResult(strings.TrimRight(sb.String(), "\n")) } // spawnStatusFormatTask renders a single SubagentTask as a human-readable block. func spawnStatusFormatTask(task *SubagentTask) string { var sb strings.Builder header := fmt.Sprintf("[%s] status=%s", task.ID, task.Status) if task.Label != "" { header += fmt.Sprintf(" label=%q", task.Label) } if task.AgentID != "" { header += fmt.Sprintf(" agent=%s", task.AgentID) } if task.Created > 0 { created := time.UnixMilli(task.Created).UTC().Format("2006-01-02 15:04:05 UTC") header += fmt.Sprintf(" created=%s", created) } sb.WriteString(header) if task.Task != "" { sb.WriteString(fmt.Sprintf("\n task: %s", task.Task)) } if task.Result != "" { result := task.Result const maxResultLen = 300 runes := []rune(result) if len(runes) > maxResultLen { result = string(runes[:maxResultLen]) + "…" } sb.WriteString(fmt.Sprintf("\n result: %s", result)) } return sb.String() } ================================================ FILE: pkg/tools/spawn_status_test.go ================================================ package tools import ( "context" "fmt" "strings" "testing" "time" ) func TestSpawnStatusTool_Name(t *testing.T) { provider := &MockLLMProvider{} workspace := t.TempDir() manager := NewSubagentManager(provider, "test-model", workspace) tool := NewSpawnStatusTool(manager) if tool.Name() != "spawn_status" { t.Errorf("Expected name 'spawn_status', got '%s'", tool.Name()) } } func TestSpawnStatusTool_Description(t *testing.T) { provider := &MockLLMProvider{} workspace := t.TempDir() manager := NewSubagentManager(provider, "test-model", workspace) tool := NewSpawnStatusTool(manager) desc := tool.Description() if desc == "" { t.Error("Description should not be empty") } if !strings.Contains(strings.ToLower(desc), "subagent") { t.Errorf("Description should mention 'subagent', got: %s", desc) } } func TestSpawnStatusTool_Parameters(t *testing.T) { provider := &MockLLMProvider{} workspace := t.TempDir() manager := NewSubagentManager(provider, "test-model", workspace) tool := NewSpawnStatusTool(manager) params := tool.Parameters() if params["type"] != "object" { t.Errorf("Expected type 'object', got: %v", params["type"]) } props, ok := params["properties"].(map[string]any) if !ok { t.Fatal("Expected 'properties' to be a map") } if _, hasTaskID := props["task_id"]; !hasTaskID { t.Error("Expected 'task_id' parameter in properties") } } func TestSpawnStatusTool_NilManager(t *testing.T) { tool := &SpawnStatusTool{manager: nil} result := tool.Execute(context.Background(), map[string]any{}) if !result.IsError { t.Error("Expected error result when manager is nil") } } func TestSpawnStatusTool_Empty(t *testing.T) { provider := &MockLLMProvider{} workspace := t.TempDir() manager := NewSubagentManager(provider, "test-model", workspace) tool := NewSpawnStatusTool(manager) result := tool.Execute(context.Background(), map[string]any{}) if result.IsError { t.Fatalf("Expected success, got error: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "No subagents") { t.Errorf("Expected 'No subagents' message, got: %s", result.ForLLM) } } func TestSpawnStatusTool_ListAll(t *testing.T) { provider := &MockLLMProvider{} workspace := t.TempDir() manager := NewSubagentManager(provider, "test-model", workspace) now := time.Now().UnixMilli() manager.mu.Lock() manager.tasks["subagent-1"] = &SubagentTask{ ID: "subagent-1", Task: "Do task A", Label: "task-a", Status: "running", Created: now, } manager.tasks["subagent-2"] = &SubagentTask{ ID: "subagent-2", Task: "Do task B", Label: "task-b", Status: "completed", Result: "Done successfully", Created: now, } manager.tasks["subagent-3"] = &SubagentTask{ ID: "subagent-3", Task: "Do task C", Status: "failed", Result: "Error: something went wrong", } manager.mu.Unlock() tool := NewSpawnStatusTool(manager) result := tool.Execute(context.Background(), map[string]any{}) if result.IsError { t.Fatalf("Expected success, got error: %s", result.ForLLM) } // Summary header if !strings.Contains(result.ForLLM, "3 total") { t.Errorf("Expected total count in header, got: %s", result.ForLLM) } // Individual task IDs for _, id := range []string{"subagent-1", "subagent-2", "subagent-3"} { if !strings.Contains(result.ForLLM, id) { t.Errorf("Expected task %s in output, got:\n%s", id, result.ForLLM) } } // Status values for _, status := range []string{"running", "completed", "failed"} { if !strings.Contains(result.ForLLM, status) { t.Errorf("Expected status '%s' in output, got:\n%s", status, result.ForLLM) } } // Result content if !strings.Contains(result.ForLLM, "Done successfully") { t.Errorf("Expected result text in output, got:\n%s", result.ForLLM) } } func TestSpawnStatusTool_GetByID(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") manager.mu.Lock() manager.tasks["subagent-42"] = &SubagentTask{ ID: "subagent-42", Task: "Specific task", Label: "my-task", Status: "failed", Result: "Something went wrong", Created: time.Now().UnixMilli(), } manager.mu.Unlock() tool := NewSpawnStatusTool(manager) result := tool.Execute(context.Background(), map[string]any{"task_id": "subagent-42"}) if result.IsError { t.Fatalf("Expected success, got error: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "subagent-42") { t.Errorf("Expected task ID in output, got: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "failed") { t.Errorf("Expected status 'failed' in output, got: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "Something went wrong") { t.Errorf("Expected result text in output, got: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "my-task") { t.Errorf("Expected label in output, got: %s", result.ForLLM) } } func TestSpawnStatusTool_GetByID_NotFound(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") tool := NewSpawnStatusTool(manager) result := tool.Execute(context.Background(), map[string]any{"task_id": "nonexistent-999"}) if !result.IsError { t.Errorf("Expected error for nonexistent task, got: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "nonexistent-999") { t.Errorf("Expected task ID in error message, got: %s", result.ForLLM) } } func TestSpawnStatusTool_TaskID_NonString(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") tool := NewSpawnStatusTool(manager) for _, badVal := range []any{42, 3.14, true, map[string]any{"x": 1}, []string{"a"}} { result := tool.Execute(context.Background(), map[string]any{"task_id": badVal}) if !result.IsError { t.Errorf("Expected error for task_id=%T(%v), got success: %s", badVal, badVal, result.ForLLM) } if !strings.Contains(result.ForLLM, "task_id must be a string") { t.Errorf("Expected type-error message, got: %s", result.ForLLM) } } } func TestSpawnStatusTool_ResultTruncation(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") longResult := strings.Repeat("X", 500) manager.mu.Lock() manager.tasks["subagent-1"] = &SubagentTask{ ID: "subagent-1", Task: "Long task", Status: "completed", Result: longResult, } manager.mu.Unlock() tool := NewSpawnStatusTool(manager) result := tool.Execute(context.Background(), map[string]any{"task_id": "subagent-1"}) if result.IsError { t.Fatalf("Unexpected error: %s", result.ForLLM) } // Output should be shorter than the raw result due to truncation if len(result.ForLLM) >= len(longResult) { t.Errorf("Expected result to be truncated, but ForLLM is %d chars", len(result.ForLLM)) } if !strings.Contains(result.ForLLM, "…") { t.Errorf("Expected truncation indicator '…' in output, got: %s", result.ForLLM) } } func TestSpawnStatusTool_ResultTruncation_Unicode(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") // Each CJK rune is 3 bytes; 400 runes = 1200 bytes — well over the 300-rune limit. cjkChar := string(rune(0x5b57)) longResult := strings.Repeat(cjkChar, 400) manager.mu.Lock() manager.tasks["subagent-1"] = &SubagentTask{ ID: "subagent-1", Task: "Unicode task", Status: "completed", Result: longResult, } manager.mu.Unlock() tool := NewSpawnStatusTool(manager) result := tool.Execute(context.Background(), map[string]any{"task_id": "subagent-1"}) if result.IsError { t.Fatalf("Unexpected error: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "…") { t.Errorf("Expected truncation indicator in output") } // The truncated result must be valid UTF-8 (no split rune boundaries). if !strings.Contains(result.ForLLM, cjkChar) { t.Errorf("Expected CJK runes to appear intact in output") } } func TestSpawnStatusTool_StatusCounts(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") manager.mu.Lock() for i, status := range []string{"running", "running", "completed", "failed", "canceled"} { id := fmt.Sprintf("subagent-%d", i+1) manager.tasks[id] = &SubagentTask{ID: id, Task: "t", Status: status} } manager.mu.Unlock() tool := NewSpawnStatusTool(manager) result := tool.Execute(context.Background(), map[string]any{}) if result.IsError { t.Fatalf("Unexpected error: %s", result.ForLLM) } // The summary line should mention all statuses that have counts for _, want := range []string{"Running:", "Completed:", "Failed:", "Canceled:"} { if !strings.Contains(result.ForLLM, want) { t.Errorf("Expected %q in summary, got:\n%s", want, result.ForLLM) } } } func TestSpawnStatusTool_SortByCreatedTimestamp(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") now := time.Now().UnixMilli() manager.mu.Lock() // Intentionally insert with out-of-order IDs and timestamps that reflect // true spawn order: subagent-2 was spawned first, subagent-10 second. manager.tasks["subagent-10"] = &SubagentTask{ ID: "subagent-10", Task: "second", Status: "running", Created: now + 1, } manager.tasks["subagent-2"] = &SubagentTask{ ID: "subagent-2", Task: "first", Status: "running", Created: now, } manager.mu.Unlock() tool := NewSpawnStatusTool(manager) result := tool.Execute(context.Background(), map[string]any{}) if result.IsError { t.Fatalf("Unexpected error: %s", result.ForLLM) } pos2 := strings.Index(result.ForLLM, "subagent-2") pos10 := strings.Index(result.ForLLM, "subagent-10") if pos2 < 0 || pos10 < 0 { t.Fatalf("Both task IDs should appear in output:\n%s", result.ForLLM) } if pos2 > pos10 { t.Errorf("Expected subagent-2 (created first) to appear before subagent-10, but got:\n%s", result.ForLLM) } } func TestSpawnStatusTool_ChannelFiltering_ListAll(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") manager.mu.Lock() manager.tasks["subagent-1"] = &SubagentTask{ ID: "subagent-1", Task: "mine", Status: "running", OriginChannel: "telegram", OriginChatID: "chat-A", } manager.tasks["subagent-2"] = &SubagentTask{ ID: "subagent-2", Task: "other user", Status: "running", OriginChannel: "telegram", OriginChatID: "chat-B", } manager.mu.Unlock() tool := NewSpawnStatusTool(manager) // Caller is chat-A — should only see subagent-1. ctx := WithToolContext(context.Background(), "telegram", "chat-A") result := tool.Execute(ctx, map[string]any{}) if result.IsError { t.Fatalf("Unexpected error: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "subagent-1") { t.Errorf("Expected own task in output, got:\n%s", result.ForLLM) } if strings.Contains(result.ForLLM, "subagent-2") { t.Errorf("Should NOT see other chat's task, got:\n%s", result.ForLLM) } } func TestSpawnStatusTool_ChannelFiltering_GetByID(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") manager.mu.Lock() manager.tasks["subagent-99"] = &SubagentTask{ ID: "subagent-99", Task: "secret", Status: "completed", Result: "private data", OriginChannel: "slack", OriginChatID: "room-Z", } manager.mu.Unlock() tool := NewSpawnStatusTool(manager) // Different chat trying to look up subagent-99 by ID. ctx := WithToolContext(context.Background(), "slack", "room-OTHER") result := tool.Execute(ctx, map[string]any{"task_id": "subagent-99"}) if !result.IsError { t.Errorf("Expected error (cross-chat lookup blocked), got: %s", result.ForLLM) } } func TestSpawnStatusTool_ChannelFiltering_NoContext(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") manager.mu.Lock() manager.tasks["subagent-1"] = &SubagentTask{ ID: "subagent-1", Task: "t", Status: "completed", OriginChannel: "telegram", OriginChatID: "chat-A", } manager.mu.Unlock() tool := NewSpawnStatusTool(manager) // No ToolContext injected (e.g. a direct programmatic call that bypasses // WithToolContext entirely) — callerChannel and callerChatID are both "". // Note: the normal CLI path uses ProcessDirectWithChannel("cli", "direct"), // which *does* inject a non-empty context; this test covers the case where // no context injection happens at all. // The filter conditions require a non-empty caller value, so all tasks pass through. result := tool.Execute(context.Background(), map[string]any{}) if result.IsError { t.Fatalf("Unexpected error: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "subagent-1") { t.Errorf("Expected task visible from no-context caller, got:\n%s", result.ForLLM) } } ================================================ FILE: pkg/tools/spawn_test.go ================================================ package tools import ( "context" "strings" "testing" ) func TestSpawnTool_Execute_EmptyTask(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") tool := NewSpawnTool(manager) ctx := context.Background() tests := []struct { name string args map[string]any }{ {"empty string", map[string]any{"task": ""}}, {"whitespace only", map[string]any{"task": " "}}, {"tabs and newlines", map[string]any{"task": "\t\n "}}, {"missing task key", map[string]any{"label": "test"}}, {"wrong type", map[string]any{"task": 123}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := tool.Execute(ctx, tt.args) if result == nil { t.Fatal("Result should not be nil") } if !result.IsError { t.Error("Expected error for invalid task parameter") } if !strings.Contains(result.ForLLM, "task is required") { t.Errorf("Error message should mention 'task is required', got: %s", result.ForLLM) } }) } } func TestSpawnTool_Execute_ValidTask(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") tool := NewSpawnTool(manager) ctx := context.Background() args := map[string]any{ "task": "Write a haiku about coding", "label": "haiku-task", } result := tool.Execute(ctx, args) if result == nil { t.Fatal("Result should not be nil") } if result.IsError { t.Errorf("Expected success for valid task, got error: %s", result.ForLLM) } if !result.Async { t.Error("SpawnTool should return async result") } } func TestSpawnTool_Execute_NilManager(t *testing.T) { tool := NewSpawnTool(nil) ctx := context.Background() args := map[string]any{"task": "test task"} result := tool.Execute(ctx, args) if !result.IsError { t.Error("Expected error for nil manager") } if !strings.Contains(result.ForLLM, "Subagent manager not configured") { t.Errorf("Error message should mention manager not configured, got: %s", result.ForLLM) } } ================================================ FILE: pkg/tools/spi.go ================================================ package tools import ( "context" "encoding/json" "fmt" "path/filepath" "regexp" "runtime" ) // SPITool provides SPI bus interaction for high-speed peripheral communication. type SPITool struct{} func NewSPITool() *SPITool { return &SPITool{} } func (t *SPITool) Name() string { return "spi" } func (t *SPITool) Description() string { return "Interact with SPI bus devices for high-speed peripheral communication. Actions: list (find SPI devices), transfer (full-duplex send/receive), read (receive bytes). Linux only." } func (t *SPITool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "action": map[string]any{ "type": "string", "enum": []string{"list", "transfer", "read"}, "description": "Action to perform: list (find available SPI devices), transfer (full-duplex send/receive), read (receive bytes by sending zeros)", }, "device": map[string]any{ "type": "string", "description": "SPI device identifier (e.g. \"2.0\" for /dev/spidev2.0). Required for transfer/read.", }, "speed": map[string]any{ "type": "integer", "description": "SPI clock speed in Hz. Default: 1000000 (1 MHz).", }, "mode": map[string]any{ "type": "integer", "description": "SPI mode (0-3). Default: 0. Mode sets CPOL and CPHA: 0=0,0 1=0,1 2=1,0 3=1,1.", }, "bits": map[string]any{ "type": "integer", "description": "Bits per word. Default: 8.", }, "data": map[string]any{ "type": "array", "items": map[string]any{"type": "integer"}, "description": "Bytes to send (0-255 each). Required for transfer action.", }, "length": map[string]any{ "type": "integer", "description": "Number of bytes to read (1-4096). Required for read action.", }, "confirm": map[string]any{ "type": "boolean", "description": "Must be true for transfer operations. Safety guard to prevent accidental writes.", }, }, "required": []string{"action"}, } } func (t *SPITool) Execute(ctx context.Context, args map[string]any) *ToolResult { if runtime.GOOS != "linux" { return ErrorResult("SPI is only supported on Linux. This tool requires /dev/spidev* device files.") } action, ok := args["action"].(string) if !ok { return ErrorResult("action is required") } switch action { case "list": return t.list() case "transfer": return t.transfer(args) case "read": return t.readDevice(args) default: return ErrorResult(fmt.Sprintf("unknown action: %s (valid: list, transfer, read)", action)) } } // list finds available SPI devices by globbing /dev/spidev* func (t *SPITool) list() *ToolResult { matches, err := filepath.Glob("/dev/spidev*") if err != nil { return ErrorResult(fmt.Sprintf("failed to scan for SPI devices: %v", err)) } if len(matches) == 0 { return SilentResult( "No SPI devices found. You may need to:\n1. Enable SPI in device tree\n2. Configure pinmux for your board (see hardware skill)\n3. Check that spidev module is loaded", ) } type devInfo struct { Path string `json:"path"` Device string `json:"device"` } devices := make([]devInfo, 0, len(matches)) re := regexp.MustCompile(`/dev/spidev(\d+\.\d+)`) for _, m := range matches { if sub := re.FindStringSubmatch(m); sub != nil { devices = append(devices, devInfo{Path: m, Device: sub[1]}) } } result, _ := json.MarshalIndent(devices, "", " ") return SilentResult(fmt.Sprintf("Found %d SPI device(s):\n%s", len(devices), string(result))) } // Helper function for SPI operations (used by platform-specific implementations) // parseSPIArgs extracts and validates common SPI parameters // //nolint:unused // Used by spi_linux.go func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8, bits uint8, errMsg string) { dev, ok := args["device"].(string) if !ok || dev == "" { return "", 0, 0, 0, "device is required (e.g. \"2.0\" for /dev/spidev2.0)" } matched, _ := regexp.MatchString(`^\d+\.\d+$`, dev) if !matched { return "", 0, 0, 0, "invalid device identifier: must be in format \"X.Y\" (e.g. \"2.0\")" } speed = 1000000 // default 1 MHz if s, ok := args["speed"].(float64); ok { if s < 1 || s > 125000000 { return "", 0, 0, 0, "speed must be between 1 Hz and 125 MHz" } speed = uint32(s) } mode = 0 if m, ok := args["mode"].(float64); ok { if int(m) < 0 || int(m) > 3 { return "", 0, 0, 0, "mode must be 0-3" } mode = uint8(m) } bits = 8 if b, ok := args["bits"].(float64); ok { if int(b) < 1 || int(b) > 32 { return "", 0, 0, 0, "bits must be between 1 and 32" } bits = uint8(b) } return dev, speed, mode, bits, "" } ================================================ FILE: pkg/tools/spi_linux.go ================================================ package tools import ( "encoding/json" "fmt" "runtime" "syscall" "unsafe" ) // SPI ioctl constants from Linux kernel headers. // Calculated from _IOW('k', nr, size) macro: // // direction(1)<<30 | size<<16 | type(0x6B)<<8 | nr const ( spiIocWrMode = 0x40016B01 // _IOW('k', 1, __u8) spiIocWrBitsPerWord = 0x40016B03 // _IOW('k', 3, __u8) spiIocWrMaxSpeedHz = 0x40046B04 // _IOW('k', 4, __u32) spiIocMessage1 = 0x40206B00 // _IOW('k', 0, struct spi_ioc_transfer) — 32 bytes ) // spiTransfer matches Linux kernel struct spi_ioc_transfer (32 bytes on all architectures). type spiTransfer struct { txBuf uint64 rxBuf uint64 length uint32 speedHz uint32 delayUsecs uint16 bitsPerWord uint8 csChange uint8 txNbits uint8 rxNbits uint8 wordDelay uint8 pad uint8 } // configureSPI opens an SPI device and sets mode, bits per word, and speed func configureSPI(devPath string, mode uint8, bits uint8, speed uint32) (int, *ToolResult) { fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) if err != nil { return -1, ErrorResult(fmt.Sprintf("failed to open %s: %v (check permissions and spidev module)", devPath, err)) } // Set SPI mode _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrMode, uintptr(unsafe.Pointer(&mode))) if errno != 0 { syscall.Close(fd) return -1, ErrorResult(fmt.Sprintf("failed to set SPI mode %d: %v", mode, errno)) } // Set bits per word _, _, errno = syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrBitsPerWord, uintptr(unsafe.Pointer(&bits))) if errno != 0 { syscall.Close(fd) return -1, ErrorResult(fmt.Sprintf("failed to set bits per word %d: %v", bits, errno)) } // Set max speed _, _, errno = syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrMaxSpeedHz, uintptr(unsafe.Pointer(&speed))) if errno != 0 { syscall.Close(fd) return -1, ErrorResult(fmt.Sprintf("failed to set SPI speed %d Hz: %v", speed, errno)) } return fd, nil } // transfer performs a full-duplex SPI transfer func (t *SPITool) transfer(args map[string]any) *ToolResult { confirm, _ := args["confirm"].(bool) if !confirm { return ErrorResult( "transfer operations require confirm: true. Please confirm with the user before sending data to SPI devices.", ) } dev, speed, mode, bits, errMsg := parseSPIArgs(args) if errMsg != "" { return ErrorResult(errMsg) } dataRaw, ok := args["data"].([]any) if !ok || len(dataRaw) == 0 { return ErrorResult("data is required for transfer (array of byte values 0-255)") } if len(dataRaw) > 4096 { return ErrorResult("data too long: maximum 4096 bytes per SPI transfer") } txBuf := make([]byte, len(dataRaw)) for i, v := range dataRaw { f, ok := v.(float64) if !ok { return ErrorResult(fmt.Sprintf("data[%d] is not a valid byte value", i)) } b := int(f) if b < 0 || b > 255 { return ErrorResult(fmt.Sprintf("data[%d] = %d is out of byte range (0-255)", i, b)) } txBuf[i] = byte(b) } devPath := fmt.Sprintf("/dev/spidev%s", dev) fd, errResult := configureSPI(devPath, mode, bits, speed) if errResult != nil { return errResult } defer syscall.Close(fd) rxBuf := make([]byte, len(txBuf)) xfer := spiTransfer{ txBuf: uint64(uintptr(unsafe.Pointer(&txBuf[0]))), rxBuf: uint64(uintptr(unsafe.Pointer(&rxBuf[0]))), length: uint32(len(txBuf)), speedHz: speed, bitsPerWord: bits, } _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocMessage1, uintptr(unsafe.Pointer(&xfer))) runtime.KeepAlive(txBuf) runtime.KeepAlive(rxBuf) if errno != 0 { return ErrorResult(fmt.Sprintf("SPI transfer failed: %v", errno)) } // Format received bytes hexBytes := make([]string, len(rxBuf)) intBytes := make([]int, len(rxBuf)) for i, b := range rxBuf { hexBytes[i] = fmt.Sprintf("0x%02x", b) intBytes[i] = int(b) } result, _ := json.MarshalIndent(map[string]any{ "device": devPath, "sent": len(txBuf), "received": intBytes, "hex": hexBytes, }, "", " ") return SilentResult(string(result)) } // readDevice reads bytes from SPI by sending zeros (read-only, no confirm needed) func (t *SPITool) readDevice(args map[string]any) *ToolResult { dev, speed, mode, bits, errMsg := parseSPIArgs(args) if errMsg != "" { return ErrorResult(errMsg) } length := 0 if l, ok := args["length"].(float64); ok { length = int(l) } if length < 1 || length > 4096 { return ErrorResult("length is required for read (1-4096)") } devPath := fmt.Sprintf("/dev/spidev%s", dev) fd, errResult := configureSPI(devPath, mode, bits, speed) if errResult != nil { return errResult } defer syscall.Close(fd) txBuf := make([]byte, length) // zeros rxBuf := make([]byte, length) xfer := spiTransfer{ txBuf: uint64(uintptr(unsafe.Pointer(&txBuf[0]))), rxBuf: uint64(uintptr(unsafe.Pointer(&rxBuf[0]))), length: uint32(length), speedHz: speed, bitsPerWord: bits, } _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocMessage1, uintptr(unsafe.Pointer(&xfer))) runtime.KeepAlive(txBuf) runtime.KeepAlive(rxBuf) if errno != 0 { return ErrorResult(fmt.Sprintf("SPI read failed: %v", errno)) } hexBytes := make([]string, len(rxBuf)) intBytes := make([]int, len(rxBuf)) for i, b := range rxBuf { hexBytes[i] = fmt.Sprintf("0x%02x", b) intBytes[i] = int(b) } result, _ := json.MarshalIndent(map[string]any{ "device": devPath, "bytes": intBytes, "hex": hexBytes, "length": len(rxBuf), }, "", " ") return SilentResult(string(result)) } ================================================ FILE: pkg/tools/spi_other.go ================================================ //go:build !linux package tools // transfer is a stub for non-Linux platforms. func (t *SPITool) transfer(args map[string]any) *ToolResult { return ErrorResult("SPI is only supported on Linux") } // readDevice is a stub for non-Linux platforms. func (t *SPITool) readDevice(args map[string]any) *ToolResult { return ErrorResult("SPI is only supported on Linux") } ================================================ FILE: pkg/tools/subagent.go ================================================ package tools import ( "context" "fmt" "sync" "time" "github.com/sipeed/picoclaw/pkg/providers" ) type SubagentTask struct { ID string Task string Label string AgentID string OriginChannel string OriginChatID string Status string Result string Created int64 } type SubagentManager struct { tasks map[string]*SubagentTask mu sync.RWMutex provider providers.LLMProvider defaultModel string workspace string tools *ToolRegistry maxIterations int maxTokens int temperature float64 hasMaxTokens bool hasTemperature bool nextID int } func NewSubagentManager( provider providers.LLMProvider, defaultModel, workspace string, ) *SubagentManager { return &SubagentManager{ tasks: make(map[string]*SubagentTask), provider: provider, defaultModel: defaultModel, workspace: workspace, tools: NewToolRegistry(), maxIterations: 10, nextID: 1, } } // SetLLMOptions sets max tokens and temperature for subagent LLM calls. func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) { sm.mu.Lock() defer sm.mu.Unlock() sm.maxTokens = maxTokens sm.hasMaxTokens = true sm.temperature = temperature sm.hasTemperature = true } // SetTools sets the tool registry for subagent execution. // If not set, subagent will have access to the provided tools. func (sm *SubagentManager) SetTools(tools *ToolRegistry) { sm.mu.Lock() defer sm.mu.Unlock() sm.tools = tools } // RegisterTool registers a tool for subagent execution. func (sm *SubagentManager) RegisterTool(tool Tool) { sm.mu.Lock() defer sm.mu.Unlock() sm.tools.Register(tool) } func (sm *SubagentManager) Spawn( ctx context.Context, task, label, agentID, originChannel, originChatID string, callback AsyncCallback, ) (string, error) { sm.mu.Lock() defer sm.mu.Unlock() taskID := fmt.Sprintf("subagent-%d", sm.nextID) sm.nextID++ subagentTask := &SubagentTask{ ID: taskID, Task: task, Label: label, AgentID: agentID, OriginChannel: originChannel, OriginChatID: originChatID, Status: "running", Created: time.Now().UnixMilli(), } sm.tasks[taskID] = subagentTask // Start task in background with context cancellation support go sm.runTask(ctx, subagentTask, callback) if label != "" { return fmt.Sprintf("Spawned subagent '%s' for task: %s", label, task), nil } return fmt.Sprintf("Spawned subagent for task: %s", task), nil } func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback AsyncCallback) { // Build system prompt for subagent systemPrompt := `You are a subagent. Complete the given task independently and report the result. You have access to tools - use them as needed to complete your task. After completing the task, provide a clear summary of what was done.` messages := []providers.Message{ { Role: "system", Content: systemPrompt, }, { Role: "user", Content: task.Task, }, } // Check if context is already canceled before starting select { case <-ctx.Done(): sm.mu.Lock() task.Status = "canceled" task.Result = "Task canceled before execution" sm.mu.Unlock() return default: } // Run tool loop with access to tools sm.mu.RLock() tools := sm.tools maxIter := sm.maxIterations maxTokens := sm.maxTokens temperature := sm.temperature hasMaxTokens := sm.hasMaxTokens hasTemperature := sm.hasTemperature sm.mu.RUnlock() var llmOptions map[string]any if hasMaxTokens || hasTemperature { llmOptions = map[string]any{} if hasMaxTokens { llmOptions["max_tokens"] = maxTokens } if hasTemperature { llmOptions["temperature"] = temperature } } loopResult, err := RunToolLoop(ctx, ToolLoopConfig{ Provider: sm.provider, Model: sm.defaultModel, Tools: tools, MaxIterations: maxIter, LLMOptions: llmOptions, }, messages, task.OriginChannel, task.OriginChatID) sm.mu.Lock() var result *ToolResult defer func() { sm.mu.Unlock() // Call callback if provided and result is set if callback != nil && result != nil { callback(ctx, result) } }() if err != nil { task.Status = "failed" task.Result = fmt.Sprintf("Error: %v", err) // Check if it was canceled if ctx.Err() != nil { task.Status = "canceled" task.Result = "Task canceled during execution" } result = &ToolResult{ ForLLM: task.Result, ForUser: "", Silent: false, IsError: true, Async: false, Err: err, } } else { task.Status = "completed" task.Result = loopResult.Content result = &ToolResult{ ForLLM: fmt.Sprintf( "Subagent '%s' completed (iterations: %d): %s", task.Label, loopResult.Iterations, loopResult.Content, ), ForUser: loopResult.Content, Silent: false, IsError: false, Async: false, } } } func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) { sm.mu.RLock() defer sm.mu.RUnlock() task, ok := sm.tasks[taskID] return task, ok } // GetTaskCopy returns a copy of the task with the given ID, taken under the // read lock, so the caller receives a consistent snapshot with no data race. func (sm *SubagentManager) GetTaskCopy(taskID string) (SubagentTask, bool) { sm.mu.RLock() defer sm.mu.RUnlock() task, ok := sm.tasks[taskID] if !ok { return SubagentTask{}, false } return *task, true } func (sm *SubagentManager) ListTasks() []*SubagentTask { sm.mu.RLock() defer sm.mu.RUnlock() tasks := make([]*SubagentTask, 0, len(sm.tasks)) for _, task := range sm.tasks { tasks = append(tasks, task) } return tasks } // ListTaskCopies returns value copies of all tasks, taken under the read lock, // so callers receive consistent snapshots with no data race. func (sm *SubagentManager) ListTaskCopies() []SubagentTask { sm.mu.RLock() defer sm.mu.RUnlock() copies := make([]SubagentTask, 0, len(sm.tasks)) for _, task := range sm.tasks { copies = append(copies, *task) } return copies } // SubagentTool executes a subagent task synchronously and returns the result. // Unlike SpawnTool which runs tasks asynchronously, SubagentTool waits for completion // and returns the result directly in the ToolResult. type SubagentTool struct { manager *SubagentManager } func NewSubagentTool(manager *SubagentManager) *SubagentTool { return &SubagentTool{ manager: manager, } } func (t *SubagentTool) Name() string { return "subagent" } func (t *SubagentTool) Description() string { return "Execute a subagent task synchronously and return the result. Use this for delegating specific tasks to an independent agent instance. Returns execution summary to user and full details to LLM." } func (t *SubagentTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "task": map[string]any{ "type": "string", "description": "The task for subagent to complete", }, "label": map[string]any{ "type": "string", "description": "Optional short label for the task (for display)", }, }, "required": []string{"task"}, } } func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolResult { task, ok := args["task"].(string) if !ok { return ErrorResult("task is required").WithError(fmt.Errorf("task parameter is required")) } label, _ := args["label"].(string) if t.manager == nil { return ErrorResult("Subagent manager not configured").WithError(fmt.Errorf("manager is nil")) } // Build messages for subagent messages := []providers.Message{ { Role: "system", Content: "You are a subagent. Complete the given task independently and provide a clear, concise result.", }, { Role: "user", Content: task, }, } // Use RunToolLoop to execute with tools (same as async SpawnTool) sm := t.manager sm.mu.RLock() tools := sm.tools maxIter := sm.maxIterations maxTokens := sm.maxTokens temperature := sm.temperature hasMaxTokens := sm.hasMaxTokens hasTemperature := sm.hasTemperature sm.mu.RUnlock() var llmOptions map[string]any if hasMaxTokens || hasTemperature { llmOptions = map[string]any{} if hasMaxTokens { llmOptions["max_tokens"] = maxTokens } if hasTemperature { llmOptions["temperature"] = temperature } } // Fall back to "cli"/"direct" for non-conversation callers (e.g., CLI, tests) // to preserve the same defaults as the original NewSubagentTool constructor. channel := ToolChannel(ctx) if channel == "" { channel = "cli" } chatID := ToolChatID(ctx) if chatID == "" { chatID = "direct" } loopResult, err := RunToolLoop(ctx, ToolLoopConfig{ Provider: sm.provider, Model: sm.defaultModel, Tools: tools, MaxIterations: maxIter, LLMOptions: llmOptions, }, messages, channel, chatID) if err != nil { return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err) } // ForUser: Brief summary for user (truncated if too long) userContent := loopResult.Content maxUserLen := 500 if len(userContent) > maxUserLen { userContent = userContent[:maxUserLen] + "..." } // ForLLM: Full execution details labelStr := label if labelStr == "" { labelStr = "(unnamed)" } llmContent := fmt.Sprintf("Subagent task completed:\nLabel: %s\nIterations: %d\nResult: %s", labelStr, loopResult.Iterations, loopResult.Content) return &ToolResult{ ForLLM: llmContent, ForUser: userContent, Silent: false, IsError: false, Async: false, } } ================================================ FILE: pkg/tools/subagent_tool_test.go ================================================ package tools import ( "context" "strings" "testing" "github.com/sipeed/picoclaw/pkg/providers" ) // MockLLMProvider is a test implementation of LLMProvider type MockLLMProvider struct { lastOptions map[string]any } func (m *MockLLMProvider) Chat( ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]any, ) (*providers.LLMResponse, error) { m.lastOptions = options // Find the last user message to generate a response for i := len(messages) - 1; i >= 0; i-- { if messages[i].Role == "user" { return &providers.LLMResponse{ Content: "Task completed: " + messages[i].Content, }, nil } } return &providers.LLMResponse{Content: "No task provided"}, nil } func (m *MockLLMProvider) GetDefaultModel() string { return "test-model" } func (m *MockLLMProvider) SupportsTools() bool { return false } func (m *MockLLMProvider) GetContextWindow() int { return 4096 } func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") manager.SetLLMOptions(2048, 0.6) tool := NewSubagentTool(manager) ctx := WithToolContext(context.Background(), "cli", "direct") args := map[string]any{"task": "Do something"} result := tool.Execute(ctx, args) if result == nil || result.IsError { t.Fatalf("Expected successful result, got: %+v", result) } if provider.lastOptions == nil { t.Fatal("Expected LLM options to be passed, got nil") } if provider.lastOptions["max_tokens"] != 2048 { t.Fatalf("max_tokens = %v, want %d", provider.lastOptions["max_tokens"], 2048) } if provider.lastOptions["temperature"] != 0.6 { t.Fatalf("temperature = %v, want %v", provider.lastOptions["temperature"], 0.6) } } // TestSubagentTool_Name verifies tool name func TestSubagentTool_Name(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") tool := NewSubagentTool(manager) if tool.Name() != "subagent" { t.Errorf("Expected name 'subagent', got '%s'", tool.Name()) } } // TestSubagentTool_Description verifies tool description func TestSubagentTool_Description(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") tool := NewSubagentTool(manager) desc := tool.Description() if desc == "" { t.Error("Description should not be empty") } if !strings.Contains(desc, "subagent") { t.Errorf("Description should mention 'subagent', got: %s", desc) } } // TestSubagentTool_Parameters verifies tool parameters schema func TestSubagentTool_Parameters(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") tool := NewSubagentTool(manager) params := tool.Parameters() if params == nil { t.Error("Parameters should not be nil") } // Check type if params["type"] != "object" { t.Errorf("Expected type 'object', got: %v", params["type"]) } // Check properties props, ok := params["properties"].(map[string]any) if !ok { t.Fatal("Properties should be a map") } // Verify task parameter task, ok := props["task"].(map[string]any) if !ok { t.Fatal("Task parameter should exist") } if task["type"] != "string" { t.Errorf("Task type should be 'string', got: %v", task["type"]) } // Verify label parameter label, ok := props["label"].(map[string]any) if !ok { t.Fatal("Label parameter should exist") } if label["type"] != "string" { t.Errorf("Label type should be 'string', got: %v", label["type"]) } // Check required fields required, ok := params["required"].([]string) if !ok { t.Fatal("Required should be a string array") } if len(required) != 1 || required[0] != "task" { t.Errorf("Required should be ['task'], got: %v", required) } } // TestSubagentTool_Execute_Success tests successful execution func TestSubagentTool_Execute_Success(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") tool := NewSubagentTool(manager) ctx := WithToolContext(context.Background(), "telegram", "chat-123") args := map[string]any{ "task": "Write a haiku about coding", "label": "haiku-task", } result := tool.Execute(ctx, args) // Verify basic ToolResult structure if result == nil { t.Fatal("Result should not be nil") } // Verify no error if result.IsError { t.Errorf("Expected success, got error: %s", result.ForLLM) } // Verify not async if result.Async { t.Error("SubagentTool should be synchronous, not async") } // Verify not silent if result.Silent { t.Error("SubagentTool should not be silent") } // Verify ForUser contains brief summary (not empty) if result.ForUser == "" { t.Error("ForUser should contain result summary") } if !strings.Contains(result.ForUser, "Task completed") { t.Errorf("ForUser should contain task completion, got: %s", result.ForUser) } // Verify ForLLM contains full details if result.ForLLM == "" { t.Error("ForLLM should contain full details") } if !strings.Contains(result.ForLLM, "haiku-task") { t.Errorf("ForLLM should contain label 'haiku-task', got: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "Task completed:") { t.Errorf("ForLLM should contain task result, got: %s", result.ForLLM) } } // TestSubagentTool_Execute_NoLabel tests execution without label func TestSubagentTool_Execute_NoLabel(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") tool := NewSubagentTool(manager) ctx := context.Background() args := map[string]any{ "task": "Test task without label", } result := tool.Execute(ctx, args) if result.IsError { t.Errorf("Expected success without label, got error: %s", result.ForLLM) } // ForLLM should show (unnamed) for missing label if !strings.Contains(result.ForLLM, "(unnamed)") { t.Errorf("ForLLM should show '(unnamed)' for missing label, got: %s", result.ForLLM) } } // TestSubagentTool_Execute_MissingTask tests error handling for missing task func TestSubagentTool_Execute_MissingTask(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") tool := NewSubagentTool(manager) ctx := context.Background() args := map[string]any{ "label": "test", } result := tool.Execute(ctx, args) // Should return error if !result.IsError { t.Error("Expected error for missing task parameter") } // ForLLM should contain error message if !strings.Contains(result.ForLLM, "task is required") { t.Errorf("Error message should mention 'task is required', got: %s", result.ForLLM) } // Err should be set if result.Err == nil { t.Error("Err should be set for validation failure") } } // TestSubagentTool_Execute_NilManager tests error handling for nil manager func TestSubagentTool_Execute_NilManager(t *testing.T) { tool := NewSubagentTool(nil) ctx := context.Background() args := map[string]any{ "task": "test task", } result := tool.Execute(ctx, args) // Should return error if !result.IsError { t.Error("Expected error for nil manager") } if !strings.Contains(result.ForLLM, "Subagent manager not configured") { t.Errorf("Error message should mention manager not configured, got: %s", result.ForLLM) } } // TestSubagentTool_Execute_ContextPassing verifies context is properly used func TestSubagentTool_Execute_ContextPassing(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") tool := NewSubagentTool(manager) channel := "test-channel" chatID := "test-chat" ctx := WithToolContext(context.Background(), channel, chatID) args := map[string]any{ "task": "Test context passing", } result := tool.Execute(ctx, args) // Should succeed if result.IsError { t.Errorf("Expected success with context, got error: %s", result.ForLLM) } // The context is used internally; we can't directly test it // but execution success indicates context was handled properly } // TestSubagentTool_ForUserTruncation verifies long content is truncated for user func TestSubagentTool_ForUserTruncation(t *testing.T) { // Create a mock provider that returns very long content provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") tool := NewSubagentTool(manager) ctx := context.Background() // Create a task that will generate long response longTask := strings.Repeat("This is a very long task description. ", 100) args := map[string]any{ "task": longTask, "label": "long-test", } result := tool.Execute(ctx, args) // ForUser should be truncated to 500 chars + "..." maxUserLen := 500 if len(result.ForUser) > maxUserLen+3 { // +3 for "..." t.Errorf("ForUser should be truncated to ~%d chars, got: %d", maxUserLen, len(result.ForUser)) } // ForLLM should have full content if !strings.Contains(result.ForLLM, longTask[:50]) { t.Error("ForLLM should contain reference to original task") } } ================================================ FILE: pkg/tools/toolloop.go ================================================ // PicoClaw - Ultra-lightweight personal AI agent // Inspired by and based on nanobot: https://github.com/HKUDS/nanobot // License: MIT // // Copyright (c) 2026 PicoClaw contributors package tools import ( "context" "encoding/json" "fmt" "sync" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/utils" ) // ToolLoopConfig configures the tool execution loop. type ToolLoopConfig struct { Provider providers.LLMProvider Model string Tools *ToolRegistry MaxIterations int LLMOptions map[string]any } // ToolLoopResult contains the result of running the tool loop. type ToolLoopResult struct { Content string Iterations int } // RunToolLoop executes the LLM + tool call iteration loop. // This is the core agent logic that can be reused by both main agent and subagents. func RunToolLoop( ctx context.Context, config ToolLoopConfig, messages []providers.Message, channel, chatID string, ) (*ToolLoopResult, error) { iteration := 0 var finalContent string for iteration < config.MaxIterations { iteration++ logger.DebugCF("toolloop", "LLM iteration", map[string]any{ "iteration": iteration, "max": config.MaxIterations, }) // 1. Build tool definitions var providerToolDefs []providers.ToolDefinition if config.Tools != nil { providerToolDefs = config.Tools.ToProviderDefs() } // 2. Set default LLM options llmOpts := config.LLMOptions if llmOpts == nil { llmOpts = map[string]any{} } // 3. Call LLM response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts) if err != nil { logger.ErrorCF("toolloop", "LLM call failed", map[string]any{ "iteration": iteration, "error": err.Error(), }) return nil, fmt.Errorf("LLM call failed: %w", err) } // 4. If no tool calls, we're done if len(response.ToolCalls) == 0 { finalContent = response.Content logger.InfoCF("toolloop", "LLM response without tool calls (direct answer)", map[string]any{ "iteration": iteration, "content_chars": len(finalContent), }) break } normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls)) for _, tc := range response.ToolCalls { normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc)) } // 5. Log tool calls toolNames := make([]string, 0, len(normalizedToolCalls)) for _, tc := range normalizedToolCalls { toolNames = append(toolNames, tc.Name) } logger.InfoCF("toolloop", "LLM requested tool calls", map[string]any{ "tools": toolNames, "count": len(normalizedToolCalls), "iteration": iteration, }) // 6. Build assistant message with tool calls assistantMsg := providers.Message{ Role: "assistant", Content: response.Content, } for _, tc := range normalizedToolCalls { argumentsJSON, _ := json.Marshal(tc.Arguments) assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ ID: tc.ID, Type: "function", Name: tc.Name, Arguments: tc.Arguments, Function: &providers.FunctionCall{ Name: tc.Name, Arguments: string(argumentsJSON), }, }) } messages = append(messages, assistantMsg) // 7. Execute tool calls in parallel type indexedResult struct { result *ToolResult tc providers.ToolCall } results := make([]indexedResult, len(normalizedToolCalls)) var wg sync.WaitGroup for i, tc := range normalizedToolCalls { results[i].tc = tc wg.Add(1) go func(idx int, tc providers.ToolCall) { defer wg.Done() argsJSON, _ := json.Marshal(tc.Arguments) argsPreview := utils.Truncate(string(argsJSON), 200) logger.InfoCF("toolloop", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview), map[string]any{ "tool": tc.Name, "iteration": iteration, }) var toolResult *ToolResult if config.Tools != nil { toolResult = config.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, channel, chatID, nil) } else { toolResult = ErrorResult("No tools available") } results[idx].result = toolResult }(i, tc) } wg.Wait() // Append results in original order for _, r := range results { contentForLLM := r.result.ForLLM if contentForLLM == "" && r.result.Err != nil { contentForLLM = r.result.Err.Error() } messages = append(messages, providers.Message{ Role: "tool", Content: contentForLLM, ToolCallID: r.tc.ID, }) } } return &ToolLoopResult{ Content: finalContent, Iterations: iteration, }, nil } ================================================ FILE: pkg/tools/types.go ================================================ package tools import "context" type Message struct { Role string `json:"role"` Content string `json:"content"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` } type ToolCall struct { ID string `json:"id"` Type string `json:"type"` Function *FunctionCall `json:"function,omitempty"` Name string `json:"name,omitempty"` Arguments map[string]any `json:"arguments,omitempty"` } type FunctionCall struct { Name string `json:"name"` Arguments string `json:"arguments"` } type LLMResponse struct { Content string `json:"content"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` FinishReason string `json:"finish_reason"` Usage *UsageInfo `json:"usage,omitempty"` } type UsageInfo struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` TotalTokens int `json:"total_tokens"` } type LLMProvider interface { Chat( ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, ) (*LLMResponse, error) GetDefaultModel() string } type ToolDefinition struct { Type string `json:"type"` Function ToolFunctionDefinition `json:"function"` } type ToolFunctionDefinition struct { Name string `json:"name"` Description string `json:"description"` Parameters map[string]any `json:"parameters"` } ================================================ FILE: pkg/tools/web.go ================================================ package tools import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "mime" "net" "net/http" "net/url" "regexp" "strings" "sync/atomic" "time" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) const ( userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" userAgentHonest = "picoclaw/%s (+https://github.com/sipeed/picoclaw; AI assistant bot)" // HTTP client timeouts for web tool providers. searchTimeout = 10 * time.Second // Brave, Tavily, DuckDuckGo perplexityTimeout = 30 * time.Second // Perplexity (LLM-based, slower) fetchTimeout = 60 * time.Second // WebFetchTool defaultMaxChars = 50000 maxRedirects = 5 ) // Pre-compiled regexes for HTML text extraction var ( reScript = regexp.MustCompile(`<script[\s\S]*?</script>`) reStyle = regexp.MustCompile(`<style[\s\S]*?</style>`) reTags = regexp.MustCompile(`<[^>]+>`) reWhitespace = regexp.MustCompile(`[^\S\n]+`) reBlankLines = regexp.MustCompile(`\n{3,}`) // DuckDuckGo result extraction reDDGLink = regexp.MustCompile(`<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)</a>`) reDDGSnippet = regexp.MustCompile(`<a class="result__snippet[^"]*".*?>([\s\S]*?)</a>`) ) type APIKeyPool struct { keys []string current uint32 } func NewAPIKeyPool(keys []string) *APIKeyPool { return &APIKeyPool{ keys: keys, } } type APIKeyIterator struct { pool *APIKeyPool startIdx uint32 attempt uint32 } func (p *APIKeyPool) NewIterator() *APIKeyIterator { if len(p.keys) == 0 { return &APIKeyIterator{pool: p} } idx := atomic.AddUint32(&p.current, 1) - 1 return &APIKeyIterator{ pool: p, startIdx: idx, } } func (it *APIKeyIterator) Next() (string, bool) { length := uint32(len(it.pool.keys)) if length == 0 || it.attempt >= length { return "", false } key := it.pool.keys[(it.startIdx+it.attempt)%length] it.attempt++ return key, true } type SearchProvider interface { Search(ctx context.Context, query string, count int) (string, error) } type BraveSearchProvider struct { keyPool *APIKeyPool proxy string client *http.Client } func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d", url.QueryEscape(query), count) var lastErr error iter := p.keyPool.NewIterator() for { apiKey, ok := iter.Next() if !ok { break } req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Accept", "application/json") req.Header.Set("X-Subscription-Token", apiKey) resp, err := p.client.Do(req) if err != nil { lastErr = fmt.Errorf("request failed: %w", err) continue } body, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { lastErr = fmt.Errorf("failed to read response: %w", err) continue } if resp.StatusCode != http.StatusOK { lastErr = fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden || resp.StatusCode >= 500 { continue } return "", lastErr } var searchResp struct { Web struct { Results []struct { Title string `json:"title"` URL string `json:"url"` Description string `json:"description"` } `json:"results"` } `json:"web"` } if err := json.Unmarshal(body, &searchResp); err != nil { // Log error body for debugging return "", fmt.Errorf("failed to parse response: %w", err) } results := searchResp.Web.Results if len(results) == 0 { return fmt.Sprintf("No results for: %s", query), nil } var lines []string lines = append(lines, fmt.Sprintf("Results for: %s", query)) for i, item := range results { if i >= count { break } lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL)) if item.Description != "" { lines = append(lines, fmt.Sprintf(" %s", item.Description)) } } return strings.Join(lines, "\n"), nil } return "", fmt.Errorf("all api keys failed, last error: %w", lastErr) } type TavilySearchProvider struct { keyPool *APIKeyPool baseURL string proxy string client *http.Client } func (p *TavilySearchProvider) Search(ctx context.Context, query string, count int) (string, error) { searchURL := p.baseURL if searchURL == "" { searchURL = "https://api.tavily.com/search" } var lastErr error iter := p.keyPool.NewIterator() for { apiKey, ok := iter.Next() if !ok { break } payload := map[string]any{ "api_key": apiKey, "query": query, "search_depth": "advanced", "include_answer": false, "include_images": false, "include_raw_content": false, "max_results": count, } bodyBytes, err := json.Marshal(payload) if err != nil { return "", fmt.Errorf("failed to marshal payload: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewBuffer(bodyBytes)) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", userAgent) resp, err := p.client.Do(req) if err != nil { lastErr = fmt.Errorf("request failed: %w", err) continue } body, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { lastErr = fmt.Errorf("failed to read response: %w", err) continue } if resp.StatusCode != http.StatusOK { lastErr = fmt.Errorf("tavily api error (status %d): %s", resp.StatusCode, string(body)) if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden || resp.StatusCode >= 500 { continue } return "", lastErr } var searchResp struct { Results []struct { Title string `json:"title"` URL string `json:"url"` Content string `json:"content"` } `json:"results"` } if err := json.Unmarshal(body, &searchResp); err != nil { return "", fmt.Errorf("failed to parse response: %w", err) } results := searchResp.Results if len(results) == 0 { return fmt.Sprintf("No results for: %s", query), nil } var lines []string lines = append(lines, fmt.Sprintf("Results for: %s (via Tavily)", query)) for i, item := range results { if i >= count { break } lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL)) if item.Content != "" { lines = append(lines, fmt.Sprintf(" %s", item.Content)) } } return strings.Join(lines, "\n"), nil } return "", fmt.Errorf("all api keys failed, last error: %w", lastErr) } type DuckDuckGoSearchProvider struct { proxy string client *http.Client } func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { searchURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(query)) req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } req.Header.Set("User-Agent", userAgent) resp, err := p.client.Do(req) if err != nil { return "", fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("failed to read response: %w", err) } return p.extractResults(string(body), count, query) } func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query string) (string, error) { // Simple regex based extraction for DDG HTML // Strategy: Find all result containers or key anchors directly // Try finding the result links directly first, as they are the most critical // Pattern: <a class="result__a" href="...">Title</a> // The previous regex was a bit strict. Let's make it more flexible for attributes order/content matches := reDDGLink.FindAllStringSubmatch(html, count+5) if len(matches) == 0 { return fmt.Sprintf("No results found or extraction failed. Query: %s", query), nil } var lines []string lines = append(lines, fmt.Sprintf("Results for: %s (via DuckDuckGo)", query)) // Pre-compile snippet regex to run inside the loop // We'll search for snippets relative to the link position or just globally if needed // But simple global search for snippets might mismatch order. // Since we only have the raw HTML string, let's just extract snippets globally and assume order matches (risky but simple for regex) // Or better: Let's assume the snippet follows the link in the HTML // A better regex approach: iterate through text and find matches in order // But for now, let's grab all snippets too snippetMatches := reDDGSnippet.FindAllStringSubmatch(html, count+5) maxItems := min(len(matches), count) for i := range maxItems { urlStr := matches[i][1] title := stripTags(matches[i][2]) title = strings.TrimSpace(title) // URL decoding if needed if strings.Contains(urlStr, "uddg=") { if u, err := url.QueryUnescape(urlStr); err == nil { _, after, ok := strings.Cut(u, "uddg=") if ok { urlStr = after } } } lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, title, urlStr)) // Attempt to attach snippet if available and index aligns if i < len(snippetMatches) { snippet := stripTags(snippetMatches[i][1]) snippet = strings.TrimSpace(snippet) if snippet != "" { lines = append(lines, fmt.Sprintf(" %s", snippet)) } } } return strings.Join(lines, "\n"), nil } func stripTags(content string) string { return reTags.ReplaceAllString(content, "") } type PerplexitySearchProvider struct { keyPool *APIKeyPool proxy string client *http.Client } func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) { searchURL := "https://api.perplexity.ai/chat/completions" var lastErr error iter := p.keyPool.NewIterator() for { apiKey, ok := iter.Next() if !ok { break } payload := map[string]any{ "model": "sonar", "messages": []map[string]string{ { "role": "system", "content": "You are a search assistant. Provide concise search results with titles, URLs, and brief descriptions in the following format:\n1. Title\n URL\n Description\n\nDo not add extra commentary.", }, { "role": "user", "content": fmt.Sprintf("Search for: %s. Provide up to %d relevant results.", query, count), }, }, "max_tokens": 1000, } payloadBytes, err := json.Marshal(payload) if err != nil { return "", fmt.Errorf("failed to marshal request: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", searchURL, strings.NewReader(string(payloadBytes))) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("User-Agent", userAgent) resp, err := p.client.Do(req) if err != nil { lastErr = fmt.Errorf("request failed: %w", err) continue } body, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { lastErr = fmt.Errorf("failed to read response: %w", err) continue } if resp.StatusCode != http.StatusOK { lastErr = fmt.Errorf("Perplexity API error: %s", string(body)) if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden || resp.StatusCode >= 500 { continue } return "", lastErr } var searchResp struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.Unmarshal(body, &searchResp); err != nil { return "", fmt.Errorf("failed to parse response: %w", err) } if len(searchResp.Choices) == 0 { return fmt.Sprintf("No results for: %s", query), nil } return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil } return "", fmt.Errorf("all api keys failed, last error: %w", lastErr) } type SearXNGSearchProvider struct { baseURL string } func (p *SearXNGSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { searchURL := fmt.Sprintf("%s/search?q=%s&format=json&categories=general", strings.TrimSuffix(p.baseURL, "/"), url.QueryEscape(query)) req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("SearXNG returned status %d", resp.StatusCode) } var result struct { Results []struct { Title string `json:"title"` URL string `json:"url"` Content string `json:"content"` Engine string `json:"engine"` Score float64 `json:"score"` } `json:"results"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", fmt.Errorf("failed to parse response: %w", err) } if len(result.Results) == 0 { return fmt.Sprintf("No results for: %s", query), nil } // Limit results to requested count if len(result.Results) > count { result.Results = result.Results[:count] } // Format results in standard PicoClaw format var b strings.Builder b.WriteString(fmt.Sprintf("Results for: %s (via SearXNG)\n", query)) for i, r := range result.Results { b.WriteString(fmt.Sprintf("%d. %s\n", i+1, r.Title)) b.WriteString(fmt.Sprintf(" %s\n", r.URL)) if r.Content != "" { b.WriteString(fmt.Sprintf(" %s\n", r.Content)) } } return b.String(), nil } type GLMSearchProvider struct { apiKey string baseURL string searchEngine string proxy string client *http.Client } func (p *GLMSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { searchURL := p.baseURL if searchURL == "" { searchURL = "https://open.bigmodel.cn/api/paas/v4/web_search" } payload := map[string]any{ "search_query": query, "search_engine": p.searchEngine, "search_intent": false, "count": count, "content_size": "medium", } bodyBytes, err := json.Marshal(payload) if err != nil { return "", fmt.Errorf("failed to marshal payload: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewReader(bodyBytes)) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+p.apiKey) resp, err := p.client.Do(req) if err != nil { return "", fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return "", fmt.Errorf("failed to read response: %w", err) } if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("GLM Search API error (status %d): %s", resp.StatusCode, string(body)) } var searchResp struct { SearchResult []struct { Title string `json:"title"` Content string `json:"content"` Link string `json:"link"` } `json:"search_result"` } if err := json.Unmarshal(body, &searchResp); err != nil { return "", fmt.Errorf("failed to parse response: %w", err) } results := searchResp.SearchResult if len(results) == 0 { return fmt.Sprintf("No results for: %s", query), nil } var lines []string lines = append(lines, fmt.Sprintf("Results for: %s (via GLM Search)", query)) for i, item := range results { if i >= count { break } lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.Link)) if item.Content != "" { lines = append(lines, fmt.Sprintf(" %s", item.Content)) } } return strings.Join(lines, "\n"), nil } type WebSearchTool struct { provider SearchProvider maxResults int } type WebSearchToolOptions struct { BraveAPIKeys []string BraveMaxResults int BraveEnabled bool TavilyAPIKeys []string TavilyBaseURL string TavilyMaxResults int TavilyEnabled bool DuckDuckGoMaxResults int DuckDuckGoEnabled bool PerplexityAPIKeys []string PerplexityMaxResults int PerplexityEnabled bool SearXNGBaseURL string SearXNGMaxResults int SearXNGEnabled bool GLMSearchAPIKey string GLMSearchBaseURL string GLMSearchEngine string GLMSearchMaxResults int GLMSearchEnabled bool Proxy string } func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { var provider SearchProvider maxResults := 5 // Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > GLM Search if opts.PerplexityEnabled && len(opts.PerplexityAPIKeys) > 0 { client, err := utils.CreateHTTPClient(opts.Proxy, perplexityTimeout) if err != nil { return nil, fmt.Errorf("failed to create HTTP client for Perplexity: %w", err) } provider = &PerplexitySearchProvider{ keyPool: NewAPIKeyPool(opts.PerplexityAPIKeys), proxy: opts.Proxy, client: client, } if opts.PerplexityMaxResults > 0 { maxResults = opts.PerplexityMaxResults } } else if opts.BraveEnabled && len(opts.BraveAPIKeys) > 0 { client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) if err != nil { return nil, fmt.Errorf("failed to create HTTP client for Brave: %w", err) } provider = &BraveSearchProvider{keyPool: NewAPIKeyPool(opts.BraveAPIKeys), proxy: opts.Proxy, client: client} if opts.BraveMaxResults > 0 { maxResults = opts.BraveMaxResults } } else if opts.SearXNGEnabled && opts.SearXNGBaseURL != "" { provider = &SearXNGSearchProvider{baseURL: opts.SearXNGBaseURL} if opts.SearXNGMaxResults > 0 { maxResults = opts.SearXNGMaxResults } } else if opts.TavilyEnabled && len(opts.TavilyAPIKeys) > 0 { client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) if err != nil { return nil, fmt.Errorf("failed to create HTTP client for Tavily: %w", err) } provider = &TavilySearchProvider{ keyPool: NewAPIKeyPool(opts.TavilyAPIKeys), baseURL: opts.TavilyBaseURL, proxy: opts.Proxy, client: client, } if opts.TavilyMaxResults > 0 { maxResults = opts.TavilyMaxResults } } else if opts.DuckDuckGoEnabled { client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) if err != nil { return nil, fmt.Errorf("failed to create HTTP client for DuckDuckGo: %w", err) } provider = &DuckDuckGoSearchProvider{proxy: opts.Proxy, client: client} if opts.DuckDuckGoMaxResults > 0 { maxResults = opts.DuckDuckGoMaxResults } } else if opts.GLMSearchEnabled && opts.GLMSearchAPIKey != "" { client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) if err != nil { return nil, fmt.Errorf("failed to create HTTP client for GLM Search: %w", err) } searchEngine := opts.GLMSearchEngine if searchEngine == "" { searchEngine = "search_std" } provider = &GLMSearchProvider{ apiKey: opts.GLMSearchAPIKey, baseURL: opts.GLMSearchBaseURL, searchEngine: searchEngine, proxy: opts.Proxy, client: client, } if opts.GLMSearchMaxResults > 0 { maxResults = opts.GLMSearchMaxResults } } else { return nil, nil } return &WebSearchTool{ provider: provider, maxResults: maxResults, }, nil } func (t *WebSearchTool) Name() string { return "web_search" } func (t *WebSearchTool) Description() string { return "Search the web for current information. Returns titles, URLs, and snippets from search results." } func (t *WebSearchTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "query": map[string]any{ "type": "string", "description": "Search query", }, "count": map[string]any{ "type": "integer", "description": "Number of results (1-10)", "minimum": 1.0, "maximum": 10.0, }, }, "required": []string{"query"}, } } func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { query, ok := args["query"].(string) if !ok { return ErrorResult("query is required") } count := t.maxResults if c, ok := args["count"].(float64); ok { if int(c) > 0 && int(c) <= 10 { count = int(c) } } result, err := t.provider.Search(ctx, query, count) if err != nil { return ErrorResult(fmt.Sprintf("search failed: %v", err)) } return &ToolResult{ ForLLM: result, ForUser: result, } } type WebFetchTool struct { maxChars int proxy string client *http.Client format string fetchLimitBytes int64 whitelist *privateHostWhitelist } type privateHostWhitelist struct { exact map[string]struct{} cidrs []*net.IPNet } func NewWebFetchTool(maxChars int, format string, fetchLimitBytes int64) (*WebFetchTool, error) { // createHTTPClient cannot fail with an empty proxy string. return NewWebFetchToolWithConfig(maxChars, "", format, fetchLimitBytes, nil) } // allowPrivateWebFetchHosts controls whether loopback/private hosts are allowed. // This is false in normal runtime to reduce SSRF exposure, and tests can override it temporarily. var allowPrivateWebFetchHosts atomic.Bool func NewWebFetchToolWithProxy( maxChars int, proxy string, format string, fetchLimitBytes int64, privateHostWhitelist []string, ) (*WebFetchTool, error) { return NewWebFetchToolWithConfig(maxChars, proxy, format, fetchLimitBytes, privateHostWhitelist) } func NewWebFetchToolWithConfig( maxChars int, proxy string, format string, fetchLimitBytes int64, privateHostWhitelist []string, ) (*WebFetchTool, error) { if maxChars <= 0 { maxChars = defaultMaxChars } whitelist, err := newPrivateHostWhitelist(privateHostWhitelist) if err != nil { return nil, fmt.Errorf("failed to parse web fetch private host whitelist: %w", err) } client, err := utils.CreateHTTPClient(proxy, fetchTimeout) if err != nil { return nil, fmt.Errorf("failed to create HTTP client for web fetch: %w", err) } if transport, ok := client.Transport.(*http.Transport); ok { dialer := &net.Dialer{ Timeout: 15 * time.Second, KeepAlive: 30 * time.Second, } transport.DialContext = newSafeDialContext(dialer, whitelist) } client.CheckRedirect = func(req *http.Request, via []*http.Request) error { if len(via) >= maxRedirects { return fmt.Errorf("stopped after %d redirects", maxRedirects) } if isObviousPrivateHost(req.URL.Hostname(), whitelist) { return fmt.Errorf("redirect target is private or local network host") } return nil } if fetchLimitBytes <= 0 { fetchLimitBytes = 10 * 1024 * 1024 // Security Fallback } return &WebFetchTool{ maxChars: maxChars, proxy: proxy, client: client, format: format, fetchLimitBytes: fetchLimitBytes, whitelist: whitelist, }, nil } func (t *WebFetchTool) Name() string { return "web_fetch" } func (t *WebFetchTool) Description() string { return "Fetch a URL and extract readable content (HTML to text). Use this to get weather info, news, articles, or any web content." } func (t *WebFetchTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "url": map[string]any{ "type": "string", "description": "URL to fetch", }, "maxChars": map[string]any{ "type": "integer", "description": "Maximum characters to extract", "minimum": 100.0, }, }, "required": []string{"url"}, } } func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { urlStr, ok := args["url"].(string) if !ok { return ErrorResult("url is required") } parsedURL, err := url.Parse(urlStr) if err != nil { return ErrorResult(fmt.Sprintf("invalid URL: %v", err)) } if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { return ErrorResult("only http/https URLs are allowed") } if parsedURL.Host == "" { return ErrorResult("missing domain in URL") } // Lightweight pre-flight: block obvious localhost/literal-IP without DNS resolution. // The real SSRF guard is newSafeDialContext at connect time. hostname := parsedURL.Hostname() if isObviousPrivateHost(hostname, t.whitelist) { return ErrorResult("fetching private or local network hosts is not allowed") } maxChars := t.maxChars if mc, ok := args["maxChars"].(float64); ok { if int(mc) > 100 { maxChars = int(mc) } } doFetch := func(ua string) (*http.Response, []byte, error) { req, reqErr := http.NewRequestWithContext(ctx, "GET", urlStr, nil) if reqErr != nil { return nil, nil, fmt.Errorf("failed to create request: %w", reqErr) } req.Header.Set("User-Agent", ua) resp, doErr := t.client.Do(req) if doErr != nil { return nil, nil, fmt.Errorf("request failed: %w", doErr) } resp.Body = http.MaxBytesReader(nil, resp.Body, t.fetchLimitBytes) b, readErr := io.ReadAll(resp.Body) return resp, b, readErr } resp, body, err := doFetch(userAgent) if resp != nil && resp.Body != nil { defer resp.Body.Close() } if err != nil { var maxBytesErr *http.MaxBytesError if errors.As(err, &maxBytesErr) { return ErrorResult(fmt.Sprintf("failed to read response: size exceeded %d bytes limit", t.fetchLimitBytes)) } return ErrorResult(err.Error()) } // Cloudflare (and similar WAFs) signal bot challenges with 403 + cf-mitigated: challenge. // Retry once with an honest User-Agent that identifies picoclaw, which some // operators explicitly allow-list for AI assistants. if resp.StatusCode == http.StatusForbidden && resp.Header.Get("Cf-Mitigated") == "challenge" { logger.DebugCF("tool", "Cloudflare challenge detected, retrying with honest User-Agent", map[string]any{"url": urlStr}) honestUA := fmt.Sprintf(userAgentHonest, config.Version) resp2, body2, err2 := doFetch(honestUA) if resp2 != nil && resp2.Body != nil { defer resp2.Body.Close() } if err2 == nil { resp, body = resp2, body2 } else { var maxBytesErr *http.MaxBytesError if errors.As(err2, &maxBytesErr) { return ErrorResult( fmt.Sprintf("failed to read response: size exceeded %d bytes limit", t.fetchLimitBytes), ) } return ErrorResult(err2.Error()) } } bodyStr := string(body) contentType := resp.Header.Get("Content-Type") mediaType, params, err := mime.ParseMediaType(contentType) if err != nil { // The most common error here is "mime: no media type" if the header is empty. logger.WarnCF("tool", "Failed to parse Content-Type", map[string]any{ "raw_header": contentType, "error": err.Error(), }) // security fallback mediaType = "application/octet-stream" } charset, hasCharset := params["charset"] if hasCharset { // If the charset is not utf-8, we might have to convert the bodyStr // before passing it to the HTML/Markdown parser if strings.ToLower(charset) != "utf-8" { logger.WarnCF("tool", "Note: the content is not in UTF-8", map[string]any{"charset": charset}) } } var text, extractor string switch { case mediaType == "application/json": var jsonData any if err := json.Unmarshal(body, &jsonData); err != nil { text = bodyStr extractor = "raw" break } formatted, err := json.MarshalIndent(jsonData, "", " ") if err != nil { text = bodyStr extractor = "raw" break } text = string(formatted) extractor = "json" case mediaType == "text/html" || looksLikeHTML(bodyStr): switch strings.ToLower(t.format) { case "markdown": var err error text, err = utils.HtmlToMarkdown(bodyStr) if err != nil { return ErrorResult(fmt.Sprintf("failed to HTML to markdown: %v", err)) } extractor = "markdown" default: text = t.extractText(bodyStr) extractor = "text" } default: text = bodyStr extractor = "raw" } truncated := len(text) > maxChars if truncated { text = text[:maxChars] + "\n[Content truncated due to size limit]" } result := map[string]any{ "url": urlStr, "status": resp.StatusCode, "extractor": extractor, "truncated": truncated, "length": len(text), "text": text, } resultJSON, _ := json.MarshalIndent(result, "", " ") return &ToolResult{ ForLLM: string(resultJSON), ForUser: fmt.Sprintf( "Fetched %d bytes from %s (extractor: %s, truncated: %v)", len(text), urlStr, extractor, truncated, ), } } func looksLikeHTML(body string) bool { if body == "" { return false } lower := strings.ToLower(body) return strings.HasPrefix(body, "<!doctype") || strings.HasPrefix(lower, "<html") } func (t *WebFetchTool) extractText(htmlContent string) string { result := reScript.ReplaceAllLiteralString(htmlContent, "") result = reStyle.ReplaceAllLiteralString(result, "") result = reTags.ReplaceAllLiteralString(result, "") result = strings.TrimSpace(result) result = reWhitespace.ReplaceAllString(result, " ") result = reBlankLines.ReplaceAllString(result, "\n\n") lines := strings.Split(result, "\n") var cleanLines []string for _, line := range lines { line = strings.TrimSpace(line) if line != "" { cleanLines = append(cleanLines, line) } } return strings.Join(cleanLines, "\n") } // newSafeDialContext re-resolves DNS at connect time to mitigate DNS rebinding (TOCTOU) // where a hostname resolves to a public IP during pre-flight but a private IP at connect time. func newSafeDialContext( dialer *net.Dialer, whitelist *privateHostWhitelist, ) func(context.Context, string, string) (net.Conn, error) { return func(ctx context.Context, network, address string) (net.Conn, error) { if allowPrivateWebFetchHosts.Load() { return dialer.DialContext(ctx, network, address) } host, port, err := net.SplitHostPort(address) if err != nil { return nil, fmt.Errorf("invalid target address %q: %w", address, err) } if host == "" { return nil, fmt.Errorf("empty target host") } if ip := net.ParseIP(host); ip != nil { if shouldBlockPrivateIP(ip, whitelist) { return nil, fmt.Errorf("blocked private or local target: %s", host) } return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) } ipAddrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) if err != nil { return nil, fmt.Errorf("failed to resolve %s: %w", host, err) } attempted := 0 var lastErr error for _, ipAddr := range ipAddrs { if shouldBlockPrivateIP(ipAddr.IP, whitelist) { continue } attempted++ conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ipAddr.IP.String(), port)) if err == nil { return conn, nil } lastErr = err } if attempted == 0 { return nil, fmt.Errorf("all resolved addresses for %s are private, restricted, or not whitelisted", host) } if lastErr != nil { return nil, fmt.Errorf("failed connecting to public addresses for %s: %w", host, lastErr) } return nil, fmt.Errorf("failed connecting to public addresses for %s", host) } } func newPrivateHostWhitelist(entries []string) (*privateHostWhitelist, error) { if len(entries) == 0 { return nil, nil } whitelist := &privateHostWhitelist{ exact: make(map[string]struct{}), cidrs: make([]*net.IPNet, 0, len(entries)), } for _, entry := range entries { entry = strings.TrimSpace(entry) if entry == "" { continue } if ip := net.ParseIP(entry); ip != nil { whitelist.exact[normalizeWhitelistIP(ip).String()] = struct{}{} continue } _, network, err := net.ParseCIDR(entry) if err != nil { return nil, fmt.Errorf("invalid entry %q: expected IP or CIDR", entry) } whitelist.cidrs = append(whitelist.cidrs, network) } if len(whitelist.exact) == 0 && len(whitelist.cidrs) == 0 { return nil, nil } return whitelist, nil } func (w *privateHostWhitelist) Contains(ip net.IP) bool { if w == nil || ip == nil { return false } normalized := normalizeWhitelistIP(ip) if _, ok := w.exact[normalized.String()]; ok { return true } for _, network := range w.cidrs { if network.Contains(normalized) { return true } } return false } func normalizeWhitelistIP(ip net.IP) net.IP { if ip == nil { return nil } if ip4 := ip.To4(); ip4 != nil { return ip4 } return ip } func shouldBlockPrivateIP(ip net.IP, whitelist *privateHostWhitelist) bool { return isPrivateOrRestrictedIP(ip) && !whitelist.Contains(ip) } // isObviousPrivateHost performs a lightweight, no-DNS check for obviously private hosts. // It catches localhost, literal private IPs, and empty hosts. It does NOT resolve DNS — // the real SSRF guard is newSafeDialContext which checks IPs at connect time. func isObviousPrivateHost(host string, whitelist *privateHostWhitelist) bool { if allowPrivateWebFetchHosts.Load() { return false } h := strings.ToLower(strings.TrimSpace(host)) h = strings.TrimSuffix(h, ".") if h == "" { return true } if h == "localhost" || strings.HasSuffix(h, ".localhost") { return true } if ip := net.ParseIP(h); ip != nil { return shouldBlockPrivateIP(ip, whitelist) } return false } // isPrivateOrRestrictedIP returns true for IPs that should never be reached via web_fetch: // RFC 1918, loopback, link-local (incl. cloud metadata 169.254.x.x), carrier-grade NAT, // IPv6 unique-local (fc00::/7), 6to4 (2002::/16), and Teredo (2001:0000::/32). func isPrivateOrRestrictedIP(ip net.IP) bool { if ip == nil { return true } if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() { return true } if ip4 := ip.To4(); ip4 != nil { // IPv4 private, loopback, link-local, and carrier-grade NAT ranges. if ip4[0] == 10 || ip4[0] == 127 || ip4[0] == 0 || (ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31) || (ip4[0] == 192 && ip4[1] == 168) || (ip4[0] == 169 && ip4[1] == 254) || (ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127) { return true } return false } if len(ip) == net.IPv6len { // IPv6 unique local addresses (fc00::/7) if (ip[0] & 0xfe) == 0xfc { return true } // 6to4 addresses (2002::/16): check the embedded IPv4 at bytes [2:6]. if ip[0] == 0x20 && ip[1] == 0x02 { embedded := net.IPv4(ip[2], ip[3], ip[4], ip[5]) return isPrivateOrRestrictedIP(embedded) } // Teredo (2001:0000::/32): client IPv4 is at bytes [12:16], XOR-inverted. if ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x00 && ip[3] == 0x00 { client := net.IPv4(ip[12]^0xff, ip[13]^0xff, ip[14]^0xff, ip[15]^0xff) return isPrivateOrRestrictedIP(client) } } return false } ================================================ FILE: pkg/tools/web_test.go ================================================ package tools import ( "bytes" "context" "encoding/json" "fmt" "net" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/sipeed/picoclaw/pkg/logger" ) const ( testFetchLimit = int64(10 * 1024 * 1024) format = "plaintext" ) // TestWebTool_WebFetch_Success verifies successful URL fetching func TestWebTool_WebFetch_Success(t *testing.T) { withPrivateWebFetchHostsAllowed(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusOK) w.Write([]byte("<html><body><h1>Test Page</h1><p>Content here</p></body></html>")) })) defer server.Close() tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { t.Fatalf("Failed to create web fetch tool: %v", err) } ctx := context.Background() args := map[string]any{ "url": server.URL, } result := tool.Execute(ctx, args) // Success should not be an error if result.IsError { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } // ForLLM should contain the fetched content (full JSON result) if !strings.Contains(result.ForLLM, "Test Page") { t.Errorf("Expected ForLLM to contain 'Test Page', got: %s", result.ForLLM) } // ForUser should contain summary if !strings.Contains(result.ForUser, "bytes") && !strings.Contains(result.ForUser, "extractor") { t.Errorf("Expected ForUser to contain summary, got: %s", result.ForUser) } } // TestWebTool_WebFetch_JSON verifies JSON content handling func TestWebTool_WebFetch_JSON(t *testing.T) { withPrivateWebFetchHostsAllowed(t) testData := map[string]string{"key": "value", "number": "123"} expectedJSON, _ := json.MarshalIndent(testData, "", " ") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write(expectedJSON) })) defer server.Close() tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) } ctx := context.Background() args := map[string]any{ "url": server.URL, } result := tool.Execute(ctx, args) // Success should not be an error if result.IsError { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } // ForLLM should contain formatted JSON if !strings.Contains(result.ForLLM, "key") && !strings.Contains(result.ForLLM, "value") { t.Errorf("Expected ForLLM to contain JSON data, got: %s", result.ForLLM) } } // TestWebTool_WebFetch_InvalidURL verifies error handling for invalid URL func TestWebTool_WebFetch_InvalidURL(t *testing.T) { tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) } ctx := context.Background() args := map[string]any{ "url": "not-a-valid-url", } result := tool.Execute(ctx, args) // Should return error result if !result.IsError { t.Errorf("Expected error for invalid URL") } // Should contain error message (either "invalid URL" or scheme error) if !strings.Contains(result.ForLLM, "URL") && !strings.Contains(result.ForUser, "URL") { t.Errorf("Expected error message for invalid URL, got ForLLM: %s", result.ForLLM) } } // TestWebTool_WebFetch_UnsupportedScheme verifies error handling for non-http URLs func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) { tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) } ctx := context.Background() args := map[string]any{ "url": "ftp://example.com/file.txt", } result := tool.Execute(ctx, args) // Should return error result if !result.IsError { t.Errorf("Expected error for unsupported URL scheme") } // Should mention only http/https allowed if !strings.Contains(result.ForLLM, "http/https") && !strings.Contains(result.ForUser, "http/https") { t.Errorf("Expected scheme error message, got ForLLM: %s", result.ForLLM) } } // TestWebTool_WebFetch_MissingURL verifies error handling for missing URL func TestWebTool_WebFetch_MissingURL(t *testing.T) { tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) } ctx := context.Background() args := map[string]any{} result := tool.Execute(ctx, args) // Should return error result if !result.IsError { t.Errorf("Expected error when URL is missing") } // Should mention URL is required if !strings.Contains(result.ForLLM, "url is required") && !strings.Contains(result.ForUser, "url is required") { t.Errorf("Expected 'url is required' message, got ForLLM: %s", result.ForLLM) } } // TestWebTool_WebFetch_Truncation verifies content truncation func TestWebTool_WebFetch_Truncation(t *testing.T) { withPrivateWebFetchHostsAllowed(t) longContent := strings.Repeat("x", 20000) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) w.Write([]byte(longContent)) })) defer server.Close() tool, err := NewWebFetchTool(1000, format, testFetchLimit) // Limit to 1000 chars if err != nil { logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) } ctx := context.Background() args := map[string]any{ "url": server.URL, } result := tool.Execute(ctx, args) // Success should not be an error if result.IsError { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } // ForLLM should contain truncated content (not the full 20000 chars) resultMap := make(map[string]any) json.Unmarshal([]byte(result.ForLLM), &resultMap) if text, ok := resultMap["text"].(string); ok { if len(text) > 1100 { // Allow some margin t.Errorf("Expected content to be truncated to ~1000 chars, got: %d", len(text)) } } // Should be marked as truncated if truncated, ok := resultMap["truncated"].(bool); !ok || !truncated { t.Errorf("Expected 'truncated' to be true in result") } // Text should end with the truncation notice if text, ok := resultMap["text"].(string); ok { if !strings.HasSuffix(text, "[Content truncated due to size limit]") { t.Errorf("Expected text to end with truncation notice, got: %q", text[max(0, len(text)-60):]) } } } // TestWebTool_WebFetch_TruncationNotice verifies the truncation notice is appended // for all content formats (text/plain, text/html, markdown, application/json). func TestWebTool_WebFetch_TruncationNotice(t *testing.T) { withPrivateWebFetchHostsAllowed(t) const truncationNotice = "[Content truncated due to size limit]" const maxChars = 100 tests := []struct { name string contentType string body string format string }{ { name: "plain text", contentType: "text/plain", body: strings.Repeat("a", 500), format: "plaintext", }, { name: "html plaintext extractor", contentType: "text/html", body: "<html><body>" + strings.Repeat("b", 500) + "</body></html>", format: "plaintext", }, { name: "html markdown extractor", contentType: "text/html", body: "<html><body>" + strings.Repeat("c", 500) + "</body></html>", format: "markdown", }, { name: "json", contentType: "application/json", body: `"` + strings.Repeat("d", 500) + `"`, format: "plaintext", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", tt.contentType) w.WriteHeader(http.StatusOK) w.Write([]byte(tt.body)) })) defer server.Close() tool, err := NewWebFetchTool(maxChars, tt.format, testFetchLimit) if err != nil { t.Fatalf("NewWebFetchTool() error: %v", err) } result := tool.Execute(context.Background(), map[string]any{"url": server.URL}) if result.IsError { t.Fatalf("unexpected error: %s", result.ForLLM) } var resultMap map[string]any if err := json.Unmarshal([]byte(result.ForLLM), &resultMap); err != nil { t.Fatalf("failed to unmarshal result JSON: %v", err) } text, ok := resultMap["text"].(string) if !ok { t.Fatal("missing 'text' field in result") } if !strings.HasSuffix(text, truncationNotice) { t.Errorf("expected text to end with %q, got suffix: %q", truncationNotice, text[max(0, len(text)-60):]) } if truncated, ok := resultMap["truncated"].(bool); !ok || !truncated { t.Errorf("expected truncated=true in result") } }) } } // TestWebTool_WebFetch_NoTruncationNoticeWhenFitsInLimit verifies that the notice // is NOT appended when the content fits within the limit. func TestWebTool_WebFetch_NoTruncationNoticeWhenFitsInLimit(t *testing.T) { withPrivateWebFetchHostsAllowed(t) const truncationNotice = "[Content truncated due to size limit]" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) w.Write([]byte("short content")) })) defer server.Close() tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { t.Fatalf("NewWebFetchTool() error: %v", err) } result := tool.Execute(context.Background(), map[string]any{"url": server.URL}) if result.IsError { t.Fatalf("unexpected error: %s", result.ForLLM) } var resultMap map[string]any if err := json.Unmarshal([]byte(result.ForLLM), &resultMap); err != nil { t.Fatalf("failed to unmarshal result JSON: %v", err) } text, _ := resultMap["text"].(string) if strings.Contains(text, truncationNotice) { t.Errorf("expected no truncation notice for content within limit, got: %q", text) } if truncated, _ := resultMap["truncated"].(bool); truncated { t.Errorf("expected truncated=false for content within limit") } } func TestWebFetchTool_PayloadTooLarge(t *testing.T) { withPrivateWebFetchHostsAllowed(t) // Create a mock HTTP server ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusOK) // Generate a payload intentionally larger than our limit. // Limit: 10 * 1024 * 1024 (10MB). We generate 10MB + 100 bytes of the letter 'A'. largeData := bytes.Repeat([]byte("A"), int(testFetchLimit)+100) w.Write(largeData) })) // Ensure the server is shut down at the end of the test defer ts.Close() // Initialize the tool tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) } // Prepare the arguments pointing to the URL of our local mock server args := map[string]any{ "url": ts.URL, } // Execute the tool ctx := context.Background() result := tool.Execute(ctx, args) // Assuming ErrorResult sets the ForLLM field with the error text. if result == nil { t.Fatal("expected a ToolResult, got nil") } // Search for the exact error string we set earlier in the Execute method expectedErrorMsg := fmt.Sprintf("size exceeded %d bytes limit", testFetchLimit) if !strings.Contains(result.ForLLM, expectedErrorMsg) && !strings.Contains(result.ForUser, expectedErrorMsg) { t.Errorf("test failed: expected error %q, but got: %+v", expectedErrorMsg, result) } } // TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing func TestWebTool_WebSearch_NoApiKey(t *testing.T) { tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKeys: nil}) if err != nil { t.Fatalf("Unexpected error: %v", err) } if tool != nil { t.Errorf("Expected nil tool when Brave API key is empty") } // Also nil when nothing is enabled tool, err = NewWebSearchTool(WebSearchToolOptions{}) if err != nil { t.Fatalf("Unexpected error: %v", err) } if tool != nil { t.Errorf("Expected nil tool when no provider is enabled") } } // TestWebTool_WebSearch_MissingQuery verifies error handling for missing query func TestWebTool_WebSearch_MissingQuery(t *testing.T) { tool, err := NewWebSearchTool(WebSearchToolOptions{ BraveEnabled: true, BraveAPIKeys: []string{"test-key"}, BraveMaxResults: 5, }) if err != nil { t.Fatalf("Unexpected error: %v", err) } ctx := context.Background() args := map[string]any{} result := tool.Execute(ctx, args) // Should return error result if !result.IsError { t.Errorf("Expected error when query is missing") } } // TestWebTool_WebFetch_HTMLExtraction verifies HTML text extraction func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) { withPrivateWebFetchHostsAllowed(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusOK) w.Write( []byte( `<html><body><script>alert('test');</script><style>body{color:red;}</style><h1>Title</h1><p>Content</p></body></html>`, ), ) })) defer server.Close() tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) } ctx := context.Background() args := map[string]any{ "url": server.URL, } result := tool.Execute(ctx, args) // Success should not be an error if result.IsError { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } // ForLLM should contain extracted text (without script/style tags) if !strings.Contains(result.ForLLM, "Title") && !strings.Contains(result.ForLLM, "Content") { t.Errorf("Expected ForLLM to contain extracted text, got: %s", result.ForLLM) } // Should NOT contain script or style tags in ForLLM if strings.Contains(result.ForLLM, "<script>") || strings.Contains(result.ForLLM, "<style>") { t.Errorf("Expected script/style tags to be removed, got: %s", result.ForLLM) } } // TestWebFetchTool_extractText verifies text extraction preserves newlines func TestWebFetchTool_extractText(t *testing.T) { tool := &WebFetchTool{} tests := []struct { name string input string wantFunc func(t *testing.T, got string) }{ { name: "preserves newlines between block elements", input: "<html><body><h1>Title</h1>\n<p>Paragraph 1</p>\n<p>Paragraph 2</p></body></html>", wantFunc: func(t *testing.T, got string) { lines := strings.Split(got, "\n") if len(lines) < 2 { t.Errorf("Expected multiple lines, got %d: %q", len(lines), got) } if !strings.Contains(got, "Title") || !strings.Contains(got, "Paragraph 1") || !strings.Contains(got, "Paragraph 2") { t.Errorf("Missing expected text: %q", got) } }, }, { name: "removes script and style tags", input: "<script>alert('x');</script><style>body{}</style><p>Keep this</p>", wantFunc: func(t *testing.T, got string) { if strings.Contains(got, "alert") || strings.Contains(got, "body{}") { t.Errorf("Expected script/style content removed, got: %q", got) } if !strings.Contains(got, "Keep this") { t.Errorf("Expected 'Keep this' to remain, got: %q", got) } }, }, { name: "collapses excessive blank lines", input: "<p>A</p>\n\n\n\n\n<p>B</p>", wantFunc: func(t *testing.T, got string) { if strings.Contains(got, "\n\n\n") { t.Errorf("Expected excessive blank lines collapsed, got: %q", got) } }, }, { name: "collapses horizontal whitespace", input: "<p>hello world</p>", wantFunc: func(t *testing.T, got string) { if strings.Contains(got, " ") { t.Errorf("Expected spaces collapsed, got: %q", got) } if !strings.Contains(got, "hello world") { t.Errorf("Expected 'hello world', got: %q", got) } }, }, { name: "empty input", input: "", wantFunc: func(t *testing.T, got string) { if got != "" { t.Errorf("Expected empty string, got: %q", got) } }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tool.extractText(tt.input) tt.wantFunc(t, got) }) } } func withPrivateWebFetchHostsAllowed(t *testing.T) { t.Helper() previous := allowPrivateWebFetchHosts.Load() allowPrivateWebFetchHosts.Store(true) t.Cleanup(func() { allowPrivateWebFetchHosts.Store(previous) }) } func serverHostAndPort(t *testing.T, rawURL string) (string, string) { t.Helper() hostPort := strings.TrimPrefix(rawURL, "http://") hostPort = strings.TrimPrefix(hostPort, "https://") host, port, err := net.SplitHostPort(hostPort) if err != nil { t.Fatalf("failed to split host/port from %q: %v", rawURL, err) } return host, port } func singleHostCIDR(t *testing.T, host string) string { t.Helper() ip := net.ParseIP(host) if ip == nil { t.Fatalf("failed to parse IP %q", host) } if ip.To4() != nil { return ip.String() + "/32" } return ip.String() + "/128" } func TestWebTool_WebFetch_PrivateHostBlocked(t *testing.T) { tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { t.Fatalf("Failed to create web fetch tool: %v", err) } result := tool.Execute(context.Background(), map[string]any{ "url": "http://127.0.0.1:0", }) if !result.IsError { t.Errorf("expected error for private host URL, got success") } if !strings.Contains(result.ForLLM, "private or local network") && !strings.Contains(result.ForUser, "private or local network") { t.Errorf("expected private host block message, got %q", result.ForLLM) } } func TestWebTool_WebFetch_PrivateHostAllowedByExactWhitelist(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) w.Write([]byte("exact whitelist ok")) })) defer server.Close() host, _ := serverHostAndPort(t, server.URL) tool, err := NewWebFetchToolWithConfig(50000, "", format, testFetchLimit, []string{host}) if err != nil { t.Fatalf("Failed to create web fetch tool: %v", err) } result := tool.Execute(context.Background(), map[string]any{ "url": server.URL, }) if result.IsError { t.Fatalf("expected success for exact whitelisted private IP, got %q", result.ForLLM) } if !strings.Contains(result.ForLLM, "exact whitelist ok") { t.Fatalf("expected fetched content, got %q", result.ForLLM) } } func TestWebTool_WebFetch_PrivateHostAllowedByCIDRWhitelist(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) w.Write([]byte("cidr whitelist ok")) })) defer server.Close() host, _ := serverHostAndPort(t, server.URL) tool, err := NewWebFetchToolWithConfig(50000, "", format, testFetchLimit, []string{singleHostCIDR(t, host)}) if err != nil { t.Fatalf("Failed to create web fetch tool: %v", err) } result := tool.Execute(context.Background(), map[string]any{ "url": server.URL, }) if result.IsError { t.Fatalf("expected success for CIDR-whitelisted private IP, got %q", result.ForLLM) } if !strings.Contains(result.ForLLM, "cidr whitelist ok") { t.Fatalf("expected fetched content, got %q", result.ForLLM) } } func TestWebTool_WebFetch_PrivateHostAllowedForTests(t *testing.T) { withPrivateWebFetchHostsAllowed(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) })) defer server.Close() tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { t.Fatalf("Failed to create web fetch tool: %v", err) } result := tool.Execute(context.Background(), map[string]any{ "url": server.URL, }) if result.IsError { t.Errorf("expected success when private host access is allowed in tests, got %q", result.ForLLM) } } // TestWebFetch_BlocksIPv4MappedIPv6Loopback verifies ::ffff:127.0.0.1 is blocked func TestWebFetch_BlocksIPv4MappedIPv6Loopback(t *testing.T) { tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { t.Fatalf("Failed to create web fetch tool: %v", err) } result := tool.Execute(context.Background(), map[string]any{ "url": "http://[::ffff:127.0.0.1]:0", }) if !result.IsError { t.Error("expected error for IPv4-mapped IPv6 loopback URL, got success") } } // TestWebFetch_BlocksMetadataIP verifies 169.254.169.254 is blocked func TestWebFetch_BlocksMetadataIP(t *testing.T) { tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { t.Fatalf("Failed to create web fetch tool: %v", err) } result := tool.Execute(context.Background(), map[string]any{ "url": "http://169.254.169.254/latest/meta-data", }) if !result.IsError { t.Error("expected error for cloud metadata IP, got success") } } // TestWebFetch_BlocksIPv6UniqueLocal verifies fc00::/7 addresses are blocked func TestWebFetch_BlocksIPv6UniqueLocal(t *testing.T) { tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { t.Fatalf("Failed to create web fetch tool: %v", err) } result := tool.Execute(context.Background(), map[string]any{ "url": "http://[fd00::1]:0", }) if !result.IsError { t.Error("expected error for IPv6 unique local address, got success") } } // TestWebFetch_Blocks6to4WithPrivateEmbed verifies 6to4 with private embedded IPv4 is blocked func TestWebFetch_Blocks6to4WithPrivateEmbed(t *testing.T) { tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { t.Fatalf("Failed to create web fetch tool: %v", err) } // 2002:7f00:0001::1 embeds 127.0.0.1 result := tool.Execute(context.Background(), map[string]any{ "url": "http://[2002:7f00:0001::1]:0", }) if !result.IsError { t.Error("expected error for 6to4 with private embedded IPv4, got success") } } // TestWebFetch_Allows6to4WithPublicEmbed verifies 6to4 with public embedded IPv4 is NOT blocked func TestWebFetch_Allows6to4WithPublicEmbed(t *testing.T) { tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { t.Fatalf("Failed to create web fetch tool: %v", err) } // 2002:0801:0101::1 embeds 8.1.1.1 (public) — pre-flight should pass, // connection will fail (no listener) but that's after the SSRF check. result := tool.Execute(context.Background(), map[string]any{ "url": "http://[2002:0801:0101::1]:0", }) // Should NOT be blocked by SSRF check — error should be connection failure, not "private" if result.IsError && strings.Contains(result.ForLLM, "private") { t.Error("6to4 with public embedded IPv4 should not be blocked as private") } } // TestWebFetch_RedirectToPrivateBlocked verifies redirects to private IPs are blocked func TestWebFetch_RedirectToPrivateBlocked(t *testing.T) { withPrivateWebFetchHostsAllowed(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Redirect to a private IP http.Redirect(w, r, "http://10.0.0.1/secret", http.StatusFound) })) defer server.Close() // Temporarily disable private host allowance for the redirect check allowPrivateWebFetchHosts.Store(false) defer allowPrivateWebFetchHosts.Store(true) tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { t.Fatalf("Failed to create web fetch tool: %v", err) } result := tool.Execute(context.Background(), map[string]any{ "url": server.URL, }) if !result.IsError { t.Error("expected error when redirecting to private IP, got success") } } func TestNewSafeDialContext_BlocksPrivateDNSResolutionWithoutWhitelist(t *testing.T) { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("failed to listen on loopback: %v", err) } defer listener.Close() _, port, err := net.SplitHostPort(listener.Addr().String()) if err != nil { t.Fatalf("failed to split listener address: %v", err) } dialContext := newSafeDialContext(&net.Dialer{Timeout: time.Second}, nil) _, err = dialContext(context.Background(), "tcp", net.JoinHostPort("localhost", port)) if err == nil { t.Fatal("expected localhost DNS resolution to be blocked without whitelist") } if !strings.Contains(err.Error(), "private") && !strings.Contains(err.Error(), "whitelisted") { t.Fatalf("unexpected error: %v", err) } } func TestNewSafeDialContext_AllowsWhitelistedPrivateDNSResolution(t *testing.T) { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("failed to listen on loopback: %v", err) } defer listener.Close() accepted := make(chan struct{}, 1) go func() { conn, acceptErr := listener.Accept() if acceptErr != nil { return } conn.Close() accepted <- struct{}{} }() _, port, err := net.SplitHostPort(listener.Addr().String()) if err != nil { t.Fatalf("failed to split listener address: %v", err) } whitelist, err := newPrivateHostWhitelist([]string{"127.0.0.0/8"}) if err != nil { t.Fatalf("failed to parse whitelist: %v", err) } dialContext := newSafeDialContext(&net.Dialer{Timeout: time.Second}, whitelist) conn, err := dialContext(context.Background(), "tcp", net.JoinHostPort("localhost", port)) if err != nil { t.Fatalf("expected localhost DNS resolution to succeed with whitelist, got %v", err) } conn.Close() select { case <-accepted: case <-time.After(time.Second): t.Fatal("expected localhost listener to accept a connection") } } // TestIsPrivateOrRestrictedIP_Table tests IP classification logic func TestIsPrivateOrRestrictedIP_Table(t *testing.T) { tests := []struct { ip string blocked bool desc string }{ {"127.0.0.1", true, "IPv4 loopback"}, {"10.0.0.1", true, "IPv4 private class A"}, {"172.16.0.1", true, "IPv4 private class B"}, {"192.168.1.1", true, "IPv4 private class C"}, {"169.254.169.254", true, "link-local / cloud metadata"}, {"100.64.0.1", true, "carrier-grade NAT"}, {"0.0.0.0", true, "unspecified"}, {"8.8.8.8", false, "public DNS"}, {"1.1.1.1", false, "public DNS"}, {"::1", true, "IPv6 loopback"}, {"::ffff:127.0.0.1", true, "IPv4-mapped IPv6 loopback"}, {"::ffff:10.0.0.1", true, "IPv4-mapped IPv6 private"}, {"fc00::1", true, "IPv6 unique local"}, {"fd00::1", true, "IPv6 unique local"}, {"2002:7f00:0001::1", true, "6to4 with embedded 127.x (private)"}, {"2002:0a00:0001::1", true, "6to4 with embedded 10.0.0.1 (private)"}, {"2002:0801:0101::1", false, "6to4 with embedded 8.1.1.1 (public)"}, {"2001:0000:4136:e378:8000:63bf:f5ff:fffe", true, "Teredo with client 10.0.0.1 (private)"}, {"2001:0000:4136:e378:8000:63bf:f7f6:fefe", false, "Teredo with client 8.9.1.1 (public)"}, {"2607:f8b0:4004:800::200e", false, "public IPv6 (Google)"}, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { ip := net.ParseIP(tt.ip) if ip == nil { t.Fatalf("failed to parse IP: %s", tt.ip) } got := isPrivateOrRestrictedIP(ip) if got != tt.blocked { t.Errorf("isPrivateOrRestrictedIP(%s) = %v, want %v", tt.ip, got, tt.blocked) } }) } } // TestWebTool_WebFetch_MissingDomain verifies error handling for URL without domain func TestWebTool_WebFetch_MissingDomain(t *testing.T) { tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) } ctx := context.Background() args := map[string]any{ "url": "https://", } result := tool.Execute(ctx, args) // Should return error result if !result.IsError { t.Errorf("Expected error for URL without domain") } // Should mention missing domain if !strings.Contains(result.ForLLM, "domain") && !strings.Contains(result.ForUser, "domain") { t.Errorf("Expected domain error message, got ForLLM: %s", result.ForLLM) } } func TestNewWebFetchToolWithProxy(t *testing.T) { tool, err := NewWebFetchToolWithProxy(1024, "http://127.0.0.1:7890", format, testFetchLimit, nil) if err != nil { logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) } else if tool.maxChars != 1024 { t.Fatalf("maxChars = %d, want %d", tool.maxChars, 1024) } if tool.proxy != "http://127.0.0.1:7890" { t.Fatalf("proxy = %q, want %q", tool.proxy, "http://127.0.0.1:7890") } tool, err = NewWebFetchToolWithProxy(0, "http://127.0.0.1:7890", format, testFetchLimit, nil) if err != nil { logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) } if tool.maxChars != 50000 { t.Fatalf("default maxChars = %d, want %d", tool.maxChars, 50000) } } func TestNewWebFetchToolWithConfig_InvalidPrivateHostWhitelist(t *testing.T) { _, err := NewWebFetchToolWithConfig(1024, "", format, testFetchLimit, []string{"not-an-ip-or-cidr"}) if err == nil { t.Fatal("expected invalid whitelist entry to fail") } if !strings.Contains(err.Error(), "invalid entry") { t.Fatalf("unexpected error: %v", err) } } func TestNewWebSearchTool_PropagatesProxy(t *testing.T) { t.Run("perplexity", func(t *testing.T) { tool, err := NewWebSearchTool(WebSearchToolOptions{ PerplexityEnabled: true, PerplexityAPIKeys: []string{"k"}, PerplexityMaxResults: 3, Proxy: "http://127.0.0.1:7890", }) if err != nil { t.Fatalf("NewWebSearchTool() error: %v", err) } p, ok := tool.provider.(*PerplexitySearchProvider) if !ok { t.Fatalf("provider type = %T, want *PerplexitySearchProvider", tool.provider) } if p.proxy != "http://127.0.0.1:7890" { t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890") } }) t.Run("brave", func(t *testing.T) { tool, err := NewWebSearchTool(WebSearchToolOptions{ BraveEnabled: true, BraveAPIKeys: []string{"k"}, BraveMaxResults: 3, Proxy: "http://127.0.0.1:7890", }) if err != nil { t.Fatalf("NewWebSearchTool() error: %v", err) } p, ok := tool.provider.(*BraveSearchProvider) if !ok { t.Fatalf("provider type = %T, want *BraveSearchProvider", tool.provider) } if p.proxy != "http://127.0.0.1:7890" { t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890") } }) t.Run("duckduckgo", func(t *testing.T) { tool, err := NewWebSearchTool(WebSearchToolOptions{ DuckDuckGoEnabled: true, DuckDuckGoMaxResults: 3, Proxy: "http://127.0.0.1:7890", }) if err != nil { t.Fatalf("NewWebSearchTool() error: %v", err) } p, ok := tool.provider.(*DuckDuckGoSearchProvider) if !ok { t.Fatalf("provider type = %T, want *DuckDuckGoSearchProvider", tool.provider) } if p.proxy != "http://127.0.0.1:7890" { t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890") } }) } // TestWebTool_TavilySearch_Success verifies successful Tavily search func TestWebTool_TavilySearch_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { t.Errorf("Expected POST request, got %s", r.Method) } if r.Header.Get("Content-Type") != "application/json" { t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) } // Verify payload var payload map[string]any json.NewDecoder(r.Body).Decode(&payload) if payload["api_key"] != "test-key" { t.Errorf("Expected api_key test-key, got %v", payload["api_key"]) } if payload["query"] != "test query" { t.Errorf("Expected query 'test query', got %v", payload["query"]) } // Return mock response response := map[string]any{ "results": []map[string]any{ { "title": "Test Result 1", "url": "https://example.com/1", "content": "Content for result 1", }, { "title": "Test Result 2", "url": "https://example.com/2", "content": "Content for result 2", }, }, } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(response) })) defer server.Close() tool, err := NewWebSearchTool(WebSearchToolOptions{ TavilyEnabled: true, TavilyAPIKeys: []string{"test-key"}, TavilyBaseURL: server.URL, TavilyMaxResults: 5, }) if err != nil { t.Fatalf("NewWebSearchTool() error: %v", err) } ctx := context.Background() args := map[string]any{ "query": "test query", } result := tool.Execute(ctx, args) // Success should not be an error if result.IsError { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } // ForUser should contain result titles and URLs if !strings.Contains(result.ForUser, "Test Result 1") || !strings.Contains(result.ForUser, "https://example.com/1") { t.Errorf("Expected results in output, got: %s", result.ForUser) } // Should mention via Tavily if !strings.Contains(result.ForUser, "via Tavily") { t.Errorf("Expected 'via Tavily' in output, got: %s", result.ForUser) } } // TestWebFetchTool_CloudflareChallenge_RetryWithHonestUA verifies that a 403 response // with cf-mitigated: challenge triggers a retry using the honest picoclaw User-Agent, // and that the retry response is returned when it succeeds. func TestWebFetchTool_CloudflareChallenge_RetryWithHonestUA(t *testing.T) { withPrivateWebFetchHostsAllowed(t) requestCount := 0 var receivedUAs []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestCount++ receivedUAs = append(receivedUAs, r.Header.Get("User-Agent")) if requestCount == 1 { // First request: simulate Cloudflare challenge w.Header().Set("Cf-Mitigated", "challenge") w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusForbidden) w.Write([]byte("<html><body>Cloudflare challenge</body></html>")) return } // Second request (honest UA retry): success w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) w.Write([]byte("real content")) })) defer server.Close() tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { t.Fatalf("NewWebFetchTool() error: %v", err) } result := tool.Execute(context.Background(), map[string]any{"url": server.URL}) if result.IsError { t.Fatalf("expected success after retry, got error: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "real content") { t.Errorf("expected retry response content, got: %s", result.ForLLM) } if requestCount != 2 { t.Errorf("expected exactly 2 requests, got %d", requestCount) } // First request must use the generic user agent if receivedUAs[0] != userAgent { t.Errorf("first request UA = %q, want %q", receivedUAs[0], userAgent) } // Second request must use the honest picoclaw user agent if !strings.Contains(receivedUAs[1], "picoclaw") { t.Errorf("retry request UA = %q, want it to contain 'picoclaw'", receivedUAs[1]) } } // TestWebFetchTool_CloudflareChallenge_NoRetryOnOtherErrors verifies that a plain 403 // (without cf-mitigated: challenge) does NOT trigger a retry. func TestWebFetchTool_CloudflareChallenge_NoRetryOnOtherErrors(t *testing.T) { withPrivateWebFetchHostsAllowed(t) requestCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestCount++ w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusForbidden) w.Write([]byte("plain forbidden")) })) defer server.Close() tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { t.Fatalf("NewWebFetchTool() error: %v", err) } tool.Execute(context.Background(), map[string]any{"url": server.URL}) if requestCount != 1 { t.Errorf("expected exactly 1 request for plain 403, got %d", requestCount) } } // TestWebFetchTool_CloudflareChallenge_RetryFailsToo verifies that if the honest-UA // retry also fails (e.g. still blocked), the error from the retry is returned. func TestWebFetchTool_CloudflareChallenge_RetryFailsToo(t *testing.T) { withPrivateWebFetchHostsAllowed(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Always return CF challenge regardless of UA w.Header().Set("Cf-Mitigated", "challenge") w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusForbidden) w.Write([]byte("<html><body>still blocked</body></html>")) })) defer server.Close() tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { t.Fatalf("NewWebFetchTool() error: %v", err) } result := tool.Execute(context.Background(), map[string]any{"url": server.URL}) // Should not be an error — the retry response is used as-is (403 is a valid HTTP response) if result.IsError { t.Fatalf("expected non-error result even when retry is also blocked, got: %s", result.ForLLM) } // Status in the JSON result should reflect the 403 if !strings.Contains(result.ForLLM, "403") { t.Errorf("expected status 403 in result, got: %s", result.ForLLM) } } func TestAPIKeyPool(t *testing.T) { pool := NewAPIKeyPool([]string{"key1", "key2", "key3"}) if len(pool.keys) != 3 { t.Fatalf("expected 3 keys, got %d", len(pool.keys)) } if pool.keys[0] != "key1" || pool.keys[1] != "key2" || pool.keys[2] != "key3" { t.Fatalf("unexpected keys: %v", pool.keys) } // Test Iterator: each iterator should cover all keys exactly once iter := pool.NewIterator() expected := []string{"key1", "key2", "key3"} for i, want := range expected { k, ok := iter.Next() if !ok { t.Fatalf("iter.Next() returned false at step %d", i) } if k != want { t.Errorf("step %d: expected %s, got %s", i, want, k) } } // Should be exhausted if _, ok := iter.Next(); ok { t.Errorf("expected iterator exhausted after all keys") } // Second iterator starts at next position (load balancing) iter2 := pool.NewIterator() k, ok := iter2.Next() if !ok { t.Fatal("iter2.Next() returned false") } if k != "key2" { t.Errorf("expected key2 (round-robin), got %s", k) } // Empty pool emptyPool := NewAPIKeyPool([]string{}) emptyIter := emptyPool.NewIterator() if _, ok := emptyIter.Next(); ok { t.Errorf("expected false for empty pool") } // Single key pool singlePool := NewAPIKeyPool([]string{"single"}) singleIter := singlePool.NewIterator() if k, ok := singleIter.Next(); !ok || k != "single" { t.Errorf("expected single, got %s (ok=%v)", k, ok) } if _, ok := singleIter.Next(); ok { t.Errorf("expected exhausted after single key") } } func TestWebTool_TavilySearch_Failover(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var payload map[string]any if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Fatalf("failed to decode payload: %v", err) } apiKey := payload["api_key"].(string) if apiKey == "key1" { w.WriteHeader(http.StatusTooManyRequests) w.Write([]byte("Rate limited")) return } if apiKey == "key2" { // Success response := map[string]any{ "results": []map[string]any{ { "title": "Success Result", "url": "https://example.com/success", "content": "Success content", }, }, } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(response) return } w.WriteHeader(http.StatusBadRequest) })) defer server.Close() tool, err := NewWebSearchTool(WebSearchToolOptions{ TavilyEnabled: true, TavilyAPIKeys: []string{"key1", "key2"}, TavilyBaseURL: server.URL, TavilyMaxResults: 5, }) if err != nil { t.Fatalf("NewWebSearchTool() error: %v", err) } ctx := context.Background() args := map[string]any{ "query": "test query", } result := tool.Execute(ctx, args) if result.IsError { t.Errorf("Expected success, got Error: %s", result.ForLLM) } if !strings.Contains(result.ForUser, "Success Result") { t.Errorf("Expected failover to second key and success result, got: %s", result.ForUser) } } func TestWebTool_GLMSearch_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { t.Errorf("Expected POST request, got %s", r.Method) } if r.Header.Get("Content-Type") != "application/json" { t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) } if r.Header.Get("Authorization") != "Bearer test-glm-key" { t.Errorf("Expected Authorization Bearer test-glm-key, got %s", r.Header.Get("Authorization")) } var payload map[string]any json.NewDecoder(r.Body).Decode(&payload) if payload["search_query"] != "test query" { t.Errorf("Expected search_query 'test query', got %v", payload["search_query"]) } if payload["search_engine"] != "search_std" { t.Errorf("Expected search_engine 'search_std', got %v", payload["search_engine"]) } response := map[string]any{ "id": "web-search-test", "created": 1709568000, "search_result": []map[string]any{ { "title": "Test GLM Result", "content": "GLM search snippet", "link": "https://example.com/glm", "media": "Example", "publish_date": "2026-03-04", }, }, } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(response) })) defer server.Close() tool, err := NewWebSearchTool(WebSearchToolOptions{ GLMSearchEnabled: true, GLMSearchAPIKey: "test-glm-key", GLMSearchBaseURL: server.URL, GLMSearchEngine: "search_std", }) if err != nil { t.Fatalf("NewWebSearchTool() error: %v", err) } result := tool.Execute(context.Background(), map[string]any{ "query": "test query", }) if result.IsError { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } if !strings.Contains(result.ForUser, "Test GLM Result") { t.Errorf("Expected 'Test GLM Result' in output, got: %s", result.ForUser) } if !strings.Contains(result.ForUser, "https://example.com/glm") { t.Errorf("Expected URL in output, got: %s", result.ForUser) } if !strings.Contains(result.ForUser, "via GLM Search") { t.Errorf("Expected 'via GLM Search' in output, got: %s", result.ForUser) } } func TestWebTool_GLMSearch_APIError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte(`{"error":"invalid api key"}`)) })) defer server.Close() tool, err := NewWebSearchTool(WebSearchToolOptions{ GLMSearchEnabled: true, GLMSearchAPIKey: "bad-key", GLMSearchBaseURL: server.URL, GLMSearchEngine: "search_std", }) if err != nil { t.Fatalf("NewWebSearchTool() error: %v", err) } result := tool.Execute(context.Background(), map[string]any{ "query": "test query", }) if !result.IsError { t.Errorf("Expected IsError=true for 401 response") } if !strings.Contains(result.ForLLM, "status 401") { t.Errorf("Expected status 401 in error, got: %s", result.ForLLM) } } func TestWebTool_GLMSearch_Priority(t *testing.T) { // GLM Search should only be selected when all other providers are disabled tool, err := NewWebSearchTool(WebSearchToolOptions{ DuckDuckGoEnabled: true, DuckDuckGoMaxResults: 5, GLMSearchEnabled: true, GLMSearchAPIKey: "test-key", GLMSearchBaseURL: "https://example.com", GLMSearchEngine: "search_std", }) if err != nil { t.Fatalf("NewWebSearchTool() error: %v", err) } // DuckDuckGo should win over GLM Search if _, ok := tool.provider.(*DuckDuckGoSearchProvider); !ok { t.Errorf("Expected DuckDuckGoSearchProvider when both enabled, got %T", tool.provider) } // With DuckDuckGo disabled, GLM Search should be selected tool2, err := NewWebSearchTool(WebSearchToolOptions{ DuckDuckGoEnabled: false, GLMSearchEnabled: true, GLMSearchAPIKey: "test-key", GLMSearchBaseURL: "https://example.com", GLMSearchEngine: "search_std", }) if err != nil { t.Fatalf("NewWebSearchTool() error: %v", err) } if _, ok := tool2.provider.(*GLMSearchProvider); !ok { t.Errorf("Expected GLMSearchProvider when only GLM enabled, got %T", tool2.provider) } } ================================================ FILE: pkg/utils/bm25.go ================================================ // Package utils provides shared, reusable algorithms. // This file implements a generic BM25 search engine. // // Usage: // // type MyDoc struct { ID string; Body string } // // corpus := []MyDoc{...} // engine := bm25.New(corpus, func(d MyDoc) string { // return d.ID + " " + d.Body // }) // results := engine.Search("my query", 5) package utils import ( "math" "sort" "strings" ) // ── Tuning defaults ─────────────────────────────────────────────────────────── const ( // DefaultBM25K1 is the term-frequency saturation factor (typical range 1.2–2.0). // Higher values give more weight to repeated terms. DefaultBM25K1 = 1.2 // DefaultBM25B is the document-length normalization factor (0 = none, 1 = full). DefaultBM25B = 0.75 ) // BM25Engine is a query-time BM25 search engine over a generic corpus. // T is the document type; the caller supplies a TextFunc that extracts the // searchable text from each document. // // The engine is stateless between queries: no caching, no invalidation logic. // All indexing work is performed inside Search() on every call, making it // safe to use on corpora that change frequently. type BM25Engine[T any] struct { corpus []T textFunc func(T) string k1 float64 b float64 } // BM25Option is a functional option to configure a BM25Engine. type BM25Option func(*bm25Config) type bm25Config struct { k1 float64 b float64 } // WithK1 overrides the term-frequency saturation constant (default 1.2). func WithK1(k1 float64) BM25Option { return func(c *bm25Config) { c.k1 = k1 } } // WithB overrides the document-length normalization factor (default 0.75). func WithB(b float64) BM25Option { return func(c *bm25Config) { c.b = b } } // NewBM25Engine creates a BM25Engine for the given corpus. // // - corpus : slice of documents of any type T. // - textFunc : function that returns the searchable text for a document. // - opts : optional tuning (WithK1, WithB). // // The corpus slice is referenced, not copied. Callers must not mutate it // concurrently with Search(). func NewBM25Engine[T any](corpus []T, textFunc func(T) string, opts ...BM25Option) *BM25Engine[T] { cfg := bm25Config{k1: DefaultBM25K1, b: DefaultBM25B} for _, o := range opts { o(&cfg) } return &BM25Engine[T]{ corpus: corpus, textFunc: textFunc, k1: cfg.k1, b: cfg.b, } } // BM25Result is a single ranked result from a Search call. type BM25Result[T any] struct { Document T Score float32 } // Search ranks the corpus against query and returns the top-k results. // Returns an empty slice (not nil) when there are no matches. // // Complexity: O(N×L) for indexing + O(|Q|×avgPostingLen) for scoring, // where N = corpus size, L = average document length, Q = query terms. // Top-k extraction uses a fixed-size min-heap: O(candidates × log k). func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] { if topK <= 0 { return []BM25Result[T]{} } queryTerms := bm25Tokenize(query) if len(queryTerms) == 0 { return []BM25Result[T]{} } N := len(e.corpus) if N == 0 { return []BM25Result[T]{} } // Step 1: build per-document tf + raw doc lengths type docEntry struct { tf map[string]uint32 rawLen int } entries := make([]docEntry, N) df := make(map[string]int, 64) totalLen := 0 for i, doc := range e.corpus { tokens := bm25Tokenize(e.textFunc(doc)) totalLen += len(tokens) tf := make(map[string]uint32, len(tokens)) for _, t := range tokens { tf[t]++ } // df: each term counts once per document (iterate the map, keys are unique) for t := range tf { df[t]++ } entries[i] = docEntry{tf: tf, rawLen: len(tokens)} } avgDocLen := float64(totalLen) / float64(N) // Step 2: pre-compute IDF and per-doc length normalization // IDF (Robertson smoothing): log( (N - df(t) + 0.5) / (df(t) + 0.5) + 1 ) idf := make(map[string]float32, len(df)) for term, freq := range df { idf[term] = float32(math.Log( (float64(N)-float64(freq)+0.5)/(float64(freq)+0.5) + 1, )) } // docLenNorm[i] = k1 * (1 - b + b * |doc_i| / avgDocLen) // Stored as float32 — sufficient precision for ranking. docLenNorm := make([]float32, N) for i, entry := range entries { docLenNorm[i] = float32(e.k1 * (1 - e.b + e.b*float64(entry.rawLen)/avgDocLen)) } // Step 3: build inverted index (posting lists) // Iterate the tf map directly — map keys are already unique, no seen-set needed. posting := make(map[string][]int32, len(df)) for i, entry := range entries { for term := range entry.tf { posting[term] = append(posting[term], int32(i)) } } // Step 4: score via posting lists // Deduplicate query terms to avoid double-weighting the same term. unique := bm25Dedupe(queryTerms) scores := make(map[int32]float32) for _, term := range unique { termIDF, ok := idf[term] if !ok { continue // term not in vocabulary → zero contribution } for _, docID := range posting[term] { freq := float32(entries[docID].tf[term]) // TF_norm = freq * (k1+1) / (freq + docLenNorm) tfNorm := freq * float32(e.k1+1) / (freq + docLenNorm[docID]) scores[docID] += termIDF * tfNorm } } if len(scores) == 0 { return []BM25Result[T]{} } // Step 5: top-K via fixed-size min-heap heap := make([]bm25ScoredDoc, 0, topK) for docID, sc := range scores { switch { case len(heap) < topK: heap = append(heap, bm25ScoredDoc{docID: docID, score: sc}) if len(heap) == topK { bm25MinHeapify(heap) } case sc > heap[0].score: heap[0] = bm25ScoredDoc{docID: docID, score: sc} bm25SiftDown(heap, 0) } } sort.Slice(heap, func(i, j int) bool { return heap[i].score > heap[j].score }) out := make([]BM25Result[T], len(heap)) for i, h := range heap { out[i] = BM25Result[T]{ Document: e.corpus[h.docID], Score: h.score, } } return out } // bm25Tokenize splits s into lowercase tokens, stripping edge punctuation. func bm25Tokenize(s string) []string { raw := strings.Fields(strings.ToLower(s)) out := raw[:0] // reuse backing array to avoid extra allocation for _, t := range raw { t = strings.Trim(t, ".,;:!?\"'()/\\-_") if t != "" { out = append(out, t) } } return out } // bm25Dedupe returns a new slice with duplicate tokens removed, // preserving first-occurrence order. func bm25Dedupe(tokens []string) []string { seen := make(map[string]struct{}, len(tokens)) out := make([]string, 0, len(tokens)) for _, t := range tokens { if _, ok := seen[t]; !ok { seen[t] = struct{}{} out = append(out, t) } } return out } type bm25ScoredDoc struct { docID int32 score float32 } // bm25MinHeapify builds a min-heap in-place using Floyd's algorithm: O(k). func bm25MinHeapify(h []bm25ScoredDoc) { for i := len(h)/2 - 1; i >= 0; i-- { bm25SiftDown(h, i) } } // bm25SiftDown restores the min-heap property starting at node i: O(log k). func bm25SiftDown(h []bm25ScoredDoc, i int) { n := len(h) for { smallest := i l, r := 2*i+1, 2*i+2 if l < n && h[l].score < h[smallest].score { smallest = l } if r < n && h[r].score < h[smallest].score { smallest = r } if smallest == i { break } h[i], h[smallest] = h[smallest], h[i] i = smallest } } ================================================ FILE: pkg/utils/bm25_test.go ================================================ package utils import ( "reflect" "testing" ) // testDoc is a generic structure for use in tests. type testDoc struct { ID int Text string } func extractText(d testDoc) string { return d.Text } func TestBM25Search_EdgeCases(t *testing.T) { corpus := []testDoc{ {1, "hello world"}, {2, "foo bar"}, } engine := NewBM25Engine(corpus, extractText) tests := []struct { name string query string topK int }{ {"Zero topK", "hello", 0}, {"Negative topK", "hello", -1}, {"Empty query", "", 5}, {"Query with only punctuation", "...,,,!!!", 5}, {"No matches found", "golang", 5}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { results := engine.Search(tt.query, tt.topK) if len(results) != 0 { t.Errorf("expected 0 results, got %d", len(results)) } // Check that it never returns nil, but an empty slice if results == nil { t.Errorf("expected empty slice, got nil") } }) } } func TestBM25Search_EmptyCorpus(t *testing.T) { engine := NewBM25Engine([]testDoc{}, extractText) results := engine.Search("hello", 5) if len(results) != 0 || results == nil { t.Errorf("expected empty slice from empty corpus, got %v", results) } } func TestBM25Search_RankingLogic(t *testing.T) { corpus := []testDoc{ {1, "the quick brown fox jumps over the lazy dog"}, {2, "quick fox"}, {3, "quick quick quick fox"}, // High Term Frequency (TF) {4, "completely irrelevant document here"}, } engine := NewBM25Engine(corpus, extractText) t.Run("Term Frequency (TF) boosts score", func(t *testing.T) { results := engine.Search("quick", 5) if len(results) < 3 { t.Fatalf("expected at least 3 results, got %d", len(results)) } // Doc 3 has the word "quick" repeated 3 times, it should beat Doc 2 if results[0].Document.ID != 3 { t.Errorf("expected doc 3 to rank first due to high TF, got doc %d", results[0].Document.ID) } }) t.Run("Document Length penalty", func(t *testing.T) { results := engine.Search("fox", 5) if len(results) < 3 { t.Fatalf("expected at least 3 results, got %d", len(results)) } // Doc 2 ("quick fox") is much shorter than Doc 1 ("the quick brown fox..."), // so, with equal Term Frequency for the word "fox" (1 time), Doc 2 wins. if results[0].Document.ID != 2 { t.Errorf("expected doc 2 to rank first due to shorter length, got doc %d", results[0].Document.ID) } }) t.Run("TopK limits results", func(t *testing.T) { results := engine.Search("quick", 2) if len(results) != 2 { t.Errorf("expected exactly 2 results, got %d", len(results)) } }) } func TestBM25Tokenize(t *testing.T) { tests := []struct { input string expected []string }{ {"Hello World", []string{"hello", "world"}}, {" spaces everywhere ", []string{"spaces", "everywhere"}}, {"punctuation... test!!!", []string{"punctuation", "test"}}, {"(parentheses) and-hyphens", []string{"parentheses", "and-hyphens"}}, // hyphens trimmed from edges {"internal-hyphen is kept", []string{"internal-hyphen", "is", "kept"}}, {".,;?!", []string{}}, // Becomes empty after trim } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { got := bm25Tokenize(tt.input) if len(got) == 0 && len(tt.expected) == 0 { return // Both empty } if !reflect.DeepEqual(got, tt.expected) { t.Errorf("bm25Tokenize(%q) = %v, want %v", tt.input, got, tt.expected) } }) } } func TestBM25Dedupe(t *testing.T) { input := []string{"apple", "banana", "apple", "orange", "banana"} expected := []string{"apple", "banana", "orange"} got := bm25Dedupe(input) if !reflect.DeepEqual(got, expected) { t.Errorf("bm25Dedupe() = %v, want %v", got, expected) } } func TestBM25Options(t *testing.T) { corpus := []testDoc{{1, "test"}} engine := NewBM25Engine( corpus, extractText, WithK1(2.5), WithB(0.9), ) if engine.k1 != 2.5 { t.Errorf("expected k1 to be 2.5, got %v", engine.k1) } if engine.b != 0.9 { t.Errorf("expected b to be 0.9, got %v", engine.b) } } func TestBM25Search_SortingStability(t *testing.T) { // Ensure that sorting by heap returns in correct descending order corpus := []testDoc{ {1, "golang is good"}, {2, "golang golang"}, {3, "golang golang golang"}, {4, "golang golang golang golang"}, } engine := NewBM25Engine(corpus, extractText) results := engine.Search("golang", 10) if len(results) != 4 { t.Fatalf("expected 4 results, got %d", len(results)) } // Score should be strictly decreasing for i := 1; i < len(results); i++ { if results[i].Score > results[i-1].Score { t.Errorf("results not sorted correctly: result %d score (%v) > result %d score (%v)", i, results[i].Score, i-1, results[i-1].Score) } } } ================================================ FILE: pkg/utils/download.go ================================================ package utils import ( "context" "fmt" "io" "net/http" "os" "github.com/sipeed/picoclaw/pkg/logger" ) // DownloadToFile streams an HTTP response body to a temporary file in small // chunks (~32KB), keeping peak memory usage constant regardless of file size. // // Parameters: // - ctx: context for cancellation/timeout // - client: HTTP client to use (caller controls timeouts, transport, etc.) // - req: fully prepared *http.Request (method, URL, headers, etc.) // - maxBytes: maximum bytes to download; 0 means no limit // // Returns the path to the temporary file. The caller is responsible for // removing it when done (defer os.Remove(path)). // // On any error the temp file is cleaned up automatically. func DownloadToFile(ctx context.Context, client *http.Client, req *http.Request, maxBytes int64) (string, error) { // Attach context. req = req.WithContext(ctx) logger.DebugCF("download", "Starting download", map[string]any{ "url": req.URL.String(), "max_bytes": maxBytes, }) resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { // Read a small amount for the error message. errBody := make([]byte, 512) n, _ := io.ReadFull(resp.Body, errBody) return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(errBody[:n])) } // Create temp file. tmpFile, err := os.CreateTemp("", "picoclaw-dl-*") if err != nil { return "", fmt.Errorf("failed to create temp file: %w", err) } tmpPath := tmpFile.Name() logger.DebugCF("download", "Streaming to temp file", map[string]any{ "path": tmpPath, }) // Cleanup helper — removes the temp file on any error. cleanup := func() { _ = tmpFile.Close() _ = os.Remove(tmpPath) } // Optionally limit the download size. var src io.Reader = resp.Body if maxBytes > 0 { src = io.LimitReader(resp.Body, maxBytes+1) // +1 to detect overflow } written, err := io.Copy(tmpFile, src) if err != nil { cleanup() return "", fmt.Errorf("download write failed: %w", err) } if maxBytes > 0 && written > maxBytes { cleanup() return "", fmt.Errorf("download too large: %d bytes (max %d)", written, maxBytes) } if err := tmpFile.Close(); err != nil { _ = os.Remove(tmpPath) return "", fmt.Errorf("failed to close temp file: %w", err) } logger.DebugCF("download", "Download complete", map[string]any{ "path": tmpPath, "bytes_written": written, }) return tmpPath, nil } ================================================ FILE: pkg/utils/http_client.go ================================================ package utils import ( "fmt" "net/http" "net/url" "strings" "time" ) // CreateHTTPClient creates an HTTP client with optional proxy support. // If proxyURL is empty, it uses the system environment proxy settings. // Supported proxy schemes: http, https, socks5, socks5h. func CreateHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) { client := &http.Client{ Timeout: timeout, Transport: &http.Transport{ MaxIdleConns: 10, IdleConnTimeout: 30 * time.Second, DisableCompression: false, TLSHandshakeTimeout: 15 * time.Second, }, } if proxyURL != "" { proxy, err := url.Parse(proxyURL) if err != nil { return nil, fmt.Errorf("invalid proxy URL: %w", err) } scheme := strings.ToLower(proxy.Scheme) switch scheme { case "http", "https", "socks5", "socks5h": default: return nil, fmt.Errorf( "unsupported proxy scheme %q (supported: http, https, socks5, socks5h)", proxy.Scheme, ) } if proxy.Host == "" { return nil, fmt.Errorf("invalid proxy URL: missing host") } client.Transport.(*http.Transport).Proxy = http.ProxyURL(proxy) } else { client.Transport.(*http.Transport).Proxy = http.ProxyFromEnvironment } return client, nil } ================================================ FILE: pkg/utils/http_client_test.go ================================================ package utils import ( "net/http" "strings" "testing" "time" ) func TestCreateHTTPClient_ProxyConfigured(t *testing.T) { client, err := CreateHTTPClient("http://127.0.0.1:7890", 12*time.Second) if err != nil { t.Fatalf("createHTTPClient() error: %v", err) } if client.Timeout != 12*time.Second { t.Fatalf("client.Timeout = %v, want %v", client.Timeout, 12*time.Second) } tr, ok := client.Transport.(*http.Transport) if !ok { t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport) } if tr.Proxy == nil { t.Fatal("transport.Proxy is nil, want non-nil") } req, err := http.NewRequest("GET", "https://example.com", nil) if err != nil { t.Fatalf("http.NewRequest() error: %v", err) } proxyURL, err := tr.Proxy(req) if err != nil { t.Fatalf("transport.Proxy(req) error: %v", err) } if proxyURL == nil || proxyURL.String() != "http://127.0.0.1:7890" { t.Fatalf("proxy URL = %v, want %q", proxyURL, "http://127.0.0.1:7890") } } func TestCreateHTTPClient_InvalidProxy(t *testing.T) { _, err := CreateHTTPClient("://bad-proxy", 10*time.Second) if err == nil { t.Fatal("createHTTPClient() expected error for invalid proxy URL, got nil") } } func TestCreateHTTPClient_Socks5ProxyConfigured(t *testing.T) { client, err := CreateHTTPClient("socks5://127.0.0.1:1080", 8*time.Second) if err != nil { t.Fatalf("createHTTPClient() error: %v", err) } tr, ok := client.Transport.(*http.Transport) if !ok { t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport) } req, err := http.NewRequest("GET", "https://example.com", nil) if err != nil { t.Fatalf("http.NewRequest() error: %v", err) } proxyURL, err := tr.Proxy(req) if err != nil { t.Fatalf("transport.Proxy(req) error: %v", err) } if proxyURL == nil || proxyURL.String() != "socks5://127.0.0.1:1080" { t.Fatalf("proxy URL = %v, want %q", proxyURL, "socks5://127.0.0.1:1080") } } func TestCreateHTTPClient_UnsupportedProxyScheme(t *testing.T) { _, err := CreateHTTPClient("ftp://127.0.0.1:21", 10*time.Second) if err == nil { t.Fatal("createHTTPClient() expected error for unsupported scheme, got nil") } if !strings.Contains(err.Error(), "unsupported proxy scheme") { t.Fatalf("error = %q, want to contain %q", err.Error(), "unsupported proxy scheme") } } func TestCreateHTTPClient_ProxyFromEnvironmentWhenConfigEmpty(t *testing.T) { t.Setenv("HTTP_PROXY", "http://127.0.0.1:8888") t.Setenv("http_proxy", "http://127.0.0.1:8888") t.Setenv("HTTPS_PROXY", "http://127.0.0.1:8888") t.Setenv("https_proxy", "http://127.0.0.1:8888") t.Setenv("ALL_PROXY", "") t.Setenv("all_proxy", "") t.Setenv("NO_PROXY", "") t.Setenv("no_proxy", "") client, err := CreateHTTPClient("", 10*time.Second) if err != nil { t.Fatalf("createHTTPClient() error: %v", err) } tr, ok := client.Transport.(*http.Transport) if !ok { t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport) } if tr.Proxy == nil { t.Fatal("transport.Proxy is nil, want proxy function from environment") } req, err := http.NewRequest("GET", "https://example.com", nil) if err != nil { t.Fatalf("http.NewRequest() error: %v", err) } if _, err := tr.Proxy(req); err != nil { t.Fatalf("transport.Proxy(req) error: %v", err) } } ================================================ FILE: pkg/utils/http_retry.go ================================================ package utils import ( "context" "fmt" "net/http" "time" ) const maxRetries = 3 var retryDelayUnit = time.Second func shouldRetry(statusCode int) bool { return statusCode == http.StatusTooManyRequests || statusCode >= 500 } func DoRequestWithRetry(client *http.Client, req *http.Request) (*http.Response, error) { var resp *http.Response var err error for i := range maxRetries { if i > 0 && resp != nil { resp.Body.Close() } resp, err = client.Do(req) if err == nil { if resp.StatusCode == http.StatusOK { break } if !shouldRetry(resp.StatusCode) { break } } if i < maxRetries-1 { if err = sleepWithCtx(req.Context(), retryDelayUnit*time.Duration(i+1)); err != nil { if resp != nil { resp.Body.Close() } return nil, fmt.Errorf("failed to sleep: %w", err) } } } return resp, err } func sleepWithCtx(ctx context.Context, d time.Duration) error { timer := time.NewTimer(d) defer timer.Stop() select { case <-ctx.Done(): return ctx.Err() case <-timer.C: return nil } } ================================================ FILE: pkg/utils/http_retry_test.go ================================================ package utils import ( "context" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestDoRequestWithRetry(t *testing.T) { retryDelayUnit = time.Millisecond t.Cleanup(func() { retryDelayUnit = time.Second }) testcases := []struct { name string serverBehavior func(*httptest.Server) int wantSuccess bool wantAttempts int }{ { name: "success-on-first-attempt", serverBehavior: func(server *httptest.Server) int { return 0 }, wantSuccess: true, wantAttempts: 1, }, { name: "fail-all-attempts", serverBehavior: func(server *httptest.Server) int { return 4 }, wantSuccess: false, wantAttempts: 3, }, } for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { attempts := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts++ if attempts <= tc.serverBehavior(nil) { w.WriteHeader(http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) w.Write([]byte("success")) })) t.Cleanup(func() { server.Close() }) client := &http.Client{Timeout: 5 * time.Second} req, err := http.NewRequest(http.MethodGet, server.URL, nil) require.NoError(t, err) resp, err := DoRequestWithRetry(client, req) if tc.wantSuccess { require.NoError(t, err) require.NotNil(t, resp) assert.Equal(t, http.StatusOK, resp.StatusCode) resp.Body.Close() } else { require.NotNil(t, resp) assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) resp.Body.Close() } assert.Equal(t, tc.wantAttempts, attempts) }) } } func TestDoRequestWithRetry_ContextCancel(t *testing.T) { // Use a long retry delay so cancellation always hits during sleepWithCtx. retryDelayUnit = 10 * time.Second t.Cleanup(func() { retryDelayUnit = time.Second }) bodyClosed := false firstRoundTripDone := make(chan struct{}, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("error")) })) defer server.Close() client := server.Client() client.Timeout = 30 * time.Second client.Transport = &bodyCloseTracker{ rt: client.Transport, onClose: func() { bodyClosed = true }, // Signal after the first round-trip response is fully constructed on the client side. onRoundTrip: func() { select { case firstRoundTripDone <- struct{}{}: default: } }, trackURL: server.URL, } ctx, cancel := context.WithCancel(context.Background()) defer cancel() // Cancel the context after the first round-trip completes on the client side. // This ensures client.Do has returned a valid resp (with body) and the retry // loop is about to enter sleepWithCtx, where the cancel will be detected. go func() { <-firstRoundTripDone cancel() }() req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil) require.NoError(t, err) resp, err := DoRequestWithRetry(client, req) if resp != nil { resp.Body.Close() } require.Error(t, err, "expected error from context cancellation") assert.Nil(t, resp, "expected nil response when context is canceled") assert.True(t, bodyClosed, "expected resp.Body to be closed on context cancellation") } // bodyCloseTracker wraps an http.RoundTripper and records when response bodies are closed. type bodyCloseTracker struct { rt http.RoundTripper onClose func() onRoundTrip func() // called after each successful round-trip trackURL string } func (t *bodyCloseTracker) RoundTrip(req *http.Request) (*http.Response, error) { resp, err := t.rt.RoundTrip(req) if err != nil { return resp, err } if strings.HasPrefix(req.URL.String(), t.trackURL) { resp.Body = &closeNotifier{ReadCloser: resp.Body, onClose: t.onClose} if t.onRoundTrip != nil { t.onRoundTrip() } } return resp, nil } // closeNotifier wraps an io.ReadCloser to detect Close calls. type closeNotifier struct { io.ReadCloser onClose func() } func (c *closeNotifier) Close() error { c.onClose() return c.ReadCloser.Close() } func TestDoRequestWithRetry_Delay(t *testing.T) { retryDelayUnit = time.Millisecond t.Cleanup(func() { retryDelayUnit = time.Second }) var start time.Time delays := []time.Duration{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if len(delays) == 0 { delays = append(delays, 0) w.WriteHeader(http.StatusInternalServerError) return } if len(delays) == 1 { start = time.Now() delays = append(delays, 0) w.WriteHeader(http.StatusInternalServerError) return } if len(delays) == 2 { elapsed := time.Since(start) delays = append(delays, elapsed) w.WriteHeader(http.StatusOK) w.Write([]byte("success")) } })) defer server.Close() client := &http.Client{Timeout: 10 * time.Second} req, err := http.NewRequest(http.MethodGet, server.URL, nil) require.NoError(t, err) resp, err := DoRequestWithRetry(client, req) require.NoError(t, err) require.NotNil(t, resp) assert.Equal(t, http.StatusOK, resp.StatusCode) resp.Body.Close() assert.GreaterOrEqual(t, delays[2], time.Millisecond) } ================================================ FILE: pkg/utils/markdown.go ================================================ package utils import ( "bytes" "net/url" "regexp" "strconv" "strings" "golang.org/x/net/html" ) var ( reSpaces = regexp.MustCompile(`[ \t]+`) reNewlines = regexp.MustCompile(`\n{3,}`) reEmptyListItem = regexp.MustCompile(`(?m)^[-*]\s*$`) reImageOnlyLink = regexp.MustCompile(`\[!\[\]\(<[^>]*>\)\]\(<[^>]*>\)`) reEmptyHeader = regexp.MustCompile(`(?m)^#{1,6}\s*$`) reLeadingLineSpace = regexp.MustCompile(`(?m)^([ \t])([^ \t\n])`) ) var skipTags = map[string]bool{ "script": true, "style": true, "head": true, "noscript": true, "template": true, "nav": true, "footer": true, "aside": true, "header": true, "form": true, "dialog": true, } func isSafeHref(href string) bool { lower := strings.ToLower(strings.TrimSpace(href)) if strings.HasPrefix(lower, "javascript:") || strings.HasPrefix(lower, "vbscript:") || strings.HasPrefix(lower, "data:") { return false } u, err := url.Parse(strings.TrimSpace(href)) if err != nil { return false } scheme := strings.ToLower(u.Scheme) return scheme == "" || scheme == "http" || scheme == "https" || scheme == "mailto" } func isSafeImageSrc(src string) bool { lower := strings.ToLower(strings.TrimSpace(src)) if strings.HasPrefix(lower, "data:image/") { return true } return isSafeHref(src) } func escapeMdAlt(s string) string { s = strings.ReplaceAll(s, `\`, `\\`) s = strings.ReplaceAll(s, `[`, `\[`) s = strings.ReplaceAll(s, `]`, `\]`) return s } func getAttr(n *html.Node, key string) string { for _, a := range n.Attr { if a.Key == key { return a.Val } } return "" } func normalizeAttr(val string) string { val = strings.ReplaceAll(val, "\n", "") val = strings.ReplaceAll(val, "\r", "") val = strings.ReplaceAll(val, "\t", "") return strings.TrimSpace(val) } func isUnlikelyNode(n *html.Node) bool { if n.Type != html.ElementNode { return false } classId := strings.ToLower(getAttr(n, "class") + " " + getAttr(n, "id")) if classId == " " { return false } if strings.Contains(classId, "article") || strings.Contains(classId, "main") || strings.Contains(classId, "content") { return false } unlikelyKeywords := []string{ "menu", "nav", "footer", "sidebar", "cookie", "banner", "sponsor", "advert", "popup", "modal", "newsletter", "share", "social", } for _, keyword := range unlikelyKeywords { if strings.Contains(classId, keyword) { return true } } return false } type converter struct { stack []*bytes.Buffer linkHrefs []string linkStates []bool emphStack []string // Tracks "**", "*", "~~" for buffered emphasis olCounters []int inPre bool listDepth int } func newConverter() *converter { return &converter{ stack: []*bytes.Buffer{{}}, } } func (c *converter) write(s string) { c.stack[len(c.stack)-1].WriteString(s) } func (c *converter) pushBuf() { c.stack = append(c.stack, &bytes.Buffer{}) } func (c *converter) popBuf() string { top := c.stack[len(c.stack)-1] c.stack = c.stack[:len(c.stack)-1] return top.String() } func (c *converter) walk(n *html.Node) { if n.Type == html.ElementNode { if skipTags[n.Data] { return } if isUnlikelyNode(n) { return } } if n.Type == html.TextNode { text := n.Data if !c.inPre { text = strings.ReplaceAll(text, "\n", " ") text = reSpaces.ReplaceAllString(text, " ") } if text != "" { c.write(text) } return } if n.Type != html.ElementNode { for ch := n.FirstChild; ch != nil; ch = ch.NextSibling { c.walk(ch) } return } // Opening Tags switch n.Data { // Buffer emphasis content so we can TrimSpace the inner text, // avoiding the regex-across-boundaries bug. case "b", "strong": c.emphStack = append(c.emphStack, "**") c.pushBuf() case "i", "em": c.emphStack = append(c.emphStack, "*") c.pushBuf() case "del", "s": c.emphStack = append(c.emphStack, "~~") c.pushBuf() case "a": href := normalizeAttr(getAttr(n, "href")) if href != "" && !isSafeHref(href) { href = "#" } hasHref := href != "" c.linkStates = append(c.linkStates, hasHref) if hasHref { c.linkHrefs = append(c.linkHrefs, href) c.pushBuf() } case "h1": c.write("\n\n# ") case "h2": c.write("\n\n## ") case "h3": c.write("\n\n### ") case "h4": c.write("\n\n#### ") case "h5": c.write("\n\n##### ") case "h6": c.write("\n\n###### ") case "p": c.write("\n\n") case "br": c.write("\n") case "hr": c.write("\n\n---\n\n") case "ol": c.olCounters = append(c.olCounters, 1) // Only write leading newline for top-level list. if c.listDepth == 0 { c.write("\n") } c.listDepth++ case "ul": if c.listDepth == 0 { c.write("\n") } c.listDepth++ case "li": c.write("\n") if c.listDepth > 1 { c.write(strings.Repeat(" ", c.listDepth-1)) } if n.Parent != nil && n.Parent.Data == "ol" && len(c.olCounters) > 0 { idx := c.olCounters[len(c.olCounters)-1] c.write(strconv.Itoa(idx) + ". ") c.olCounters[len(c.olCounters)-1]++ } else { c.write("- ") } case "pre": c.inPre = true c.write("\n\n```\n") case "code": if !c.inPre { c.write("`") } case "blockquote": c.pushBuf() for ch := n.FirstChild; ch != nil; ch = ch.NextSibling { c.walk(ch) } inner := strings.TrimSpace(c.popBuf()) lines := strings.Split(inner, "\n") var quoted []string for _, l := range lines { if strings.TrimSpace(l) == "" { quoted = append(quoted, ">") } else { quoted = append(quoted, "> "+l) } } var deduped []string for i, line := range quoted { if line == ">" && i > 0 && deduped[len(deduped)-1] == ">" { continue } deduped = append(deduped, line) } c.write("\n\n" + strings.Join(deduped, "\n") + "\n\n") return case "img": src := normalizeAttr(getAttr(n, "src")) if src == "" { src = normalizeAttr(getAttr(n, "data-src")) } if src == "" { return } alt := escapeMdAlt(normalizeAttr(getAttr(n, "alt"))) if isSafeImageSrc(src) { c.write("![" + alt + "](" + src + ")") } return } // Traverse Children for ch := n.FirstChild; ch != nil; ch = ch.NextSibling { c.walk(ch) } // Closing Tags switch n.Data { // Pop buffer, trim, wrap with the correct marker. case "b", "strong", "i", "em", "del", "s": if len(c.emphStack) == 0 { break } marker := c.emphStack[len(c.emphStack)-1] c.emphStack = c.emphStack[:len(c.emphStack)-1] inner := strings.TrimSpace(c.popBuf()) if inner != "" { c.write(marker + inner + marker) } case "a": if len(c.linkStates) == 0 { break } hasHref := c.linkStates[len(c.linkStates)-1] c.linkStates = c.linkStates[:len(c.linkStates)-1] if !hasHref { break } href := c.linkHrefs[len(c.linkHrefs)-1] c.linkHrefs = c.linkHrefs[:len(c.linkHrefs)-1] inner := strings.TrimSpace(c.popBuf()) if strings.Contains(inner, "\n") { lines := strings.Split(inner, "\n") linked := false for i, l := range lines { cleanLine := strings.TrimSpace(l) if cleanLine != "" && !strings.HasPrefix(cleanLine, "![") && !linked { lines[i] = "[" + cleanLine + "](" + href + ")" linked = true } } c.write(strings.Join(lines, "\n")) } else { c.write("[" + inner + "](" + href + ")") } case "h1", "h2", "h3", "h4", "h5", "h6", "p", "div", "section", "article", "header", "footer", "aside", "nav", "figure": c.write("\n") case "ol": c.listDepth-- if len(c.olCounters) > 0 { c.olCounters = c.olCounters[:len(c.olCounters)-1] } if c.listDepth == 0 { c.write("\n") } case "ul": c.listDepth-- if c.listDepth == 0 { c.write("\n") } case "pre": c.inPre = false c.write("\n```\n\n") case "code": if !c.inPre { c.write("`") } } } func HtmlToMarkdown(htmlStr string) (string, error) { doc, err := html.Parse(strings.NewReader(htmlStr)) if err != nil { return "", err } c := newConverter() c.walk(doc) res := c.stack[0].String() // Post-processing res = reImageOnlyLink.ReplaceAllString(res, "") res = reEmptyListItem.ReplaceAllString(res, "") res = reEmptyHeader.ReplaceAllString(res, "") lines := strings.Split(res, "\n") var cleanLines []string for _, line := range lines { line = strings.TrimRight(line, " \t") cleanTest := strings.TrimSpace(line) if cleanTest == "[](</>)" || cleanTest == "[](#)" || cleanTest == "-" { cleanLines = append(cleanLines, "") continue } cleanLines = append(cleanLines, line) } res = strings.Join(cleanLines, "\n") res = strings.TrimSpace(res) res = reNewlines.ReplaceAllString(res, "\n\n") // Strip a single leading space from lines that are NOT list indentation. // "(?m)^([ \t])([^ \t\n])" matches exactly one space/tab at line start followed // by a non-whitespace char, so " - nested" (4 spaces) is left untouched. res = reLeadingLineSpace.ReplaceAllString(res, "$2") return res, nil } ================================================ FILE: pkg/utils/markdown_test.go ================================================ package utils import ( "testing" "github.com/sipeed/picoclaw/pkg/logger" ) func TestHtmlToMarkdown(t *testing.T) { // Define our test cases tests := []struct { name string input string expected string }{ { name: "Removes scripts and styles", input: `<script>alert("hello");</script><style>body { color: red; }</style><p>Clean text</p>`, expected: "Clean text", }, { name: "Extracts links correctly", input: `Visit my <a href="https://example.com">website</a> for info.`, expected: "Visit my [website](https://example.com) for info.", }, { name: "Converts headers (H1, H2, H3)", input: `<h1>Main Title</h1><h2>Subtitle</h2><h3>Section</h3>`, expected: "# Main Title\n\n## Subtitle\n\n### Section", }, { name: "Handles bold and italics", input: `Text <b>bold</b> and <strong>strong</strong>, then <i>italic</i> and <em>em</em>.`, expected: "Text **bold** and **strong**, then *italic* and *em*.", }, { name: "Converts lists", input: `<ul><li>First element</li><li>Second element</li></ul>`, expected: "- First element\n- Second element", }, { name: "Handles paragraphs and line breaks (<br>)", input: `<p>First paragraph</p><p>Second paragraph with<br>a line break.</p>`, expected: "First paragraph\n\nSecond paragraph with\na line break.", }, { name: "Decodes HTML entities", input: `Math: 5 > 3 & 2 < 4. A "quote".`, expected: "Math: 5 > 3 & 2 < 4. A \"quote\".", }, { name: "Cleans up residual HTML tags", input: `<div><span>Text inside div and span</span></div>`, expected: "Text inside div and span", }, { name: "Removes multiple spaces and excessive empty lines", input: `This text has too many spaces. <br><br><br><br> And too many newlines.`, expected: "This text has too many spaces.\n\nAnd too many newlines.", }, { name: "Nested lists with indentation", input: "<ul><li>One<ul><li>Two</li></ul></li></ul>", // Expect the sub-element to have 4 spaces of indentation expected: "- One\n - Two", }, { name: "Image support", input: `<img src="image.jpg" alt="alternative text">`, // Correct Markdown syntax for images expected: "![alternative text](image.jpg)", }, { name: "Image support without alt-text", input: `<img src="image.jpg">`, // If alt is missing, square brackets remain empty expected: "![](image.jpg)", }, { name: "XSS Bypass on Links (Obfuscated HTML entities)", // The Go HTML parser resolves entities, so this becomes "javascript:alert(1)" input: `<a href="jav ascript:alert(1)">Click here</a>`, // Our isSafeHref (if updated with net/url) should neutralize it to "#" expected: "[Click here](#)", }, { name: "Empty link or used as anchor", input: `<a name="top"></a>`, // With no text or href, it shouldn't print anything (not even empty brackets) expected: "", }, { name: "Link without href but with text (Textual anchor)", input: `<a id="top">Back to top</a>`, // Should extract only plain text, without generating a broken Markdown link like [Back to top](#) or [Back to top]() expected: "Back to top", }, { name: "Badly spaced bold and italics (Edge Case)", input: `<b> Text </b>`, // In Markdown `** Text **` is often not formatted correctly. The ideal is `**Text**` expected: "**Text**", }, { name: "Complex Test - Real Article", input: ` <h1>Article Title</h1> <p>This is an <strong>introductory text</strong> with a <a href="http://link.com">link</a>.</p> <h2>Subtitle</h2> <ul> <li>Point one</li> <li>Point two</li> </ul> <script>console.log("do not show me")</script> `, // Note: The indentation of the real HTML test will generate spaces that // regex will clean up. expected: "# Article Title\n\nThis is an **introductory text** with a [link](http://link.com).\n\n## Subtitle\n\n- Point one\n- Point two", }, { name: "Ordered list (OL)", input: `<ol><li>First</li><li>Second</li><li>Third</li></ol>`, expected: "1. First\n2. Second\n3. Third", }, { name: "Ordered list nested in unordered list", input: `<ul><li>Fruits<ol><li>Apples</li><li>Pears</li></ol></li><li>Vegetables</li></ul>`, expected: "- Fruits\n 1. Apples\n 2. Pears\n- Vegetables", }, { name: "Code block (pre/code)", input: "<pre><code>func main() {\n fmt.Println(\"hello\")\n}</code></pre>", expected: "```\nfunc main() {\n fmt.Println(\"hello\")\n}\n```", }, { name: "Inline code", input: `<p>Use the command <code>go test ./...</code> to run the tests.</p>`, expected: "Use the command `go test ./...` to run the tests.", }, { name: "Simple blockquote", input: `<blockquote><p>An important quote.</p></blockquote>`, expected: "> An important quote.", }, { name: "Multiline blockquote", input: `<blockquote><p>First line of the quote.</p><p>Second line of the quote.</p></blockquote>`, expected: "> First line of the quote.\n>\n> Second line of the quote.", }, { name: "Strikethrough text (del/s)", input: `This text is <del>deleted</del> and this is <s>crossed out</s>.`, expected: "This text is ~~deleted~~ and this is ~~crossed out~~.", }, { name: "Horizontal separator (HR)", input: `<p>Above the line</p><hr><p>Below the line</p>`, expected: "Above the line\n\n---\n\nBelow the line", }, { name: "Bold nested in link", input: `<a href="https://example.com"><strong>Linked bold text</strong></a>`, expected: "[**Linked bold text**](https://example.com)", }, { name: "data-src Image (lazy loading)", input: `<img data-src="lazy.jpg" alt="Lazy image">`, expected: "![Lazy image](lazy.jpg)", }, { name: "Image with javascript: src blocked", input: `<img src="javascript:alert(1)" alt="XSS">`, // src is not safe, so the image is not emitted expected: "", }, { name: "Link with data: href blocked", input: `<a href="data:text/html,<script>alert(1)</script>">Click</a>`, expected: "[Click](#)", }, { name: "Deeply nested divs", input: `<div><div><div><div><p>Deeply nested text</p></div></div></div></div>`, expected: "Deeply nested text", }, { name: "Non-consecutive headers (H1, H3, H5)", input: `<h1>Title</h1><h3>Subsection</h3><h5>Sub-subsection</h5>`, expected: "# Title\n\n### Subsection\n\n##### Sub-subsection", }, { name: "Paragraph with mixed multiple emphasis", input: `<p><strong>Important:</strong> read the <strong><em>critical instructions</em></strong> <em>carefully</em>.</p>`, expected: "**Important:** read the ***critical instructions*** *carefully*.", }, { name: "Article with nav and aside sections (noise to filter)", input: ` <nav><a href="/home">Home</a><a href="/about-us">About us</a></nav> <article> <h2>Article title</h2> <p>This is the body of the article.</p> </article> <aside><p>Advertisement</p></aside> `, expected: "## Article title\n\nThis is the body of the article.", }, { name: "Text with mixed special HTML entities", input: `Copyright © 2024 — All rights reserved ®`, expected: "Copyright © 2024 — All rights reserved ®", }, { name: "Mailto link", input: `Write to us at <a href="mailto:info@example.com">info@example.com</a>`, expected: "Write to us at [info@example.com](mailto:info@example.com)", }, { name: "Image inside a link (clickable figure)", input: `<a href="https://example.com"><img src="photo.jpg" alt="Photo"></a>`, // The image-link without text must not generate broken markup expected: "[![Photo](photo.jpg)](https://example.com)", }, { name: "Empty content or only whitespace", input: ` <p> </p> <div> </div> `, expected: "", }, } // Iterate over all test cases for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := HtmlToMarkdown(tt.input) if err != nil { logger.ErrorCF("tool", "Failed to parse html to markdown: %s", map[string]any{"error": err.Error()}) } if got != tt.expected { t.Errorf("\nTest case failed: %s\nInput: %q\nGot: %q\nExpected: %q", tt.name, tt.input, got, tt.expected) } }) } } ================================================ FILE: pkg/utils/media.go ================================================ package utils import ( "io" "net/http" "net/url" "os" "path/filepath" "strings" "time" "github.com/google/uuid" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" ) // IsAudioFile checks if a file is an audio file based on its filename extension and content type. func IsAudioFile(filename, contentType string) bool { audioExtensions := []string{".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma"} audioTypes := []string{"audio/", "application/ogg", "application/x-ogg"} for _, ext := range audioExtensions { if strings.HasSuffix(strings.ToLower(filename), ext) { return true } } for _, audioType := range audioTypes { if strings.HasPrefix(strings.ToLower(contentType), audioType) { return true } } return false } // SanitizeFilename removes potentially dangerous characters from a filename // and returns a safe version for local filesystem storage. func SanitizeFilename(filename string) string { // Get the base filename without path base := filepath.Base(filename) // Remove any directory traversal attempts base = strings.ReplaceAll(base, "..", "") base = strings.ReplaceAll(base, "/", "_") base = strings.ReplaceAll(base, "\\", "_") return base } // DownloadOptions holds optional parameters for downloading files type DownloadOptions struct { Timeout time.Duration ExtraHeaders map[string]string LoggerPrefix string ProxyURL string } // DownloadFile downloads a file from URL to a local temp directory. // Returns the local file path or empty string on error. func DownloadFile(urlStr, filename string, opts DownloadOptions) string { // Set defaults if opts.Timeout == 0 { opts.Timeout = 60 * time.Second } if opts.LoggerPrefix == "" { opts.LoggerPrefix = "utils" } mediaDir := media.TempDir() if err := os.MkdirAll(mediaDir, 0o700); err != nil { logger.ErrorCF(opts.LoggerPrefix, "Failed to create media directory", map[string]any{ "error": err.Error(), }) return "" } // Generate unique filename with UUID prefix to prevent conflicts safeName := SanitizeFilename(filename) localPath := filepath.Join(mediaDir, uuid.New().String()[:8]+"_"+safeName) // Create HTTP request req, err := http.NewRequest("GET", urlStr, nil) if err != nil { logger.ErrorCF(opts.LoggerPrefix, "Failed to create download request", map[string]any{ "error": err.Error(), }) return "" } // Add extra headers (e.g., Authorization for Slack) for key, value := range opts.ExtraHeaders { req.Header.Set(key, value) } client := &http.Client{Timeout: opts.Timeout} if opts.ProxyURL != "" { proxyURL, parseErr := url.Parse(opts.ProxyURL) if parseErr != nil { logger.ErrorCF(opts.LoggerPrefix, "Invalid proxy URL for download", map[string]any{ "error": parseErr.Error(), "proxy": opts.ProxyURL, }) return "" } client.Transport = &http.Transport{ Proxy: http.ProxyURL(proxyURL), } } resp, err := client.Do(req) if err != nil { logger.ErrorCF(opts.LoggerPrefix, "Failed to download file", map[string]any{ "error": err.Error(), "url": urlStr, }) return "" } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { logger.ErrorCF(opts.LoggerPrefix, "File download returned non-200 status", map[string]any{ "status": resp.StatusCode, "url": urlStr, }) return "" } out, err := os.Create(localPath) if err != nil { logger.ErrorCF(opts.LoggerPrefix, "Failed to create local file", map[string]any{ "error": err.Error(), }) return "" } defer out.Close() if _, err := io.Copy(out, resp.Body); err != nil { out.Close() os.Remove(localPath) logger.ErrorCF(opts.LoggerPrefix, "Failed to write file", map[string]any{ "error": err.Error(), }) return "" } logger.DebugCF(opts.LoggerPrefix, "File downloaded successfully", map[string]any{ "path": localPath, }) return localPath } // DownloadFileSimple is a simplified version of DownloadFile without options func DownloadFileSimple(url, filename string) string { return DownloadFile(url, filename, DownloadOptions{ LoggerPrefix: "media", }) } ================================================ FILE: pkg/utils/skills.go ================================================ package utils import ( "fmt" "strings" ) // ValidateSkillIdentifier validates that the given skill identifier (slug or registry name) is non-empty // and does not contain path separators ("/", "\\") or ".." for security. func ValidateSkillIdentifier(identifier string) error { trimmed := strings.TrimSpace(identifier) if trimmed == "" { return fmt.Errorf("identifier is required and must be a non-empty string") } if strings.ContainsAny(trimmed, "/\\") || strings.Contains(trimmed, "..") { return fmt.Errorf("identifier must not contain path separators or '..' to prevent directory traversal") } return nil } ================================================ FILE: pkg/utils/string.go ================================================ package utils import ( "strings" "sync/atomic" "unicode" ) // Global variable to disable truncation var disableTruncation atomic.Bool // SetDisableTruncation globally enables or disables string truncation func SetDisableTruncation(enabled bool) { disableTruncation.Store(enabled) } // SanitizeMessageContent removes Unicode control characters, format characters (RTL overrides, // zero-width characters), and other non-graphic characters that could confuse an LLM // or cause display issues in the agent UI. func SanitizeMessageContent(input string) string { var sb strings.Builder // Pre-allocate memory to avoid multiple allocations sb.Grow(len(input)) for _, r := range input { // unicode.IsGraphic returns true if the rune is a Unicode graphic character. // This includes letters, marks, numbers, punctuation, and symbols. // It excludes control characters (Cc), format characters (Cf), // surrogates (Cs), and private use (Co). if unicode.IsGraphic(r) || r == '\n' || r == '\r' || r == '\t' { sb.WriteRune(r) } } return sb.String() } // Truncate returns a truncated version of s with at most maxLen runes. // Handles multi-byte Unicode characters properly. // If the string is truncated, "..." is appended to indicate truncation. func Truncate(s string, maxLen int) string { // If the no-truncate flag is active, it returns the full string if disableTruncation.Load() { return s } if maxLen <= 0 { return "" } runes := []rune(s) if len(runes) <= maxLen { return s } // Reserve 3 chars for "..." if maxLen <= 3 { return string(runes[:maxLen]) } return string(runes[:maxLen-3]) + "..." } // DerefStr dereferences a pointer to a string and // returns the value or a fallback if the pointer is nil. func DerefStr(s *string, fallback string) string { if s == nil { return fallback } return *s } ================================================ FILE: pkg/utils/string_test.go ================================================ package utils import "testing" func TestTruncate(t *testing.T) { tests := []struct { name string input string maxLen int want string }{ { name: "short string unchanged", input: "hi", maxLen: 10, want: "hi", }, { name: "exact length unchanged", input: "hello", maxLen: 5, want: "hello", }, { name: "long string truncated with ellipsis", input: "hello world", maxLen: 8, want: "hello...", }, { name: "maxLen equals 4 leaves 1 char plus ellipsis", input: "abcdef", maxLen: 4, want: "a...", }, { name: "maxLen 3 returns first 3 chars without ellipsis", input: "abcdef", maxLen: 3, want: "abc", }, { name: "maxLen 2 returns first 2 chars", input: "abcdef", maxLen: 2, want: "ab", }, { name: "maxLen 1 returns first char", input: "abcdef", maxLen: 1, want: "a", }, { name: "maxLen 0 returns empty", input: "hello", maxLen: 0, want: "", }, { name: "negative maxLen returns empty", input: "hello", maxLen: -1, want: "", }, { name: "empty string unchanged", input: "", maxLen: 5, want: "", }, { name: "empty string with zero maxLen", input: "", maxLen: 0, want: "", }, { name: "unicode truncated correctly", input: "\U0001f600\U0001f601\U0001f602\U0001f603\U0001f604", maxLen: 4, want: "\U0001f600...", }, { name: "unicode short enough", input: "\u00e9\u00e8", maxLen: 5, want: "\u00e9\u00e8", }, { name: "mixed ascii and unicode", input: "Go\U0001f680\U0001f525\U0001f4a5\U0001f30d", maxLen: 5, want: "Go...", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := Truncate(tt.input, tt.maxLen) if got != tt.want { t.Errorf("Truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want) } }) } } func TestSanitizeMessageContent(t *testing.T) { tests := []struct { name string input string want string }{ {"empty", "", ""}, {"plain text unchanged", "Hello world", "Hello world"}, {"strip ZWSP", "Hello\u200bworld", "Helloworld"}, {"strip RTL override", "Hi\u202eevil", "Hievil"}, {"strip BOM", "\uFEFFcontent", "content"}, {"strip multiple", "a\u200c\u202ab\u202cc", "abc"}, {"unicode letters preserved", "café \u65e5\u672c\u8a9e", "café \u65e5\u672c\u8a9e"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := SanitizeMessageContent(tt.input) if got != tt.want { t.Errorf("SanitizeMessageContent(%q) = %q, want %q", tt.input, got, tt.want) } }) } } ================================================ FILE: pkg/utils/zip.go ================================================ package utils import ( "archive/zip" "fmt" "io" "os" "path/filepath" "strings" "github.com/sipeed/picoclaw/pkg/logger" ) // ExtractZipFile extracts a ZIP archive from disk to targetDir. // It reads entries one at a time from disk, keeping memory usage minimal. // // Security: rejects path traversal attempts and symlinks. func ExtractZipFile(zipPath string, targetDir string) error { reader, err := zip.OpenReader(zipPath) if err != nil { return fmt.Errorf("invalid ZIP: %w", err) } defer reader.Close() logger.DebugCF("zip", "Extracting ZIP", map[string]any{ "zip_path": zipPath, "target_dir": targetDir, "entries": len(reader.File), }) if err := os.MkdirAll(targetDir, 0o755); err != nil { return fmt.Errorf("failed to create target dir: %w", err) } for _, f := range reader.File { // Path traversal protection. cleanName := filepath.Clean(f.Name) if strings.HasPrefix(cleanName, "..") || filepath.IsAbs(cleanName) { return fmt.Errorf("zip entry has unsafe path: %q", f.Name) } destPath := filepath.Join(targetDir, cleanName) // Double-check the resolved path is within target directory (defense-in-depth). targetDirClean := filepath.Clean(targetDir) if !strings.HasPrefix(filepath.Clean(destPath), targetDirClean+string(filepath.Separator)) && filepath.Clean(destPath) != targetDirClean { return fmt.Errorf("zip entry escapes target dir: %q", f.Name) } mode := f.FileInfo().Mode() // Reject any symlink. if mode&os.ModeSymlink != 0 { return fmt.Errorf("zip contains symlink %q; symlinks are not allowed", f.Name) } if f.FileInfo().IsDir() { if err := os.MkdirAll(destPath, 0o755); err != nil { return err } continue } // Ensure parent directory exists. if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { return err } if err := extractSingleFile(f, destPath); err != nil { return err } } return nil } // extractSingleFile extracts one zip.File entry to destPath, with a size check. func extractSingleFile(f *zip.File, destPath string) error { const maxFileSize = 5 * 1024 * 1024 // 5MB, adjust as appropriate // Check the uncompressed size from the header, if available. if f.UncompressedSize64 > maxFileSize { return fmt.Errorf("zip entry %q is too large (%d bytes)", f.Name, f.UncompressedSize64) } rc, err := f.Open() if err != nil { return fmt.Errorf("failed to open zip entry %q: %w", f.Name, err) } defer rc.Close() outFile, err := os.Create(destPath) if err != nil { return fmt.Errorf("failed to create file %q: %w", destPath, err) } // We don't return the close error via return, since it's not a named error return. // Instead, we log to stderr and remove the partially written file as defensive cleanup. defer func() { if cerr := outFile.Close(); cerr != nil { _ = os.Remove(destPath) logger.ErrorCF("zip", "Failed to close file", map[string]any{ "dest_path": destPath, "error": cerr.Error(), }) } }() // Streamed size check: prevent overruns and malicious/corrupt headers. written, err := io.CopyN(outFile, rc, maxFileSize+1) if err != nil && err != io.EOF { _ = os.Remove(destPath) return fmt.Errorf("failed to extract %q: %w", f.Name, err) } if written > maxFileSize { _ = os.Remove(destPath) return fmt.Errorf("zip entry %q exceeds max size (%d bytes)", f.Name, written) } return nil } ================================================ FILE: pkg/voice/transcriber.go ================================================ package voice import ( "bytes" "context" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "os" "path/filepath" "strings" "time" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) type Transcriber interface { Name() string Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) } type GroqTranscriber struct { apiKey string apiBase string httpClient *http.Client } type TranscriptionResponse struct { Text string `json:"text"` Language string `json:"language,omitempty"` Duration float64 `json:"duration,omitempty"` } func NewGroqTranscriber(apiKey string) *GroqTranscriber { logger.DebugCF("voice", "Creating Groq transcriber", map[string]any{"has_api_key": apiKey != ""}) apiBase := "https://api.groq.com/openai/v1" return &GroqTranscriber{ apiKey: apiKey, apiBase: apiBase, httpClient: &http.Client{ Timeout: 60 * time.Second, }, } } func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) { logger.InfoCF("voice", "Starting transcription", map[string]any{"audio_file": audioFilePath}) audioFile, err := os.Open(audioFilePath) if err != nil { logger.ErrorCF("voice", "Failed to open audio file", map[string]any{"path": audioFilePath, "error": err}) return nil, fmt.Errorf("failed to open audio file: %w", err) } defer audioFile.Close() fileInfo, err := audioFile.Stat() if err != nil { logger.ErrorCF("voice", "Failed to get file info", map[string]any{"path": audioFilePath, "error": err}) return nil, fmt.Errorf("failed to get file info: %w", err) } logger.DebugCF("voice", "Audio file details", map[string]any{ "size_bytes": fileInfo.Size(), "file_name": filepath.Base(audioFilePath), }) var requestBody bytes.Buffer writer := multipart.NewWriter(&requestBody) part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath)) if err != nil { logger.ErrorCF("voice", "Failed to create form file", map[string]any{"error": err}) return nil, fmt.Errorf("failed to create form file: %w", err) } copied, err := io.Copy(part, audioFile) if err != nil { logger.ErrorCF("voice", "Failed to copy file content", map[string]any{"error": err}) return nil, fmt.Errorf("failed to copy file content: %w", err) } logger.DebugCF("voice", "File copied to request", map[string]any{"bytes_copied": copied}) if err = writer.WriteField("model", "whisper-large-v3"); err != nil { logger.ErrorCF("voice", "Failed to write model field", map[string]any{"error": err}) return nil, fmt.Errorf("failed to write model field: %w", err) } if err = writer.WriteField("response_format", "json"); err != nil { logger.ErrorCF("voice", "Failed to write response_format field", map[string]any{"error": err}) return nil, fmt.Errorf("failed to write response_format field: %w", err) } if err = writer.Close(); err != nil { logger.ErrorCF("voice", "Failed to close multipart writer", map[string]any{"error": err}) return nil, fmt.Errorf("failed to close multipart writer: %w", err) } url := t.apiBase + "/audio/transcriptions" req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody) if err != nil { logger.ErrorCF("voice", "Failed to create request", map[string]any{"error": err}) return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Authorization", "Bearer "+t.apiKey) logger.DebugCF("voice", "Sending transcription request to Groq API", map[string]any{ "url": url, "request_size_bytes": requestBody.Len(), "file_size_bytes": fileInfo.Size(), }) resp, err := t.httpClient.Do(req) if err != nil { logger.ErrorCF("voice", "Failed to send request", map[string]any{"error": err}) return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { logger.ErrorCF("voice", "Failed to read response", map[string]any{"error": err}) return nil, fmt.Errorf("failed to read response: %w", err) } if resp.StatusCode != http.StatusOK { logger.ErrorCF("voice", "API error", map[string]any{ "status_code": resp.StatusCode, "response": string(body), }) return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) } logger.DebugCF("voice", "Received response from Groq API", map[string]any{ "status_code": resp.StatusCode, "response_size_bytes": len(body), }) var result TranscriptionResponse if err := json.Unmarshal(body, &result); err != nil { logger.ErrorCF("voice", "Failed to unmarshal response", map[string]any{"error": err}) return nil, fmt.Errorf("failed to unmarshal response: %w", err) } logger.InfoCF("voice", "Transcription completed successfully", map[string]any{ "text_length": len(result.Text), "language": result.Language, "duration_seconds": result.Duration, "transcription_preview": utils.Truncate(result.Text, 50), }) return &result, nil } func (t *GroqTranscriber) Name() string { return "groq" } // DetectTranscriber inspects cfg and returns the appropriate Transcriber, or // nil if no supported transcription provider is configured. func DetectTranscriber(cfg *config.Config) Transcriber { // Direct Groq provider config takes priority. if key := cfg.Providers.Groq.APIKey; key != "" { return NewGroqTranscriber(key) } // Fall back to any model-list entry that uses the groq/ protocol. for _, mc := range cfg.ModelList { if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" { return NewGroqTranscriber(mc.APIKey) } } return nil } ================================================ FILE: pkg/voice/transcriber_test.go ================================================ package voice import ( "context" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "github.com/sipeed/picoclaw/pkg/config" ) // Ensure GroqTranscriber satisfies the Transcriber interface at compile time. var _ Transcriber = (*GroqTranscriber)(nil) func TestGroqTranscriberName(t *testing.T) { tr := NewGroqTranscriber("sk-test") if got := tr.Name(); got != "groq" { t.Errorf("Name() = %q, want %q", got, "groq") } } func TestDetectTranscriber(t *testing.T) { tests := []struct { name string cfg *config.Config wantNil bool wantName string }{ { name: "no config", cfg: &config.Config{}, wantNil: true, }, { name: "groq provider key", cfg: &config.Config{ Providers: config.ProvidersConfig{ Groq: config.ProviderConfig{APIKey: "sk-groq-direct"}, }, }, wantName: "groq", }, { name: "groq via model list", cfg: &config.Config{ ModelList: []config.ModelConfig{ {Model: "openai/gpt-4o", APIKey: "sk-openai"}, {Model: "groq/llama-3.3-70b", APIKey: "sk-groq-model"}, }, }, wantName: "groq", }, { name: "groq model list entry without key is skipped", cfg: &config.Config{ ModelList: []config.ModelConfig{ {Model: "groq/llama-3.3-70b", APIKey: ""}, }, }, wantNil: true, }, { name: "provider key takes priority over model list", cfg: &config.Config{ Providers: config.ProvidersConfig{ Groq: config.ProviderConfig{APIKey: "sk-groq-direct"}, }, ModelList: []config.ModelConfig{ {Model: "groq/llama-3.3-70b", APIKey: "sk-groq-model"}, }, }, wantName: "groq", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tr := DetectTranscriber(tc.cfg) if tc.wantNil { if tr != nil { t.Errorf("DetectTranscriber() = %v, want nil", tr) } return } if tr == nil { t.Fatal("DetectTranscriber() = nil, want non-nil") } if got := tr.Name(); got != tc.wantName { t.Errorf("Name() = %q, want %q", got, tc.wantName) } }) } } func TestTranscribe(t *testing.T) { // Write a minimal fake audio file so the transcriber can open and send it. tmpDir := t.TempDir() audioPath := filepath.Join(tmpDir, "clip.ogg") if err := os.WriteFile(audioPath, []byte("fake-audio-data"), 0o644); err != nil { t.Fatalf("failed to write fake audio file: %v", err) } t.Run("success", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/audio/transcriptions" { t.Errorf("unexpected path: %s", r.URL.Path) } if r.Header.Get("Authorization") != "Bearer sk-test" { t.Errorf("unexpected Authorization header: %s", r.Header.Get("Authorization")) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(TranscriptionResponse{ Text: "hello world", Language: "en", Duration: 1.5, }) })) defer srv.Close() tr := NewGroqTranscriber("sk-test") tr.apiBase = srv.URL resp, err := tr.Transcribe(context.Background(), audioPath) if err != nil { t.Fatalf("Transcribe() error: %v", err) } if resp.Text != "hello world" { t.Errorf("Text = %q, want %q", resp.Text, "hello world") } if resp.Language != "en" { t.Errorf("Language = %q, want %q", resp.Language, "en") } }) t.Run("api error", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"invalid_api_key"}`, http.StatusUnauthorized) })) defer srv.Close() tr := NewGroqTranscriber("sk-bad") tr.apiBase = srv.URL _, err := tr.Transcribe(context.Background(), audioPath) if err == nil { t.Fatal("expected error for non-200 response, got nil") } }) t.Run("missing file", func(t *testing.T) { tr := NewGroqTranscriber("sk-test") _, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg")) if err == nil { t.Fatal("expected error for missing file, got nil") } }) } ================================================ FILE: scripts/build-macos-app.sh ================================================ #!/bin/bash # Build macOS .app bundle for PicoClaw Launcher set -e EXECUTABLE=$1 if [ -z "$EXECUTABLE" ]; then echo "Usage: $0 <executable>" exit 1 fi echo "executable: $EXECUTABLE" APP_NAME="PicoClaw Launcher" APP_PATH="./build/${APP_NAME}.app" APP_CONTENTS="${APP_PATH}/Contents" APP_MACOS="${APP_CONTENTS}/MacOS" APP_RESOURCES="${APP_CONTENTS}/Resources" APP_EXECUTABLE="picoclaw-launcher" ICON_SOURCE="./scripts/icon.icns" # Clean up existing .app if [ -d "$APP_PATH" ]; then echo "Removing existing ${APP_PATH}" rm -rf "$APP_PATH" fi # Create directory structure echo "Creating .app bundle structure..." mkdir -p "$APP_MACOS" mkdir -p "$APP_RESOURCES" # Copy executable echo "Copying executable..." if [ -f "./web/build/${APP_EXECUTABLE}" ]; then cp "./web/build/${APP_EXECUTABLE}" "${APP_MACOS}/" else echo "Error: ./web/build/${APP_EXECUTABLE} not found. Please build the web backend first." echo "Run: make build in web dir" exit 1 fi if [ -f "./build/picoclaw" ]; then cp "./build/picoclaw" "${APP_MACOS}/" else echo "Error: ./build/picoclaw not found. Please build the main file first." echo "Run: make build" exit 1 fi chmod +x "${APP_MACOS}/"* # Create Info.plist echo "Creating Info.plist..." cat > "${APP_CONTENTS}/Info.plist" << 'EOF' <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>CFBundleExecutable</key> <string>picoclaw-launcher</string> <key>CFBundleIdentifier</key> <string>com.picoclaw.launcher</string> <key>CFBundleName</key> <string>PicoClaw Launcher</string> <key>CFBundleDisplayName</key> <string>PicoClaw Launcher</string> <key>CFBundleIconFile</key> <string>icon.icns</string> <key>CFBundlePackageType</key> <string>APPL</string> <key>CFBundleShortVersionString</key> <string>1.0</string> <key>CFBundleVersion</key> <string>1</string> <key>NSHighResolutionCapable</key> <true/> <key>NSSupportsAutomaticGraphicsSwitching</key> <true/> <key>LSRequiresCarbon</key> <true/> <key>LSUIElement</key> <string>1</string> </dict> </plist> EOF #sips -z 128 128 "$ICON_SOURCE" --out "${ICONSET_PATH}/icon_128x128.png" > /dev/null 2>&1 # ## Create icns file #iconutil -c icns "$ICONSET_PATH" -o "$ICON_OUTPUT" 2>/dev/null || { # echo "Warning: iconutil failed" #} cp $ICON_SOURCE "${APP_RESOURCES}/icon.icns" echo "" echo "==========================================" echo "Successfully created: ${APP_PATH}" echo "==========================================" echo "" echo "To launch PicoClaw:" echo " 1. Double-click ${APP_NAME}.app in Finder" echo " 2. Or use: open ${APP_PATH}" echo "" echo "Note: The app will run in the menu bar (systray) without a terminal window." echo "" ================================================ FILE: scripts/setup.iss ================================================ ; Script generated by the Inno Setup Script Wizard. ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "PicoClaw Launcher" #define MyAppVersion "1.0" #define MyAppPublisher "PicoClaw" #define MyAppURL "https://github.com/sipeed/picoclaw" #define MyAppExeName "picoclaw-launcher.exe" [Setup] ; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. ; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) AppId={{C8A1B4E7-D5F9-4C2A-8A6E-5F4D3C2A1B0E} AppName={#MyAppName} AppVersion={#MyAppVersion} ;AppVerName={#MyAppName} {#MyAppVersion} AppPublisher={#MyAppPublisher} AppPublisherURL={#MyAppURL} AppSupportURL={#MyAppURL} AppUpdatesURL={#MyAppURL} DefaultDirName={autopf}\PicoClaw DefaultGroupName={#MyAppName} ; "ArchitecturesAllowed=x64compatible" specifies that Setup cannot run ; on anything but x64 and Windows 11 on Arm. ArchitecturesAllowed=x64compatible ; "ArchitecturesInstallIn64BitMode=x64compatible" requests that the ; install be done in "64-bit mode" on x64 or Windows 11 on Arm, ; meaning it should use the native 64-bit Program Files directory and ; the 64-bit view of the registry. ArchitecturesInstallIn64BitMode=x64compatible DisableProgramGroupPage=yes ; Remove the following line to run in administrative install mode (install for all users.) PrivilegesRequired=lowest OutputDir=build OutputBaseFilename=PicoClawSetup Compression=lzma SolidCompression=yes WizardStyle=modern ; SourceDir=windows SetupIconFile=icon.ico [Languages] Name: "english"; MessagesFile: "compiler:Default.isl" [Tasks] Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked [Dirs] [Files] Source: "..\web\build\picoclaw-launcher.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}"; Flags: ignoreversion Source: "..\build\picoclaw.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "..\web\backend\icon.ico"; DestDir: "{app}"; Flags: ignoreversion ; NOTE: Don't use "Flags: ignoreversion" on any shared system files [UninstallDelete] [Icons] Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; WorkingDir: "{app}"; IconFilename: "{app}\icon.ico" Name: "{group}\Uninstall {#MyAppName}"; Filename: "{uninstallexe}" Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; WorkingDir: "{app}"; Tasks: desktopicon; IconFilename: "{app}\icon.ico" [Run] Filename:"{app}\{#MyAppExeName}"; WorkingDir: "{app}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent ================================================ FILE: scripts/test-docker-mcp.sh ================================================ #!/bin/sh # Test script for MCP tools in Docker (full-featured image) set -e COMPOSE_FILE="docker/docker-compose.full.yml" SERVICE="picoclaw-agent" echo "🧪 Testing MCP tools in Docker container (full-featured image)..." echo "" # Build the image echo "📦 Building Docker image..." docker compose -f "$COMPOSE_FILE" build "$SERVICE" # Test npx echo "✅ Testing npx..." docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'npx --version' # Test npm echo "✅ Testing npm..." docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'npm --version' # Test node echo "✅ Testing Node.js..." docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'node --version' # Test git echo "✅ Testing git..." docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'git --version' # Test python echo "✅ Testing Python..." docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'python3 --version' # Test uv echo "✅ Testing uv..." docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'uv --version' # Test MCP server installation (quick) echo "✅ Testing @modelcontextprotocol/server-filesystem MCP server install with npx..." docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c '</dev/null timeout 5 npx -y @modelcontextprotocol/server-filesystem /tmp || true' echo "" echo "🎉 All MCP tools are working correctly!" echo "" echo "Next steps:" echo " 1. Configure MCP servers in config/config.json" echo " 2. Run: docker compose -f $COMPOSE_FILE --profile gateway up" ================================================ FILE: scripts/test-irc.sh ================================================ #!/bin/sh # Starts a local Ergo IRC server for testing the IRC channel. # # Requirements: docker # Usage: ./scripts/test-irc.sh set -e CONTAINER_NAME="picoclaw-test-ergo" IRC_PORT=6667 # Clean up any previous instance docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true echo "Starting Ergo IRC server on port $IRC_PORT..." docker run -d \ --name "$CONTAINER_NAME" \ -p "$IRC_PORT:6667" \ ghcr.io/ergochat/ergo:stable for i in $(seq 1 10); do if nc -z localhost "$IRC_PORT" 2>/dev/null; then break fi if [ "$i" -eq 10 ]; then echo "ERROR: Server did not start within 10s" exit 1 fi sleep 1 done echo "" echo "IRC server ready on localhost:$IRC_PORT" echo "" echo "Add this to your ~/.picoclaw/config.json under \"channels\":" echo "" echo ' "irc": {' echo ' "enabled": true,' echo ' "server": "localhost:6667",' echo ' "tls": false,' echo ' "nick": "picobot",' echo ' "channels": ["#test"],' echo ' "allow_from": [],' echo ' "group_trigger": { "mention_only": true }' echo ' }' echo "" echo "Then run picoclaw:" echo " cd packages/picoclaw && go run ./cmd/picoclaw gateway" echo "" echo "Connect with an IRC client:" echo " irssi: /connect localhost $IRC_PORT" echo " weechat: /server add test localhost/$IRC_PORT && /connect test" echo " Join #test, then: picobot: hello" echo "" echo "To stop the IRC server:" echo " docker rm -f $CONTAINER_NAME" ================================================ FILE: web/Makefile ================================================ .PHONY: dev dev-frontend dev-backend build test lint clean # Go variables GO?=CGO_ENABLED=0 go WEB_GO?=$(GO) GOFLAGS?=-v -tags stdjson # Build variables BUILD_DIR=build # Version VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev") BUILD_TIME=$(shell date +%FT%T%z) GO_VERSION=$(shell $(WEB_GO) version | awk '{print $$3}') CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w # OS detection UNAME_S:=$(shell uname -s) UNAME_M:=$(shell uname -m) # Platform-specific settings ifeq ($(UNAME_S),Linux) PLATFORM=linux ifeq ($(UNAME_M),x86_64) ARCH=amd64 else ifeq ($(UNAME_M),aarch64) ARCH=arm64 else ifeq ($(UNAME_M),armv81) ARCH=arm64 else ifeq ($(UNAME_M),loongarch64) ARCH=loong64 else ifeq ($(UNAME_M),riscv64) ARCH=riscv64 else ifeq ($(UNAME_M),mipsel) ARCH=mipsle else ARCH=$(UNAME_M) endif else ifeq ($(UNAME_S),Darwin) PLATFORM=darwin WEB_GO=CGO_ENABLED=1 go ifeq ($(UNAME_M),x86_64) ARCH=amd64 else ifeq ($(UNAME_M),arm64) ARCH=arm64 else ARCH=$(UNAME_M) endif else ifeq ($(UNAME_S),Windows) PLATFORM=windows ARCH=$(UNAME_M) LDFLAGS=-H=windowsgui $(LDFLAGS) else PLATFORM=$(UNAME_S) ARCH=$(UNAME_M) endif # Run both frontend and backend dev servers dev: @if [ ! -f $(BUILD_DIR)/picoclaw-launcher ] || [ ! -d backend/dist ]; then \ echo "Build artifacts not found, building..."; \ $(MAKE) build; \ fi @echo "Starting backend and frontend dev servers..." @$(MAKE) dev-backend & $(MAKE) dev-frontend # Start frontend dev server (Vite, with proxy to backend) dev-frontend: cd frontend && pnpm dev # Start backend dev server dev-backend: cd backend && ${WEB_GO} run -ldflags "$(LDFLAGS)" . # Build frontend and embed into Go binary build: cd frontend && pnpm build:backend ${WEB_GO} build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/picoclaw-launcher ./backend/ # Run all tests test: cd backend && ${WEB_GO} test ./... cd frontend && pnpm lint # Lint and format lint: cd backend && ${WEB_GO} vet ./... cd frontend && pnpm check # Clean build artifacts clean: rm -rf frontend/dist backend/dist $(BUILD_DIR) mkdir -p backend/dist && touch backend/dist/.gitkeep ================================================ FILE: web/README.md ================================================ # Picoclaw Web This directory contains the standalone web service for `picoclaw`. It provides a complete unified web interface, acting as a dashboard, configuration center, and interactive console (channel client) for the core `picoclaw` engine. ## Architecture The service is structured as a monorepo containing both the backend and frontend code to ensure high cohesion and simplify deployment. * **`backend/`**: The Go-based web server. It provides RESTful APIs, manages WebSocket connections for chat, and handles the lifecycle of the `picoclaw` process. It eventually embeds the compiled frontend assets into a single executable. * **`frontend/`**: The Vite + React + TanStack Router single-page application (SPA). It provides the interactive user interface. ## Getting Started ### Prerequisites * Go 1.25+ * Node.js 20+ with pnpm ### Development Run both the frontend dev server and the Go backend simultaneously: ```bash make dev ``` Or run them separately: ```bash make dev-frontend # Vite dev server make dev-backend # Go backend ``` ### Build Build the frontend and embed it into a single Go binary: ```bash make build ``` The output binary is `backend/picoclaw-web`. ### Other Commands ```bash make test # Run backend tests and frontend lint make lint # Run go vet and prettier/eslint make clean # Remove all build artifacts ``` ================================================ FILE: web/backend/.gitignore ================================================ # Go build output *.exe *.dll *.so *.dylib *.test *.out picoclaw-web # Frontend build artifacts (embedded by Go) dist/* !dist/.gitkeep # OS .DS_Store # Editors .vscode/ .idea/ ================================================ FILE: web/backend/api/channels.go ================================================ package api import ( "encoding/json" "net/http" ) type channelCatalogItem struct { Name string `json:"name"` ConfigKey string `json:"config_key"` Variant string `json:"variant,omitempty"` } var channelCatalog = []channelCatalogItem{ {Name: "telegram", ConfigKey: "telegram"}, {Name: "discord", ConfigKey: "discord"}, {Name: "slack", ConfigKey: "slack"}, {Name: "feishu", ConfigKey: "feishu"}, {Name: "dingtalk", ConfigKey: "dingtalk"}, {Name: "line", ConfigKey: "line"}, {Name: "qq", ConfigKey: "qq"}, {Name: "onebot", ConfigKey: "onebot"}, {Name: "wecom", ConfigKey: "wecom"}, {Name: "wecom_app", ConfigKey: "wecom_app"}, {Name: "wecom_aibot", ConfigKey: "wecom_aibot"}, {Name: "whatsapp", ConfigKey: "whatsapp", Variant: "bridge"}, {Name: "whatsapp_native", ConfigKey: "whatsapp", Variant: "native"}, {Name: "pico", ConfigKey: "pico"}, {Name: "maixcam", ConfigKey: "maixcam"}, {Name: "matrix", ConfigKey: "matrix"}, {Name: "irc", ConfigKey: "irc"}, } // registerChannelRoutes binds read-only channel catalog endpoints to the ServeMux. func (h *Handler) registerChannelRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/channels/catalog", h.handleListChannelCatalog) } // handleListChannelCatalog returns the channels supported by backend. // // GET /api/channels/catalog func (h *Handler) handleListChannelCatalog(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "channels": channelCatalog, }) } ================================================ FILE: web/backend/api/config.go ================================================ package api import ( "encoding/json" "fmt" "io" "net/http" "regexp" "github.com/sipeed/picoclaw/pkg/config" ) // registerConfigRoutes binds configuration management endpoints to the ServeMux. func (h *Handler) registerConfigRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/config", h.handleGetConfig) mux.HandleFunc("PUT /api/config", h.handleUpdateConfig) mux.HandleFunc("PATCH /api/config", h.handlePatchConfig) } // handleGetConfig returns the complete system configuration. // // GET /api/config func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) { cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(cfg); err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) } } // handleUpdateConfig updates the complete system configuration. // // PUT /api/config func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "Failed to read request body", http.StatusBadRequest) return } defer r.Body.Close() var cfg config.Config if err := json.Unmarshal(body, &cfg); err != nil { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } if execAllowRemoteOmitted(body) { cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote } if errs := validateConfig(&cfg); len(errs) > 0 { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]any{ "status": "validation_error", "errors": errs, }) return } if err := config.SaveConfig(h.configPath, &cfg); err != nil { http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } func execAllowRemoteOmitted(body []byte) bool { var raw struct { Tools *struct { Exec *struct { AllowRemote *bool `json:"allow_remote"` } `json:"exec"` } `json:"tools"` } if err := json.Unmarshal(body, &raw); err != nil { return false } return raw.Tools == nil || raw.Tools.Exec == nil || raw.Tools.Exec.AllowRemote == nil } // handlePatchConfig partially updates the system configuration using JSON Merge Patch (RFC 7396). // Only the fields present in the request body will be updated; all other fields remain unchanged. // // PATCH /api/config func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { patchBody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "Failed to read request body", http.StatusBadRequest) return } defer r.Body.Close() // Validate the patch is valid JSON var patch map[string]any if err = json.Unmarshal(patchBody, &patch); err != nil { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } // Load existing config and marshal to a map for merging cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } existing, err := json.Marshal(cfg) if err != nil { http.Error(w, "Failed to serialize current config", http.StatusInternalServerError) return } var base map[string]any if err = json.Unmarshal(existing, &base); err != nil { http.Error(w, "Failed to parse current config", http.StatusInternalServerError) return } // Recursively merge patch into base mergeMap(base, patch) // Convert merged map back to Config struct merged, err := json.Marshal(base) if err != nil { http.Error(w, "Failed to serialize merged config", http.StatusInternalServerError) return } var newCfg config.Config if err := json.Unmarshal(merged, &newCfg); err != nil { http.Error(w, fmt.Sprintf("Merged config is invalid: %v", err), http.StatusBadRequest) return } if errs := validateConfig(&newCfg); len(errs) > 0 { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]any{ "status": "validation_error", "errors": errs, }) return } if err := config.SaveConfig(h.configPath, &newCfg); err != nil { http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } // validateConfig checks the config for common errors before saving. // Returns a list of human-readable error strings; empty means valid. func validateConfig(cfg *config.Config) []string { var errs []string // Validate model_list entries if err := cfg.ValidateModelList(); err != nil { errs = append(errs, err.Error()) } // Gateway port range if cfg.Gateway.Port != 0 && (cfg.Gateway.Port < 1 || cfg.Gateway.Port > 65535) { errs = append(errs, fmt.Sprintf("gateway.port %d is out of valid range (1-65535)", cfg.Gateway.Port)) } // Pico channel: token required when enabled if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token == "" { errs = append(errs, "channels.pico.token is required when pico channel is enabled") } // Telegram: token required when enabled if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token == "" { errs = append(errs, "channels.telegram.token is required when telegram channel is enabled") } // Discord: token required when enabled if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token == "" { errs = append(errs, "channels.discord.token is required when discord channel is enabled") } if cfg.Tools.Exec.Enabled { if cfg.Tools.Exec.EnableDenyPatterns { errs = append( errs, validateRegexPatterns("tools.exec.custom_deny_patterns", cfg.Tools.Exec.CustomDenyPatterns)...) } errs = append( errs, validateRegexPatterns("tools.exec.custom_allow_patterns", cfg.Tools.Exec.CustomAllowPatterns)...) } return errs } func validateRegexPatterns(field string, patterns []string) []string { var errs []string for index, pattern := range patterns { if _, err := regexp.Compile(pattern); err != nil { errs = append(errs, fmt.Sprintf("%s[%d] is not a valid regular expression: %v", field, index, err)) } } return errs } // mergeMap recursively merges src into dst (JSON Merge Patch semantics). // - If a key in src has a null value, it is deleted from dst. // - If both dst and src have a nested object for the same key, merge recursively. // - Otherwise the value from src overwrites dst. func mergeMap(dst, src map[string]any) { for key, srcVal := range src { if srcVal == nil { delete(dst, key) continue } srcMap, srcIsMap := srcVal.(map[string]any) dstMap, dstIsMap := dst[key].(map[string]any) if srcIsMap && dstIsMap { mergeMap(dstMap, srcMap) } else { dst[key] = srcVal } } } ================================================ FILE: web/backend/api/config_test.go ================================================ package api import ( "bytes" "net/http" "net/http/httptest" "testing" "github.com/sipeed/picoclaw/pkg/config" ) func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{ "agents": { "defaults": { "workspace": "~/.picoclaw/workspace" } }, "model_list": [ { "model_name": "custom-default", "model": "openai/gpt-4o", "api_key": "sk-default" } ] }`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } if !cfg.Tools.Exec.AllowRemote { t.Fatal("tools.exec.allow_remote should remain true when omitted from PUT /api/config") } } func TestHandleUpdateConfig_DoesNotInheritDefaultModelFields(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{ "agents": { "defaults": { "workspace": "~/.picoclaw/workspace" } }, "model_list": [ { "model_name": "custom-default", "model": "openai/gpt-4o", "api_key": "sk-default" } ] }`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } if got := cfg.ModelList[0].APIBase; got != "" { t.Fatalf("model_list[0].api_base = %q, want empty string", got) } } func TestHandlePatchConfig_RejectsInvalidExecRegexPatterns(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ "tools": { "exec": { "custom_deny_patterns": ["("] } } }`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) } if !bytes.Contains(rec.Body.Bytes(), []byte("custom_deny_patterns")) { t.Fatalf("expected validation error mentioning custom_deny_patterns, body=%s", rec.Body.String()) } } func TestHandlePatchConfig_AllowsInvalidExecRegexPatternsWhenExecDisabled(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ "tools": { "exec": { "enabled": false, "custom_deny_patterns": ["("], "custom_allow_patterns": ["("] } } }`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } } func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisabled(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ "tools": { "exec": { "enabled": true, "enable_deny_patterns": false, "custom_deny_patterns": ["("] } } }`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } } ================================================ FILE: web/backend/api/gateway.go ================================================ package api import ( "bufio" "encoding/json" "errors" "fmt" "io" "net" "net/http" "os" "os/exec" "runtime" "strconv" "strings" "sync" "syscall" "time" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/web/backend/utils" ) // gateway holds the state for the managed gateway process. var gateway = struct { mu sync.Mutex cmd *exec.Cmd owned bool // true if we started the process, false if we attached to an existing one bootDefaultModel string runtimeStatus string startupDeadline time.Time logs *LogBuffer }{ runtimeStatus: "stopped", logs: NewLogBuffer(200), } var ( gatewayStartupWindow = 15 * time.Second gatewayRestartGracePeriod = 5 * time.Second gatewayRestartForceKillWindow = 3 * time.Second gatewayRestartPollInterval = 100 * time.Millisecond ) var gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) { client := http.Client{Timeout: timeout} return client.Get(url) } // getGatewayHealth checks the gateway health endpoint and returns the status response // Returns (*health.StatusResponse, statusCode, error). If error is not nil, the other values are not valid. func (h *Handler) getGatewayHealth(cfg *config.Config, timeout time.Duration) (*health.StatusResponse, int, error) { port := 18790 if cfg != nil && cfg.Gateway.Port != 0 { port = cfg.Gateway.Port } probeHost := gatewayProbeHost(h.effectiveGatewayBindHost(cfg)) url := "http://" + net.JoinHostPort(probeHost, strconv.Itoa(port)) + "/health" return getGatewayHealthByURL(url, timeout) } func getGatewayHealthByURL(url string, timeout time.Duration) (*health.StatusResponse, int, error) { resp, err := gatewayHealthGet(url, timeout) if err != nil { return nil, 0, err } defer resp.Body.Close() var healthResponse health.StatusResponse if decErr := json.NewDecoder(resp.Body).Decode(&healthResponse); decErr != nil { return nil, resp.StatusCode, decErr } return &healthResponse, resp.StatusCode, nil } // registerGatewayRoutes binds gateway lifecycle endpoints to the ServeMux. func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus) mux.HandleFunc("GET /api/gateway/logs", h.handleGatewayLogs) mux.HandleFunc("POST /api/gateway/logs/clear", h.handleGatewayClearLogs) mux.HandleFunc("POST /api/gateway/start", h.handleGatewayStart) mux.HandleFunc("POST /api/gateway/stop", h.handleGatewayStop) mux.HandleFunc("POST /api/gateway/restart", h.handleGatewayRestart) } // TryAutoStartGateway checks whether gateway start preconditions are met and // starts it when possible. Intended to be called by the backend at startup. func (h *Handler) TryAutoStartGateway() { // Check if gateway is already running via health endpoint cfg, cfgErr := config.LoadConfig(h.configPath) if cfgErr == nil && cfg != nil { healthResp, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second) if err == nil && statusCode == http.StatusOK { // Gateway is already running, attach to the existing process pid := healthResp.Pid gateway.mu.Lock() defer gateway.mu.Unlock() ready, reason, err := h.gatewayStartReady() if err != nil { logger.ErrorC("gateway", fmt.Sprintf("Skip auto-starting gateway: %v", err)) return } if !ready { logger.InfoC("gateway", fmt.Sprintf("Skip auto-starting gateway: %s", reason)) return } _, err = h.startGatewayLocked("starting", pid) if err != nil { logger.ErrorC("gateway", fmt.Sprintf("Failed to attach to running gateway (PID: %d): %v", pid, err)) } return } } gateway.mu.Lock() defer gateway.mu.Unlock() if gateway.cmd != nil && gateway.cmd.Process != nil { gateway.cmd = nil } ready, reason, err := h.gatewayStartReady() if err != nil { logger.ErrorC("gateway", fmt.Sprintf("Skip auto-starting gateway: %v", err)) return } if !ready { logger.InfoC("gateway", fmt.Sprintf("Skip auto-starting gateway: %s", reason)) return } pid, err := h.startGatewayLocked("starting", 0) if err != nil { logger.ErrorC("gateway", fmt.Sprintf("Failed to auto-start gateway: %v", err)) return } logger.InfoC("gateway", fmt.Sprintf("Gateway auto-started (PID: %d)", pid)) } // gatewayStartReady validates whether current config can start the gateway. func (h *Handler) gatewayStartReady() (bool, string, error) { cfg, err := config.LoadConfig(h.configPath) if err != nil { return false, "", fmt.Errorf("failed to load config: %w", err) } modelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName()) if modelName == "" { return false, "no default model configured", nil } modelCfg := lookupModelConfig(cfg, modelName) if modelCfg == nil { return false, fmt.Sprintf("default model %q is invalid", modelName), nil } if !hasModelConfiguration(*modelCfg) { return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil } if requiresRuntimeProbe(*modelCfg) && !probeLocalModelAvailability(*modelCfg) { return false, fmt.Sprintf("default model %q is not reachable", modelName), nil } return true, "", nil } func lookupModelConfig(cfg *config.Config, modelName string) *config.ModelConfig { modelCfg, err := cfg.GetModelConfig(modelName) if err != nil { return nil } return modelCfg } func gatewayRestartRequired(configDefaultModel, bootDefaultModel, gatewayStatus string) bool { if gatewayStatus != "running" { return false } if strings.TrimSpace(configDefaultModel) == "" || strings.TrimSpace(bootDefaultModel) == "" { return false } return configDefaultModel != bootDefaultModel } func isCmdProcessAliveLocked(cmd *exec.Cmd) bool { if cmd == nil || cmd.Process == nil { return false } // Wait() sets ProcessState when the process exits; use it when available. if cmd.ProcessState != nil && cmd.ProcessState.Exited() { return false } // Windows does not support Signal(0) probing. If we still own cmd and it // has not reported exit, treat it as alive. if runtime.GOOS == "windows" { return true } return cmd.Process.Signal(syscall.Signal(0)) == nil } func setGatewayRuntimeStatusLocked(status string) { gateway.runtimeStatus = status if status == "starting" || status == "restarting" { gateway.startupDeadline = time.Now().Add(gatewayStartupWindow) return } gateway.startupDeadline = time.Time{} } // attachToGatewayProcess attaches to an existing gateway process by PID // and updates the gateway state accordingly. // Assumes gateway.mu is held by the caller. func attachToGatewayProcessLocked(pid int, cfg *config.Config) error { process, err := os.FindProcess(pid) if err != nil { return fmt.Errorf("failed to find process for PID %d: %w", pid, err) } gateway.cmd = &exec.Cmd{Process: process} gateway.owned = false // We didn't start this process setGatewayRuntimeStatusLocked("running") // Update bootDefaultModel from config if cfg != nil { defaultModelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName()) gateway.bootDefaultModel = defaultModelName } logger.InfoC("gateway", fmt.Sprintf("Attached to gateway process (PID: %d)", pid)) return nil } func gatewayStatusWithoutHealthLocked() string { if gateway.runtimeStatus == "starting" || gateway.runtimeStatus == "restarting" { if gateway.startupDeadline.IsZero() || time.Now().Before(gateway.startupDeadline) { return gateway.runtimeStatus } return "error" } if gateway.runtimeStatus == "running" { return "running" } if gateway.runtimeStatus == "error" { return "error" } return "stopped" } func waitForGatewayProcessExit(cmd *exec.Cmd, timeout time.Duration) bool { if cmd == nil || cmd.Process == nil { return true } deadline := time.Now().Add(timeout) for { if !isCmdProcessAliveLocked(cmd) { return true } if time.Now().After(deadline) { return false } time.Sleep(gatewayRestartPollInterval) } } // StopGateway stops the gateway process if it was started by this handler. // This method is called during application shutdown to ensure the gateway subprocess // is properly terminated. It only stops processes that were started by this handler, // not processes that were attached to from existing instances. func (h *Handler) StopGateway() { gateway.mu.Lock() defer gateway.mu.Unlock() // Only stop if we own the process (started it ourselves) if !gateway.owned || gateway.cmd == nil || gateway.cmd.Process == nil { return } pid, err := stopGatewayLocked() if err != nil { logger.ErrorC("gateway", fmt.Sprintf("Failed to stop gateway (PID %d): %v", pid, err)) return } logger.InfoC("gateway", fmt.Sprintf("Gateway stopped (PID: %d)", pid)) } // stopGatewayLocked sends a stop signal to the gateway process. // Assumes gateway.mu is held by the caller. // Returns the PID of the stopped process and any error encountered. func stopGatewayLocked() (int, error) { if gateway.cmd == nil || gateway.cmd.Process == nil { return 0, nil } pid := gateway.cmd.Process.Pid // Send SIGTERM for graceful shutdown (SIGKILL on Windows) var sigErr error if runtime.GOOS == "windows" { sigErr = gateway.cmd.Process.Kill() } else { sigErr = gateway.cmd.Process.Signal(syscall.SIGTERM) } if sigErr != nil { return pid, sigErr } logger.InfoC("gateway", fmt.Sprintf("Sent stop signal to gateway (PID: %d)", pid)) gateway.cmd = nil gateway.owned = false gateway.bootDefaultModel = "" setGatewayRuntimeStatusLocked("stopped") return pid, nil } func stopGatewayProcessForRestart(cmd *exec.Cmd) error { if cmd == nil || cmd.Process == nil || !isCmdProcessAliveLocked(cmd) { return nil } var stopErr error if runtime.GOOS == "windows" { stopErr = cmd.Process.Kill() } else { stopErr = cmd.Process.Signal(syscall.SIGTERM) } if stopErr != nil && isCmdProcessAliveLocked(cmd) { return fmt.Errorf("failed to stop existing gateway: %w", stopErr) } if waitForGatewayProcessExit(cmd, gatewayRestartGracePeriod) { return nil } if runtime.GOOS != "windows" { killErr := cmd.Process.Signal(syscall.SIGKILL) if killErr != nil && isCmdProcessAliveLocked(cmd) { return fmt.Errorf("failed to force-stop existing gateway: %w", killErr) } if waitForGatewayProcessExit(cmd, gatewayRestartForceKillWindow) { return nil } } return fmt.Errorf("existing gateway did not exit before restart") } func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int, error) { cfg, err := config.LoadConfig(h.configPath) if err != nil { return 0, fmt.Errorf("failed to load config: %w", err) } defaultModelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName()) var cmd *exec.Cmd var pid int if existingPid > 0 { // Attach to existing process pid = existingPid gateway.cmd = nil // Clear first to ensure clean state if err = attachToGatewayProcessLocked(pid, cfg); err != nil { return 0, err } return pid, nil } // Start new process // Locate the picoclaw executable execPath := utils.FindPicoclawBinary() cmd = exec.Command(execPath, "gateway", "-E") cmd.Env = os.Environ() // Forward the launcher's config path via the environment variable that // GetConfigPath() already reads, so the gateway sub-process uses the same // config file without requiring a --config flag on the gateway subcommand. if h.configPath != "" { cmd.Env = append(cmd.Env, config.EnvConfig+"="+h.configPath) } if host := h.gatewayHostOverride(); host != "" { cmd.Env = append(cmd.Env, config.EnvGatewayHost+"="+host) } stdoutPipe, err := cmd.StdoutPipe() if err != nil { return 0, fmt.Errorf("failed to create stdout pipe: %w", err) } stderrPipe, err := cmd.StderrPipe() if err != nil { return 0, fmt.Errorf("failed to create stderr pipe: %w", err) } // Clear old logs for this new run gateway.logs.Reset() // Ensure Pico Channel is configured before starting gateway if _, err := h.ensurePicoChannel(""); err != nil { logger.ErrorC("gateway", fmt.Sprintf("Warning: failed to ensure pico channel: %v", err)) // Non-fatal: gateway can still start without pico channel } if err := cmd.Start(); err != nil { return 0, fmt.Errorf("failed to start gateway: %w", err) } gateway.cmd = cmd gateway.owned = true // We started this process gateway.bootDefaultModel = defaultModelName setGatewayRuntimeStatusLocked(initialStatus) pid = cmd.Process.Pid logger.InfoC("gateway", fmt.Sprintf("Started picoclaw gateway (PID: %d) from %s", pid, execPath)) // Capture stdout/stderr in background go scanPipe(stdoutPipe, gateway.logs) go scanPipe(stderrPipe, gateway.logs) // Wait for exit in background and clean up go func() { if err := cmd.Wait(); err != nil { logger.ErrorC("gateway", fmt.Sprintf("Gateway process exited: %v", err)) } else { logger.InfoC("gateway", "Gateway process exited normally") } gateway.mu.Lock() if gateway.cmd == cmd { gateway.cmd = nil gateway.bootDefaultModel = "" if gateway.runtimeStatus != "restarting" { setGatewayRuntimeStatusLocked("stopped") } } gateway.mu.Unlock() }() // Start a goroutine to probe health and update the runtime state once ready. go func() { for i := 0; i < 30; i++ { // try for up to 15 seconds time.Sleep(500 * time.Millisecond) gateway.mu.Lock() stillOurs := gateway.cmd == cmd gateway.mu.Unlock() if !stillOurs { return } cfg, err := config.LoadConfig(h.configPath) if err != nil { continue } healthResp, statusCode, err := h.getGatewayHealth(cfg, 1*time.Second) if err == nil && statusCode == http.StatusOK && healthResp.Pid == pid { // Verify the health endpoint returns the expected pid gateway.mu.Lock() if gateway.cmd == cmd { setGatewayRuntimeStatusLocked("running") } gateway.mu.Unlock() return } } }() return pid, nil } // handleGatewayStart starts the picoclaw gateway subprocess. // // POST /api/gateway/start func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) { // Prevent duplicate starts by checking health endpoint cfg, cfgErr := config.LoadConfig(h.configPath) if cfgErr == nil && cfg != nil { healthResp, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second) if err == nil && statusCode == http.StatusOK { // Gateway is already running, attach to the existing process pid := healthResp.Pid gateway.mu.Lock() ready, reason, err := h.gatewayStartReady() if err != nil { gateway.mu.Unlock() http.Error( w, fmt.Sprintf("Failed to validate gateway start conditions: %v", err), http.StatusInternalServerError, ) return } if !ready { gateway.mu.Unlock() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]any{ "status": "precondition_failed", "message": reason, }) return } _, err = h.startGatewayLocked("starting", pid) gateway.mu.Unlock() if err != nil { logger.ErrorC("gateway", fmt.Sprintf("Failed to attach to running gateway (PID: %d): %v", pid, err)) http.Error(w, fmt.Sprintf("Failed to attach to gateway: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]any{ "status": "ok", "pid": pid, }) return } } gateway.mu.Lock() defer gateway.mu.Unlock() if gateway.cmd != nil && gateway.cmd.Process != nil { gateway.cmd = nil setGatewayRuntimeStatusLocked("stopped") } ready, reason, err := h.gatewayStartReady() if err != nil { http.Error( w, fmt.Sprintf("Failed to validate gateway start conditions: %v", err), http.StatusInternalServerError, ) return } if !ready { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]any{ "status": "precondition_failed", "message": reason, }) return } pid, err := h.startGatewayLocked("starting", 0) if err != nil { http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "status": "ok", "pid": pid, }) } // handleGatewayStop stops the running gateway subprocess gracefully. // Note: Unlike StopGateway (which only stops self-started processes), this API endpoint // stops any gateway process, including attached ones. This is intentional for user control. // // POST /api/gateway/stop func (h *Handler) handleGatewayStop(w http.ResponseWriter, r *http.Request) { gateway.mu.Lock() defer gateway.mu.Unlock() if gateway.cmd == nil || gateway.cmd.Process == nil { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "status": "not_running", }) return } pid, err := stopGatewayLocked() if err != nil { http.Error(w, fmt.Sprintf("Failed to stop gateway (PID %d): %v", pid, err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "status": "ok", "pid": pid, }) } // RestartGateway restarts the gateway process. This is a non-blocking operation // that stops the current gateway (if running) and starts a new one. // Returns the PID of the new gateway process or an error. func (h *Handler) RestartGateway() (int, error) { ready, reason, err := h.gatewayStartReady() if err != nil { return 0, fmt.Errorf("failed to validate gateway start conditions: %w", err) } if !ready { return 0, &preconditionFailedError{reason: reason} } gateway.mu.Lock() previousCmd := gateway.cmd setGatewayRuntimeStatusLocked("restarting") gateway.mu.Unlock() if err = stopGatewayProcessForRestart(previousCmd); err != nil { gateway.mu.Lock() if gateway.cmd == previousCmd { if isCmdProcessAliveLocked(previousCmd) { setGatewayRuntimeStatusLocked("running") } else { gateway.cmd = nil gateway.bootDefaultModel = "" setGatewayRuntimeStatusLocked("error") } } gateway.mu.Unlock() return 0, fmt.Errorf("failed to stop gateway: %w", err) } gateway.mu.Lock() if gateway.cmd == previousCmd { gateway.cmd = nil gateway.bootDefaultModel = "" } pid, err := h.startGatewayLocked("restarting", 0) if err != nil { gateway.cmd = nil gateway.bootDefaultModel = "" setGatewayRuntimeStatusLocked("error") } gateway.mu.Unlock() if err != nil { return 0, fmt.Errorf("failed to start gateway: %w", err) } return pid, nil } // preconditionFailedError is returned when gateway restart preconditions are not met type preconditionFailedError struct { reason string } func (e *preconditionFailedError) Error() string { return e.reason } // IsBadRequest returns true if the error should result in a 400 Bad Request status func (e *preconditionFailedError) IsBadRequest() bool { return true } // handleGatewayRestart stops the gateway (if running) and starts a new instance. // // POST /api/gateway/restart func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) { pid, err := h.RestartGateway() if err != nil { // Check if it's a precondition failed error var precondErr *preconditionFailedError if errors.As(err, &precondErr) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]any{ "status": "precondition_failed", "message": precondErr.reason, }) return } http.Error(w, fmt.Sprintf("Failed to restart gateway: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "status": "ok", "pid": pid, }) } // handleGatewayClearLogs clears the in-memory gateway log buffer. // // POST /api/gateway/logs/clear func (h *Handler) handleGatewayClearLogs(w http.ResponseWriter, r *http.Request) { gateway.logs.Clear() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "status": "cleared", "log_total": 0, "log_run_id": gateway.logs.RunID(), }) } // handleGatewayStatus returns the gateway run status and health info. // // GET /api/gateway/status func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) { data := h.gatewayStatusData() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(data) } func (h *Handler) gatewayStatusData() map[string]any { data := map[string]any{} configDefaultModel := "" cfg, cfgErr := config.LoadConfig(h.configPath) if cfgErr == nil && cfg != nil { configDefaultModel = strings.TrimSpace(cfg.Agents.Defaults.GetModelName()) if configDefaultModel != "" { data["config_default_model"] = configDefaultModel } } // Probe health endpoint to get pid and status healthResp, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second) if err != nil { gateway.mu.Lock() data["gateway_status"] = gatewayStatusWithoutHealthLocked() gateway.mu.Unlock() logger.ErrorC("gateway", fmt.Sprintf("Gateway health check failed: %v", err)) } else { logger.InfoC("gateway", fmt.Sprintf("Gateway health status: %d", statusCode)) if statusCode != http.StatusOK { gateway.mu.Lock() setGatewayRuntimeStatusLocked("error") gateway.mu.Unlock() data["gateway_status"] = "error" data["status_code"] = statusCode } else { gateway.mu.Lock() setGatewayRuntimeStatusLocked("running") if gateway.cmd == nil || gateway.cmd.Process == nil || gateway.cmd.Process.Pid != healthResp.Pid { oldPid := "none" if gateway.cmd != nil && gateway.cmd.Process != nil { oldPid = fmt.Sprintf("%d", gateway.cmd.Process.Pid) } logger.InfoC( "gateway", fmt.Sprintf( "Detected new gateway PID (old: %s, new: %d), attempting to attach", oldPid, healthResp.Pid, ), ) if err := attachToGatewayProcessLocked(healthResp.Pid, cfg); err != nil { // Failed to find the process, treat as error setGatewayRuntimeStatusLocked("error") data["gateway_status"] = "error" data["pid"] = healthResp.Pid logger.ErrorC( "gateway", fmt.Sprintf("Failed to attach to new gateway process (PID: %d): %v", healthResp.Pid, err), ) } else { // Successfully attached, update response data bootDefaultModel := gateway.bootDefaultModel if bootDefaultModel != "" { data["boot_default_model"] = bootDefaultModel } data["gateway_status"] = "running" data["pid"] = healthResp.Pid } } bootDefaultModel := gateway.bootDefaultModel if bootDefaultModel != "" { data["boot_default_model"] = bootDefaultModel } data["gateway_status"] = "running" data["pid"] = healthResp.Pid gateway.mu.Unlock() } } bootDefaultModel, _ := data["boot_default_model"].(string) gatewayStatus, _ := data["gateway_status"].(string) data["gateway_restart_required"] = gatewayRestartRequired( configDefaultModel, bootDefaultModel, gatewayStatus, ) ready, reason, readyErr := h.gatewayStartReady() if readyErr != nil { data["gateway_start_allowed"] = false data["gateway_start_reason"] = readyErr.Error() } else { data["gateway_start_allowed"] = ready if !ready { data["gateway_start_reason"] = reason } } return data } // handleGatewayLogs returns buffered gateway logs, optionally incrementally. // // GET /api/gateway/logs func (h *Handler) handleGatewayLogs(w http.ResponseWriter, r *http.Request) { data := gatewayLogsData(r) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(data) } // gatewayLogsData reads log_offset and log_run_id query params from the request // and returns incremental log lines. func gatewayLogsData(r *http.Request) map[string]any { data := map[string]any{} clientOffset := 0 clientRunID := -1 if v := r.URL.Query().Get("log_offset"); v != "" { if n, err := strconv.Atoi(v); err == nil { clientOffset = n } } if v := r.URL.Query().Get("log_run_id"); v != "" { if n, err := strconv.Atoi(v); err == nil { clientRunID = n } } runID := gateway.logs.RunID() if runID == 0 { data["logs"] = []string{} data["log_total"] = 0 data["log_run_id"] = 0 return data } // If runID changed, reset offset to get all logs from new run offset := clientOffset if clientRunID != runID { offset = 0 } lines, total, runID := gateway.logs.LinesSince(offset) if lines == nil { lines = []string{} } data["logs"] = lines data["log_total"] = total data["log_run_id"] = runID return data } // scanPipe reads lines from r and appends them to buf. Returns when r reaches EOF. func scanPipe(r io.Reader, buf *LogBuffer) { scanner := bufio.NewScanner(r) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for scanner.Scan() { buf.Append(scanner.Text()) } } ================================================ FILE: web/backend/api/gateway_host.go ================================================ package api import ( "net" "net/http" "net/url" "strconv" "strings" "github.com/sipeed/picoclaw/pkg/config" ) func (h *Handler) effectiveLauncherPublic() bool { if h.serverPublicExplicit { return h.serverPublic } cfg, err := h.loadLauncherConfig() if err == nil { return cfg.Public } return h.serverPublic } func (h *Handler) gatewayHostOverride() string { if h.effectiveLauncherPublic() { return "0.0.0.0" } return "" } func (h *Handler) effectiveGatewayBindHost(cfg *config.Config) string { if override := h.gatewayHostOverride(); override != "" { return override } if cfg == nil { return "" } return strings.TrimSpace(cfg.Gateway.Host) } func gatewayProbeHost(bindHost string) string { if bindHost == "" || bindHost == "0.0.0.0" { return "127.0.0.1" } return bindHost } func (h *Handler) gatewayProxyURL() *url.URL { cfg, err := config.LoadConfig(h.configPath) port := 18790 bindHost := "" if err == nil && cfg != nil { if cfg.Gateway.Port != 0 { port = cfg.Gateway.Port } bindHost = h.effectiveGatewayBindHost(cfg) } return &url.URL{ Scheme: "http", Host: net.JoinHostPort(gatewayProbeHost(bindHost), strconv.Itoa(port)), } } func requestHostName(r *http.Request) string { reqHost, _, err := net.SplitHostPort(r.Host) if err == nil { return reqHost } if strings.TrimSpace(r.Host) != "" { return r.Host } return "127.0.0.1" } func requestWSScheme(r *http.Request) string { if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" { proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0])) if proto == "https" || proto == "wss" { return "wss" } if proto == "http" || proto == "ws" { return "ws" } } if r.TLS != nil { return "wss" } return "ws" } func (h *Handler) buildWsURL(r *http.Request, cfg *config.Config) string { host := h.effectiveGatewayBindHost(cfg) if host == "" || host == "0.0.0.0" { host = requestHostName(r) } // Use web server port instead of gateway port to avoid exposing extra ports // The WebSocket connection will be proxied by the backend to the gateway wsPort := h.serverPort if wsPort == 0 { wsPort = 18800 // default web server port } return requestWSScheme(r) + "://" + net.JoinHostPort(host, strconv.Itoa(wsPort)) + "/pico/ws" } ================================================ FILE: web/backend/api/gateway_host_test.go ================================================ package api import ( "crypto/tls" "errors" "net/http" "net/http/httptest" "path/filepath" "testing" "time" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/web/backend/launcherconfig" ) func TestGatewayHostOverrideUsesExplicitRuntimePublic(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") launcherPath := launcherconfig.PathForAppConfig(configPath) if err := launcherconfig.Save(launcherPath, launcherconfig.Config{ Port: 18800, Public: false, }); err != nil { t.Fatalf("launcherconfig.Save() error = %v", err) } h := NewHandler(configPath) h.SetServerOptions(18800, true, true, nil) if got := h.gatewayHostOverride(); got != "0.0.0.0" { t.Fatalf("gatewayHostOverride() = %q, want %q", got, "0.0.0.0") } } func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") launcherPath := launcherconfig.PathForAppConfig(configPath) if err := launcherconfig.Save(launcherPath, launcherconfig.Config{ Port: 18800, Public: true, }); err != nil { t.Fatalf("launcherconfig.Save() error = %v", err) } h := NewHandler(configPath) h.SetServerOptions(18800, false, false, nil) cfg := config.DefaultConfig() cfg.Gateway.Host = "127.0.0.1" cfg.Gateway.Port = 18790 req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil) req.Host = "192.168.1.9:18800" if got := h.buildWsURL(req, cfg); got != "ws://192.168.1.9:18800/pico/ws" { t.Fatalf("buildWsURL() = %q, want %q", got, "ws://192.168.1.9:18800/pico/ws") } } func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) { if got := gatewayProbeHost("0.0.0.0"); got != "127.0.0.1" { t.Fatalf("gatewayProbeHost() = %q, want %q", got, "127.0.0.1") } } func TestGatewayProxyURLUsesConfiguredHost(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) cfg := config.DefaultConfig() cfg.Gateway.Host = "192.168.1.10" cfg.Gateway.Port = 18791 if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } if got := h.gatewayProxyURL().String(); got != "http://192.168.1.10:18791" { t.Fatalf("gatewayProxyURL() = %q, want %q", got, "http://192.168.1.10:18791") } } func TestGetGatewayHealthUsesConfiguredHost(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) cfg := config.DefaultConfig() cfg.Gateway.Host = "192.168.1.10" cfg.Gateway.Port = 18791 originalHealthGet := gatewayHealthGet t.Cleanup(func() { gatewayHealthGet = originalHealthGet }) var requestedURL string gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) { requestedURL = url return nil, errors.New("probe failed") } _, statusCode, err := h.getGatewayHealth(cfg, time.Second) _ = statusCode _ = err if requestedURL != "http://192.168.1.10:18791/health" { t.Fatalf("health url = %q, want %q", requestedURL, "http://192.168.1.10:18791/health") } } func TestGetGatewayHealthUsesProbeHostForPublicLauncher(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) h.SetServerOptions(18800, true, true, nil) cfg := config.DefaultConfig() cfg.Gateway.Host = "127.0.0.1" cfg.Gateway.Port = 18791 originalHealthGet := gatewayHealthGet t.Cleanup(func() { gatewayHealthGet = originalHealthGet }) var requestedURL string gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) { requestedURL = url return nil, errors.New("probe failed") } _, statusCode, err := h.getGatewayHealth(cfg, time.Second) _ = statusCode _ = err if requestedURL != "http://127.0.0.1:18791/health" { t.Fatalf("health url = %q, want %q", requestedURL, "http://127.0.0.1:18791/health") } } func TestBuildWsURLUsesWSSWhenForwardedProtoIsHTTPS(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) cfg := config.DefaultConfig() cfg.Gateway.Host = "0.0.0.0" cfg.Gateway.Port = 18790 req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil) req.Host = "chat.example.com" req.Header.Set("X-Forwarded-Proto", "https") if got := h.buildWsURL(req, cfg); got != "wss://chat.example.com:18800/pico/ws" { t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:18800/pico/ws") } } func TestBuildWsURLUsesWSSWhenRequestIsTLS(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) cfg := config.DefaultConfig() cfg.Gateway.Host = "0.0.0.0" cfg.Gateway.Port = 18790 req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil) req.Host = "secure.example.com" req.TLS = &tls.ConnectionState{} if got := h.buildWsURL(req, cfg); got != "wss://secure.example.com:18800/pico/ws" { t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:18800/pico/ws") } } func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) cfg := config.DefaultConfig() cfg.Gateway.Host = "0.0.0.0" cfg.Gateway.Port = 18790 req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil) req.Host = "chat.example.com" req.TLS = &tls.ConnectionState{} req.Header.Set("X-Forwarded-Proto", "http") if got := h.buildWsURL(req, cfg); got != "ws://chat.example.com:18800/pico/ws" { t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:18800/pico/ws") } } ================================================ FILE: web/backend/api/gateway_test.go ================================================ package api import ( "encoding/json" "errors" "io" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "testing" "time" "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/web/backend/utils" ) func startLongRunningProcess(t *testing.T) *exec.Cmd { t.Helper() var cmd *exec.Cmd if runtime.GOOS == "windows" { cmd = exec.Command("powershell", "-NoProfile", "-Command", "Start-Sleep -Seconds 30") } else { cmd = exec.Command("sleep", "30") } if err := cmd.Start(); err != nil { t.Fatalf("Start() error = %v", err) } return cmd } func mockGatewayHealthResponse(statusCode, pid int) *http.Response { return &http.Response{ StatusCode: statusCode, Body: io.NopCloser(strings.NewReader( `{"status":"ok","uptime":"1s","pid":` + strconv.Itoa(pid) + `}`, )), } } func startIgnoringTermProcess(t *testing.T) *exec.Cmd { t.Helper() if runtime.GOOS == "windows" { t.Skip("TERM handling differs on Windows") } cmd := exec.Command("sh", "-c", "trap '' TERM; sleep 30") if err := cmd.Start(); err != nil { t.Fatalf("Start() error = %v", err) } return cmd } func resetGatewayTestState(t *testing.T) { t.Helper() originalHealthGet := gatewayHealthGet originalRestartGracePeriod := gatewayRestartGracePeriod originalRestartForceKillWindow := gatewayRestartForceKillWindow originalRestartPollInterval := gatewayRestartPollInterval t.Cleanup(func() { gatewayHealthGet = originalHealthGet gatewayRestartGracePeriod = originalRestartGracePeriod gatewayRestartForceKillWindow = originalRestartForceKillWindow gatewayRestartPollInterval = originalRestartPollInterval gateway.mu.Lock() gateway.cmd = nil gateway.bootDefaultModel = "" setGatewayRuntimeStatusLocked("stopped") gateway.mu.Unlock() }) } func TestGatewayStartReady_NoDefaultModel(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) ready, reason, err := h.gatewayStartReady() if err != nil { t.Fatalf("gatewayStartReady() error = %v", err) } if ready { t.Fatalf("gatewayStartReady() ready = true, want false") } if reason != "no default model configured" { t.Fatalf("gatewayStartReady() reason = %q, want %q", reason, "no default model configured") } } func TestGatewayStartReady_InvalidDefaultModel(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") cfg := config.DefaultConfig() cfg.Agents.Defaults.Model = "missing-model" err := config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) ready, reason, err := h.gatewayStartReady() if err != nil { t.Fatalf("gatewayStartReady() error = %v", err) } if ready { t.Fatalf("gatewayStartReady() ready = true, want false") } if reason == "" { t.Fatalf("gatewayStartReady() reason is empty") } } func TestGatewayStartReady_ValidDefaultModel(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") cfg := config.DefaultConfig() cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName cfg.ModelList[0].APIKey = "test-key" err := config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) ready, reason, err := h.gatewayStartReady() if err != nil { t.Fatalf("gatewayStartReady() error = %v", err) } if !ready { t.Fatalf("gatewayStartReady() ready = false, want true (reason=%q)", reason) } } func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") cfg := config.DefaultConfig() cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName cfg.ModelList[0].APIKey = "" cfg.ModelList[0].AuthMethod = "" err := config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) ready, reason, err := h.gatewayStartReady() if err != nil { t.Fatalf("gatewayStartReady() error = %v", err) } if ready { t.Fatalf("gatewayStartReady() ready = true, want false") } if !strings.Contains(reason, "no credentials configured") { t.Fatalf("gatewayStartReady() reason = %q, want contains %q", reason, "no credentials configured") } } func TestGatewayStartReady_LocalModelWithoutAPIKey(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetModelProbeHooks(t) probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool { return false } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []config.ModelConfig{{ ModelName: "local-vllm", Model: "vllm/custom-model", APIBase: "http://localhost:8000/v1", }} cfg.Agents.Defaults.ModelName = "local-vllm" err = config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) ready, reason, err := h.gatewayStartReady() if err != nil { t.Fatalf("gatewayStartReady() error = %v", err) } if ready { t.Fatalf("gatewayStartReady() ready = true, want false without a running local service") } if !strings.Contains(reason, "not reachable") { t.Fatalf("gatewayStartReady() reason = %q, want contains %q", reason, "not reachable") } } func TestGatewayStartReady_LocalModelWithRunningService(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetModelProbeHooks(t) probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool { return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model" } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []config.ModelConfig{{ ModelName: "local-vllm", Model: "vllm/custom-model", APIBase: "http://127.0.0.1:8000/v1", }} cfg.Agents.Defaults.ModelName = "local-vllm" err = config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) ready, reason, err := h.gatewayStartReady() if err != nil { t.Fatalf("gatewayStartReady() error = %v", err) } if !ready { t.Fatalf("gatewayStartReady() ready = false, want true with a running local service (reason=%q)", reason) } } func TestGatewayStartReady_RemoteVLLMWithAPIKeyDoesNotProbe(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetModelProbeHooks(t) probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool { t.Fatalf("unexpected OpenAI-compatible probe for %q (%q)", apiBase, modelID) return false } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []config.ModelConfig{{ ModelName: "remote-vllm", Model: "vllm/custom-model", APIBase: "https://models.example.com/v1", APIKey: "remote-key", }} cfg.Agents.Defaults.ModelName = "remote-vllm" err = config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) ready, reason, err := h.gatewayStartReady() if err != nil { t.Fatalf("gatewayStartReady() error = %v", err) } if !ready { t.Fatalf("gatewayStartReady() ready = false, want true for remote vllm with api key (reason=%q)", reason) } } func TestGatewayStartReady_LocalOllamaUsesDefaultProbeBase(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetModelProbeHooks(t) probeOllamaModelFunc = func(apiBase, modelID string) bool { return apiBase == "http://localhost:11434/v1" && modelID == "llama3" } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []config.ModelConfig{{ ModelName: "local-ollama", Model: "ollama/llama3", }} cfg.Agents.Defaults.ModelName = "local-ollama" err = config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) ready, reason, err := h.gatewayStartReady() if err != nil { t.Fatalf("gatewayStartReady() error = %v", err) } if !ready { t.Fatalf("gatewayStartReady() ready = false, want true with default Ollama probe base (reason=%q)", reason) } } func TestGatewayStartReady_OAuthModelRequiresStoredCredential(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []config.ModelConfig{{ ModelName: "openai-oauth", Model: "openai/gpt-5.4", AuthMethod: "oauth", }} cfg.Agents.Defaults.ModelName = "openai-oauth" err = config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) ready, reason, err := h.gatewayStartReady() if err != nil { t.Fatalf("gatewayStartReady() error = %v", err) } if ready { t.Fatalf("gatewayStartReady() ready = true, want false without stored credential") } if !strings.Contains(reason, "no credentials configured") { t.Fatalf("gatewayStartReady() reason = %q, want contains %q", reason, "no credentials configured") } err = auth.SetCredential(oauthProviderOpenAI, &auth.AuthCredential{ AccessToken: "openai-token", Provider: oauthProviderOpenAI, AuthMethod: "oauth", }) if err != nil { t.Fatalf("SetCredential() error = %v", err) } ready, reason, err = h.gatewayStartReady() if err != nil { t.Fatalf("gatewayStartReady() error = %v", err) } if !ready { t.Fatalf("gatewayStartReady() ready = false, want true with stored credential (reason=%q)", reason) } } func TestGatewayStatusIncludesStartConditionWhenNotReady(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("unmarshal response: %v", err) } allowed, ok := body["gateway_start_allowed"].(bool) if !ok { t.Fatalf("gateway_start_allowed missing or not bool: %#v", body["gateway_start_allowed"]) } if allowed { t.Fatalf("gateway_start_allowed = true, want false") } if _, ok := body["gateway_start_reason"].(string); !ok { t.Fatalf("gateway_start_reason missing or not string: %#v", body["gateway_start_reason"]) } } func TestGatewayStatusKeepsRunningWhenHealthProbeFailsAfterRunning(t *testing.T) { resetGatewayTestState(t) configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) cmd := startLongRunningProcess(t) t.Cleanup(func() { if cmd.Process != nil { _ = cmd.Process.Kill() } _ = cmd.Wait() }) gateway.mu.Lock() gateway.cmd = cmd gateway.bootDefaultModel = "existing-model" // Simulate a process that has already reached the running state. setGatewayRuntimeStatusLocked("running") gateway.mu.Unlock() gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { return nil, errors.New("probe failed") } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("unmarshal response: %v", err) } if got := body["gateway_status"]; got != "running" { t.Fatalf("gateway_status = %#v, want %q", got, "running") } } func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) { resetGatewayTestState(t) configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) cmd := startLongRunningProcess(t) t.Cleanup(func() { if cmd.Process != nil { _ = cmd.Process.Kill() } _ = cmd.Wait() }) gateway.mu.Lock() setGatewayRuntimeStatusLocked("stopped") gateway.mu.Unlock() gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { return mockGatewayHealthResponse(http.StatusOK, cmd.Process.Pid), nil } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("unmarshal response: %v", err) } if got := body["gateway_status"]; got != "running" { t.Fatalf("gateway_status = %#v, want %q", got, "running") } if got := body["pid"]; got != float64(cmd.Process.Pid) { t.Fatalf("pid = %#v, want %d", got, cmd.Process.Pid) } if got := body["gateway_restart_required"]; got != false { t.Fatalf("gateway_restart_required = %#v, want false", got) } } func TestGatewayStatusRequiresRestartAfterDefaultModelChange(t *testing.T) { resetGatewayTestState(t) configPath := filepath.Join(t.TempDir(), "config.json") cfg := config.DefaultConfig() cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName cfg.ModelList[0].APIKey = "test-key" cfg.ModelList = append(cfg.ModelList, config.ModelConfig{ ModelName: "second-model", Model: "openai/gpt-4.1", APIKey: "second-key", }) if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) process, err := os.FindProcess(os.Getpid()) if err != nil { t.Fatalf("FindProcess() error = %v", err) } gateway.mu.Lock() gateway.cmd = &exec.Cmd{Process: process} gateway.bootDefaultModel = cfg.ModelList[0].ModelName setGatewayRuntimeStatusLocked("running") gateway.mu.Unlock() updatedCfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } updatedCfg.Agents.Defaults.ModelName = "second-model" if err := config.SaveConfig(configPath, updatedCfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("unmarshal response: %v", err) } if got := body["gateway_status"]; got != "running" { t.Fatalf("gateway_status = %#v, want %q", got, "running") } if got := body["boot_default_model"]; got != cfg.ModelList[0].ModelName { t.Fatalf("boot_default_model = %#v, want %q", got, cfg.ModelList[0].ModelName) } if got := body["config_default_model"]; got != "second-model" { t.Fatalf("config_default_model = %#v, want %q", got, "second-model") } if got := body["gateway_restart_required"]; got != true { t.Fatalf("gateway_restart_required = %#v, want true", got) } } func TestGatewayStatusReturnsErrorAfterStartupWindowExpires(t *testing.T) { resetGatewayTestState(t) configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) cmd := startLongRunningProcess(t) t.Cleanup(func() { if cmd.Process != nil { _ = cmd.Process.Kill() } _ = cmd.Wait() }) gateway.mu.Lock() gateway.cmd = cmd gateway.bootDefaultModel = "existing-model" setGatewayRuntimeStatusLocked("starting") gateway.startupDeadline = time.Now().Add(-time.Second) gateway.mu.Unlock() gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { return nil, errors.New("probe failed") } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("unmarshal response: %v", err) } if got := body["gateway_status"]; got != "error" { t.Fatalf("gateway_status = %#v, want %q", got, "error") } } func TestGatewayStatusReturnsRestartingDuringRestartGap(t *testing.T) { resetGatewayTestState(t) configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) gateway.mu.Lock() setGatewayRuntimeStatusLocked("restarting") gateway.mu.Unlock() rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("unmarshal response: %v", err) } if got := body["gateway_status"]; got != "restarting" { t.Fatalf("gateway_status = %#v, want %q", got, "restarting") } } func TestGatewayRestartKeepsRunningProcessWhenPreconditionsFail(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") cfg := config.DefaultConfig() cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName cfg.ModelList[0].APIKey = "" cfg.ModelList[0].AuthMethod = "" if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) cmd := startLongRunningProcess(t) t.Cleanup(func() { gateway.mu.Lock() if gateway.cmd == cmd { gateway.cmd = nil gateway.bootDefaultModel = "" } gateway.mu.Unlock() if cmd.Process != nil { _ = cmd.Process.Kill() } _ = cmd.Wait() }) gateway.mu.Lock() gateway.cmd = cmd gateway.bootDefaultModel = "existing-model" gateway.mu.Unlock() rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/gateway/restart", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) } gateway.mu.Lock() stillRunning := gateway.cmd == cmd && isCmdProcessAliveLocked(cmd) gateway.mu.Unlock() if !stillRunning { t.Fatalf("gateway process was stopped when restart preconditions failed") } } func TestGatewayRestartKeepsOldProcessWhenItDoesNotExitInTime(t *testing.T) { resetGatewayTestState(t) configPath := filepath.Join(t.TempDir(), "config.json") cfg := config.DefaultConfig() cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName cfg.ModelList[0].APIKey = "test-key" if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) cmd := startIgnoringTermProcess(t) t.Cleanup(func() { gateway.mu.Lock() if gateway.cmd == cmd { gateway.cmd = nil gateway.bootDefaultModel = "" } gateway.mu.Unlock() if cmd.Process != nil { _ = cmd.Process.Kill() } _ = cmd.Wait() }) gatewayRestartGracePeriod = 150 * time.Millisecond gatewayRestartForceKillWindow = 150 * time.Millisecond gatewayRestartPollInterval = 10 * time.Millisecond gateway.mu.Lock() gateway.cmd = cmd gateway.bootDefaultModel = "existing-model" setGatewayRuntimeStatusLocked("running") gateway.mu.Unlock() rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/gateway/restart", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError) } gateway.mu.Lock() stillRunning := gateway.cmd == cmd && isCmdProcessAliveLocked(cmd) status := gateway.runtimeStatus gateway.mu.Unlock() if !stillRunning { t.Fatalf("gateway process was replaced before the old process exited") } if status != "running" { t.Fatalf("runtimeStatus = %q, want %q", status, "running") } } func TestGatewayRestartReturnsErrorStatusWhenReplacementFailsToStart(t *testing.T) { resetGatewayTestState(t) configPath := filepath.Join(t.TempDir(), "config.json") cfg := config.DefaultConfig() cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName cfg.ModelList[0].APIKey = "test-key" if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } invalidBinaryPath := filepath.Join(t.TempDir(), "fake-picoclaw") if err := os.WriteFile(invalidBinaryPath, []byte("#!/bin/sh\n"), 0o644); err != nil { t.Fatalf("WriteFile() error = %v", err) } t.Setenv("PICOCLAW_BINARY", invalidBinaryPath) h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/gateway/restart", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusInternalServerError { t.Fatalf("restart status = %d, want %d", rec.Code, http.StatusInternalServerError) } statusRec := httptest.NewRecorder() statusReq := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) mux.ServeHTTP(statusRec, statusReq) if statusRec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", statusRec.Code, http.StatusOK) } var body map[string]any if err := json.Unmarshal(statusRec.Body.Bytes(), &body); err != nil { t.Fatalf("unmarshal response: %v", err) } if got := body["gateway_status"]; got != "error" { t.Fatalf("gateway_status = %#v, want %q", got, "error") } } func TestGatewayStatusExcludesLogsFields(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("unmarshal response: %v", err) } if _, ok := body["logs"]; ok { t.Fatalf("logs unexpectedly present in status response: %#v", body["logs"]) } if _, ok := body["log_total"]; ok { t.Fatalf("log_total unexpectedly present in status response: %#v", body["log_total"]) } if _, ok := body["log_run_id"]; ok { t.Fatalf("log_run_id unexpectedly present in status response: %#v", body["log_run_id"]) } } func TestGatewayLogsReturnsIncrementalHistory(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) gateway.logs.Clear() gateway.logs.Append("first line") gateway.logs.Append("second line") runID := gateway.logs.RunID() rec := httptest.NewRecorder() req := httptest.NewRequest( http.MethodGet, "/api/gateway/logs?log_offset=1&log_run_id="+strconv.Itoa(runID), nil, ) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("logs status = %d, want %d", rec.Code, http.StatusOK) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("unmarshal logs response: %v", err) } logs, ok := body["logs"].([]any) if !ok { t.Fatalf("logs missing or not array: %#v", body["logs"]) } if len(logs) != 1 || logs[0] != "second line" { t.Fatalf("logs = %#v, want [\"second line\"]", logs) } if got := body["log_total"]; got != float64(2) { t.Fatalf("log_total = %#v, want 2", got) } if got := body["log_run_id"]; got != float64(runID) { t.Fatalf("log_run_id = %#v, want %d", got, runID) } } func TestGatewayClearLogsResetsBufferedHistory(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) gateway.logs.Clear() gateway.logs.Append("first line") gateway.logs.Append("second line") previousRunID := gateway.logs.RunID() clearRec := httptest.NewRecorder() clearReq := httptest.NewRequest(http.MethodPost, "/api/gateway/logs/clear", nil) mux.ServeHTTP(clearRec, clearReq) if clearRec.Code != http.StatusOK { t.Fatalf("clear status = %d, want %d", clearRec.Code, http.StatusOK) } var clearBody map[string]any if err := json.Unmarshal(clearRec.Body.Bytes(), &clearBody); err != nil { t.Fatalf("unmarshal clear response: %v", err) } if got := clearBody["status"]; got != "cleared" { t.Fatalf("clear status body = %#v, want %q", got, "cleared") } clearRunID, ok := clearBody["log_run_id"].(float64) if !ok { t.Fatalf("log_run_id missing or not number: %#v", clearBody["log_run_id"]) } if int(clearRunID) <= previousRunID { t.Fatalf("log_run_id = %d, want > %d", int(clearRunID), previousRunID) } logsRec := httptest.NewRecorder() logsReq := httptest.NewRequest( http.MethodGet, "/api/gateway/logs?log_offset=0&log_run_id="+strconv.Itoa(previousRunID), nil, ) mux.ServeHTTP(logsRec, logsReq) if logsRec.Code != http.StatusOK { t.Fatalf("logs code = %d, want %d", logsRec.Code, http.StatusOK) } var logsBody map[string]any if err := json.Unmarshal(logsRec.Body.Bytes(), &logsBody); err != nil { t.Fatalf("unmarshal logs response: %v", err) } logs, ok := logsBody["logs"].([]any) if !ok { t.Fatalf("logs missing or not array: %#v", logsBody["logs"]) } if len(logs) != 0 { t.Fatalf("logs len = %d, want 0", len(logs)) } if got := logsBody["log_total"]; got != float64(0) { t.Fatalf("log_total = %#v, want 0", got) } if got := logsBody["log_run_id"]; got != clearBody["log_run_id"] { t.Fatalf("log_run_id = %#v, want %#v", got, clearBody["log_run_id"]) } } func TestFindPicoclawBinary_EnvOverride(t *testing.T) { // Create a temporary file to act as the mock binary tmpDir := t.TempDir() mockBinary := filepath.Join(tmpDir, "picoclaw-mock") if err := os.WriteFile(mockBinary, []byte("mock"), 0o755); err != nil { t.Fatalf("WriteFile() error = %v", err) } t.Setenv("PICOCLAW_BINARY", mockBinary) got := utils.FindPicoclawBinary() if got != mockBinary { t.Errorf("FindPicoclawBinary() = %q, want %q", got, mockBinary) } } func TestFindPicoclawBinary_EnvOverride_InvalidPath(t *testing.T) { // When PICOCLAW_BINARY points to a non-existent path, fall through to next strategy t.Setenv("PICOCLAW_BINARY", "/nonexistent/picoclaw-binary") got := utils.FindPicoclawBinary() // Should not return the invalid path; falls back to "picoclaw" or another found path if got == "/nonexistent/picoclaw-binary" { t.Errorf("FindPicoclawBinary() returned invalid env path %q, expected fallback", got) } } ================================================ FILE: web/backend/api/launcher_config.go ================================================ package api import ( "encoding/json" "fmt" "net/http" "github.com/sipeed/picoclaw/web/backend/launcherconfig" ) type launcherConfigPayload struct { Port int `json:"port"` Public bool `json:"public"` AllowedCIDRs []string `json:"allowed_cidrs"` } func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/system/launcher-config", h.handleGetLauncherConfig) mux.HandleFunc("PUT /api/system/launcher-config", h.handleUpdateLauncherConfig) } func (h *Handler) launcherConfigPath() string { return launcherconfig.PathForAppConfig(h.configPath) } func (h *Handler) launcherFallbackConfig() launcherconfig.Config { port := h.serverPort if port <= 0 { port = launcherconfig.DefaultPort } return launcherconfig.Config{ Port: port, Public: h.serverPublic, AllowedCIDRs: append([]string(nil), h.serverCIDRs...), } } func (h *Handler) loadLauncherConfig() (launcherconfig.Config, error) { return launcherconfig.Load(h.launcherConfigPath(), h.launcherFallbackConfig()) } func (h *Handler) handleGetLauncherConfig(w http.ResponseWriter, r *http.Request) { cfg, err := h.loadLauncherConfig() if err != nil { http.Error(w, fmt.Sprintf("Failed to load launcher config: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(launcherConfigPayload{ Port: cfg.Port, Public: cfg.Public, AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), }) } func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Request) { var payload launcherConfigPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } cfg := launcherconfig.Config{ Port: payload.Port, Public: payload.Public, AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...), } if err := launcherconfig.Validate(cfg); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := launcherconfig.Save(h.launcherConfigPath(), cfg); err != nil { http.Error(w, fmt.Sprintf("Failed to save launcher config: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(launcherConfigPayload{ Port: cfg.Port, Public: cfg.Public, AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), }) } ================================================ FILE: web/backend/api/launcher_config_test.go ================================================ package api import ( "encoding/json" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "github.com/sipeed/picoclaw/web/backend/launcherconfig" ) func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) h.SetServerOptions(19999, true, false, []string{"192.168.1.0/24"}) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/system/launcher-config", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var got launcherConfigPayload if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("unmarshal response: %v", err) } if got.Port != 19999 || !got.Public { t.Fatalf("response = %+v, want port=19999 public=true", got) } if len(got.AllowedCIDRs) != 1 || got.AllowedCIDRs[0] != "192.168.1.0/24" { t.Fatalf("response allowed_cidrs = %v, want [192.168.1.0/24]", got.AllowedCIDRs) } } func TestPutLauncherConfigPersists(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest( http.MethodPut, "/api/system/launcher-config", strings.NewReader(`{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`), ) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } path := launcherconfig.PathForAppConfig(configPath) cfg, err := launcherconfig.Load(path, launcherconfig.Default()) if err != nil { t.Fatalf("launcherconfig.Load() error = %v", err) } if cfg.Port != 18080 || !cfg.Public { t.Fatalf("saved config = %+v, want port=18080 public=true", cfg) } if len(cfg.AllowedCIDRs) != 1 || cfg.AllowedCIDRs[0] != "192.168.1.0/24" { t.Fatalf("saved config allowed_cidrs = %v, want [192.168.1.0/24]", cfg.AllowedCIDRs) } } func TestPutLauncherConfigRejectsInvalidPort(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest( http.MethodPut, "/api/system/launcher-config", strings.NewReader(`{"port":70000,"public":false}`), ) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) } } func TestPutLauncherConfigRejectsInvalidCIDR(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest( http.MethodPut, "/api/system/launcher-config", strings.NewReader(`{"port":18080,"public":false,"allowed_cidrs":["bad-cidr"]}`), ) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) } } ================================================ FILE: web/backend/api/log.go ================================================ package api import "sync" // LogBuffer is a thread-safe ring buffer that stores the most recent N log lines. // It supports incremental reads via LinesSince and tracks a runID that increments // whenever the buffer is reset or cleared so clients can detect log history resets. type LogBuffer struct { mu sync.RWMutex lines []string cap int total int // total lines ever appended in current run runID int } // NewLogBuffer creates a LogBuffer with the given capacity. func NewLogBuffer(capacity int) *LogBuffer { return &LogBuffer{ lines: make([]string, 0, capacity), cap: capacity, } } // Append adds a line to the buffer. If the buffer is full, the oldest line is evicted. func (b *LogBuffer) Append(line string) { b.mu.Lock() defer b.mu.Unlock() if len(b.lines) < b.cap { b.lines = append(b.lines, line) } else { b.lines[b.total%b.cap] = line } b.total++ } // Reset clears the buffer and increments the runID. Call this when starting a new gateway process. func (b *LogBuffer) Reset() { b.mu.Lock() defer b.mu.Unlock() b.lines = b.lines[:0] b.total = 0 b.runID++ } // Clear removes all buffered lines and increments the runID so clients treat // subsequent reads as a new log stream. func (b *LogBuffer) Clear() { b.Reset() } // LinesSince returns lines appended after the given offset, the current total count, and the runID. // If offset >= total, no lines are returned. If offset is too old (evicted), all buffered lines are returned. func (b *LogBuffer) LinesSince(offset int) (lines []string, total int, runID int) { b.mu.RLock() defer b.mu.RUnlock() total = b.total runID = b.runID if offset >= b.total { return nil, total, runID } buffered := len(b.lines) // How many new lines since offset newCount := b.total - offset if newCount > buffered { newCount = buffered } result := make([]string, newCount) if b.total <= b.cap { // Buffer hasn't wrapped yet — simple slice copy(result, b.lines[buffered-newCount:]) } else { // Buffer has wrapped — read from ring start := (b.total - newCount) % b.cap for i := range newCount { result[i] = b.lines[(start+i)%b.cap] } } return result, total, runID } // RunID returns the current run identifier. func (b *LogBuffer) RunID() int { b.mu.RLock() defer b.mu.RUnlock() return b.runID } ================================================ FILE: web/backend/api/model_status.go ================================================ package api import ( "encoding/json" "fmt" "net" "net/http" "net/url" "strings" "time" "github.com/sipeed/picoclaw/pkg/config" ) const modelProbeTimeout = 800 * time.Millisecond var ( probeTCPServiceFunc = probeTCPService probeOllamaModelFunc = probeOllamaModel probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel ) func hasModelConfiguration(m config.ModelConfig) bool { authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod)) apiKey := strings.TrimSpace(m.APIKey) if authMethod == "oauth" || authMethod == "token" { if provider, ok := oauthProviderForModel(m.Model); ok { cred, err := oauthGetCredential(provider) if err != nil || cred == nil { return false } return strings.TrimSpace(cred.AccessToken) != "" || strings.TrimSpace(cred.RefreshToken) != "" } return true } if requiresRuntimeProbe(m) { return true } return apiKey != "" } // isModelConfigured reports whether a model is currently available to use. // Local models must be reachable; remote/API-key models only need saved config. func isModelConfigured(m config.ModelConfig) bool { if !hasModelConfiguration(m) { return false } if requiresRuntimeProbe(m) { return probeLocalModelAvailability(m) } return true } func requiresRuntimeProbe(m config.ModelConfig) bool { authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod)) if authMethod == "local" { return true } switch modelProtocol(m.Model) { case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot": return true case "ollama", "vllm": apiBase := strings.TrimSpace(m.APIBase) return apiBase == "" || hasLocalAPIBase(apiBase) } if hasLocalAPIBase(m.APIBase) { return true } return false } func probeLocalModelAvailability(m config.ModelConfig) bool { apiBase := modelProbeAPIBase(m) protocol, modelID := splitModel(m.Model) switch protocol { case "ollama": return probeOllamaModelFunc(apiBase, modelID) case "vllm": return probeOpenAICompatibleModelFunc(apiBase, modelID) case "github-copilot", "copilot": return probeTCPServiceFunc(apiBase) case "claude-cli", "claudecli", "codex-cli", "codexcli": return true default: if hasLocalAPIBase(apiBase) { return probeOpenAICompatibleModelFunc(apiBase, modelID) } return false } } func modelProbeAPIBase(m config.ModelConfig) string { if apiBase := strings.TrimSpace(m.APIBase); apiBase != "" { return normalizeModelProbeAPIBase(apiBase) } switch modelProtocol(m.Model) { case "ollama": return "http://localhost:11434/v1" case "vllm": return "http://localhost:8000/v1" case "github-copilot", "copilot": return "localhost:4321" default: return "" } } func normalizeModelProbeAPIBase(raw string) string { u, err := parseAPIBase(raw) if err != nil { return strings.TrimSpace(raw) } switch strings.ToLower(u.Hostname()) { case "0.0.0.0": u.Host = net.JoinHostPort("127.0.0.1", u.Port()) case "::": u.Host = net.JoinHostPort("::1", u.Port()) default: return strings.TrimSpace(raw) } if u.Port() == "" { u.Host = u.Hostname() } return u.String() } func oauthProviderForModel(model string) (string, bool) { switch modelProtocol(model) { case "openai": return oauthProviderOpenAI, true case "anthropic": return oauthProviderAnthropic, true case "antigravity", "google-antigravity": return oauthProviderGoogleAntigravity, true default: return "", false } } func modelProtocol(model string) string { protocol, _ := splitModel(model) return protocol } func splitModel(model string) (protocol, modelID string) { model = strings.ToLower(strings.TrimSpace(model)) protocol, _, found := strings.Cut(model, "/") if !found { return "openai", model } return protocol, strings.TrimSpace(model[strings.Index(model, "/")+1:]) } func hasLocalAPIBase(raw string) bool { raw = strings.TrimSpace(raw) if raw == "" { return false } u, err := url.Parse(raw) if err != nil || u.Hostname() == "" { u, err = url.Parse("//" + raw) if err != nil { return false } } switch strings.ToLower(u.Hostname()) { case "localhost", "127.0.0.1", "::1", "0.0.0.0": return true default: return false } } func probeTCPService(raw string) bool { hostPort, err := hostPortFromAPIBase(raw) if err != nil { return false } conn, err := net.DialTimeout("tcp", hostPort, modelProbeTimeout) if err != nil { return false } _ = conn.Close() return true } func probeOllamaModel(apiBase, modelID string) bool { root, err := apiRootFromAPIBase(apiBase) if err != nil { return false } var resp struct { Models []struct { Name string `json:"name"` Model string `json:"model"` } `json:"models"` } if err := getJSON(root+"/api/tags", &resp); err != nil { return false } for _, model := range resp.Models { if ollamaModelMatches(model.Name, modelID) || ollamaModelMatches(model.Model, modelID) { return true } } return false } func probeOpenAICompatibleModel(apiBase, modelID string) bool { if strings.TrimSpace(apiBase) == "" { return false } var resp struct { Data []struct { ID string `json:"id"` } `json:"data"` } if err := getJSON(strings.TrimRight(strings.TrimSpace(apiBase), "/")+"/models", &resp); err != nil { return false } for _, model := range resp.Data { if strings.EqualFold(strings.TrimSpace(model.ID), modelID) { return true } } return false } func getJSON(rawURL string, out any) error { req, err := http.NewRequest(http.MethodGet, rawURL, nil) if err != nil { return err } client := &http.Client{Timeout: modelProbeTimeout} resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("unexpected status %d", resp.StatusCode) } return json.NewDecoder(resp.Body).Decode(out) } func apiRootFromAPIBase(raw string) (string, error) { u, err := parseAPIBase(raw) if err != nil { return "", err } return (&url.URL{Scheme: u.Scheme, Host: u.Host}).String(), nil } func hostPortFromAPIBase(raw string) (string, error) { u, err := parseAPIBase(raw) if err != nil { return "", err } if port := u.Port(); port != "" { return u.Host, nil } switch strings.ToLower(u.Scheme) { case "https": return net.JoinHostPort(u.Hostname(), "443"), nil default: return net.JoinHostPort(u.Hostname(), "80"), nil } } func parseAPIBase(raw string) (*url.URL, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, fmt.Errorf("empty api base") } u, err := url.Parse(raw) if err == nil && u.Hostname() != "" { return u, nil } u, err = url.Parse("//" + raw) if err != nil || u.Hostname() == "" { return nil, fmt.Errorf("invalid api base %q", raw) } if u.Scheme == "" { u.Scheme = "http" } return u, nil } func ollamaModelMatches(candidate, want string) bool { candidate = strings.TrimSpace(candidate) want = strings.TrimSpace(want) if candidate == "" || want == "" { return false } if strings.EqualFold(candidate, want) { return true } base, _, _ := strings.Cut(candidate, ":") return strings.EqualFold(base, want) } ================================================ FILE: web/backend/api/models.go ================================================ package api import ( "encoding/json" "fmt" "io" "net/http" "strconv" "sync" "github.com/sipeed/picoclaw/pkg/config" ) // registerModelRoutes binds model list management endpoints to the ServeMux. func (h *Handler) registerModelRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/models", h.handleListModels) mux.HandleFunc("POST /api/models", h.handleAddModel) mux.HandleFunc("POST /api/models/default", h.handleSetDefaultModel) mux.HandleFunc("PUT /api/models/{index}", h.handleUpdateModel) mux.HandleFunc("DELETE /api/models/{index}", h.handleDeleteModel) } // modelResponse is the JSON structure returned for each model in the list. // All ModelConfig fields are included so the frontend can display and edit them. type modelResponse struct { Index int `json:"index"` ModelName string `json:"model_name"` Model string `json:"model"` APIBase string `json:"api_base,omitempty"` APIKey string `json:"api_key"` Proxy string `json:"proxy,omitempty"` AuthMethod string `json:"auth_method,omitempty"` // Advanced fields ConnectMode string `json:"connect_mode,omitempty"` Workspace string `json:"workspace,omitempty"` RPM int `json:"rpm,omitempty"` MaxTokensField string `json:"max_tokens_field,omitempty"` RequestTimeout int `json:"request_timeout,omitempty"` ThinkingLevel string `json:"thinking_level,omitempty"` // Meta Configured bool `json:"configured"` IsDefault bool `json:"is_default"` } // handleListModels returns all model_list entries with masked API keys. // // GET /api/models func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } defaultModel := cfg.Agents.Defaults.GetModelName() configured := make([]bool, len(cfg.ModelList)) var wg sync.WaitGroup wg.Add(len(cfg.ModelList)) for i, m := range cfg.ModelList { go func(i int, m config.ModelConfig) { defer wg.Done() configured[i] = isModelConfigured(m) }(i, m) } wg.Wait() models := make([]modelResponse, 0, len(cfg.ModelList)) for i, m := range cfg.ModelList { models = append(models, modelResponse{ Index: i, ModelName: m.ModelName, Model: m.Model, APIBase: m.APIBase, APIKey: maskAPIKey(m.APIKey), Proxy: m.Proxy, AuthMethod: m.AuthMethod, ConnectMode: m.ConnectMode, Workspace: m.Workspace, RPM: m.RPM, MaxTokensField: m.MaxTokensField, RequestTimeout: m.RequestTimeout, ThinkingLevel: m.ThinkingLevel, Configured: configured[i], IsDefault: m.ModelName == defaultModel, }) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "models": models, "total": len(models), "default_model": defaultModel, }) } // handleAddModel appends a new model configuration entry. // // POST /api/models func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "Failed to read request body", http.StatusBadRequest) return } defer r.Body.Close() var mc config.ModelConfig if err = json.Unmarshal(body, &mc); err != nil { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } if err = mc.Validate(); err != nil { http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest) return } cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } cfg.ModelList = append(cfg.ModelList, mc) if err := config.SaveConfig(h.configPath, cfg); err != nil { http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "status": "ok", "index": len(cfg.ModelList) - 1, }) } // handleUpdateModel replaces a model configuration entry at the given index. // If the request body omits api_key (or sends an empty string), the existing // stored key is preserved so callers can update only api_base / proxy without // exposing or clearing the secret. // // PUT /api/models/{index} func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { idx, err := strconv.Atoi(r.PathValue("index")) if err != nil { http.Error(w, "Invalid index", http.StatusBadRequest) return } body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "Failed to read request body", http.StatusBadRequest) return } defer r.Body.Close() var mc config.ModelConfig if err = json.Unmarshal(body, &mc); err != nil { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } if err = mc.Validate(); err != nil { http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest) return } cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } if idx < 0 || idx >= len(cfg.ModelList) { http.Error(w, fmt.Sprintf("Index %d out of range (0-%d)", idx, len(cfg.ModelList)-1), http.StatusNotFound) return } // Preserve the existing API key when the caller omits it (empty string). // This lets the UI update api_base / proxy without clearing the stored secret. if mc.APIKey == "" { mc.APIKey = cfg.ModelList[idx].APIKey } cfg.ModelList[idx] = mc if err := config.SaveConfig(h.configPath, cfg); err != nil { http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } // handleDeleteModel removes a model configuration entry at the given index. // // DELETE /api/models/{index} func (h *Handler) handleDeleteModel(w http.ResponseWriter, r *http.Request) { idx, err := strconv.Atoi(r.PathValue("index")) if err != nil { http.Error(w, "Invalid index", http.StatusBadRequest) return } cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } if idx < 0 || idx >= len(cfg.ModelList) { http.Error(w, fmt.Sprintf("Index %d out of range (0-%d)", idx, len(cfg.ModelList)-1), http.StatusNotFound) return } deletedModelName := cfg.ModelList[idx].ModelName cfg.ModelList = append(cfg.ModelList[:idx], cfg.ModelList[idx+1:]...) // If the deleted model was the default, clear it. if cfg.Agents.Defaults.ModelName == deletedModelName { cfg.Agents.Defaults.ModelName = "" } if cfg.Agents.Defaults.Model == deletedModelName { cfg.Agents.Defaults.Model = "" } if err := config.SaveConfig(h.configPath, cfg); err != nil { http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } // handleSetDefaultModel sets the default model for all agents. // // POST /api/models/default func (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "Failed to read request body", http.StatusBadRequest) return } defer r.Body.Close() var req struct { ModelName string `json:"model_name"` } if err = json.Unmarshal(body, &req); err != nil { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } if req.ModelName == "" { http.Error(w, "model_name is required", http.StatusBadRequest) return } cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } // Verify the model_name exists in model_list found := false for _, m := range cfg.ModelList { if m.ModelName == req.ModelName { found = true break } } if !found { http.Error(w, fmt.Sprintf("Model %q not found in model_list", req.ModelName), http.StatusNotFound) return } cfg.Agents.Defaults.ModelName = req.ModelName if err := config.SaveConfig(h.configPath, cfg); err != nil { http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "status": "ok", "default_model": req.ModelName, }) } // maskAPIKey returns a masked version of an API key for safe display. // Keys longer than 8 chars show prefix + last 4 chars: "sk-****abcd" // Shorter keys are fully masked as "****". // Empty keys return empty string. func maskAPIKey(key string) string { if key == "" { return "" } if len(key) <= 8 { return "****" } // Show first 3 chars and last 4 chars return key[:3] + "****" + key[len(key)-4:] } ================================================ FILE: web/backend/api/models_test.go ================================================ package api import ( "encoding/json" "net/http" "net/http/httptest" "sync" "testing" "time" "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" ) func resetModelProbeHooks(t *testing.T) { t.Helper() origTCPProbe := probeTCPServiceFunc origOllamaProbe := probeOllamaModelFunc origOpenAIProbe := probeOpenAICompatibleModelFunc t.Cleanup(func() { probeTCPServiceFunc = origTCPProbe probeOllamaModelFunc = origOllamaProbe probeOpenAICompatibleModelFunc = origOpenAIProbe }) } func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetOAuthHooks(t) resetModelProbeHooks(t) var mu sync.Mutex var openAIProbes []string var ollamaProbes []string var tcpProbes []string probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool { mu.Lock() openAIProbes = append(openAIProbes, apiBase+"|"+modelID) mu.Unlock() return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model" } probeOllamaModelFunc = func(apiBase, modelID string) bool { mu.Lock() ollamaProbes = append(ollamaProbes, apiBase+"|"+modelID) mu.Unlock() return apiBase == "http://localhost:11434/v1" && modelID == "llama3" } probeTCPServiceFunc = func(apiBase string) bool { mu.Lock() tcpProbes = append(tcpProbes, apiBase) mu.Unlock() return apiBase == "http://127.0.0.1:4321" } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []config.ModelConfig{ { ModelName: "openai-oauth", Model: "openai/gpt-5.4", AuthMethod: "oauth", }, { ModelName: "vllm-local", Model: "vllm/custom-model", APIBase: "http://127.0.0.1:8000/v1", }, { ModelName: "ollama-default", Model: "ollama/llama3", }, { ModelName: "vllm-remote", Model: "vllm/custom-model", APIBase: "https://models.example.com/v1", APIKey: "remote-key", }, { ModelName: "copilot-gpt-5.4", Model: "github-copilot/gpt-5.4", APIBase: "http://127.0.0.1:4321", AuthMethod: "oauth", }, } cfg.Agents.Defaults.ModelName = "openai-oauth" if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp struct { Models []modelResponse `json:"models"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } got := make(map[string]bool, len(resp.Models)) for _, model := range resp.Models { got[model.ModelName] = model.Configured } if got["openai-oauth"] { t.Fatalf("openai oauth model configured = true, want false without stored credential") } if !got["vllm-local"] { t.Fatalf("vllm local model configured = false, want true when local probe succeeds") } if !got["ollama-default"] { t.Fatalf("ollama default model configured = false, want true when default local probe succeeds") } if !got["vllm-remote"] { t.Fatalf("remote vllm model configured = false, want true with api_key") } if !got["copilot-gpt-5.4"] { t.Fatalf("copilot model configured = false, want true when local bridge probe succeeds") } if len(openAIProbes) != 1 || openAIProbes[0] != "http://127.0.0.1:8000/v1|custom-model" { t.Fatalf("openAI probes = %#v, want only local vllm probe", openAIProbes) } if len(ollamaProbes) != 1 || ollamaProbes[0] != "http://localhost:11434/v1|llama3" { t.Fatalf("ollama probes = %#v, want default local probe", ollamaProbes) } if len(tcpProbes) != 1 || tcpProbes[0] != "http://127.0.0.1:4321" { t.Fatalf("tcp probes = %#v, want only local copilot probe", tcpProbes) } } func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetOAuthHooks(t) resetModelProbeHooks(t) cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []config.ModelConfig{{ ModelName: "claude-oauth", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "oauth", }} cfg.Agents.Defaults.ModelName = "claude-oauth" if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } if err := auth.SetCredential(oauthProviderAnthropic, &auth.AuthCredential{ AccessToken: "anthropic-token", Provider: oauthProviderAnthropic, AuthMethod: "oauth", }); err != nil { t.Fatalf("SetCredential() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp struct { Models []modelResponse `json:"models"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) } if !resp.Models[0].Configured { t.Fatalf("oauth model configured = false, want true with stored credential") } } func TestHandleListModels_ProbesLocalModelsConcurrently(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetOAuthHooks(t) resetModelProbeHooks(t) started := make(chan string, 2) release := make(chan struct{}) probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool { started <- apiBase + "|" + modelID <-release return true } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []config.ModelConfig{ { ModelName: "local-vllm-a", Model: "vllm/custom-a", APIBase: "http://127.0.0.1:8000/v1", }, { ModelName: "local-vllm-b", Model: "vllm/custom-b", APIBase: "http://127.0.0.1:8001/v1", }, } if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) recCh := make(chan *httptest.ResponseRecorder, 1) go func() { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models", nil) mux.ServeHTTP(rec, req) recCh <- rec }() for i := 0; i < 2; i++ { select { case <-started: case <-time.After(200 * time.Millisecond): t.Fatal("expected both local probes to start before the first one completed") } } close(release) rec := <-recCh if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } } func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetOAuthHooks(t) resetModelProbeHooks(t) var gotProbe string probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool { gotProbe = apiBase + "|" + modelID return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model" } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.ModelList = []config.ModelConfig{{ ModelName: "vllm-local", Model: "vllm/custom-model", APIBase: "http://0.0.0.0:8000/v1", }} if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp struct { Models []modelResponse `json:"models"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) } if !resp.Models[0].Configured { t.Fatal("wildcard-bound local model configured = false, want true after probe host normalization") } if gotProbe != "http://127.0.0.1:8000/v1|custom-model" { t.Fatalf("probe api base = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model") } } ================================================ FILE: web/backend/api/oauth.go ================================================ package api import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "html" "io" "net/http" "strings" "time" "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" ) const ( oauthProviderOpenAI = "openai" oauthProviderAnthropic = "anthropic" oauthProviderGoogleAntigravity = "google-antigravity" oauthMethodBrowser = "browser" oauthMethodDeviceCode = "device_code" oauthMethodToken = "token" oauthFlowPending = "pending" oauthFlowSuccess = "success" oauthFlowError = "error" oauthFlowExpired = "expired" ) const ( oauthBrowserFlowTTL = 10 * time.Minute oauthDeviceCodeFlowTTL = 15 * time.Minute oauthTerminalFlowGC = 30 * time.Minute ) var oauthProviderOrder = []string{ oauthProviderOpenAI, oauthProviderAnthropic, oauthProviderGoogleAntigravity, } var oauthProviderMethods = map[string][]string{ oauthProviderOpenAI: {oauthMethodBrowser, oauthMethodDeviceCode, oauthMethodToken}, oauthProviderAnthropic: {oauthMethodToken}, oauthProviderGoogleAntigravity: {oauthMethodBrowser}, } var oauthProviderLabels = map[string]string{ oauthProviderOpenAI: "OpenAI", oauthProviderAnthropic: "Anthropic", oauthProviderGoogleAntigravity: "Google Antigravity", } var ( oauthNow = time.Now oauthGeneratePKCE = auth.GeneratePKCE oauthGenerateState = auth.GenerateState oauthBuildAuthorizeURL = auth.BuildAuthorizeURL oauthRequestDeviceCode = auth.RequestDeviceCode oauthPollDeviceCodeOnce = auth.PollDeviceCodeOnce oauthExchangeCodeForTokens = auth.ExchangeCodeForTokens oauthGetCredential = auth.GetCredential oauthSetCredential = auth.SetCredential oauthDeleteCredential = auth.DeleteCredential oauthLoadConfig = config.LoadConfig oauthSaveConfig = config.SaveConfig oauthFetchAntigravityProject = providers.FetchAntigravityProjectID oauthFetchGoogleUserEmailFunc = fetchGoogleUserEmail ) type oauthFlow struct { ID string Provider string Method string Status string CreatedAt time.Time UpdatedAt time.Time ExpiresAt time.Time Error string CodeVerifier string OAuthState string RedirectURI string DeviceAuthID string UserCode string VerifyURL string Interval int } type oauthProviderStatus struct { Provider string `json:"provider"` DisplayName string `json:"display_name"` Methods []string `json:"methods"` LoggedIn bool `json:"logged_in"` Status string `json:"status"` AuthMethod string `json:"auth_method,omitempty"` ExpiresAt string `json:"expires_at,omitempty"` AccountID string `json:"account_id,omitempty"` Email string `json:"email,omitempty"` ProjectID string `json:"project_id,omitempty"` } type oauthFlowResponse struct { FlowID string `json:"flow_id"` Provider string `json:"provider"` Method string `json:"method"` Status string `json:"status"` ExpiresAt string `json:"expires_at,omitempty"` Error string `json:"error,omitempty"` UserCode string `json:"user_code,omitempty"` VerifyURL string `json:"verify_url,omitempty"` Interval int `json:"interval,omitempty"` } // registerOAuthRoutes binds OAuth login/logout endpoints to the ServeMux. func (h *Handler) registerOAuthRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/oauth/providers", h.handleListOAuthProviders) mux.HandleFunc("POST /api/oauth/login", h.handleOAuthLogin) mux.HandleFunc("GET /api/oauth/flows/{id}", h.handleGetOAuthFlow) mux.HandleFunc("POST /api/oauth/flows/{id}/poll", h.handlePollOAuthFlow) mux.HandleFunc("POST /api/oauth/logout", h.handleOAuthLogout) mux.HandleFunc("GET /oauth/callback", h.handleOAuthCallback) } func (h *Handler) handleListOAuthProviders(w http.ResponseWriter, r *http.Request) { providersResp := make([]oauthProviderStatus, 0, len(oauthProviderOrder)) for _, provider := range oauthProviderOrder { cred, err := oauthGetCredential(provider) if err != nil { http.Error(w, fmt.Sprintf("failed to load credentials: %v", err), http.StatusInternalServerError) return } item := oauthProviderStatus{ Provider: provider, DisplayName: oauthProviderLabels[provider], Methods: oauthProviderMethods[provider], Status: "not_logged_in", } if cred != nil { item.LoggedIn = true item.AuthMethod = cred.AuthMethod item.AccountID = cred.AccountID item.Email = cred.Email item.ProjectID = cred.ProjectID if !cred.ExpiresAt.IsZero() { item.ExpiresAt = cred.ExpiresAt.Format(time.RFC3339) } switch { case cred.IsExpired(): item.Status = "expired" case cred.NeedsRefresh(): item.Status = "needs_refresh" default: item.Status = "connected" } } providersResp = append(providersResp, item) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "providers": providersResp, }) } func (h *Handler) handleOAuthLogin(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "failed to read request body", http.StatusBadRequest) return } defer r.Body.Close() var req struct { Provider string `json:"provider"` Method string `json:"method"` Token string `json:"token"` } if err = json.Unmarshal(body, &req); err != nil { http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest) return } provider, err := normalizeOAuthProvider(req.Provider) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } method := strings.ToLower(strings.TrimSpace(req.Method)) if !isOAuthMethodSupported(provider, method) { http.Error( w, fmt.Sprintf("unsupported login method %q for provider %q", method, provider), http.StatusBadRequest, ) return } switch method { case oauthMethodToken: token := strings.TrimSpace(req.Token) if token == "" { http.Error(w, "token is required", http.StatusBadRequest) return } cred := &auth.AuthCredential{ AccessToken: token, Provider: provider, AuthMethod: oauthMethodToken, } if err := h.persistCredentialAndConfig(provider, oauthMethodToken, cred); err != nil { http.Error(w, fmt.Sprintf("token login failed: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "status": "ok", "provider": provider, "method": method, }) return case oauthMethodDeviceCode: cfg := auth.OpenAIOAuthConfig() info, err := oauthRequestDeviceCode(cfg) if err != nil { http.Error(w, fmt.Sprintf("failed to request device code: %v", err), http.StatusInternalServerError) return } now := oauthNow() flow := &oauthFlow{ ID: newOAuthFlowID(), Provider: provider, Method: method, Status: oauthFlowPending, CreatedAt: now, UpdatedAt: now, ExpiresAt: now.Add(oauthDeviceCodeFlowTTL), DeviceAuthID: info.DeviceAuthID, UserCode: info.UserCode, VerifyURL: info.VerifyURL, Interval: info.Interval, } h.storeOAuthFlow(flow) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "status": "ok", "provider": provider, "method": method, "flow_id": flow.ID, "user_code": flow.UserCode, "verify_url": flow.VerifyURL, "interval": flow.Interval, "expires_at": flow.ExpiresAt.Format(time.RFC3339), }) return case oauthMethodBrowser: cfg, err := oauthConfigForProvider(provider) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } pkce, err := oauthGeneratePKCE() if err != nil { http.Error(w, fmt.Sprintf("failed to generate PKCE: %v", err), http.StatusInternalServerError) return } state, err := oauthGenerateState() if err != nil { http.Error(w, fmt.Sprintf("failed to generate state: %v", err), http.StatusInternalServerError) return } redirectURI := buildOAuthRedirectURI(r) authURL := oauthBuildAuthorizeURL(cfg, pkce, state, redirectURI) now := oauthNow() flow := &oauthFlow{ ID: newOAuthFlowID(), Provider: provider, Method: method, Status: oauthFlowPending, CreatedAt: now, UpdatedAt: now, ExpiresAt: now.Add(oauthBrowserFlowTTL), CodeVerifier: pkce.CodeVerifier, OAuthState: state, RedirectURI: redirectURI, } h.storeOAuthFlow(flow) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "status": "ok", "provider": provider, "method": method, "flow_id": flow.ID, "auth_url": authURL, "expires_at": flow.ExpiresAt.Format(time.RFC3339), }) return default: http.Error(w, "unsupported login method", http.StatusBadRequest) } } func (h *Handler) handleGetOAuthFlow(w http.ResponseWriter, r *http.Request) { flowID := strings.TrimSpace(r.PathValue("id")) if flowID == "" { http.Error(w, "missing flow id", http.StatusBadRequest) return } flow, ok := h.getOAuthFlow(flowID) if !ok { http.Error(w, "flow not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(flowToResponse(flow)) } func (h *Handler) handlePollOAuthFlow(w http.ResponseWriter, r *http.Request) { flowID := strings.TrimSpace(r.PathValue("id")) if flowID == "" { http.Error(w, "missing flow id", http.StatusBadRequest) return } flow, ok := h.getOAuthFlow(flowID) if !ok { http.Error(w, "flow not found", http.StatusNotFound) return } if flow.Method != oauthMethodDeviceCode { http.Error(w, "flow does not support polling", http.StatusBadRequest) return } if flow.Status != oauthFlowPending { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(flowToResponse(flow)) return } cfg := auth.OpenAIOAuthConfig() cred, err := oauthPollDeviceCodeOnce(cfg, flow.DeviceAuthID, flow.UserCode) if err != nil { if strings.Contains(strings.ToLower(err.Error()), "pending") { updated, _ := h.getOAuthFlow(flowID) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(flowToResponse(updated)) return } h.setOAuthFlowError(flowID, fmt.Sprintf("device code poll failed: %v", err)) updated, _ := h.getOAuthFlow(flowID) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(flowToResponse(updated)) return } if cred == nil { updated, _ := h.getOAuthFlow(flowID) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(flowToResponse(updated)) return } if err := h.persistCredentialAndConfig(flow.Provider, oauthMethodTokenOrOAuth(flow.Method), cred); err != nil { h.setOAuthFlowError(flowID, fmt.Sprintf("failed to save credential: %v", err)) updated, _ := h.getOAuthFlow(flowID) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(flowToResponse(updated)) return } h.setOAuthFlowSuccess(flowID) updated, _ := h.getOAuthFlow(flowID) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(flowToResponse(updated)) } func (h *Handler) handleOAuthCallback(w http.ResponseWriter, r *http.Request) { state := strings.TrimSpace(r.URL.Query().Get("state")) if state == "" { renderOAuthCallbackPage(w, "", oauthFlowError, "Missing state", "missing_state") return } flow, ok := h.getOAuthFlowByState(state) if !ok { renderOAuthCallbackPage(w, "", oauthFlowError, "OAuth flow not found", "flow_not_found") return } if flow.Status != oauthFlowPending { renderOAuthCallbackPage(w, flow.ID, flow.Status, "Flow already completed", flow.Error) return } if errMsg := strings.TrimSpace(r.URL.Query().Get("error")); errMsg != "" { if desc := strings.TrimSpace(r.URL.Query().Get("error_description")); desc != "" { errMsg += ": " + desc } h.setOAuthFlowError(flow.ID, errMsg) renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Authorization failed", errMsg) return } code := strings.TrimSpace(r.URL.Query().Get("code")) if code == "" { h.setOAuthFlowError(flow.ID, "missing authorization code") renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Missing authorization code", "missing_code") return } cfg, err := oauthConfigForProvider(flow.Provider) if err != nil { h.setOAuthFlowError(flow.ID, err.Error()) renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Unsupported provider", err.Error()) return } cred, err := oauthExchangeCodeForTokens(cfg, code, flow.CodeVerifier, flow.RedirectURI) if err != nil { h.setOAuthFlowError(flow.ID, fmt.Sprintf("token exchange failed: %v", err)) renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Token exchange failed", err.Error()) return } if err := h.persistCredentialAndConfig(flow.Provider, oauthMethodTokenOrOAuth(flow.Method), cred); err != nil { h.setOAuthFlowError(flow.ID, fmt.Sprintf("failed to save credential: %v", err)) renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Failed to save credential", err.Error()) return } h.setOAuthFlowSuccess(flow.ID) renderOAuthCallbackPage(w, flow.ID, oauthFlowSuccess, "Authentication successful", "") } func (h *Handler) handleOAuthLogout(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "failed to read request body", http.StatusBadRequest) return } defer r.Body.Close() var req struct { Provider string `json:"provider"` } if err = json.Unmarshal(body, &req); err != nil { http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest) return } provider, err := normalizeOAuthProvider(req.Provider) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := oauthDeleteCredential(provider); err != nil { http.Error(w, fmt.Sprintf("failed to delete credential: %v", err), http.StatusInternalServerError) return } if err := h.syncProviderAuthMethod(provider, ""); err != nil { http.Error(w, fmt.Sprintf("failed to update config: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "status": "ok", "provider": provider, }) } func renderOAuthCallbackPage(w http.ResponseWriter, flowID, status, title, errMsg string) { payload := map[string]string{ "type": "picoclaw-oauth-result", "flowId": flowID, "status": status, } if errMsg != "" { payload["error"] = errMsg } payloadJSON, _ := json.Marshal(payload) message := title if errMsg != "" { message = fmt.Sprintf("%s: %s", title, errMsg) } w.Header().Set("Content-Type", "text/html; charset=utf-8") if status == oauthFlowSuccess { w.WriteHeader(http.StatusOK) } else { w.WriteHeader(http.StatusBadRequest) } _, _ = fmt.Fprintf( w, "<!doctype html><html><head><meta charset=\"utf-8\"><title>PicoClaw OAuth

%s

%s

You can close this window.

", string(payloadJSON), html.EscapeString(title), html.EscapeString(message), ) } func normalizeOAuthProvider(raw string) (string, error) { provider := strings.ToLower(strings.TrimSpace(raw)) switch provider { case "antigravity": return oauthProviderGoogleAntigravity, nil case oauthProviderOpenAI, oauthProviderAnthropic, oauthProviderGoogleAntigravity: return provider, nil default: return "", fmt.Errorf("unsupported provider %q", raw) } } func isOAuthMethodSupported(provider, method string) bool { methods := oauthProviderMethods[provider] for _, m := range methods { if m == method { return true } } return false } func oauthConfigForProvider(provider string) (auth.OAuthProviderConfig, error) { switch provider { case oauthProviderOpenAI: return auth.OpenAIOAuthConfig(), nil case oauthProviderGoogleAntigravity: return auth.GoogleAntigravityOAuthConfig(), nil default: return auth.OAuthProviderConfig{}, fmt.Errorf("provider %q does not support browser oauth", provider) } } func oauthMethodTokenOrOAuth(method string) string { if method == oauthMethodToken { return oauthMethodToken } return "oauth" } func buildOAuthRedirectURI(r *http.Request) string { scheme := "http" if r.TLS != nil { scheme = "https" } if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" { scheme = strings.Split(forwarded, ",")[0] } return fmt.Sprintf("%s://%s/oauth/callback", scheme, r.Host) } func flowToResponse(flow *oauthFlow) oauthFlowResponse { resp := oauthFlowResponse{ FlowID: flow.ID, Provider: flow.Provider, Method: flow.Method, Status: flow.Status, Error: flow.Error, } if !flow.ExpiresAt.IsZero() { resp.ExpiresAt = flow.ExpiresAt.Format(time.RFC3339) } if flow.Method == oauthMethodDeviceCode { resp.UserCode = flow.UserCode resp.VerifyURL = flow.VerifyURL resp.Interval = flow.Interval } return resp } func newOAuthFlowID() string { buf := make([]byte, 16) if _, err := rand.Read(buf); err != nil { return fmt.Sprintf("oauth_%d", time.Now().UnixNano()) } return hex.EncodeToString(buf) } func (h *Handler) storeOAuthFlow(flow *oauthFlow) { now := oauthNow() h.oauthMu.Lock() defer h.oauthMu.Unlock() h.gcOAuthFlowsLocked(now) h.oauthFlows[flow.ID] = flow if flow.OAuthState != "" { h.oauthState[flow.OAuthState] = flow.ID } } func (h *Handler) getOAuthFlow(flowID string) (*oauthFlow, bool) { now := oauthNow() h.oauthMu.Lock() defer h.oauthMu.Unlock() h.gcOAuthFlowsLocked(now) flow, ok := h.oauthFlows[flowID] if !ok { return nil, false } cp := *flow return &cp, true } func (h *Handler) getOAuthFlowByState(state string) (*oauthFlow, bool) { now := oauthNow() h.oauthMu.Lock() defer h.oauthMu.Unlock() h.gcOAuthFlowsLocked(now) flowID, ok := h.oauthState[state] if !ok { return nil, false } flow, ok := h.oauthFlows[flowID] if !ok { delete(h.oauthState, state) return nil, false } cp := *flow return &cp, true } func (h *Handler) setOAuthFlowSuccess(flowID string) { now := oauthNow() h.oauthMu.Lock() defer h.oauthMu.Unlock() flow, ok := h.oauthFlows[flowID] if !ok { return } flow.Status = oauthFlowSuccess flow.Error = "" flow.UpdatedAt = now if flow.OAuthState != "" { delete(h.oauthState, flow.OAuthState) } } func (h *Handler) setOAuthFlowError(flowID, errMsg string) { now := oauthNow() h.oauthMu.Lock() defer h.oauthMu.Unlock() flow, ok := h.oauthFlows[flowID] if !ok { return } flow.Status = oauthFlowError flow.Error = errMsg flow.UpdatedAt = now if flow.OAuthState != "" { delete(h.oauthState, flow.OAuthState) } } func (h *Handler) gcOAuthFlowsLocked(now time.Time) { for id, flow := range h.oauthFlows { if flow.Status == oauthFlowPending && !flow.ExpiresAt.IsZero() && now.After(flow.ExpiresAt) { flow.Status = oauthFlowExpired flow.Error = "flow expired" flow.UpdatedAt = now if flow.OAuthState != "" { delete(h.oauthState, flow.OAuthState) } } if flow.Status != oauthFlowPending && now.Sub(flow.UpdatedAt) > oauthTerminalFlowGC { if flow.OAuthState != "" { delete(h.oauthState, flow.OAuthState) } delete(h.oauthFlows, id) } } } func (h *Handler) persistCredentialAndConfig(provider, authMethod string, cred *auth.AuthCredential) error { if cred == nil { return fmt.Errorf("empty credential") } cp := *cred cp.Provider = provider if cp.AuthMethod == "" { cp.AuthMethod = authMethod } if provider == oauthProviderGoogleAntigravity { if cp.Email == "" { email, err := oauthFetchGoogleUserEmailFunc(cp.AccessToken) if err != nil { logger.ErrorC("oauth", fmt.Sprintf("oauth warning: could not fetch google email: %v", err)) } else { cp.Email = email } } if cp.ProjectID == "" { projectID, err := oauthFetchAntigravityProject(cp.AccessToken) if err != nil { logger.ErrorC("oauth", fmt.Sprintf("oauth warning: could not fetch antigravity project id: %v", err)) } else { cp.ProjectID = projectID } } } if err := oauthSetCredential(provider, &cp); err != nil { return fmt.Errorf("saving credential: %w", err) } if err := h.syncProviderAuthMethod(provider, authMethod); err != nil { return fmt.Errorf("syncing provider auth config: %w", err) } return nil } func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error { cfg, err := oauthLoadConfig(h.configPath) if err != nil { return err } switch provider { case oauthProviderOpenAI: cfg.Providers.OpenAI.AuthMethod = authMethod case oauthProviderAnthropic: cfg.Providers.Anthropic.AuthMethod = authMethod case oauthProviderGoogleAntigravity: cfg.Providers.Antigravity.AuthMethod = authMethod default: return fmt.Errorf("unsupported provider %q", provider) } found := false for i := range cfg.ModelList { if modelBelongsToProvider(provider, cfg.ModelList[i].Model) { cfg.ModelList[i].AuthMethod = authMethod found = true } } if !found && authMethod != "" { cfg.ModelList = append(cfg.ModelList, defaultModelConfigForProvider(provider, authMethod)) } return oauthSaveConfig(h.configPath, cfg) } func modelBelongsToProvider(provider, model string) bool { lower := strings.ToLower(strings.TrimSpace(model)) switch provider { case oauthProviderOpenAI: return lower == "openai" || strings.HasPrefix(lower, "openai/") case oauthProviderAnthropic: return lower == "anthropic" || strings.HasPrefix(lower, "anthropic/") case oauthProviderGoogleAntigravity: return lower == "antigravity" || lower == "google-antigravity" || strings.HasPrefix(lower, "antigravity/") || strings.HasPrefix(lower, "google-antigravity/") default: return false } } func defaultModelConfigForProvider(provider, authMethod string) config.ModelConfig { switch provider { case oauthProviderOpenAI: return config.ModelConfig{ ModelName: "gpt-5.4", Model: "openai/gpt-5.4", AuthMethod: authMethod, } case oauthProviderAnthropic: return config.ModelConfig{ ModelName: "claude-sonnet-4.6", Model: "anthropic/claude-sonnet-4.6", AuthMethod: authMethod, } case oauthProviderGoogleAntigravity: return config.ModelConfig{ ModelName: "gemini-flash", Model: "antigravity/gemini-3-flash", AuthMethod: authMethod, } default: return config.ModelConfig{} } } func fetchGoogleUserEmail(accessToken string) (string, error) { req, err := http.NewRequest(http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil) if err != nil { return "", err } req.Header.Set("Authorization", "Bearer "+accessToken) client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("userinfo request failed: %s", string(body)) } var userInfo struct { Email string `json:"email"` } if err := json.Unmarshal(body, &userInfo); err != nil { return "", err } if userInfo.Email == "" { return "", fmt.Errorf("empty email in userinfo response") } return userInfo.Email, nil } ================================================ FILE: web/backend/api/oauth_test.go ================================================ package api import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" ) func TestOAuthLoginRejectsUnsupportedMethod(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetOAuthHooks(t) h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest( http.MethodPost, "/api/oauth/login", strings.NewReader(`{"provider":"anthropic","method":"browser"}`), ) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) } } func TestOAuthBrowserFlowCreatedAndQueried(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetOAuthHooks(t) oauthGeneratePKCE = func() (auth.PKCECodes, error) { return auth.PKCECodes{CodeVerifier: "verifier-1", CodeChallenge: "challenge-1"}, nil } oauthGenerateState = func() (string, error) { return "state-1", nil } oauthBuildAuthorizeURL = func(cfg auth.OAuthProviderConfig, pkce auth.PKCECodes, state, redirectURI string) string { return "https://example.com/authorize?state=" + state } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest( http.MethodPost, "/api/oauth/login", strings.NewReader(`{"provider":"openai","method":"browser"}`), ) req.Host = "localhost:18800" req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var loginResp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &loginResp); err != nil { t.Fatalf("unmarshal login response: %v", err) } flowID, _ := loginResp["flow_id"].(string) if flowID == "" { t.Fatalf("flow_id is empty: %v", loginResp) } if loginResp["auth_url"] != "https://example.com/authorize?state=state-1" { t.Fatalf("unexpected auth_url: %v", loginResp["auth_url"]) } rec2 := httptest.NewRecorder() req2 := httptest.NewRequest(http.MethodGet, "/api/oauth/flows/"+flowID, nil) mux.ServeHTTP(rec2, req2) if rec2.Code != http.StatusOK { t.Fatalf("flow status code = %d, want %d, body=%s", rec2.Code, http.StatusOK, rec2.Body.String()) } var flowResp oauthFlowResponse if err := json.Unmarshal(rec2.Body.Bytes(), &flowResp); err != nil { t.Fatalf("unmarshal flow response: %v", err) } if flowResp.Status != oauthFlowPending { t.Fatalf("flow status = %q, want %q", flowResp.Status, oauthFlowPending) } if flowResp.Method != oauthMethodBrowser { t.Fatalf("flow method = %q, want %q", flowResp.Method, oauthMethodBrowser) } } func TestOAuthFlowExpiresWhenQueried(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetOAuthHooks(t) now := time.Date(2026, 3, 6, 12, 0, 0, 0, time.UTC) oauthNow = func() time.Time { return now } h := NewHandler(configPath) h.storeOAuthFlow(&oauthFlow{ ID: "expired-flow", Provider: oauthProviderOpenAI, Method: oauthMethodBrowser, Status: oauthFlowPending, CreatedAt: now.Add(-20 * time.Minute), UpdatedAt: now.Add(-20 * time.Minute), ExpiresAt: now.Add(-1 * time.Minute), }) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/oauth/flows/expired-flow", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var flowResp oauthFlowResponse if err := json.Unmarshal(rec.Body.Bytes(), &flowResp); err != nil { t.Fatalf("unmarshal flow response: %v", err) } if flowResp.Status != oauthFlowExpired { t.Fatalf("flow status = %q, want %q", flowResp.Status, oauthFlowExpired) } } func TestOAuthCallbackUnknownState(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetOAuthHooks(t) h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/oauth/callback?state=unknown&code=abc", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) } if !strings.Contains(rec.Body.String(), "OAuth flow not found") { t.Fatalf("unexpected body: %s", rec.Body.String()) } } func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetOAuthHooks(t) cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig error: %v", err) } cfg.Providers.OpenAI.AuthMethod = "oauth" cfg.ModelList = append(cfg.ModelList, config.ModelConfig{ ModelName: "gpt-5.4", Model: "openai/gpt-5.4", AuthMethod: "oauth", }) if err = config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig error: %v", err) } if err = auth.SetCredential(oauthProviderOpenAI, &auth.AuthCredential{ AccessToken: "token-before-logout", Provider: oauthProviderOpenAI, AuthMethod: "oauth", }); err != nil { t.Fatalf("SetCredential error: %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/oauth/logout", bytes.NewBufferString(`{"provider":"openai"}`)) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } cred, err := auth.GetCredential(oauthProviderOpenAI) if err != nil { t.Fatalf("GetCredential error: %v", err) } if cred != nil { t.Fatalf("expected credential deleted, got %#v", cred) } updated, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig error: %v", err) } if updated.Providers.OpenAI.AuthMethod != "" { t.Fatalf("providers.openai.auth_method = %q, want empty", updated.Providers.OpenAI.AuthMethod) } for _, m := range updated.ModelList { if strings.HasPrefix(m.Model, "openai/") && m.AuthMethod != "" { t.Fatalf("openai model auth_method = %q, want empty", m.AuthMethod) } } } func setupOAuthTestEnv(t *testing.T) (string, func()) { t.Helper() tmp := t.TempDir() oldHome := os.Getenv("HOME") oldPicoHome := os.Getenv("PICOCLAW_HOME") if err := os.Setenv("HOME", tmp); err != nil { t.Fatalf("set HOME: %v", err) } if err := os.Setenv("PICOCLAW_HOME", filepath.Join(tmp, ".picoclaw")); err != nil { t.Fatalf("set PICOCLAW_HOME: %v", err) } cfg := config.DefaultConfig() cfg.ModelList = []config.ModelConfig{{ ModelName: "custom-default", Model: "openai/gpt-4o", APIKey: "sk-default", }} cfg.Agents.Defaults.ModelName = "custom-default" configPath := filepath.Join(tmp, "config.json") if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig error: %v", err) } cleanup := func() { _ = os.Setenv("HOME", oldHome) if oldPicoHome == "" { _ = os.Unsetenv("PICOCLAW_HOME") } else { _ = os.Setenv("PICOCLAW_HOME", oldPicoHome) } } return configPath, cleanup } func resetOAuthHooks(t *testing.T) { t.Helper() origNow := oauthNow origGeneratePKCE := oauthGeneratePKCE origGenerateState := oauthGenerateState origBuildAuthorizeURL := oauthBuildAuthorizeURL origRequestDeviceCode := oauthRequestDeviceCode origPollDeviceCodeOnce := oauthPollDeviceCodeOnce origExchangeCodeForTokens := oauthExchangeCodeForTokens origGetCredential := oauthGetCredential origSetCredential := oauthSetCredential origDeleteCredential := oauthDeleteCredential origLoadConfig := oauthLoadConfig origSaveConfig := oauthSaveConfig origFetchProject := oauthFetchAntigravityProject origFetchGoogleEmail := oauthFetchGoogleUserEmailFunc t.Cleanup(func() { oauthNow = origNow oauthGeneratePKCE = origGeneratePKCE oauthGenerateState = origGenerateState oauthBuildAuthorizeURL = origBuildAuthorizeURL oauthRequestDeviceCode = origRequestDeviceCode oauthPollDeviceCodeOnce = origPollDeviceCodeOnce oauthExchangeCodeForTokens = origExchangeCodeForTokens oauthGetCredential = origGetCredential oauthSetCredential = origSetCredential oauthDeleteCredential = origDeleteCredential oauthLoadConfig = origLoadConfig oauthSaveConfig = origSaveConfig oauthFetchAntigravityProject = origFetchProject oauthFetchGoogleUserEmailFunc = origFetchGoogleEmail }) } ================================================ FILE: web/backend/api/pico.go ================================================ package api import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "net/http" "net/http/httputil" "time" "github.com/sipeed/picoclaw/pkg/config" ) // registerPicoRoutes binds Pico Channel management endpoints to the ServeMux. func (h *Handler) registerPicoRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/pico/token", h.handleGetPicoToken) mux.HandleFunc("POST /api/pico/token", h.handleRegenPicoToken) mux.HandleFunc("POST /api/pico/setup", h.handlePicoSetup) // WebSocket proxy: forward /pico/ws to gateway // This allows the frontend to connect via the same port as the web UI, // avoiding the need to expose extra ports for WebSocket communication. mux.HandleFunc("GET /pico/ws", h.handleWebSocketProxy()) } // createWsProxy creates a reverse proxy to the current gateway WebSocket endpoint. // The gateway bind host and port are resolved from the latest configuration. func (h *Handler) createWsProxy() *httputil.ReverseProxy { wsProxy := httputil.NewSingleHostReverseProxy(h.gatewayProxyURL()) wsProxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway) } return wsProxy } // handleWebSocketProxy wraps a reverse proxy to handle WebSocket connections. // The reverse proxy forwards the incoming upgrade handshake as-is. func (h *Handler) handleWebSocketProxy() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { proxy := h.createWsProxy() proxy.ServeHTTP(w, r) } } // handleGetPicoToken returns the current WS token and URL for the frontend. // // GET /api/pico/token func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) { cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } wsURL := h.buildWsURL(r, cfg) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "token": cfg.Channels.Pico.Token, "ws_url": wsURL, "enabled": cfg.Channels.Pico.Enabled, }) } // handleRegenPicoToken generates a new Pico WebSocket token and saves it. // // POST /api/pico/token func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) { cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } token := generateSecureToken() cfg.Channels.Pico.Token = token if err := config.SaveConfig(h.configPath, cfg); err != nil { http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) return } wsURL := h.buildWsURL(r, cfg) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "token": token, "ws_url": wsURL, }) } // ensurePicoChannel enables the Pico channel with sane defaults if it isn't // already configured. Returns true when the config was modified. // // callerOrigin is the Origin header from the setup request. If non-empty and // no origins are configured yet, it's written as the allowed origin so the // WebSocket handshake works for whatever host the caller is on (LAN, custom // port, etc.). Pass "" when there's no request context. func (h *Handler) ensurePicoChannel(callerOrigin string) (bool, error) { cfg, err := config.LoadConfig(h.configPath) if err != nil { return false, fmt.Errorf("failed to load config: %w", err) } changed := false if !cfg.Channels.Pico.Enabled { cfg.Channels.Pico.Enabled = true changed = true } if cfg.Channels.Pico.Token == "" { cfg.Channels.Pico.Token = generateSecureToken() changed = true } // Seed origins from the request instead of hardcoding ports. if len(cfg.Channels.Pico.AllowOrigins) == 0 && callerOrigin != "" { cfg.Channels.Pico.AllowOrigins = []string{callerOrigin} changed = true } if changed { if err := config.SaveConfig(h.configPath, cfg); err != nil { return false, fmt.Errorf("failed to save config: %w", err) } } return changed, nil } // handlePicoSetup automatically configures everything needed for the Pico Channel to work. // // POST /api/pico/setup func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) { changed, err := h.ensurePicoChannel(r.Header.Get("Origin")) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } wsURL := h.buildWsURL(r, cfg) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "token": cfg.Channels.Pico.Token, "ws_url": wsURL, "enabled": true, "changed": changed, }) } // generateSecureToken creates a random 32-character hex string. func generateSecureToken() string { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { // Fallback to something pseudo-random if crypto/rand fails return fmt.Sprintf("pico_%x", time.Now().UnixNano()) } return hex.EncodeToString(b) } ================================================ FILE: web/backend/api/pico_test.go ================================================ package api import ( "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "path/filepath" "strconv" "testing" "github.com/sipeed/picoclaw/pkg/config" ) func TestEnsurePicoChannel_FreshConfig(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) changed, err := h.ensurePicoChannel("") if err != nil { t.Fatalf("ensurePicoChannel() error = %v", err) } if !changed { t.Fatal("ensurePicoChannel() should report changed on a fresh config") } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } if !cfg.Channels.Pico.Enabled { t.Error("expected Pico to be enabled after setup") } if cfg.Channels.Pico.Token == "" { t.Error("expected a non-empty token after setup") } } func TestEnsurePicoChannel_DoesNotEnableTokenQuery(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) if _, err := h.ensurePicoChannel(""); err != nil { t.Fatalf("ensurePicoChannel() error = %v", err) } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } if cfg.Channels.Pico.AllowTokenQuery { t.Error("setup must not enable allow_token_query by default") } } func TestEnsurePicoChannel_DoesNotSetWildcardOrigins(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) if _, err := h.ensurePicoChannel("http://localhost:18800"); err != nil { t.Fatalf("ensurePicoChannel() error = %v", err) } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } for _, origin := range cfg.Channels.Pico.AllowOrigins { if origin == "*" { t.Error("setup must not set wildcard origin '*'") } } } func TestEnsurePicoChannel_NoOriginWithoutCaller(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) if _, err := h.ensurePicoChannel(""); err != nil { t.Fatalf("ensurePicoChannel() error = %v", err) } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } // Without a caller origin, allow_origins stays empty (CheckOrigin // allows all when the list is empty, so the channel still works). if len(cfg.Channels.Pico.AllowOrigins) != 0 { t.Errorf("allow_origins = %v, want empty when no caller origin", cfg.Channels.Pico.AllowOrigins) } } func TestEnsurePicoChannel_SetsCallerOrigin(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) lanOrigin := "http://192.168.1.9:18800" if _, err := h.ensurePicoChannel(lanOrigin); err != nil { t.Fatalf("ensurePicoChannel() error = %v", err) } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } if len(cfg.Channels.Pico.AllowOrigins) != 1 || cfg.Channels.Pico.AllowOrigins[0] != lanOrigin { t.Errorf("allow_origins = %v, want [%s]", cfg.Channels.Pico.AllowOrigins, lanOrigin) } } func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") // Pre-configure with custom user settings cfg := config.DefaultConfig() cfg.Channels.Pico.Enabled = true cfg.Channels.Pico.Token = "user-custom-token" cfg.Channels.Pico.AllowTokenQuery = true cfg.Channels.Pico.AllowOrigins = []string{"https://myapp.example.com"} if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) changed, err := h.ensurePicoChannel("") if err != nil { t.Fatalf("ensurePicoChannel() error = %v", err) } if changed { t.Error("ensurePicoChannel() should not change a fully configured config") } cfg, err = config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } if cfg.Channels.Pico.Token != "user-custom-token" { t.Errorf("token = %q, want %q", cfg.Channels.Pico.Token, "user-custom-token") } if !cfg.Channels.Pico.AllowTokenQuery { t.Error("user's allow_token_query=true must be preserved") } if len(cfg.Channels.Pico.AllowOrigins) != 1 || cfg.Channels.Pico.AllowOrigins[0] != "https://myapp.example.com" { t.Errorf("allow_origins = %v, want [https://myapp.example.com]", cfg.Channels.Pico.AllowOrigins) } } func TestEnsurePicoChannel_Idempotent(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) origin := "http://localhost:18800" // First call sets things up if _, err := h.ensurePicoChannel(origin); err != nil { t.Fatalf("first ensurePicoChannel() error = %v", err) } cfg1, _ := config.LoadConfig(configPath) token1 := cfg1.Channels.Pico.Token // Second call should be a no-op changed, err := h.ensurePicoChannel(origin) if err != nil { t.Fatalf("second ensurePicoChannel() error = %v", err) } if changed { t.Error("second ensurePicoChannel() should not report changed") } cfg2, _ := config.LoadConfig(configPath) if cfg2.Channels.Pico.Token != token1 { t.Error("token should not change on subsequent calls") } } func TestHandlePicoSetup_IncludesRequestOrigin(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) req := httptest.NewRequest("POST", "/api/pico/setup", nil) req.Header.Set("Origin", "http://10.0.0.5:3000") rec := httptest.NewRecorder() h.handlePicoSetup(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } if len(cfg.Channels.Pico.AllowOrigins) != 1 || cfg.Channels.Pico.AllowOrigins[0] != "http://10.0.0.5:3000" { t.Errorf("allow_origins = %v, want [http://10.0.0.5:3000]", cfg.Channels.Pico.AllowOrigins) } } func TestHandlePicoSetup_Response(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) req := httptest.NewRequest("POST", "/api/pico/setup", nil) rec := httptest.NewRecorder() h.handlePicoSetup(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } var resp map[string]any if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if resp["token"] == nil || resp["token"] == "" { t.Error("response should contain a non-empty token") } if resp["ws_url"] == nil || resp["ws_url"] == "" { t.Error("response should contain ws_url") } if resp["enabled"] != true { t.Error("response should have enabled=true") } if resp["changed"] != true { t.Error("response should have changed=true on first setup") } } func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) handler := h.handleWebSocketProxy() server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/pico/ws" { t.Fatalf("server1 path = %q, want %q", r.URL.Path, "/pico/ws") } w.WriteHeader(http.StatusOK) _, _ = io.WriteString(w, "server1") })) defer server1.Close() server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/pico/ws" { t.Fatalf("server2 path = %q, want %q", r.URL.Path, "/pico/ws") } w.WriteHeader(http.StatusOK) _, _ = io.WriteString(w, "server2") })) defer server2.Close() cfg := config.DefaultConfig() cfg.Gateway.Host = "127.0.0.1" cfg.Gateway.Port = mustGatewayTestPort(t, server1.URL) if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } req1 := httptest.NewRequest(http.MethodGet, "/pico/ws", nil) rec1 := httptest.NewRecorder() handler(rec1, req1) if rec1.Code != http.StatusOK { t.Fatalf("first status = %d, want %d", rec1.Code, http.StatusOK) } if body := rec1.Body.String(); body != "server1" { t.Fatalf("first body = %q, want %q", body, "server1") } cfg.Gateway.Port = mustGatewayTestPort(t, server2.URL) if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } req2 := httptest.NewRequest(http.MethodGet, "/pico/ws", nil) rec2 := httptest.NewRecorder() handler(rec2, req2) if rec2.Code != http.StatusOK { t.Fatalf("second status = %d, want %d", rec2.Code, http.StatusOK) } if body := rec2.Body.String(); body != "server2" { t.Fatalf("second body = %q, want %q", body, "server2") } } func mustGatewayTestPort(t *testing.T, rawURL string) int { t.Helper() parsed, err := url.Parse(rawURL) if err != nil { t.Fatalf("url.Parse() error = %v", err) } port, err := strconv.Atoi(parsed.Port()) if err != nil { t.Fatalf("Atoi(%q) error = %v", parsed.Port(), err) } return port } ================================================ FILE: web/backend/api/router.go ================================================ package api import ( "net/http" "sync" "github.com/sipeed/picoclaw/web/backend/launcherconfig" ) // Handler serves HTTP API requests. type Handler struct { configPath string serverPort int serverPublic bool serverPublicExplicit bool serverCIDRs []string oauthMu sync.Mutex oauthFlows map[string]*oauthFlow oauthState map[string]string } // NewHandler creates an instance of the API handler. func NewHandler(configPath string) *Handler { return &Handler{ configPath: configPath, serverPort: launcherconfig.DefaultPort, oauthFlows: make(map[string]*oauthFlow), oauthState: make(map[string]string), } } // SetServerOptions stores current backend listen options for fallback behavior. func (h *Handler) SetServerOptions(port int, public bool, publicExplicit bool, allowedCIDRs []string) { h.serverPort = port h.serverPublic = public h.serverPublicExplicit = publicExplicit h.serverCIDRs = append([]string(nil), allowedCIDRs...) } // RegisterRoutes binds all API endpoint handlers to the ServeMux. func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // Config CRUD h.registerConfigRoutes(mux) // Pico Channel (WebSocket chat) h.registerPicoRoutes(mux) // Gateway process lifecycle h.registerGatewayRoutes(mux) // Session history h.registerSessionRoutes(mux) // OAuth login and credential management h.registerOAuthRoutes(mux) // Model list management h.registerModelRoutes(mux) // Channel catalog (for frontend navigation/config pages) h.registerChannelRoutes(mux) // Skills and tools support/actions h.registerSkillRoutes(mux) h.registerToolRoutes(mux) // OS startup / launch-at-login h.registerStartupRoutes(mux) // Launcher service parameters (port/public) h.registerLauncherConfigRoutes(mux) } // Shutdown gracefully shuts down the handler, stopping the gateway if it was started by this handler. func (h *Handler) Shutdown() { h.StopGateway() } ================================================ FILE: web/backend/api/session.go ================================================ package api import ( "bufio" "encoding/json" "errors" "net/http" "os" "path/filepath" "sort" "strconv" "strings" "time" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" ) // registerSessionRoutes binds session list and detail endpoints to the ServeMux. func (h *Handler) registerSessionRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/sessions", h.handleListSessions) mux.HandleFunc("GET /api/sessions/{id}", h.handleGetSession) mux.HandleFunc("DELETE /api/sessions/{id}", h.handleDeleteSession) } // sessionFile mirrors the on-disk session JSON structure from pkg/session. type sessionFile struct { Key string `json:"key"` Messages []providers.Message `json:"messages"` Summary string `json:"summary,omitempty"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` } // sessionListItem is a lightweight summary returned by GET /api/sessions. type sessionListItem struct { ID string `json:"id"` Title string `json:"title"` Preview string `json:"preview"` MessageCount int `json:"message_count"` Created string `json:"created"` Updated string `json:"updated"` } type sessionMetaFile struct { Key string `json:"key"` Summary string `json:"summary"` Skip int `json:"skip"` Count int `json:"count"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // picoSessionPrefix is the key prefix used by the gateway's routing for Pico // channel sessions. The full key format is: // // agent:main:pico:direct:pico: // // The sanitized filename replaces ':' with '_', so on disk it becomes: // // agent_main_pico_direct_pico_.json const ( picoSessionPrefix = "agent:main:pico:direct:pico:" sanitizedPicoSessionPrefix = "agent_main_pico_direct_pico_" maxSessionJSONLLineSize = 10 * 1024 * 1024 // 10 MB maxSessionTitleRunes = 60 ) // extractPicoSessionID extracts the session UUID from a full session key. // Returns the UUID and true if the key matches the Pico session pattern. func extractPicoSessionID(key string) (string, bool) { if strings.HasPrefix(key, picoSessionPrefix) { return strings.TrimPrefix(key, picoSessionPrefix), true } return "", false } func extractPicoSessionIDFromSanitizedKey(key string) (string, bool) { if strings.HasPrefix(key, sanitizedPicoSessionPrefix) { return strings.TrimPrefix(key, sanitizedPicoSessionPrefix), true } return "", false } func sanitizeSessionKey(key string) string { return strings.ReplaceAll(key, ":", "_") } func (h *Handler) readLegacySession(dir, sessionID string) (sessionFile, error) { path := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID)+".json") data, err := os.ReadFile(path) if err != nil { return sessionFile{}, err } var sess sessionFile if err := json.Unmarshal(data, &sess); err != nil { return sessionFile{}, err } return sess, nil } func (h *Handler) readSessionMeta(path, sessionKey string) (sessionMetaFile, error) { data, err := os.ReadFile(path) if os.IsNotExist(err) { return sessionMetaFile{Key: sessionKey}, nil } if err != nil { return sessionMetaFile{}, err } var meta sessionMetaFile if err := json.Unmarshal(data, &meta); err != nil { return sessionMetaFile{}, err } if meta.Key == "" { meta.Key = sessionKey } return meta, nil } func (h *Handler) readSessionMessages(path string, skip int) ([]providers.Message, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() msgs := make([]providers.Message, 0) scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 64*1024), maxSessionJSONLLineSize) seen := 0 for scanner.Scan() { line := scanner.Bytes() if len(line) == 0 { continue } seen++ if seen <= skip { continue } var msg providers.Message if err := json.Unmarshal(line, &msg); err != nil { continue } msgs = append(msgs, msg) } if err := scanner.Err(); err != nil { return nil, err } return msgs, nil } func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) { sessionKey := picoSessionPrefix + sessionID base := filepath.Join(dir, sanitizeSessionKey(sessionKey)) jsonlPath := base + ".jsonl" metaPath := base + ".meta.json" meta, err := h.readSessionMeta(metaPath, sessionKey) if err != nil { return sessionFile{}, err } messages, err := h.readSessionMessages(jsonlPath, meta.Skip) if err != nil { return sessionFile{}, err } updated := meta.UpdatedAt created := meta.CreatedAt if created.IsZero() || updated.IsZero() { if info, statErr := os.Stat(jsonlPath); statErr == nil { if created.IsZero() { created = info.ModTime() } if updated.IsZero() { updated = info.ModTime() } } } return sessionFile{ Key: meta.Key, Messages: messages, Summary: meta.Summary, Created: created, Updated: updated, }, nil } func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem { preview := "" for _, msg := range sess.Messages { if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" { preview = msg.Content break } } title := strings.TrimSpace(sess.Summary) if title == "" { title = preview } title = truncateRunes(title, maxSessionTitleRunes) preview = truncateRunes(preview, maxSessionTitleRunes) if preview == "" { preview = "(empty)" } if title == "" { title = preview } validMessageCount := 0 for _, msg := range sess.Messages { if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" { validMessageCount++ } } return sessionListItem{ ID: sessionID, Title: title, Preview: preview, MessageCount: validMessageCount, Created: sess.Created.Format(time.RFC3339), Updated: sess.Updated.Format(time.RFC3339), } } func isEmptySession(sess sessionFile) bool { return len(sess.Messages) == 0 && strings.TrimSpace(sess.Summary) == "" } func truncateRunes(s string, maxLen int) string { if maxLen <= 0 { return "" } runes := []rune(strings.TrimSpace(s)) if len(runes) <= maxLen { return string(runes) } return string(runes[:maxLen]) + "..." } // sessionsDir resolves the path to the gateway's session storage directory. // It reads the workspace from config, falling back to ~/.picoclaw/workspace. func (h *Handler) sessionsDir() (string, error) { cfg, err := config.LoadConfig(h.configPath) if err != nil { return "", err } workspace := cfg.Agents.Defaults.Workspace if workspace == "" { home, _ := os.UserHomeDir() workspace = filepath.Join(home, ".picoclaw", "workspace") } // Expand ~ prefix if len(workspace) > 0 && workspace[0] == '~' { home, _ := os.UserHomeDir() if len(workspace) > 1 && workspace[1] == '/' { workspace = home + workspace[1:] } else { workspace = home } } return filepath.Join(workspace, "sessions"), nil } // handleListSessions returns a list of Pico session summaries. // // GET /api/sessions func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) { dir, err := h.sessionsDir() if err != nil { http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError) return } entries, err := os.ReadDir(dir) if err != nil { // Directory doesn't exist yet = no sessions w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode([]sessionListItem{}) return } items := []sessionListItem{} seen := make(map[string]struct{}) for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() var ( sessionID string sess sessionFile loadErr error ok bool ) switch { case strings.HasSuffix(name, ".jsonl"): sessionID, ok = extractPicoSessionIDFromSanitizedKey(strings.TrimSuffix(name, ".jsonl")) if !ok { continue } sess, loadErr = h.readJSONLSession(dir, sessionID) if loadErr == nil && isEmptySession(sess) { continue } case strings.HasSuffix(name, ".meta.json"): continue case filepath.Ext(name) == ".json": base := strings.TrimSuffix(name, ".json") if _, statErr := os.Stat(filepath.Join(dir, base+".jsonl")); statErr == nil { if jsonlSessionID, found := extractPicoSessionIDFromSanitizedKey(base); found { if jsonlSess, jsonlErr := h.readJSONLSession( dir, jsonlSessionID, ); jsonlErr == nil && !isEmptySession(jsonlSess) { continue } } } data, err := os.ReadFile(filepath.Join(dir, name)) if err != nil { continue } if err := json.Unmarshal(data, &sess); err != nil { continue } if isEmptySession(sess) { continue } sessionID, ok = extractPicoSessionID(sess.Key) if !ok { continue } if _, exists := seen[sessionID]; exists { continue } default: continue } if loadErr != nil { continue } if _, exists := seen[sessionID]; exists { continue } seen[sessionID] = struct{}{} items = append(items, buildSessionListItem(sessionID, sess)) } // Sort by updated descending (most recent first) sort.Slice(items, func(i, j int) bool { return items[i].Updated > items[j].Updated }) // Pagination parameters offsetStr := r.URL.Query().Get("offset") limitStr := r.URL.Query().Get("limit") offset := 0 limit := 20 // Default limit if val, err := strconv.Atoi(offsetStr); err == nil && val >= 0 { offset = val } if val, err := strconv.Atoi(limitStr); err == nil && val > 0 { limit = val } totalItems := len(items) end := offset + limit if offset >= totalItems { items = []sessionListItem{} // Out of bounds, return empty } else { if end > totalItems { end = totalItems } items = items[offset:end] } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(items) } // handleGetSession returns the full message history for a specific session. // // GET /api/sessions/{id} func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) { sessionID := r.PathValue("id") if sessionID == "" { http.Error(w, "missing session id", http.StatusBadRequest) return } dir, err := h.sessionsDir() if err != nil { http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError) return } sess, err := h.readJSONLSession(dir, sessionID) if err == nil && isEmptySession(sess) { err = os.ErrNotExist } if err != nil { if errors.Is(err, os.ErrNotExist) { sess, err = h.readLegacySession(dir, sessionID) if err == nil && isEmptySession(sess) { err = os.ErrNotExist } } if err != nil { if errors.Is(err, os.ErrNotExist) { http.Error(w, "session not found", http.StatusNotFound) } else { http.Error(w, "failed to parse session", http.StatusInternalServerError) } return } } // Convert to a simpler format for the frontend type chatMessage struct { Role string `json:"role"` Content string `json:"content"` } messages := make([]chatMessage, 0, len(sess.Messages)) for _, msg := range sess.Messages { // Only include user and assistant messages that have actual content if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" { messages = append(messages, chatMessage{ Role: msg.Role, Content: msg.Content, }) } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "id": sessionID, "messages": messages, "summary": sess.Summary, "created": sess.Created.Format(time.RFC3339), "updated": sess.Updated.Format(time.RFC3339), }) } // handleDeleteSession deletes a specific session. // // DELETE /api/sessions/{id} func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) { sessionID := r.PathValue("id") if sessionID == "" { http.Error(w, "missing session id", http.StatusBadRequest) return } dir, err := h.sessionsDir() if err != nil { http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError) return } base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID)) jsonlPath := base + ".jsonl" metaPath := base + ".meta.json" legacyPath := base + ".json" removed := false for _, path := range []string{jsonlPath, metaPath, legacyPath} { if err := os.Remove(path); err != nil { if os.IsNotExist(err) { continue } http.Error(w, "failed to delete session", http.StatusInternalServerError) return } removed = true } if !removed { http.Error(w, "session not found", http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) } ================================================ FILE: web/backend/api/session_test.go ================================================ package api import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/memory" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/session" ) func sessionsTestDir(t *testing.T, configPath string) string { t.Helper() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } dir := filepath.Join(cfg.Agents.Defaults.Workspace, "sessions") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } return dir } func TestHandleListSessions_JSONLStorage(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() dir := sessionsTestDir(t, configPath) store, err := memory.NewJSONLStore(dir) if err != nil { t.Fatalf("NewJSONLStore() error = %v", err) } sessionKey := picoSessionPrefix + "history-jsonl" if err := store.AddFullMessage(nil, sessionKey, providers.Message{ Role: "user", Content: "Explain why the history API is empty after migration.", }); err != nil { t.Fatalf("AddFullMessage(user) error = %v", err) } if err := store.AddFullMessage(nil, sessionKey, providers.Message{ Role: "assistant", Content: "Because the API still reads only legacy JSON session files.", }); err != nil { t.Fatalf("AddFullMessage(assistant) error = %v", err) } if err := store.AddFullMessage(nil, sessionKey, providers.Message{ Role: "tool", Content: "ignored", }); err != nil { t.Fatalf("AddFullMessage(tool) error = %v", err) } if err := store.SetSummary(nil, sessionKey, "JSONL-backed session"); err != nil { t.Fatalf("SetSummary() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var items []sessionListItem if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(items) != 1 { t.Fatalf("len(items) = %d, want 1", len(items)) } if items[0].ID != "history-jsonl" { t.Fatalf("items[0].ID = %q, want %q", items[0].ID, "history-jsonl") } if items[0].MessageCount != 2 { t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount) } if items[0].Title != "JSONL-backed session" { t.Fatalf("items[0].Title = %q, want %q", items[0].Title, "JSONL-backed session") } if items[0].Preview != "Explain why the history API is empty after migration." { t.Fatalf("items[0].Preview = %q", items[0].Preview) } } func TestHandleListSessions_TitleUsesTrimmedSummary(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() dir := sessionsTestDir(t, configPath) store, err := memory.NewJSONLStore(dir) if err != nil { t.Fatalf("NewJSONLStore() error = %v", err) } sessionKey := picoSessionPrefix + "summary-title" if err := store.AddFullMessage(nil, sessionKey, providers.Message{ Role: "user", Content: "fallback preview", }); err != nil { t.Fatalf("AddFullMessage() error = %v", err) } if err := store.SetSummary( nil, sessionKey, " This summary is intentionally longer than sixty characters so it must be truncated in the history menu. ", ); err != nil { t.Fatalf("SetSummary() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var items []sessionListItem if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(items) != 1 { t.Fatalf("len(items) = %d, want 1", len(items)) } expectedTitle := truncateRunes( "This summary is intentionally longer than sixty characters so it must be truncated in the history menu.", maxSessionTitleRunes, ) if items[0].Title != expectedTitle { t.Fatalf("items[0].Title = %q", items[0].Title) } if items[0].Preview != "fallback preview" { t.Fatalf("items[0].Preview = %q, want %q", items[0].Preview, "fallback preview") } } func TestHandleGetSession_JSONLStorage(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() dir := sessionsTestDir(t, configPath) store, err := memory.NewJSONLStore(dir) if err != nil { t.Fatalf("NewJSONLStore() error = %v", err) } sessionKey := picoSessionPrefix + "detail-jsonl" for _, msg := range []providers.Message{ {Role: "user", Content: "first"}, {Role: "assistant", Content: "second"}, {Role: "tool", Content: "ignored"}, } { if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { t.Fatalf("AddFullMessage() error = %v", err) } } if err := store.SetSummary(nil, sessionKey, "detail summary"); err != nil { t.Fatalf("SetSummary() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-jsonl", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp struct { ID string `json:"id"` Summary string `json:"summary"` Messages []struct { Role string `json:"role"` Content string `json:"content"` } `json:"messages"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if resp.ID != "detail-jsonl" { t.Fatalf("resp.ID = %q, want %q", resp.ID, "detail-jsonl") } if resp.Summary != "detail summary" { t.Fatalf("resp.Summary = %q, want %q", resp.Summary, "detail summary") } if len(resp.Messages) != 2 { t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages)) } if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "first" { t.Fatalf("first message = %#v, want user/first", resp.Messages[0]) } if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "second" { t.Fatalf("second message = %#v, want assistant/second", resp.Messages[1]) } } func TestHandleDeleteSession_JSONLStorage(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() dir := sessionsTestDir(t, configPath) store, err := memory.NewJSONLStore(dir) if err != nil { t.Fatalf("NewJSONLStore() error = %v", err) } sessionKey := picoSessionPrefix + "delete-jsonl" if err := store.AddFullMessage(nil, sessionKey, providers.Message{ Role: "user", Content: "delete me", }); err != nil { t.Fatalf("AddFullMessage() error = %v", err) } if err := store.SetSummary(nil, sessionKey, "delete summary"); err != nil { t.Fatalf("SetSummary() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodDelete, "/api/sessions/delete-jsonl", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusNoContent, rec.Body.String()) } base := filepath.Join(dir, sanitizeSessionKey(sessionKey)) for _, path := range []string{base + ".jsonl", base + ".meta.json"} { if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatalf("expected %s to be removed, stat err = %v", path, err) } } } func TestHandleGetSession_LegacyJSONFallback(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() dir := sessionsTestDir(t, configPath) manager := session.NewSessionManager(dir) sessionKey := picoSessionPrefix + "legacy-json" manager.AddMessage(sessionKey, "user", "legacy user") manager.AddMessage(sessionKey, "assistant", "legacy assistant") if err := manager.Save(sessionKey); err != nil { t.Fatalf("Save() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/sessions/legacy-json", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } } func TestHandleSessions_FiltersEmptyJSONLFiles(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() dir := sessionsTestDir(t, configPath) base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+"empty-jsonl")) if err := os.WriteFile(base+".jsonl", []byte{}, 0o644); err != nil { t.Fatalf("WriteFile(jsonl) error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) listRec := httptest.NewRecorder() listReq := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) mux.ServeHTTP(listRec, listReq) if listRec.Code != http.StatusOK { t.Fatalf("list status = %d, want %d, body=%s", listRec.Code, http.StatusOK, listRec.Body.String()) } var items []sessionListItem if err := json.Unmarshal(listRec.Body.Bytes(), &items); err != nil { t.Fatalf("Unmarshal(list) error = %v", err) } if len(items) != 0 { t.Fatalf("len(items) = %d, want 0", len(items)) } detailRec := httptest.NewRecorder() detailReq := httptest.NewRequest(http.MethodGet, "/api/sessions/empty-jsonl", nil) mux.ServeHTTP(detailRec, detailReq) if detailRec.Code != http.StatusNotFound { t.Fatalf("detail status = %d, want %d, body=%s", detailRec.Code, http.StatusNotFound, detailRec.Body.String()) } } ================================================ FILE: web/backend/api/skills.go ================================================ package api import ( "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "regexp" "strings" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/skills" ) type skillSupportResponse struct { Skills []skills.SkillInfo `json:"skills"` } type skillDetailResponse struct { Name string `json:"name"` Path string `json:"path"` Source string `json:"source"` Description string `json:"description"` Content string `json:"content"` } var ( skillNameSanitizer = regexp.MustCompile(`[^a-z0-9-]+`) importedSkillFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`) skillFrontmatterStripper = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`) ) func (h *Handler) registerSkillRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/skills", h.handleListSkills) mux.HandleFunc("GET /api/skills/{name}", h.handleGetSkill) mux.HandleFunc("POST /api/skills/import", h.handleImportSkill) mux.HandleFunc("DELETE /api/skills/{name}", h.handleDeleteSkill) } func (h *Handler) handleListSkills(w http.ResponseWriter, r *http.Request) { cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } loader := newSkillsLoader(cfg.WorkspacePath()) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(skillSupportResponse{ Skills: loader.ListSkills(), }) } func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) { cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } loader := newSkillsLoader(cfg.WorkspacePath()) name := r.PathValue("name") allSkills := loader.ListSkills() for _, skill := range allSkills { if skill.Name != name { continue } content, err := loadSkillContent(skill.Path) if err != nil { http.Error(w, "Skill content not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(skillDetailResponse{ Name: skill.Name, Path: skill.Path, Source: skill.Source, Description: skill.Description, Content: content, }) return } http.Error(w, "Skill not found", http.StatusNotFound) } func (h *Handler) handleImportSkill(w http.ResponseWriter, r *http.Request) { cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } err = r.ParseMultipartForm(2 << 20) if err != nil { http.Error(w, fmt.Sprintf("Invalid multipart form: %v", err), http.StatusBadRequest) return } uploadedFile, fileHeader, err := r.FormFile("file") if err != nil { http.Error(w, "file is required", http.StatusBadRequest) return } defer uploadedFile.Close() content, err := io.ReadAll(io.LimitReader(uploadedFile, (1<<20)+1)) if err != nil { http.Error(w, fmt.Sprintf("Failed to read file: %v", err), http.StatusBadRequest) return } if len(content) > 1<<20 { http.Error(w, "file exceeds 1MB limit", http.StatusBadRequest) return } skillName, err := normalizeImportedSkillName(fileHeader.Filename, content) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } content = normalizeImportedSkillContent(content, skillName) workspace := cfg.WorkspacePath() skillDir := filepath.Join(workspace, "skills", skillName) skillFile := filepath.Join(skillDir, "SKILL.md") if _, err := os.Stat(skillDir); err == nil { http.Error(w, "skill already exists", http.StatusConflict) return } if err := os.MkdirAll(skillDir, 0o755); err != nil { http.Error(w, fmt.Sprintf("Failed to create skill directory: %v", err), http.StatusInternalServerError) return } if err := os.WriteFile(skillFile, content, 0o644); err != nil { http.Error(w, fmt.Sprintf("Failed to save skill: %v", err), http.StatusInternalServerError) return } loader := newSkillsLoader(workspace) for _, skill := range loader.ListSkills() { if skill.Path == skillFile || (skill.Name == skillName && skill.Source == "workspace") { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(skill) return } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "name": skillName, "path": skillFile, }) } func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) { cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } loader := newSkillsLoader(cfg.WorkspacePath()) name := r.PathValue("name") for _, skill := range loader.ListSkills() { if skill.Name != name { continue } if skill.Source != "workspace" { http.Error(w, "only workspace skills can be deleted", http.StatusBadRequest) return } if err := os.RemoveAll(filepath.Dir(skill.Path)); err != nil { http.Error(w, fmt.Sprintf("Failed to delete skill: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) return } http.Error(w, "Skill not found", http.StatusNotFound) } func newSkillsLoader(workspace string) *skills.SkillsLoader { return skills.NewSkillsLoader( workspace, filepath.Join(globalConfigDir(), "skills"), builtinSkillsDir(), ) } func normalizeImportedSkillName(filename string, content []byte) (string, error) { rawContent := strings.ReplaceAll(string(content), "\r\n", "\n") rawContent = strings.ReplaceAll(rawContent, "\r", "\n") metadata, _ := extractImportedSkillMetadata(rawContent) raw := strings.TrimSpace(metadata["name"]) if raw == "" { raw = strings.TrimSpace(strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))) } raw = strings.ToLower(raw) raw = strings.ReplaceAll(raw, "_", "-") raw = strings.ReplaceAll(raw, " ", "-") raw = skillNameSanitizer.ReplaceAllString(raw, "-") raw = strings.Trim(raw, "-") raw = strings.Join(strings.FieldsFunc(raw, func(r rune) bool { return r == '-' }), "-") if raw == "" { return "", fmt.Errorf("skill name is required in frontmatter or filename") } if len(raw) > 64 { return "", fmt.Errorf("skill name exceeds 64 characters") } matched, err := regexp.MatchString(`^[a-z0-9]+(-[a-z0-9]+)*$`, raw) if err != nil || !matched { return "", fmt.Errorf("skill name must be alphanumeric with hyphens") } return raw, nil } func normalizeImportedSkillContent(content []byte, skillName string) []byte { raw := strings.ReplaceAll(string(content), "\r\n", "\n") raw = strings.ReplaceAll(raw, "\r", "\n") metadata, body := extractImportedSkillMetadata(raw) description := strings.TrimSpace(metadata["description"]) if description == "" { description = inferImportedSkillDescription(body) } if description == "" { description = "Imported skill" } if len(description) > 1024 { description = strings.TrimSpace(description[:1024]) } body = strings.TrimLeft(body, "\n") var builder strings.Builder builder.WriteString("---\n") builder.WriteString("name: ") builder.WriteString(skillName) builder.WriteString("\n") builder.WriteString("description: ") builder.WriteString(description) builder.WriteString("\n") builder.WriteString("---\n\n") builder.WriteString(body) if !strings.HasSuffix(builder.String(), "\n") { builder.WriteString("\n") } return []byte(builder.String()) } func extractImportedSkillMetadata(raw string) (map[string]string, string) { matches := importedSkillFrontmatter.FindStringSubmatch(raw) if len(matches) != 2 { return map[string]string{}, raw } meta := parseImportedSkillYAML(matches[1]) body := importedSkillFrontmatter.ReplaceAllString(raw, "") return meta, body } func parseImportedSkillYAML(frontmatter string) map[string]string { result := make(map[string]string) for _, line := range strings.Split(frontmatter, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } key, value, ok := strings.Cut(line, ":") if !ok { continue } result[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"'`) } return result } func inferImportedSkillDescription(body string) string { for _, line := range strings.Split(body, "\n") { line = strings.TrimSpace(line) if line == "" { continue } line = strings.TrimLeft(line, "#-*0123456789. ") line = strings.TrimSpace(line) if line != "" { return line } } return "" } func loadSkillContent(path string) (string, error) { content, err := os.ReadFile(path) if err != nil { return "", err } return skillFrontmatterStripper.ReplaceAllString(string(content), ""), nil } func globalConfigDir() string { if home := os.Getenv(config.EnvHome); home != "" { return home } home, err := os.UserHomeDir() if err != nil { return "" } return filepath.Join(home, ".picoclaw") } func builtinSkillsDir() string { if path := os.Getenv(config.EnvBuiltinSkills); path != "" { return path } wd, err := os.Getwd() if err != nil { return "" } return filepath.Join(wd, "skills") } ================================================ FILE: web/backend/api/skills_test.go ================================================ package api import ( "bytes" "encoding/json" "io" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "github.com/sipeed/picoclaw/pkg/config" ) func TestHandleListSkills(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } workspace := filepath.Join(t.TempDir(), "workspace") cfg.Agents.Defaults.Workspace = workspace err = config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } if err := os.MkdirAll(filepath.Join(workspace, "skills", "workspace-skill"), 0o755); err != nil { t.Fatalf("MkdirAll(workspace skill) error = %v", err) } if err := os.WriteFile( filepath.Join(workspace, "skills", "workspace-skill", "SKILL.md"), []byte("---\nname: workspace-skill\ndescription: Workspace skill\n---\n"), 0o644, ); err != nil { t.Fatalf("WriteFile(workspace skill) error = %v", err) } globalSkillDir := filepath.Join(globalConfigDir(), "skills", "global-skill") if err := os.MkdirAll(globalSkillDir, 0o755); err != nil { t.Fatalf("MkdirAll(global skill) error = %v", err) } if err := os.WriteFile( filepath.Join(globalSkillDir, "SKILL.md"), []byte("---\nname: global-skill\ndescription: Global skill\n---\n"), 0o644, ); err != nil { t.Fatalf("WriteFile(global skill) error = %v", err) } builtinRoot := filepath.Join(t.TempDir(), "builtin-skills") oldBuiltin := os.Getenv("PICOCLAW_BUILTIN_SKILLS") if err := os.Setenv("PICOCLAW_BUILTIN_SKILLS", builtinRoot); err != nil { t.Fatalf("Setenv(PICOCLAW_BUILTIN_SKILLS) error = %v", err) } defer func() { if oldBuiltin == "" { _ = os.Unsetenv("PICOCLAW_BUILTIN_SKILLS") } else { _ = os.Setenv("PICOCLAW_BUILTIN_SKILLS", oldBuiltin) } }() builtinSkillDir := filepath.Join(builtinRoot, "builtin-skill") if err := os.MkdirAll(builtinSkillDir, 0o755); err != nil { t.Fatalf("MkdirAll(builtin skill) error = %v", err) } if err := os.WriteFile( filepath.Join(builtinSkillDir, "SKILL.md"), []byte("---\nname: builtin-skill\ndescription: Builtin skill\n---\n"), 0o644, ); err != nil { t.Fatalf("WriteFile(builtin skill) error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/skills", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp skillSupportResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(resp.Skills) != 3 { t.Fatalf("skills count = %d, want 3", len(resp.Skills)) } gotSkills := make(map[string]string, len(resp.Skills)) for _, skill := range resp.Skills { gotSkills[skill.Name] = skill.Source } if gotSkills["workspace-skill"] != "workspace" { t.Fatalf("workspace-skill source = %q, want workspace", gotSkills["workspace-skill"]) } if gotSkills["global-skill"] != "global" { t.Fatalf("global-skill source = %q, want global", gotSkills["global-skill"]) } if gotSkills["builtin-skill"] != "builtin" { t.Fatalf("builtin-skill source = %q, want builtin", gotSkills["builtin-skill"]) } } func TestHandleGetSkill(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } workspace := filepath.Join(t.TempDir(), "workspace") cfg.Agents.Defaults.Workspace = workspace err = config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } skillDir := filepath.Join(workspace, "skills", "viewer-skill") if err := os.MkdirAll(skillDir, 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } if err := os.WriteFile( filepath.Join(skillDir, "SKILL.md"), []byte( "---\nname: viewer-skill\ndescription: Viewable skill\n---\n# Viewer Skill\n\nThis is visible content.\n", ), 0o644, ); err != nil { t.Fatalf("WriteFile() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/skills/viewer-skill", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp skillDetailResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if resp.Name != "viewer-skill" || resp.Source != "workspace" || resp.Description != "Viewable skill" { t.Fatalf("unexpected response: %#v", resp) } if resp.Content != "# Viewer Skill\n\nThis is visible content.\n" { t.Fatalf("content = %q", resp.Content) } } func TestHandleGetSkillUsesResolvedPath(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } workspace := filepath.Join(t.TempDir(), "workspace") cfg.Agents.Defaults.Workspace = workspace err = config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } skillDir := filepath.Join(workspace, "skills", "folder-name") if err := os.MkdirAll(skillDir, 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } if err := os.WriteFile( filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: display-name\ndescription: Mismatched path skill\n---\n# Display Name\n"), 0o644, ); err != nil { t.Fatalf("WriteFile() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/skills/display-name", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp skillDetailResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if resp.Name != "display-name" { t.Fatalf("resp.Name = %q, want display-name", resp.Name) } if resp.Content != "# Display Name\n" { t.Fatalf("content = %q", resp.Content) } } func TestHandleImportSkill(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } workspace := filepath.Join(t.TempDir(), "workspace") cfg.Agents.Defaults.Workspace = workspace err = config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } var body bytes.Buffer writer := multipart.NewWriter(&body) part, err := writer.CreateFormFile("file", "Plain Skill.md") if err != nil { t.Fatalf("CreateFormFile() error = %v", err) } _, err = io.WriteString(part, "# Plain Skill\n\nUse this skill to test imports.\n") if err != nil { t.Fatalf("WriteString() error = %v", err) } err = writer.Close() if err != nil { t.Fatalf("Close() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &body) req.Header.Set("Content-Type", writer.FormDataContentType()) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } skillFile := filepath.Join(workspace, "skills", "plain-skill", "SKILL.md") content, err := os.ReadFile(skillFile) if err != nil { t.Fatalf("ReadFile() error = %v", err) } expected := "---\nname: plain-skill\ndescription: Plain Skill\n---\n\n# Plain Skill\n\nUse this skill to test imports.\n" if string(content) != expected { t.Fatalf("saved skill content mismatch:\n%s", string(content)) } rec2 := httptest.NewRecorder() req2 := httptest.NewRequest(http.MethodGet, "/api/skills", nil) mux.ServeHTTP(rec2, req2) if rec2.Code != http.StatusOK { t.Fatalf("list status = %d, want %d, body=%s", rec2.Code, http.StatusOK, rec2.Body.String()) } var listResp skillSupportResponse if err := json.Unmarshal(rec2.Body.Bytes(), &listResp); err != nil { t.Fatalf("Unmarshal list response error = %v", err) } found := false for _, skill := range listResp.Skills { if skill.Name == "plain-skill" && skill.Source == "workspace" && skill.Description == "Plain Skill" { found = true } } if !found { t.Fatalf("plain-skill should be listed after import, got %#v", listResp.Skills) } } func TestHandleDeleteSkill(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } workspace := filepath.Join(t.TempDir(), "workspace") cfg.Agents.Defaults.Workspace = workspace if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } skillDir := filepath.Join(workspace, "skills", "delete-me") if err := os.MkdirAll(skillDir, 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } if err := os.WriteFile( filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: delete-me\ndescription: delete me\n---\n"), 0o644, ); err != nil { t.Fatalf("WriteFile() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodDelete, "/api/skills/delete-me", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if _, err := os.Stat(skillDir); !os.IsNotExist(err) { t.Fatalf("skill directory should be removed, stat err=%v", err) } } ================================================ FILE: web/backend/api/startup.go ================================================ package api import ( "bytes" "encoding/json" "errors" "fmt" "net/http" "os" "os/exec" "path/filepath" "runtime" "strings" ) const ( autoStartEntryName = "PicoClawLauncher" launchAgentLabel = "io.picoclaw.launcher" ) type autoStartRequest struct { Enabled bool `json:"enabled"` } type autoStartResponse struct { Enabled bool `json:"enabled"` Supported bool `json:"supported"` Platform string `json:"platform"` Message string `json:"message,omitempty"` } var errAutoStartUnsupported = errors.New("autostart is not supported on this platform") func (h *Handler) registerStartupRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/system/autostart", h.handleGetAutoStart) mux.HandleFunc("PUT /api/system/autostart", h.handleSetAutoStart) } func (h *Handler) handleGetAutoStart(w http.ResponseWriter, r *http.Request) { enabled, supported, message, err := h.getAutoStartStatus() if err != nil { http.Error(w, fmt.Sprintf("Failed to read startup setting: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(autoStartResponse{ Enabled: enabled, Supported: supported, Platform: runtime.GOOS, Message: message, }) } func (h *Handler) handleSetAutoStart(w http.ResponseWriter, r *http.Request) { var req autoStartRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } if err := h.setAutoStart(req.Enabled); err != nil { if errors.Is(err, errAutoStartUnsupported) { http.Error(w, err.Error(), http.StatusBadRequest) return } http.Error(w, fmt.Sprintf("Failed to update startup setting: %v", err), http.StatusInternalServerError) return } enabled, supported, message, err := h.getAutoStartStatus() if err != nil { http.Error(w, fmt.Sprintf("Failed to verify startup setting: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(autoStartResponse{ Enabled: enabled, Supported: supported, Platform: runtime.GOOS, Message: message, }) } func (h *Handler) resolveLaunchCommand() (string, []string, error) { exePath, err := os.Executable() if err != nil { return "", nil, err } args := []string{"-no-browser"} if h.configPath != "" { args = append(args, h.configPath) } return exePath, args, nil } func (h *Handler) getAutoStartStatus() (enabled bool, supported bool, message string, err error) { switch runtime.GOOS { case "darwin": exists, err := fileExists(macLaunchAgentPath()) return exists, true, "Changes apply on next login.", err case "linux": exists, err := fileExists(linuxAutoStartPath()) return exists, true, "Changes apply on next login.", err case "windows": exists, err := windowsRunKeyExists() return exists, true, "Changes apply on next login.", err default: return false, false, "Current platform does not support launch at login.", nil } } func (h *Handler) setAutoStart(enabled bool) error { exePath, args, err := h.resolveLaunchCommand() if err != nil { return err } switch runtime.GOOS { case "darwin": return setDarwinAutoStart(enabled, exePath, args) case "linux": return setLinuxAutoStart(enabled, exePath, args) case "windows": return setWindowsAutoStart(enabled, exePath, args) default: return errAutoStartUnsupported } } func fileExists(path string) (bool, error) { _, err := os.Stat(path) if err == nil { return true, nil } if os.IsNotExist(err) { return false, nil } return false, err } func macLaunchAgentPath() string { home, _ := os.UserHomeDir() return filepath.Join(home, "Library", "LaunchAgents", launchAgentLabel+".plist") } func setDarwinAutoStart(enabled bool, exePath string, args []string) error { plistPath := macLaunchAgentPath() if enabled { if err := os.MkdirAll(filepath.Dir(plistPath), 0o755); err != nil { return err } content := buildDarwinPlist(exePath, args) return os.WriteFile(plistPath, []byte(content), 0o644) } if err := os.Remove(plistPath); err != nil && !os.IsNotExist(err) { return err } return nil } func xmlEscape(s string) string { var b bytes.Buffer for _, r := range s { switch r { case '&': b.WriteString("&") case '<': b.WriteString("<") case '>': b.WriteString(">") case '"': b.WriteString(""") case '\'': b.WriteString("'") default: b.WriteRune(r) } } return b.String() } func buildDarwinPlist(exePath string, args []string) string { programArgs := make([]string, 0, len(args)+1) programArgs = append(programArgs, exePath) programArgs = append(programArgs, args...) var b strings.Builder b.WriteString(`` + "\n") b.WriteString( `` + "\n", ) b.WriteString(`` + "\n") b.WriteString(`` + "\n") b.WriteString(` Label` + "\n") b.WriteString(` ` + launchAgentLabel + `` + "\n") b.WriteString(` ProgramArguments` + "\n") b.WriteString(` ` + "\n") for _, arg := range programArgs { b.WriteString(` ` + xmlEscape(arg) + `` + "\n") } b.WriteString(` ` + "\n") b.WriteString(` RunAtLoad` + "\n") b.WriteString(` ` + "\n") b.WriteString(` ProcessType` + "\n") b.WriteString(` Background` + "\n") b.WriteString(`` + "\n") b.WriteString(`` + "\n") return b.String() } func linuxAutoStartPath() string { home, _ := os.UserHomeDir() return filepath.Join(home, ".config", "autostart", "picoclaw-web.desktop") } func shellQuote(s string) string { if s == "" { return "''" } if !strings.ContainsAny(s, " \t\n'\"\\$`") { return s } return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" } func buildLinuxExecLine(exePath string, args []string) string { parts := make([]string, 0, len(args)+1) parts = append(parts, shellQuote(exePath)) for _, arg := range args { parts = append(parts, shellQuote(arg)) } return strings.Join(parts, " ") } func setLinuxAutoStart(enabled bool, exePath string, args []string) error { desktopPath := linuxAutoStartPath() if enabled { if err := os.MkdirAll(filepath.Dir(desktopPath), 0o755); err != nil { return err } content := strings.Join([]string{ "[Desktop Entry]", "Type=Application", "Version=1.0", "Name=PicoClaw Web", "Comment=Start PicoClaw Web on login", "Exec=" + buildLinuxExecLine(exePath, args), "Terminal=false", "X-GNOME-Autostart-enabled=true", "NoDisplay=true", "", }, "\n") return os.WriteFile(desktopPath, []byte(content), 0o644) } if err := os.Remove(desktopPath); err != nil && !os.IsNotExist(err) { return err } return nil } func windowsCommandLine(exePath string, args []string) string { parts := make([]string, 0, len(args)+1) parts = append(parts, fmt.Sprintf("%q", exePath)) for _, arg := range args { parts = append(parts, fmt.Sprintf("%q", arg)) } return strings.Join(parts, " ") } func windowsRunKeyExists() (bool, error) { cmd := exec.Command("reg", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autoStartEntryName) if err := cmd.Run(); err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) { return false, nil } return false, err } return true, nil } func setWindowsAutoStart(enabled bool, exePath string, args []string) error { key := `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` if enabled { commandLine := windowsCommandLine(exePath, args) cmd := exec.Command("reg", "add", key, "/v", autoStartEntryName, "/t", "REG_SZ", "/d", commandLine, "/f") return cmd.Run() } cmd := exec.Command("reg", "delete", key, "/v", autoStartEntryName, "/f") if err := cmd.Run(); err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) { return nil } return err } return nil } ================================================ FILE: web/backend/api/startup_test.go ================================================ package api import ( "path/filepath" "strings" "testing" "github.com/sipeed/picoclaw/web/backend/launcherconfig" ) func TestResolveLaunchCommandUsesConfigFileDefaults(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) // Persist non-default launcher options to ensure resolveLaunchCommand does not // pin them into autostart args. launcherPath := launcherconfig.PathForAppConfig(configPath) if err := launcherconfig.Save(launcherPath, launcherconfig.Config{ Port: 19999, Public: true, }); err != nil { t.Fatalf("launcherconfig.Save() error = %v", err) } exePath, args, err := h.resolveLaunchCommand() if err != nil { t.Fatalf("resolveLaunchCommand() error = %v", err) } if exePath == "" { t.Fatal("resolveLaunchCommand() returned empty executable path") } if len(args) != 2 { t.Fatalf("args len = %d, want 2 (got %v)", len(args), args) } if args[0] != "-no-browser" { t.Fatalf("args[0] = %q, want %q", args[0], "-no-browser") } if args[1] != configPath { t.Fatalf("args[1] = %q, want %q", args[1], configPath) } for _, arg := range args { if arg == "-port" || arg == "-public" { t.Fatalf("autostart args should not pin network flags, got %v", args) } } } func TestBuildDarwinPlistIncludesRunAtLoad(t *testing.T) { plist := buildDarwinPlist("/tmp/picoclaw-web", []string{"-no-browser", "/tmp/config.json"}) if !strings.Contains(plist, "RunAtLoad") { t.Fatalf("plist missing RunAtLoad key:\n%s", plist) } if !strings.Contains(plist, "") { t.Fatalf("plist missing RunAtLoad true value:\n%s", plist) } } ================================================ FILE: web/backend/api/tools.go ================================================ package api import ( "encoding/json" "fmt" "net/http" "runtime" "github.com/sipeed/picoclaw/pkg/config" ) type toolCatalogEntry struct { Name string Description string Category string ConfigKey string } type toolSupportItem struct { Name string `json:"name"` Description string `json:"description"` Category string `json:"category"` ConfigKey string `json:"config_key"` Status string `json:"status"` ReasonCode string `json:"reason_code,omitempty"` } type toolSupportResponse struct { Tools []toolSupportItem `json:"tools"` } type toolStateRequest struct { Enabled bool `json:"enabled"` } var toolCatalog = []toolCatalogEntry{ { Name: "read_file", Description: "Read file content from the workspace or explicitly allowed paths.", Category: "filesystem", ConfigKey: "read_file", }, { Name: "write_file", Description: "Create or overwrite files within the writable workspace scope.", Category: "filesystem", ConfigKey: "write_file", }, { Name: "list_dir", Description: "Inspect directories and enumerate files available to the agent.", Category: "filesystem", ConfigKey: "list_dir", }, { Name: "edit_file", Description: "Apply targeted edits to existing files without rewriting everything.", Category: "filesystem", ConfigKey: "edit_file", }, { Name: "append_file", Description: "Append content to the end of an existing file.", Category: "filesystem", ConfigKey: "append_file", }, { Name: "exec", Description: "Run shell commands inside the configured workspace sandbox.", Category: "filesystem", ConfigKey: "exec", }, { Name: "cron", Description: "Schedule one-time or recurring reminders, jobs, and shell commands.", Category: "automation", ConfigKey: "cron", }, { Name: "web_search", Description: "Search the web using the configured providers.", Category: "web", ConfigKey: "web", }, { Name: "web_fetch", Description: "Fetch and summarize the contents of a webpage.", Category: "web", ConfigKey: "web_fetch", }, { Name: "message", Description: "Send a follow-up message back to the active user or chat.", Category: "communication", ConfigKey: "message", }, { Name: "send_file", Description: "Send an outbound file or media attachment to the active chat.", Category: "communication", ConfigKey: "send_file", }, { Name: "find_skills", Description: "Search external skill registries for installable skills.", Category: "skills", ConfigKey: "find_skills", }, { Name: "install_skill", Description: "Install a skill into the current workspace from a registry.", Category: "skills", ConfigKey: "install_skill", }, { Name: "spawn", Description: "Launch a background subagent for long-running or delegated work.", Category: "agents", ConfigKey: "spawn", }, { Name: "spawn_status", Description: "Query the status of spawned subagents.", Category: "agents", ConfigKey: "spawn_status", }, { Name: "i2c", Description: "Interact with I2C hardware devices exposed on the host.", Category: "hardware", ConfigKey: "i2c", }, { Name: "spi", Description: "Interact with SPI hardware devices exposed on the host.", Category: "hardware", ConfigKey: "spi", }, { Name: "tool_search_tool_regex", Description: "Discover hidden MCP tools by regex search when tool discovery is enabled.", Category: "discovery", ConfigKey: "mcp.discovery.use_regex", }, { Name: "tool_search_tool_bm25", Description: "Discover hidden MCP tools by semantic ranking when tool discovery is enabled.", Category: "discovery", ConfigKey: "mcp.discovery.use_bm25", }, } func (h *Handler) registerToolRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/tools", h.handleListTools) mux.HandleFunc("PUT /api/tools/{name}/state", h.handleUpdateToolState) } func (h *Handler) handleListTools(w http.ResponseWriter, r *http.Request) { cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(toolSupportResponse{ Tools: buildToolSupport(cfg), }) } func (h *Handler) handleUpdateToolState(w http.ResponseWriter, r *http.Request) { cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } var req toolStateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } if err := applyToolState(cfg, r.PathValue("name"), req.Enabled); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := config.SaveConfig(h.configPath, cfg); err != nil { http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } func buildToolSupport(cfg *config.Config) []toolSupportItem { items := make([]toolSupportItem, 0, len(toolCatalog)) for _, entry := range toolCatalog { status := "disabled" reasonCode := "" switch entry.Name { case "find_skills", "install_skill": if cfg.Tools.IsToolEnabled(entry.ConfigKey) { if cfg.Tools.IsToolEnabled("skills") { status = "enabled" } else { status = "blocked" reasonCode = "requires_skills" } } case "spawn", "spawn_status": if cfg.Tools.IsToolEnabled(entry.ConfigKey) { if cfg.Tools.IsToolEnabled("subagent") { status = "enabled" } else { status = "blocked" reasonCode = "requires_subagent" } } case "tool_search_tool_regex": status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseRegex) case "tool_search_tool_bm25": status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseBM25) case "i2c", "spi": status, reasonCode = resolveHardwareToolSupport(cfg.Tools.IsToolEnabled(entry.ConfigKey)) default: if cfg.Tools.IsToolEnabled(entry.ConfigKey) { status = "enabled" } } items = append(items, toolSupportItem{ Name: entry.Name, Description: entry.Description, Category: entry.Category, ConfigKey: entry.ConfigKey, Status: status, ReasonCode: reasonCode, }) } return items } func resolveHardwareToolSupport(enabled bool) (string, string) { if !enabled { return "disabled", "" } if runtime.GOOS != "linux" { return "blocked", "requires_linux" } return "enabled", "" } func resolveDiscoveryToolSupport(cfg *config.Config, methodEnabled bool) (string, string) { if !cfg.Tools.IsToolEnabled("mcp") { return "disabled", "" } if !cfg.Tools.MCP.Discovery.Enabled { return "blocked", "requires_mcp_discovery" } if !methodEnabled { return "disabled", "" } return "enabled", "" } func applyToolState(cfg *config.Config, toolName string, enabled bool) error { switch toolName { case "read_file": cfg.Tools.ReadFile.Enabled = enabled case "write_file": cfg.Tools.WriteFile.Enabled = enabled case "list_dir": cfg.Tools.ListDir.Enabled = enabled case "edit_file": cfg.Tools.EditFile.Enabled = enabled case "append_file": cfg.Tools.AppendFile.Enabled = enabled case "exec": cfg.Tools.Exec.Enabled = enabled case "cron": cfg.Tools.Cron.Enabled = enabled case "web_search": cfg.Tools.Web.Enabled = enabled case "web_fetch": cfg.Tools.WebFetch.Enabled = enabled case "message": cfg.Tools.Message.Enabled = enabled case "send_file": cfg.Tools.SendFile.Enabled = enabled case "find_skills": cfg.Tools.FindSkills.Enabled = enabled if enabled { cfg.Tools.Skills.Enabled = true } case "install_skill": cfg.Tools.InstallSkill.Enabled = enabled if enabled { cfg.Tools.Skills.Enabled = true } case "spawn": cfg.Tools.Spawn.Enabled = enabled if enabled { cfg.Tools.Subagent.Enabled = true } case "spawn_status": cfg.Tools.SpawnStatus.Enabled = enabled if enabled { cfg.Tools.Spawn.Enabled = true cfg.Tools.Subagent.Enabled = true } case "i2c": cfg.Tools.I2C.Enabled = enabled case "spi": cfg.Tools.SPI.Enabled = enabled case "tool_search_tool_regex": cfg.Tools.MCP.Discovery.UseRegex = enabled if enabled { cfg.Tools.MCP.Enabled = true cfg.Tools.MCP.Discovery.Enabled = true } case "tool_search_tool_bm25": cfg.Tools.MCP.Discovery.UseBM25 = enabled if enabled { cfg.Tools.MCP.Enabled = true cfg.Tools.MCP.Discovery.Enabled = true } default: return fmt.Errorf("tool %q cannot be updated", toolName) } return nil } ================================================ FILE: web/backend/api/tools_test.go ================================================ package api import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "runtime" "testing" "github.com/sipeed/picoclaw/pkg/config" ) func TestHandleListTools(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.Tools.ReadFile.Enabled = true cfg.Tools.WriteFile.Enabled = false cfg.Tools.Cron.Enabled = true cfg.Tools.FindSkills.Enabled = true cfg.Tools.Skills.Enabled = true cfg.Tools.Spawn.Enabled = true cfg.Tools.Subagent.Enabled = false cfg.Tools.MCP.Enabled = true cfg.Tools.MCP.Discovery.Enabled = true cfg.Tools.MCP.Discovery.UseRegex = true cfg.Tools.MCP.Discovery.UseBM25 = false err = config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/tools", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp toolSupportResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } gotTools := make(map[string]toolSupportItem, len(resp.Tools)) for _, tool := range resp.Tools { gotTools[tool.Name] = tool } if gotTools["read_file"].Status != "enabled" { t.Fatalf("read_file status = %q, want enabled", gotTools["read_file"].Status) } if gotTools["write_file"].Status != "disabled" { t.Fatalf("write_file status = %q, want disabled", gotTools["write_file"].Status) } if gotTools["cron"].Status != "enabled" { t.Fatalf("cron status = %q, want enabled", gotTools["cron"].Status) } if gotTools["spawn"].Status != "blocked" || gotTools["spawn"].ReasonCode != "requires_subagent" { t.Fatalf("spawn = %#v, want blocked/requires_subagent", gotTools["spawn"]) } if gotTools["find_skills"].Status != "enabled" { t.Fatalf("find_skills status = %q, want enabled", gotTools["find_skills"].Status) } if gotTools["tool_search_tool_regex"].Status != "enabled" { t.Fatalf("tool_search_tool_regex status = %q, want enabled", gotTools["tool_search_tool_regex"].Status) } if gotTools["tool_search_tool_regex"].ConfigKey != "mcp.discovery.use_regex" { t.Fatalf( "tool_search_tool_regex config_key = %q, want mcp.discovery.use_regex", gotTools["tool_search_tool_regex"].ConfigKey, ) } if gotTools["tool_search_tool_bm25"].Status != "disabled" { t.Fatalf("tool_search_tool_bm25 status = %q, want disabled", gotTools["tool_search_tool_bm25"].Status) } if gotTools["tool_search_tool_bm25"].ConfigKey != "mcp.discovery.use_bm25" { t.Fatalf( "tool_search_tool_bm25 config_key = %q, want mcp.discovery.use_bm25", gotTools["tool_search_tool_bm25"].ConfigKey, ) } if runtime.GOOS == "linux" { if gotTools["i2c"].Status != "disabled" { t.Fatalf("i2c status = %q, want disabled on linux when config is off", gotTools["i2c"].Status) } } else { cfg.Tools.I2C.Enabled = true cfg.Tools.SPI.Enabled = true if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } rec = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/api/tools", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } gotTools = make(map[string]toolSupportItem, len(resp.Tools)) for _, tool := range resp.Tools { gotTools[tool.Name] = tool } if gotTools["i2c"].Status != "blocked" || gotTools["i2c"].ReasonCode != "requires_linux" { t.Fatalf("i2c = %#v, want blocked/requires_linux", gotTools["i2c"]) } if gotTools["spi"].Status != "blocked" || gotTools["spi"].ReasonCode != "requires_linux" { t.Fatalf("spi = %#v, want blocked/requires_linux", gotTools["spi"]) } } } func TestHandleUpdateToolState(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } cfg.Tools.Spawn.Enabled = false cfg.Tools.Subagent.Enabled = false cfg.Tools.Cron.Enabled = false cfg.Tools.MCP.Enabled = false cfg.Tools.MCP.Discovery.Enabled = false cfg.Tools.MCP.Discovery.UseRegex = false err = config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest( http.MethodPut, "/api/tools/spawn/state", bytes.NewBufferString(`{"enabled":true}`), ) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("spawn status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } rec2 := httptest.NewRecorder() req2 := httptest.NewRequest( http.MethodPut, "/api/tools/tool_search_tool_regex/state", bytes.NewBufferString(`{"enabled":true}`), ) req2.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec2, req2) if rec2.Code != http.StatusOK { t.Fatalf("regex status = %d, want %d, body=%s", rec2.Code, http.StatusOK, rec2.Body.String()) } rec3 := httptest.NewRecorder() req3 := httptest.NewRequest( http.MethodPut, "/api/tools/cron/state", bytes.NewBufferString(`{"enabled":true}`), ) req3.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec3, req3) if rec3.Code != http.StatusOK { t.Fatalf("cron status = %d, want %d, body=%s", rec3.Code, http.StatusOK, rec3.Body.String()) } updated, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig(updated) error = %v", err) } if !updated.Tools.Spawn.Enabled || !updated.Tools.Subagent.Enabled { t.Fatalf("spawn/subagent should both be enabled: %#v", updated.Tools) } if !updated.Tools.MCP.Enabled || !updated.Tools.MCP.Discovery.Enabled || !updated.Tools.MCP.Discovery.UseRegex { t.Fatalf("mcp regex discovery should be enabled: %#v", updated.Tools.MCP) } if !updated.Tools.Cron.Enabled { t.Fatalf("cron should be enabled: %#v", updated.Tools.Cron) } } ================================================ FILE: web/backend/app_runtime.go ================================================ package main import ( "context" "errors" "fmt" "time" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/web/backend/utils" ) const ( browserDelay = 500 * time.Millisecond shutdownTimeout = 15 * time.Second ) // shutdownApp gracefully shuts down all server components and resources. // It performs the following shutdown sequence: // - Shuts down the API handler to close all active SSE (Server-Sent Events) connections // - Disables HTTP keep-alive to prevent new connections during shutdown // - Attempts graceful HTTP server shutdown with timeout // - Logs shutdown status at appropriate log levels // // The function handles timeout errors gracefully by logging them at info level // since context.DeadlineExceeded is expected when there are active long-running // connections (such as SSE streams). // // This function should be called during application termination to ensure // clean resource cleanup and proper connection closure. func shutdownApp() { // First, shutdown API handler to close all SSE connections if apiHandler != nil { apiHandler.Shutdown() } if server != nil { // Disable keep-alive to allow graceful shutdown server.SetKeepAlivesEnabled(false) ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) defer cancel() if err := server.Shutdown(ctx); err != nil { // Context deadline exceeded is expected if there are active connections // This is not necessarily an error, so log it at info level if errors.Is(err, context.DeadlineExceeded) { logger.Infof("Server shutdown timeout after %v, forcing close", shutdownTimeout) } else { logger.Errorf("Server shutdown error: %v", err) } } else { logger.Infof("Server shutdown completed successfully") } } } func openBrowser() error { if serverAddr == "" { return fmt.Errorf("server address not set") } return utils.OpenBrowser(serverAddr) } ================================================ FILE: web/backend/dist/.gitkeep ================================================ # Keep the embedded web backend dist directory in version control. ================================================ FILE: web/backend/embed.go ================================================ package main import ( "embed" "fmt" "io/fs" "mime" "net/http" "path" "strings" "github.com/sipeed/picoclaw/pkg/logger" ) //go:embed all:dist var frontendFS embed.FS // registerEmbedRoutes sets up the HTTP handler to serve the embedded frontend files func registerEmbedRoutes(mux *http.ServeMux) { // Register correct MIME type for SVG files // Go's built-in mime.TypeByExtension returns "image/svg" which is incorrect // The correct MIME type per RFC 6838 is "image/svg+xml" if err := mime.AddExtensionType(".svg", "image/svg+xml"); err != nil { logger.ErrorC("web", fmt.Sprintf("Warning: failed to register SVG MIME type: %v", err)) } // Attempt to get the subdirectory 'dist' where Vite usually builds subFS, err := fs.Sub(frontendFS, "dist") if err != nil { // Log a warning if dist doesn't exist yet (e.g., during development before a frontend build) logger.WarnC("web", "Warning: no 'dist' folder found in embedded frontend. "+ "Ensure you run `pnpm build:backend` in the frontend directory "+ "before building the Go backend.", ) return } fileServer := http.FileServer(http.FS(subFS)) // Serve static assets and fallback to index.html for SPA routes. mux.Handle( "/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { http.NotFound(w, r) return } // Keep unknown API paths as 404 instead of falling back to SPA entry. if r.URL.Path == "/api" || strings.HasPrefix(r.URL.Path, "/api/") { http.NotFound(w, r) return } cleanPath := path.Clean(strings.TrimPrefix(r.URL.Path, "/")) if cleanPath == "." { cleanPath = "" } // Existing static files/directories should be served directly. if cleanPath != "" { if _, statErr := fs.Stat(subFS, cleanPath); statErr == nil { fileServer.ServeHTTP(w, r) return } // Missing asset-like paths should remain 404. if strings.Contains(path.Base(cleanPath), ".") { fileServer.ServeHTTP(w, r) return } } indexReq := r.Clone(r.Context()) indexReq.URL.Path = "/" fileServer.ServeHTTP(w, indexReq) }), ) } ================================================ FILE: web/backend/embed_test.go ================================================ package main import ( "net/http" "net/http/httptest" "testing" ) func TestUnknownAPIPathStays404(t *testing.T) { mux := http.NewServeMux() registerEmbedRoutes(mux) req := httptest.NewRequest(http.MethodGet, "/api/not-found", nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusNotFound { t.Fatalf("status = %d, want %d", rr.Code, http.StatusNotFound) } } func TestMissingAssetStays404(t *testing.T) { mux := http.NewServeMux() registerEmbedRoutes(mux) req := httptest.NewRequest(http.MethodGet, "/assets/not-found.js", nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusNotFound { t.Fatalf("status = %d, want %d", rr.Code, http.StatusNotFound) } } ================================================ FILE: web/backend/i18n.go ================================================ package main import ( "fmt" "os" "strings" ) // Language represents the supported languages type Language string const ( LanguageEnglish Language = "en" LanguageChinese Language = "zh" ) // current language (default: English) var currentLang Language = LanguageEnglish // TranslationKey represents a translation key used for i18n type TranslationKey string const ( AppTooltip TranslationKey = "AppTooltip" MenuOpen TranslationKey = "MenuOpen" MenuOpenTooltip TranslationKey = "MenuOpenTooltip" MenuAbout TranslationKey = "MenuAbout" MenuAboutTooltip TranslationKey = "MenuAboutTooltip" MenuVersion TranslationKey = "MenuVersion" MenuVersionTooltip TranslationKey = "MenuVersionTooltip" MenuGitHub TranslationKey = "MenuGitHub" MenuDocs TranslationKey = "MenuDocs" MenuRestart TranslationKey = "MenuRestart" MenuRestartTooltip TranslationKey = "MenuRestartTooltip" MenuQuit TranslationKey = "MenuQuit" MenuQuitTooltip TranslationKey = "MenuQuitTooltip" Exiting TranslationKey = "Exiting" DocUrl TranslationKey = "DocUrl" ) // Translation tables // Chinese translations intentionally contain Han script // //nolint:gosmopolitan var translations = map[Language]map[TranslationKey]string{ LanguageEnglish: { AppTooltip: "%s - Web Console", MenuOpen: "Open Console", MenuOpenTooltip: "Open PicoClaw console in browser", MenuAbout: "About", MenuAboutTooltip: "About PicoClaw", MenuVersion: "Version: %s", MenuVersionTooltip: "Current version number", MenuGitHub: "GitHub", MenuDocs: "Documentation", MenuRestart: "Restart Service", MenuRestartTooltip: "Restart Gateway service", MenuQuit: "Quit", MenuQuitTooltip: "Exit PicoClaw", Exiting: "Exiting PicoClaw...", DocUrl: "https://docs.picoclaw.io/docs/", }, LanguageChinese: { AppTooltip: "%s - Web Console", MenuOpen: "打开控制台", MenuOpenTooltip: "在浏览器中打开 PicoClaw 控制台", MenuAbout: "关于", MenuAboutTooltip: "关于 PicoClaw", MenuVersion: "版本: %s", MenuVersionTooltip: "当前版本号", MenuGitHub: "GitHub", MenuDocs: "文档", MenuRestart: "重启服务", MenuRestartTooltip: "重启核心服务", MenuQuit: "退出", MenuQuitTooltip: "退出 PicoClaw", Exiting: "正在退出 PicoClaw...", DocUrl: "https://docs.picoclaw.io/zh-Hans/docs/", }, } // SetLanguage sets the current language func SetLanguage(lang string) { lang = strings.ToLower(strings.TrimSpace(lang)) // Extract language code before first underscore or dot // e.g., "en_US.UTF-8" -> "en", "zh_CN" -> "zh" if idx := strings.IndexAny(lang, "_."); idx > 0 { lang = lang[:idx] } if lang == "zh" || lang == "zh-cn" || lang == "chinese" { currentLang = LanguageChinese } else { currentLang = LanguageEnglish } } // GetLanguage returns the current language func GetLanguage() Language { return currentLang } // T translates a key to the current language func T(key TranslationKey, args ...any) string { if trans, ok := translations[currentLang][key]; ok { if len(args) > 0 { return fmt.Sprintf(trans, args...) } return trans } return string(key) } // Initialize i18n from environment variable func init() { if lang := os.Getenv("LANG"); lang != "" { SetLanguage(lang) } } ================================================ FILE: web/backend/launcherconfig/config.go ================================================ package launcherconfig import ( "encoding/json" "fmt" "net" "os" "path/filepath" "strings" ) const ( // FileName is the launcher-specific settings file name. FileName = "launcher-config.json" // DefaultPort is the default port for the web launcher. DefaultPort = 18800 ) // Config stores launch parameters for the web backend service. type Config struct { Port int `json:"port"` Public bool `json:"public"` AllowedCIDRs []string `json:"allowed_cidrs,omitempty"` } // Default returns default launcher settings. func Default() Config { return Config{Port: DefaultPort, Public: false} } // Validate checks if launcher settings are valid. func Validate(cfg Config) error { if cfg.Port < 1 || cfg.Port > 65535 { return fmt.Errorf("port %d is out of range (1-65535)", cfg.Port) } for _, cidr := range cfg.AllowedCIDRs { if _, _, err := net.ParseCIDR(cidr); err != nil { return fmt.Errorf("invalid CIDR %q", cidr) } } return nil } // NormalizeCIDRs trims entries, removes empty values, and deduplicates CIDRs. func NormalizeCIDRs(cidrs []string) []string { if len(cidrs) == 0 { return nil } out := make([]string, 0, len(cidrs)) seen := make(map[string]struct{}, len(cidrs)) for _, raw := range cidrs { trimmed := strings.TrimSpace(raw) if trimmed == "" { continue } if _, ok := seen[trimmed]; ok { continue } seen[trimmed] = struct{}{} out = append(out, trimmed) } if len(out) == 0 { return nil } return out } // PathForAppConfig returns launcher-config path near the app config file. func PathForAppConfig(appConfigPath string) string { dir := filepath.Dir(appConfigPath) if dir == "" || dir == "." { dir = "." } return filepath.Join(dir, FileName) } // Load reads launcher settings; fallback is returned when file does not exist. func Load(path string, fallback Config) (Config, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return fallback, nil } return Config{}, err } cfg := fallback if err := json.Unmarshal(data, &cfg); err != nil { return Config{}, err } cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs) if err := Validate(cfg); err != nil { return Config{}, err } return cfg, nil } // Save writes launcher settings to disk. func Save(path string, cfg Config) error { cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs) if err := Validate(cfg); err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } data = append(data, '\n') return os.WriteFile(path, data, 0o600) } ================================================ FILE: web/backend/launcherconfig/config_test.go ================================================ package launcherconfig import ( "os" "path/filepath" "testing" ) func TestLoadReturnsFallbackWhenMissing(t *testing.T) { path := filepath.Join(t.TempDir(), "launcher-config.json") fallback := Config{Port: 19999, Public: true} got, err := Load(path, fallback) if err != nil { t.Fatalf("Load() error = %v", err) } if got.Port != fallback.Port || got.Public != fallback.Public { t.Fatalf("Load() = %+v, want %+v", got, fallback) } } func TestSaveAndLoadRoundTrip(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "launcher-config.json") want := Config{ Port: 18080, Public: true, AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"}, } if err := Save(path, want); err != nil { t.Fatalf("Save() error = %v", err) } got, err := Load(path, Default()) if err != nil { t.Fatalf("Load() error = %v", err) } if got.Port != want.Port || got.Public != want.Public { t.Fatalf("Load() = %+v, want %+v", got, want) } if len(got.AllowedCIDRs) != len(want.AllowedCIDRs) { t.Fatalf("allowed_cidrs len = %d, want %d", len(got.AllowedCIDRs), len(want.AllowedCIDRs)) } for i := range want.AllowedCIDRs { if got.AllowedCIDRs[i] != want.AllowedCIDRs[i] { t.Fatalf("allowed_cidrs[%d] = %q, want %q", i, got.AllowedCIDRs[i], want.AllowedCIDRs[i]) } } stat, err := os.Stat(path) if err != nil { t.Fatalf("Stat() error = %v", err) } if perm := stat.Mode().Perm(); perm != 0o600 { t.Fatalf("file perm = %o, want 600", perm) } } func TestValidateRejectsInvalidPort(t *testing.T) { if err := Validate(Config{Port: 0, Public: false}); err == nil { t.Fatal("Validate() expected error for port 0") } if err := Validate(Config{Port: 65536, Public: false}); err == nil { t.Fatal("Validate() expected error for port 65536") } } func TestValidateRejectsInvalidCIDR(t *testing.T) { err := Validate(Config{ Port: 18800, AllowedCIDRs: []string{"192.168.1.0/24", "not-a-cidr"}, }) if err == nil { t.Fatal("Validate() expected error for invalid CIDR") } } func TestNormalizeCIDRs(t *testing.T) { got := NormalizeCIDRs([]string{" 192.168.1.0/24 ", "", "10.0.0.0/8", "192.168.1.0/24"}) want := []string{"192.168.1.0/24", "10.0.0.0/8"} if len(got) != len(want) { t.Fatalf("len(got) = %d, want %d", len(got), len(want)) } for i := range want { if got[i] != want[i] { t.Fatalf("got[%d] = %q, want %q", i, got[i], want[i]) } } } ================================================ FILE: web/backend/main.go ================================================ // PicoClaw Web Console - Web-based chat and management interface // // Provides a web UI for chatting with PicoClaw via the Pico Channel WebSocket, // with configuration management and gateway process control. // // Usage: // // go build -o picoclaw-web ./web/backend/ // ./picoclaw-web [config.json] // ./picoclaw-web -public config.json package main import ( "errors" "flag" "fmt" "net/http" "os" "os/signal" "path/filepath" "strconv" "syscall" "time" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/web/backend/api" "github.com/sipeed/picoclaw/web/backend/launcherconfig" "github.com/sipeed/picoclaw/web/backend/middleware" "github.com/sipeed/picoclaw/web/backend/utils" ) const ( appName = "PicoClaw" ) var ( appVersion = config.Version server *http.Server serverAddr string apiHandler *api.Handler noBrowser *bool ) func main() { port := flag.String("port", "18800", "Port to listen on") public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only") noBrowser = flag.Bool("no-browser", false, "Do not auto-open browser on startup") lang := flag.String("lang", "", "Language: en (English) or zh (Chinese). Default: auto-detect from system locale") console := flag.Bool("console", false, "Console mode, no GUI") flag.Usage = func() { fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n") fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0]) fmt.Fprintf(os.Stderr, "Arguments:\n") fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n") fmt.Fprintf(os.Stderr, "Options:\n") flag.PrintDefaults() fmt.Fprintf(os.Stderr, "\nExamples:\n") fmt.Fprintf(os.Stderr, " %s Use default config path\n", os.Args[0]) fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0]) fmt.Fprintf( os.Stderr, " %s -public ./config.json Allow access from other devices on the network\n", os.Args[0], ) } flag.Parse() // Initialize logger picoHome := utils.GetPicoclawHome() // By default, detect terminal to decide console log behavior // If -console-logs flag is explicitly set, it overrides the detection enableConsole := *console if !enableConsole { // Disable console logging by setting level to Fatal (no output) logger.SetConsoleLevel(logger.FATAL) logPath := filepath.Join(picoHome, "logs", "web.log") if err := logger.EnableFileLogging(logPath); err != nil { // FIXME: https://github.com/sipeed/picoclaw/issues/1734 fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err) os.Exit(1) } defer logger.DisableFileLogging() } logger.InfoC("web", "PicoClaw Launcher starting...") logger.InfoC("web", fmt.Sprintf("PicoClaw Home: %s", picoHome)) // Set language from command line or auto-detect if *lang != "" { SetLanguage(*lang) } // Resolve config path configPath := utils.GetDefaultConfigPath() if flag.NArg() > 0 { configPath = flag.Arg(0) } absPath, err := filepath.Abs(configPath) if err != nil { logger.Fatalf("Failed to resolve config path: %v", err) } err = utils.EnsureOnboarded(absPath) if err != nil { logger.Errorf("Warning: Failed to initialize PicoClaw config automatically: %v", err) } var explicitPort bool var explicitPublic bool flag.Visit(func(f *flag.Flag) { switch f.Name { case "port": explicitPort = true case "public": explicitPublic = true } }) launcherPath := launcherconfig.PathForAppConfig(absPath) launcherCfg, err := launcherconfig.Load(launcherPath, launcherconfig.Default()) if err != nil { logger.ErrorC("web", fmt.Sprintf("Warning: Failed to load %s: %v", launcherPath, err)) launcherCfg = launcherconfig.Default() } effectivePort := *port effectivePublic := *public if !explicitPort { effectivePort = strconv.Itoa(launcherCfg.Port) } if !explicitPublic { effectivePublic = launcherCfg.Public } portNum, err := strconv.Atoi(effectivePort) if err != nil || portNum < 1 || portNum > 65535 { if err == nil { err = errors.New("must be in range 1-65535") } logger.Fatalf("Invalid port %q: %v", effectivePort, err) } // Determine listen address var addr string if effectivePublic { addr = "0.0.0.0:" + effectivePort } else { addr = "127.0.0.1:" + effectivePort } // Initialize Server components mux := http.NewServeMux() // API Routes (e.g. /api/status) apiHandler = api.NewHandler(absPath) apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs) apiHandler.RegisterRoutes(mux) // Frontend Embedded Assets registerEmbedRoutes(mux) accessControlledMux, err := middleware.IPAllowlist(launcherCfg.AllowedCIDRs, mux) if err != nil { logger.Fatalf("Invalid allowed CIDR configuration: %v", err) } // Apply middleware stack handler := middleware.Recoverer( middleware.Logger( middleware.JSONContentType(accessControlledMux), ), ) // Print startup banner (only in console mode) if enableConsole { fmt.Print(utils.Banner) fmt.Println() fmt.Println(" Open the following URL in your browser:") fmt.Println() fmt.Printf(" >> http://localhost:%s <<\n", effectivePort) if effectivePublic { if ip := utils.GetLocalIP(); ip != "" { fmt.Printf(" >> http://%s:%s <<\n", ip, effectivePort) } } fmt.Println() } // Log startup info to file logger.InfoC("web", fmt.Sprintf("Server will listen on http://localhost:%s", effectivePort)) if effectivePublic { if ip := utils.GetLocalIP(); ip != "" { logger.InfoC("web", fmt.Sprintf("Public access enabled at http://%s:%s", ip, effectivePort)) } } // Share the local URL with the launcher runtime. serverAddr = fmt.Sprintf("http://localhost:%s", effectivePort) // Auto-open browser will be handled by the launcher runtime. // Auto-start gateway after backend starts listening. go func() { time.Sleep(1 * time.Second) apiHandler.TryAutoStartGateway() }() // Start the Server in a goroutine server = &http.Server{Addr: addr, Handler: handler} go func() { logger.InfoC("web", fmt.Sprintf("Server listening on %s", addr)) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { logger.Fatalf("Server failed to start: %v", err) } }() defer shutdownApp() // Start system tray or run in console mode if enableConsole { if !*noBrowser { // Auto-open browser after systray is ready (if not disabled) // Check no-browser flag via environment or pass as parameter if needed if err := openBrowser(); err != nil { logger.Errorf("Warning: Failed to auto-open browser: %v", err) } } sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) // Main event loop - wait for signals or config changes for { select { case <-sigChan: logger.Info("Shutting down...") return } } } else { // GUI mode: start system tray runTray() } } ================================================ FILE: web/backend/middleware/access_control.go ================================================ package middleware import ( "fmt" "net" "net/http" "strings" ) // IPAllowlist restricts access to requests from configured CIDR ranges. // Loopback addresses are always allowed for local administration. // Empty CIDR list means no restriction. func IPAllowlist(allowedCIDRs []string, next http.Handler) (http.Handler, error) { if len(allowedCIDRs) == 0 { return next, nil } nets := make([]*net.IPNet, 0, len(allowedCIDRs)) for _, cidr := range allowedCIDRs { _, ipNet, err := net.ParseCIDR(cidr) if err != nil { return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err) } nets = append(nets, ipNet) } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ip := clientIPFromRemoteAddr(r.RemoteAddr) if ip == nil { rejectByPolicy(w, r) return } if ip.IsLoopback() { next.ServeHTTP(w, r) return } for _, ipNet := range nets { if ipNet.Contains(ip) { next.ServeHTTP(w, r) return } } rejectByPolicy(w, r) }), nil } func clientIPFromRemoteAddr(remoteAddr string) net.IP { host := remoteAddr if h, _, err := net.SplitHostPort(remoteAddr); err == nil { host = h } return net.ParseIP(host) } func rejectByPolicy(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/api/") { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(`{"error":"access denied by network policy"}`)) return } http.Error(w, "Forbidden", http.StatusForbidden) } ================================================ FILE: web/backend/middleware/access_control_test.go ================================================ package middleware import ( "net/http" "net/http/httptest" "testing" ) func TestIPAllowlist_EmptyCIDRsAllowsAll(t *testing.T) { h, err := IPAllowlist(nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) if err != nil { t.Fatalf("IPAllowlist() error = %v", err) } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "203.0.113.5:1234" h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } } func TestIPAllowlist_RejectsOutsideCIDR(t *testing.T) { h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) if err != nil { t.Fatalf("IPAllowlist() error = %v", err) } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/config", nil) req.RemoteAddr = "10.0.0.8:1234" h.ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden) } } func TestIPAllowlist_AllowsInsideCIDR(t *testing.T) { h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) if err != nil { t.Fatalf("IPAllowlist() error = %v", err) } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "192.168.1.88:1234" h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } } func TestIPAllowlist_AlwaysAllowsLoopback(t *testing.T) { h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) if err != nil { t.Fatalf("IPAllowlist() error = %v", err) } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "127.0.0.1:1234" h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } } func TestIPAllowlist_InvalidCIDR(t *testing.T) { _, err := IPAllowlist([]string{"bad-cidr"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) if err == nil { t.Fatal("IPAllowlist() expected error for invalid CIDR") } } ================================================ FILE: web/backend/middleware/middleware.go ================================================ package middleware import ( "fmt" "net/http" "runtime/debug" "time" "github.com/sipeed/picoclaw/pkg/logger" ) // JSONContentType sets the Content-Type header to application/json for // API requests handled by the wrapped handler. func JSONContentType(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if len(r.URL.Path) >= 5 && r.URL.Path[:5] == "/api/" { w.Header().Set("Content-Type", "application/json") } next.ServeHTTP(w, r) }) } // responseRecorder wraps http.ResponseWriter to capture the status code. type responseRecorder struct { http.ResponseWriter statusCode int } func (rr *responseRecorder) WriteHeader(code int) { rr.statusCode = code rr.ResponseWriter.WriteHeader(code) } // Flush delegates to the underlying ResponseWriter if it implements http.Flusher. func (rr *responseRecorder) Flush() { if f, ok := rr.ResponseWriter.(http.Flusher); ok { f.Flush() } } // Unwrap returns the underlying ResponseWriter so that http.ResponseController // and interface checks (like http.Flusher) can see through the wrapper. func (rr *responseRecorder) Unwrap() http.ResponseWriter { return rr.ResponseWriter } // Logger logs each HTTP request with method, path, status code, and duration. func Logger(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() rec := &responseRecorder{ResponseWriter: w, statusCode: http.StatusOK} next.ServeHTTP(rec, r) logger.DebugC("http", fmt.Sprintf("%s %s %d %s", r.Method, r.URL.Path, rec.statusCode, time.Since(start))) }) } // Recoverer recovers from panics in downstream handlers and returns a 500 // Internal Server Error response. func Recoverer(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if err := recover(); err != nil { logger.ErrorC("http", fmt.Sprintf("panic recovered: %v\n%s", err, debug.Stack())) http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError) } }() next.ServeHTTP(w, r) }) } ================================================ FILE: web/backend/model/status.go ================================================ package model // StatusResponse represents the response payload for the GET /api/status endpoint. type StatusResponse struct { Status string `json:"status"` Version string `json:"version"` Uptime string `json:"uptime"` } ================================================ FILE: web/backend/systray.go ================================================ //go:build (!darwin && !freebsd) || cgo package main import ( _ "embed" "fmt" "fyne.io/systray" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/web/backend/utils" ) func runTray() { systray.Run(onReady, onExit) } // onReady is called when the system tray is ready func onReady() { // Set icon and tooltip systray.SetIcon(getIcon()) systray.SetTooltip(fmt.Sprintf(T(AppTooltip), appName)) // Create menu items mOpen := systray.AddMenuItem(T(MenuOpen), T(MenuOpenTooltip)) mAbout := systray.AddMenuItem(T(MenuAbout), T(MenuAboutTooltip)) // Add version info under About menu mVersion := mAbout.AddSubMenuItem(fmt.Sprintf(T(MenuVersion), appVersion), T(MenuVersionTooltip)) mVersion.Disable() mRepo := mAbout.AddSubMenuItem(T(MenuGitHub), "") mDocs := mAbout.AddSubMenuItem(T(MenuDocs), "") systray.AddSeparator() // Add restart option mRestart := systray.AddMenuItem(T(MenuRestart), T(MenuRestartTooltip)) systray.AddSeparator() // Quit option mQuit := systray.AddMenuItem(T(MenuQuit), T(MenuQuitTooltip)) // Handle menu clicks go func() { for { select { case <-mOpen.ClickedCh: if err := openBrowser(); err != nil { logger.Errorf("Failed to open browser: %v", err) } case <-mVersion.ClickedCh: // Version info - do nothing, just shows current version case <-mRepo.ClickedCh: if err := utils.OpenBrowser("https://github.com/sipeed/picoclaw"); err != nil { logger.Errorf("Failed to open GitHub: %v", err) } case <-mDocs.ClickedCh: if err := utils.OpenBrowser(T(DocUrl)); err != nil { logger.Errorf("Failed to open docs: %v", err) } case <-mRestart.ClickedCh: fmt.Println("Restart request received...") if apiHandler != nil { if pid, err := apiHandler.RestartGateway(); err != nil { logger.Errorf("Failed to restart gateway: %v", err) } else { logger.Infof("Gateway restarted (PID: %d)", pid) } } case <-mQuit.ClickedCh: systray.Quit() } } }() if !*noBrowser { // Auto-open browser after systray is ready (if not disabled) // Check no-browser flag via environment or pass as parameter if needed if err := openBrowser(); err != nil { logger.Errorf("Warning: Failed to auto-open browser: %v", err) } } } // onExit is called when the system tray is exiting func onExit() { logger.Info(T(Exiting)) } // getIcon returns the system tray icon func getIcon() []byte { return iconData } ================================================ FILE: web/backend/systray_unix.go ================================================ //go:build !windows package main import _ "embed" //go:embed icon.png var iconData []byte ================================================ FILE: web/backend/systray_windows.go ================================================ //go:build windows package main import _ "embed" //go:embed icon.ico var iconData []byte ================================================ FILE: web/backend/tray_stub_nocgo.go ================================================ //go:build (darwin || freebsd) && !cgo package main import ( "context" "os" "os/signal" "runtime" "syscall" "time" "github.com/sipeed/picoclaw/pkg/logger" ) func runTray() { logger.Infof("System tray is unavailable in %s builds without cgo; running without tray", runtime.GOOS) if !*noBrowser { go func() { time.Sleep(browserDelay) if err := openBrowser(); err != nil { logger.Errorf("Warning: Failed to auto-open browser: %v", err) } }() } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() <-ctx.Done() shutdownApp() } ================================================ FILE: web/backend/utils/banner.go ================================================ package utils const ( colorBlue = "\x1b[38;2;62;93;185m" colorRed = "\x1b[38;2;213;70;70m" colorReset = "\x1b[0m" Banner = "\r\n" + colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" + colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" + colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" + colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" + colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" + colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n" + colorReset ) ================================================ FILE: web/backend/utils/onboard.go ================================================ package utils import ( "fmt" "os" "os/exec" "strings" "github.com/sipeed/picoclaw/pkg/config" ) var execCommand = exec.Command func EnsureOnboarded(configPath string) error { _, err := os.Stat(configPath) if err == nil { return nil } if !os.IsNotExist(err) { return fmt.Errorf("stat config: %w", err) } cmd := execCommand(FindPicoclawBinary(), "onboard") cmd.Env = append(os.Environ(), config.EnvConfig+"="+configPath) cmd.Stdin = strings.NewReader("n\n") output, err := cmd.CombinedOutput() if err != nil { trimmed := strings.TrimSpace(string(output)) if trimmed == "" { return fmt.Errorf("run onboard: %w", err) } return fmt.Errorf("run onboard: %w: %s", err, trimmed) } if _, err := os.Stat(configPath); err != nil { if os.IsNotExist(err) { return fmt.Errorf("onboard completed but did not create config %s", configPath) } return fmt.Errorf("verify config after onboard: %w", err) } return nil } ================================================ FILE: web/backend/utils/onboard_test.go ================================================ package utils import ( "os" "os/exec" "path/filepath" "strings" "testing" ) func TestEnsureOnboardedSkipsWhenConfigExists(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") if err := os.WriteFile(configPath, []byte(`{}`), 0o644); err != nil { t.Fatalf("WriteFile() error = %v", err) } origExecCommand := execCommand defer func() { execCommand = origExecCommand }() called := false execCommand = func(name string, args ...string) *exec.Cmd { called = true return exec.Command("sh", "-c", "exit 1") } if err := EnsureOnboarded(configPath); err != nil { t.Fatalf("EnsureOnboarded() error = %v", err) } if called { t.Fatal("expected onboard command not to run when config already exists") } } func TestEnsureOnboardedRunsOnboardWhenConfigMissing(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") t.Setenv("EXPECTED_CONFIG_PATH", configPath) origExecCommand := execCommand defer func() { execCommand = origExecCommand }() var gotName string var gotArgs []string execCommand = func(name string, args ...string) *exec.Cmd { gotName = name gotArgs = append([]string(nil), args...) return exec.Command( "sh", "-c", `test "$PICOCLAW_CONFIG" = "$EXPECTED_CONFIG_PATH" && mkdir -p "$(dirname "$PICOCLAW_CONFIG")" && printf '{}' > "$PICOCLAW_CONFIG"`, ) } if err := EnsureOnboarded(configPath); err != nil { t.Fatalf("EnsureOnboarded() error = %v", err) } if gotName == "" { t.Fatal("expected onboard command to run") } if len(gotArgs) != 1 || gotArgs[0] != "onboard" { t.Fatalf("command args = %#v, want []string{\"onboard\"}", gotArgs) } if _, err := os.Stat(configPath); err != nil { t.Fatalf("expected config to be created: %v", err) } } func TestEnsureOnboardedFailsWhenOnboardDoesNotCreateConfig(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") origExecCommand := execCommand defer func() { execCommand = origExecCommand }() execCommand = func(name string, args ...string) *exec.Cmd { return exec.Command("sh", "-c", "exit 0") } if err := EnsureOnboarded(configPath); err == nil { t.Fatal("EnsureOnboarded() error = nil, want failure when onboard does not create config") } } func TestEnsureOnboardedIncludesOnboardOutputOnFailure(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") origExecCommand := execCommand defer func() { execCommand = origExecCommand }() execCommand = func(name string, args ...string) *exec.Cmd { return exec.Command("sh", "-c", "echo onboarding failed >&2; exit 2") } err := EnsureOnboarded(configPath) if err == nil { t.Fatal("EnsureOnboarded() error = nil, want failure") } if !strings.Contains(err.Error(), "onboarding failed") { t.Fatalf("error = %q, want onboard output included", err) } } ================================================ FILE: web/backend/utils/runtime.go ================================================ package utils import ( "fmt" "net" "os" "os/exec" "path/filepath" "runtime" "github.com/sipeed/picoclaw/pkg/config" ) // GetPicoclawHome returns the picoclaw home directory. // Priority: $PICOCLAW_HOME > ~/.picoclaw func GetPicoclawHome() string { if home := os.Getenv(config.EnvHome); home != "" { return home } home, _ := os.UserHomeDir() return filepath.Join(home, ".picoclaw") } // GetDefaultConfigPath returns the default path to the picoclaw config file. func GetDefaultConfigPath() string { if configPath := os.Getenv(config.EnvConfig); configPath != "" { return configPath } return filepath.Join(GetPicoclawHome(), "config.json") } // FindPicoclawBinary locates the picoclaw executable. // Search order: // 1. PICOCLAW_BINARY environment variable (explicit override) // 2. Same directory as the current executable // 3. Falls back to "picoclaw" and relies on $PATH func FindPicoclawBinary() string { binaryName := "picoclaw" if runtime.GOOS == "windows" { binaryName = "picoclaw.exe" } if p := os.Getenv(config.EnvBinary); p != "" { if info, _ := os.Stat(p); info != nil && !info.IsDir() { return p } } if exe, err := os.Executable(); err == nil { candidate := filepath.Join(filepath.Dir(exe), binaryName) if info, err := os.Stat(candidate); err == nil && !info.IsDir() { return candidate } } return "picoclaw" } // GetLocalIP returns the local IP address of the machine. func GetLocalIP() string { addrs, err := net.InterfaceAddrs() if err != nil { return "" } for _, a := range addrs { if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { return ipnet.IP.String() } } return "" } // OpenBrowser automatically opens the given URL in the default browser. func OpenBrowser(url string) error { switch runtime.GOOS { case "linux": return exec.Command("xdg-open", url).Start() case "windows": return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() case "darwin": return exec.Command("open", url).Start() default: return fmt.Errorf("unsupported platform") } } ================================================ FILE: web/backend/winres/winres.json ================================================ { "RT_GROUP_ICON": { "APP": { "0000": "../icon.ico" } }, "RT_MANIFEST": { "#1": { "0409": { "identity": { "name": "PicoClaw Launcher", "version": "0.0.0.0" }, "description": "PicoClaw Launcher - Web-based configuration editor", "minimum-os": "win7", "execution-level": "asInvoker", "dpi-awareness": "system", "use-common-controls-v6": true } } } } ================================================ FILE: web/frontend/.editorconfig ================================================ root = true [*] charset = utf-8 indent_style = space indent_size = 2 end_of_line = lf ================================================ FILE: web/frontend/.gitignore ================================================ # Logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? .tanstack ================================================ FILE: web/frontend/.prettierignore ================================================ package-lock.json pnpm-lock.yaml yarn.lock routeTree.gen.ts src/components/ui ================================================ FILE: web/frontend/components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "radix-vega", "rsc": false, "tsx": true, "tailwind": { "config": "", "css": "src/index.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "iconLibrary": "tabler", "rtl": false, "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }, "menuColor": "default", "menuAccent": "subtle", "registries": {} } ================================================ FILE: web/frontend/eslint.config.js ================================================ import js from "@eslint/js" import eslintConfigPrettier from "eslint-config-prettier" import reactHooks from "eslint-plugin-react-hooks" import reactRefresh from "eslint-plugin-react-refresh" import { defineConfig, globalIgnores } from "eslint/config" import globals from "globals" import tseslint from "typescript-eslint" export default defineConfig([ globalIgnores(["dist", "src/components/ui", "src/routeTree.gen.ts"]), { files: ["**/*.{ts,tsx}"], extends: [ js.configs.recommended, tseslint.configs.recommended, reactHooks.configs.flat.recommended, reactRefresh.configs.vite, eslintConfigPrettier, ], languageOptions: { ecmaVersion: "latest", globals: globals.browser, }, rules: { "react-refresh/only-export-components": [ "warn", { allowConstantExport: true }, ], }, }, ]) ================================================ FILE: web/frontend/index.html ================================================ PicoClaw
================================================ FILE: web/frontend/package.json ================================================ { "name": "picoclaw-web", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "build:backend": "tsc -b && vite build --outDir ../backend/dist --emptyOutDir && node ./scripts/ensure-backend-gitkeep.cjs", "lint": "eslint .", "preview": "vite preview", "format": "prettier --check .", "check": "prettier --write . && eslint --fix" }, "dependencies": { "@fontsource-variable/inter": "^5.2.8", "@tabler/icons-react": "^3.40.0", "@tailwindcss/vite": "^4.2.2", "@tanstack/react-query": "^5.90.21", "@tanstack/react-router": "^1.167.0", "@tanstack/react-router-devtools": "^1.163.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.20", "i18next": "^25.8.14", "i18next-browser-languagedetector": "^8.2.1", "jotai": "^2.18.1", "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", "react-i18next": "^16.5.8", "react-markdown": "^10.1.0", "react-textarea-autosize": "^8.5.9", "remark-gfm": "^4.0.1", "shadcn": "^4.1.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.2", "tw-animate-css": "^1.4.0", "wrap-ansi": "^10.0.0" }, "devDependencies": { "@eslint/js": "^9.39.4", "@tailwindcss/typography": "^0.5.19", "@tanstack/router-plugin": "^1.164.0", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/node": "^25.5.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.57.1", "@vitejs/plugin-react": "^5.2.0", "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.26", "globals": "^16.5.0", "prettier": "^3.8.1", "prettier-plugin-tailwindcss": "^0.7.2", "typescript": "~5.9.3", "typescript-eslint": "^8.57.1", "vite": "^7.3.1" } } ================================================ FILE: web/frontend/prettier.config.js ================================================ // @ts-check /** @type {import('prettier').Config} */ const config = { semi: false, printWidth: 80, tabWidth: 2, importOrder: ["", "", "^@/", "^[./]"], importOrderSeparation: true, importOrderSortSpecifiers: true, plugins: [ "@trivago/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss", ], } export default config ================================================ FILE: web/frontend/public/site.webmanifest ================================================ { "name": "MyWebSite", "short_name": "MySite", "icons": [ { "src": "/web-app-manifest-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { "src": "/web-app-manifest-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ], "theme_color": "#ffffff", "background_color": "#ffffff", "display": "standalone" } ================================================ FILE: web/frontend/scripts/ensure-backend-gitkeep.cjs ================================================ const fs = require("node:fs") const path = require("node:path") const gitkeepPath = path.resolve(__dirname, "../../backend/dist/.gitkeep") const gitkeepContents = "# Keep the embedded web backend dist directory in version control.\n" fs.mkdirSync(path.dirname(gitkeepPath), { recursive: true }) fs.writeFileSync(gitkeepPath, gitkeepContents) ================================================ FILE: web/frontend/src/api/channels.ts ================================================ // API client for channels navigation and channel-specific config flows. export type ChannelConfig = Record export type AppConfig = Record export interface SupportedChannel { name: string display_name?: string config_key: string variant?: string } interface ChannelsCatalogResponse { channels: SupportedChannel[] } interface ConfigActionResponse { status: string errors?: string[] } const BASE_URL = "" async function request(path: string, options?: RequestInit): Promise { const res = await fetch(`${BASE_URL}${path}`, options) if (!res.ok) { let message = `API error: ${res.status} ${res.statusText}` try { const body = (await res.json()) as { error?: string errors?: string[] status?: string } if (Array.isArray(body.errors) && body.errors.length > 0) { message = body.errors.join("; ") } else if (typeof body.error === "string" && body.error.trim() !== "") { message = body.error } } catch { // Keep default fallback message if response body is not JSON. } throw new Error(message) } return res.json() as Promise } export async function getChannelsCatalog(): Promise { return request("/api/channels/catalog") } export async function getAppConfig(): Promise { return request("/api/config") } export async function patchAppConfig( patch: Record, ): Promise { return request("/api/config", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }) } export type { ChannelsCatalogResponse, ConfigActionResponse } ================================================ FILE: web/frontend/src/api/gateway.ts ================================================ // API client for gateway process management. interface GatewayStatusResponse { gateway_status: "running" | "starting" | "restarting" | "stopped" | "error" gateway_start_allowed?: boolean gateway_start_reason?: string gateway_restart_required?: boolean pid?: number boot_default_model?: string config_default_model?: string [key: string]: unknown } interface GatewayLogsResponse { logs?: string[] log_total?: number log_run_id?: number } interface GatewayActionResponse { status: string pid?: number log_total?: number log_run_id?: number } const BASE_URL = "" async function request(path: string, options?: RequestInit): Promise { const res = await fetch(`${BASE_URL}${path}`, options) if (!res.ok) { throw new Error(`API error: ${res.status} ${res.statusText}`) } return res.json() as Promise } export async function getGatewayStatus(): Promise { return request("/api/gateway/status") } export async function getGatewayLogs(options?: { log_offset?: number log_run_id?: number }): Promise { const params = new URLSearchParams() if (options?.log_offset !== undefined) { params.set("log_offset", options.log_offset.toString()) } if (options?.log_run_id !== undefined) { params.set("log_run_id", options.log_run_id.toString()) } const queryString = params.toString() ? `?${params.toString()}` : "" return request(`/api/gateway/logs${queryString}`) } export async function startGateway(): Promise { return request("/api/gateway/start", { method: "POST", }) } export async function stopGateway(): Promise { return request("/api/gateway/stop", { method: "POST", }) } export async function restartGateway(): Promise { return request("/api/gateway/restart", { method: "POST", }) } export async function clearGatewayLogs(): Promise { return request("/api/gateway/logs/clear", { method: "POST", }) } export type { GatewayStatusResponse, GatewayLogsResponse, GatewayActionResponse, } ================================================ FILE: web/frontend/src/api/models.ts ================================================ import { refreshGatewayState } from "@/store/gateway" // API client for model list management. export interface ModelInfo { index: number model_name: string model: string api_base?: string api_key: string proxy?: string auth_method?: string // Advanced fields connect_mode?: string workspace?: string rpm?: number max_tokens_field?: string request_timeout?: number thinking_level?: string // Meta configured: boolean is_default: boolean } interface ModelsListResponse { models: ModelInfo[] total: number default_model: string } interface ModelActionResponse { status: string index?: number default_model?: string } const BASE_URL = "" async function request(path: string, options?: RequestInit): Promise { const res = await fetch(`${BASE_URL}${path}`, options) if (!res.ok) { throw new Error(`API error: ${res.status} ${res.statusText}`) } return res.json() as Promise } export async function getModels(): Promise { return request("/api/models") } export async function addModel( model: Partial, ): Promise { return request("/api/models", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(model), }) } export async function updateModel( index: number, model: Partial, ): Promise { return request(`/api/models/${index}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(model), }) } export async function deleteModel(index: number): Promise { return request(`/api/models/${index}`, { method: "DELETE", }) } export async function setDefaultModel( modelName: string, ): Promise { const response = await request("/api/models/default", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model_name: modelName }), }) await refreshGatewayState() return response } export type { ModelsListResponse, ModelActionResponse } ================================================ FILE: web/frontend/src/api/oauth.ts ================================================ export type OAuthProvider = "openai" | "anthropic" | "google-antigravity" export type OAuthMethod = "browser" | "device_code" | "token" export interface OAuthProviderStatus { provider: OAuthProvider display_name: string methods: OAuthMethod[] logged_in: boolean status: "connected" | "expired" | "needs_refresh" | "not_logged_in" auth_method?: string expires_at?: string account_id?: string email?: string project_id?: string } export interface OAuthFlowState { flow_id: string provider: OAuthProvider method: OAuthMethod status: "pending" | "success" | "error" | "expired" expires_at?: string error?: string user_code?: string verify_url?: string interval?: number } export interface OAuthLoginRequest { provider: OAuthProvider method: OAuthMethod token?: string } export interface OAuthLoginResponse { status: string provider: OAuthProvider method: OAuthMethod flow_id?: string auth_url?: string user_code?: string verify_url?: string interval?: number expires_at?: string } interface OAuthProvidersResponse { providers: OAuthProviderStatus[] } const BASE_URL = "" async function request(path: string, options?: RequestInit): Promise { const res = await fetch(`${BASE_URL}${path}`, options) if (!res.ok) { const message = await res.text() throw new Error(message || `API error: ${res.status} ${res.statusText}`) } return res.json() as Promise } export async function getOAuthProviders(): Promise { return request("/api/oauth/providers") } export async function loginOAuth( payload: OAuthLoginRequest, ): Promise { return request("/api/oauth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }) } export async function getOAuthFlow(flowID: string): Promise { return request( `/api/oauth/flows/${encodeURIComponent(flowID)}`, ) } export async function pollOAuthFlow(flowID: string): Promise { return request( `/api/oauth/flows/${encodeURIComponent(flowID)}/poll`, { method: "POST", }, ) } export async function logoutOAuth( provider: OAuthProvider, ): Promise<{ status: string; provider: OAuthProvider }> { return request<{ status: string; provider: OAuthProvider }>( "/api/oauth/logout", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider }), }, ) } ================================================ FILE: web/frontend/src/api/pico.ts ================================================ // API client for Pico Channel configuration. interface PicoTokenResponse { token: string ws_url: string enabled: boolean } interface PicoSetupResponse { token: string ws_url: string enabled: boolean changed: boolean } const BASE_URL = "" async function request(path: string, options?: RequestInit): Promise { const res = await fetch(`${BASE_URL}${path}`, options) if (!res.ok) { throw new Error(`API error: ${res.status} ${res.statusText}`) } return res.json() as Promise } export async function getPicoToken(): Promise { return request("/api/pico/token") } export async function regenPicoToken(): Promise { return request("/api/pico/token", { method: "POST" }) } export async function setupPico(): Promise { return request("/api/pico/setup", { method: "POST" }) } export type { PicoTokenResponse, PicoSetupResponse } ================================================ FILE: web/frontend/src/api/sessions.ts ================================================ // Sessions API — list and retrieve chat session history export interface SessionSummary { id: string title: string preview: string message_count: number created: string updated: string } export interface SessionDetail { id: string messages: { role: "user" | "assistant"; content: string }[] summary: string created: string updated: string } export async function getSessions( offset: number = 0, limit: number = 20, ): Promise { const params = new URLSearchParams({ offset: offset.toString(), limit: limit.toString(), }) const res = await fetch(`/api/sessions?${params.toString()}`) if (!res.ok) { throw new Error(`Failed to fetch sessions: ${res.status}`) } return res.json() } export async function getSessionHistory(id: string): Promise { const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`) if (!res.ok) { throw new Error(`Failed to fetch session ${id}: ${res.status}`) } return res.json() } export async function deleteSession(id: string): Promise { const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE", }) if (!res.ok) { throw new Error(`Failed to delete session ${id}: ${res.status}`) } } ================================================ FILE: web/frontend/src/api/skills.ts ================================================ export interface SkillSupportItem { name: string path: string source: "workspace" | "global" | "builtin" | string description: string } export interface SkillDetailResponse extends SkillSupportItem { content: string } interface SkillsResponse { skills: SkillSupportItem[] } interface SkillActionResponse { status?: string name?: string path?: string source?: string description?: string } async function request(path: string, options?: RequestInit): Promise { const res = await fetch(path, options) if (!res.ok) { throw new Error(await extractErrorMessage(res)) } return res.json() as Promise } export async function getSkills(): Promise { return request("/api/skills") } export async function getSkill(name: string): Promise { return request(`/api/skills/${encodeURIComponent(name)}`) } export async function importSkill(file: File): Promise { const formData = new FormData() formData.set("file", file) const res = await fetch("/api/skills/import", { method: "POST", body: formData, }) if (!res.ok) { throw new Error(await extractErrorMessage(res)) } return res.json() as Promise } export async function deleteSkill(name: string): Promise { return request( `/api/skills/${encodeURIComponent(name)}`, { method: "DELETE", }, ) } async function extractErrorMessage(res: Response): Promise { try { const body = (await res.json()) as { error?: string errors?: string[] } if (Array.isArray(body.errors) && body.errors.length > 0) { return body.errors.join("; ") } if (typeof body.error === "string" && body.error.trim() !== "") { return body.error } } catch { // ignore invalid body } return `API error: ${res.status} ${res.statusText}` } ================================================ FILE: web/frontend/src/api/system.ts ================================================ export interface AutoStartStatus { enabled: boolean supported: boolean platform: string message?: string } export interface LauncherConfig { port: number public: boolean allowed_cidrs: string[] } async function request(path: string, options?: RequestInit): Promise { const res = await fetch(path, options) if (!res.ok) { let message = `API error: ${res.status} ${res.statusText}` try { const body = (await res.json()) as { error?: string errors?: string[] } if (Array.isArray(body.errors) && body.errors.length > 0) { message = body.errors.join("; ") } else if (typeof body.error === "string" && body.error.trim() !== "") { message = body.error } } catch { // Keep fallback error message when response body is not JSON. } throw new Error(message) } return res.json() as Promise } export async function getAutoStartStatus(): Promise { return request("/api/system/autostart") } export async function setAutoStartEnabled( enabled: boolean, ): Promise { return request("/api/system/autostart", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled }), }) } export async function getLauncherConfig(): Promise { return request("/api/system/launcher-config") } export async function setLauncherConfig( payload: LauncherConfig, ): Promise { return request("/api/system/launcher-config", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }) } ================================================ FILE: web/frontend/src/api/tools.ts ================================================ export interface ToolSupportItem { name: string description: string category: string config_key: string status: "enabled" | "disabled" | "blocked" reason_code?: string } interface ToolsResponse { tools: ToolSupportItem[] } interface ToolActionResponse { status: string } async function request(path: string, options?: RequestInit): Promise { const res = await fetch(path, options) if (!res.ok) { let message = `API error: ${res.status} ${res.statusText}` try { const body = (await res.json()) as { error?: string errors?: string[] } if (Array.isArray(body.errors) && body.errors.length > 0) { message = body.errors.join("; ") } else if (typeof body.error === "string" && body.error.trim() !== "") { message = body.error } } catch { // ignore invalid body } throw new Error(message) } return res.json() as Promise } export async function getTools(): Promise { return request("/api/tools") } export async function setToolEnabled( name: string, enabled: boolean, ): Promise { return request( `/api/tools/${encodeURIComponent(name)}/state`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled }), }, ) } ================================================ FILE: web/frontend/src/components/app-header.tsx ================================================ import { IconBook, IconLanguage, IconLoader2, IconMenu2, IconMoon, IconPlayerPlay, IconPower, IconRefresh, IconSun, } from "@tabler/icons-react" import { Link } from "@tanstack/react-router" import * as React from "react" import { useTranslation } from "react-i18next" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog.tsx" import { Button } from "@/components/ui/button.tsx" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu.tsx" import { Separator } from "@/components/ui/separator.tsx" import { SidebarTrigger } from "@/components/ui/sidebar" import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" import { useGateway } from "@/hooks/use-gateway.ts" import { useTheme } from "@/hooks/use-theme.ts" export function AppHeader() { const { i18n, t } = useTranslation() const { theme, toggleTheme } = useTheme() const { state: gwState, loading: gwLoading, canStart, restartRequired, start, restart, stop, } = useGateway() const isRunning = gwState === "running" const isStarting = gwState === "starting" const isRestarting = gwState === "restarting" const isStopping = gwState === "stopping" const isStopped = gwState === "stopped" || gwState === "unknown" const showNotConnectedHint = !isRestarting && !isStopping && canStart && (gwState === "stopped" || gwState === "error") const [showStopDialog, setShowStopDialog] = React.useState(false) const handleGatewayToggle = () => { if (gwLoading || isRestarting || isStopping || (!isRunning && !canStart)) { return } if (isRunning) { setShowStopDialog(true) } else { void start() } } const handleGatewayRestart = () => { if (gwLoading || isRestarting || !restartRequired || !canStart) return void restart() } const confirmStop = () => { setShowStopDialog(false) stop() } return (
Logo
{/* Center prominent connection status */}
{showNotConnectedHint && (
{t("chat.notConnected")}
)}
{t("header.gateway.stopDialog.title")} {t("header.gateway.stopDialog.description")} {t("common.cancel")} {t("header.gateway.stopDialog.confirm")}
{restartRequired && ( {t("header.gateway.restartRequired")} )} {/* Gateway Start/Stop */} {isRunning ? ( {t("header.gateway.action.stop")} ) : ( )} {/* Docs Link */} {/* Language Switcher */} i18n.changeLanguage("en")}> English i18n.changeLanguage("zh")}> 简体中文 {/* Theme Toggle */}
) } ================================================ FILE: web/frontend/src/components/app-layout.tsx ================================================ import type { ReactNode } from "react" import { Toaster } from "sonner" import { AppHeader } from "@/components/app-header" import { AppSidebar } from "@/components/app-sidebar" import { SidebarProvider } from "@/components/ui/sidebar" import { TooltipProvider } from "@/components/ui/tooltip" export function AppLayout({ children }: { children: ReactNode }) { return (
{children}
) } ================================================ FILE: web/frontend/src/components/app-sidebar.tsx ================================================ import { IconChevronRight } from "@tabler/icons-react" import { IconAtom, IconChevronsDown, IconChevronsUp, IconKey, IconListDetails, IconMessageCircle, IconSettings, IconSparkles, IconTools, } from "@tabler/icons-react" import { Link, useRouterState } from "@tanstack/react-router" import * as React from "react" import { useTranslation } from "react-i18next" import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible" import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarRail, } from "@/components/ui/sidebar" import { useSidebarChannels } from "@/hooks/use-sidebar-channels" interface NavItem { title: string url: string icon: React.ComponentType<{ className?: string }> translateTitle?: boolean } interface NavGroup { label: string defaultOpen: boolean items: NavItem[] isChannelsGroup?: boolean } const baseNavGroups: Omit[] = [ { label: "navigation.chat", defaultOpen: true, }, { label: "navigation.model_group", defaultOpen: true, }, { label: "navigation.agent_group", defaultOpen: true, }, { label: "navigation.services", defaultOpen: true, }, ] export function AppSidebar({ ...props }: React.ComponentProps) { const routerState = useRouterState() const { t } = useTranslation() const currentPath = routerState.location.pathname const { channelItems, hasMoreChannels, showAllChannels, toggleShowAllChannels, } = useSidebarChannels({ t }) const navGroups: NavGroup[] = React.useMemo(() => { return [ { ...baseNavGroups[0], items: [ { title: "navigation.chat", url: "/", icon: IconMessageCircle, translateTitle: true, }, ], }, { ...baseNavGroups[1], items: [ { title: "navigation.models", url: "/models", icon: IconAtom, translateTitle: true, }, { title: "navigation.credentials", url: "/credentials", icon: IconKey, translateTitle: true, }, ], }, { label: "navigation.channels_group", defaultOpen: true, items: channelItems.map((item) => ({ title: item.title, url: item.url, icon: item.icon, translateTitle: false, })), isChannelsGroup: true, }, { ...baseNavGroups[2], items: [ { title: "navigation.skills", url: "/agent/skills", icon: IconSparkles, translateTitle: true, }, { title: "navigation.tools", url: "/agent/tools", icon: IconTools, translateTitle: true, }, ], }, { ...baseNavGroups[3], items: [ { title: "navigation.config", url: "/config", icon: IconSettings, translateTitle: true, }, { title: "navigation.logs", url: "/logs", icon: IconListDetails, translateTitle: true, }, ], }, ] }, [channelItems]) return ( {navGroups.map((group) => ( {t(group.label)} {group.items.map((item) => { const isActive = currentPath === item.url || (item.url !== "/" && currentPath.startsWith(`${item.url}/`)) return ( {item.translateTitle === false ? item.title : t(item.title)} ) })} {group.isChannelsGroup && hasMoreChannels && ( {showAllChannels ? ( ) : ( )} {showAllChannels ? t("navigation.show_less_channels") : t("navigation.show_more_channels")} )} ))} ) } ================================================ FILE: web/frontend/src/components/channels/channel-config-page.tsx ================================================ import { IconLoader2 } from "@tabler/icons-react" import { useAtomValue } from "jotai" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" import { type ChannelConfig, type SupportedChannel, getAppConfig, getChannelsCatalog, patchAppConfig, } from "@/api/channels" import { getChannelDisplayName } from "@/components/channels/channel-display-name" import { DiscordForm } from "@/components/channels/channel-forms/discord-form" import { FeishuForm } from "@/components/channels/channel-forms/feishu-form" import { GenericForm } from "@/components/channels/channel-forms/generic-form" import { SlackForm } from "@/components/channels/channel-forms/slack-form" import { TelegramForm } from "@/components/channels/channel-forms/telegram-form" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" import { gatewayAtom } from "@/store/gateway" interface ChannelConfigPageProps { channelName: string } const SECRET_FIELD_MAP: Record = { token: "_token", app_secret: "_app_secret", client_secret: "_client_secret", corp_secret: "_corp_secret", channel_secret: "_channel_secret", channel_access_token: "_channel_access_token", access_token: "_access_token", bot_token: "_bot_token", app_token: "_app_token", encoding_aes_key: "_encoding_aes_key", encrypt_key: "_encrypt_key", verification_token: "_verification_token", password: "_password", nickserv_password: "_nickserv_password", sasl_password: "_sasl_password", } function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record } return {} } function asString(value: unknown): string { return typeof value === "string" ? value : "" } function asBool(value: unknown): boolean { return value === true } function buildEditConfig(config: ChannelConfig): ChannelConfig { const edit: ChannelConfig = { ...config } for (const secretKey of Object.keys(SECRET_FIELD_MAP)) { if (secretKey in config) { edit[SECRET_FIELD_MAP[secretKey]] = "" } } return edit } function normalizeConfig( channel: SupportedChannel, rawConfig: ChannelConfig, ): ChannelConfig { const config = { ...rawConfig } if (channel.name === "whatsapp_native") { config.use_native = true } if (channel.name === "whatsapp") { config.use_native = false } return config } function buildSavePayload( channel: SupportedChannel, editConfig: ChannelConfig, enabled: boolean, ): ChannelConfig { const payload: ChannelConfig = { enabled } for (const [key, value] of Object.entries(editConfig)) { if (key.startsWith("_")) continue if (key === "enabled") continue if (key in SECRET_FIELD_MAP) { const editKey = SECRET_FIELD_MAP[key] const incoming = asString(editConfig[editKey]) payload[key] = incoming !== "" ? incoming : value continue } payload[key] = value } if (channel.name === "whatsapp_native") { payload.use_native = true } if (channel.name === "whatsapp") { payload.use_native = false } return payload } function isConfigured( channel: SupportedChannel, config: ChannelConfig, ): boolean { switch (channel.name) { case "telegram": return asString(config.token) !== "" case "discord": return asString(config.token) !== "" case "slack": return asString(config.bot_token) !== "" case "feishu": return ( asString(config.app_id) !== "" && asString(config.app_secret) !== "" ) case "dingtalk": return ( asString(config.client_id) !== "" && asString(config.client_secret) !== "" ) case "line": return asString(config.channel_access_token) !== "" case "qq": return ( asString(config.app_id) !== "" && asString(config.app_secret) !== "" ) case "onebot": return asString(config.ws_url) !== "" case "wecom": return asString(config.token) !== "" case "wecom_app": return ( asString(config.corp_id) !== "" && asString(config.corp_secret) !== "" ) case "wecom_aibot": return asString(config.token) !== "" case "whatsapp": return asString(config.bridge_url) !== "" case "whatsapp_native": return asBool(config.use_native) case "pico": return asString(config.token) !== "" case "maixcam": return asString(config.host) !== "" case "matrix": return ( asString(config.homeserver) !== "" && asString(config.user_id) !== "" && asString(config.access_token) !== "" ) case "irc": return asString(config.server) !== "" default: return false } } function getRequiredFieldKeys(channelName: string): string[] { switch (channelName) { case "telegram": return ["token"] case "discord": return ["token"] case "slack": return ["bot_token"] case "feishu": return ["app_id", "app_secret"] case "dingtalk": return ["client_id", "client_secret"] case "line": return ["channel_secret", "channel_access_token"] case "qq": return ["app_id", "app_secret"] case "onebot": return ["ws_url"] case "wecom": return ["token"] case "wecom_app": return ["corp_id", "corp_secret"] case "wecom_aibot": return ["token"] case "whatsapp": return ["bridge_url"] case "pico": return ["token"] case "maixcam": return ["host"] case "matrix": return ["homeserver", "user_id", "access_token"] case "irc": return ["server"] default: return [] } } function isMissingRequiredValue(value: unknown): boolean { if (value === null || value === undefined) { return true } if (typeof value === "string") { return value.trim() === "" } if (Array.isArray(value)) { return value.length === 0 } return false } function getChannelDocSlug(channelName: string): string { return channelName.replaceAll("_", "-") } const CHANNELS_WITHOUT_DOCS = new Set([ "pico", "wecom", "matrix", "irc", "whatsapp", "whatsapp_native", ]) export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { const { t, i18n } = useTranslation() const gateway = useAtomValue(gatewayAtom) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [fetchError, setFetchError] = useState("") const [serverError, setServerError] = useState("") const [fieldErrors, setFieldErrors] = useState>({}) const [channel, setChannel] = useState(null) const [baseConfig, setBaseConfig] = useState({}) const [editConfig, setEditConfig] = useState({}) const [enabled, setEnabled] = useState(false) const loadData = useCallback(async () => { setLoading(true) try { const [catalog, appConfig] = await Promise.all([ getChannelsCatalog(), getAppConfig(), ]) const matched = catalog.channels.find((item) => item.name === channelName) ?? null if (!matched) { setChannel(null) setFetchError( t("channels.page.notFound", { name: channelName, }), ) return } const channelsConfig = asRecord(asRecord(appConfig).channels) const raw = asRecord(channelsConfig[matched.config_key]) const normalized = normalizeConfig(matched, raw) setChannel(matched) setBaseConfig(normalized) setEditConfig(buildEditConfig(normalized)) setEnabled(asBool(normalized.enabled)) setFetchError("") setServerError("") setFieldErrors({}) } catch (e) { setFetchError(e instanceof Error ? e.message : t("channels.loadError")) } finally { setLoading(false) } }, [channelName, t]) useEffect(() => { loadData() }, [loadData]) const previousGatewayStatusRef = useRef(gateway.status) useEffect(() => { const previousStatus = previousGatewayStatusRef.current if (previousStatus !== "running" && gateway.status === "running") { void loadData() } previousGatewayStatusRef.current = gateway.status }, [gateway.status, loadData]) const savePayload = useMemo(() => { if (!channel) return null return buildSavePayload(channel, editConfig, enabled) }, [channel, editConfig, enabled]) const configured = useMemo(() => { if (!channel || !savePayload) return false return isConfigured(channel, savePayload) }, [channel, savePayload]) const docsUrl = useMemo(() => { if (!channel) return "" if (CHANNELS_WITHOUT_DOCS.has(channel.name)) return "" const language = ( i18n.resolvedLanguage ?? i18n.language ?? "" ).toLowerCase() const base = language.startsWith("zh") ? "https://docs.picoclaw.io/zh-Hans/docs/channels" : "https://docs.picoclaw.io/docs/channels" return `${base}/${getChannelDocSlug(channel.name)}` }, [channel, i18n.language, i18n.resolvedLanguage]) const channelDisplayName = useMemo(() => { if (!channel) return channelName return getChannelDisplayName(channel, t) }, [channel, channelName, t]) const hiddenKeys = useMemo(() => { if (!channel) return [] if (channel.name === "whatsapp") { return ["use_native"] } if (channel.name === "whatsapp_native") { return ["use_native", "bridge_url"] } return [] }, [channel]) const requiredKeys = useMemo( () => getRequiredFieldKeys(channelName), [channelName], ) const handleChange = useCallback((key: string, value: unknown) => { const normalizedKey = key.startsWith("_") ? key.slice(1) : key setEditConfig((prev) => ({ ...prev, [key]: value })) setFieldErrors((prev) => { if (!(key in prev) && !(normalizedKey in prev)) { return prev } const next = { ...prev } delete next[key] delete next[normalizedKey] return next }) }, []) const handleReset = () => { setEditConfig(buildEditConfig(baseConfig)) setEnabled(asBool(baseConfig.enabled)) setServerError("") setFieldErrors({}) } const handleSave = async () => { if (!channel || !savePayload) return const missingRequiredFields = requiredKeys.filter((key) => isMissingRequiredValue(savePayload[key]), ) if (missingRequiredFields.length > 0) { const requiredFieldError = t("channels.validation.requiredField") const nextFieldErrors: Record = {} for (const key of missingRequiredFields) { nextFieldErrors[key] = requiredFieldError } setFieldErrors(nextFieldErrors) setServerError("") return } setSaving(true) setServerError("") setFieldErrors({}) try { await patchAppConfig({ channels: { [channel.config_key]: savePayload, }, }) toast.success(t("channels.page.saveSuccess")) await loadData() } catch (e) { const message = e instanceof Error ? e.message : t("channels.page.saveError") setServerError(message) toast.error(message) } finally { setSaving(false) } } const renderForm = () => { if (!channel) return null const isEdit = configured switch (channel.name) { case "telegram": return ( ) case "discord": return ( ) case "slack": return ( ) case "feishu": return ( ) default: return ( ) } } return (
{enabled ? ( {t("channels.page.enabled")} ) : configured ? ( {t("channels.status.configured")} ) : null}
) : undefined } />
{loading ? (
) : fetchError ? (
{fetchError}
) : (

{t("channels.edit", { name: channelDisplayName, })}

{channel && docsUrl && ( {t("channels.page.docLink")} )}

{t("channels.page.enableLabel")}

{renderForm()} {serverError && (

{serverError}

)}
)}
) } ================================================ FILE: web/frontend/src/components/channels/channel-display-name.ts ================================================ import type { TFunction } from "i18next" import type { SupportedChannel } from "@/api/channels" export function getChannelDisplayName( channel: Pick, t: TFunction, ): string { const key = `channels.name.${channel.name}` const translated = t(key) if (translated !== key) { return translated } if (channel.display_name && channel.display_name.trim() !== "") { return channel.display_name } return channel.name .split("_") .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) .join(" ") } ================================================ FILE: web/frontend/src/components/channels/channel-forms/discord-form.tsx ================================================ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" import { maskedSecretPlaceholder } from "@/components/secret-placeholder" import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" import { Input } from "@/components/ui/input" interface DiscordFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void isEdit: boolean fieldErrors?: Record } function asString(value: unknown): string { return typeof value === "string" ? value : "" } function asStringArray(value: unknown): string[] { if (!Array.isArray(value)) return [] return value.filter((item): item is string => typeof item === "string") } function asBool(value: unknown): boolean { return value === true } function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record } return {} } export function DiscordForm({ config, onChange, isEdit, fieldErrors = {}, }: DiscordFormProps) { const { t } = useTranslation() const groupTriggerConfig = asRecord(config.group_trigger) const tokenExtraHint = isEdit && asString(config.token) ? ` ${t("channels.field.secretHintSet")}` : "" return (
onChange("_token", v)} placeholder={maskedSecretPlaceholder( config.token, t("channels.field.tokenPlaceholder"), )} /> onChange("proxy", e.target.value)} placeholder="http://127.0.0.1:7890" /> onChange( "allow_from", e.target.value .split(",") .map((s: string) => s.trim()) .filter(Boolean), ) } placeholder={t("channels.field.allowFromPlaceholder")} /> { onChange("group_trigger", { ...groupTriggerConfig, mention_only: checked, }) }} ariaLabel={t("channels.field.mentionOnly")} />
) } ================================================ FILE: web/frontend/src/components/channels/channel-forms/feishu-form.tsx ================================================ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" import { maskedSecretPlaceholder } from "@/components/secret-placeholder" import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" import { Input } from "@/components/ui/input" interface FeishuFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void isEdit: boolean fieldErrors?: Record } function asString(value: unknown): string { return typeof value === "string" ? value : "" } function asBool(value: unknown): boolean { return typeof value === "boolean" ? value : false } function asStringArray(value: unknown): string[] { if (!Array.isArray(value)) return [] return value.filter((item): item is string => typeof item === "string") } export function FeishuForm({ config, onChange, isEdit, fieldErrors = {}, }: FeishuFormProps) { const { t } = useTranslation() const appSecretExtraHint = isEdit && asString(config.app_secret) ? ` ${t("channels.field.secretHintSet")}` : "" const verificationExtraHint = isEdit && asString(config.verification_token) ? ` ${t("channels.field.secretHintSet")}` : "" const encryptExtraHint = isEdit && asString(config.encrypt_key) ? ` ${t("channels.field.secretHintSet")}` : "" return (
onChange("app_id", e.target.value)} placeholder="cli_xxxx" /> onChange("_app_secret", v)} placeholder={maskedSecretPlaceholder( config.app_secret, t("channels.field.secretPlaceholder"), )} /> onChange("_verification_token", v)} placeholder={maskedSecretPlaceholder( config.verification_token, t("channels.field.secretPlaceholder"), )} /> onChange("_encrypt_key", v)} placeholder={maskedSecretPlaceholder( config.encrypt_key, t("channels.field.secretPlaceholder"), )} /> onChange("is_lark", checked)} /> onChange( "allow_from", e.target.value .split(",") .map((s: string) => s.trim()) .filter(Boolean), ) } placeholder={t("channels.field.allowFromPlaceholder")} />
) } ================================================ FILE: web/frontend/src/components/channels/channel-forms/generic-form.tsx ================================================ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" import { maskedSecretPlaceholder } from "@/components/secret-placeholder" import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" import { Input } from "@/components/ui/input" interface GenericFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void isEdit: boolean hiddenKeys?: string[] requiredKeys?: string[] fieldErrors?: Record } // Secret field names that should use masked input. const SECRET_FIELDS = new Set([ "token", "app_secret", "client_secret", "corp_secret", "channel_secret", "channel_access_token", "access_token", "bot_token", "app_token", "encoding_aes_key", "encrypt_key", "verification_token", "password", "nickserv_password", "sasl_password", ]) // Fields to skip in the generic form (handled by enabled toggle or internal). const SKIP_FIELDS = new Set(["enabled", "reasoning_channel_id"]) // Fields that are objects/nested — show as JSON or skip. const OBJECT_FIELDS = new Set([ "group_trigger", "typing", "placeholder", "allow_token_query", "allow_from", "allow_origins", ]) function formatLabel(key: string): string { return key .split("_") .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(" ") } function formatSentenceFieldName(key: string): string { const label = formatLabel(key) return label.charAt(0).toLowerCase() + label.slice(1) } function asString(value: unknown): string { return typeof value === "string" ? value : "" } function asStringArray(value: unknown): string[] { if (!Array.isArray(value)) return [] return value.filter((item): item is string => typeof item === "string") } function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record } return {} } function asBool(value: unknown): boolean { return value === true } export function GenericForm({ config, onChange, isEdit, hiddenKeys = [], requiredKeys = [], fieldErrors = {}, }: GenericFormProps) { const { t } = useTranslation() const hiddenFieldSet = new Set(hiddenKeys) const requiredFieldSet = new Set(requiredKeys) const groupTriggerConfig = asRecord(config.group_trigger) const typingConfig = asRecord(config.typing) const placeholderConfig = asRecord(config.placeholder) const placeholderEnabled = asBool(placeholderConfig.enabled) const fields = Object.keys(config).filter( (k) => !k.startsWith("_") && !SKIP_FIELDS.has(k) && !OBJECT_FIELDS.has(k) && !hiddenFieldSet.has(k), ) const buildHint = (key: string): string => { const descriptions: Record = { ws_url: t("channels.form.desc.wsUrl"), reconnect_interval: t("channels.form.desc.reconnectInterval"), bridge_url: t("channels.form.desc.bridgeUrl"), session_store_path: t("channels.form.desc.sessionStorePath"), use_native: t("channels.form.desc.useNative"), host: t("channels.form.desc.host"), port: t("channels.form.desc.port"), homeserver: t("channels.form.desc.homeserver"), user_id: t("channels.form.desc.userId"), device_id: t("channels.form.desc.deviceId"), join_on_invite: t("channels.form.desc.joinOnInvite"), app_id: t("channels.form.desc.appId"), client_id: t("channels.form.desc.clientId"), corp_id: t("channels.form.desc.corpId"), agent_id: t("channels.form.desc.agentId"), webhook_url: t("channels.form.desc.webhookUrl"), webhook_host: t("channels.form.desc.webhookHost"), webhook_port: t("channels.form.desc.webhookPort"), webhook_path: t("channels.form.desc.webhookPath"), reply_timeout: t("channels.form.desc.replyTimeout"), max_steps: t("channels.form.desc.maxSteps"), welcome_message: t("channels.form.desc.welcomeMessage"), allow_token_query: t("channels.form.desc.allowTokenQuery"), ping_interval: t("channels.form.desc.pingInterval"), read_timeout: t("channels.form.desc.readTimeout"), write_timeout: t("channels.form.desc.writeTimeout"), max_connections: t("channels.form.desc.maxConnections"), server: t("channels.form.desc.server"), tls: t("channels.form.desc.tls"), nick: t("channels.form.desc.nick"), user: t("channels.form.desc.user"), real_name: t("channels.form.desc.realName"), channels: t("channels.form.desc.channels"), request_caps: t("channels.form.desc.requestCaps"), max_base64_file_size_mib: t("channels.form.desc.maxBase64FileSizeMiB"), } return ( descriptions[key] ?? t("channels.form.desc.genericField", { field: formatSentenceFieldName(key), }) ) } return (
{fields.map((key) => { const isRequired = requiredFieldSet.has(key) if (SECRET_FIELDS.has(key)) { const editKey = `_${key}` const extraHint = isEdit && config[key] ? ` ${t("channels.field.secretHintSet")}` : "" return ( onChange(editKey, v)} placeholder={maskedSecretPlaceholder(config[key])} /> ) } const value = config[key] if (typeof value === "boolean") { return ( onChange(key, checked)} ariaLabel={formatLabel(key)} /> ) } if (Array.isArray(value)) { return ( onChange( key, e.target.value .split(",") .map((s: string) => s.trim()) .filter(Boolean), ) } /> ) } return ( { // Attempt to preserve number types const v = e.target.value if (typeof config[key] === "number") { onChange(key, v === "" ? 0 : Number(v)) } else { onChange(key, v) } }} /> ) })} {/* Allow From field */} {config.allow_from !== undefined && !hiddenFieldSet.has("allow_from") && ( onChange( "allow_from", e.target.value .split(",") .map((s: string) => s.trim()) .filter(Boolean), ) } placeholder={t("channels.field.allowFromPlaceholder")} /> )} {config.allow_origins !== undefined && !hiddenFieldSet.has("allow_origins") && ( onChange( "allow_origins", e.target.value .split(",") .map((s: string) => s.trim()) .filter(Boolean), ) } placeholder={t("channels.field.allowOriginsPlaceholder")} /> )} {config.allow_token_query !== undefined && !hiddenFieldSet.has("allow_token_query") && ( onChange("allow_token_query", checked) } ariaLabel={formatLabel("allow_token_query")} /> )} {config.group_trigger !== undefined && !hiddenFieldSet.has("group_trigger") && ( <> onChange("group_trigger", { ...groupTriggerConfig, mention_only: checked, }) } ariaLabel={t("channels.field.groupTriggerMentionOnly")} /> onChange("group_trigger", { ...groupTriggerConfig, prefixes: e.target.value .split(",") .map((s: string) => s.trim()) .filter(Boolean), }) } placeholder={t("channels.field.groupTriggerPrefixes")} /> )} {config.typing !== undefined && !hiddenFieldSet.has("typing") && ( onChange("typing", { ...typingConfig, enabled: checked }) } ariaLabel={t("channels.field.typingEnabled")} /> )} {config.placeholder !== undefined && !hiddenFieldSet.has("placeholder") && ( onChange("placeholder", { ...placeholderConfig, enabled: checked, }) } ariaLabel={t("channels.field.placeholderEnabled")} > {placeholderEnabled && (
onChange("placeholder", { ...placeholderConfig, text: e.target.value, }) } placeholder={t("channels.field.placeholderText")} aria-label={t("channels.field.placeholderText")} />
)}
)}
) } ================================================ FILE: web/frontend/src/components/channels/channel-forms/slack-form.tsx ================================================ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" import { maskedSecretPlaceholder } from "@/components/secret-placeholder" import { Field, KeyInput } from "@/components/shared-form" import { Input } from "@/components/ui/input" interface SlackFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void isEdit: boolean fieldErrors?: Record } function asString(value: unknown): string { return typeof value === "string" ? value : "" } function asStringArray(value: unknown): string[] { if (!Array.isArray(value)) return [] return value.filter((item): item is string => typeof item === "string") } export function SlackForm({ config, onChange, isEdit, fieldErrors = {}, }: SlackFormProps) { const { t } = useTranslation() const botTokenExtraHint = isEdit && asString(config.bot_token) ? ` ${t("channels.field.secretHintSet")}` : "" const appTokenExtraHint = isEdit && asString(config.app_token) ? ` ${t("channels.field.secretHintSet")}` : "" return (
onChange("_bot_token", v)} placeholder={maskedSecretPlaceholder(config.bot_token, "xoxb-xxxx")} /> onChange("_app_token", v)} placeholder={maskedSecretPlaceholder(config.app_token, "xapp-xxxx")} /> onChange( "allow_from", e.target.value .split(",") .map((s: string) => s.trim()) .filter(Boolean), ) } placeholder={t("channels.field.allowFromPlaceholder")} />
) } ================================================ FILE: web/frontend/src/components/channels/channel-forms/telegram-form.tsx ================================================ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" import { maskedSecretPlaceholder } from "@/components/secret-placeholder" import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" import { Input } from "@/components/ui/input" interface TelegramFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void isEdit: boolean fieldErrors?: Record } function asString(value: unknown): string { return typeof value === "string" ? value : "" } function asStringArray(value: unknown): string[] { if (!Array.isArray(value)) return [] return value.filter((item): item is string => typeof item === "string") } function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record } return {} } function asBool(value: unknown): boolean { return value === true } export function TelegramForm({ config, onChange, isEdit, fieldErrors = {}, }: TelegramFormProps) { const { t } = useTranslation() const typingConfig = asRecord(config.typing) const placeholderConfig = asRecord(config.placeholder) const placeholderEnabled = asBool(placeholderConfig.enabled) const tokenExtraHint = isEdit && asString(config.token) ? ` ${t("channels.field.secretHintSet")}` : "" return (
onChange("_token", v)} placeholder={maskedSecretPlaceholder( config.token, t("channels.field.tokenPlaceholder"), )} /> onChange("base_url", e.target.value)} placeholder="https://api.telegram.org" /> onChange("proxy", e.target.value)} placeholder="http://127.0.0.1:7890" /> onChange( "allow_from", e.target.value .split(",") .map((s: string) => s.trim()) .filter(Boolean), ) } placeholder={t("channels.field.allowFromPlaceholder")} /> onChange("typing", { ...typingConfig, enabled: checked }) } ariaLabel={t("channels.field.typingEnabled")} /> onChange("placeholder", { ...placeholderConfig, enabled: checked, }) } ariaLabel={t("channels.field.placeholderEnabled")} > {placeholderEnabled && (
onChange("placeholder", { ...placeholderConfig, text: e.target.value, }) } placeholder={t("channels.field.placeholderText")} aria-label={t("channels.field.placeholderText")} />
)}
) } ================================================ FILE: web/frontend/src/components/chat/assistant-message.tsx ================================================ import { IconCheck, IconCopy } from "@tabler/icons-react" import { useState } from "react" import ReactMarkdown from "react-markdown" import remarkGfm from "remark-gfm" import { Button } from "@/components/ui/button" import { formatMessageTime } from "@/hooks/use-pico-chat" interface AssistantMessageProps { content: string timestamp?: string | number } export function AssistantMessage({ content, timestamp = "", }: AssistantMessageProps) { const [isCopied, setIsCopied] = useState(false) const formattedTimestamp = timestamp !== "" ? formatMessageTime(timestamp) : "" const handleCopy = () => { navigator.clipboard.writeText(content).then(() => { setIsCopied(true) setTimeout(() => setIsCopied(false), 2000) }) } return (
PicoClaw {formattedTimestamp && ( <> {formattedTimestamp} )}
{content}
) } ================================================ FILE: web/frontend/src/components/chat/chat-composer.tsx ================================================ import { IconArrowUp } from "@tabler/icons-react" import type { KeyboardEvent } from "react" import { useTranslation } from "react-i18next" import TextareaAutosize from "react-textarea-autosize" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" interface ChatComposerProps { input: string onInputChange: (value: string) => void onSend: () => void isConnected: boolean hasDefaultModel: boolean } export function ChatComposer({ input, onInputChange, onSend, isConnected, hasDefaultModel, }: ChatComposerProps) { const { t } = useTranslation() const canInput = isConnected && hasDefaultModel const handleKeyDown = (e: KeyboardEvent) => { if (e.nativeEvent.isComposing) return if (e.key === "Enter" && !e.shiftKey) { e.preventDefault() onSend() } } return (
onInputChange(e.target.value)} onKeyDown={handleKeyDown} placeholder={t("chat.placeholder")} disabled={!canInput} className={cn( "placeholder:text-muted-foreground max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent", !canInput && "cursor-not-allowed", )} minRows={1} maxRows={8} />
{/* action buttons */}
) } ================================================ FILE: web/frontend/src/components/chat/chat-empty-state.tsx ================================================ import { IconPlugConnectedX, IconRobot, IconRobotOff, IconStar, } from "@tabler/icons-react" import { Link } from "@tanstack/react-router" import { useTranslation } from "react-i18next" import { Button } from "@/components/ui/button" interface ChatEmptyStateProps { hasConfiguredModels: boolean defaultModelName: string isConnected: boolean } export function ChatEmptyState({ hasConfiguredModels, defaultModelName, isConnected, }: ChatEmptyStateProps) { const { t } = useTranslation() if (!hasConfiguredModels) { return (

{t("chat.empty.noConfiguredModel")}

{t("chat.empty.noConfiguredModelDescription")}

) } if (!defaultModelName) { return (

{t("chat.empty.noSelectedModel")}

{t("chat.empty.noSelectedModelDescription")}

) } if (!isConnected) { return (

{t("chat.empty.notRunning")}

{t("chat.empty.notRunningDescription")}

) } return (

{t("chat.welcome")}

{t("chat.welcomeDesc")}

) } ================================================ FILE: web/frontend/src/components/chat/chat-page.tsx ================================================ import { IconPlus } from "@tabler/icons-react" import { useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { AssistantMessage } from "@/components/chat/assistant-message" import { ChatComposer } from "@/components/chat/chat-composer" import { ChatEmptyState } from "@/components/chat/chat-empty-state" import { ModelSelector } from "@/components/chat/model-selector" import { SessionHistoryMenu } from "@/components/chat/session-history-menu" import { TypingIndicator } from "@/components/chat/typing-indicator" import { UserMessage } from "@/components/chat/user-message" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" import { useChatModels } from "@/hooks/use-chat-models" import { useGateway } from "@/hooks/use-gateway" import { usePicoChat } from "@/hooks/use-pico-chat" import { useSessionHistory } from "@/hooks/use-session-history" export function ChatPage() { const { t } = useTranslation() const scrollRef = useRef(null) const [isAtBottom, setIsAtBottom] = useState(true) const [hasScrolled, setHasScrolled] = useState(false) const [input, setInput] = useState("") const { messages, connectionState, isTyping, activeSessionId, sendMessage, switchSession, newChat, } = usePicoChat() const { state: gwState } = useGateway() const isGatewayRunning = gwState === "running" const isChatConnected = connectionState === "connected" const { defaultModelName, hasConfiguredModels, apiKeyModels, oauthModels, localModels, handleSetDefault, } = useChatModels({ isConnected: isGatewayRunning }) const canSend = isChatConnected && Boolean(defaultModelName) const { sessions, hasMore, loadError, loadErrorMessage, observerRef, loadSessions, handleDeleteSession, } = useSessionHistory({ activeSessionId, onDeletedActiveSession: newChat, }) const syncScrollState = (element: HTMLDivElement) => { const { scrollTop, scrollHeight, clientHeight } = element setHasScrolled(scrollTop > 0) setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10) } const handleScroll = (e: React.UIEvent) => { syncScrollState(e.currentTarget) } useEffect(() => { if (scrollRef.current) { if (isAtBottom) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight } syncScrollState(scrollRef.current) } }, [messages, isTyping, isAtBottom]) const handleSend = () => { if (!input.trim() || !canSend) return if (sendMessage(input.trim())) { setInput("") } } return (
) } > { if (open) { void loadSessions(true) } }} onSwitchSession={switchSession} onDeleteSession={handleDeleteSession} />
{messages.length === 0 && !isTyping && ( )} {messages.map((msg) => (
{msg.role === "assistant" ? ( ) : ( )}
))} {isTyping && }
) } ================================================ FILE: web/frontend/src/components/chat/model-selector.tsx ================================================ import { useTranslation } from "react-i18next" import type { ModelInfo } from "@/api/models" import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue, } from "@/components/ui/select" interface ModelSelectorProps { defaultModelName: string apiKeyModels: ModelInfo[] oauthModels: ModelInfo[] localModels: ModelInfo[] onValueChange: (modelName: string) => void } export function ModelSelector({ defaultModelName, apiKeyModels, oauthModels, localModels, onValueChange, }: ModelSelectorProps) { const { t } = useTranslation() return ( ) } ================================================ FILE: web/frontend/src/components/chat/session-history-menu.tsx ================================================ import { IconHistory, IconTrash } from "@tabler/icons-react" import dayjs from "dayjs" import type { RefObject } from "react" import { useTranslation } from "react-i18next" import type { SessionSummary } from "@/api/sessions" import { Button } from "@/components/ui/button" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { ScrollArea } from "@/components/ui/scroll-area" interface SessionHistoryMenuProps { sessions: SessionSummary[] activeSessionId: string hasMore: boolean loadError: boolean loadErrorMessage: string observerRef: RefObject onOpenChange: (open: boolean) => void onSwitchSession: (sessionId: string) => void onDeleteSession: (sessionId: string) => void } export function SessionHistoryMenu({ sessions, activeSessionId, hasMore, loadError, loadErrorMessage, observerRef, onOpenChange, onSwitchSession, onDeleteSession, }: SessionHistoryMenuProps) { const { t } = useTranslation() return ( {loadError && ( {loadErrorMessage} )} {sessions.length === 0 && !loadError ? ( {t("chat.noHistory")} ) : ( sessions.map((session) => ( onSwitchSession(session.id)} > {session.title || session.preview} {t("chat.messagesCount", { count: session.message_count, })}{" "} · {dayjs(session.updated).fromNow()} )) )} {hasMore && sessions.length > 0 && (
{t("chat.loadingMore")}
)}
) } ================================================ FILE: web/frontend/src/components/chat/typing-indicator.tsx ================================================ import { useEffect, useState } from "react" import { useTranslation } from "react-i18next" export function TypingIndicator() { const { t } = useTranslation() const thinkingSteps = [ t("chat.thinking.step1"), t("chat.thinking.step2"), t("chat.thinking.step3"), t("chat.thinking.step4"), ] const [stepIndex, setStepIndex] = useState(0) useEffect(() => { const stepsCount = thinkingSteps.length const interval = setInterval(() => { setStepIndex((prev) => (prev + 1) % stepsCount) }, 3000) return () => clearInterval(interval) }, [thinkingSteps.length]) return (
PicoClaw

{thinkingSteps[stepIndex]}

) } ================================================ FILE: web/frontend/src/components/chat/user-message.tsx ================================================ interface UserMessageProps { content: string } export function UserMessage({ content }: UserMessageProps) { return (
{content}
) } ================================================ FILE: web/frontend/src/components/config/config-page.tsx ================================================ import { IconCode, IconDeviceFloppy } from "@tabler/icons-react" import { useQuery, useQueryClient } from "@tanstack/react-query" import { Link } from "@tanstack/react-router" import { useEffect, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" import { patchAppConfig } from "@/api/channels" import { getAutoStartStatus, getLauncherConfig, setAutoStartEnabled as updateAutoStartEnabled, setLauncherConfig as updateLauncherConfig, } from "@/api/system" import { AgentDefaultsSection, CronSection, DevicesSection, ExecSection, LauncherSection, RuntimeSection, } from "@/components/config/config-sections" import { type CoreConfigForm, EMPTY_FORM, EMPTY_LAUNCHER_FORM, type LauncherForm, buildFormFromConfig, parseCIDRText, parseIntField, parseMultilineList, } from "@/components/config/form-model" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" export function ConfigPage() { const { t } = useTranslation() const queryClient = useQueryClient() const [form, setForm] = useState(EMPTY_FORM) const [baseline, setBaseline] = useState(EMPTY_FORM) const [launcherForm, setLauncherForm] = useState(EMPTY_LAUNCHER_FORM) const [launcherBaseline, setLauncherBaseline] = useState(EMPTY_LAUNCHER_FORM) const [autoStartEnabled, setAutoStartEnabled] = useState(false) const [autoStartBaseline, setAutoStartBaseline] = useState(false) const [saving, setSaving] = useState(false) const { data, isLoading, error } = useQuery({ queryKey: ["config"], queryFn: async () => { const res = await fetch("/api/config") if (!res.ok) { throw new Error("Failed to load config") } return res.json() }, }) const { data: launcherConfig, isLoading: isLauncherLoading } = useQuery({ queryKey: ["system", "launcher-config"], queryFn: getLauncherConfig, }) const { data: autoStartStatus, isLoading: isAutoStartLoading, error: autoStartError, } = useQuery({ queryKey: ["system", "autostart"], queryFn: getAutoStartStatus, }) useEffect(() => { if (!data) return const parsed = buildFormFromConfig(data) setForm(parsed) setBaseline(parsed) }, [data]) useEffect(() => { if (!launcherConfig) return const parsed: LauncherForm = { port: String(launcherConfig.port), publicAccess: launcherConfig.public, allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"), } setLauncherForm(parsed) setLauncherBaseline(parsed) }, [launcherConfig]) useEffect(() => { if (!autoStartStatus) return setAutoStartEnabled(autoStartStatus.enabled) setAutoStartBaseline(autoStartStatus.enabled) }, [autoStartStatus]) const configDirty = JSON.stringify(form) !== JSON.stringify(baseline) const launcherDirty = JSON.stringify(launcherForm) !== JSON.stringify(launcherBaseline) const autoStartDirty = autoStartEnabled !== autoStartBaseline const isDirty = configDirty || launcherDirty || autoStartDirty const autoStartSupported = autoStartStatus?.supported !== false const autoStartHint = autoStartError ? t("pages.config.autostart_load_error") : !autoStartSupported ? t("pages.config.autostart_unsupported") : t("pages.config.autostart_hint") const updateField = ( key: K, value: CoreConfigForm[K], ) => { setForm((prev) => ({ ...prev, [key]: value })) } const updateLauncherField = ( key: K, value: LauncherForm[K], ) => { setLauncherForm((prev) => ({ ...prev, [key]: value })) } const handleReset = () => { setForm(baseline) setLauncherForm(launcherBaseline) setAutoStartEnabled(autoStartBaseline) toast.info(t("pages.config.reset_success")) } const handleSave = async () => { try { setSaving(true) if (configDirty) { const workspace = form.workspace.trim() const dmScope = form.dmScope.trim() if (!workspace) { throw new Error("Workspace path is required.") } if (!dmScope) { throw new Error("Session scope is required.") } const maxTokens = parseIntField(form.maxTokens, "Max tokens", { min: 1, }) const maxToolIterations = parseIntField( form.maxToolIterations, "Max tool iterations", { min: 1 }, ) const summarizeMessageThreshold = parseIntField( form.summarizeMessageThreshold, "Summarize message threshold", { min: 1 }, ) const summarizeTokenPercent = parseIntField( form.summarizeTokenPercent, "Summarize token percent", { min: 1, max: 100 }, ) const heartbeatInterval = parseIntField( form.heartbeatInterval, "Heartbeat interval", { min: 1 }, ) const cronExecTimeoutMinutes = parseIntField( form.cronExecTimeoutMinutes, "Cron exec timeout", { min: 0 }, ) const execConfigPatch: Record = { enabled: form.execEnabled, } if (form.execEnabled) { execConfigPatch.allow_remote = form.allowRemote execConfigPatch.enable_deny_patterns = form.enableDenyPatterns execConfigPatch.custom_allow_patterns = parseMultilineList( form.customAllowPatternsText, ) execConfigPatch.timeout_seconds = parseIntField( form.execTimeoutSeconds, "Exec timeout", { min: 0 }, ) if (form.enableDenyPatterns) { execConfigPatch.custom_deny_patterns = parseMultilineList( form.customDenyPatternsText, ) } } await patchAppConfig({ agents: { defaults: { workspace, restrict_to_workspace: form.restrictToWorkspace, max_tokens: maxTokens, max_tool_iterations: maxToolIterations, summarize_message_threshold: summarizeMessageThreshold, summarize_token_percent: summarizeTokenPercent, }, }, session: { dm_scope: dmScope, }, tools: { cron: { allow_command: form.allowCommand, exec_timeout_minutes: cronExecTimeoutMinutes, }, exec: execConfigPatch, }, heartbeat: { enabled: form.heartbeatEnabled, interval: heartbeatInterval, }, devices: { enabled: form.devicesEnabled, monitor_usb: form.monitorUSB, }, }) setBaseline(form) queryClient.invalidateQueries({ queryKey: ["config"] }) } if (launcherDirty) { const port = parseIntField(launcherForm.port, "Service port", { min: 1, max: 65535, }) const allowedCIDRs = parseCIDRText(launcherForm.allowedCIDRsText) const savedLauncherConfig = await updateLauncherConfig({ port, public: launcherForm.publicAccess, allowed_cidrs: allowedCIDRs, }) const parsedLauncher: LauncherForm = { port: String(savedLauncherConfig.port), publicAccess: savedLauncherConfig.public, allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join( "\n", ), } setLauncherForm(parsedLauncher) setLauncherBaseline(parsedLauncher) queryClient.setQueryData( ["system", "launcher-config"], savedLauncherConfig, ) } if (autoStartDirty) { if (!autoStartSupported) { throw new Error(t("pages.config.autostart_unsupported")) } const status = await updateAutoStartEnabled(autoStartEnabled) setAutoStartEnabled(status.enabled) setAutoStartBaseline(status.enabled) queryClient.setQueryData(["system", "autostart"], status) } toast.success(t("pages.config.save_success")) } catch (err) { toast.error( err instanceof Error ? err.message : t("pages.config.save_error"), ) } finally { setSaving(false) } } return (
{t("pages.config.open_raw")} } />
{isLoading ? (
{t("labels.loading")}
) : error ? (
{t("pages.config.load_error")}
) : (
{isDirty && (
{t("pages.config.unsaved_changes")}
)}
)}
) } ================================================ FILE: web/frontend/src/components/config/config-sections.tsx ================================================ import type { ReactNode } from "react" import { useTranslation } from "react-i18next" import { type CoreConfigForm, DM_SCOPE_OPTIONS, type LauncherForm, } from "@/components/config/form-model" import { Field, SwitchCardField } from "@/components/shared-form" import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Textarea } from "@/components/ui/textarea" type UpdateCoreField = ( key: K, value: CoreConfigForm[K], ) => void type UpdateLauncherField = ( key: K, value: LauncherForm[K], ) => void interface ConfigSectionCardProps { title: string description?: string children: ReactNode } function ConfigSectionCard({ title, description, children, }: ConfigSectionCardProps) { return ( {title} {description && {description}}
{children}
) } interface AgentDefaultsSectionProps { form: CoreConfigForm onFieldChange: UpdateCoreField } export function AgentDefaultsSection({ form, onFieldChange, }: AgentDefaultsSectionProps) { const { t } = useTranslation() return ( onFieldChange("workspace", e.target.value)} placeholder="~/.picoclaw/workspace" /> onFieldChange("restrictToWorkspace", checked) } /> onFieldChange("maxTokens", e.target.value)} /> onFieldChange("maxToolIterations", e.target.value)} /> onFieldChange("summarizeMessageThreshold", e.target.value) } /> onFieldChange("summarizeTokenPercent", e.target.value) } /> ) } interface ExecSectionProps { form: CoreConfigForm onFieldChange: UpdateCoreField } export function ExecSection({ form, onFieldChange }: ExecSectionProps) { const { t } = useTranslation() return ( onFieldChange("execEnabled", checked)} /> {form.execEnabled && ( <> onFieldChange("allowRemote", checked)} /> onFieldChange("enableDenyPatterns", checked) } /> {form.enableDenyPatterns && (