Compare commits

...

302 Commits

Author SHA1 Message Date
lxowalle 639b32703a feat: support streaming (#2892)
* Support streaming

* fix: stream pico reasoning updates

Route Pico reasoning through the active streamer and hide empty thought placeholders.

* fix: harden configured streaming delivery

* fix ci

* fix split issue
2026-05-19 16:38:47 +08:00
LC 941bac2332 feat(web): add chat detail visibility selector (#2886) 2026-05-18 14:50:57 +08:00
sky5454 cb5d33124c fix(powershell): windows security enhancement, sec deny powershell encoding bypass via iex inje… (#2836)
* fix(powershell):  sec deny  powershell encoding bypass   via iex injection.

* fix(exec): security guard bypass fixes for PowerShell/CMD encoding and path traversal

- Split deny patterns into defaultDenyPatterns (all platforms) and
  windowsDenyPatterns (Windows-only) to avoid false positives
- Add PowerShell encoding bypass detection:
  - [Text.Encoding] and [System.Text.Encoding] variants
  - -EncodedCommand short forms (-e, -ec, -enc)
  - .GetString([byte[]] with whitespace variations
  - FromBase64String decoding
  - PowerShell variable = [byte[](...) patterns
  - Literal \uXXXX Unicode escape sequences
- Expand PowerShell ($env:VAR) and CMD (%VAR%) environment variables
  before workspace path checking to prevent $env:USERPROFILE bypass
- Expand ~ to home directory on Windows
- Add .../.../ path traversal variant detection (blocks .../.../, ..../..../)
- Add symlink/junction resolution before workspace check
- Add Windows path normalization for ADS (file.txt:stream) and
  extended-length paths (\?\)
- Add comprehensive tests for all new patterns

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(exec): fix -EncodedCommand regex and rename Windows tests with expanded payloads

- Fix -EncodedCommand regex to match all short forms: -e, -ec, -enc, -en
- Rename Windows-specific tests with TestWindows_ prefix for clarity:
  - TestWindows_TildeBypassPrevented
  - TestWindows_SymlinkBypassPrevented
  - TestWindows_PowerShellEncodingBypass
- Expand test payloads:
  - [Text.Encoding]: add UTF8 and Unicode variants
  - -EncodedCommand: add -enc and -en forms
  - Unicode escape: add multiple \uXXXX forms

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* ci: retest

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 10:28:03 +08:00
Mauro 68e572f969 fix(config): make load_image configurable (#2879) 2026-05-18 10:17:34 +08:00
LC 57876248e2 feat(provider): add SiliconFlow provider support (#2885) 2026-05-18 10:16:09 +08:00
LC 789f907f6d feat(chat): add independent code block copy and collapse controls (#2882)
* feat(chat): add independent copy and collapse controls for code blocks

* fix(chat): unify code block rendering styles

* fix(chat): refine code block labels

* feat(chat): highlight tool call code blocks as json
2026-05-18 10:01:39 +08:00
Guoguo feacd84b84 docs: update wechat qrcode (#2889) 2026-05-18 09:50:36 +08:00
肆月 604187e312 feat(web,api): test connection with real connectivity verification (#2833)
* feat(web,api): add fetch models and saved catalog support

Split from PR #2752 (part 2 of 3).

Backend:
- /api/models/catalog endpoint for browsing remote model catalogs
- /api/models/fetch endpoint for fetching available models from providers
- Credential reuse with provider/API base matching for security
- Default API base resolution for providers without explicit base

Frontend:
- FetchModelsDialog for importing models from remote providers
- CatalogDialog for browsing and importing from model catalogs
- Static import for FetchModelsDialog (replaces dynamic import from PR1)
- Dynamic import retained for TestModelDialog (PR3 territory)

* feat(web,api): add test connection with real connectivity verification

Split from PR #2752 (part 3 of 3).

Backend:
- /api/models/{index}/test endpoint for testing saved model configs
- /api/models/test-inline endpoint for testing unsaved form values
- Real network probe (GET /models) for connectivity verification
- Credential reuse with provider/API base matching for security
- Default API base resolution for providers without explicit base

Frontend:
- TestModelDialog for testing model connectivity
- Inline test support for add/edit model sheets
- Static import for TestModelDialog (replaces dynamic import from PR1)
2026-05-18 09:47:44 +08:00
美電球 0df050ff2e Merge pull request #2766 from SiYue-ZO/docs/v3-config-format-sync
docs: sync all documentation to V3 config format
2026-05-15 22:06:08 +08:00
Mauro 6817aa5311 Merge pull request #2811 from afjcjsbx/fix/mcp-streamable-http-support
fix(mcp): support streamable HTTP alias, request-response mode and integration tests
2026-05-15 12:55:58 +02:00
lxowalle 412705783d fix(pico): preserve image media across pico attachments and client (#2874)
* fix(pico): preserve image media across pico attachments and client

* * fix ci

* fix(pico): preserve text when client media parsing fails

- Skip non-inline Pico attachment URLs instead of treating them as invalid inline media
- Preserve pico_client text messages when malformed media payloads are received
- Add regression coverage for media.create, download attachments, and invalid media payloads

* fix lint
2026-05-15 15:49:07 +08:00
wenjie bfb2b35f74 chore: update slack-go to v0.23.1 (#2875)
Adapt Slack media uploads to the renamed UploadFile API.
2026-05-15 13:58:11 +08:00
wenjie b225629af8 build(deps): update @tailwindcss/vite to 4.3.0 (#2876) 2026-05-15 13:58:09 +08:00
美電球 c62a9bf55b Merge pull request #2862 from lc6464/fix/mimo-reasoning-history-replay
fix(openai_compat): align MiMo reasoning replay with DeepSeek
2026-05-15 12:09:52 +08:00
美電球 f7d25c6546 Merge pull request #2741 from lc6464/fix/deepseek-stream-reasoning-content
fix(openai_compat): parse reasoning_content in streaming responses
2026-05-15 12:09:26 +08:00
dependabot[bot] 215d98aa78 build(deps): bump tailwindcss from 4.2.4 to 4.3.0 in /web/frontend (#2872)
Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) from 4.2.4 to 4.3.0.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.0/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: tailwindcss
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-15 11:23:13 +08:00
dependabot[bot] dab8391344 build(deps-dev): bump typescript-eslint in /web/frontend (#2871)
Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 8.59.1 to 8.59.3.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.3/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-version: 8.59.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-15 11:08:46 +08:00
dependabot[bot] a4abbf62e2 build(deps-dev): bump prettier-plugin-tailwindcss in /web/frontend (#2870)
Bumps [prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss) from 0.7.2 to 0.8.0.
- [Release notes](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.7.2...v0.8.0)

---
updated-dependencies:
- dependency-name: prettier-plugin-tailwindcss
  dependency-version: 0.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-15 10:58:34 +08:00
dependabot[bot] 8ab455171c build(deps): bump jotai from 2.19.1 to 2.20.0 in /web/frontend (#2869)
Bumps [jotai](https://github.com/pmndrs/jotai) from 2.19.1 to 2.20.0.
- [Release notes](https://github.com/pmndrs/jotai/releases)
- [Commits](https://github.com/pmndrs/jotai/compare/v2.19.1...v2.20.0)

---
updated-dependencies:
- dependency-name: jotai
  dependency-version: 2.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-15 10:46:39 +08:00
dependabot[bot] eec4436e64 build(deps): bump github.com/adhocore/gronx from 1.19.6 to 1.19.7 (#2868)
Bumps [github.com/adhocore/gronx](https://github.com/adhocore/gronx) from 1.19.6 to 1.19.7.
- [Release notes](https://github.com/adhocore/gronx/releases)
- [Changelog](https://github.com/adhocore/gronx/blob/main/CHANGELOG.md)
- [Commits](https://github.com/adhocore/gronx/compare/v1.19.6...v1.19.7)

---
updated-dependencies:
- dependency-name: github.com/adhocore/gronx
  dependency-version: 1.19.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-15 10:31:30 +08:00
dependabot[bot] dc41c9c566 build(deps): bump golang.org/x/net from 0.53.0 to 0.54.0 (#2867)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.53.0 to 0.54.0.
- [Commits](https://github.com/golang/net/compare/v0.53.0...v0.54.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.54.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-15 10:21:14 +08:00
dependabot[bot] 2f8429f57c build(deps): bump github.com/mymmrac/telego from 1.8.0 to 1.9.0 (#2866)
Bumps [github.com/mymmrac/telego](https://github.com/mymmrac/telego) from 1.8.0 to 1.9.0.
- [Release notes](https://github.com/mymmrac/telego/releases)
- [Commits](https://github.com/mymmrac/telego/compare/v1.8.0...v1.9.0)

---
updated-dependencies:
- dependency-name: github.com/mymmrac/telego
  dependency-version: 1.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-15 10:13:57 +08:00
dependabot[bot] d8385ce0a7 build(deps-dev): bump vite from 8.0.10 to 8.0.13 in /web/frontend (#2865)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.10 to 8.0.13.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.13/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-15 10:08:02 +08:00
dependabot[bot] 89631b8671 build(deps): bump github.com/larksuite/oapi-sdk-go/v3 (#2864)
Bumps [github.com/larksuite/oapi-sdk-go/v3](https://github.com/larksuite/oapi-sdk-go) from 3.6.1 to 3.7.5.
- [Release notes](https://github.com/larksuite/oapi-sdk-go/releases)
- [Changelog](https://github.com/larksuite/oapi-sdk-go/blob/v3_main/changelog.md)
- [Commits](https://github.com/larksuite/oapi-sdk-go/compare/v3.6.1...v3.7.5)

---
updated-dependencies:
- dependency-name: github.com/larksuite/oapi-sdk-go/v3
  dependency-version: 3.7.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-15 10:06:33 +08:00
dependabot[bot] 4db1168962 build(deps): bump modernc.org/sqlite from 1.48.2 to 1.50.1 (#2863)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.48.2 to 1.50.1.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.48.2...v1.50.1)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.50.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-15 09:57:11 +08:00
肆月 f6190b54de feat(web,api): fetch models and saved catalog support (#2832)
* feat(web,api): add fetch models and saved catalog support

Split from PR #2752 (part 2 of 3).

Backend:
- /api/models/catalog endpoint for browsing remote model catalogs
- /api/models/fetch endpoint for fetching available models from providers
- Credential reuse with provider/API base matching for security
- Default API base resolution for providers without explicit base

Frontend:
- FetchModelsDialog for importing models from remote providers
- CatalogDialog for browsing and importing from model catalogs
- Static import for FetchModelsDialog (replaces dynamic import from PR1)
- Dynamic import retained for TestModelDialog (PR3 territory)

* fix(web,api): support bare-array responses in fetchOpenAICompatibleModels

* fix(web,api): tighten maskAPIKeyValue to match maskAPIKey policy

For 9-12 character keys, maskAPIKeyValue exposed first 4 + last 4
chars (only 1 char masked for a 9-char key). Now uses the same
policy as maskAPIKey: first 3 + last 2 for 9-12 chars, first 3 +
last 4 for longer keys. Adds tests covering all key length boundaries.
2026-05-15 09:49:03 +08:00
lc6464 6ae7dc38b9 fix(openai_compat): align MiMo reasoning replay with DeepSeek 2026-05-14 20:59:01 +08:00
lc6464 10f4466a7e fix(openai_compat): parse SSE events and reasoning variants in streams 2026-05-14 10:20:01 +08:00
Anton Bogdanovich 794eb04f32 feat(providers): add gemini web search provider (#2763)
* add gemini web search provider

* fix(web): prefer free providers before Gemini in auto mode

* fix(web): expose gemini api key and model settings

* fix(web): prefer configured providers before Gemini in auto mode

* fix(web): satisfy gemini lint checks

* fix(web): address gemini provider review feedback

* test(web): align auto-provider expectations

* fix(web): let gemini ignore search range
2026-05-14 09:50:47 +08:00
afjcjsbx ffb8243721 fix(integration): docker runner workspace mounts and go command path 2026-05-13 20:03:55 +02:00
afjcjsbx ec21ddc222 fix lint 2026-05-13 19:57:38 +02:00
afjcjsbx ffe091d8b2 docs(cli): mention streamable-http in mcp add transport help 2026-05-13 19:56:08 +02:00
afjcjsbx 4edbc73b64 fix(integration): execute suite commands directly in docker runner 2026-05-13 19:54:32 +02:00
afjcjsbx ffc8bdba36 fix(mcp): normalize streamable-http before config validation 2026-05-13 19:30:59 +02:00
Mauro eb0653074b Merge pull request #2857 from afjcjsbx/feat/edit-file-diff-preview
feat(tools): show unified diff for edit_file edits
2026-05-13 09:04:32 +02:00
Guoguo f62de5c0d4 docs: update wechat qrcode (#2860) 2026-05-13 11:24:11 +08:00
afjcjsbx e0370aafcc fix test 2026-05-12 23:23:26 +02:00
afjcjsbx 56cca3f12f fix(tools) limit edit diff preview size for user and model 2026-05-12 23:12:37 +02:00
afjcjsbx 87048499ff fix(tools) diff preview for files without trailing newline 2026-05-12 23:06:43 +02:00
afjcjsbx 4a81f0e740 feat(tools): show unified diff for edit_file edits 2026-05-12 18:06:47 +02:00
lxowalle 223ebdf0c7 docs: add evolution config controls (#2852)
* docs: add evolution config controls

* docs: address evolution config review
2026-05-12 11:23:06 +08:00
lxowalle 255a67e2da docs: add LicheeRV-Claw AliExpress news (#2854)
* docs: add LicheeRV-Claw AliExpress news

* * update picture

* * update multi-language docs

* * update taobao link for readme.zh.md
2026-05-12 10:18:59 +08:00
Mauro 777269b429 Merge pull request #2758 from bogdanovich/codex/telegram-media-groups
fix(telegram): media group album handling
2026-05-12 00:11:48 +02:00
肆月 d2c0b69243 feat(web,api): provider selection and model form foundation (#2831)
* feat: improve model configuration workflows

Add model catalog browsing, provider registry with form validation,
model fetch/test dialogs, and enhanced model management UI.

- Add model catalog API and catalog-dialog component for browsing saved models
- Add provider-registry with auto-populated form fields per provider
- Add provider-combobox, fetch-models-dialog, test-model-dialog components
- Add model-validation for provider-aware model ID validation
- Add command and popover UI components
- Enhance edit-model-sheet with tool schema transform support
- Add anthropic to protocolMetaByName for correct default API base
- Apply NormalizeBaseURL to anthropic provider for consistent URL handling
- Add i18n keys for new model management features (en/zh)

* fix(web): prevent auto-fetch when API key is missing in fetch models dialog

When a provider requires an API key but none is set, the dialog now shows
the warning without triggering a doomed fetch attempt. Fetch is deferred
until the user provides a key.

* fix(web): add credential warning for catalog imports from remote providers

When importing models from a catalog entry whose provider requires an API
key, a yellow warning banner now informs users that credentials will need
to be configured after import.

* feat(web,api): test connection with real connectivity verification and unsaved form values

Add POST /api/models/test-inline endpoint that performs actual network
probes (GET /models) instead of just checking config. Frontend Test
Connection now uses current form values (not saved state) and is
available in both Add and Edit model flows.

* style(web): apply linter formatting across model config components

Normalize quote style, import ordering, and class name ordering as
reported by the project linter.

* fix(web,api): fix edit test connection false negative and gate fetch for unsupported providers

- handleTestInlineModel now accepts optional model_index to fall back to stored credentials when api_key is empty, fixing false negatives when testing edited models
- Add supportsFetch to provider registry and FETCHABLE_PROVIDER_KEYS derived set
- Gate Fetch Models button to only show for OpenAI-compatible and Ollama providers
- Add backend guard in handleFetchModels to reject unsupported providers with clear error

* fix: address review feedback on model config workflow

- Send explicit {} for empty extra_body/custom_headers fields so the
  backend clears stored values instead of preserving them
- Merge backend provider_options with frontend PROVIDERS registry so
  the provider picker reflects backend-supported providers and policy
  fields (create_allowed, default_auth_method, auth_method_locked)
- Render provider combobox popover inside the sheet scroll container
  to fix wheel events scrolling the sheet instead of the provider list

* feat(web,api): add provider selection, model form foundation, and validation

Split from PR #2752 (part 1 of 3).

Backend:
- CRUD model endpoints (list/add/update/delete/set-default)
- Provider metadata with default API bases and model provider options
- Model ID validation and normalization
- Anthropic default API base normalization

Frontend:
- Provider registry with metadata, labels, icons, and aliases
- Provider combobox with backend option merging
- Model field validation with provider-aware checks
- Redesigned add/edit model sheets with provider selection
- Dynamic imports for fetch/catalog/test dialogs (coming in PR2/PR3)
- i18n support for model configuration UI
2026-05-11 16:57:37 +08:00
美電球 7dc78425d1 Merge pull request #2719 from loafoe/feat/slack-webhook-channel
feat(channels): add slack_webhook output-only channel
2026-05-11 16:22:27 +08:00
lxowalle b3a7b7ad64 feat: agent self evolution (#2847)
* feat: add agent self-evolution

* fix ci

* delete unused doc

* fix lint

* fix evolution review issues
2026-05-11 16:13:27 +08:00
Andy Lo-A-Foe b12f03be2e feat(channels): add slack_webhook channel
Add an output-only channel that sends messages to Slack via Incoming
Webhooks using Block Kit formatting.

Features:
- Multiple webhook targets with named routing (requires "default" target)
- Markdown to Slack mrkdwn conversion (bold, italic, strikethrough, links, lists)
- Code block handling with proper fence preservation across chunk splits
- Table rendering with aligned columns in code blocks
- Automatic text chunking at 3000 chars (Slack's text block limit)
- HTTPS-only webhook URL validation

Configuration example:
  channels:
    slack_webhook:
      webhooks:
        default:
          webhook_url: "https://hooks.slack.com/services/..."
          username: "PicoClaw"
          icon_emoji: ":robot_face:"

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-11 09:54:04 +02:00
美電球 894c6251c5 Merge pull request #2783 from zhangxinping666/codex/fix-reload-voice-media-store
fix(gateway): keep media store aligned after reload
2026-05-11 15:44:38 +08:00
美電球 306f96cfe3 Merge pull request #2645 from loafoe/feat/bedrock-streaming
feat(bedrock): implement StreamingProvider for real-time token streaming
2026-05-11 15:08:43 +08:00
Gabriel S. Vieira 1055e082a4 Add MCP section to config web UI (#2770)
* Add MCP section to config UI

* Handle MCP sse and URL-based server mapping

* Validate duplicate MCP server names before save

* Disable MCP discovery options based on mutual exclusivity in config section

Co-authored-by: Copilot <copilot@github.com>

* Clear stale MCP transport fields in patch payload

* Fix MCP config form state preservation and validation

* Avoid MCP form ID collisions for distinct server names

* Validate remote MCP URLs in config UI

* fix(config): correct MCP discovery merge patch behavior

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix(config): align MCP discovery semantics and MCP server editor behavior

* fix(config): validate MCP server fields only when active

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-11 11:09:27 +08:00
xp 91f024eb1d fix(gateway): keep media store aligned after reload 2026-05-10 10:26:57 +08:00
Anton Bogdanovich 6801cc7ab8 fix(telegram): wrap long voice media append 2026-05-09 12:59:41 -07:00
Anton Bogdanovich 09d3dff432 fix telegram media group album handling 2026-05-09 12:59:41 -07:00
Mauro 6e6293e596 Merge pull request #2158 from afjcjsbx/feat/agent-discovery-prompt
feat(agent): Multi-agent discovery prompt
2026-05-09 13:56:19 +02:00
Mauro f571a142bf Merge pull request #2823 from bogdanovich/fix/parent-session-tool-feedback-cleanup
fix(agent): dismiss tool feedback when outbound is skipped
2026-05-09 11:09:10 +02:00
Mauro af901617ac Merge pull request #2828 from bogdanovich/fix/queued-voice-followups
fix(agent): transcribe queued voice follow-ups
2026-05-09 10:51:45 +02:00
afjcjsbx 2ae25b1038 fix(agent): treat empty AGENT.md tools as allow none 2026-05-09 10:35:13 +02:00
Anton Bogdanovich e1ed47b0ff fix(agent): remove unused scheduled helper 2026-05-09 00:53:23 -07:00
Anton Bogdanovich 8362203631 fix(agent): transcribe queued voice follow-ups 2026-05-08 13:50:14 -07:00
afjcjsbx 148583e7bb fix(agent): hide discovery when spawn is unavailable 2026-05-08 22:23:50 +02:00
Anton Bogdanovich a3edbcd05e test(agent): satisfy lint for tool feedback cleanup 2026-05-08 10:33:16 -07:00
afjcjsbx c6a09a35e2 fix(agent): suppress MCP discovery when no servers are selectable 2026-05-08 13:48:47 +02:00
afjcjsbx ffa184d183 fix(agent): resolve primary provider from frontmatter model 2026-05-08 13:43:21 +02:00
美電球 8508f80608 Merge pull request #2705 from hehaijunandhenry/main
add MQTT channel support
2026-05-08 18:50:08 +08:00
hehaijunandhenry 569939a7b3 Fix stop_mqtt_channel 2026-05-08 17:21:25 +08:00
hehaijunandhenry 2287de521e Linter fixed 2026-05-08 15:49:28 +08:00
afjcjsbx 871892ff15 fix(tools): exempt MCP discovery tools from agent allowlists 2026-05-08 09:18:14 +02:00
李光春 d5c8bfffbc fix(docs): correct Baidu Search free tier from 1000/day to 1500/month (#2784) (#2825) 2026-05-08 15:14:33 +08:00
hehaijunandhenry f062cb41d7 1 2026-05-08 14:48:43 +08:00
Anton Bogdanovich 610e9e3fe8 fix(agent): dismiss session tool feedback on skipped outbound 2026-05-07 23:10:56 -07:00
美電球 01280eaa53 Merge pull request #2413 from ex-takashima/refactor/line-sdk
refactor(line): use official LINE Bot SDK v8
2026-05-08 14:00:00 +08:00
ex-takashima bacb9aba7c fix(line): close response body on successful SendMedia calls
Always route through classifySDKError to ensure resp.Body is
closed even when the API call succeeds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 14:11:15 +09:00
ex-takashima 6d7d1b0909 fix(line): capture QuoteToken for all message types and handle location
- Store QuoteToken for image, video, and sticker messages (not just text)
- Add webhook.LocationMessageContent case to forward as [location] placeholder

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 14:05:58 +09:00
dependabot[bot] 3788e9edad build(deps): bump i18next from 26.0.8 to 26.0.10 in /web/frontend (#2809)
Bumps [i18next](https://github.com/i18next/i18next) from 26.0.8 to 26.0.10.
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.0.8...v26.0.10)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 11:15:44 +08:00
dependabot[bot] c2044e5a2c build(deps): bump react-i18next from 17.0.4 to 17.0.6 in /web/frontend (#2808)
Bumps [react-i18next](https://github.com/i18next/react-i18next) from 17.0.4 to 17.0.6.
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.4...v17.0.6)

---
updated-dependencies:
- dependency-name: react-i18next
  dependency-version: 17.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 11:08:15 +08:00
dependabot[bot] 7c8cd7c66a build(deps-dev): bump globals from 17.5.0 to 17.6.0 in /web/frontend (#2807)
Bumps [globals](https://github.com/sindresorhus/globals) from 17.5.0 to 17.6.0.
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.5.0...v17.6.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 17.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 11:07:46 +08:00
dependabot[bot] f4338d3aab build(deps): bump @tabler/icons-react in /web/frontend (#2806)
Bumps [@tabler/icons-react](https://github.com/tabler/tabler-icons/tree/HEAD/packages/icons-react) from 3.41.1 to 3.43.0.
- [Release notes](https://github.com/tabler/tabler-icons/releases)
- [Commits](https://github.com/tabler/tabler-icons/commits/v3.43.0/packages/icons-react)

---
updated-dependencies:
- dependency-name: "@tabler/icons-react"
  dependency-version: 3.43.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 11:07:23 +08:00
dependabot[bot] b7edd35d13 build(deps): bump shadcn from 4.3.0 to 4.7.0 in /web/frontend (#2804)
Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 4.3.0 to 4.7.0.
- [Release notes](https://github.com/shadcn-ui/ui/releases)
- [Changelog](https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/CHANGELOG.md)
- [Commits](https://github.com/shadcn-ui/ui/commits/shadcn@4.7.0/packages/shadcn)

---
updated-dependencies:
- dependency-name: shadcn
  dependency-version: 4.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 10:50:08 +08:00
dependabot[bot] d0ab5aed7a build(deps): bump fyne.io/systray from 1.12.0 to 1.12.1 (#2803)
Bumps [fyne.io/systray](https://github.com/fyne-io/systray) from 1.12.0 to 1.12.1.
- [Changelog](https://github.com/fyne-io/systray/blob/master/CHANGELOG.md)
- [Commits](https://github.com/fyne-io/systray/compare/v1.12.0...v1.12.1)

---
updated-dependencies:
- dependency-name: fyne.io/systray
  dependency-version: 1.12.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 10:47:29 +08:00
Mauro 1c25dcd239 build(go): bump Go to 1.25.10 to fix stdlib vulnerabilities (#2818) 2026-05-08 09:33:17 +08:00
afjcjsbx 0ac8703e0f docs(tests): document integration test strategy 2026-05-07 20:45:05 +02:00
afjcjsbx 131f33f084 fix(tests): add t.Parallel() to mcp streamable subtests 2026-05-07 20:39:53 +02:00
afjcjsbx 6dd30a0c77 fix(ci): ensure go binary resolution and correct command execution 2026-05-07 20:31:57 +02:00
afjcjsbx 7a8d7fb218 fix(scripts): allow command expansion in integration runner 2026-05-07 20:28:07 +02:00
afjcjsbx 703f630f33 fix(ci): define golang image and volumes for integration-runner 2026-05-07 19:44:06 +02:00
Mauro 2834db13de Merge pull request #2801 from sipeed/dependabot/go_modules/github.com/google/jsonschema-go-0.4.3
build(deps): bump github.com/google/jsonschema-go from 0.4.2 to 0.4.3
2026-05-07 19:28:26 +02:00
dependabot[bot] e948106d50 build(deps): bump github.com/google/jsonschema-go from 0.4.2 to 0.4.3
Bumps [github.com/google/jsonschema-go](https://github.com/google/jsonschema-go) from 0.4.2 to 0.4.3.
- [Release notes](https://github.com/google/jsonschema-go/releases)
- [Commits](https://github.com/google/jsonschema-go/compare/v0.4.2...0.4.3)

---
updated-dependencies:
- dependency-name: github.com/google/jsonschema-go
  dependency-version: 0.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-07 17:24:06 +00:00
afjcjsbx 6e8590900b fix(mcp): support streamable HTTP alias and request-response mode 2026-05-07 19:24:02 +02:00
afjcjsbx b8f4257cee fix(agent): filter discovery by spawn permissions 2026-05-07 18:26:09 +02:00
afjcjsbx 96fd887cad fix(agent): match MCP server allowlists case-insensitively 2026-05-07 18:17:37 +02:00
afjcjsbx dd8e247550 fix(agent): align MCP prompt registration with tool allowlist 2026-05-07 14:01:43 +02:00
afjcjsbx 27bd816b1c fix(agent): validate AGENT tool declarations from registry 2026-05-07 13:49:23 +02:00
afjcjsbx f1f6e1131b removed unused code 2026-05-07 13:20:39 +02:00
afjcjsbx 6f6270b39d Merge upstream/main into feat/agent-discovery-prompt 2026-05-07 13:16:30 +02:00
ex-takashima 41d6156dce style(line): shorten long line for golines linter
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 16:48:45 +09:00
ex-takashima ad78ba06ea fix(line): close HTTP response body from WithHttpInfo calls
Fix bodyclose linter errors by ensuring resp.Body is closed
after all *WithHttpInfo SDK calls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 16:41:19 +09:00
ex-takashima 9b7fc7aa6c fix(line): classify SDK errors with HTTP status and add client timeout
Address review feedback:
- Use *WithHttpInfo SDK variants to get HTTP response status codes
- Map status codes via ClassifySendError (429→ErrRateLimit, 5xx→ErrTemporary, 4xx→ErrSendFailed)
- Fall back to ClassifyNetError for network-level failures
- Configure SDK with 30s timeout HTTP client

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 16:23:33 +09:00
hehaijunandhenry e7c0dc821a Merge remote-tracking branch 'remotes/upstream/main' 2026-05-07 14:39:29 +08:00
美電球 658961b728 Merge pull request #2531 from is-Xiaoen/feat/delegate-tool
feat(tools): add delegate tool for cross-agent task handoff
2026-05-07 11:25:41 +08:00
Mauro 788cda5c7a Merge pull request #2762 from afjcjsbx/feat/stop-command
feat(agent): stop command
2026-05-06 18:19:14 +02:00
SiYue e304dce40e docs: sync all documentation to V3 config format
- Replace config/config.example.json with V3 format (version: 3, api_keys array, channel_list)
- Update config-versioning.md: version 2→3, ConfigV2→ConfigV3, CurrentVersion=3
- Update 7 project READMEs: api_key→api_keys, add version: 3 to quick-start examples
- Update 12 security docs (ANTIGRAVITY_AUTH + credential_encryption): api_key→api_keys
- Update provider-refactoring.md: api_key→api_keys in all config examples
- Update security_configuration.md: api_key→api_keys in Before example
- Update 3 channel docs: channels→channel_list in JSON examples
2026-05-06 18:02:46 +08:00
LC 81a050555d feat(provider,web,asr): enhance model management with explicit provider metadata (#2701)
* feat(provider,web): enhance model management with provider options

* fix(asr): enhance compatibility for ElevenLabs transcription model

* fix(provider,web): align provider availability predicates and add flow gating

* fix(web,asr): preserve legacy elevenlabs transcription configs

* fix(provider,web,asr): normalize elevenlabs configs and gate default chat models

* fix: tighten provider catalog and elevenlabs compatibility
2026-05-06 16:06:49 +08:00
openapphub 4d3070e849 fix(web): 兼容 HTTP 环境复制按钮 (#2712)
Co-authored-by: openapphub <175949671+openapphub@users.noreply.github.com>
2026-05-06 14:44:36 +08:00
dependabot[bot] e3a05bd36d build(deps): bump @tailwindcss/vite from 4.2.2 to 4.2.4 in /web/frontend (#2734)
Bumps [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) from 4.2.2 to 4.2.4.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.4/packages/@tailwindcss-vite)

---
updated-dependencies:
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-06 14:32:00 +08:00
dependabot[bot] 0977f59fee build(deps): bump github.com/larksuite/oapi-sdk-go/v3 (#2736)
Bumps [github.com/larksuite/oapi-sdk-go/v3](https://github.com/larksuite/oapi-sdk-go) from 3.5.4 to 3.6.1.
- [Release notes](https://github.com/larksuite/oapi-sdk-go/releases)
- [Changelog](https://github.com/larksuite/oapi-sdk-go/blob/v3_main/changelog.md)
- [Commits](https://github.com/larksuite/oapi-sdk-go/compare/v3.5.4...v3.6.1)

---
updated-dependencies:
- dependency-name: github.com/larksuite/oapi-sdk-go/v3
  dependency-version: 3.6.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-06 14:27:14 +08:00
dependabot[bot] 00742b0196 build(deps): bump @tanstack/react-router in /web/frontend (#2733)
Bumps [@tanstack/react-router](https://github.com/TanStack/router/tree/HEAD/packages/react-router) from 1.168.23 to 1.169.2.
- [Release notes](https://github.com/TanStack/router/releases)
- [Changelog](https://github.com/TanStack/router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/TanStack/router/commits/@tanstack/react-router@1.169.2/packages/react-router)

---
updated-dependencies:
- dependency-name: "@tanstack/react-router"
  dependency-version: 1.168.26
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-06 14:17:21 +08:00
dependabot[bot] 0419497c72 build(deps): bump i18next from 26.0.7 to 26.0.8 in /web/frontend (#2732)
Bumps [i18next](https://github.com/i18next/i18next) from 26.0.7 to 26.0.8.
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.0.7...v26.0.8)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.0.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-06 14:14:08 +08:00
dependabot[bot] 864bfa1cef build(deps-dev): bump typescript-eslint in /web/frontend (#2730)
Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 8.59.0 to 8.59.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.1/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-version: 8.59.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-06 14:12:19 +08:00
dependabot[bot] c0bc8a3f9d build(deps): bump tailwindcss from 4.2.2 to 4.2.4 in /web/frontend (#2729)
Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) from 4.2.2 to 4.2.4.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.4/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: tailwindcss
  dependency-version: 4.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-06 14:10:28 +08:00
Diego Fornalha 96621eff21 feat(i18n): add Portuguese (Brazil) locale (#2037)
* feat(i18n): add Portuguese (Brazil) locale

Add pt-BR as the third supported language in the Web UI, alongside
English and Chinese. The browser language detector will auto-select
PT-BR for Portuguese-speaking users.

Changes:
- Add web/frontend/src/i18n/locales/pt-br.json with full translation
- Register pt-BR resource and dayjs locale in i18n/index.ts
- Add "Português (Brasil)" option to language selector dropdown

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore(i18n): refresh pt-br locale to match current en.json keys

Add 194 new keys (skills marketplace, tour, launcher login/setup, chat
disabled placeholders, web search tools, dashboard password, etc.) and
remove 15 outdated keys so pt-br.json now mirrors en.json (601/601 keys).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 11:33:39 +08:00
afjcjsbx a7e52e8a25 fix(agent): drain scoped follow-up queue when pending stop skips turn startup 2026-05-05 19:24:15 +02:00
Mauro eb4e187550 Merge pull request #2767 from afjcjsbx/fix/leaf-summary-target-validation
fix(seahorse): enforce target token thresholds for leaf summaries
2026-05-05 19:09:06 +02:00
Mauro 0129da1c8e Merge pull request #2773 from zhangxinping666/codex/fix-telegram-svg-media
fix(agent): send SVG attachments as files
2026-05-05 14:12:34 +02:00
xp d601b75268 fix(agent): send SVG attachments as files 2026-05-05 19:36:09 +08:00
Mauro 5745957429 Merge pull request #2731 from sipeed/dependabot/go_modules/github.com/aws/aws-sdk-go-v2/service/bedrockruntime-1.50.6
build(deps): bump github.com/aws/aws-sdk-go-v2/service/bedrockruntime from 1.50.5 to 1.50.6
2026-05-04 22:07:36 +02:00
Mauro ba4abff4a4 Merge pull request #2670 from david1gp/fix/tool-feedback-pretty-print
feat(agent): add pretty_print and disable_escape_html options for tool feedback
2026-05-04 21:47:32 +02:00
afjcjsbx a1b55fd4f9 fix(seahorse): enforce target token thresholds for leaf summaries 2026-05-04 14:10:42 +02:00
afjcjsbx d63430ab33 fix(agent): don't arm pending stop when /stop targets idle session 2026-05-04 13:10:02 +02:00
美電球 71c49812ae Merge pull request #2764 from alexhoshina/main
fix(agent): use runtime event kind for LLM retry
2026-05-04 15:48:11 +08:00
afjcjsbx 7a1f5fe8b9 fix test 2026-05-04 09:06:39 +02:00
Hoshina 057683d94c fix(agent): use runtime event kind for LLM retry 2026-05-04 15:06:34 +08:00
afjcjsbx a0245c7b02 feat(agent): stop command 2026-05-04 08:41:29 +02:00
afjcjsbx f3ef7090c5 feat(agent): stop command 2026-05-04 08:41:17 +02:00
Mauro be67aed4dc Merge pull request #2677 from alexhoshina/feat/runtime-events-plan
Feat/runtime events
2026-05-03 23:15:25 +02:00
Mauro f4a5d6e808 Merge pull request #2682 from dtapps/fix/docs-agent-defaults-model-format
docs: fix agents.defaults model configuration format
2026-05-03 23:14:26 +02:00
dependabot[bot] 330aa297e2 build(deps): bump github.com/aws/aws-sdk-go-v2/service/bedrockruntime
Bumps [github.com/aws/aws-sdk-go-v2/service/bedrockruntime](https://github.com/aws/aws-sdk-go-v2) from 1.50.5 to 1.50.6.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/ssm/v1.50.5...service/ecr/v1.50.6)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/bedrockruntime
  dependency-version: 1.50.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-03 18:30:21 +00:00
Mauro 4e8bd73a58 Merge pull request #2735 from sipeed/dependabot/go_modules/github.com/aws/aws-sdk-go-v2/config-1.32.17
build(deps): bump github.com/aws/aws-sdk-go-v2/config from 1.32.16 to 1.32.17
2026-05-03 20:27:49 +02:00
Mauro 828a7cba70 Merge pull request #2681 from afjcjsbx/fix/gemini-mcp-schema-sanitization
fix(mcp): sanitize MCP tool schemas for Gemini function calling
2026-05-03 20:25:35 +02:00
Mauro 490d90749c Merge pull request #2717 from LiusCraft/feat/deepseek-vision-unsupported-error
feat: add DeepSeek vision unsupported error detection
2026-05-03 20:24:56 +02:00
Mauro 272dee3fca Merge pull request #2669 from david1gp/fix/network-error-retry
feat(agent): add network error retry with configurable max retries and backoff
2026-05-03 20:18:18 +02:00
BeaconCat a94ba82181 chore: update WeChat group QR code (#2747)
Co-authored-by: BeaconCat <BeaconCat@users.noreply.github.com>
2026-05-02 16:55:53 +08:00
lc6464 b00ff5bc5d fix(openai_compat): parse reasoning_content in streaming responses 2026-05-01 15:20:35 +08:00
dependabot[bot] b792d8b77b build(deps): bump github.com/aws/aws-sdk-go-v2/config
Bumps [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) from 1.32.16 to 1.32.17.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.32.16...config/v1.32.17)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.32.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-30 17:19:52 +00:00
lxowalle 6e1fab80e2 Fix/build macos launcher failed (#2724)
* fix(release): drop stale launcher tui goreleaser target

* * delete unused file
2026-04-30 22:48:32 +08:00
lxowalle a7414608ed * fix build failed (#2723) 2026-04-30 18:04:48 +08:00
LC dbf5d9ce1f fix(seahorse): persist reasoning_content in sqlite history (#2707)
* fix(seahorse): persist reasoning_content in sqlite history

* fix(openai_compat): clarify DeepSeek reasoning replay rules

* style(seahorse): format files of seahorse

* fix(openai_compat): cover DeepSeek replay requirements

* fix(seahorse): repair missing reasoning_content during bootstrap

* fix(seahorse): repair reasoning_content on bootstrap prefix
2026-04-30 16:07:38 +08:00
Guoguo 5db008f384 fix(channels): dismiss tool feedback animation when turn ends via ResponseHandled (#2713)
* fix(channels): dismiss tool feedback animation when turn ends via ResponseHandled

When a tool sets ResponseHandled=true (e.g., send_file), the turn ends
without producing a final assistant response. This meant no outbound
message triggered FinalizeToolFeedbackMessage, leaving the animation
goroutine running indefinitely — editing the Feishu card every 3 seconds
with "." / ".." suffixes long after the tool had finished.

Fix: call DismissToolFeedback at "Tool output satisfied delivery" so the
tracker is cleared and the animation goroutine is stopped immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(adapters): add DismissToolFeedback to channelManagerAdapter

The adapter must implement the new interface method added in the
previous commit, otherwise the package fails to compile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(channels): pass InboundContext to DismissToolFeedback for topic-aware keys

Telegram forum topics use scoped tracker keys like "chatID/topicID",
resolved via ToolFeedbackMessageChatID with the InboundContext. The
previous nil context caused the lookup to fall back to the raw chatID,
missing the topic-scoped entry and leaving the animation goroutine
orphaned in forum-topic conversations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style: wrap long function signatures for golines

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 11:17:55 +08:00
Guoguo cb1e1a3595 fix(feishu): fix image download with API fallback and post image support (#2708)
* fix(feishu): fix image download with API fallback and post image support

- Add Image.Get API fallback when MessageResource.Get fails (different
  permission scope: im:resource vs im:message:readonly)
- Extract and download images from post (rich text) messages
- Extract images from interactive card messages
- Deduplicate post image keys across locales
- Add comprehensive tests for new helpers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(media): add image path tags alongside base64 for LLM file access

Images are still base64-encoded into msg.Media for multimodal LLMs,
but now also get [image:path] tags injected into message content so
the LLM knows the local file path for save/forward operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(media): only auto-inject images for tool results, not user messages

Channel-received images (role=user) now get path tags only, letting
the LLM decide whether to view via load_image or just operate on
the file. Tool result images (role=tool, e.g. load_image) are
base64-encoded into a synthetic user message appended after the tool
message, since many LLM APIs don't support image_url in tool messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(media): preserve tool-message ordering for multi-tool-call scenarios

Move synthetic user message (carrying base64 tool images) to after the
entire contiguous tool-message block instead of immediately after each
tool message. This preserves the assistant→tool→tool ordering required
by OpenAI-compatible APIs.

Also fix load_image to use generic [image: photo] placeholder so
injectPathTags can properly replace it with the actual path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(test): update load_image test for [image: photo] placeholder

The test was checking ForLLM for the media:// ref, but load_image now
emits the generic [image: photo] placeholder instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(media): match all channel image placeholders in injectPathTags

Different channels emit different placeholder formats — Telegram/Feishu
use [image: photo], WeCom/WeChat/Line use bare [image], QQ/Discord use
[image: <filename>]. The previous string-match code only handled
[image: photo], so for the other channels the path tag was appended as
a duplicate, producing content like "[image] [image:/path]".

Switch to per-type regex that matches all generic placeholder shapes
while leaving path tags ([image:/path]) untouched. Also fixes the same
issue for [audio], [video], [file] tags. Added test coverage for the
various placeholder shapes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(media): skip path tag append for JSON content (Feishu cards/posts)

When content is structured JSON (interactive cards, post messages),
injectPathTags now skips the fallback append — only placeholder
replacement is attempted. This prevents corrupting JSON payloads
like {"schema":"2.0",...} with appended [image:/path] tags.

Adds looksLikeJSON() helper and three test cases covering JSON
objects, arrays, and an end-to-end resolveMediaRefs scenario with
Feishu card content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(media): prepend path tags for JSON content, narrow looksLikeJSON

Two fixes from code review:

1. looksLikeJSON now only checks for '{' prefix (not '['), avoiding
   false positives on regular text like "[update] see attached".

2. For JSON content (Feishu cards/posts), path tags are prepended
   before the JSON instead of being silently dropped. This ensures
   the LLM can discover attached images via the path tag while the
   JSON payload stays valid for downstream parsing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 11:08:00 +08:00
Andy Lo-A-Foe b03fa61764 test(bedrock): add unit tests for ChatStream/parseStreamResponse
Tests cover: text-only streaming with chunk accumulation, tool call
parsing with fragmented JSON, mixed text+tool responses, context
cancellation, invalid JSON fallback to raw payload, nil stream guard,
default finish reason, and all stop reason mappings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 21:17:16 +02:00
Andy Lo-A-Foe ad5232ade8 feat(bedrock): implement StreamingProvider for real-time token streaming
Adds ConverseStream API support to the Bedrock provider, implementing
the StreamingProvider interface. Tokens flow via onChunk callback for
real-time delivery to streaming-capable channels.

- Extract buildConverseParams to share request logic between Chat and ChatStream
- Add converseStreamReader interface for testability
- Preserve raw payload in Arguments on JSON parse failure
- Ensure Function.Arguments is always valid JSON
- Streaming timeout only applied when explicitly configured
- Capture stream Close() errors for diagnostics
- Consistent "bedrock conversestream" / "bedrock:" log prefixes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 21:17:16 +02:00
LiusCraft 1722cfc282 feat: add DeepSeek vision unsupported error detection
Add detection for 'unknown variant' + 'image_url' error pattern used by
DeepSeek and other strict providers when vision is not supported.
These providers reject the image_url field at the JSON schema level
rather than returning a semantic 'not supported' message.
2026-04-30 02:24:29 +08:00
hehaijunandhenry 5c0492900e add MQTT channel support 2026-04-29 18:27:33 +08:00
taonyx a36472b55f Add CLI support for custom OpenAI-compatible endpoints and remove TUI (#2710)
* feat(model): add `picoclaw model add` for custom OpenAI-compatible endpoints

Onboards a model from a user-supplied API base + key by hitting
GET <base>/models, prompting the user to pick one, and writing the entry
into model_list[] (with api_keys) plus setting it as the default model.
This was previously only available in the TUI launcher (issue #2208) and
is now accessible from the CLI:

    picoclaw model add -b URL -k KEY [-m MODEL] [-n ALIAS]

* chore: remove deprecated picoclaw-launcher-tui

Per RFC #2208, the TUI launcher is deprecated in favor of the CLI; its
"online model picker" feature has been ported to `picoclaw model add` in
the previous commit. This drops the binary and all build/release/docs
references:

- delete cmd/picoclaw-launcher-tui/ and assets/launcher-tui.jpg
- Makefile: remove the `build-launcher-tui` target
- .goreleaser.yaml: drop the build entry plus the `picoclaw-launcher-tui`
  ids from the launcher docker image, macOS notarize list, and nfpms
  contents
- docker/Dockerfile.goreleaser.launcher: drop the COPY for the TUI binary
- READMEs (root + 8 locales): remove the "TUI Launcher" section and
  screenshot link
- docs/guides/docker.*: update the "launcher image includes …" sentence
  to reflect the two remaining binaries

`make build` still succeeds; `go build ./web/backend` (the launcher
target) still succeeds. `picoclaw-launcher` (web console) is unaffected.
2026-04-29 17:52:47 +08:00
Guoguo 62d0e34ec9 fix(docker): restore make docker-build by adding build directives and fixing Go version (#2700)
* fix(docker): restore `make docker-build` by adding build directives and fixing Go version

docker-compose.yml only had `image:` references with no `build:` sections,
so `docker compose build` had nothing to build. Also fixed golang:1.26.0-alpine
(nonexistent) to golang:1.25-alpine in Dockerfile.full/heavy, and removed
LICENSE from .dockerignore since scripts/copydir.go needs it as a repo-root anchor
during `go generate`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(docker): inject version metadata ldflags in Dockerfile.launcher

Mirror the ldflags from web/Makefile (Version, GitCommit, BuildTime,
GoVersion) into the picoclaw-launcher go build command so Docker-built
launcher images include proper version/build metadata.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 16:19:15 +08:00
美電球 db1bc6a1f8 Merge pull request #2689 from afjcjsbx/fix/cron-session-key-propagation
fix(cron): propagate sessionKey to prevent duplicate tool responses
2026-04-28 23:03:09 +08:00
LC 9b109dc7a8 fix(serial_windows): remove unused import (#2697) 2026-04-28 16:19:59 +08:00
Guoguo fc24676924 Add cross-platform serial tool support (#2673)
* feat(tools): add cross-platform serial hardware tool

* feat(config): wire serial tool into runtime and dashboard

* hardware/serial: tighten validation and error handling

* hardware/serial: improve unix cancellation and timeout polling

* hardware/serial: improve windows I/O handling

* hardware/serial: fix darwin cross-compilation build

* docs(design): summarize hardware support and serial limits

* build: keep go generate on host during cross builds

* onboard: drop unrelated go generate change from serial work

* style(tools): wrap serial lines for golines
2026-04-28 13:10:32 +08:00
SiYue-ZO bd867a16cd style(tools): wrap serial lines for golines 2026-04-28 12:58:26 +08:00
SiYue-ZO 29e7461837 onboard: drop unrelated go generate change from serial work 2026-04-28 12:58:26 +08:00
SiYue-ZO 688d47d236 build: keep go generate on host during cross builds 2026-04-28 12:57:44 +08:00
SiYue-ZO 2baeee2834 docs(design): summarize hardware support and serial limits 2026-04-28 12:57:44 +08:00
SiYue-ZO 893e61dc51 hardware/serial: fix darwin cross-compilation build 2026-04-28 12:57:44 +08:00
SiYue-ZO 64e48163d0 hardware/serial: improve windows I/O handling 2026-04-28 12:57:25 +08:00
SiYue-ZO 1f0a5f4eda hardware/serial: improve unix cancellation and timeout polling 2026-04-28 12:57:09 +08:00
SiYue-ZO 338fa258b3 hardware/serial: tighten validation and error handling 2026-04-28 12:56:47 +08:00
SiYue-ZO 2114e1a53f feat(config): wire serial tool into runtime and dashboard 2026-04-28 12:56:27 +08:00
SiYue-ZO 0f52076762 feat(tools): add cross-platform serial hardware tool 2026-04-28 12:54:28 +08:00
LC c44bd6138c refactor(pico): unify message kind handling of tool_calls and thought (#2680)
* refactor(pico): unify message kind handling of tool_calls and thought

* fix(pico): add legacy compatibility for thought payload in Send method

Co-authored-by: Copilot <copilot@github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
2026-04-28 10:17:12 +08:00
afjcjsbx 23df824c77 fix test 2026-04-27 21:27:02 +02:00
afjcjsbx 87ee76b117 Merge remote-tracking branch 'origin/fix/gemini-mcp-schema-sanitization' into fix/gemini-mcp-schema-sanitization 2026-04-27 21:18:29 +02:00
afjcjsbx 7b3e800407 fix test 2026-04-27 21:18:19 +02:00
Mauro c731ecdc74 Merge branch 'main' into fix/gemini-mcp-schema-sanitization 2026-04-27 21:14:25 +02:00
afjcjsbx cd7717bc15 feat(tool): tool schema semplification 2026-04-27 21:10:30 +02:00
afjcjsbx 0bb0fc429a fix(cron): propagate sessionKey to prevent duplicate tool responses 2026-04-27 13:17:25 +02:00
David Siewert e656ddf5bb fix: align struct tag spacing in AgentDefaults config 2026-04-27 16:47:28 +06:00
David Siewert 38baf1ccd0 fix(agent): normalize nil args and improve error handling in FormatArgsJSON
- Return fmt.Sprintf fallback instead of {} on encoding errors to preserve visibility
- Normalize nil to empty map in FormatArgsJSON for consistent output
- Remove redundant nil check in toolFeedbackArgsPreview wrapper
- Update test expectation: nil args now return {} not null
2026-04-27 16:31:20 +06:00
David Siewert 8dca2a1319 fix: improve error handling and nil consistency in FormatArgsJSON
- Use fmt.Sprintf fallback instead of {} on encoding errors
- Normalize nil args to {} in FormatArgsJSON for consistent output
- Update tests to expect {} instead of null for nil args

Based on PR #2670 review feedback from afjcjsbx
2026-04-27 16:05:10 +06:00
David Siewert 97b1c3efec fix duplicate toolFeedbackArgsPreview function declaration 2026-04-27 15:42:18 +06:00
Guoguo 0161298154 ci: add stale bot to auto-close inactive issues and PRs (#2685)
Two-phase strategy: 7 days inactive → stale warning, 7 more days → close.
Exempt labels: pinned, keep-open, wip, do-not-close, type: roadmap.
Draft PRs are also exempt. Runs daily at 03:00 JST.
Scan oldest items first (ascending: true) with 500 ops budget to avoid
backlog starvation.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 17:15:02 +08:00
ex-takashima 188ee24d2e Merge remote-tracking branch 'origin/main' into refactor/line-sdk
# Conflicts:
#	go.mod
#	go.sum
2026-04-27 17:47:41 +09:00
美電球 f90e756e21 Merge pull request #2663 from SiYue-ZO/feature/config-save-restart-prompts
feat: improve config save and restart feedback
2026-04-27 15:45:38 +08:00
Hoshina 78fd080189 fix(events): keep runtime observers non-blocking
Add a non-blocking runtime publish path and switch hot-path publishers to it.

Enforce subscription timeout boundaries, keep ordered subscriber snapshots up to date on subscribe changes, expose all runtime kinds to process hooks, add safe log attrs for non-agent events, and close the gateway message bus on full shutdown.
2026-04-27 13:09:03 +08:00
Mauro ed687d62ae fix(config): show precise malformed config diagnostics (#2415)
* fix(config): show precise malformed config diagnostics

* fix lint

* fix test
2026-04-27 09:45:52 +08:00
dtapps f62e8621fc docs: fix agents.defaults model configuration format
Change incorrect object format model.primary/fallbacks to correct
flat format model_name/model_fallbacks in Agent defaults example.

The AgentDefaults struct does not support the object format used
in AgentConfig, so the documentation example was misleading.
2026-04-27 09:30:11 +08:00
afjcjsbx 4eeb69688e fix lint 2026-04-26 22:33:35 +02:00
afjcjsbx 1ff8a418f6 fix(mcp): sanitize MCP tool schemas for Gemini function calling 2026-04-26 22:23:55 +02:00
SiYue-ZO ddf2d7c655 fix gateway boot signature after pico setup 2026-04-26 22:09:00 +08:00
SiYue-ZO cbe6a0907c feat: complete tool and model restart feedback 2026-04-26 22:09:00 +08:00
SiYue-ZO 02d9a0d190 feat: track channel and web search restart requirements 2026-04-26 22:09:00 +08:00
SiYue-ZO afc600baed feat: add config save and restart prompts 2026-04-26 22:09:00 +08:00
美電球 39dec35408 Merge pull request #2672 from lc6464/fix-tool-calls-thought-ui
feat(pico): add structured tool call support to web chat
2026-04-26 21:27:28 +08:00
lc6464 d6b38c4236 fix(chat): update tool_calls structure and ensure kind is always set 2026-04-26 20:13:13 +08:00
LC 1b9e7e32bd fix(chat): add \r? for regular expressions
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-26 19:59:55 +08:00
lc6464 1acab59fc7 fix(tests): format error message 2026-04-26 19:48:22 +08:00
lc6464 bfc37b784e fix(channels): bypass placeholder edits for thought and tool calls 2026-04-26 19:43:25 +08:00
Hoshina 4d6337fd26 fix runtime event logger reload and shutdown 2026-04-26 19:28:26 +08:00
Hoshina b3d9f86a01 feat(events): add configurable runtime event logging 2026-04-26 17:41:00 +08:00
Hoshina f4a24614b8 docs(events): remove stale subturn event names
Replace leftover SubTurnOrphanResultEvent and short subturn event references with runtime event kinds in comments, tests, and hook design notes.

Validation: GOCACHE=/tmp/picoclaw-go-cache go test ./pkg/agent -run TestSpawnSubTurn_OrphanResultRouting; make lint
2026-04-26 17:04:38 +08:00
Hoshina e613258fa5 feat(gateway): publish lifecycle runtime events
Emit gateway.start, gateway.ready, and gateway.shutdown on the shared runtime event bus, while keeping reload events on the same helper path.

Update subturn architecture docs to refer to runtime event kinds instead of the removed agent EventBus names.

Validation: GOCACHE=/tmp/picoclaw-go-cache go test ./pkg/gateway ./pkg/events; GOCACHE=/tmp/picoclaw-go-cache go test ./pkg/bus ./pkg/channels ./pkg/mcp ./pkg/tools/integration ./pkg/events ./pkg/gateway; make lint
2026-04-26 17:02:48 +08:00
Hoshina 795ee362ea refactor(events): emit agent runtime events directly
Remove the legacy EventKind/Event envelope mapping and let agent event emission build pkg/events.Event values directly.

Keep HookMeta as the shared hook metadata shape and preserve legacy observe string aliases by mapping them to runtime event kinds.

Validation: GOCACHE=/tmp/picoclaw-go-cache go test ./pkg/agent; make lint
2026-04-26 16:55:02 +08:00
Hoshina b954e6b8dc refactor(events): remove legacy agent event bus
Drop the old agent EventBus, SubscribeEvents/EventDrops public surface, legacy hook observer dispatch, and hook.event process notification path. Agent observations now flow through pkg/events runtime events.

Validation: go test ./pkg/agent; make lint
2026-04-26 16:39:35 +08:00
Hoshina fce800414d docs(events): align hook design with runtime observation
Mark the original hook design as an early record and update observer examples to pkg/events runtime events and hook.runtime_event.

Validation: make lint
2026-04-26 16:33:18 +08:00
Hoshina b2249df3ea refactor(events): split agent event payload types
Move agent domain event payload structs out of the legacy event envelope file so the remaining EventKind/Event/EventMeta compatibility layer can be removed independently later.

Validation: go test ./pkg/agent; make lint
2026-04-26 16:31:52 +08:00
Hoshina 6e8a81bfbf test(events): prefer runtime hook observation
Use RuntimeEventObserver for the normal in-process hook observer path and make the process-hook helper assert hook.runtime_event notifications.

Validation: go test ./pkg/agent; make lint
2026-04-26 16:28:41 +08:00
Hoshina dc80e8f5f2 test(events): migrate agent tests to runtime events
Move AgentLoop event assertions to the runtime event stream and keep the legacy SubscribeEvents test only for dual-publish compatibility.

Validation: go test ./pkg/agent; make lint
2026-04-26 16:23:58 +08:00
Hoshina d9717b5632 refactor(events): start runtime event consumer migration
Deprecate the legacy agent event APIs and add a runtime event test helper, then migrate the follow-up queued test to the runtime event stream.

Validation: go test ./pkg/agent; make lint
2026-04-26 16:11:09 +08:00
Hoshina 8caf9aeb2b feat(events): publish runtime service events
Migrate hook observation to runtime events and update the process hook notification protocol. Add runtime event publication for message bus failures, channel lifecycle/outbound flow, gateway reloads, MCP server state, and MCP tool calls.

Validation: go test ./pkg/events/... ./pkg/bus ./pkg/agent ./pkg/channels ./pkg/mcp ./pkg/tools/integration ./pkg/gateway; make lint
2026-04-26 16:05:10 +08:00
Hoshina eedebabbea feat(events): add runtime event bus
Introduce pkg/events with filtered channels, subscription policies, backpressure, and stats. Wire AgentLoop to dual-publish legacy agent events into runtime events while preserving old event APIs.

Validation: go test ./pkg/events/... ./pkg/agent; go test -race ./pkg/events/...; make lint
2026-04-26 15:36:03 +08:00
David Siewert f0dc709b17 fix(config): fix golines max-len for MaxLLMRetries field 2026-04-26 07:07:19 +06:00
David Siewert 4ddd650be4 align ToolFeedbackConfig field spacing 2026-04-26 07:06:36 +06:00
lc6464 9d42282672 fix(chat): tolerate animated legacy tool feedback parsing 2026-04-26 01:19:19 +08:00
David Siewert 9bc702ebaf fix test: enable pretty_print in tool feedback test 2026-04-25 23:09:40 +06:00
David Siewert 612097b411 fix(config): align gci formatting for LLM retry fields 2026-04-25 23:01:45 +06:00
lc6464 303ff8137d feat(chat): unify reasoning and tool call visibility 2026-04-26 00:50:18 +08:00
lc6464 6d04d15ce0 fix(tool-feedback): dedupe duplicate content and keep full explanations 2026-04-26 00:40:55 +08:00
David Siewert bdaff5cb69 Add pretty_print and disable_escape_html to tool_feedback defaults 2026-04-25 22:27:01 +06:00
David Siewert 1b2f8aac79 fix(config): align indentation for new LLM retry default fields 2026-04-25 22:12:41 +06:00
David Siewert 32c8b8ce6a chore(config): add default values for max_llm_retries and llm_retry_backoff_secs 2026-04-25 22:09:44 +06:00
David Siewert d2f6a08981 fix(config): align gci formatting for MaxLLMRetries field 2026-04-25 22:07:16 +06:00
lc6464 5cd10b594a feat(pico): add support for tool_calls in chat messages 2026-04-25 23:43:10 +08:00
David Siewert 3c4523e7aa test(agent): add unit tests for network error retry backoff strategy
- Test all network error types trigger retry (connection_reset, broken_pipe, read_tcp, eof, connection_refused)
- Test custom MaxLLMRetries and LLMRetryBackoffSecs config is respected
- Test retry count limit (1 initial + maxRetries retries)
- Add countingErrorProvider mock for deterministic call count verification
2026-04-25 21:19:13 +06:00
David Siewert fc89fea319 test(utils): add unit tests for FormatArgsJSON
Add tests for FormatArgsJSON covering:
- Default compact JSON output
- Pretty print formatting
- HTML escape disabling (preserves &&, <, >)
- Combined pretty print and escape disable
- Default HTML escaping behavior
- Nil args handling
2026-04-25 21:14:06 +06:00
David Siewert bcc3d447a1 feat(agent): add pretty_print and disable_escape_html options for tool feedback
- Add PrettyPrint and DisableEscapeHTML config options to ToolFeedbackConfig
- Add FormatArgsJSON helper function with configurable pretty printing and HTML escaping
- Add toolFeedbackArgsPreviewWithOptions to pass formatting options
- Update pipeline_execute.go to use new formatting options for tool feedback

This fixes the issue where '&&' would be displayed as '\u0026' in tool
feedback messages and provides optional pretty-printing for better
readability.
2026-04-25 20:46:16 +06:00
David Siewert 06fad95719 feat(agent): add network error retry with configurable max retries and backoff
- Add isNetworkError detection for connection reset, broken pipe, read/write tcp, EOF
- Add retry logic with configurable exponential backoff for network errors
- Add config options max_llm_retries and llm_retry_backoff_secs in agents.defaults
- Network errors now retry with backoff (was previously not retried)
- Timeout errors now use configurable backoff instead of hardcoded 5s
- Default: 2 retries with 2s backoff (3 total attempts)
2026-04-25 19:08:46 +06:00
美電球 77be169db4 Merge pull request #2654 from SiYue-ZO/fix/launcher-hide-windows-console-flash
fix(launcher): hide windows child-process console flashes
2026-04-25 18:00:50 +08:00
maxiaoyang 9f0f914ad7 Merge upstream/main into feat/delegate-tool
Resolves conflicts after the agent loop refactor on main:
- pkg/agent/loop.go was deleted upstream (logic split into agent.go,
  agent_init.go, pipeline.go, etc.); accepted the deletion.
- Moved the delegate tool registration block from the old loop.go
  into registerSharedTools in pkg/agent/agent_init.go, immediately
  after the spawn/spawn_status block. Logic and gating
  (len(registry.ListAgentIDs()) > 1) are unchanged.
- pkg/agent/subturn.go and pkg/agent/subturn_test.go merged cleanly
  on their own; TargetAgentID field, validation, registry lookup,
  and tests all preserved.

Verified locally:
- go build ./pkg/agent/... ./pkg/tools/...  clean
- go vet  clean
- TestDelegateTool* (17 cases) pass
- TestSpawnSubTurn_TargetAgentID_* (3 cases) pass
- TestDelegateToolRegistered_MultiAgent / _SingleAgent pass
- full pkg/agent + pkg/tools test suites green
2026-04-25 17:17:03 +08:00
美電球 726ef4fa99 Merge pull request #2661 from SiYue-ZO/feature/toggle-thought-visibility
feat: add thought visibility toggle
2026-04-25 17:15:49 +08:00
SiYue-ZO d784ec4611 feat: add thought visibility toggle 2026-04-25 17:08:37 +08:00
美電球 41f4d95597 Merge pull request #2657 from lc6464/fix-deepseek-v4-thinking-history
fix(reasoning): persist canonical history for DeepSeek and web chat
2026-04-25 15:08:48 +08:00
Mauro 04b62745e4 Merge pull request #2664 from afjcjsbx/fix/mcp-http-session-lifecycle
fix(mcp): retry tool calls on lost HTTP sessions and fix client lifec…
2026-04-25 08:51:28 +02:00
BeaconCat 78e4e59ac3 chore: update WeChat group QR code (#2667)
Co-authored-by: BeaconCat <BeaconCat@users.noreply.github.com>
2026-04-25 13:15:37 +08:00
lc6464 ae162a72b1 fix(message): ignore transient assistant thoughts in message count and history truncation 2026-04-25 12:26:28 +08:00
美電球 788f76f422 Merge pull request #2666 from afjcjsbx/fix/mcp-nil-arguments
fix(mcp): send empty object instead of null for tool
2026-04-25 11:38:48 +08:00
美電球 2f91cc0a80 Merge pull request #2660 from afjcjsbx/fix/tool-feedback-json-format
fix(tool-feedback): format tool args as JSON code blocks
2026-04-25 11:34:09 +08:00
美電球 93e9bddc6e Merge pull request #2659 from SiYue-ZO/fix/thought-bubble-collapse-state
fix: isolate thought bubble collapse state
2026-04-25 11:30:35 +08:00
美電球 caaad601af Merge pull request #2656 from alexhoshina/prompt-layering
Prompt layering
2026-04-25 11:22:21 +08:00
afjcjsbx 9d8f0dc877 fix(mcp): send empty object instead of null for tool 2026-04-24 21:24:29 +02:00
afjcjsbx 8f8af0874d fix(mcp): retry tool calls on lost HTTP sessions and fix client lifecycle 2026-04-24 20:20:57 +02:00
Hoshina 9ca73b944f fix(agent): preserve prompt hook and cache semantics 2026-04-25 01:25:17 +08:00
SiYue b4a5965602 refactor(onboard,api): harden copydir repo-root detection and use platform-neutral proc attrs naming
Address latest review comments from sky5454 in PR #2654.

scripts/copydir.go:

- Improve repository root detection in a safer, more deterministic way.

- Prefer locating repo root from the script source path via runtime.Caller(), then fallback to upward search from current working directory.

- Replace .git-only root detection with repository anchor validation: go.sum, LICENSE, and .github must exist.

- Keep \ placeholder expansion and existing in-repo path guards.

- Preserve destination safety check to prevent deleting/copying to repo root.

web/backend/api:

- Rename applyLauncherWindowsProcAttrs() to applyLauncherProcAttrs() to expose a platform-independent interface name.

- Keep platform-specific behavior split by build tags: windows keeps HideWindow SysProcAttr setup, non-windows remains no-op.

- Update gateway startup path to call the renamed helper.

Why:

- Follow reviewer feedback to avoid relying on .git detection alone and prefer runtime/file-anchor based repository location.

- Improve naming clarity by making cross-platform interfaces generic while preserving OS-specific implementation details internally.

Validation:

- go test ./cmd/picoclaw/internal/onboard

- go test ./web/backend/api
2026-04-25 00:31:36 +08:00
afjcjsbx dce29c181f fix lint 2026-04-24 18:21:01 +02:00
afjcjsbx 94a6b0c0f5 fix(tool-feedback): format tool args as JSON code blocks 2026-04-24 18:07:48 +02:00
SiYue-ZO 683ce31f2b fix: isolate thought bubble collapse state 2026-04-24 23:58:42 +08:00
SiYue 494cc381b5 build(onboard): support codespace placeholder and path checks 2026-04-24 23:31:04 +08:00
SiYue e1863234f0 fix(launcher): hide windows child-process console flashes
- hide windows when launching gateway process from launcher

- hide windows for powershell/tasklist process inspection commands
2026-04-24 23:31:04 +08:00
SiYue a977a92729 build(web): avoid shell-expanding powershell vars in windows recipe
- rewrite build-frontend Windows command without PowerShell local vars

- keep install-stamp hash check logic
2026-04-24 23:31:04 +08:00
SiYue 193e1a3cd0 Fix Windows build flow 2026-04-24 23:28:35 +08:00
SiYue f6bceb29a3 Fix Windows build flow 2026-04-24 23:27:59 +08:00
LC 979ff00cc3 fix(messageutil): remove dead code
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-24 22:16:18 +08:00
lc6464 bb0f983708 fix(reasoning): persist canonical history for DeepSeek and web chat 2026-04-24 21:45:41 +08:00
Hoshina 48d8952591 feat(agent): migrate tool prompts to capability slots 2026-04-24 19:36:46 +08:00
Mauro 8d51d306b3 Merge pull request #2641 from afjcjsbx/feat/mcp-cli
feat(mcp): add show, add, list, remove, test, edit cli commands
2026-04-24 13:06:34 +02:00
Hoshina 2e65b1be83 feat(agent): add structured prompt layering 2026-04-24 18:14:28 +08:00
肆月 ccd19a48ce Fix Windows build flow (#2487)
* Fix Windows build flow

* build(makefile): make windows recipes shell-safe

- avoid backslash line-continuation in Windows build-launcher recipe

- replace cmd-specific if-not-exist with PowerShell check in web build-frontend

* Fix Windows build flow

* build(makefile): make windows recipes shell-safe

- avoid backslash line-continuation in Windows build-launcher recipe

- replace cmd-specific if-not-exist with PowerShell check in web build-frontend

* build(web): avoid shell-expanding powershell vars in windows recipe

- rewrite build-frontend Windows command without PowerShell local vars

- keep install-stamp hash check logic
2026-04-24 17:50:59 +08:00
afjcjsbx 07032df037 fix(mcp): normalize local command paths and document env-file usage 2026-04-24 10:32:55 +02:00
BeaconCat f334ac6d01 fix: treat PID=1 as stale in PID file singleton check, fix govet shadow, add .gitattributes (#2642)
- pid: When a container stops and leaves behind a PID file with PID 1
  on a shared volume, the host's init process (PID 1) passes the
  isProcessRunning check, blocking new gateway starts. Treat recorded
  PID 1 as always stale in both WritePidFile and ReadPidFileWithCheck.
  Added unit tests covering the PID=1 container leftover scenario.

- isolation: Fix govet shadow warning on platform_windows.go line 105
  where := shadows the outer err variable. Changed to = assignment.

- gitattributes: Enforce LF line endings for shell scripts to prevent
  CRLF issues when checking out on Windows (breaks Docker entrypoint).

Co-authored-by: BeaconCat <BeaconCat@users.noreply.github.com>
2026-04-24 15:26:34 +08:00
afjcjsbx f4dbac0dcf fix(mcp): expand home paths for local stdio server commands 2026-04-24 07:47:26 +02:00
Junghwan 293477b02a Keep launcher locale changes from mutating shared web-search routing (#2573)
The launcher wired UI language changes into a process-global backend
switch that changed auto web-search provider selection and the
reported current service for every handler in the same process.

This narrows the fix to the validated leak: remove backend sync from
frontend locale changes, drop the now-unused UI endpoint, and make
auto selection fall back to a stable default when the query itself
does not contain a script hint.

Constraint: Keep the patch small and mergeable without redesigning per-user preference storage
Rejected: Add per-user backend language state | larger scope than the validated bug and unclear maintainer preference
Rejected: Persist preferred language in config | still shares mutable state across clients of the same instance
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If locale-aware provider routing is reintroduced later, scope it to explicit config or request context instead of package-global state
Tested: go test ./web/backend/api ./pkg/tools -count=1; pnpm lint; pnpm build
Not-tested: Full make check; live multi-browser manual launcher run after the backend endpoint removal
2026-04-24 13:45:25 +08:00
dependabot[bot] 47a881b11f build(deps-dev): bump typescript-eslint in /web/frontend (#2638)
Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 8.58.2 to 8.59.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-version: 8.59.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 12:05:08 +08:00
dependabot[bot] 743d7e69f2 build(deps): bump github.com/larksuite/oapi-sdk-go/v3 (#2637)
Bumps [github.com/larksuite/oapi-sdk-go/v3](https://github.com/larksuite/oapi-sdk-go) from 3.5.3 to 3.5.4.
- [Release notes](https://github.com/larksuite/oapi-sdk-go/releases)
- [Changelog](https://github.com/larksuite/oapi-sdk-go/blob/v3_main/changelog.md)
- [Commits](https://github.com/larksuite/oapi-sdk-go/compare/v3.5.3...v3.5.4)

---
updated-dependencies:
- dependency-name: github.com/larksuite/oapi-sdk-go/v3
  dependency-version: 3.5.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 12:04:47 +08:00
dependabot[bot] 047a904b4f build(deps): bump github.com/rs/zerolog from 1.35.0 to 1.35.1 (#2635)
Bumps [github.com/rs/zerolog](https://github.com/rs/zerolog) from 1.35.0 to 1.35.1.
- [Commits](https://github.com/rs/zerolog/compare/v1.35.0...v1.35.1)

---
updated-dependencies:
- dependency-name: github.com/rs/zerolog
  dependency-version: 1.35.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 12:02:04 +08:00
dependabot[bot] 1dba8e9e91 build(deps-dev): bump vite from 8.0.8 to 8.0.10 in /web/frontend (#2634)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.8 to 8.0.10.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.10/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 11:59:54 +08:00
dependabot[bot] 73594a07ca build(deps): bump github.com/aws/aws-sdk-go-v2/config (#2633)
Bumps [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) from 1.32.14 to 1.32.16.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.32.14...config/v1.32.16)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.32.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 11:59:30 +08:00
dependabot[bot] ffd22c7fb6 build(deps): bump i18next from 26.0.3 to 26.0.7 in /web/frontend (#2632)
Bumps [i18next](https://github.com/i18next/i18next) from 26.0.3 to 26.0.7.
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.0.3...v26.0.7)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 11:55:19 +08:00
dependabot[bot] 39d7b3a63e build(deps): bump react-i18next from 17.0.3 to 17.0.4 in /web/frontend (#2631)
Bumps [react-i18next](https://github.com/i18next/react-i18next) from 17.0.3 to 17.0.4.
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.3...v17.0.4)

---
updated-dependencies:
- dependency-name: react-i18next
  dependency-version: 17.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 11:50:45 +08:00
Mauro 9fc72c1fb3 feat(tool-feedback): add separate message mode for chat feedback (#2644)
* feat(tool-feedback): add separate message mode for chat feedback

* add parameter in conf
2026-04-24 11:49:41 +08:00
美電球 0d1b041d74 Merge pull request #2485 from afjcjsbx/fix/telegram-oauth-links
fix(telegram): preserve raw OAuth links in HTML rendering
2026-04-24 11:35:16 +08:00
BeaconCat 9fba52d0fa ci: parallel macOS CGO launcher build, lowercase Docker tags, conditional Docker Hub login (#2643)
- Add build-macos-launcher job (runs on macOS, parallel with GoReleaser)
  that builds the CGO-enabled launcher with systray support, signs and
  notarizes it via rcodesign, then uploads as artifact.

- Add patch-macos-archives job (runs on cheap Linux runner, needs both
  GoReleaser and build-macos-launcher) that downloads the launcher
  artifact and darwin release archives, replaces the launcher binary,
  and re-uploads the patched archives.

- Fix Docker image tag errors: GITHUB_REPOSITORY_OWNER is immutable in
  GitHub Actions. Introduce REPO_OWNER (lowercase) in workflows and
  reference it in .goreleaser.yaml for GHCR image names and nfpms
  homepage.

- Make Docker Hub login conditional on DOCKERHUB_USERNAME secret being
  set, so forks without Docker Hub credentials don't fail.

- Make Docker Hub image in goreleaser conditional on DOCKERHUB_IMAGE_NAME
  being non-empty (empty image names are ignored by GoReleaser).

Verified on fork: both nightly and release workflows pass all jobs.
  Nightly:  https://github.com/BeaconCat/picoclaw/actions/runs/24848808843
  Release:  https://github.com/BeaconCat/picoclaw/actions/runs/24849753787

Co-authored-by: BeaconCat <BeaconCat@users.noreply.github.com>
2026-04-24 09:53:07 +08:00
Mauro f440047263 Merge pull request #2640 from sipeed/dependabot/go_modules/github.com/aws/aws-sdk-go-v2/service/bedrockruntime-1.50.5
build(deps): bump github.com/aws/aws-sdk-go-v2/service/bedrockruntime from 1.50.4 to 1.50.5
2026-04-23 19:32:21 +02:00
afjcjsbx 2da05c2ad3 feat(mcp): add show, add, list, remove, test, edit cli commands 2026-04-23 19:20:15 +02:00
dependabot[bot] ac4db35c0b build(deps): bump github.com/aws/aws-sdk-go-v2/service/bedrockruntime
Bumps [github.com/aws/aws-sdk-go-v2/service/bedrockruntime](https://github.com/aws/aws-sdk-go-v2) from 1.50.4 to 1.50.5.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/ssm/v1.50.4...service/ssm/v1.50.5)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/bedrockruntime
  dependency-version: 1.50.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-23 17:19:51 +00:00
美電球 0c0a582559 Merge pull request #2586 from kunalk16/fix-functions-deduplication
refactor(deduplication): functions deduplication in pkg/providers
2026-04-23 20:55:11 +08:00
wenjie cac4f21746 fix(tools): improve web search provider fallback (#2629)
- centralize web search provider readiness and resolution logic
- fall back when the configured provider is unavailable or invalid
- allow native-search-capable models to use built-in search without the client tool
- simplify the tools page and add direct access to web search settings
- add backend, agent, and integration tests for the new selection behavior
2026-04-23 15:39:16 +08:00
Kunal Karmakar 7616470137 Revert deduplication 2026-04-23 05:50:38 +00:00
Kunal Karmakar 4ae11406d2 Deduplicate further functions 2026-04-23 05:50:37 +00:00
Kunal Karmakar bc077db0ee Deduplicate ParseDataAudioURL function 2026-04-23 05:50:37 +00:00
Kunal Karmakar e901e70c14 Fix linting 2026-04-23 05:47:58 +00:00
Kunal Karmakar c71146b1d5 Functions deduplication 2026-04-23 05:47:58 +00:00
lxowalle 451db2f5d8 Feat(channels): unify animated tool feedback across chat channels and Pico (#2622)
* feat(channels): unify tool feedback animation across discord telegram and feishu

* fix(tool-feedback): unify fallback and single-message delivery

* fix(channels): finalize tool feedback in place

* fix ci

* feat: improve tool feedback

* fix review blockers in pico token cache and tool feedback

fix(provider): preserve function thought signatures

fix(feishu): recover tool feedback after edit fallback

* * delete dead code

* fix(pico): clean up tool feedback progress state

* fix ci

* fix(web): preserve tool feedback line breaks in chat

* fix(channels): preserve tool feedback progress state

fix(pico): preserve context usage when finalizing tool feedback

chore: record branch review pass

fix: preserve tool feedback finalization state

fix(web): handle pico history update fallback

* fix ci
2026-04-23 10:35:50 +08:00
Mauro 68ceb54b36 Merge pull request #2535 from afjcjsbx/feat/mcp-channel-commands
feat(commands): add MCP slash commands and tool details
2026-04-22 14:54:28 +02:00
wenjie f367a9c010 fix(web): use raw token for Pico media proxy and refresh chat attachment UI (#2618) 2026-04-22 15:14:20 +08:00
lxowalle 77b0c43392 refactor: support explicit provider field in model list entries (#2609)
* refactor: support explicit model list providers

* fix(web): preserve explicit model providers

* fix(web): preserve legacy provider prefixes on model updates

fix(models): normalize explicit provider-prefixed ids

fix(api): preserve legacy model updates across providers

fix(agent): preserve config identity for explicit provider refs

* fix ci
2026-04-22 11:28:47 +08:00
Mauro 3316ee6923 feat(web): download files on frontend (#2563)
* feat(web): download attachments in frontend

* fix: proxy pico media and force svg downloads

* feat(web): hide ephemeral media refs from persisted session history
2026-04-22 11:28:04 +08:00
Guoguo 023ca2e4c1 ci(release): split tag creation and release into separate workflows (#2614)
- Add `create-tag.yml`: creates annotated tag at a specified commit or
  latest main HEAD, with duplicate tag and commit validation
- Simplify `release.yml`: only accepts existing tags, removes create_tag
  toggle, validates tag via GitHub API before checkout
- Always checkout main branch (fetch-depth: 0 fetches full history),
  then create tag at the specified commit

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 10:07:14 +08:00
Mauro 279c496bb2 Merge pull request #2613 from sky5454/tmp_govet_fix
chore(isolation): fix govet shadow declaration of "err" shadows
2026-04-21 18:30:48 +02:00
sky5454 d0507df894 chore(isolation): fix govet shadow declaration of "err" shadows 2026-04-21 23:23:50 +08:00
afjcjsbx 175682f152 chore: refresh PR mergeability 2026-04-21 11:01:04 +02:00
afjcjsbx 5a13616b64 fix(mcp): surface MCP init failures to command handlers 2026-04-21 11:01:04 +02:00
afjcjsbx e5a6960078 fix lint 2026-04-21 11:01:04 +02:00
afjcjsbx 276f5425f0 feat(commands): add MCP slash commands and tool details 2026-04-21 11:01:04 +02:00
xiaoen a34120b821 test(agent): assert child turn uses target agent model
Replace generic mockProvider with modelRecordingProvider that captures
the model parameter passed to Chat(). After delegation from alpha to
beta, assert the recorded model is "model-beta" — proving the child
turn actually ran with the target agent's configuration, not the
caller's.

Also add wiring tests:
- TestDelegateToolNotRegistered_SingleAgent: single-agent has no
  delegate in its tool registry
- TestDelegateToolRegistered_MultiAgent: both agents in a two-agent
  setup have the delegate tool

Ref: #2148
2026-04-15 22:27:05 +08:00
xiaoen 6ee66123f2 refactor(agent): simplify delegate registration gate
Remove the IsToolEnabled("delegate") check — there is no "delegate"
entry in ToolsConfig, so the check was always true. The only real
gate is len(agents) > 1, which is the intended behavior: delegate
is auto-registered in multi-agent setups.

Ref: #2148
2026-04-15 22:24:47 +08:00
xiaoen 6db17b8211 test(tools): verify normalization prevents self-delegation bypass
Add table-driven test with case and whitespace variants (ALPHA,
" Alpha ", "  alpha  ") that should all be caught by the self-check
after normalization.

Ref: #2148
2026-04-15 22:23:47 +08:00
xiaoen df486b9939 fix(tools): normalize agent_id before self-check and delegation
Apply routing.NormalizeAgentID to the raw agent_id input before any
logic runs. This prevents case/whitespace variants like "ALPHA" or
" alpha " from bypassing the self-delegation guard while still
resolving to the same agent in the registry.

The normalized value is used consistently for self-check, allowlist,
SpawnSubTurn, and result attribution.

Ref: #2148
2026-04-15 22:23:17 +08:00
ex-takashima 5b0c9e2708 Merge remote-tracking branch 'origin/main' into refactor/line-sdk
# Conflicts:
#	pkg/channels/line/line.go
2026-04-15 23:07:04 +09:00
xiaoen 039f35563e feat(agent): wire delegate tool registration for multi-agent setups
Register the delegate tool in registerSharedTools when multiple agents
are configured. Gated independently from the subagent tool — delegate
uses SubTurn directly and does not depend on SubagentManager.

Self-delegation is prevented by injecting the current agent ID.
Permission is enforced via CanSpawnSubagent (reuses allow_agents config).

Single-agent setups are unaffected: the tool is not registered when
only one agent exists in the registry.

Ref: #2148
2026-04-15 21:29:29 +08:00
xiaoen 0ff78fa53f test(tools): add delegate tool unit tests
12 test cases covering:
- success path with result attribution
- agent_id validation (missing, empty, whitespace, wrong type)
- task validation (missing, empty, whitespace)
- permission denied / allowed via allowlist checker
- self-delegation blocked
- nil spawner, spawner error, nil result from spawner
- open access when no allowlist checker is set

Ref: #2148
2026-04-15 21:28:54 +08:00
xiaoen 484ef399f1 feat(tools): add delegate tool for synchronous cross-agent task handoff
delegate(agent_id, task) hands off a task to a named agent and blocks
until the result is ready. The target agent runs with its own config
via the TargetAgentID mechanism in SubTurnConfig.

Key behaviors:
- Self-delegation explicitly rejected
- Permission gated by subagents.allow_agents (D4)
- Spawner errors preserve the underlying error via WithError
- Nil result from spawner handled gracefully
- Response attributed with target agent ID

Ref: #2148
2026-04-15 21:28:31 +08:00
xiaoen c8335bfd47 test(agent): verify TargetAgentID resolves to correct agent instance
Add multi-agent test setup (newMultiAgentLoop) with two agents using
distinct models (model-alpha, model-beta).

Three new tests:
- UsesTargetAgent: parent=alpha delegates to beta, event log confirms
  child runs as agent_id=beta with model=model-beta
- NotFound: TargetAgentID pointing to nonexistent agent returns error
- EmptyModelAccepted: empty Model field accepted when TargetAgentID
  provides the model implicitly

Ref: #2148
2026-04-15 21:27:39 +08:00
xiaoen c47f5fd2c4 feat(agent): add TargetAgentID to SubTurnConfig for cross-agent delegation
When TargetAgentID is set, spawnSubTurn resolves the target AgentInstance
from the registry and uses it as the base for the child turn. This gives
the child turn the target's workspace, model, tools, and system prompt
instead of inheriting from the caller.

Model validation is relaxed: empty Model is accepted when TargetAgentID
provides the model implicitly via the resolved agent instance.

Ref: #2148
2026-04-15 21:27:13 +08:00
afjcjsbx 34b9d5d6fa fix(telegram): preserve raw OAuth links in HTML rendering 2026-04-12 10:44:09 +02:00
ex-takashima fe51cd504f refactor(line): use official LINE Bot SDK v8
Replace hand-rolled HTTP/HMAC/JSON code (~270 lines) with the official
line-bot-sdk-go v8, reducing maintenance burden and eliminating potential
bugs in signature verification, request construction, and response parsing.

This continues the work started in #500 by @xiaket, addressing all review
feedback and rebasing onto current main.

Changes:
- Replace bytes/crypto/json/io imports with line-bot-sdk-go/v8
- Use webhook.ParseRequest for body reading + signature verification
- Use messaging_api.MessagingApiAPI for ReplyMessage/PushMessage/ShowLoadingAnimation/GetBotInfo
- Type-switch on webhook.MessageEvent message types (TextMessageContent,
  ImageMessageContent, etc.) instead of JSON unmarshalling
- Type-switch on webhook.SourceInterface (UserSource/GroupSource/RoomSource)
- Type-switch on webhook.Mentionee (UserMentionee/AllMentionee)

Review feedback addressed (from #500):
- Use WithContext(ctx) on all SDK calls to preserve cancellation/timeout
- Fix variable shadowing of isMentioned (declared at function scope)
- Remove reflect-based message ID extraction (use type switch + msg.Id)
- Use mentionee.IsSelf for cleaner bot mention detection
- Preserve body size security check via http.MaxBytesReader before
  webhook.ParseRequest (compatible with #1413)

All existing tests pass without modification.
2026-04-08 00:38:55 +09:00
afjcjsbx 765a165475 fix(agent): warn on unknown frontmatter capabilities 2026-03-29 23:48:06 +02:00
afjcjsbx abeb2d8e0a fix(agent): fall back to first AGENT line for discovery 2026-03-29 23:44:41 +02:00
afjcjsbx f5f1dc9808 fix(agent): load only allowed MCP servers 2026-03-29 23:43:35 +02:00
afjcjsbx 409251e69d fix(agent): fail closed on invalid AGENT frontmatter 2026-03-29 23:41:32 +02:00
afjcjsbx 847218ef29 refactor(agent): added mcp allowlist 2026-03-29 23:22:47 +02:00
afjcjsbx 0ef25f779e refactor(agent): move delegation details out of discovery prompt 2026-03-29 22:57:57 +02:00
afjcjsbx 6429f6af9a refactor(agent): source discovery identity from AGENT.md frontmatter 2026-03-29 22:43:20 +02:00
afjcjsbx bca131909d fix lint 2026-03-29 14:27:22 +02:00
afjcjsbx 07748bf076 chore: revert unrelated golines formatting 2026-03-29 14:06:19 +02:00
afjcjsbx 3b173c0bee feat(agent): add multi-agent discovery prompt and per-agent 2026-03-29 13:58:19 +02:00
537 changed files with 72604 additions and 7610 deletions
+2 -1
View File
@@ -1,3 +1,5 @@
# Do NOT exclude LICENSE or .github — scripts/copydir.go uses them as repo-root anchors
# during `go generate`, which runs inside `make build` in the Dockerfile.
.git
.gitignore
build/
@@ -6,5 +8,4 @@ config/
.env
.env.example
*.md
LICENSE
assets/
+3
View File
@@ -0,0 +1,3 @@
# Ensure shell scripts always use LF line endings regardless of OS.
*.sh text eol=lf
docker/entrypoint.sh text eol=lf
+11
View File
@@ -5,7 +5,18 @@ on:
branches: [ "main" ]
jobs:
integration:
name: Integration Tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Run Docker-backed integration suites
run: bash ./scripts/run-integration-tests.sh
build:
needs: integration
runs-on: ubuntu-latest
steps:
- name: Checkout
+60
View File
@@ -0,0 +1,60 @@
name: Create Tag
on:
workflow_dispatch:
inputs:
tag:
description: "Tag name (required, e.g. v0.2.0)"
required: true
type: string
commit:
description: "Target commit SHA (leave empty for latest main)"
required: false
type: string
default: ""
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
ref: main
- name: Validate commit exists
if: ${{ inputs.commit != '' }}
shell: bash
run: |
if ! git cat-file -t "${{ inputs.commit }}" &>/dev/null; then
echo "::error::Commit '${{ inputs.commit }}' does not exist."
exit 1
fi
- name: Check tag does not already exist
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if gh api "repos/${{ github.repository }}/git/ref/tags/${{ inputs.tag }}" --silent 2>/dev/null; then
echo "::error::Tag '${{ inputs.tag }}' already exists."
exit 1
fi
- name: Create and push tag
shell: bash
run: |
TARGET="${{ inputs.commit || 'HEAD' }}"
COMMIT_SHA=$(git rev-parse "$TARGET")
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "${{ inputs.tag }}" "$COMMIT_SHA" -m "Release ${{ inputs.tag }}"
git push origin "${{ inputs.tag }}"
echo "### Tag Created" >> "$GITHUB_STEP_SUMMARY"
echo "- **Tag:** \`${{ inputs.tag }}\`" >> "$GITHUB_STEP_SUMMARY"
echo "- **Commit:** \`${COMMIT_SHA}\`" >> "$GITHUB_STEP_SUMMARY"
echo "- **Branch:** \`$(git branch -r --contains "$COMMIT_SHA" | head -1 | xargs)\`" >> "$GITHUB_STEP_SUMMARY"
+10
View File
@@ -64,3 +64,13 @@ jobs:
- name: Run go test
run: go test -tags goolm,stdjson ./...
integration:
name: Integration Tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Run Docker-backed integration suites
run: bash ./scripts/run-integration-tests.sh
+12 -24
View File
@@ -1,10 +1,10 @@
name: Create Tag and Release
name: Release
on:
workflow_dispatch:
inputs:
tag:
description: "Release tag (required, e.g. v0.2.0)"
description: "Existing tag to release (e.g. v0.2.0)"
required: true
type: string
prerelease:
@@ -24,35 +24,23 @@ on:
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: Verify tag exists
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if ! gh api "repos/${{ github.repository }}/git/ref/tags/${{ inputs.tag }}" --silent 2>/dev/null; then
echo "::error::Tag '${{ inputs.tag }}' does not exist. Create it first using the 'Create Tag' workflow."
exit 1
fi
- name: Checkout tag
uses: actions/checkout@v6
with:
+64
View File
@@ -0,0 +1,64 @@
name: Close stale issues and PRs
on:
schedule:
# Run daily at 03:00 JST (18:00 UTC)
- cron: "0 18 * * *"
workflow_dispatch:
permissions:
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- name: Mark and close stale issues and PRs
uses: actions/stale@v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
# ── Issue: 7 days inactive → stale; 7 more days → close ──
days-before-issue-stale: 7
days-before-issue-close: 7
stale-issue-label: "stale"
stale-issue-message: >
This issue has had no activity for 7 days and has been marked as stale.
If it is still relevant, please reply or update; otherwise it will be
closed automatically in 7 days.
close-issue-message: >
This issue has been closed after 14 days of inactivity.
If it is still needed, feel free to reopen it anytime.
close-issue-reason: "not_planned"
# ── PR: 7 days inactive → stale; 7 more days → close ──
days-before-pr-stale: 7
days-before-pr-close: 7
stale-pr-label: "stale"
stale-pr-message: >
This PR has had no activity for 7 days and has been marked as stale.
If you are still working on it, please push an update or leave a comment;
otherwise it will be closed automatically in 7 days.
close-pr-message: >
This PR has been closed after 14 days of inactivity.
If you would like to continue, feel free to reopen it or submit a new PR.
# ── Protected labels (exempt from stale processing) ──
exempt-issue-labels: "pinned,keep-open,wip,do-not-close,type: roadmap"
exempt-pr-labels: "pinned,keep-open,wip,do-not-close,type: roadmap"
# ── Exempt draft PRs ──
exempt-draft-pr: true
# ── Remove stale label when activity resumes ──
remove-stale-when-updated: true
remove-issue-stale-when-updated: true
remove-pr-stale-when-updated: true
# ── Scan oldest items first so old stale items are not starved ──
ascending: true
# ── Throttle: max operations per run ──
operations-per-run: 500
+4
View File
@@ -55,6 +55,10 @@ dist/
# Windows Application Icon/Resource
*.syso
.cache/
web/frontend/.pnpm-store/
_tmp_*
web/frontend/_tmp_*
# Test telegram integration
cmd/telegram/
-46
View File
@@ -100,49 +100,6 @@ builds:
- goos: netbsd
goarch: arm
- id: picoclaw-launcher-tui
binary: picoclaw-launcher-tui
env:
- CGO_ENABLED=0
tags:
- goolm
- 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={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }}
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
@@ -166,7 +123,6 @@ dockers_v2:
ids:
- picoclaw
- picoclaw-launcher
- picoclaw-launcher-tui
images:
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
- 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
@@ -184,7 +140,6 @@ notarize:
ids:
- picoclaw
- picoclaw-launcher
- picoclaw-launcher-tui
sign:
certificate: "{{.Env.MACOS_SIGN_P12}}"
password: "{{.Env.MACOS_SIGN_PASSWORD}}"
@@ -215,7 +170,6 @@ nfpms:
ids:
- picoclaw
- picoclaw-launcher
- picoclaw-launcher-tui
package_name: picoclaw
file_name_template: >-
{{ .PackageName }}_
+3
View File
@@ -73,10 +73,13 @@ make check # Full pre-commit check: deps + fmt + vet + test + docs consist
```bash
make test # Run all tests
make integration-test # Run Docker-backed integration suites
go test -run TestName -v ./pkg/session/ # Run a single test
go test -bench=. -benchmem -run='^$' ./... # Run benchmarks
```
Docker-backed integration suites are auto-discovered from [`integration/suites/`](integration/suites/). See [`integration/README.md`](integration/README.md) for the suite layout and the conventions used by CI.
### Code Style
```bash
+98 -19
View File
@@ -1,4 +1,4 @@
.PHONY: all build install uninstall clean help test build-all lint-docs
.PHONY: all build install uninstall clean help test integration-test build-all lint-docs
# Build variables
BINARY_NAME=picoclaw
@@ -7,19 +7,43 @@ CMD_DIR=cmd/$(BINARY_NAME)
MAIN_GO=$(CMD_DIR)/main.go
EXT=
ifeq ($(OS),Windows_NT)
POWERSHELL=powershell -NoProfile -Command
WINDOWS_GOARCH_RAW:=$(strip $(shell go env GOARCH 2>NUL))
endif
# 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}')
ifeq ($(OS),Windows_NT)
VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>NUL))
GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>NUL))
BUILD_TIME_RAW:=$(strip $(shell powershell -NoProfile -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK'"))
GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>NUL))
else
VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>/dev/null))
GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>/dev/null))
BUILD_TIME_RAW:=$(strip $(shell date +%FT%T%z))
GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>/dev/null))
endif
VERSION?=$(if $(VERSION_RAW),$(VERSION_RAW),dev)
GIT_COMMIT=$(if $(GIT_COMMIT_RAW),$(GIT_COMMIT_RAW),dev)
BUILD_TIME=$(if $(BUILD_TIME_RAW),$(BUILD_TIME_RAW),dev)
GO_VERSION=$(if $(GO_VERSION_RAW),$(GO_VERSION_RAW),unknown)
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
GO?=go
WEB_GO?=$(GO)
CGO_ENABLED?=0
GO_BUILD_TAGS?=goolm,stdjson
GOFLAGS?=-v -tags $(GO_BUILD_TAGS)
GOCACHE?=$(CURDIR)/.cache/go-build
GOMODCACHE?=$(CURDIR)/.cache/go-mod
GOTOOLCHAIN?=local
export CGO_ENABLED
export GOCACHE
export GOMODCACHE
export GOTOOLCHAIN
comma:=,
empty:=
space:=$(empty) $(empty)
@@ -73,8 +97,21 @@ BUILTIN_SKILLS_DIR=$(CURDIR)/skills
LNCMD=ln -sf
# OS detection
UNAME_S?=$(shell uname -s)
UNAME_M?=$(shell uname -m)
ifeq ($(OS),Windows_NT)
UNAME_S=Windows
ifeq ($(WINDOWS_GOARCH_RAW),amd64)
UNAME_M=x86_64
else ifeq ($(WINDOWS_GOARCH_RAW),arm64)
UNAME_M=arm64
else ifeq ($(WINDOWS_GOARCH_RAW),386)
UNAME_M=x86
else
UNAME_M=$(if $(WINDOWS_GOARCH_RAW),$(WINDOWS_GOARCH_RAW),x86_64)
endif
else
UNAME_S?=$(shell uname -s)
UNAME_M?=$(shell uname -m)
endif
# Platform-specific settings
ifeq ($(UNAME_S),Linux)
@@ -122,6 +159,30 @@ else
endif
ifeq ($(OS),Windows_NT)
PLATFORM=windows
ifeq ($(UNAME_M),x86_64)
ARCH?=amd64
else ifeq ($(UNAME_M),arm64)
ARCH?=arm64
else
ARCH?=$(UNAME_M)
endif
EXT=.exe
endif
ifneq ($(strip $(GOOS)),)
PLATFORM:=$(GOOS)
endif
ifneq ($(strip $(GOARCH)),)
ARCH:=$(GOARCH)
endif
ifeq ($(PLATFORM),windows)
EXT=.exe
endif
BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH)
# Default target
@@ -130,41 +191,51 @@ all: build
## generate: Run generate
generate:
@echo "Run generate..."
ifeq ($(OS),Windows_NT)
@$(POWERSHELL) "if (Test-Path -LiteralPath './$(CMD_DIR)/workspace') { Remove-Item -LiteralPath './$(CMD_DIR)/workspace' -Recurse -Force }"
@$(POWERSHELL) "$$env:GOOS=''; $$env:GOARCH=''; $(GO) generate ./..."
else
@rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true
@$(GO) generate ./...
@GOOS=$$($(GO) env GOHOSTOS) GOARCH=$$($(GO) env GOHOSTARCH) $(GO) generate ./...
endif
@echo "Run generate complete"
## build: Build the picoclaw binary for current platform
build: generate
@echo "Building $(BINARY_NAME)$(EXT) for $(PLATFORM)/$(ARCH)..."
ifeq ($(OS),Windows_NT)
@$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null"
@$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH)$(EXT) ./$(CMD_DIR)
@$(POWERSHELL) "Copy-Item -LiteralPath '$(BINARY_PATH)$(EXT)' -Destination '$(BUILD_DIR)/$(BINARY_NAME)$(EXT)' -Force"
else
@mkdir -p $(BUILD_DIR)
@GOARCH=${ARCH} $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH)$(EXT) ./$(CMD_DIR)
@GOOS=$(PLATFORM) GOARCH=$(ARCH) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH)$(EXT) ./$(CMD_DIR)
@echo "Build complete: $(BINARY_PATH)$(EXT)"
@$(LNCMD) $(BINARY_NAME)-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/$(BINARY_NAME)$(EXT)
endif
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)$(EXT)"
## build-launcher: Build the picoclaw-launcher (web console) binary
build-launcher:
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
ifeq ($(OS),Windows_NT)
@$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null"
@$(MAKE) -C web build PLATFORM="$(PLATFORM)" ARCH="$(ARCH)" EXT="$(EXT)" OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)" GO_BUILD_TAGS="$(GO_BUILD_TAGS)"
@$(POWERSHELL) "Copy-Item -LiteralPath '$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)' -Destination '$(BUILD_DIR)/picoclaw-launcher$(EXT)' -Force"
else
@mkdir -p $(BUILD_DIR)
@GOARCH=${ARCH} $(MAKE) -C web build \
@GOOS=$(PLATFORM) GOARCH=$(ARCH) $(MAKE) -C web build \
OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)" \
WEB_GO='$(WEB_GO)' \
GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \
LDFLAGS='$(LDFLAGS)'
@$(LNCMD) picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/picoclaw-launcher$(EXT)
endif
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher$(EXT)"
build-launcher-frontend:
@$(MAKE) -C web build-frontend
## build-launcher-tui: Build the picoclaw-launcher TUI binary
build-launcher-tui:
@echo "Building picoclaw-launcher-tui for $(PLATFORM)/$(ARCH)..."
@mkdir -p $(BUILD_DIR)
@$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) ./cmd/picoclaw-launcher-tui
@ln -sf picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher-tui
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-tui"
## 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)..."
@@ -290,7 +361,11 @@ uninstall-all:
## clean: Remove build artifacts
clean:
@echo "Cleaning build artifacts..."
ifeq ($(OS),Windows_NT)
@$(POWERSHELL) "if (Test-Path -LiteralPath '$(BUILD_DIR)') { Remove-Item -LiteralPath '$(BUILD_DIR)' -Recurse -Force }"
else
@rm -rf $(BUILD_DIR)
endif
@echo "Clean complete"
## vet: Run go vet for static analysis
@@ -304,6 +379,10 @@ test: generate
@$(GO) test $(GOFLAGS) $$($(GO) list $(GOFLAGS) ./... | grep -v github.com/sipeed/picoclaw/web/)
@cd web && make test
## integration-test: Run Docker-backed integration test suites
integration-test:
@bash ./scripts/run-integration-tests.sh
## fmt: Format Go code
fmt:
@$(GOLANGCI_LINT) fmt
+33 -22
View File
@@ -56,6 +56,14 @@
## 📢 News
2026-05-11 🛒 **LicheeRV-Claw on AliExpress!** You can now purchase LicheeRV-Claw from [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), making it easier to try PicoClaw on compact RISC-V hardware.
<p align="center">
<a href="https://www.aliexpress.com/item/1005006519668532.html">
<img src="assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
</a>
</p>
2026-03-31 📱 **Android Support!** PicoClaw now runs on Android! Download the APK at [picoclaw.io](https://picoclaw.io/download)
2026-03-25 🚀 **v0.2.4 Released!** Agent architecture overhaul (SubTurn, Hooks, Steering, EventBus), WeChat/WeCom integration, security hardening (.security.yml, sensitive data filtering), new providers (AWS Bedrock, Azure, Xiaomi MiMo), and 35 bug fixes. PicoClaw has reached **26K Stars**!
@@ -291,24 +299,6 @@ After this one-time step, `picoclaw-launcher` will open normally on subsequent l
</details>
### 💻 TUI Launcher (Recommended for Headless / SSH)
The TUI (Terminal UI) Launcher provides a full-featured terminal interface for configuration and management. Ideal for servers, Raspberry Pi, and other headless environments.
```bash
picoclaw-launcher-tui
```
<p align="center">
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
</p>
**Getting started:**
Use the TUI menus to: **1)** Configure a Provider -> **2)** Configure a Channel -> **3)** Start the Gateway -> **4)** Chat!
For detailed TUI documentation, see [docs.picoclaw.io](https://docs.picoclaw.io).
<a id="-run-on-old-android-phones"></a>
### 📱 Android
@@ -465,7 +455,7 @@ For full provider configuration details, see [Providers & Models](docs/guides/pr
## 💬 Channels (Chat Apps)
Talk to your PicoClaw through 18+ messaging platforms:
Talk to your PicoClaw through 19+ messaging platforms:
| Channel | Setup | Protocol | Docs |
|---------|-------|----------|------|
@@ -483,6 +473,7 @@ Talk to your PicoClaw through 18+ messaging platforms:
| **VK** | Easy (group token) | Long Poll | [Guide](docs/channels/vk/README.md) |
| **IRC** | Medium (server + nick) | IRC protocol | [Guide](docs/guides/chat-apps.md#irc) |
| **OneBot** | Medium (WebSocket URL) | OneBot v11 | [Guide](docs/channels/onebot/README.md) |
| **MQTT** | Easy (broker + agent_id) | MQTT pub/sub | [Guide](docs/channels/mqtt/README.md) |
| **MaixCam** | Easy (enable) | TCP socket | [Guide](docs/channels/maixcam/README.md) |
| **Pico** | Easy (enable) | Native protocol | Built-in |
| **Pico Client** | Easy (WebSocket URL) | WebSocket | Built-in |
@@ -502,7 +493,8 @@ PicoClaw can search the web to provide up-to-date information. Configure in `too
| Search Engine | API Key | Free Tier | Link |
|--------------|---------|-----------|------|
| DuckDuckGo | Not needed | Unlimited | Built-in fallback |
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Required | 1000 queries/day | AI-powered, China-optimized |
| [Gemini Google Search](https://aistudio.google.com/apikey) | Required | Varies | Gemini with Google Search grounding |
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Required | 1500/month (daily allocation) | AI-powered, China-optimized |
| [Tavily](https://tavily.com) | Required | 1000 queries/month | Optimized for AI Agents |
| [Brave Search](https://brave.com/search/api) | Required | 2000 queries/month | Fast and private |
| [Perplexity](https://www.perplexity.ai) | Required | Paid | AI-powered search |
@@ -571,7 +563,20 @@ PicoClaw natively supports [MCP](https://modelcontextprotocol.io/) — connect a
}
```
For full MCP configuration (stdio, SSE, HTTP transports, Tool Discovery), see [Tools Configuration - MCP](docs/reference/tools_configuration.md#mcp-tool).
You can manage common MCP setups directly from the CLI instead of editing JSON by hand:
```bash
picoclaw mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem /tmp
picoclaw mcp list
picoclaw mcp test filesystem
```
`picoclaw mcp` is a configuration manager: it updates `config.json` under `tools.mcp.servers`, but it does not keep the server process running itself.
Use `picoclaw mcp edit` when you need advanced fields that are not covered by `picoclaw mcp add`.
For example, `picoclaw mcp add` supports `--deferred` and `--env-file`, while `picoclaw mcp edit` is still useful for direct JSON editing and uncommon MCP settings.
For full MCP configuration (stdio, SSE, HTTP transports, Tool Discovery), see [Tools Configuration - MCP](docs/reference/tools_configuration.md#mcp-tool). For CLI usage and examples, see [MCP Server CLI](docs/reference/mcp-cli.md).
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Join the Agent Social Network
@@ -591,6 +596,11 @@ Connect PicoClaw to the Agent Social Network simply by sending a single message
| `picoclaw status` | Show status |
| `picoclaw version` | Show version info |
| `picoclaw model` | View or switch the default model |
| `picoclaw mcp list` | List configured MCP servers |
| `picoclaw mcp add ...` | Add or update an MCP server entry |
| `picoclaw mcp test` | Probe a configured MCP server |
| `picoclaw mcp edit` | Open config for advanced MCP editing |
| `picoclaw mcp remove` | Remove an MCP server entry |
| `picoclaw cron list` | List all scheduled jobs |
| `picoclaw cron add ...` | Add a scheduled job |
| `picoclaw cron disable` | Disable a scheduled job |
@@ -617,8 +627,9 @@ For detailed guides beyond this README:
| Topic | Description |
|-------|-------------|
| [Docker & Quick Start](docs/guides/docker.md) | Docker Compose setup, Launcher/Agent modes |
| [Chat Apps](docs/guides/chat-apps.md) | All 17+ channel setup guides |
| [Chat Apps](docs/guides/chat-apps.md) | All 18+ channel setup guides |
| [Configuration](docs/guides/configuration.md) | Environment variables, workspace layout, security sandbox |
| [MCP Server CLI](docs/reference/mcp-cli.md) | Add, list, test, edit, and remove MCP server entries from the CLI |
| [Scheduled Tasks and Cron Jobs](docs/reference/cron.md) | Cron schedule types, deliver modes, command gates, job storage |
| [Providers & Models](docs/guides/providers.md) | 30+ LLM providers, model routing, model_list configuration |
| [Spawn & Async Tasks](docs/guides/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

After

Width:  |  Height:  |  Size: 327 KiB

-69
View File
@@ -1,69 +0,0 @@
# Picoclaw Launcher TUI
This directory contains the terminal-based TUI launcher for `picoclaw`.
It provides a lightweight, terminal-native user interface for managing, configuring, and interacting with the core `picoclaw` engine, without requiring a web browser or graphical environment.
## Architecture
The TUI launcher is implemented purely in Go with no external runtime dependencies:
* **`main.go`**: Application entry point, handles initialization and main event loop
* **`ui/`**: TUI interface components built on tview + tcell framework:
- `home.go`: Main dashboard with navigation menu
- `schemes.go`: AI model scheme management
- `users.go`: User and API key management for model providers
- `channels.go`: Communication channel (Telegram/Discord/WeChat etc.) configuration editor
- `gateway.go`: PicoClaw gateway daemon lifecycle management (start/stop/status)
- `app.go`: Core TUI application framework and navigation logic
- `models.go`: Data structures and state management
* **`config/`**: Configuration management layer, integrates with the core picoclaw configuration system
## Getting Started
### Prerequisites
* Go 1.25+
* Terminal with 256-color support (most modern terminals are compatible)
### Development
Run the TUI launcher directly in development mode:
```bash
# From project root
go run ./cmd/picoclaw-launcher-tui
# Or from this directory
go run .
```
### Build
Build the standalone TUI launcher binary:
```bash
# From project root (recommended)
make build-launcher-tui
# Output will be at:
# build/picoclaw-launcher-tui-<platform>-<arch>
# with symlink build/picoclaw-launcher-tui
# Or build directly from this directory
go build -o picoclaw-launcher-tui .
```
### Key Features
* 🖥️ Terminal-native interface - works over SSH, on headless servers, and in low-resource environments
* ⚙️ AI model scheme and API key management
* 📱 Communication channel configuration editor (Telegram/Discord/WeChat etc.)
* 🔄 PicoClaw gateway daemon management (start/stop/status monitoring)
* 💬 One-click launch of interactive AI chat session
* 🎯 Keyboard-first design with intuitive shortcuts
### Other Commands
```bash
# Run with custom config file path
go run . /path/to/custom/config.json
```
-236
View File
@@ -1,236 +0,0 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
// Package config provides types and I/O for ~/.picoclaw/tui.toml.
package config
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/BurntSushi/toml"
"github.com/sipeed/picoclaw/pkg/fileutil"
)
// DefaultConfigPath returns the default path to the tui.toml config file.
func DefaultConfigPath() string {
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
return filepath.Join(home, ".picoclaw", "tui.toml")
}
// TUIConfig is the top-level structure of ~/.picoclaw/tui.toml.
type TUIConfig struct {
Version string `toml:"version"`
Model Model `toml:"model"`
Provider Provider `toml:"provider"`
}
type Model struct {
Type string `toml:"type"` // "provider" (default) | "manual"
}
type Provider struct {
Schemes []Scheme `toml:"schemes"`
Users []User `toml:"users"`
Current ProviderCurrent `toml:"current"`
}
type Scheme struct {
Name string `toml:"name"` // unique key
BaseURL string `toml:"baseURL"` // required
Type string `toml:"type"` // "openai-compatible" (default) | "anthropic"
}
type User struct {
Name string `toml:"name"`
Scheme string `toml:"scheme"` // references Scheme.Name; (Name+Scheme) is unique
Type string `toml:"type"` // "key" (default) | "OAuth"
Key string `toml:"key"`
}
type ProviderCurrent struct {
Scheme string `toml:"scheme"` // references Scheme.Name
User string `toml:"user"` // references User.Name where User.Scheme == Scheme
Model string `toml:"model"` // from GET <baseURL>/models
}
// DefaultConfig returns a minimal valid TUIConfig.
func DefaultConfig() *TUIConfig {
return &TUIConfig{
Version: "1.0",
Model: Model{Type: "provider"},
Provider: Provider{
Schemes: []Scheme{},
Users: []User{},
Current: ProviderCurrent{},
},
}
}
// Load reads the TUI config from path. Returns a default config if the file does not exist.
func Load(path string) (*TUIConfig, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return DefaultConfig(), nil
}
if err != nil {
return nil, fmt.Errorf("failed to read config file %q: %w", path, err)
}
cfg := DefaultConfig()
if _, err := toml.Decode(string(data), cfg); err != nil {
return nil, fmt.Errorf("failed to parse config file %q: %w", path, err)
}
applyDefaults(cfg)
return cfg, nil
}
// Save writes cfg to path atomically (safe for flash / SD storage).
func Save(path string, cfg *TUIConfig) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
var buf bytes.Buffer
enc := toml.NewEncoder(&buf)
if err := enc.Encode(cfg); err != nil {
return fmt.Errorf("failed to encode config: %w", err)
}
if err := fileutil.WriteFileAtomic(path, buf.Bytes(), 0o600); err != nil {
return fmt.Errorf("failed to write config file %q: %w", path, err)
}
return nil
}
func applyDefaults(cfg *TUIConfig) {
if cfg.Version == "" {
cfg.Version = "1.0"
}
if cfg.Model.Type == "" {
cfg.Model.Type = "provider"
}
for i := range cfg.Provider.Schemes {
if cfg.Provider.Schemes[i].Type == "" {
cfg.Provider.Schemes[i].Type = "openai-compatible"
}
}
for i := range cfg.Provider.Users {
if cfg.Provider.Users[i].Type == "" {
cfg.Provider.Users[i].Type = "key"
}
}
}
// SchemeByName returns the first Scheme whose Name matches, or nil.
func (p *Provider) SchemeByName(name string) *Scheme {
for i := range p.Schemes {
if p.Schemes[i].Name == name {
return &p.Schemes[i]
}
}
return nil
}
// UsersForScheme returns all users whose Scheme field matches schemeName.
func (p *Provider) UsersForScheme(schemeName string) []User {
var out []User
for _, u := range p.Users {
if u.Scheme == schemeName {
out = append(out, u)
}
}
return out
}
// SyncSelectedModelToMainConfig syncs the currently selected model to ~/.picoclaw/config.json
// Adds/replaces a "tui-prefer" model entry and sets it as the default model.
// Preserves all other existing fields in the config file unchanged.
func SyncSelectedModelToMainConfig(scheme Scheme, user User, modelID string) error {
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
mainConfigPath := filepath.Join(home, ".picoclaw", "config.json")
var cfg map[string]any
if data, readErr := os.ReadFile(mainConfigPath); readErr == nil {
if unmarshalErr := json.Unmarshal(data, &cfg); unmarshalErr != nil {
cfg = make(map[string]any)
}
} else {
cfg = make(map[string]any)
}
if _, ok := cfg["agents"]; !ok {
cfg["agents"] = make(map[string]any)
}
agents, ok := cfg["agents"].(map[string]any)
if ok {
if _, ok := agents["defaults"]; !ok {
agents["defaults"] = make(map[string]any)
}
defaults, ok := agents["defaults"].(map[string]any)
if ok {
defaults["model"] = "tui-prefer"
}
}
tuiModel := map[string]any{
"model_name": "tui-prefer",
"model": modelID,
"api_key": user.Key,
"api_base": scheme.BaseURL,
}
modelList := []any{}
if ml, ok := cfg["model_list"].([]any); ok {
modelList = ml
}
found := false
for i, m := range modelList {
if entry, ok := m.(map[string]any); ok {
if name, ok := entry["model_name"].(string); ok && name == "tui-prefer" {
modelList[i] = tuiModel
found = true
break
}
}
}
if !found {
modelList = append(modelList, tuiModel)
}
cfg["model_list"] = modelList
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(mainConfigPath), 0o700); err != nil {
return err
}
return os.WriteFile(mainConfigPath, data, 0o600)
}
func (cfg *TUIConfig) CurrentModelLabel() string {
cur := cfg.Provider.Current
if cur.Model == "" {
return "(not configured)"
}
label := cur.Scheme
if label != "" {
label += " / "
}
return label + cur.Model
}
-48
View File
@@ -1,48 +0,0 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/ui"
)
func main() {
configPath := tuicfg.DefaultConfigPath()
if len(os.Args) > 1 {
configPath = os.Args[1]
}
configDir := filepath.Dir(configPath)
if _, err := os.Stat(configDir); os.IsNotExist(err) {
cmd := exec.Command("picoclaw", "onboard")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
}
cfg, err := tuicfg.Load(configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
os.Exit(1)
}
app := ui.New(cfg, configPath)
// Bind model selection hook to sync to main config
app.OnModelSelected = func(scheme tuicfg.Scheme, user tuicfg.User, modelID string) {
_ = tuicfg.SyncSelectedModelToMainConfig(scheme, user, modelID)
}
if err := app.Run(); err != nil {
fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
os.Exit(1)
}
}
-325
View File
@@ -1,325 +0,0 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"fmt"
"sync"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
)
// App is the root TUI application.
type App struct {
tapp *tview.Application
pages *tview.Pages
pageStack []string
cfg *tuicfg.TUIConfig
configPath string
pageRefreshFns map[string]func()
headerModelTV *tview.TextView
modalOpen map[string]bool
// OnModelSelected is called when a model is selected in the UI.
// Can be nil to disable.
OnModelSelected func(scheme tuicfg.Scheme, user tuicfg.User, modelID string)
modelCache map[string][]modelEntry
modelCacheMu sync.RWMutex
refreshMu sync.Mutex
}
// cacheKey returns the map key for a (scheme, user) pair.
func cacheKey(schemeName, userName string) string {
return fmt.Sprintf("%s/%s", schemeName, userName)
}
// cachedModels returns a defensive copy of the cached model list for a user (may be nil).
func (a *App) cachedModels(schemeName, userName string) []modelEntry {
a.modelCacheMu.RLock()
defer a.modelCacheMu.RUnlock()
entries := a.modelCache[cacheKey(schemeName, userName)]
return append([]modelEntry(nil), entries...)
}
// refreshModelCache fetches models for every user in the config concurrently.
// Serialized by refreshMu so concurrent calls don't race on the cache map.
// When all fetches complete it calls onDone via QueueUpdateDraw.
func (a *App) refreshModelCache(onDone func()) {
go func() {
a.refreshMu.Lock()
defer a.refreshMu.Unlock()
users := a.cfg.Provider.Users
schemes := a.cfg.Provider.Schemes
schemeURL := make(map[string]string, len(schemes))
for _, s := range schemes {
schemeURL[s.Name] = s.BaseURL
}
var wg sync.WaitGroup
for _, u := range users {
baseURL, ok := schemeURL[u.Scheme]
if !ok || baseURL == "" {
continue
}
if u.Key == "" {
a.modelCacheMu.Lock()
if a.modelCache == nil {
a.modelCache = make(map[string][]modelEntry)
}
a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
a.modelCacheMu.Unlock()
continue
}
wg.Add(1)
bURL := baseURL
go func() {
defer wg.Done()
entries, err := fetchModels(bURL, u.Key)
a.modelCacheMu.Lock()
if a.modelCache == nil {
a.modelCache = make(map[string][]modelEntry)
}
if err != nil || len(entries) == 0 {
a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
} else {
a.modelCache[cacheKey(u.Scheme, u.Name)] = entries
}
a.modelCacheMu.Unlock()
}()
}
wg.Wait()
if onDone != nil {
a.tapp.QueueUpdateDraw(onDone)
}
}()
}
// New creates and wires up the TUI application.
func New(cfg *tuicfg.TUIConfig, configPath string) *App {
// Cyberpunk Theme Colors
// Dark background
tview.Styles.PrimitiveBackgroundColor = tcell.NewHexColor(0x050510) // Deep Void
tview.Styles.ContrastBackgroundColor = tcell.NewHexColor(0x1a1a2e) // Dark Indigo
tview.Styles.MoreContrastBackgroundColor = tcell.NewHexColor(0x2a2a40)
// Borders and Titles
tview.Styles.BorderColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
tview.Styles.TitleColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
tview.Styles.GraphicsColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
// Text
tview.Styles.PrimaryTextColor = tcell.NewHexColor(0xe0e0e0) // Off-white
tview.Styles.SecondaryTextColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
tview.Styles.TertiaryTextColor = tcell.NewHexColor(0x39ff14) // Neon Lime
tview.Styles.InverseTextColor = tcell.NewHexColor(0x000000) // Black
tview.Styles.ContrastSecondaryTextColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
a := &App{
tapp: tview.NewApplication(),
pages: tview.NewPages(),
pageStack: []string{},
cfg: cfg,
configPath: configPath,
pageRefreshFns: make(map[string]func()),
modalOpen: make(map[string]bool),
}
a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
if len(a.modalOpen) > 0 {
return event
}
return a.goBack()
}
return event
})
a.buildPages()
return a
}
// Run starts the TUI event loop.
func (a *App) Run() error {
return a.tapp.SetRoot(a.pages, true).EnableMouse(true).Run()
}
func (a *App) buildPages() {
a.pages.AddPage("home", a.newHomePage(), true, true)
a.pageStack = []string{"home"}
}
func (a *App) navigateTo(name string, page tview.Primitive) {
a.pages.RemovePage(name)
a.pages.AddPage(name, page, true, false)
a.pageStack = append(a.pageStack, name)
a.pages.SwitchToPage(name)
}
func (a *App) goBack() *tcell.EventKey {
if len(a.pageStack) <= 1 {
return nil
}
popped := a.pageStack[len(a.pageStack)-1]
a.pageStack = a.pageStack[:len(a.pageStack)-1]
a.pages.RemovePage(popped)
prev := a.pageStack[len(a.pageStack)-1]
if fn, ok := a.pageRefreshFns[prev]; ok {
fn()
}
if prev == "home" && a.headerModelTV != nil {
a.headerModelTV.SetText(a.cfg.CurrentModelLabel() + " ")
}
a.pages.SwitchToPage(prev)
return nil
}
func (a *App) showModal(name string, primitive tview.Primitive) {
a.modalOpen[name] = true
a.pages.AddPage(name, primitive, true, true)
}
func (a *App) hideModal(name string) {
delete(a.modalOpen, name)
a.pages.HidePage(name)
a.pages.RemovePage(name)
}
func (a *App) save() {
if err := tuicfg.Save(a.configPath, a.cfg); err != nil {
a.showError("save failed: " + err.Error())
}
}
func (a *App) showError(msg string) {
modal := tview.NewModal().
SetText(" [red::b]ERROR[-::-]\n\n" + msg).
AddButtons([]string{"OK"}).
SetDoneFunc(func(_ int, _ string) {
a.hideModal("error")
})
// Cyberpunk Modal Style
modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red
modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
a.showModal("error", modal)
}
func (a *App) confirmDelete(label string, onConfirm func()) {
modal := tview.NewModal().
SetText(" [red::b]DELETE WARNING[-::-]\n\nDelete " + label + "?\n[gray]This action cannot be undone.[-]").
AddButtons([]string{"Delete", "Cancel"}).
SetDoneFunc(func(_ int, buttonLabel string) {
a.hideModal("confirm-delete")
if buttonLabel == "Delete" {
onConfirm()
}
})
// Cyberpunk Modal Style
modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red for danger
modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
a.showModal("confirm-delete", modal)
}
func centeredForm(form *tview.Form, widthPct, height int) tview.Primitive {
return tview.NewFlex().
AddItem(tview.NewBox(), 0, 1, false).
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(tview.NewBox(), 0, 1, false).
AddItem(form, height, 1, true).
AddItem(tview.NewBox(), 0, 1, false), 0, widthPct, true).
AddItem(tview.NewBox(), 0, 1, false)
}
func hintBar(text string) *tview.TextView {
tv := tview.NewTextView().
SetText(text).
SetDynamicColors(true).
SetTextAlign(tview.AlignCenter).
SetTextColor(tcell.NewHexColor(0x00f0ff)) // Neon Cyan
tv.SetBackgroundColor(tcell.NewHexColor(0x2a2a40)) // Darker Indigo
return tv
}
func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tview.Primitive {
var modelTV *tview.TextView
if pageID == "home" {
if a.headerModelTV == nil {
a.headerModelTV = tview.NewTextView()
a.headerModelTV.SetTextAlign(tview.AlignRight).
SetTextColor(tcell.NewHexColor(0x39ff14)). // Neon Lime
SetDynamicColors(true).
SetBackgroundColor(tcell.NewHexColor(0x050510))
}
modelTV = a.headerModelTV
modelTV.SetText("MODEL: " + a.cfg.CurrentModelLabel() + " ")
} else {
modelTV = tview.NewTextView()
modelTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
}
headerLeft := tview.NewTextView().
SetText(" [#ff00ff::b]///[#00f0ff] PICOCLAW LAUNCHER [#ff00ff]///").
SetDynamicColors(true).
SetBackgroundColor(tcell.NewHexColor(0x050510))
header := tview.NewFlex().
AddItem(headerLeft, 0, 1, false).
AddItem(modelTV, 0, 1, false)
sidebar := tview.NewTextView().
SetDynamicColors(true).
SetWrap(false)
sidebar.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
// Cyberpunk Sidebar Styling
activePrefix := "[#39ff14::b]>> " // Neon Lime arrow
activeSuffix := "[-]"
inactivePrefix := "[#808080] "
inactiveSuffix := "[-]"
sbText := "\n\n" // Top padding
menuItem := func(id, label string) string {
if pageID == id {
return activePrefix + label + activeSuffix + "\n\n"
}
return inactivePrefix + label + inactiveSuffix + "\n\n"
}
sbText += menuItem("home", "HOME")
sbText += menuItem("schemes", "SCHEMES")
sbText += menuItem("users", "USERS")
sbText += menuItem("models", "MODELS")
sbText += menuItem("channels", "CHANNELS")
sbText += menuItem("gateway", "GATEWAY")
sidebar.SetText(sbText)
footer := hintBar(hint)
grid := tview.NewGrid().
SetRows(1, 0, 1).
SetColumns(20, 0). // Slightly wider sidebar
AddItem(header, 0, 0, 1, 2, 0, 0, false).
AddItem(sidebar, 1, 0, 1, 1, 0, 0, false).
AddItem(content, 1, 1, 1, 1, 0, 0, true).
AddItem(footer, 2, 0, 1, 2, 0, 0, false)
// Add a border around the content area if possible, or ensure content has its own border
// grid.SetBorders(false) // Grid borders usually look bad, handled by components
return grid
}
-202
View File
@@ -1,202 +0,0 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"strconv"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
func (a *App) newChannelsPage() tview.Primitive {
list := tview.NewList()
list.SetBorder(true).
SetTitle(" [#00f0ff::b] COMMUNICATION CHANNELS ").
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
list.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0x050510)),
)
list.SetHighlightFullLine(true)
list.SetBackgroundColor(tcell.NewHexColor(0x050510))
rebuild := func() {
sel := list.GetCurrentItem()
list.Clear()
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
configPath := filepath.Join(home, ".picoclaw", "config.json")
var cfg map[string]any
if data, err := os.ReadFile(configPath); err == nil {
_ = json.Unmarshal(data, &cfg)
}
if chRaw, ok := cfg["channels"].(map[string]any); ok {
for name, ch := range chRaw {
chMap, ok := ch.(map[string]any)
enabled := "disabled"
if ok {
if e, ok := chMap["enabled"].(bool); ok && e {
enabled = "enabled"
}
}
list.AddItem(name, fmt.Sprintf("Status: %s", enabled), 0, func() {
a.showChannelEditForm(configPath, name, chMap)
})
}
}
if sel >= 0 && sel < list.GetItemCount() {
list.SetCurrentItem(sel)
}
}
rebuild()
a.pageRefreshFns["channels"] = rebuild
list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
return a.goBack()
}
return event
})
return a.buildShell("channels", list, " [#ff00ff]Enter:[-] edit [#ff2a2a]ESC:[-] back ")
}
func (a *App) showChannelEditForm(configPath, channelName string, existing map[string]any) {
form := tview.NewForm()
form.SetBorder(true).
SetTitle(" [::b]EDIT CHANNEL ").
SetTitleColor(tcell.NewHexColor(0x39ff14)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
fields := make(map[string]*tview.InputField)
var nameField *tview.InputField
if channelName == "" {
nameField = tview.NewInputField().
SetLabel("Channel Name").
SetText("").
SetFieldWidth(28)
form.AddFormItem(nameField)
}
for k, v := range existing {
if reflect.ValueOf(v).Kind() == reflect.Map || reflect.ValueOf(v).Kind() == reflect.Slice {
continue
}
valStr := fmt.Sprintf("%v", v)
field := tview.NewInputField().
SetLabel(k).
SetText(valStr).
SetFieldWidth(28)
form.AddFormItem(field)
fields[k] = field
}
form.AddButton("SAVE", func() {
var cfg map[string]any
if data, err := os.ReadFile(configPath); err == nil {
if err := json.Unmarshal(data, &cfg); err != nil {
cfg = make(map[string]any)
}
} else {
cfg = make(map[string]any)
}
if _, ok := cfg["channels"]; !ok {
cfg["channels"] = make(map[string]any)
}
channels, ok := cfg["channels"].(map[string]any)
if !ok {
channels = make(map[string]any)
cfg["channels"] = channels
}
finalName := channelName
if channelName == "" {
if nameField == nil || nameField.GetText() == "" {
a.showError("Channel name is required")
return
}
finalName = nameField.GetText()
}
updated := make(map[string]any)
if existing != nil {
for k, v := range existing {
updated[k] = v
}
}
for k, field := range fields {
val := field.GetText()
if val == "true" {
updated[k] = true
} else if val == "false" {
updated[k] = false
} else if num, err := strconv.Atoi(val); err == nil {
updated[k] = num
} else {
updated[k] = val
}
}
if channelName != "" && finalName != channelName {
delete(channels, channelName)
}
channels[finalName] = updated
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
a.showError(fmt.Sprintf("Failed to save config: %v", err))
return
}
if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
a.showError(fmt.Sprintf("Failed to create config directory: %v", err))
return
}
if err := os.WriteFile(configPath, data, 0o600); err != nil {
a.showError(fmt.Sprintf("Failed to write config: %v", err))
return
}
a.hideModal("channel-edit")
a.goBack()
})
form.AddButton("CANCEL", func() {
a.hideModal("channel-edit")
})
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
a.hideModal("channel-edit")
return nil
}
return event
})
a.showModal("channel-edit", centeredForm(form, 4, 20))
}
-229
View File
@@ -1,229 +0,0 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"fmt"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"github.com/sipeed/picoclaw/pkg/config"
ppid "github.com/sipeed/picoclaw/pkg/pid"
)
type gatewayStatus struct {
running bool
pid int
version string
}
func picoHome() string {
return config.GetHome()
}
func getGatewayStatus() gatewayStatus {
data := ppid.ReadPidFileWithCheck(picoHome())
if data == nil {
return gatewayStatus{running: false}
}
return gatewayStatus{
running: true,
pid: data.PID,
version: data.Version,
}
}
func startGateway() error {
status := getGatewayStatus()
if status.running {
return fmt.Errorf("gateway is already running (PID: %d)", status.pid)
}
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", "/C", "start /B picoclaw gateway > NUL 2>&1")
} else {
cmd = exec.Command("sh", "-c", "nohup picoclaw gateway > /dev/null 2>&1 &")
}
err := cmd.Start()
if err != nil {
return err
}
time.Sleep(1 * time.Second)
if runtime.GOOS == "windows" {
cmd := exec.Command(
"wmic",
"process",
"where",
"name='picoclaw.exe' and commandline like '%gateway%'",
"get",
"processid",
)
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to get gateway PID: %w", err)
}
lines := strings.Split(string(output), "\n")
for _, line := range lines[1:] {
line = strings.TrimSpace(line)
if line == "" {
continue
}
_, err := strconv.Atoi(line)
if err == nil {
break
}
}
}
status = getGatewayStatus()
if !status.running {
return fmt.Errorf("failed to start gateway")
}
return nil
}
func stopGateway() error {
status := getGatewayStatus()
if !status.running {
return fmt.Errorf("gateway is not running")
}
var err error
if runtime.GOOS == "windows" {
err = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(status.pid)).Run()
} else {
err = exec.Command("kill", strconv.Itoa(status.pid)).Run()
}
if err != nil {
return err
}
// Wait for process to stop (ReadPidFileWithCheck cleans up stale pid file)
for i := 0; i < 5; i++ {
if !getGatewayStatus().running {
break
}
time.Sleep(200 * time.Millisecond)
}
return nil
}
func (a *App) newGatewayPage() tview.Primitive {
flex := tview.NewFlex().SetDirection(tview.FlexRow)
flex.SetBorder(true).
SetTitle(" [#00f0ff::b] GATEWAY MANAGEMENT ").
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
flex.SetBackgroundColor(tcell.NewHexColor(0x050510))
statusTV := tview.NewTextView().
SetDynamicColors(true).
SetTextAlign(tview.AlignCenter).
SetText("Checking status...")
statusTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
var updateStatus func()
// 使用List作为按钮,保证显示和交互正常
buttons := tview.NewList()
buttons.SetBackgroundColor(tcell.NewHexColor(0x050510))
buttons.SetMainTextColor(tcell.ColorWhite)
buttons.SetSelectedBackgroundColor(tcell.NewHexColor(0xff00ff))
buttons.SetSelectedTextColor(tcell.ColorBlack)
buttons.AddItem(" [lime]START[white] ", "", 0, func() {
if !getGatewayStatus().running {
err := startGateway()
if err != nil {
a.showError(err.Error())
}
updateStatus()
}
})
buttons.AddItem(" [red]STOP[white] ", "", 0, func() {
if getGatewayStatus().running {
err := stopGateway()
if err != nil {
a.showError(err.Error())
}
updateStatus()
}
})
buttonFlex := tview.NewFlex().SetDirection(tview.FlexColumn)
buttonFlex.
AddItem(tview.NewBox(), 0, 1, false).
AddItem(buttons, 20, 1, true).
AddItem(tview.NewBox(), 0, 1, false)
flex.
AddItem(tview.NewBox(), 0, 1, false).
AddItem(statusTV, 3, 1, false).
AddItem(tview.NewBox(), 0, 1, false).
AddItem(buttonFlex, 4, 1, true).
AddItem(tview.NewBox(), 0, 1, false)
updateStatus = func() {
status := getGatewayStatus()
if status.running {
versionInfo := ""
if status.version != "" {
versionInfo = fmt.Sprintf("\nVersion: %s", status.version)
}
statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d%s", status.pid, versionInfo))
buttons.SetItemText(0, " [gray]START[white] ", "")
buttons.SetItemText(1, " [red]STOP[white] ", "")
} else {
statusTV.SetText("[#ff2a2a::b]GATEWAY STOPPED[-]\n\nPID: N/A")
buttons.SetItemText(0, " [lime]START[white] ", "")
buttons.SetItemText(1, " [gray]STOP[white] ", "")
}
}
updateStatus()
done := make(chan struct{})
go func() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
a.tapp.QueueUpdateDraw(updateStatus)
case <-done:
return
}
}
}()
originalInputCapture := flex.GetInputCapture()
flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
close(done)
return a.goBack()
}
if originalInputCapture != nil {
return originalInputCapture(event)
}
return event
})
a.pageRefreshFns["gateway"] = updateStatus
return a.buildShell("gateway", flex, " [#39ff14]Enter:[-] select [#ff2a2a]ESC:[-] back ")
}
-70
View File
@@ -1,70 +0,0 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"os"
"os/exec"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
func (a *App) newHomePage() tview.Primitive {
list := tview.NewList()
list.SetBorder(true).
SetTitle(" [#00f0ff::b] ACTIVE CONFIGURATION ").
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
list.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0x39ff14)).Foreground(tcell.NewHexColor(0x050510)),
)
list.SetHighlightFullLine(true)
list.SetBackgroundColor(tcell.NewHexColor(0x050510))
rebuildList := func() {
sel := list.GetCurrentItem()
list.Clear()
list.AddItem("MODEL: "+a.cfg.CurrentModelLabel(), "Select to configure AI model", 'm', func() {
a.navigateTo("schemes", a.newSchemesPage())
})
list.AddItem(
"CHANNELS: Configure communication channels",
"Manage Telegram/Discord/WeChat channels",
'n',
func() {
a.navigateTo("channels", a.newChannelsPage())
},
)
list.AddItem("GATEWAY MANAGEMENT", "Manage PicoClaw gateway daemon", 'g', func() {
a.navigateTo("gateway", a.newGatewayPage())
})
list.AddItem("CHAT: Start AI agent chat", "Launch interactive chat session", 'c', func() {
a.tapp.Suspend(func() {
cmd := exec.Command("picoclaw", "agent")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
})
})
list.AddItem("QUIT SYSTEM", "Exit PicoClaw Launcher", 'q', func() { a.tapp.Stop() })
if sel >= 0 && sel < list.GetItemCount() {
list.SetCurrentItem(sel)
}
}
rebuildList()
a.pageRefreshFns["home"] = rebuildList
return a.buildShell(
"home",
list,
" [#00f0ff]m:[-] model [#00f0ff]n:[-] channels [#00f0ff]g:[-] gateway [#00f0ff]c:[-] chat [#ff2a2a]q:[-] quit ",
)
}
-200
View File
@@ -1,200 +0,0 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
)
type modelsAPIResponse struct {
Data []modelEntry `json:"data"`
}
type modelEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitive {
table := tview.NewTable().
SetBorders(false).
SetSelectable(true, false).
SetFixed(0, 0)
table.SetBorder(true).
SetTitle(fmt.Sprintf(" [#00f0ff::b] MODELS · %s / %s ", schemeName, userName)).
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
table.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
)
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
var modelIDs []string
status := tview.NewTextView().
SetTextAlign(tview.AlignCenter).
SetDynamicColors(true).
SetText("[#ffff00]FETCHING MODELS...[-]")
status.SetBackgroundColor(tcell.NewHexColor(0x050510))
flex := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(status, 1, 0, false).
AddItem(table, 0, 1, false)
apiKey := a.resolveKey(schemeName, userName)
go func() {
var entries []modelEntry
var err error
if apiKey == "" {
err = fmt.Errorf("key is required")
} else {
entries, err = fetchModels(baseURL, apiKey)
}
a.modelCacheMu.Lock()
if a.modelCache == nil {
a.modelCache = make(map[string][]modelEntry)
}
if err == nil && len(entries) > 0 {
a.modelCache[cacheKey(schemeName, userName)] = entries
} else {
a.modelCache[cacheKey(schemeName, userName)] = nil
}
a.modelCacheMu.Unlock()
a.tapp.QueueUpdateDraw(func() {
if err != nil {
status.SetText(fmt.Sprintf("[#ff2a2a]ERROR: %s[-]", err.Error()))
table.SetCell(0, 0, tview.NewTableCell(" (failed to load models)"))
a.tapp.SetFocus(table)
return
}
if len(entries) == 0 {
status.SetText("[#ff2a2a]NO MODELS RETURNED[-]")
table.SetCell(0, 0, tview.NewTableCell(" (no models available)"))
a.tapp.SetFocus(table)
return
}
status.SetText(fmt.Sprintf("[#39ff14]%d MODEL(S) LOADED[-]", len(entries)))
for i, m := range entries {
modelIDs = append(modelIDs, m.ID)
table.SetCell(i, 0,
tview.NewTableCell(fmt.Sprintf("%3d", i+1)).
SetAlign(tview.AlignRight).
SetTextColor(tcell.NewHexColor(0x808080)).
SetSelectable(false),
)
table.SetCell(i, 1,
tview.NewTableCell(" "+m.ID).
SetAlign(tview.AlignLeft).
SetExpansion(1).
SetTextColor(tcell.NewHexColor(0xe0e0e0)),
)
}
a.tapp.SetFocus(table)
})
}()
table.SetSelectedFunc(func(row, _ int) {
if row < 0 || row >= len(modelIDs) {
return
}
a.cfg.Provider.Current = tuicfg.ProviderCurrent{
Scheme: schemeName,
User: userName,
Model: modelIDs[row],
}
a.save()
// Trigger model selected callback if set
if a.OnModelSelected != nil && a.cfg.Model.Type == "provider" {
scheme := a.cfg.Provider.SchemeByName(schemeName)
if scheme == nil {
a.goBack()
return
}
var user tuicfg.User
for _, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName && u.Name == userName {
user = u
break
}
}
a.OnModelSelected(*scheme, user, modelIDs[row])
}
a.goBack()
})
return a.buildShell("models", flex, " [#39ff14]Enter:[-] select [#ff00ff]ESC:[-] back ")
}
func (a *App) resolveKey(schemeName, userName string) string {
for _, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName && u.Name == userName {
return u.Key
}
}
return ""
}
func fetchModels(baseURL, apiKey string) ([]modelEntry, error) {
url := strings.TrimRight(baseURL, "/") + "/models"
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
var result modelsAPIResponse
if err := json.Unmarshal(body, &result); err == nil && len(result.Data) > 0 {
return result.Data, nil
}
var arr []modelEntry
if err := json.Unmarshal(body, &arr); err == nil {
return arr, nil
}
return nil, fmt.Errorf(
"decode response: unrecognized shape: %s",
strings.TrimSpace(string(body[:min(len(body), 256)])),
)
}
-252
View File
@@ -1,252 +0,0 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"fmt"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
)
func (a *App) newSchemesPage() tview.Primitive {
table := tview.NewTable().
SetBorders(false).
SetSelectable(true, false)
table.SetBorder(true).
SetTitle(" [#00f0ff::b] PROVIDER SCHEMES ").
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
table.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
)
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
rowToIdx := func(row int) int { return row / 2 }
selectedSchemeName := func() string {
row, _ := table.GetSelection()
idx := rowToIdx(row)
schemes := a.cfg.Provider.Schemes
if idx >= 0 && idx < len(schemes) {
return schemes[idx].Name
}
return ""
}
rebuild := func() {
selName := selectedSchemeName()
table.Clear()
schemes := a.cfg.Provider.Schemes
for i, s := range schemes {
nameRow := i * 2
detailRow := nameRow + 1
table.SetCell(nameRow, 0,
tview.NewTableCell(" "+s.Name).
SetTextColor(tcell.NewHexColor(0xe0e0e0)).
SetExpansion(1).
SetSelectable(true),
)
users := a.cfg.Provider.UsersForScheme(s.Name)
n := len(users)
m := 0
for _, u := range users {
if models := a.cachedModels(s.Name, u.Name); len(models) > 0 {
m++
}
}
table.SetCell(detailRow, 0,
tview.NewTableCell(fmt.Sprintf(" [#808080](%d/%d) %s", m, n, s.BaseURL)).
SetTextColor(tcell.NewHexColor(0x808080)).
SetExpansion(1).
SetSelectable(false),
)
table.SetCell(detailRow, 1,
tview.NewTableCell("[#00f0ff]"+s.Type+" ").
SetAlign(tview.AlignRight).
SetSelectable(false),
)
}
if selName != "" {
for i, s := range schemes {
if s.Name == selName {
table.Select(i*2, 0)
return
}
}
}
if table.GetRowCount() > 0 {
table.Select(0, 0)
}
}
rebuild()
a.refreshModelCache(rebuild)
a.pageRefreshFns["schemes"] = func() { a.refreshModelCache(rebuild) }
table.SetSelectedFunc(func(row, _ int) {
idx := rowToIdx(row)
schemes := a.cfg.Provider.Schemes
if idx < 0 || idx >= len(schemes) {
return
}
name := schemes[idx].Name
a.navigateTo("users", a.newUsersPage(name))
})
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
row, _ := table.GetSelection()
idx := rowToIdx(row)
schemes := a.cfg.Provider.Schemes
switch event.Rune() {
case 'a':
a.showSchemeForm(nil, func(s tuicfg.Scheme) {
a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s)
a.save()
a.refreshModelCache(rebuild)
})
return nil
case 'e':
if idx < 0 || idx >= len(schemes) {
return nil
}
origName := schemes[idx].Name
orig := schemes[idx]
a.showSchemeForm(&orig, func(s tuicfg.Scheme) {
current := a.cfg.Provider.Schemes
for i, sc := range current {
if sc.Name == origName {
a.cfg.Provider.Schemes[i] = s
break
}
}
a.save()
a.refreshModelCache(func() {
rebuild()
for i, sc := range a.cfg.Provider.Schemes {
if sc.Name == s.Name {
table.Select(i*2, 0)
break
}
}
})
})
return nil
case 'd':
if idx < 0 || idx >= len(schemes) {
return nil
}
name := schemes[idx].Name
a.confirmDelete(fmt.Sprintf("scheme %q", name), func() {
current := a.cfg.Provider.Schemes
newSchemes := make([]tuicfg.Scheme, 0, len(current))
for _, sc := range current {
if sc.Name != name {
newSchemes = append(newSchemes, sc)
}
}
a.cfg.Provider.Schemes = newSchemes
existing := a.cfg.Provider.Users
filtered := make([]tuicfg.User, 0, len(existing))
for _, u := range existing {
if u.Scheme != name {
filtered = append(filtered, u)
}
}
a.cfg.Provider.Users = filtered
a.save()
a.refreshModelCache(rebuild)
})
return nil
}
return event
})
return a.buildShell(
"schemes",
table,
" [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] open [#ff00ff]ESC:[-] back ",
)
}
func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) {
name := ""
baseURL := ""
schemeType := "openai-compatible"
title := " ADD SCHEME "
if existing != nil {
name = existing.Name
baseURL = existing.BaseURL
schemeType = existing.Type
title = " EDIT SCHEME "
}
typeOptions := []string{"openai-compatible", "anthropic"}
typeIdx := 0
for i, t := range typeOptions {
if t == schemeType {
typeIdx = i
break
}
}
form := tview.NewForm()
form.
AddInputField("Name", name, 20, nil, func(text string) { name = text }).
AddInputField("Base URL", baseURL, 28, nil, func(text string) { baseURL = text }).
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }).
AddButton("SAVE", func() {
if name == "" {
a.showError("Name is required")
return
}
if baseURL == "" {
a.showError("Base URL is required")
return
}
if existing == nil {
for _, s := range a.cfg.Provider.Schemes {
if s.Name == name {
a.showError(fmt.Sprintf("Scheme name %q already exists", name))
return
}
}
}
a.hideModal("scheme-form")
onSave(tuicfg.Scheme{Name: name, BaseURL: baseURL, Type: schemeType})
}).
AddButton("CANCEL", func() {
a.hideModal("scheme-form")
})
form.SetBorder(true).
SetTitle(" [::b]" + title + " ").
SetTitleColor(tcell.NewHexColor(0x39ff14)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
a.hideModal("scheme-form")
return nil
}
return event
})
a.showModal("scheme-form", centeredForm(form, 4, 12))
}
-261
View File
@@ -1,261 +0,0 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"fmt"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
)
func (a *App) newUsersPage(schemeName string) tview.Primitive {
table := tview.NewTable().
SetBorders(false).
SetSelectable(true, false)
table.SetBorder(true).
SetTitle(fmt.Sprintf(" [#00f0ff::b] USERS · %s ", schemeName)).
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
table.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
)
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
visibleUsers := func() []tuicfg.User {
var out []tuicfg.User
for _, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName {
out = append(out, u)
}
}
return out
}
findUserGlobalIdx := func(userName string) int {
for i, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName && u.Name == userName {
return i
}
}
return -1
}
rowToVisIdx := func(row int) int { return row / 2 }
selectedUserName := func() string {
row, _ := table.GetSelection()
users := visibleUsers()
visIdx := rowToVisIdx(row)
if visIdx >= 0 && visIdx < len(users) {
return users[visIdx].Name
}
return ""
}
rebuild := func() {
selName := selectedUserName()
table.Clear()
users := visibleUsers()
for i, u := range users {
nameRow := i * 2
detailRow := nameRow + 1
table.SetCell(nameRow, 0,
tview.NewTableCell(" "+u.Name).
SetTextColor(tcell.NewHexColor(0xe0e0e0)).
SetExpansion(1).
SetSelectable(true),
)
table.SetCell(nameRow, 1,
tview.NewTableCell("").
SetSelectable(false),
)
models := a.cachedModels(schemeName, u.Name)
var detailText string
if len(models) > 0 {
detailText = fmt.Sprintf(" [#39ff14]%d models available[-]", len(models))
} else {
detailText = " [#ff2a2a]Inactive / No Access[-]"
}
table.SetCell(detailRow, 0,
tview.NewTableCell(detailText).
SetTextColor(tcell.NewHexColor(0x808080)).
SetExpansion(1).
SetSelectable(false),
)
table.SetCell(detailRow, 1,
tview.NewTableCell("[#00f0ff]"+u.Type+" ").
SetAlign(tview.AlignRight).
SetSelectable(false),
)
}
if selName != "" {
for i, u := range users {
if u.Name == selName {
table.Select(i*2, 0)
return
}
}
}
if table.GetRowCount() > 0 {
table.Select(0, 0)
}
}
rebuild()
a.refreshModelCache(rebuild)
a.pageRefreshFns["users"] = func() { a.refreshModelCache(rebuild) }
table.SetSelectedFunc(func(row, _ int) {
visIdx := rowToVisIdx(row)
users := visibleUsers()
if visIdx < 0 || visIdx >= len(users) {
return
}
uName := users[visIdx].Name
scheme := a.cfg.Provider.SchemeByName(schemeName)
if scheme == nil {
a.showError(fmt.Sprintf("Scheme %q not found", schemeName))
return
}
a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL))
})
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
row, _ := table.GetSelection()
visIdx := rowToVisIdx(row)
users := visibleUsers()
switch event.Rune() {
case 'a':
a.showUserForm(schemeName, nil, func(u tuicfg.User) {
a.cfg.Provider.Users = append(a.cfg.Provider.Users, u)
a.save()
a.refreshModelCache(rebuild)
})
return nil
case 'e':
if visIdx < 0 || visIdx >= len(users) {
return nil
}
origName := users[visIdx].Name
orig := a.cfg.Provider.Users[findUserGlobalIdx(origName)]
a.showUserForm(schemeName, &orig, func(u tuicfg.User) {
cfgIdx := findUserGlobalIdx(origName)
if cfgIdx < 0 {
a.showError(fmt.Sprintf("User %q no longer exists", origName))
return
}
a.cfg.Provider.Users[cfgIdx] = u
a.save()
a.refreshModelCache(func() {
rebuild()
for i, usr := range visibleUsers() {
if usr.Name == u.Name {
table.Select(i*2, 0)
break
}
}
})
})
return nil
case 'd':
if visIdx < 0 || visIdx >= len(users) {
return nil
}
uName := users[visIdx].Name
a.confirmDelete(fmt.Sprintf("user %q", uName), func() {
cfgIdx := findUserGlobalIdx(uName)
if cfgIdx < 0 {
return
}
all := a.cfg.Provider.Users
a.cfg.Provider.Users = append(all[:cfgIdx], all[cfgIdx+1:]...)
a.save()
a.refreshModelCache(rebuild)
})
return nil
}
return event
})
return a.buildShell(
"users",
table,
" [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] models [#ff00ff]ESC:[-] back ",
)
}
func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) {
name := ""
userType := "key"
key := ""
title := " ADD USER "
if existing != nil {
name = existing.Name
userType = existing.Type
key = existing.Key
title = " EDIT USER "
}
typeOptions := []string{"key", "OAuth"}
typeIdx := 0
for i, t := range typeOptions {
if t == userType {
typeIdx = i
break
}
}
form := tview.NewForm()
form.
AddInputField("Name", name, 20, nil, func(text string) { name = text }).
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }).
AddPasswordField("Key", key, 28, '*', func(text string) { key = text }).
AddButton("SAVE", func() {
if name == "" {
a.showError("Name is required")
return
}
if existing == nil {
for _, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName && u.Name == name {
a.showError(fmt.Sprintf("User name %q already exists for this scheme", name))
return
}
}
}
a.hideModal("user-form")
onSave(tuicfg.User{Name: name, Scheme: schemeName, Type: userType, Key: key})
}).
AddButton("CANCEL", func() {
a.hideModal("user-form")
})
form.SetBorder(true).
SetTitle(" [::b]" + title + " ").
SetTitleColor(tcell.NewHexColor(0x39ff14)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
a.hideModal("user-form")
return nil
}
return event
})
a.showModal("user-form", centeredForm(form, 4, 13))
}
+20 -22
View File
@@ -59,7 +59,7 @@ func authLoginOpenAI(useDeviceCode bool, noBrowser bool) error {
// Update or add openai in ModelList
foundOpenAI := false
for i := range appCfg.ModelList {
if isOpenAIModel(appCfg.ModelList[i].Model) {
if isOpenAIModel(appCfg.ModelList[i]) {
appCfg.ModelList[i].AuthMethod = "oauth"
foundOpenAI = true
break
@@ -130,7 +130,7 @@ func authLoginGoogleAntigravity(noBrowser bool) error {
// Update or add antigravity in ModelList
foundAntigravity := false
for i := range appCfg.ModelList {
if isAntigravityModel(appCfg.ModelList[i].Model) {
if isAntigravityModel(appCfg.ModelList[i]) {
appCfg.ModelList[i].AuthMethod = "oauth"
foundAntigravity = true
break
@@ -206,7 +206,7 @@ func authLoginAnthropicSetupToken() error {
if err == nil {
found := false
for i := range appCfg.ModelList {
if isAnthropicModel(appCfg.ModelList[i].Model) {
if isAnthropicModel(appCfg.ModelList[i]) {
appCfg.ModelList[i].AuthMethod = "oauth"
found = true
break
@@ -282,7 +282,7 @@ func authLoginPasteToken(provider string) error {
// Update ModelList
found := false
for i := range appCfg.ModelList {
if isAnthropicModel(appCfg.ModelList[i].Model) {
if isAnthropicModel(appCfg.ModelList[i]) {
appCfg.ModelList[i].AuthMethod = "token"
found = true
break
@@ -300,7 +300,7 @@ func authLoginPasteToken(provider string) error {
// Update ModelList
found := false
for i := range appCfg.ModelList {
if isOpenAIModel(appCfg.ModelList[i].Model) {
if isOpenAIModel(appCfg.ModelList[i]) {
appCfg.ModelList[i].AuthMethod = "token"
found = true
break
@@ -342,15 +342,15 @@ func authLogoutCmd(provider string) error {
for i := range appCfg.ModelList {
switch provider {
case "openai":
if isOpenAIModel(appCfg.ModelList[i].Model) {
if isOpenAIModel(appCfg.ModelList[i]) {
appCfg.ModelList[i].AuthMethod = ""
}
case "anthropic":
if isAnthropicModel(appCfg.ModelList[i].Model) {
if isAnthropicModel(appCfg.ModelList[i]) {
appCfg.ModelList[i].AuthMethod = ""
}
case "google-antigravity", "antigravity":
if isAntigravityModel(appCfg.ModelList[i].Model) {
if isAntigravityModel(appCfg.ModelList[i]) {
appCfg.ModelList[i].AuthMethod = ""
}
}
@@ -484,22 +484,20 @@ func authModelsCmd() error {
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/")
// isAntigravityModel checks if a model config belongs to an Antigravity provider.
func isAntigravityModel(modelCfg *config.ModelConfig) bool {
protocol, _ := providers.ExtractProtocol(modelCfg)
return protocol == "antigravity" || protocol == "google-antigravity"
}
// isOpenAIModel checks if a model string belongs to openai provider
func isOpenAIModel(model string) bool {
return model == "openai" ||
strings.HasPrefix(model, "openai/")
// isOpenAIModel checks if a model config belongs to the OpenAI provider.
func isOpenAIModel(modelCfg *config.ModelConfig) bool {
protocol, _ := providers.ExtractProtocol(modelCfg)
return protocol == "openai"
}
// isAnthropicModel checks if a model string belongs to anthropic provider
func isAnthropicModel(model string) bool {
return model == "anthropic" ||
strings.HasPrefix(model, "anthropic/")
// isAnthropicModel checks if a model config belongs to the Anthropic provider.
func isAnthropicModel(modelCfg *config.ModelConfig) bool {
protocol, _ := providers.ExtractProtocol(modelCfg)
return protocol == "anthropic"
}
+16 -4
View File
@@ -3,6 +3,7 @@ package auth
import (
"bytes"
"context"
"net"
"net/http"
"net/http/httptest"
"net/url"
@@ -19,6 +20,19 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
)
func newIPv4TestServer(t *testing.T, handler http.Handler) *httptest.Server {
t.Helper()
server := httptest.NewUnstartedServer(handler)
listener, err := net.Listen("tcp4", "127.0.0.1:0")
require.NoError(t, err)
server.Listener = listener
server.Start()
t.Cleanup(server.Close)
return server
}
func TestNewWeComCommand(t *testing.T) {
cmd := newWeComCommand()
@@ -53,7 +67,7 @@ func TestBuildWeComQRCodePageURL(t *testing.T) {
}
func TestFetchWeComQRCode(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
server := newIPv4TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/generate", r.URL.Path)
assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("source"))
assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("sourceID"))
@@ -61,7 +75,6 @@ func TestFetchWeComQRCode(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"scode":"scode-1","auth_url":"https://example.com/qr"}}`))
}))
defer server.Close()
opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{
HTTPClient: server.Client(),
@@ -78,7 +91,7 @@ func TestFetchWeComQRCode(t *testing.T) {
func TestPollWeComQRCodeResult(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
server := newIPv4TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
call := calls.Add(1)
assert.Equal(t, "/query", r.URL.Path)
assert.Equal(t, "scode-1", r.URL.Query().Get("scode"))
@@ -92,7 +105,6 @@ func TestPollWeComQRCodeResult(t *testing.T) {
_, _ = w.Write([]byte(`{"data":{"status":"success","bot_info":{"botid":"bot-1","secret":"secret-1"}}}`))
}
}))
defer server.Close()
var output bytes.Buffer
opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{
+384
View File
@@ -0,0 +1,384 @@
package cliui
import (
"fmt"
"io"
"strings"
"github.com/charmbracelet/lipgloss"
)
// MCPShowServer holds the server metadata for PrintMCPShow.
type MCPShowServer struct {
Name string
Type string
Target string
Enabled bool
EffectiveDeferred bool // resolved value (per-server override or global default)
DeferredExplicit bool // true = per-server override set, false = inherited from global
EnvKeys []string // sorted env var names (values intentionally omitted)
EnvFile string
Headers []string // sorted header names
}
// MCPShowTool holds one tool's info for PrintMCPShow.
type MCPShowTool struct {
Name string
Description string
Parameters []MCPShowParam
}
// MCPShowParam is one parameter entry.
type MCPShowParam struct {
Name string
Type string
Description string
Required bool
}
// PrintMCPShow renders the mcp show output (plain or fancy).
// w is where the output is written; pass cmd.OutOrStdout() from cobra commands.
func PrintMCPShow(w io.Writer, server MCPShowServer, tools []MCPShowTool, disabled bool) {
if !UseFancyLayout() {
printMCPShowPlain(w, server, tools, disabled)
return
}
printMCPShowFancy(w, server, tools, disabled)
}
// ── plain (narrow / non-TTY) ────────────────────────────────────────────────
func printMCPShowPlain(w io.Writer, server MCPShowServer, tools []MCPShowTool, disabled bool) {
fmt.Fprintf(w, "Server: %s\n", server.Name)
fmt.Fprintf(w, "Type: %s\n", server.Type)
fmt.Fprintf(w, "Target: %s\n", server.Target)
fmt.Fprintf(w, "Enabled: %s\n", boolWord(server.Enabled))
deferredLabel := boolWord(server.EffectiveDeferred)
if !server.DeferredExplicit {
deferredLabel += " (default)"
}
fmt.Fprintf(w, "Deferred: %s\n", deferredLabel)
if len(server.EnvKeys) > 0 {
fmt.Fprintf(w, "Env vars: %s\n", strings.Join(server.EnvKeys, ", "))
}
if server.EnvFile != "" {
fmt.Fprintf(w, "Env file: %s\n", server.EnvFile)
}
if len(server.Headers) > 0 {
fmt.Fprintf(w, "Headers: %s\n", strings.Join(server.Headers, ", "))
}
fmt.Fprintln(w)
if disabled {
fmt.Fprintln(w, "Server is disabled; skipping tool discovery.")
return
}
if len(tools) == 0 {
fmt.Fprintln(w, "No tools exposed by this server.")
return
}
fmt.Fprintf(w, "Tools (%d):\n", len(tools))
for _, tool := range tools {
fmt.Fprintf(w, " %s\n", tool.Name)
if tool.Description != "" {
fmt.Fprintf(w, " %s\n", truncateDescription(tool.Description, 120))
}
if len(tool.Parameters) == 0 {
fmt.Fprintln(w, " Parameters: none")
continue
}
for _, p := range tool.Parameters {
line := fmt.Sprintf(" - %s", p.Name)
if p.Type != "" {
line += fmt.Sprintf(" (%s", p.Type)
if p.Required {
line += ", required"
}
line += ")"
} else if p.Required {
line += " (required)"
}
if p.Description != "" {
line += ": " + truncateDescription(p.Description, 80)
}
fmt.Fprintln(w, line)
}
}
}
// ── fancy (wide TTY) ────────────────────────────────────────────────────────
var (
mcpToolNameStyle = func() lipgloss.Style {
return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
}
mcpParamNameStyle = func() lipgloss.Style {
return lipgloss.NewStyle().Foreground(accentRed).Bold(true)
}
mcpTagStyle = func() lipgloss.Style {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
}
mcpRequiredStyle = func() lipgloss.Style {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#D54646")).Bold(true)
}
mcpOptionalStyle = func() lipgloss.Style {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#6B6B6B"))
}
mcpDescStyle = func() lipgloss.Style {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC"))
}
)
func printMCPShowFancy(w io.Writer, server MCPShowServer, tools []MCPShowTool, disabled bool) {
inner := InnerWidth()
box := borderStyle().Width(inner)
var b strings.Builder
// ── server header ──
b.WriteString(titleBarStyle().Render("⬡ " + server.Name))
b.WriteString("\n\n")
keyW := 10
writeKV := func(key, val string) {
k := kvKeyStyle().Width(keyW).Render(key)
b.WriteString(k + " " + val + "\n")
}
writeKV("Type", server.Type)
writeKV("Target", server.Target)
writeKV("Enabled", coloredBool(server.Enabled))
deferredVal := coloredBool(server.EffectiveDeferred)
if !server.DeferredExplicit {
deferredVal += " " + mcpTagStyle().Render("(default)")
}
writeKV("Deferred", deferredVal)
if len(server.EnvKeys) > 0 {
writeKV("Env vars", mutedStyle().Render(strings.Join(server.EnvKeys, ", ")))
}
if server.EnvFile != "" {
writeKV("Env file", mutedStyle().Render(server.EnvFile))
}
if len(server.Headers) > 0 {
writeKV("Headers", mutedStyle().Render(strings.Join(server.Headers, ", ")))
}
if disabled {
b.WriteString("\n")
b.WriteString(mutedStyle().Render("Server is disabled; skipping tool discovery."))
fmt.Fprintln(w, box.Render(b.String()))
return
}
if len(tools) == 0 {
b.WriteString("\n")
b.WriteString(mutedStyle().Render("No tools exposed by this server."))
fmt.Fprintln(w, box.Render(b.String()))
return
}
// ── tools section ──
b.WriteString("\n")
b.WriteString(kvKeyStyle().Render(fmt.Sprintf("Tools (%d)", len(tools))))
b.WriteString("\n")
contentW := inner - 4 // account for box padding
for i, tool := range tools {
if i > 0 {
b.WriteString(strings.Repeat("─", contentW) + "\n")
}
b.WriteString("\n")
// Tool name + index badge
badge := mcpTagStyle().Render(fmt.Sprintf("[%d/%d]", i+1, len(tools)))
b.WriteString(" " + mcpToolNameStyle().Render(tool.Name) + " " + badge + "\n")
// Description (wrapped to content width)
if tool.Description != "" {
desc := truncateDescription(tool.Description, 160)
b.WriteString(" " + mcpDescStyle().Render(desc) + "\n")
}
// Parameters
if len(tool.Parameters) == 0 {
b.WriteString(" " + mcpTagStyle().Render("no parameters") + "\n")
continue
}
b.WriteString("\n")
for _, p := range tool.Parameters {
// name
pName := mcpParamNameStyle().Render(p.Name)
// type tag
typeTag := ""
if p.Type != "" {
typeTag = " " + mcpTagStyle().Render("<"+p.Type+">")
}
// required / optional badge
var reqBadge string
if p.Required {
reqBadge = " " + mcpRequiredStyle().Render("required")
} else {
reqBadge = " " + mcpOptionalStyle().Render("optional")
}
b.WriteString(" " + pName + typeTag + reqBadge + "\n")
if p.Description != "" {
desc := truncateDescription(p.Description, 120)
b.WriteString(" " + mutedStyle().Render(desc) + "\n")
}
}
}
fmt.Fprintln(w, box.Render(b.String()))
}
// ── mcp list ────────────────────────────────────────────────────────────────
// MCPListRow is one row in the mcp list output.
type MCPListRow struct {
Name string
Type string
Target string
Status string // "enabled", "disabled", "ok (N tools)", "error"
EffectiveDeferred bool // resolved value (per-server override or global default)
DeferredExplicit bool // true = per-server override set, false = inherited from global
}
// PrintMCPList renders the mcp list output (plain or fancy).
func PrintMCPList(w io.Writer, rows []MCPListRow) {
if !UseFancyLayout() {
printMCPListPlain(w, rows)
return
}
printMCPListFancy(w, rows)
}
func printMCPListPlain(w io.Writer, rows []MCPListRow) {
headers := []string{"Name", "Type", "Command", "Status", "Deferred"}
tableRows := make([][]string, len(rows))
for i, r := range rows {
deferred := boolWord(r.EffectiveDeferred)
if !r.DeferredExplicit {
deferred += " (default)"
}
tableRows[i] = []string{r.Name, r.Type, r.Target, r.Status, deferred}
}
// reuse the ASCII table renderer already in helpers.go via the caller
// (list.go still uses renderTable for the plain path)
widths := make([]int, len(headers))
for i, h := range headers {
widths[i] = len(h)
}
for _, row := range tableRows {
for i, cell := range row {
if len(cell) > widths[i] {
widths[i] = len(cell)
}
}
}
border := func() {
fmt.Fprint(w, "+")
for _, width := range widths {
fmt.Fprint(w, strings.Repeat("-", width+2)+"+")
}
fmt.Fprintln(w)
}
writeRow := func(row []string) {
fmt.Fprint(w, "|")
for i, cell := range row {
fmt.Fprintf(w, " %s%s |", cell, strings.Repeat(" ", widths[i]-len(cell)))
}
fmt.Fprintln(w)
}
border()
writeRow(headers)
border()
for _, row := range tableRows {
writeRow(row)
}
border()
}
func printMCPListFancy(w io.Writer, rows []MCPListRow) {
inner := InnerWidth()
box := borderStyle().Width(inner)
var b strings.Builder
title := fmt.Sprintf("MCP Servers (%d)", len(rows))
b.WriteString(titleBarStyle().Render(title))
b.WriteString("\n")
contentW := inner - 4
for i, row := range rows {
if i > 0 {
b.WriteString(strings.Repeat("─", contentW) + "\n")
}
b.WriteString("\n")
statusBadge := mcpListStatusStyle(row.Status).Render(row.Status)
var deferredBadge string
if row.EffectiveDeferred {
if row.DeferredExplicit {
deferredBadge = " " + mcpTagStyle().Render("deferred")
} else {
deferredBadge = " " + mcpOptionalStyle().Render("deferred (default)")
}
}
b.WriteString(" " + mcpToolNameStyle().Render(row.Name) + " " + statusBadge + deferredBadge + "\n")
b.WriteString(" " + mcpTagStyle().Render(row.Type+" "+row.Target) + "\n")
}
fmt.Fprintln(w, box.Render(b.String()))
}
func mcpListStatusStyle(status string) lipgloss.Style {
switch {
case status == "enabled":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#2E7D32")).Bold(true)
case status == "disabled":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#6B6B6B"))
case strings.HasPrefix(status, "ok"):
return lipgloss.NewStyle().Foreground(lipgloss.Color("#2E7D32")).Bold(true)
case status == "error":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#D54646")).Bold(true)
default:
return lipgloss.NewStyle()
}
}
// ── helpers ─────────────────────────────────────────────────────────────────
func boolWord(v bool) string {
if v {
return "yes"
}
return "no"
}
func coloredBool(v bool) string {
if v {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#2E7D32")).Bold(true).Render("yes")
}
return lipgloss.NewStyle().Foreground(lipgloss.Color("#D54646")).Render("no")
}
// truncateDescription strips newlines, collapses whitespace, and caps length.
func truncateDescription(s string, maxLen int) string {
// collapse newlines and repeated spaces into a single space
s = strings.Join(strings.Fields(s), " ")
if len(s) <= maxLen {
return s
}
// cut at last space before maxLen
cut := s[:maxLen]
if idx := strings.LastIndex(cut, " "); idx > maxLen/2 {
cut = cut[:idx]
}
return cut + "…"
}
+249
View File
@@ -0,0 +1,249 @@
package mcp
import (
"fmt"
"net/url"
"strings"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/pkg/config"
)
type addOptions struct {
Env []string
EnvFile string
Headers []string
Transport string
Force bool
Deferred *bool // nil = not set, true = deferred, false = not deferred
}
func newAddCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "add [flags] <name> <command-or-url> [args...]",
Short: "Add or update an MCP server",
DisableFlagParsing: true,
RunE: func(cmd *cobra.Command, args []string) error {
opts, name, target, targetArgs, showHelp, err := parseAddArgs(args)
if showHelp {
return cmd.Help()
}
if err != nil {
return err
}
cfg, err := loadConfig()
if err != nil {
return err
}
if cfg.Tools.MCP.Servers == nil {
cfg.Tools.MCP.Servers = make(map[string]config.MCPServerConfig)
}
if _, exists := cfg.Tools.MCP.Servers[name]; exists && !opts.Force {
var overwrite bool
overwrite, err = confirmOverwrite(cmd.InOrStdin(), cmd.OutOrStdout(), name)
if err != nil {
return fmt.Errorf("failed to confirm overwrite: %w", err)
}
if !overwrite {
return fmt.Errorf("aborted: MCP server %q already exists", name)
}
}
server, err := buildServerConfig(target, targetArgs, opts)
if err != nil {
return err
}
cfg.Tools.MCP.Enabled = true
cfg.Tools.MCP.Servers[name] = server
if err := saveValidatedConfig(cfg); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q saved.\n", name)
return nil
},
}
flags := cmd.Flags()
flags.StringArrayP("env", "e", nil, "Environment variable in KEY=value format (repeatable, saved to config)")
flags.String("env-file", "", "Path to an env file for stdio servers (recommended for secrets)")
flags.StringArrayP("header", "H", nil, "HTTP header in 'Name: Value' or 'Name=Value' format (repeatable)")
flags.StringP("transport", "t", "stdio", "Transport type: stdio, http / streamable-http, or sse")
flags.BoolP("force", "f", false, "Overwrite an existing server without prompting")
flags.Bool("deferred", false, "Mark server as deferred (tools hidden until explicitly activated)")
flags.Bool("no-deferred", false, "Mark server as non-deferred (tools always active)")
return cmd
}
func parseAddArgs(args []string) (addOptions, string, string, []string, bool, error) {
opts := addOptions{Transport: "stdio"}
var positional []string
serverArgs := make([]string, 0)
explicitCommand := make([]string, 0)
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--help" || arg == "-h":
return addOptions{}, "", "", nil, true, nil
case arg == "--":
if i+1 < len(args) {
explicitCommand = append(explicitCommand, args[i+1:]...)
}
i = len(args)
case arg == "--force" || arg == "-f":
opts.Force = true
case arg == "--deferred":
t := true
opts.Deferred = &t
case arg == "--no-deferred":
f := false
opts.Deferred = &f
case arg == "--transport" || arg == "-t":
if i+1 >= len(args) {
return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg)
}
i++
opts.Transport = args[i]
case strings.HasPrefix(arg, "--transport="):
opts.Transport = strings.TrimPrefix(arg, "--transport=")
case arg == "--env" || arg == "-e":
if i+1 >= len(args) {
return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg)
}
i++
opts.Env = append(opts.Env, args[i])
case arg == "--env-file":
if i+1 >= len(args) {
return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg)
}
i++
opts.EnvFile = args[i]
case strings.HasPrefix(arg, "--env="):
opts.Env = append(opts.Env, strings.TrimPrefix(arg, "--env="))
case strings.HasPrefix(arg, "--env-file="):
opts.EnvFile = strings.TrimPrefix(arg, "--env-file=")
case arg == "--header" || arg == "-H":
if i+1 >= len(args) {
return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg)
}
i++
opts.Headers = append(opts.Headers, args[i])
case strings.HasPrefix(arg, "--header="):
opts.Headers = append(opts.Headers, strings.TrimPrefix(arg, "--header="))
case strings.HasPrefix(arg, "-") && len(positional) >= 2:
serverArgs = append(serverArgs, args[i:]...)
i = len(args)
default:
positional = append(positional, arg)
}
}
if len(explicitCommand) > 0 {
if len(positional) != 1 {
return addOptions{}, "", "", nil, false, fmt.Errorf(
"usage: picoclaw mcp add [flags] <name> <command-or-url> [args...] or picoclaw mcp add [flags] <name> -- <command> [args...]",
)
}
if len(explicitCommand) == 0 {
return addOptions{}, "", "", nil, false, fmt.Errorf("missing stdio command after --")
}
return opts, positional[0], explicitCommand[0], explicitCommand[1:], false, nil
}
if len(positional) < 2 {
return addOptions{}, "", "", nil, false, fmt.Errorf(
"usage: picoclaw mcp add [flags] <name> <command-or-url> [args...] or picoclaw mcp add [flags] <name> -- <command> [args...]",
)
}
targetArgs := make([]string, 0, len(positional)-2+len(serverArgs))
targetArgs = append(targetArgs, positional[2:]...)
targetArgs = append(targetArgs, serverArgs...)
return opts, positional[0], positional[1], targetArgs, false, nil
}
func buildServerConfig(target string, args []string, opts addOptions) (config.MCPServerConfig, error) {
transport := config.NormalizeMCPTransportType(opts.Transport)
if transport == "" {
transport = "stdio"
}
switch transport {
case "stdio", "http", "sse":
default:
return config.MCPServerConfig{}, fmt.Errorf("unsupported transport %q", opts.Transport)
}
env, err := parseEnvAssignments(opts.Env)
if err != nil {
return config.MCPServerConfig{}, err
}
headers, err := parseHeaderAssignments(opts.Headers)
if err != nil {
return config.MCPServerConfig{}, err
}
server := config.MCPServerConfig{
Enabled: true,
Type: transport,
Deferred: opts.Deferred,
}
switch transport {
case "http", "sse":
if len(env) > 0 {
return config.MCPServerConfig{}, fmt.Errorf("--env can only be used with stdio transport")
}
if strings.TrimSpace(opts.EnvFile) != "" {
return config.MCPServerConfig{}, fmt.Errorf("--env-file can only be used with stdio transport")
}
if len(args) > 0 {
return config.MCPServerConfig{}, fmt.Errorf("%s transport does not accept command arguments", transport)
}
parsedURL, err := url.ParseRequestURI(target)
if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
return config.MCPServerConfig{}, fmt.Errorf("invalid MCP URL %q", target)
}
server.URL = target
server.Headers = headers
return server, nil
}
if len(headers) > 0 {
return config.MCPServerConfig{}, fmt.Errorf("--header can only be used with http or sse transport")
}
if looksLikeRemoteURL(target) {
return config.MCPServerConfig{}, fmt.Errorf(
"target %q looks like a remote MCP URL, but transport is %q. Use --transport http or --transport sse",
target,
transport,
)
}
command := target
commandArgs := append([]string(nil), args...)
if err := validateLocalCommandPath(target); err != nil {
return config.MCPServerConfig{}, err
}
if isLocalCommandPath(command) {
command = expandHomePath(command)
}
server.Command = command
server.Args = commandArgs
server.Env = env
server.EnvFile = strings.TrimSpace(opts.EnvFile)
return server, nil
}
+25
View File
@@ -0,0 +1,25 @@
package mcp
import "github.com/spf13/cobra"
func NewMCPCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "mcp",
Short: "Manage MCP server configuration",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return cmd.Help()
},
}
cmd.AddCommand(
newAddCommand(),
newRemoveCommand(),
newListCommand(),
newEditCommand(),
newTestCommand(),
newShowCommand(),
)
return cmd
}
+660
View File
@@ -0,0 +1,660 @@
package mcp
import (
"bytes"
"context"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestNewMCPCommand(t *testing.T) {
cmd := NewMCPCommand()
require.NotNil(t, cmd)
assert.Equal(t, "mcp", cmd.Use)
assert.Equal(t, "Manage MCP server configuration", cmd.Short)
assert.True(t, cmd.HasSubCommands())
allowedCommands := []string{
"add",
"remove",
"list",
"edit",
"test",
"show",
}
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)
}
}
func TestMCPAddAddsGenericStdioServer(t *testing.T) {
configPath := setupMCPConfigEnv(t)
cmd := NewMCPCommand()
output, err := executeCommand(cmd, []string{
"add",
"sqlite",
"npx",
"-y",
"@modelcontextprotocol/server-sqlite",
"--db",
"./mydb.db",
}, "")
require.NoError(t, err)
assert.Contains(t, output, `MCP server "sqlite" saved`)
cfg := readMCPConfig(t, configPath)
require.True(t, cfg.Tools.MCP.Enabled)
server, ok := cfg.Tools.MCP.Servers["sqlite"]
require.True(t, ok)
assert.True(t, server.Enabled)
assert.Equal(t, "stdio", server.Type)
assert.Equal(t, "npx", server.Command)
assert.Equal(t, []string{"-y", "@modelcontextprotocol/server-sqlite", "--db", "./mydb.db"}, server.Args)
}
func TestMCPAddSupportsHeadersAfterURL(t *testing.T) {
configPath := setupMCPConfigEnv(t)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{
"add",
"apify",
"https://mcp.apify.com/",
"-t",
"http",
"--header",
"Authorization: Bearer OMITTED",
}, "")
require.NoError(t, err)
cfg := readMCPConfig(t, configPath)
server := cfg.Tools.MCP.Servers["apify"]
assert.Equal(t, "http", server.Type)
assert.Equal(t, "https://mcp.apify.com/", server.URL)
assert.Equal(t, map[string]string{"Authorization": "Bearer OMITTED"}, server.Headers)
}
func TestMCPAddSupportsTransportBeforeName(t *testing.T) {
configPath := setupMCPConfigEnv(t)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{
"add",
"--transport",
"sse",
"fiscal-ai",
"https://api.fiscal.ai/mcp/sse",
}, "")
require.NoError(t, err)
cfg := readMCPConfig(t, configPath)
server := cfg.Tools.MCP.Servers["fiscal-ai"]
assert.Equal(t, "sse", server.Type)
assert.Equal(t, "https://api.fiscal.ai/mcp/sse", server.URL)
}
func TestMCPAddSupportsExplicitStdioCommandAfterSeparator(t *testing.T) {
configPath := setupMCPConfigEnv(t)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{
"add",
"--transport",
"stdio",
"--env",
"AIRTABLE_API_KEY=YOUR_KEY",
"airtable",
"--",
"npx",
"-y",
"airtable-mcp-server",
}, "")
require.NoError(t, err)
cfg := readMCPConfig(t, configPath)
server := cfg.Tools.MCP.Servers["airtable"]
assert.Equal(t, "stdio", server.Type)
assert.Equal(t, "npx", server.Command)
assert.Equal(t, []string{"-y", "airtable-mcp-server"}, server.Args)
assert.Equal(t, map[string]string{"AIRTABLE_API_KEY": "YOUR_KEY"}, server.Env)
}
func TestMCPAddSupportsEnvFileForStdio(t *testing.T) {
configPath := setupMCPConfigEnv(t)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{
"add",
"--env-file",
".env.mcp",
"filesystem",
"npx",
"-y",
"@modelcontextprotocol/server-filesystem",
}, "")
require.NoError(t, err)
cfg := readMCPConfig(t, configPath)
server := cfg.Tools.MCP.Servers["filesystem"]
assert.Equal(t, "stdio", server.Type)
assert.Equal(t, "npx", server.Command)
assert.Equal(t, []string{"-y", "@modelcontextprotocol/server-filesystem"}, server.Args)
assert.Equal(t, ".env.mcp", server.EnvFile)
}
func TestMCPAddRejectsEnvFileForHTTP(t *testing.T) {
setupMCPConfigEnv(t)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{
"add",
"--transport",
"http",
"--env-file",
".env.mcp",
"context7",
"https://mcp.context7.com/mcp",
}, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "--env-file can only be used with stdio transport")
}
func TestMCPAddRejectsNonExecutableLocalCommand(t *testing.T) {
setupMCPConfigEnv(t)
tmpDir := t.TempDir()
localCmd := filepath.Join(tmpDir, "server.sh")
require.NoError(t, os.WriteFile(localCmd, []byte("#!/bin/sh\nexit 0\n"), 0o644))
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{"add", "local", localCmd}, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "not executable")
}
func TestMCPAddExpandsHomeInSavedLocalCommand(t *testing.T) {
configPath := setupMCPConfigEnv(t)
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
localCmd := filepath.Join(homeDir, "bin", "my-mcp")
require.NoError(t, os.MkdirAll(filepath.Dir(localCmd), 0o755))
require.NoError(t, os.WriteFile(localCmd, []byte("#!/bin/sh\nexit 0\n"), 0o755))
tildeCmd := "~" + string(os.PathSeparator) + filepath.Join("bin", "my-mcp")
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{"add", "local-home", tildeCmd}, "")
require.NoError(t, err)
cfg := readMCPConfig(t, configPath)
server := cfg.Tools.MCP.Servers["local-home"]
assert.Equal(t, localCmd, server.Command)
}
func TestMCPAddShowsClearErrorForRemoteURLWithoutTransport(t *testing.T) {
setupMCPConfigEnv(t)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{"add", "apify", "https://mcp.apify.com/"}, "")
require.Error(t, err)
assert.Contains(t, err.Error(), `looks like a remote MCP URL`)
assert.Contains(t, err.Error(), `Use --transport http or --transport sse`)
}
func TestMCPAddOverwritePromptDecline(t *testing.T) {
configPath := setupMCPConfigEnv(t)
writeMCPConfig(t, configPath, &config.Config{
Tools: config.ToolsConfig{
MCP: config.MCPConfig{
ToolConfig: config.ToolConfig{Enabled: true},
Servers: map[string]config.MCPServerConfig{
"filesystem": {
Enabled: true,
Type: "stdio",
Command: "old",
},
},
},
},
})
cmd := NewMCPCommand()
output, err := executeCommand(cmd, []string{"add", "filesystem", "new-command"}, "n\n")
require.Error(t, err)
assert.Contains(t, output, `Overwrite? [y/N]:`)
assert.Contains(t, err.Error(), "aborted")
cfg := readMCPConfig(t, configPath)
assert.Equal(t, "old", cfg.Tools.MCP.Servers["filesystem"].Command)
}
func TestMCPAddOverwriteWithConfirmation(t *testing.T) {
configPath := setupMCPConfigEnv(t)
writeMCPConfig(t, configPath, &config.Config{
Tools: config.ToolsConfig{
MCP: config.MCPConfig{
ToolConfig: config.ToolConfig{Enabled: true},
Servers: map[string]config.MCPServerConfig{
"filesystem": {
Enabled: true,
Type: "stdio",
Command: "old",
},
},
},
},
})
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{"add", "filesystem", "new-command"}, "y\n")
require.NoError(t, err)
cfg := readMCPConfig(t, configPath)
assert.Equal(t, "new-command", cfg.Tools.MCP.Servers["filesystem"].Command)
}
func TestMCPAddHTTPServer(t *testing.T) {
configPath := setupMCPConfigEnv(t)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{
"add",
"context7",
"--transport",
"http",
"https://mcp.context7.com/mcp",
}, "")
require.NoError(t, err)
cfg := readMCPConfig(t, configPath)
server := cfg.Tools.MCP.Servers["context7"]
assert.Equal(t, "http", server.Type)
assert.Equal(t, "https://mcp.context7.com/mcp", server.URL)
assert.Empty(t, server.Command)
}
func TestMCPAddSupportsStreamableHTTPAlias(t *testing.T) {
configPath := setupMCPConfigEnv(t)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{
"add",
"context7",
"--transport",
"streamable-http",
"https://mcp.context7.com/mcp",
}, "")
require.NoError(t, err)
cfg := readMCPConfig(t, configPath)
server := cfg.Tools.MCP.Servers["context7"]
assert.Equal(t, "http", server.Type)
assert.Equal(t, "https://mcp.context7.com/mcp", server.URL)
}
func TestSaveValidatedConfigNormalizesStreamableHTTPAlias(t *testing.T) {
configPath := setupMCPConfigEnv(t)
cfg := config.DefaultConfig()
cfg.Tools.MCP.Enabled = true
cfg.Tools.MCP.Servers = map[string]config.MCPServerConfig{
"context7": {
Enabled: true,
Type: "streamable-http",
URL: "https://mcp.context7.com/mcp",
},
}
require.NoError(t, saveValidatedConfig(cfg))
saved := readMCPConfig(t, configPath)
server := saved.Tools.MCP.Servers["context7"]
assert.Equal(t, "http", server.Type)
assert.Equal(t, "https://mcp.context7.com/mcp", server.URL)
assert.Equal(t, "streamable-http", cfg.Tools.MCP.Servers["context7"].Type)
}
func TestMCPRemoveRemovesLastServerAndDisablesMCP(t *testing.T) {
configPath := setupMCPConfigEnv(t)
writeMCPConfig(t, configPath, &config.Config{
Tools: config.ToolsConfig{
MCP: config.MCPConfig{
ToolConfig: config.ToolConfig{Enabled: true},
Servers: map[string]config.MCPServerConfig{
"filesystem": {
Enabled: true,
Type: "stdio",
Command: "npx",
},
},
},
},
})
cmd := NewMCPCommand()
output, err := executeCommand(cmd, []string{"remove", "filesystem"}, "")
require.NoError(t, err)
assert.Contains(t, output, `MCP server "filesystem" removed`)
cfg := readMCPConfig(t, configPath)
assert.False(t, cfg.Tools.MCP.Enabled)
assert.Empty(t, cfg.Tools.MCP.Servers)
}
func TestMCPListPrintsTable(t *testing.T) {
configPath := setupMCPConfigEnv(t)
writeMCPConfig(t, configPath, &config.Config{
Tools: config.ToolsConfig{
MCP: config.MCPConfig{
ToolConfig: config.ToolConfig{Enabled: true},
Servers: map[string]config.MCPServerConfig{
"context7": {
Enabled: true,
Type: "http",
URL: "https://mcp.context7.com/mcp",
},
"filesystem": {
Enabled: false,
Type: "stdio",
Command: "npx",
Args: []string{"-y", "@modelcontextprotocol/server-filesystem", "/tmp"},
},
},
},
},
})
cmd := NewMCPCommand()
output, err := executeCommand(cmd, []string{"list"}, "")
require.NoError(t, err)
assert.Contains(t, output, "| Name")
assert.Contains(t, output, "context7")
assert.Contains(t, output, "filesystem")
assert.Contains(t, output, "https://mcp.context7.com/mcp")
assert.Contains(t, output, "disabled")
}
func TestMCPListWithStatusUsesProbe(t *testing.T) {
configPath := setupMCPConfigEnv(t)
writeMCPConfig(t, configPath, &config.Config{
Tools: config.ToolsConfig{
MCP: config.MCPConfig{
ToolConfig: config.ToolConfig{Enabled: true},
Servers: map[string]config.MCPServerConfig{
"filesystem": {
Enabled: true,
Type: "stdio",
Command: "npx",
},
},
},
},
})
originalProbe := serverProbe
defer func() { serverProbe = originalProbe }()
serverProbe = func(_ context.Context, name string, server config.MCPServerConfig, workspacePath string) (probeResult, error) {
assert.Equal(t, "filesystem", name)
assert.Equal(t, readMCPConfig(t, configPath).WorkspacePath(), workspacePath)
assert.Equal(t, "npx", server.Command)
return probeResult{ToolCount: 3}, nil
}
cmd := NewMCPCommand()
output, err := executeCommand(cmd, []string{"list", "--status"}, "")
require.NoError(t, err)
assert.Contains(t, output, "ok (3 tools)")
}
func TestMCPEditUsesEditor(t *testing.T) {
configPath := setupMCPConfigEnv(t)
originalEditor := editorCommand
defer func() { editorCommand = originalEditor }()
var gotName string
var gotArgs []string
editorCommand = func(name string, args ...string) *exec.Cmd {
gotName = name
gotArgs = append([]string(nil), args...)
return exec.Command("sh", "-c", "exit 0")
}
t.Setenv("EDITOR", `dummy-editor --wait`)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{"edit"}, "")
require.NoError(t, err)
assert.Equal(t, "dummy-editor", gotName)
assert.Equal(t, []string{"--wait", configPath}, gotArgs)
_, statErr := os.Stat(configPath)
assert.NoError(t, statErr)
}
func TestMCPEditRequiresEditor(t *testing.T) {
setupMCPConfigEnv(t)
t.Setenv("EDITOR", "")
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{"edit"}, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "$EDITOR is not set")
}
func TestMCPTestUsesProbe(t *testing.T) {
configPath := setupMCPConfigEnv(t)
writeMCPConfig(t, configPath, &config.Config{
Tools: config.ToolsConfig{
MCP: config.MCPConfig{
ToolConfig: config.ToolConfig{Enabled: true},
Servers: map[string]config.MCPServerConfig{
"filesystem": {
Enabled: false,
Type: "stdio",
Command: "npx",
},
},
},
},
})
originalProbe := serverProbe
defer func() { serverProbe = originalProbe }()
serverProbe = func(_ context.Context, name string, _ config.MCPServerConfig, workspacePath string) (probeResult, error) {
assert.Equal(t, "filesystem", name)
assert.Equal(t, readMCPConfig(t, configPath).WorkspacePath(), workspacePath)
return probeResult{ToolCount: 2}, nil
}
cmd := NewMCPCommand()
output, err := executeCommand(cmd, []string{"test", "filesystem"}, "")
require.NoError(t, err)
assert.Contains(t, output, `MCP server "filesystem" reachable (2 tools)`)
}
func TestMCPAddDeferredFlag(t *testing.T) {
configPath := setupMCPConfigEnv(t)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{"add", "--deferred", "myserver", "npx", "my-mcp"}, "")
require.NoError(t, err)
cfg := readMCPConfig(t, configPath)
server := cfg.Tools.MCP.Servers["myserver"]
require.NotNil(t, server.Deferred)
assert.True(t, *server.Deferred)
}
func TestMCPAddNoDeferredFlag(t *testing.T) {
configPath := setupMCPConfigEnv(t)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{"add", "--no-deferred", "myserver", "npx", "my-mcp"}, "")
require.NoError(t, err)
cfg := readMCPConfig(t, configPath)
server := cfg.Tools.MCP.Servers["myserver"]
require.NotNil(t, server.Deferred)
assert.False(t, *server.Deferred)
}
func TestMCPAddNoDeferredByDefault(t *testing.T) {
configPath := setupMCPConfigEnv(t)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{"add", "myserver", "npx", "my-mcp"}, "")
require.NoError(t, err)
cfg := readMCPConfig(t, configPath)
server := cfg.Tools.MCP.Servers["myserver"]
assert.Nil(t, server.Deferred)
}
func TestMCPShowNotFound(t *testing.T) {
configPath := setupMCPConfigEnv(t)
writeMCPConfig(t, configPath, nil)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{"show", "missing"}, "")
require.Error(t, err)
assert.Contains(t, err.Error(), `"missing" not found`)
}
func TestMCPShowDisabledServer(t *testing.T) {
configPath := setupMCPConfigEnv(t)
writeMCPConfig(t, configPath, &config.Config{
Tools: config.ToolsConfig{
MCP: config.MCPConfig{
ToolConfig: config.ToolConfig{Enabled: true},
Servers: map[string]config.MCPServerConfig{
"myserver": {
Enabled: false,
Type: "stdio",
Command: "npx",
},
},
},
},
})
cmd := NewMCPCommand()
output, err := executeCommand(cmd, []string{"show", "myserver"}, "")
require.NoError(t, err)
assert.Contains(t, output, "myserver")
assert.Contains(t, output, "disabled")
}
func TestMCPShowUsesProbe(t *testing.T) {
configPath := setupMCPConfigEnv(t)
writeMCPConfig(t, configPath, &config.Config{
Tools: config.ToolsConfig{
MCP: config.MCPConfig{
ToolConfig: config.ToolConfig{Enabled: true},
Servers: map[string]config.MCPServerConfig{
"myserver": {
Enabled: true,
Type: "stdio",
Command: "npx",
},
},
},
},
})
original := serverShowProbe
defer func() { serverShowProbe = original }()
serverShowProbe = func(_ context.Context, name string, _ config.MCPServerConfig, _ string) ([]toolDetail, error) {
assert.Equal(t, "myserver", name)
return []toolDetail{
{
Name: "read_file",
Description: "Read a file from the filesystem",
Parameters: []paramDetail{
{Name: "path", Type: "string", Description: "File path", Required: true},
{Name: "encoding", Type: "string", Description: "Character encoding", Required: false},
},
},
{
Name: "list_dir",
Description: "List directory contents",
Parameters: nil,
},
}, nil
}
cmd := NewMCPCommand()
output, err := executeCommand(cmd, []string{"show", "myserver"}, "")
require.NoError(t, err)
assert.Contains(t, output, "myserver")
assert.Contains(t, output, "read_file")
assert.Contains(t, output, "Read a file from the filesystem")
assert.Contains(t, output, "path")
assert.Contains(t, output, "string")
assert.Contains(t, output, "required")
assert.Contains(t, output, "list_dir")
assert.Contains(t, output, "none")
}
func setupMCPConfigEnv(t *testing.T) string {
t.Helper()
configPath := filepath.Join(t.TempDir(), "config.json")
t.Setenv(config.EnvConfig, configPath)
t.Setenv(config.EnvHome, filepath.Dir(configPath))
return configPath
}
func writeMCPConfig(t *testing.T, path string, cfg *config.Config) {
t.Helper()
if cfg == nil {
cfg = config.DefaultConfig()
}
require.NoError(t, config.SaveConfig(path, cfg))
}
func readMCPConfig(t *testing.T, path string) *config.Config {
t.Helper()
cfg, err := config.LoadConfig(path)
require.NoError(t, err)
return cfg
}
func executeCommand(cmd *cobra.Command, args []string, stdin string) (string, error) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.SetArgs(args)
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetIn(strings.NewReader(stdin))
err := cmd.Execute()
return stdout.String() + stderr.String(), err
}
+54
View File
@@ -0,0 +1,54 @@
package mcp
import (
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"go.mau.fi/util/shlex"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
)
func newEditCommand() *cobra.Command {
return &cobra.Command{
Use: "edit",
Short: "Open the PicoClaw config in $EDITOR",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
editor := strings.TrimSpace(os.Getenv("EDITOR"))
if editor == "" {
return fmt.Errorf("$EDITOR is not set")
}
cfg, err := loadConfig()
if err != nil {
return err
}
if err = saveValidatedConfig(cfg); err != nil {
return err
}
editorArgs, err := shlex.Split(editor)
if err != nil {
return fmt.Errorf("failed to parse $EDITOR: %w", err)
}
if len(editorArgs) == 0 {
return fmt.Errorf("$EDITOR is empty")
}
editorArgs = append(editorArgs, internal.GetConfigPath())
process := editorCommand(editorArgs[0], editorArgs[1:]...)
process.Stdin = cmd.InOrStdin()
process.Stdout = cmd.OutOrStdout()
process.Stderr = cmd.ErrOrStderr()
if err := process.Run(); err != nil {
return fmt.Errorf("failed to start editor: %w", err)
}
return nil
},
}
}
+374
View File
@@ -0,0 +1,374 @@
package mcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"github.com/google/jsonschema-go/jsonschema"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
picomcp "github.com/sipeed/picoclaw/pkg/mcp"
)
type probeResult struct {
ToolCount int
}
var (
editorCommand = exec.Command
serverProbe = defaultServerProbe
mcpConfigSchemaOnce sync.Once
mcpConfigSchema *jsonschema.Resolved
errMcpConfigSchema error
)
const mcpConfigSchemaJSON = `{
"type": "object",
"properties": {
"tools": {
"type": "object",
"properties": {
"mcp": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"discovery": { "type": "object", "additionalProperties": true },
"max_inline_text_chars": { "type": "integer" },
"servers": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"deferred": { "type": "boolean" },
"command": { "type": "string" },
"args": {
"type": "array",
"items": { "type": "string" }
},
"env": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"env_file": { "type": "string" },
"type": {
"type": "string",
"enum": ["stdio", "http", "sse"]
},
"url": { "type": "string" },
"headers": {
"type": "object",
"additionalProperties": { "type": "string" }
}
},
"required": ["enabled"],
"anyOf": [
{ "required": ["command"] },
{ "required": ["url"] }
],
"additionalProperties": false
}
}
},
"required": ["enabled"],
"additionalProperties": true
}
},
"required": ["mcp"],
"additionalProperties": true
}
},
"required": ["tools"],
"additionalProperties": true
}`
func loadConfig() (*config.Config, error) {
cfg, err := config.LoadConfig(internal.GetConfigPath())
if err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
return cfg, nil
}
func saveValidatedConfig(cfg *config.Config) error {
if cfg == nil {
return fmt.Errorf("config is nil")
}
normalizedCfg := normalizedConfigForSave(cfg)
data, err := json.Marshal(normalizedCfg)
if err != nil {
return fmt.Errorf("failed to serialize config: %w", err)
}
if err := validateConfigDocument(data); err != nil {
return err
}
if err := config.SaveConfig(internal.GetConfigPath(), normalizedCfg); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func normalizedConfigForSave(cfg *config.Config) *config.Config {
clone := *cfg
if cfg.Tools.MCP.Servers == nil {
return &clone
}
clone.Tools = cfg.Tools
clone.Tools.MCP = cfg.Tools.MCP
clone.Tools.MCP.Servers = make(map[string]config.MCPServerConfig, len(cfg.Tools.MCP.Servers))
for name, server := range cfg.Tools.MCP.Servers {
if server.Type != "" {
server.Type = config.NormalizeMCPTransportType(server.Type)
}
clone.Tools.MCP.Servers[name] = server
}
return &clone
}
func validateConfigDocument(data []byte) error {
var instance map[string]any
if err := json.Unmarshal(data, &instance); err != nil {
return fmt.Errorf("failed to decode serialized config: %w", err)
}
schema, err := loadMCPConfigSchema()
if err != nil {
return fmt.Errorf("failed to load MCP config schema: %w", err)
}
if err := schema.Validate(instance); err != nil {
return fmt.Errorf("config validation failed: %w", err)
}
return nil
}
func loadMCPConfigSchema() (*jsonschema.Resolved, error) {
mcpConfigSchemaOnce.Do(func() {
var schema jsonschema.Schema
if err := json.Unmarshal([]byte(mcpConfigSchemaJSON), &schema); err != nil {
errMcpConfigSchema = err
return
}
mcpConfigSchema, errMcpConfigSchema = schema.Resolve(nil)
})
return mcpConfigSchema, errMcpConfigSchema
}
func inferTransportType(server config.MCPServerConfig) string {
transport := config.EffectiveMCPTransportType(server)
if transport == "" {
return "unknown"
}
return transport
}
func renderServerTarget(server config.MCPServerConfig) string {
transport := inferTransportType(server)
if transport == "http" || transport == "sse" {
if server.URL == "" {
return "<missing url>"
}
return server.URL
}
parts := append([]string{server.Command}, server.Args...)
rendered := strings.TrimSpace(strings.Join(parts, " "))
if rendered == "" {
return "<missing command>"
}
return rendered
}
func sortedServerNames(servers map[string]config.MCPServerConfig) []string {
names := make([]string, 0, len(servers))
for name := range servers {
names = append(names, name)
}
sort.Strings(names)
return names
}
func parseEnvAssignments(values []string) (map[string]string, error) {
if len(values) == 0 {
return nil, nil
}
env := make(map[string]string, len(values))
for _, entry := range values {
key, value, found := strings.Cut(entry, "=")
if !found {
return nil, fmt.Errorf("invalid env assignment %q: expected KEY=value", entry)
}
key = strings.TrimSpace(key)
if key == "" {
return nil, fmt.Errorf("invalid env assignment %q: key cannot be empty", entry)
}
env[key] = value
}
return env, nil
}
func parseHeaderAssignments(values []string) (map[string]string, error) {
if len(values) == 0 {
return nil, nil
}
headers := make(map[string]string, len(values))
for _, entry := range values {
key, value, found := strings.Cut(entry, ":")
if !found {
key, value, found = strings.Cut(entry, "=")
}
if !found {
return nil, fmt.Errorf("invalid header %q: expected 'Name: Value' or 'Name=Value'", entry)
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if key == "" {
return nil, fmt.Errorf("invalid header %q: name cannot be empty", entry)
}
headers[key] = value
}
return headers, nil
}
func looksLikeRemoteURL(target string) bool {
parsedURL, err := url.ParseRequestURI(target)
if err != nil {
return false
}
if parsedURL.Host == "" {
return false
}
switch strings.ToLower(parsedURL.Scheme) {
case "http", "https":
return true
default:
return false
}
}
func isLocalCommandPath(command string) bool {
if command == "" {
return false
}
if looksLikeRemoteURL(command) {
return false
}
return filepath.IsAbs(command) ||
filepath.VolumeName(command) != "" ||
strings.HasPrefix(command, "."+string(os.PathSeparator)) ||
strings.HasPrefix(command, ".."+string(os.PathSeparator)) ||
command == "." ||
command == ".." ||
strings.ContainsRune(command, os.PathSeparator)
}
func expandHomePath(path string) string {
if path == "" || path[0] != '~' {
return path
}
home, err := os.UserHomeDir()
if err != nil {
return path
}
if path == "~" {
return home
}
if strings.HasPrefix(path, "~/") || strings.HasPrefix(path, "~\\") {
return filepath.Join(home, path[2:])
}
return path
}
func validateLocalCommandPath(command string) error {
if !isLocalCommandPath(command) {
return nil
}
path := expandHomePath(command)
info, err := os.Stat(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("local command %q does not exist", command)
}
return fmt.Errorf("failed to stat local command %q: %w", command, err)
}
if info.IsDir() {
return fmt.Errorf("local command %q is a directory", command)
}
if runtime.GOOS != "windows" && info.Mode()&0o111 == 0 {
return fmt.Errorf("local command %q is not executable", command)
}
return nil
}
func defaultServerProbe(
ctx context.Context,
name string,
server config.MCPServerConfig,
workspacePath string,
) (probeResult, error) {
mgr := picomcp.NewManager()
defer func() { _ = mgr.Close() }()
server.Enabled = true
mcpCfg := config.MCPConfig{
ToolConfig: config.ToolConfig{Enabled: true},
Servers: map[string]config.MCPServerConfig{
name: server,
},
}
if err := mgr.LoadFromMCPConfig(ctx, mcpCfg, workspacePath); err != nil {
return probeResult{}, err
}
conn, ok := mgr.GetServer(name)
if !ok {
return probeResult{}, fmt.Errorf("server %q did not register a connection", name)
}
return probeResult{ToolCount: len(conn.Tools)}, nil
}
func confirmOverwrite(r io.Reader, w io.Writer, name string) (bool, error) {
if _, err := fmt.Fprintf(w, "MCP server %q already exists. Overwrite? [y/N]: ", name); err != nil {
return false, err
}
var answer string
if _, err := fmt.Fscanln(r, &answer); err != nil {
if errors.Is(err, io.EOF) {
return false, nil
}
return false, err
}
answer = strings.TrimSpace(strings.ToLower(answer))
return answer == "y" || answer == "yes", nil
}
+78
View File
@@ -0,0 +1,78 @@
package mcp
import (
"context"
"fmt"
"time"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
)
func newListCommand() *cobra.Command {
var (
includeStatus bool
timeout time.Duration
)
cmd := &cobra.Command{
Use: "list",
Short: "List configured MCP servers",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
if len(cfg.Tools.MCP.Servers) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "No MCP servers configured.")
return nil
}
rows := make([]cliui.MCPListRow, 0, len(cfg.Tools.MCP.Servers))
for _, name := range sortedServerNames(cfg.Tools.MCP.Servers) {
server := cfg.Tools.MCP.Servers[name]
status := "disabled"
if server.Enabled {
status = "enabled"
}
if includeStatus && server.Enabled {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
result, probeErr := serverProbe(ctx, name, server, cfg.WorkspacePath())
cancel()
if probeErr != nil {
status = "error"
} else {
status = fmt.Sprintf("ok (%d tools)", result.ToolCount)
}
}
effectiveDeferred := cfg.Tools.MCP.Discovery.Enabled
deferredExplicit := server.Deferred != nil
if deferredExplicit {
effectiveDeferred = *server.Deferred
}
rows = append(rows, cliui.MCPListRow{
Name: name,
Type: inferTransportType(server),
Target: renderServerTarget(server),
Status: status,
EffectiveDeferred: effectiveDeferred,
DeferredExplicit: deferredExplicit,
})
}
cliui.PrintMCPList(cmd.OutOrStdout(), rows)
return nil
},
}
cmd.Flags().BoolVar(&includeStatus, "status", false, "Ping enabled servers and show live status")
cmd.Flags().DurationVar(&timeout, "timeout", 5*time.Second, "Timeout for each live status check")
return cmd
}
+39
View File
@@ -0,0 +1,39 @@
package mcp
import (
"fmt"
"github.com/spf13/cobra"
)
func newRemoveCommand() *cobra.Command {
return &cobra.Command{
Use: "remove <name>",
Short: "Remove an MCP server from config",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
name := args[0]
if _, exists := cfg.Tools.MCP.Servers[name]; !exists {
return fmt.Errorf("MCP server %q not found", name)
}
delete(cfg.Tools.MCP.Servers, name)
if len(cfg.Tools.MCP.Servers) == 0 {
cfg.Tools.MCP.Servers = nil
cfg.Tools.MCP.Enabled = false
}
if err := saveValidatedConfig(cfg); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q removed.\n", name)
return nil
},
}
}
+237
View File
@@ -0,0 +1,237 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/pkg/config"
picomcp "github.com/sipeed/picoclaw/pkg/mcp"
)
type toolDetail struct {
Name string
Description string
Parameters []paramDetail
}
type paramDetail struct {
Name string
Type string
Description string
Required bool
}
var serverShowProbe = defaultServerShowProbe
func defaultServerShowProbe(
ctx context.Context,
name string,
server config.MCPServerConfig,
workspacePath string,
) ([]toolDetail, error) {
mgr := picomcp.NewManager()
defer func() { _ = mgr.Close() }()
server.Enabled = true
mcpCfg := config.MCPConfig{
ToolConfig: config.ToolConfig{Enabled: true},
Servers: map[string]config.MCPServerConfig{
name: server,
},
}
if err := mgr.LoadFromMCPConfig(ctx, mcpCfg, workspacePath); err != nil {
return nil, err
}
conn, ok := mgr.GetServer(name)
if !ok {
return nil, fmt.Errorf("server %q did not register a connection", name)
}
details := make([]toolDetail, 0, len(conn.Tools))
for _, tool := range conn.Tools {
details = append(details, toolDetail{
Name: tool.Name,
Description: tool.Description,
Parameters: extractParameters(tool.InputSchema),
})
}
return details, nil
}
func extractParameters(schema any) []paramDetail {
schemaMap := normalizeSchema(schema)
properties, ok := schemaMap["properties"].(map[string]any)
if !ok || len(properties) == 0 {
return nil
}
required := make(map[string]struct{})
switch raw := schemaMap["required"].(type) {
case []string:
for _, name := range raw {
required[name] = struct{}{}
}
case []any:
for _, value := range raw {
if name, ok := value.(string); ok {
required[name] = struct{}{}
}
}
}
names := make([]string, 0, len(properties))
for name := range properties {
names = append(names, name)
}
sort.Strings(names)
params := make([]paramDetail, 0, len(names))
for _, name := range names {
param := paramDetail{Name: name}
if propMap, ok := properties[name].(map[string]any); ok {
if typeName, ok := propMap["type"].(string); ok {
param.Type = strings.TrimSpace(typeName)
}
if desc, ok := propMap["description"].(string); ok {
param.Description = strings.TrimSpace(desc)
}
}
_, param.Required = required[name]
params = append(params, param)
}
return params
}
func normalizeSchema(schema any) map[string]any {
if schema == nil {
return map[string]any{}
}
if schemaMap, ok := schema.(map[string]any); ok {
return schemaMap
}
var jsonData []byte
switch raw := schema.(type) {
case json.RawMessage:
jsonData = raw
case []byte:
jsonData = raw
default:
var err error
jsonData, err = json.Marshal(schema)
if err != nil {
return map[string]any{}
}
}
var result map[string]any
if err := json.Unmarshal(jsonData, &result); err != nil {
return map[string]any{}
}
return result
}
func newShowCommand() *cobra.Command {
var timeout time.Duration
cmd := &cobra.Command{
Use: "show <name>",
Short: "Show details and tools for a configured MCP server",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
name := args[0]
server, exists := cfg.Tools.MCP.Servers[name]
if !exists {
return fmt.Errorf("MCP server %q not found", name)
}
serverInfo := buildServerInfo(name, server, cfg.Tools.MCP.Discovery.Enabled)
if !server.Enabled {
cliui.PrintMCPShow(cmd.OutOrStdout(), serverInfo, nil, true)
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
details, err := serverShowProbe(ctx, name, server, cfg.WorkspacePath())
if err != nil {
return fmt.Errorf("failed to connect to MCP server %q: %w", name, err)
}
tools := make([]cliui.MCPShowTool, 0, len(details))
for _, d := range details {
params := make([]cliui.MCPShowParam, 0, len(d.Parameters))
for _, p := range d.Parameters {
params = append(params, cliui.MCPShowParam{
Name: p.Name,
Type: p.Type,
Description: p.Description,
Required: p.Required,
})
}
tools = append(tools, cliui.MCPShowTool{
Name: d.Name,
Description: d.Description,
Parameters: params,
})
}
cliui.PrintMCPShow(cmd.OutOrStdout(), serverInfo, tools, false)
return nil
},
}
cmd.Flags().DurationVar(&timeout, "timeout", 10*time.Second, "Connection timeout")
return cmd
}
func buildServerInfo(name string, server config.MCPServerConfig, discoveryEnabled bool) cliui.MCPShowServer {
effectiveDeferred := discoveryEnabled
deferredExplicit := server.Deferred != nil
if deferredExplicit {
effectiveDeferred = *server.Deferred
}
info := cliui.MCPShowServer{
Name: name,
Type: inferTransportType(server),
Target: renderServerTarget(server),
Enabled: server.Enabled,
EffectiveDeferred: effectiveDeferred,
DeferredExplicit: deferredExplicit,
EnvFile: server.EnvFile,
}
if len(server.Env) > 0 {
keys := make([]string, 0, len(server.Env))
for k := range server.Env {
keys = append(keys, k)
}
sort.Strings(keys)
info.EnvKeys = keys
}
if len(server.Headers) > 0 {
keys := make([]string, 0, len(server.Headers))
for k := range server.Headers {
keys = append(keys, k)
}
sort.Strings(keys)
info.Headers = keys
}
return info
}
+46
View File
@@ -0,0 +1,46 @@
package mcp
import (
"context"
"fmt"
"time"
"github.com/spf13/cobra"
)
func newTestCommand() *cobra.Command {
var timeout time.Duration
cmd := &cobra.Command{
Use: "test <name>",
Short: "Test connectivity for a configured MCP server",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
name := args[0]
server, exists := cfg.Tools.MCP.Servers[name]
if !exists {
return fmt.Errorf("MCP server %q not found", name)
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
result, err := serverProbe(ctx, name, server, cfg.WorkspacePath())
if err != nil {
return fmt.Errorf("failed to reach MCP server %q: %w", name, err)
}
fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q reachable (%d tools).\n", name, result.ToolCount)
return nil
},
}
cmd.Flags().DurationVar(&timeout, "timeout", 5*time.Second, "Connection timeout")
return cmd
}
+200
View File
@@ -0,0 +1,200 @@
package model
import (
"bufio"
"fmt"
"io"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
)
const defaultAliasName = "custom-prefer"
func newAddCommand() *cobra.Command {
var (
apiBase string
apiKey string
modelID string
alias string
modelType string
)
cmd := &cobra.Command{
Use: "add",
Short: "Add a model from an OpenAI-compatible endpoint",
Long: `Add a model entry by querying an OpenAI-compatible endpoint exposing
GET <api-base>/models, then setting it as the default model.
If --model is omitted, the available models are listed and you can pick one
interactively. If --model is provided, the entry is written without contacting
the server.
Sample interactive session (key shown masked):
$ picoclaw model add \
-b https://ark.cn-beijing.volces.com/api/v3 \
-k 7dff****-****-****-****-********e829
115 model(s) available:
1) doubao-lite-128k-240428 (doubao-lite-128k)
2) doubao-pro-128k-240515 (doubao-pro-128k)
...
48) deepseek-r1-250120 (deepseek-r1)
78) kimi-k2-250711 (kimi-k2)
...
115) doubao-seed3d-2-0-260328 (doubao-seed3d-2-0)
Pick a model (number or id): 48
✓ Saved model 'custom-prefer' (deepseek-r1-250120) and set as default.`,
Example: ` picoclaw model add --api-base https://api.openai.com/v1 --api-key sk-...
picoclaw model add -b http://localhost:8000/v1 -k dummy -m my-model -n local`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return runAdd(addOptions{
apiBase: strings.TrimSpace(apiBase),
apiKey: strings.TrimSpace(apiKey),
modelID: strings.TrimSpace(modelID),
alias: strings.TrimSpace(alias),
modelType: strings.TrimSpace(modelType),
stdin: cmd.InOrStdin(),
stdout: cmd.OutOrStdout(),
})
},
}
cmd.Flags().StringVarP(&apiBase, "api-base", "b", "",
"API base URL (required), e.g. https://api.openai.com/v1")
cmd.Flags().StringVarP(&apiKey, "api-key", "k", "", "API key (required)")
cmd.Flags().StringVarP(&modelID, "model", "m", "",
"Model id; when set, skips the interactive picker and the network call")
cmd.Flags().StringVarP(&alias, "name", "n", defaultAliasName,
"Local alias written to model_list and used as the default model name")
cmd.Flags().StringVar(&modelType, "type", "openai-compatible",
"Endpoint type (only 'openai-compatible' is supported today)")
_ = cmd.MarkFlagRequired("api-base")
_ = cmd.MarkFlagRequired("api-key")
return cmd
}
type addOptions struct {
apiBase string
apiKey string
modelID string
alias string
modelType string
stdin io.Reader
stdout io.Writer
}
func runAdd(opt addOptions) error {
if opt.modelType != "" && opt.modelType != "openai-compatible" {
return fmt.Errorf("unsupported --type %q (only 'openai-compatible' is supported)", opt.modelType)
}
if opt.alias == "" {
opt.alias = defaultAliasName
}
selected := opt.modelID
if selected == "" {
entries, err := fetchOpenAIModels(opt.apiBase, opt.apiKey)
if err != nil {
return fmt.Errorf("fetch models: %w", err)
}
if len(entries) == 0 {
return fmt.Errorf("no models returned by %s", opt.apiBase)
}
selected, err = pickModel(opt.stdin, opt.stdout, entries)
if err != nil {
return err
}
}
return upsertModelDefault(opt.apiBase, opt.apiKey, opt.alias, selected, opt.stdout)
}
func pickModel(stdin io.Reader, stdout io.Writer, entries []modelEntry) (string, error) {
fmt.Fprintf(stdout, "\n%d model(s) available:\n", len(entries))
for i, m := range entries {
line := m.ID
if m.Name != "" && m.Name != m.ID {
line = fmt.Sprintf("%s (%s)", m.ID, m.Name)
}
fmt.Fprintf(stdout, " %3d) %s\n", i+1, line)
}
scanner := bufio.NewScanner(stdin)
for {
fmt.Fprint(stdout, "Pick a model (number or id): ")
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("read input: %w", err)
}
return "", fmt.Errorf("no selection provided")
}
text := strings.TrimSpace(scanner.Text())
if text == "" {
continue
}
if idx, err := strconv.Atoi(text); err == nil {
if idx < 1 || idx > len(entries) {
fmt.Fprintf(stdout, "Out of range. Enter 1-%d.\n", len(entries))
continue
}
return entries[idx-1].ID, nil
}
for _, m := range entries {
if m.ID == text {
return m.ID, nil
}
}
fmt.Fprintln(stdout, "Not a valid number or model id; try again.")
}
}
func upsertModelDefault(apiBase, apiKey, alias, modelID string, stdout io.Writer) error {
configPath := internal.GetConfigPath()
cfg, err := config.LoadConfig(configPath)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
secureKeys := config.SimpleSecureStrings(apiKey)
found := false
for _, m := range cfg.ModelList {
if m == nil {
continue
}
if m.ModelName == alias {
m.Model = modelID
m.APIBase = apiBase
m.APIKeys = secureKeys
m.Enabled = true
found = true
break
}
}
if !found {
cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{
ModelName: alias,
Model: modelID,
APIBase: apiBase,
APIKeys: secureKeys,
Enabled: true,
})
}
cfg.Agents.Defaults.ModelName = alias
if err := config.SaveConfig(configPath, cfg); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Fprintf(stdout, "✓ Saved model '%s' (%s) and set as default.\n", alias, modelID)
return nil
}
+257
View File
@@ -0,0 +1,257 @@
package model
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestFetchOpenAIModels_DataEnvelope(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/models", r.URL.Path)
assert.Equal(t, "Bearer secret", r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-foo","name":"Foo"},{"id":"gpt-bar"}]}`))
}))
defer srv.Close()
entries, err := fetchOpenAIModels(srv.URL, "secret")
require.NoError(t, err)
require.Len(t, entries, 2)
assert.Equal(t, "gpt-foo", entries[0].ID)
assert.Equal(t, "Foo", entries[0].Name)
assert.Equal(t, "gpt-bar", entries[1].ID)
}
func TestFetchOpenAIModels_BareArray(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"id":"a"},{"id":"b"}]`))
}))
defer srv.Close()
entries, err := fetchOpenAIModels(srv.URL, "secret")
require.NoError(t, err)
require.Len(t, entries, 2)
assert.Equal(t, "a", entries[0].ID)
assert.Equal(t, "b", entries[1].ID)
}
func TestFetchOpenAIModels_TrimsTrailingSlash(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
_, _ = w.Write([]byte(`{"data":[{"id":"x"}]}`))
}))
defer srv.Close()
_, err := fetchOpenAIModels(srv.URL+"/", "k")
require.NoError(t, err)
assert.Equal(t, "/models", gotPath)
}
func TestFetchOpenAIModels_HTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "nope", http.StatusUnauthorized)
}))
defer srv.Close()
_, err := fetchOpenAIModels(srv.URL, "bad")
require.Error(t, err)
assert.Contains(t, err.Error(), "HTTP 401")
}
func TestFetchOpenAIModels_EmptyDataEnvelope(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"data":[]}`))
}))
defer srv.Close()
entries, err := fetchOpenAIModels(srv.URL, "k")
require.NoError(t, err)
assert.Empty(t, entries)
}
func TestFetchOpenAIModels_EmptyBareArray(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[]`))
}))
defer srv.Close()
entries, err := fetchOpenAIModels(srv.URL, "k")
require.NoError(t, err)
assert.Empty(t, entries)
}
func TestFetchOpenAIModels_UnrecognizedShape(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"models":"not-supported"}`))
}))
defer srv.Close()
_, err := fetchOpenAIModels(srv.URL, "k")
require.Error(t, err)
assert.Contains(t, err.Error(), "unrecognized shape")
}
func TestFetchOpenAIModels_RequiresInputs(t *testing.T) {
_, err := fetchOpenAIModels("", "k")
require.Error(t, err)
assert.Contains(t, err.Error(), "api base")
_, err = fetchOpenAIModels("https://example.com", "")
require.Error(t, err)
assert.Contains(t, err.Error(), "api key")
}
func TestPickModel_ByIndex(t *testing.T) {
entries := []modelEntry{{ID: "a"}, {ID: "b"}, {ID: "c"}}
out := &bytes.Buffer{}
got, err := pickModel(strings.NewReader("2\n"), out, entries)
require.NoError(t, err)
assert.Equal(t, "b", got)
assert.Contains(t, out.String(), "3 model(s) available")
}
func TestPickModel_ByID(t *testing.T) {
entries := []modelEntry{{ID: "alpha"}, {ID: "beta"}}
out := &bytes.Buffer{}
got, err := pickModel(strings.NewReader("beta\n"), out, entries)
require.NoError(t, err)
assert.Equal(t, "beta", got)
}
func TestPickModel_RetriesOnInvalid(t *testing.T) {
entries := []modelEntry{{ID: "x"}}
out := &bytes.Buffer{}
got, err := pickModel(strings.NewReader("\n9\nnot-a-model\nx\n"), out, entries)
require.NoError(t, err)
assert.Equal(t, "x", got)
rendered := out.String()
assert.Contains(t, rendered, "Out of range")
assert.Contains(t, rendered, "Not a valid number")
}
func TestRunAdd_WithExplicitModel_NoNetwork(t *testing.T) {
initTest(t)
out := &bytes.Buffer{}
err := runAdd(addOptions{
apiBase: "https://invalid.invalid/v1",
apiKey: "k",
modelID: "explicit-model",
alias: "myalias",
modelType: "openai-compatible",
stdout: out,
})
require.NoError(t, err)
assert.Contains(t, out.String(), "Saved model 'myalias' (explicit-model)")
cfg, err := config.LoadConfig(configPath)
require.NoError(t, err)
assert.Equal(t, "myalias", cfg.Agents.Defaults.GetModelName())
added := findModelByName(cfg, "myalias")
require.NotNil(t, added, "expected model 'myalias' in model_list")
assert.Equal(t, "explicit-model", added.Model)
assert.Equal(t, "https://invalid.invalid/v1", added.APIBase)
assert.True(t, added.Enabled)
require.Len(t, added.APIKeys, 1)
assert.Equal(t, "k", added.APIKeys[0].String())
}
func findModelByName(cfg *config.Config, name string) *config.ModelConfig {
for _, m := range cfg.ModelList {
if m != nil && m.ModelName == name {
return m
}
}
return nil
}
func TestRunAdd_FetchAndPick(t *testing.T) {
initTest(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "Bearer my-key", r.Header.Get("Authorization"))
_, _ = w.Write([]byte(`{"data":[{"id":"m1"},{"id":"m2"}]}`))
}))
defer srv.Close()
out := &bytes.Buffer{}
err := runAdd(addOptions{
apiBase: srv.URL,
apiKey: "my-key",
alias: defaultAliasName,
modelType: "openai-compatible",
stdin: strings.NewReader("2\n"),
stdout: out,
})
require.NoError(t, err)
cfg, err := config.LoadConfig(configPath)
require.NoError(t, err)
assert.Equal(t, defaultAliasName, cfg.Agents.Defaults.GetModelName())
added := findModelByName(cfg, defaultAliasName)
require.NotNil(t, added)
assert.Equal(t, "m2", added.Model)
}
func TestRunAdd_UpsertsExistingAlias(t *testing.T) {
initTest(t)
first := &bytes.Buffer{}
require.NoError(t, runAdd(addOptions{
apiBase: "https://a.example/v1",
apiKey: "k1",
modelID: "m1",
alias: "shared",
stdout: first,
}))
second := &bytes.Buffer{}
require.NoError(t, runAdd(addOptions{
apiBase: "https://b.example/v1",
apiKey: "k2",
modelID: "m2",
alias: "shared",
stdout: second,
}))
cfg, err := config.LoadConfig(configPath)
require.NoError(t, err)
matches := 0
for _, m := range cfg.ModelList {
if m != nil && m.ModelName == "shared" {
matches++
}
}
assert.Equal(t, 1, matches, "alias should be updated, not duplicated")
updated := findModelByName(cfg, "shared")
require.NotNil(t, updated)
assert.Equal(t, "m2", updated.Model)
assert.Equal(t, "https://b.example/v1", updated.APIBase)
assert.Equal(t, "k2", updated.APIKeys[0].String())
}
func TestRunAdd_RejectsUnsupportedType(t *testing.T) {
initTest(t)
err := runAdd(addOptions{
apiBase: "https://x/v1",
apiKey: "k",
modelID: "m",
alias: "a",
modelType: "anthropic",
stdout: &bytes.Buffer{},
})
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported --type")
}
+11
View File
@@ -21,11 +21,17 @@ func NewModelCommand() *cobra.Command {
If no argument is provided, shows the current default model.
If a model name is provided, sets it as the default model.
To onboard a model from a custom OpenAI-compatible endpoint (fetch the
available list online and pick one), use the 'add' subcommand:
picoclaw model add --help
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
picoclaw model add -b URL -k KEY # Add a model from a custom endpoint
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.`,
@@ -51,6 +57,8 @@ Note: 'local-model' is a special value for using a local VLLM server
},
}
cmd.AddCommand(newAddCommand())
return cmd
}
@@ -66,6 +74,9 @@ func showCurrentModel(cfg *config.Config) {
fmt.Println("\nAvailable models in your config:")
listAvailableModels(cfg)
}
fmt.Println("\nTip: 'picoclaw model add -b URL -k KEY' adds a model from a custom")
fmt.Println(" OpenAI-compatible endpoint (see 'picoclaw model add --help').")
}
func listAvailableModels(cfg *config.Config) {
+77
View File
@@ -0,0 +1,77 @@
package model
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type modelEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
type modelsAPIResponse struct {
Data []modelEntry `json:"data"`
}
// fetchOpenAIModels GETs <baseURL>/models with Bearer auth and accepts both the
// {data:[…]} envelope and a bare array shape used by various OpenAI-compatible servers.
func fetchOpenAIModels(baseURL, apiKey string) ([]modelEntry, error) {
if strings.TrimSpace(baseURL) == "" {
return nil, fmt.Errorf("api base is required")
}
if strings.TrimSpace(apiKey) == "" {
return nil, fmt.Errorf("api key is required")
}
url := strings.TrimRight(baseURL, "/") + "/models"
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
// {"data": [...]} envelope. Distinguish "envelope shape with empty list"
// from "object without a data key" via Data being non-nil after unmarshal:
// json.Unmarshal sets Data to []modelEntry{} for `{"data":[]}` but leaves
// it as nil when "data" is absent or null.
var envelope modelsAPIResponse
if err := json.Unmarshal(body, &envelope); err == nil && envelope.Data != nil {
return envelope.Data, nil
}
// Bare-array shape, including `[]`.
var arr []modelEntry
if err := json.Unmarshal(body, &arr); err == nil {
return arr, nil
}
preview := body
if len(preview) > 256 {
preview = preview[:256]
}
return nil, fmt.Errorf("decode response: unrecognized shape: %s", strings.TrimSpace(string(preview)))
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"github.com/spf13/cobra"
)
//go:generate cp -r ../../../../workspace .
//go:generate go run ../../../../scripts/copydir.go ../../../../workspace ./workspace
//go:embed workspace
var embeddedFiles embed.FS
+7 -5
View File
@@ -3,12 +3,12 @@ package status
import (
"fmt"
"os"
"strings"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
)
func statusCmd() {
@@ -44,12 +44,13 @@ func statusCmd() {
// not depend on a legacy cfg.Providers field (which may not exist under some
// build tags). We infer provider availability from model_list entries.
hasProtocolKey := func(protocol string) bool {
prefix := protocol + "/"
want := providers.NormalizeProvider(protocol)
for _, m := range cfg.ModelList {
if m == nil {
continue
}
if strings.HasPrefix(m.Model, prefix) && m.APIKey() != "" {
got, _ := providers.ExtractProtocol(m)
if got == want && m.APIKey() != "" {
return true
}
}
@@ -67,12 +68,13 @@ func statusCmd() {
return "", false
}
findProtocolBase := func(protocol string) (string, bool) {
prefix := protocol + "/"
want := providers.NormalizeProvider(protocol)
for _, m := range cfg.ModelList {
if m == nil {
continue
}
if strings.HasPrefix(m.Model, prefix) && m.APIBase != "" {
got, _ := providers.ExtractProtocol(m)
if got == want && m.APIBase != "" {
return m.APIBase, true
}
}
@@ -0,0 +1,89 @@
package status
import (
"bytes"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
oldStdout := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe() error = %v", err)
}
os.Stdout = w
fn()
_ = w.Close()
os.Stdout = oldStdout
defer r.Close()
var buf bytes.Buffer
if _, err := io.Copy(&buf, r); err != nil {
t.Fatalf("io.Copy() error = %v", err)
}
return buf.String()
}
func TestStatusCmd_RecognizesProviderFieldWithoutModelPrefix(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
workspace := filepath.Join(tmpDir, "workspace")
if err := os.MkdirAll(workspace, 0o755); err != nil {
t.Fatalf("os.MkdirAll() error = %v", err)
}
t.Setenv(config.EnvConfig, configPath)
t.Setenv(config.EnvHome, tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "gpt-5.4",
Workspace: workspace,
Provider: "openai",
MaxTokens: 65536,
Temperature: nil,
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "gpt-5.4",
Provider: "openai",
Model: "gpt-5.4",
APIBase: "https://api.openai.com/v1",
APIKeys: config.SimpleSecureStrings("test-key"),
Enabled: true,
},
{
ModelName: "qwen-plus",
Provider: "qwen",
Model: "qwen-plus",
APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
APIKeys: config.SimpleSecureStrings("test-key"),
Enabled: true,
},
},
}
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("config.SaveConfig() error = %v", err)
}
output := captureStdout(t, statusCmd)
if !strings.Contains(output, "OpenAI API: \u2713") {
t.Fatalf("status output missing OpenAI provider: %s", output)
}
if !strings.Contains(output, "Qwen API: \u2713") {
t.Fatalf("status output missing Qwen provider: %s", output)
}
}
+2
View File
@@ -19,6 +19,7 @@ import (
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/mcp"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/model"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard"
@@ -87,6 +88,7 @@ picoclaw --no-color status`,
gateway.NewGatewayCommand(),
status.NewStatusCommand(),
cron.NewCronCommand(),
mcp.NewMCPCommand(),
migrate.NewMigrateCommand(),
skills.NewSkillsCommand(),
model.NewModelCommand(),
+1
View File
@@ -41,6 +41,7 @@ func TestNewPicoclawCommand(t *testing.T) {
"auth",
"cron",
"gateway",
"mcp",
"migrate",
"model",
"onboard",
+169 -91
View File
@@ -1,4 +1,5 @@
{
"version": 3,
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
@@ -11,23 +12,35 @@
"summarize_message_threshold": 20,
"summarize_token_percent": 75,
"split_on_marker": false,
"max_llm_retries": 2,
"llm_retry_backoff_secs": 2,
"tool_feedback": {
"enabled": false,
"max_args_length": 300
"max_args_length": 300,
"separate_messages": false
}
}
},
"evolution": {
"enabled": false,
"mode": "observe",
"state_dir": "",
"min_task_count": 2,
"min_success_ratio": 0.7,
"cold_path_trigger": "after_turn",
"cold_path_times": []
},
"model_list": [
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key",
"api_keys": ["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_keys": ["sk-ant-your-key"],
"api_base": "https://api.anthropic.com/v1",
"thinking_level": "high"
},
@@ -35,23 +48,24 @@
"_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_keys": ["sk-ant-your-key"],
"api_base": "https://api.anthropic.com"
},
{
"model_name": "gemini",
"_comment": "Optional: set \"tool_schema_transform\": \"simple\" for providers that reject complex tool JSON Schema.",
"model": "antigravity/gemini-2.0-flash",
"auth_method": "oauth"
},
{
"model_name": "deepseek",
"model": "deepseek/deepseek-chat",
"api_key": "sk-your-deepseek-key"
"api_keys": ["sk-your-deepseek-key"]
},
{
"model_name": "venice-uncensored",
"model": "venice/venice-uncensored",
"api_key": "your-venice-api-key"
"api_keys": ["your-venice-api-key"]
},
{
"model_name": "lmstudio-local",
@@ -60,114 +74,134 @@
{
"model_name": "longcat",
"model": "longcat/LongCat-Flash-Thinking",
"api_key": "your-longcat-api-key"
"api_keys": ["your-longcat-api-key"]
},
{
"model_name": "modelscope-qwen",
"model": "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
"api_key": "your-modelscope-access-token",
"api_keys": ["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_keys": ["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_keys": ["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_keys": ["sk-key2"],
"api_base": "https://api2.example.com/v1"
}
],
"channels": {
"channel_list": {
"telegram": {
"enabled": false,
"token": "YOUR_TELEGRAM_BOT_TOKEN",
"base_url": "",
"proxy": "",
"type": "telegram",
"allow_from": ["YOUR_USER_ID"],
"use_markdown_v2": false,
"reasoning_channel_id": "",
"streaming": {
"enabled": true
"settings": {
"token": "YOUR_TELEGRAM_BOT_TOKEN",
"base_url": "",
"proxy": "",
"use_markdown_v2": false,
"streaming": {
"enabled": true
}
}
},
"discord": {
"enabled": false,
"token": "YOUR_DISCORD_BOT_TOKEN",
"proxy": "",
"type": "discord",
"allow_from": [],
"group_trigger": {
"mention_only": false
},
"reasoning_channel_id": ""
"reasoning_channel_id": "",
"settings": {
"token": "YOUR_DISCORD_BOT_TOKEN",
"proxy": ""
}
},
"qq": {
"enabled": false,
"app_id": "YOUR_QQ_APP_ID",
"app_secret": "YOUR_QQ_APP_SECRET",
"type": "qq",
"allow_from": [],
"reasoning_channel_id": ""
"reasoning_channel_id": "",
"settings": {
"app_id": "YOUR_QQ_APP_ID",
"app_secret": "YOUR_QQ_APP_SECRET"
}
},
"maixcam": {
"enabled": false,
"host": "0.0.0.0",
"port": 18790,
"type": "maixcam",
"allow_from": [],
"reasoning_channel_id": ""
"reasoning_channel_id": "",
"settings": {
"host": "0.0.0.0",
"port": 18790
}
},
"whatsapp": {
"enabled": false,
"bridge_url": "ws://localhost:3001",
"use_native": false,
"session_store_path": "",
"type": "whatsapp",
"allow_from": [],
"reasoning_channel_id": ""
"reasoning_channel_id": "",
"settings": {
"bridge_url": "ws://localhost:3001",
"use_native": false,
"session_store_path": ""
}
},
"feishu": {
"enabled": false,
"app_id": "",
"app_secret": "",
"encrypt_key": "",
"verification_token": "",
"type": "feishu",
"allow_from": [],
"reasoning_channel_id": "",
"placeholder": {
"enabled": true,
"text": ["Thinking...", "Processing...", "Typing..."]
},
"reasoning_channel_id": "",
"random_reaction_emoji": [],
"is_lark": false
"settings": {
"app_id": "",
"app_secret": "",
"encrypt_key": "",
"verification_token": "",
"random_reaction_emoji": [],
"is_lark": false
}
},
"dingtalk": {
"enabled": false,
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"type": "dingtalk",
"allow_from": [],
"reasoning_channel_id": ""
"reasoning_channel_id": "",
"settings": {
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET"
}
},
"slack": {
"enabled": false,
"bot_token": "xoxb-YOUR-BOT-TOKEN",
"app_token": "xapp-YOUR-APP-TOKEN",
"type": "slack",
"allow_from": [],
"reasoning_channel_id": ""
"reasoning_channel_id": "",
"settings": {
"bot_token": "xoxb-YOUR-BOT-TOKEN",
"app_token": "xapp-YOUR-APP-TOKEN"
}
},
"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,
"type": "matrix",
"allow_from": [],
"group_trigger": {
"mention_only": true
@@ -177,68 +211,82 @@
"text": ["Thinking...", "Processing...", "Typing..."]
},
"reasoning_channel_id": "",
"crypto_database_path": "",
"crypto_passphrase": "YOUR_MATRIX_CRYPTO_PICKLE_KEY"
"settings": {
"homeserver": "https://matrix.org",
"user_id": "@your-bot:matrix.org",
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
"device_id": "",
"join_on_invite": true,
"crypto_database_path": "",
"crypto_passphrase": "YOUR_MATRIX_CRYPTO_PICKLE_KEY"
}
},
"line": {
"enabled": false,
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
"channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN",
"webhook_path": "/webhook/line",
"type": "line",
"allow_from": [],
"reasoning_channel_id": ""
"reasoning_channel_id": "",
"settings": {
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
"channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN",
"webhook_path": "/webhook/line"
}
},
"onebot": {
"enabled": false,
"ws_url": "ws://127.0.0.1:3001",
"access_token": "",
"reconnect_interval": 5,
"group_trigger_prefix": [],
"type": "onebot",
"allow_from": [],
"reasoning_channel_id": ""
"reasoning_channel_id": "",
"group_trigger": {
"prefixes": []
},
"settings": {
"ws_url": "ws://127.0.0.1:3001",
"access_token": "",
"reconnect_interval": 5
}
},
"wecom": {
"_comment": "WeCom AI Bot over WebSocket.",
"enabled": false,
"bot_id": "YOUR_BOT_ID",
"secret": "YOUR_SECRET",
"websocket_url": "wss://openws.work.weixin.qq.com",
"send_thinking_message": true,
"type": "wecom",
"allow_from": [],
"reasoning_channel_id": ""
"reasoning_channel_id": "",
"settings": {
"bot_id": "YOUR_BOT_ID",
"secret": "YOUR_SECRET",
"websocket_url": "wss://openws.work.weixin.qq.com",
"send_thinking_message": true
}
},
"pico": {
"enabled": false,
"token": "YOUR_PICO_TOKEN",
"allow_token_query": false,
"allow_origins": [],
"ping_interval": 30,
"read_timeout": 60,
"max_connections": 100,
"allow_from": []
"type": "pico",
"allow_from": [],
"settings": {
"token": "YOUR_PICO_TOKEN",
"allow_token_query": false,
"allow_origins": [],
"ping_interval": 30,
"read_timeout": 60,
"max_connections": 100
}
},
"pico_client": {
"enabled": false,
"url": "wss://remote-pico-server/pico/ws",
"token": "YOUR_PICO_TOKEN",
"session_id": "",
"ping_interval": 30,
"read_timeout": 60,
"allow_from": []
"type": "pico_client",
"allow_from": [],
"settings": {
"url": "wss://remote-pico-server/pico/ws",
"token": "YOUR_PICO_TOKEN",
"session_id": "",
"ping_interval": 30,
"read_timeout": 60
}
},
"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"],
"type": "irc",
"allow_from": [],
"group_trigger": {
"mention_only": true
@@ -246,7 +294,20 @@
"typing": {
"enabled": false
},
"reasoning_channel_id": ""
"reasoning_channel_id": "",
"settings": {
"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"]
}
}
},
"tools": {
@@ -255,7 +316,6 @@
"web": {
"enabled": true,
"prefer_native": true,
"fetch_limit_bytes": 10485760,
"format": "plaintext",
"brave": {
"enabled": false,
@@ -278,6 +338,12 @@
"enabled": false,
"max_results": 5
},
"gemini": {
"enabled": false,
"api_key": "",
"model": "gemini-2.5-flash",
"max_results": 5
},
"perplexity": {
"enabled": false,
"api_key": "pplx-xxx",
@@ -436,6 +502,9 @@
"enabled": true,
"mode": "bytes"
},
"serial": {
"enabled": false
},
"send_tts": {
"enabled": false
},
@@ -475,6 +544,15 @@
"approval_timeout_ms": 60000
}
},
"events": {
"logging": {
"enabled": true,
"include": ["agent.*"],
"exclude": [],
"min_severity": "info",
"include_payload": false
}
},
"gateway": {
"_comment": "Default log level is set to 'fatal'. Other available options are 'debug', 'info', 'warn' and 'error'.",
"host": "localhost",
+1 -1
View File
@@ -1,7 +1,7 @@
# ============================================================
# Stage 1: Build the picoclaw binary
# ============================================================
FROM golang:1.26.0-alpine AS builder
FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git make
-1
View File
@@ -6,7 +6,6 @@ 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 ["-console", "-public", "-no-browser"]
+1 -1
View File
@@ -1,7 +1,7 @@
# ============================================================
# Stage 1: Build the picoclaw binary
# ============================================================
FROM golang:1.26.0-alpine AS builder
FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git make
+65
View File
@@ -0,0 +1,65 @@
# ============================================================
# Stage 1: Build frontend assets (Node.js + pnpm)
# ============================================================
FROM node:24-alpine3.23 AS frontend
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /src/web/frontend
# Cache frontend dependencies
COPY web/frontend/package.json web/frontend/pnpm-lock.yaml ./
RUN CI=true pnpm install --frozen-lockfile
# Build frontend
COPY web/frontend/ ./
RUN pnpm build:backend
# ============================================================
# Stage 2: Build Go binaries (picoclaw + picoclaw-launcher)
# ============================================================
FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git make
WORKDIR /src
# Cache Go dependencies
COPY go.mod go.sum ./
RUN go mod download
# Copy source
COPY . .
# Copy pre-built frontend assets into the backend embed directory
COPY --from=frontend /src/web/backend/dist web/backend/dist
# Build picoclaw binary (includes go generate)
RUN make build
# Build picoclaw-launcher binary (frontend already built in stage 1)
# Mirror ldflags from web/Makefile to inject version metadata
RUN CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config && \
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo dev) && \
GIT_COMMIT=$(git rev-parse --short=8 HEAD 2>/dev/null || echo dev) && \
BUILD_TIME=$(date +%FT%T%z) && \
GO_VERSION=$(go env GOVERSION) && \
CGO_ENABLED=0 go build -v -tags goolm,stdjson \
-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" \
-o build/picoclaw-launcher ./web/backend/
# ============================================================
# Stage 3: Minimal runtime image
# ============================================================
FROM alpine:3.23
RUN apk add --no-cache ca-certificates tzdata curl
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q --spider http://localhost:18790/health || exit 1
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
COPY --from=builder /src/build/picoclaw-launcher /usr/local/bin/picoclaw-launcher
ENTRYPOINT ["picoclaw-launcher"]
CMD ["-console", "-public", "-no-browser"]
+9
View File
@@ -4,6 +4,9 @@ services:
# docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Hello"
# ─────────────────────────────────────────────
picoclaw-agent:
build:
context: ..
dockerfile: docker/Dockerfile
image: docker.io/sipeed/picoclaw:latest
container_name: picoclaw-agent
profiles:
@@ -22,6 +25,9 @@ services:
# docker compose -f docker/docker-compose.yml --profile gateway up
# ─────────────────────────────────────────────
picoclaw-gateway:
build:
context: ..
dockerfile: docker/Dockerfile
image: docker.io/sipeed/picoclaw:latest
container_name: picoclaw-gateway
restart: unless-stopped
@@ -38,6 +44,9 @@ services:
# docker compose -f docker/docker-compose.yml --profile launcher up
# ─────────────────────────────────────────────
picoclaw-launcher:
build:
context: ..
dockerfile: docker/Dockerfile.launcher
image: docker.io/sipeed/picoclaw:launcher
container_name: picoclaw-launcher
restart: unless-stopped
+6
View File
@@ -12,4 +12,10 @@ if [ ! -d "${HOME}/.picoclaw/workspace" ] && [ ! -f "${HOME}/.picoclaw/config.js
exit 0
fi
# Remove stale PID file from a previous container run.
# After docker kill / OOM / crash the PID file may linger on the bind-mounted
# volume and block the next gateway start (the recorded PID could collide with
# an unrelated process inside the new container).
rm -f "${HOME}/.picoclaw/.picoclaw.pid"
exec picoclaw gateway "$@"
+2
View File
@@ -6,6 +6,8 @@ Internal architecture notes for major runtime mechanisms and subsystem design.
- [SubTurn Mechanism](subturn.md): sub-agent coordination, concurrency control, and lifecycle handling.
- [Session System](session-system.md): session scope allocation, JSONL persistence, alias compatibility, and migration. ([ZH](session-system.zh.md))
- [Routing System](routing-system.md): agent dispatch, session policy selection, and light/heavy model routing. ([ZH](routing-system.zh.md))
- [Runtime Events](runtime-events.md): runtime event envelope, centralized event logging, filters, and examples. ([ZH](runtime-events.zh.md))
- [Agent Self-Evolution](agent-self-evolution.md): learning records, draft generation, application modes, and state layout.
- [Hook System Guide](hooks/README.md): current hook architecture and protocol details.
- [Agent Refactor](agent-refactor/README.md): notes and checkpoints for the agent refactor work.
+47
View File
@@ -0,0 +1,47 @@
# Agent Self-Evolution
Agent self-evolution lets PicoClaw learn from completed turns and turn repeated successful behavior into skill improvements. The runtime is controlled by the top-level `evolution` config block.
## Flow
The hot path runs at the end of an agent turn. When `evolution.enabled` is true, it records a learning record with the turn summary, success state, used skills, tool executions, and session/workspace metadata. Heartbeat turns are skipped.
The cold path groups related task records, checks the configured success threshold, and prepares skill drafts for patterns that have enough evidence. Drafts can target new skills or append/replace/merge existing workspace skills.
The apply path validates generated `SKILL.md` content before writing. Invalid drafts are rejected before a skill directory or file is created.
## Safety Considerations
Evolution creates a persistent feedback loop: user input can become a task record, task records can be clustered into an LLM-generated draft, and an accepted draft can become `SKILL.md` content that is loaded into future agent prompts. Treat generated skill content as prompt-sensitive material, especially in `apply` mode.
The current local scanner is a narrow guardrail, not a complete safety boundary. It rejects structurally invalid drafts and a small set of obvious secret-like substrings, but it does not reliably detect prompt injection, unsafe instructions, or every form of sensitive data. Use `observe` or `draft` when human review is required before skill changes reach disk.
In `apply` mode, accepted drafts can update workspace skills automatically. Existing skills are backed up before replacement, but recovery is manual: an operator must restore the desired backup if an applied skill should be rolled back.
## Modes
| Mode | Behavior |
|------|----------|
| `observe` | Record learning data only. No cold-path draft generation runs automatically. |
| `draft` | Record learning data and generate candidate skill drafts when the cold path runs. |
| `apply` | Generate drafts and allow accepted drafts to update workspace skills. |
When `evolution.enabled` is false, `mode` is treated as disabled at runtime.
## Cold Path Trigger
`cold_path_trigger` only matters in `draft` and `apply` modes.
| Trigger | Behavior |
|---------|----------|
| `after_turn` | Run the cold path after eligible turns. |
| `scheduled` | Run the cold path at configured `cold_path_times`. |
| `manual` | Do not run automatically. There is no user-facing Web/API/CLI trigger yet; code can still invoke `Runtime.RunColdPathOnce`. |
`cold_path_times` uses `HH:MM` strings and is ignored unless the trigger is `scheduled`.
## State
By default, evolution state is stored under the workspace. `state_dir` can redirect that state to another directory. The state includes learning records, clustered pattern records, drafts, and skill profiles.
For user-facing configuration fields, see the [Configuration Guide](../guides/configuration.md#agent-self-evolution).
+21 -20
View File
@@ -13,7 +13,7 @@ The repository no longer ships standalone example source files. The Go and Pytho
| Type | Interface | Stage | Can modify data |
| --- | --- | --- | --- |
| Observer | `EventObserver` | EventBus broadcast | No |
| Observer | `RuntimeEventObserver` | Runtime event bus broadcast | No |
| LLM interceptor | `LLMInterceptor` | `before_llm` / `after_llm` | Yes |
| Tool interceptor | `ToolInterceptor` | `before_tool` / `after_tool` | Yes |
| Tool approver | `ToolApprover` | `approve_tool` | No, returns allow/deny |
@@ -136,9 +136,9 @@ Example:
"/tmp/review_gate.py"
],
"observe": [
"tool_exec_start",
"tool_exec_end",
"tool_exec_skipped"
"agent.tool.exec_start",
"agent.tool.exec_end",
"agent.tool.exec_skipped"
],
"intercept": [
"before_tool",
@@ -174,7 +174,7 @@ Both examples are intentionally safe: they only log, never rewrite, and never de
The following is a minimal logging hook for in-process use. It implements:
1. `EventObserver`
1. `RuntimeEventObserver`
2. `LLMInterceptor`
3. `ToolInterceptor`
4. `ToolApprover`
@@ -196,6 +196,7 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/agent"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/logger"
)
@@ -217,12 +218,12 @@ func NewExampleLoggerHook(opts ExampleLoggerHookOptions) *ExampleLoggerHook {
}
}
func (h *ExampleLoggerHook) OnEvent(ctx context.Context, evt agent.Event) error {
func (h *ExampleLoggerHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error {
_ = ctx
if h == nil || !h.logEvents {
return nil
}
h.record("event", evt.Meta, map[string]any{
h.record("event", evt.Scope, map[string]any{
"event": evt.Kind.String(),
"payload": evt.Payload,
}, nil)
@@ -275,7 +276,7 @@ func (h *ExampleLoggerHook) ApproveTool(
return decision, nil
}
func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload any, decision any) {
func (h *ExampleLoggerHook) record(stage string, refs any, payload any, decision any) {
logger.InfoCF("hooks", "Example hook observed", map[string]any{
"stage": stage,
})
@@ -286,7 +287,7 @@ func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload a
entry := map[string]any{
"ts": time.Now().UTC(),
"stage": stage,
"meta": meta,
"refs": refs,
"payload": payload,
"decision": decision,
}
@@ -428,7 +429,7 @@ If you only see `before_llm` and `after_llm`, that usually means the request did
The following script is a minimal process-hook example. It uses only the Python standard library and supports:
1. `hook.hello`
2. `hook.event`
2. `hook.runtime_event`
3. `hook.before_tool`
4. `hook.approve_tool`
@@ -564,8 +565,8 @@ def main() -> int:
})
if not message_id:
if method == "hook.event" and LOG_EVENTS:
log_stderr(f"observed event: {params.get('Kind')}")
if method == "hook.runtime_event" and LOG_EVENTS:
log_stderr(f"observed event: {params.get('kind')}")
continue
try:
@@ -606,9 +607,9 @@ if __name__ == "__main__":
"/abs/path/to/review_gate.py"
],
"observe": [
"tool_exec_start",
"tool_exec_end",
"tool_exec_skipped"
"agent.tool.exec_start",
"agent.tool.exec_end",
"agent.tool.exec_skipped"
],
"intercept": [
"before_tool",
@@ -626,7 +627,7 @@ if __name__ == "__main__":
### Environment Variables
- `PICOCLAW_HOOK_LOG_EVENTS`
Whether to write `hook.event` summaries to `stderr`, enabled by default
Whether to write `hook.runtime_event` summaries to `stderr`, enabled by default
- `PICOCLAW_HOOK_LOG_FILE`
Path to an external log file. When set, the script appends inbound hook requests, notifications, and outbound responses as JSON Lines
@@ -645,7 +646,7 @@ Typical interpretation:
- Only `hook.hello`
The process started and completed the handshake, but no business hook request has arrived yet
- `hook.event`
- `hook.runtime_event`
The `observe` configuration is working
- `hook.before_tool`
The `intercept: ["before_tool", ...]` configuration is working
@@ -664,7 +665,7 @@ A complete sample:
```json
{"ts":"2026-03-21T14:12:00+00:00","direction":"in","id":1,"method":"hook.hello","params":{"name":"py_review_gate","version":1,"modes":["observe","tool","approve"]},"notification":false}
{"ts":"2026-03-21T14:12:00+00:00","direction":"out","id":1,"response":{"ok":true,"name":"python-review-gate"},"error":null}
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.event","params":{"Kind":"tool_exec_start"},"notification":true}
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.runtime_event","params":{"kind":"agent.tool.exec_start"},"notification":true}
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":7,"method":"hook.before_tool","params":{"tool":"echo_text","arguments":{"text":"hello"}},"notification":false}
{"ts":"2026-03-21T14:12:05+00:00","direction":"out","id":7,"response":{"action":"continue"},"error":null}
```
@@ -672,7 +673,7 @@ A complete sample:
Additional notes:
- Timestamps are UTC
- `notification=true` means it was a notification such as `hook.event`, which does not expect a response
- `notification=true` means it was a notification such as `hook.runtime_event`, which does not expect a response
- `id` increases within a single hook process; if the process restarts, the counter starts over
## Process-Hook Protocol
@@ -681,7 +682,7 @@ Current process hooks use `JSON-RPC over stdio`:
- PicoClaw starts the external process
- Requests and responses are exchanged as one JSON message per line
- `hook.event` is a notification and does not need a response
- `hook.runtime_event` is a notification and does not need a response
- `hook.before_llm`, `hook.after_llm`, `hook.before_tool`, `hook.after_tool`, and `hook.approve_tool` are request/response calls
The host does not currently accept new RPCs initiated by the process hook. In practice, that means an external hook can only respond to PicoClaw calls; it cannot call back into the host to send channel messages.
+21 -20
View File
@@ -13,7 +13,7 @@
| 类型 | 接口 | 作用阶段 | 能否改写 |
| --- | --- | --- | --- |
| 观察型 | `EventObserver` | EventBus 广播事件时 | 否 |
| 观察型 | `RuntimeEventObserver` | runtime event bus 广播事件时 | 否 |
| LLM 拦截型 | `LLMInterceptor` | `before_llm` / `after_llm` | 是 |
| Tool 拦截型 | `ToolInterceptor` | `before_tool` / `after_tool` | 是 |
| Tool 审批型 | `ToolApprover` | `approve_tool` | 否,返回批准/拒绝 |
@@ -136,9 +136,9 @@ HookManager 的排序规则是:
"/tmp/review_gate.py"
],
"observe": [
"tool_exec_start",
"tool_exec_end",
"tool_exec_skipped"
"agent.tool.exec_start",
"agent.tool.exec_end",
"agent.tool.exec_skipped"
],
"intercept": [
"before_tool",
@@ -174,7 +174,7 @@ tail -f /tmp/picoclaw-hook-review-gate.log
下面这段代码是一个最小的“记录型” in-process hook。它实现了:
1. `EventObserver`
1. `RuntimeEventObserver`
2. `LLMInterceptor`
3. `ToolInterceptor`
4. `ToolApprover`
@@ -196,6 +196,7 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/agent"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/logger"
)
@@ -217,12 +218,12 @@ func NewExampleLoggerHook(opts ExampleLoggerHookOptions) *ExampleLoggerHook {
}
}
func (h *ExampleLoggerHook) OnEvent(ctx context.Context, evt agent.Event) error {
func (h *ExampleLoggerHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error {
_ = ctx
if h == nil || !h.logEvents {
return nil
}
h.record("event", evt.Meta, map[string]any{
h.record("event", evt.Scope, map[string]any{
"event": evt.Kind.String(),
"payload": evt.Payload,
}, nil)
@@ -275,7 +276,7 @@ func (h *ExampleLoggerHook) ApproveTool(
return decision, nil
}
func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload any, decision any) {
func (h *ExampleLoggerHook) record(stage string, refs any, payload any, decision any) {
logger.InfoCF("hooks", "Example hook observed", map[string]any{
"stage": stage,
})
@@ -286,7 +287,7 @@ func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload a
entry := map[string]any{
"ts": time.Now().UTC(),
"stage": stage,
"meta": meta,
"refs": refs,
"payload": payload,
"decision": decision,
}
@@ -428,7 +429,7 @@ func init() {
下面这段脚本是一个最小的 `process hook` 示例。它只使用 Python 标准库,支持:
1. `hook.hello`
2. `hook.event`
2. `hook.runtime_event`
3. `hook.before_tool`
4. `hook.approve_tool`
@@ -564,8 +565,8 @@ def main() -> int:
})
if not message_id:
if method == "hook.event" and LOG_EVENTS:
log_stderr(f"observed event: {params.get('Kind')}")
if method == "hook.runtime_event" and LOG_EVENTS:
log_stderr(f"observed event: {params.get('kind')}")
continue
try:
@@ -606,9 +607,9 @@ if __name__ == "__main__":
"/abs/path/to/review_gate.py"
],
"observe": [
"tool_exec_start",
"tool_exec_end",
"tool_exec_skipped"
"agent.tool.exec_start",
"agent.tool.exec_end",
"agent.tool.exec_skipped"
],
"intercept": [
"before_tool",
@@ -626,7 +627,7 @@ if __name__ == "__main__":
### 环境变量
- `PICOCLAW_HOOK_LOG_EVENTS`
是否把 `hook.event` 写到 `stderr`,默认开启
是否把 `hook.runtime_event` 写到 `stderr`,默认开启
- `PICOCLAW_HOOK_LOG_FILE`
外部日志文件路径。设置后,脚本会把收到的 hook 请求、notification 和返回结果按 JSON Lines 追加到该文件
@@ -645,7 +646,7 @@ if __name__ == "__main__":
- 只看到 `hook.hello`
说明进程启动并完成握手了,但还没有新的业务 hook 请求真正打进来
- 看到 `hook.event`
- 看到 `hook.runtime_event`
说明 `observe` 配置生效了
- 看到 `hook.before_tool`
说明 `intercept: ["before_tool", ...]` 生效了
@@ -664,7 +665,7 @@ if __name__ == "__main__":
```json
{"ts":"2026-03-21T14:12:00+00:00","direction":"in","id":1,"method":"hook.hello","params":{"name":"py_review_gate","version":1,"modes":["observe","tool","approve"]},"notification":false}
{"ts":"2026-03-21T14:12:00+00:00","direction":"out","id":1,"response":{"ok":true,"name":"python-review-gate"},"error":null}
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.event","params":{"Kind":"tool_exec_start"},"notification":true}
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.runtime_event","params":{"kind":"agent.tool.exec_start"},"notification":true}
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":7,"method":"hook.before_tool","params":{"tool":"echo_text","arguments":{"text":"hello"}},"notification":false}
{"ts":"2026-03-21T14:12:05+00:00","direction":"out","id":7,"response":{"action":"continue"},"error":null}
```
@@ -672,7 +673,7 @@ if __name__ == "__main__":
补充说明:
- 时间戳是 UTC,不是本地时区
- `notification=true` 表示这是 `hook.event` 这类不需要响应的通知
- `notification=true` 表示这是 `hook.runtime_event` 这类不需要响应的通知
- `id` 会随着当前进程内的请求递增;如果 hook 进程重启,计数会重新开始
## Process Hook 协议约定
@@ -681,7 +682,7 @@ if __name__ == "__main__":
- PicoClaw 启动外部进程
- 请求和响应都按“一行一个 JSON 消息”传输
- `hook.event` 是 notification,不需要响应
- `hook.runtime_event` 是 notification,不需要响应
- `hook.before_llm` / `hook.after_llm` / `hook.before_tool` / `hook.after_tool` / `hook.approve_tool` 是 request/response
当前宿主不会接受 process hook 主动发起的新 RPC。也就是说,外部 hook 现在只能“响应 PicoClaw 的调用”,不能反向调用宿主去发送 channel 消息。
+29 -20
View File
@@ -437,21 +437,28 @@ Approval hook for deciding whether to allow execution of sensitive tools.
---
## 7. `hook.event` (notification)
## 7. `hook.runtime_event` (notification)
Observer event, broadcast only, no response required. `id` is `0` or absent.
Runtime observer event, broadcast only, no response required. `id` is `0` or absent.
```json
{
"jsonrpc": "2.0",
"method": "hook.event",
"method": "hook.runtime_event",
"params": {
"Kind": "tool_exec_start",
"Meta": {
"AgentID": "agent-1",
"TurnID": "turn-1"
"kind": "agent.tool.exec_start",
"source": {
"component": "agent",
"name": "agent-1"
},
"Payload": {
"scope": {
"agent_id": "agent-1",
"session_key": "session-1",
"turn_id": "turn-1",
"channel": "cli",
"chat_id": "chat-1"
},
"payload": {
"Tool": "echo_text",
"Arguments": {"text": "hello"}
}
@@ -460,12 +467,14 @@ Observer event, broadcast only, no response required. `id` is `0` or absent.
```
Common `Kind` values:
- `turn_start` / `turn_end`
- `llm_request` / `llm_response`
- `tool_exec_start` / `tool_exec_end` / `tool_exec_skipped`
- `steering_injected`
- `interrupt_received`
- `error`
- `agent.turn.start` / `agent.turn.end`
- `agent.llm.request` / `agent.llm.response`
- `agent.tool.exec_start` / `agent.tool.exec_end` / `agent.tool.exec_skipped`
- `agent.steering.injected`
- `agent.interrupt.received`
- `agent.error`
Legacy observe configuration names such as `turn_end` and `tool_exec_start` are still accepted and normalized to runtime event names. New process hook notifications use `hook.runtime_event`.
---
@@ -513,7 +522,7 @@ Standard flow for plugin tool injection:
```python
def handle_before_llm(params: dict) -> dict:
tools = params.get("tools", [])
# Add plugin tool definition
tools.append({
"type": "function",
@@ -529,7 +538,7 @@ def handle_before_llm(params: dict) -> dict:
}
}
})
return {
"action": "modify",
"request": {
@@ -546,12 +555,12 @@ def handle_before_llm(params: dict) -> dict:
```python
def handle_before_tool(params: dict) -> dict:
tool = params.get("tool", "")
if tool == "my_plugin_tool":
# Implement tool logic here
args = params.get("arguments", {})
input_text = args.get("input", "")
# Return result directly, no need to register in ToolRegistry
return {
"action": "respond",
@@ -561,8 +570,8 @@ def handle_before_tool(params: dict) -> dict:
"is_error": False
}
}
return {"action": "continue"}
```
This way, external hooks can fully implement plugin tools without registering any tool implementation inside PicoClaw.
This way, external hooks can fully implement plugin tools without registering any tool implementation inside PicoClaw.
@@ -437,21 +437,28 @@
---
## 7. `hook.event`notification
## 7. `hook.runtime_event`notification
观察型事件,仅广播,无需响应。`id``0` 或不存在。
runtime 观察型事件,仅广播,无需响应。`id``0` 或不存在。
```json
{
"jsonrpc": "2.0",
"method": "hook.event",
"method": "hook.runtime_event",
"params": {
"Kind": "tool_exec_start",
"Meta": {
"AgentID": "agent-1",
"TurnID": "turn-1"
"kind": "agent.tool.exec_start",
"source": {
"component": "agent",
"name": "agent-1"
},
"Payload": {
"scope": {
"agent_id": "agent-1",
"session_key": "session-1",
"turn_id": "turn-1",
"channel": "cli",
"chat_id": "chat-1"
},
"payload": {
"Tool": "echo_text",
"Arguments": {"text": "hello"}
}
@@ -460,12 +467,14 @@
```
常见 `Kind` 值:
- `turn_start` / `turn_end`
- `llm_request` / `llm_response`
- `tool_exec_start` / `tool_exec_end` / `tool_exec_skipped`
- `steering_injected`
- `interrupt_received`
- `error`
- `agent.turn.start` / `agent.turn.end`
- `agent.llm.request` / `agent.llm.response`
- `agent.tool.exec_start` / `agent.tool.exec_end` / `agent.tool.exec_skipped`
- `agent.steering.injected`
- `agent.interrupt.received`
- `agent.error`
旧 observe 配置名如 `turn_end``tool_exec_start` 仍然可用,并会归一化为 runtime event 名称。新的 process hook 通知使用 `hook.runtime_event`
---
@@ -513,7 +522,7 @@
```python
def handle_before_llm(params: dict) -> dict:
tools = params.get("tools", [])
# 添加插件工具定义
tools.append({
"type": "function",
@@ -529,7 +538,7 @@ def handle_before_llm(params: dict) -> dict:
}
}
})
return {
"action": "modify",
"request": {
@@ -546,12 +555,12 @@ def handle_before_llm(params: dict) -> dict:
```python
def handle_before_tool(params: dict) -> dict:
tool = params.get("tool", "")
if tool == "my_plugin_tool":
# 在这里实现工具逻辑
args = params.get("arguments", {})
input_text = args.get("input", "")
# 直接返回结果,无需在 ToolRegistry 注册
return {
"action": "respond",
@@ -561,8 +570,8 @@ def handle_before_tool(params: dict) -> dict:
"is_error": False
}
}
return {"action": "continue"}
```
通过这种方式,外部 hook 可以完全实现插件工具,无需在 PicoClaw 内部注册任何工具实现。
通过这种方式,外部 hook 可以完全实现插件工具,无需在 PicoClaw 内部注册任何工具实现。
@@ -67,7 +67,7 @@ def handle_hello(params: dict) -> dict:
def handle_before_llm(params: dict) -> dict:
"""Inject weather query tool definition"""
tools = params.get("tools", [])
# Add weather query tool
tools.append({
"type": "function",
@@ -86,7 +86,7 @@ def handle_before_llm(params: dict) -> dict:
}
}
})
return {
"action": "modify",
"request": {
@@ -102,17 +102,17 @@ def handle_before_tool(params: dict) -> dict:
"""Handle tool call, return result directly"""
tool = params.get("tool", "")
args = params.get("arguments", {})
if tool == "get_weather":
city = args.get("city", "")
result = get_weather(city)
# Use respond action to return result directly, skip ToolRegistry
return {
"action": "respond",
"result": result,
}
# Other tools continue normal flow
return {"action": "continue"}
@@ -142,7 +142,7 @@ def send_response(message_id: int, result: Any | None = None, error: str | None
payload["error"] = {"code": -32000, "message": error}
else:
payload["result"] = result if result is not None else {}
sys.stdout.write(json.dumps(payload, ensure_ascii=True) + "\n")
sys.stdout.flush()
@@ -152,19 +152,19 @@ def main() -> int:
line = raw_line.strip()
if not line:
continue
try:
message = json.loads(line)
except json.JSONDecodeError:
continue
method = message.get("method")
message_id = message.get("id", 0)
params = message.get("params") or {}
if not message_id:
continue
try:
result = handle_request(str(method or ""), params)
send_response(int(message_id), result=result)
@@ -172,7 +172,7 @@ def main() -> int:
send_response(int(message_id), error=str(exc))
except Exception as exc:
send_response(int(message_id), error=f"unexpected error: {exc}")
return 0
@@ -375,7 +375,7 @@ Multiple tools can be injected simultaneously:
```python
def handle_before_llm(params: dict) -> dict:
tools = params.get("tools", [])
# Tool 1: Weather query
tools.append({
"type": "function",
@@ -391,7 +391,7 @@ def handle_before_llm(params: dict) -> dict:
}
}
})
# Tool 2: Calculator
tools.append({
"type": "function",
@@ -407,7 +407,7 @@ def handle_before_llm(params: dict) -> dict:
}
}
})
return {
"action": "modify",
"request": {
@@ -422,13 +422,13 @@ def handle_before_llm(params: dict) -> dict:
def handle_before_tool(params: dict) -> dict:
tool = params.get("tool", "")
args = params.get("arguments", {})
if tool == "get_weather":
return {
"action": "respond",
"result": get_weather(args.get("city", "")),
}
if tool == "calculate":
# Simple calculation example
try:
@@ -451,7 +451,7 @@ def handle_before_tool(params: dict) -> dict:
"is_error": True,
},
}
return {"action": "continue"}
```
@@ -504,7 +504,7 @@ func (h *WeatherPluginHook) BeforeLLM(
},
},
})
return req, agent.HookDecision{Action: agent.HookActionContinue}, nil
}
@@ -514,7 +514,7 @@ func (h *WeatherPluginHook) BeforeTool(
) (*agent.ToolCallHookRequest, agent.HookDecision, error) {
if call.Tool == "get_weather" {
city := call.Arguments["city"].(string)
// Set HookResult, use respond action
next := call.Clone()
next.HookResult = &tools.ToolResult{
@@ -522,10 +522,10 @@ func (h *WeatherPluginHook) BeforeTool(
Silent: false,
IsError: false,
}
return next, agent.HookDecision{Action: agent.HookActionRespond}, nil
}
return call, agent.HookDecision{Action: agent.HookActionContinue}, nil
}
@@ -572,14 +572,14 @@ This means:
def handle_before_tool(params: dict) -> dict:
tool = params.get("tool", "")
args = params.get("arguments", {})
# Security check: only handle plugin tools
if tool in ["get_weather", "calculate"]:
return {
"action": "respond",
"result": execute_plugin_tool(tool, args),
}
# Other tools continue normal flow (will go through approval)
return {"action": "continue"}
```
@@ -67,7 +67,7 @@ def handle_hello(params: dict) -> dict:
def handle_before_llm(params: dict) -> dict:
"""注入天气查询工具定义"""
tools = params.get("tools", [])
# 添加天气查询工具
tools.append({
"type": "function",
@@ -86,7 +86,7 @@ def handle_before_llm(params: dict) -> dict:
}
}
})
return {
"action": "modify",
"request": {
@@ -102,17 +102,17 @@ def handle_before_tool(params: dict) -> dict:
"""处理工具调用,直接返回结果"""
tool = params.get("tool", "")
args = params.get("arguments", {})
if tool == "get_weather":
city = args.get("city", "")
result = get_weather(city)
# 使用 respond action 直接返回结果,跳过 ToolRegistry
return {
"action": "respond",
"result": result,
}
# 其他工具继续正常流程
return {"action": "continue"}
@@ -142,7 +142,7 @@ def send_response(message_id: int, result: Any | None = None, error: str | None
payload["error"] = {"code": -32000, "message": error}
else:
payload["result"] = result if result is not None else {}
sys.stdout.write(json.dumps(payload, ensure_ascii=True) + "\n")
sys.stdout.flush()
@@ -152,19 +152,19 @@ def main() -> int:
line = raw_line.strip()
if not line:
continue
try:
message = json.loads(line)
except json.JSONDecodeError:
continue
method = message.get("method")
message_id = message.get("id", 0)
params = message.get("params") or {}
if not message_id:
continue
try:
result = handle_request(str(method or ""), params)
send_response(int(message_id), result=result)
@@ -172,7 +172,7 @@ def main() -> int:
send_response(int(message_id), error=str(exc))
except Exception as exc:
send_response(int(message_id), error=f"unexpected error: {exc}")
return 0
@@ -375,7 +375,7 @@ media://<store-id>
```python
def handle_before_llm(params: dict) -> dict:
tools = params.get("tools", [])
# 工具1:天气查询
tools.append({
"type": "function",
@@ -391,7 +391,7 @@ def handle_before_llm(params: dict) -> dict:
}
}
})
# 工具2:计算器
tools.append({
"type": "function",
@@ -407,7 +407,7 @@ def handle_before_llm(params: dict) -> dict:
}
}
})
return {
"action": "modify",
"request": {
@@ -422,13 +422,13 @@ def handle_before_llm(params: dict) -> dict:
def handle_before_tool(params: dict) -> dict:
tool = params.get("tool", "")
args = params.get("arguments", {})
if tool == "get_weather":
return {
"action": "respond",
"result": get_weather(args.get("city", "")),
}
if tool == "calculate":
# 简单计算示例
try:
@@ -451,7 +451,7 @@ def handle_before_tool(params: dict) -> dict:
"is_error": True,
},
}
return {"action": "continue"}
```
@@ -504,7 +504,7 @@ func (h *WeatherPluginHook) BeforeLLM(
},
},
})
return req, agent.HookDecision{Action: agent.HookActionContinue}, nil
}
@@ -514,7 +514,7 @@ func (h *WeatherPluginHook) BeforeTool(
) (*agent.ToolCallHookRequest, agent.HookDecision, error) {
if call.Tool == "get_weather" {
city := call.Arguments["city"].(string)
// 设置 HookResult,使用 respond action
next := call.Clone()
next.HookResult = &tools.ToolResult{
@@ -522,10 +522,10 @@ func (h *WeatherPluginHook) BeforeTool(
Silent: false,
IsError: false,
}
return next, agent.HookDecision{Action: agent.HookActionRespond}, nil
}
return call, agent.HookDecision{Action: agent.HookActionContinue}, nil
}
@@ -572,14 +572,14 @@ func getWeatherData(city string) string {
def handle_before_tool(params: dict) -> dict:
tool = params.get("tool", "")
args = params.get("arguments", {})
# 安全检查:只处理插件工具
if tool in ["get_weather", "calculate"]:
return {
"action": "respond",
"result": execute_plugin_tool(tool, args),
}
# 其他工具继续正常流程(会经过审批)
return {"action": "continue"}
```
+216
View File
@@ -0,0 +1,216 @@
# Runtime Events And Event Logging
PicoClaw runtime events are the read-only observation surface for agent, channel, gateway, message bus, and MCP activity. Publishing events and printing logs are separate responsibilities:
- Event publishing: components publish `pkg/events.Event` values to the runtime event bus for hooks, tests, diagnostics, and future UI consumers.
- Event logging: the built-in runtime event logger subscribes to the same bus and prints only the events selected by configuration.
This keeps runtime code focused on publishing events while log policy stays centralized.
## Default Behavior
By default, only `agent.*` events are printed:
```json
{
"events": {
"logging": {
"enabled": true,
"include": ["agent.*"],
"min_severity": "info",
"include_payload": false
}
}
}
```
This preserves the previous behavior: agent turn, LLM, tool, steering, subturn, and error events appear in logs. Channel, gateway, bus, and MCP events are still published to the runtime event bus, but they are not printed unless configured.
## Configuration
The configuration lives under `events.logging` in `config.json`:
| Field | Type | Default | Description |
| ----- | ---- | ------- | ----------- |
| `enabled` | bool | `true` | Enables the built-in event logger subscription |
| `include` | string[] | `["agent.*"]` | Event kinds to print; supports exact matches, `*`, and patterns such as `agent.*` |
| `exclude` | string[] | `[]` | Event kinds to suppress after include matching |
| `min_severity` | string | `info` | Minimum severity: `debug`, `info`, `warn`, or `error` |
| `include_payload` | bool | `false` | Adds raw event payloads to log fields |
`include_payload` is disabled by default. Agent events print safe summary fields such as `user_len`, `args_count`, and `content_len` instead of full user messages or tool arguments. Enable raw payload logging only for short-lived diagnostics in a trusted log environment.
## Matching Rules
`include` and `exclude` match the `Event.Kind` string:
```json
{
"events": {
"logging": {
"include": ["gateway.*", "channel.lifecycle.*", "agent.error"],
"exclude": ["gateway.ready"],
"min_severity": "info"
}
}
}
```
Common patterns:
- `["agent.*"]`: print agent events only.
- `["*"]`: print all runtime events.
- `["gateway.*", "channel.*"]`: print gateway and channel events only.
- `exclude: ["agent.llm.delta"]`: suppress high-volume streaming delta events.
- `min_severity: "warn"`: print warn and error events only.
## Environment Variables
The same settings can be overridden with environment variables:
```bash
PICOCLAW_EVENTS_LOGGING_ENABLED=true
PICOCLAW_EVENTS_LOGGING_INCLUDE="gateway.*,channel.lifecycle.*"
PICOCLAW_EVENTS_LOGGING_EXCLUDE="gateway.ready"
PICOCLAW_EVENTS_LOGGING_MIN_SEVERITY=info
PICOCLAW_EVENTS_LOGGING_INCLUDE_PAYLOAD=false
```
`include` and `exclude` use comma-separated values.
## Event Names And Triggers
The table below lists the current runtime event kinds, when they are emitted, and the most useful event details. `Source`, `Scope`, and `Correlation` are shared envelope fields that may appear on every event. The "Details" column refers to useful payload fields or log summary fields.
### Agent
| Event | Trigger | Details |
| ----- | ------- | ------- |
| `agent.turn.start` | An agent starts processing one user or system input after the turn scope has been created. | `user_len`, `media_count`; scope usually includes `agent_id`, `session_key`, `turn_id`, `channel`, `chat_id`, `message_id` |
| `agent.turn.end` | A turn exits, whether it completed, errored, or was hard-aborted. | `status` (`completed`/`error`/`aborted`), `iterations_total`, `duration_ms`, `final_len` |
| `agent.llm.request` | Before each LLM provider request. | `model`, `messages`, `tools`, `max_tokens` |
| `agent.llm.delta` | Reserved for streaming LLM deltas; the kind is defined, but the current implementation has no natural emit site. | `content_delta_len`, `reasoning_delta_len` |
| `agent.llm.response` | After the LLM provider returns a complete response. | `content_len`, `tool_calls`, `has_reasoning` |
| `agent.llm.retry` | Before retrying an LLM request after context, rate-limit, transient provider, or fallback handling. | `attempt`, `max_retries`, `reason`, `error`, `backoff_ms` |
| `agent.context.compress` | Agent context history is compressed, for example during proactive budget checks or LLM retry handling. | `reason`, `dropped_messages`, `remaining_messages` |
| `agent.session.summarize` | Async session history summarization completes. | `summarized_messages`, `kept_messages`, `summary_len`, `omitted_oversized` |
| `agent.tool.exec_start` | Before the agent executes a tool call. | `tool`, `args_count`; full arguments are not logged by default |
| `agent.tool.exec_end` | After a tool call completes, including successful results, tool errors, and async results. | `tool`, `duration_ms`, `for_llm_len`, `for_user_len`, `is_error`, `async` |
| `agent.tool.exec_skipped` | A tool call is skipped because the tool is unavailable, arguments are invalid, or turn control logic requires skipping it. | `tool`, `reason` |
| `agent.steering.injected` | Queued steering messages are injected into the next LLM context. | `count`, `total_content_len` |
| `agent.follow_up.queued` | An async tool result is queued back into the inbound/follow-up flow. | `source_tool`, `content_len` |
| `agent.interrupt.received` | A turn accepts steering, graceful interrupt, or hard-abort input. | `interrupt_kind`, `role`, `content_len`, `queue_depth`, `hint_len` |
| `agent.subturn.spawn` | A parent turn creates a child turn/subagent. | `child_agent_id`, `label`, `parent_turn_id` |
| `agent.subturn.end` | A child turn ends. | `child_agent_id`, `status` |
| `agent.subturn.result_delivered` | A child turn result is delivered to the target channel/chat. | `target_channel`, `target_chat_id`, `content_len` |
| `agent.subturn.orphan` | A child turn result cannot be delivered or cannot be associated back to its parent turn. | `parent_turn_id`, `child_turn_id`, `reason` |
| `agent.error` | Agent execution reports an error. | `stage`, `error` |
### Channel
| Event | Trigger | Details |
| ----- | ------- | ------- |
| `channel.lifecycle.initialized` | The channel manager creates and registers a channel instance from config. | `type`; scope includes `channel` |
| `channel.lifecycle.started` | Channel `Start()` succeeds and worker goroutines have been started; added channels during hot reload also emit it. | `type` |
| `channel.lifecycle.start_failed` | Channel `Start()` fails. | `type`, `error`; severity is `error` |
| `channel.lifecycle.stopped` | Channel `Stop()` succeeds. | `type` |
| `channel.webhook.registered` | A channel webhook handler is registered on the shared HTTP mux. | `type`; scope includes `channel` |
| `channel.webhook.unregistered` | A channel webhook handler is removed from the shared HTTP mux. | `type`; scope includes `channel` |
| `channel.message.outbound_queued` | An outbound text or media message is queued into its channel worker. | `media`, `content_len`, `reply_to_message_id`; scope comes from the original inbound context |
| `channel.message.outbound_sent` | An outbound text or media message is sent successfully, or a placeholder edit handled the response. | `media`, `content_len`, `message_ids`, `reply_to_message_id` |
| `channel.message.outbound_failed` | An outbound text or media message exhausts retries or hits a permanent failure. | `media`, `content_len`, `retries`, `error`, `reply_to_message_id`; severity is `error` |
| `channel.rate_limited` | A channel worker is waiting for a rate-limit token and the context is canceled, interrupting this delivery. | `media`, `content_len`, `error`, `reply_to_message_id`; severity is `warn` |
### Message Bus
| Event | Trigger | Details |
| ----- | ------- | ------- |
| `bus.publish.failed` | Publishing inbound, outbound, media, audio, or voice-control data fails, or required context is missing. | `stream`, `error`; scope is derived from message context when possible |
| `bus.close.started` | Message bus shutdown begins. | `drained` is usually `0` |
| `bus.close.drained` | Shutdown waits for buffered messages to drain and at least one buffered message was drained. | `drained` |
| `bus.close.completed` | Message bus shutdown completes. | `drained` |
### Gateway
| Event | Trigger | Details |
| ----- | ------- | ------- |
| `gateway.start` | Gateway startup reaches the agent/runtime event bus/bootstrap binding point. | `duration_ms` |
| `gateway.ready` | Gateway services, channel manager, HTTP server, and other core services are ready. | `duration_ms` |
| `gateway.shutdown` | Gateway shutdown begins. | No fixed payload; envelope fields may be the only fields |
| `gateway.reload.started` | Hot reload execution starts. | `duration_ms` |
| `gateway.reload.completed` | Hot reload completes successfully. | `duration_ms` |
| `gateway.reload.failed` | Hot reload fails. | `duration_ms`, `error`; severity is `error` |
### MCP
| Event | Trigger | Details |
| ----- | ------- | ------- |
| `mcp.server.connecting` | The MCP manager is about to connect to a server. | `server`, `type`, `url`, `command` |
| `mcp.server.connected` | An MCP server connects and its tool list has been initialized. | `server`, `type`, `url`, `command`, `tool_count` |
| `mcp.server.failed` | An MCP server connection fails, or the manager is closed before connecting. | `server`, `type`, `url`, `command`, `error`; severity is `error` |
| `mcp.tool.discovered` | A tool from an MCP server is discovered and registered. | `server`, `type`, `url`, `command`, `tool` |
| `mcp.tool.call.start` | The MCP tool wrapper starts a remote tool call. | `server`, `tool`; when emitted inside an agent turn, scope includes turn/chat information |
| `mcp.tool.call.end` | The MCP tool wrapper finishes a remote tool call, including failures. | `server`, `tool`, `duration_ms`, `is_error`, `error` |
## Log Fields
Runtime event logs include stable envelope fields when available:
- `event_id`
- `event_kind`
- `severity`
- `event_time`
- `source_component`
- `source_name`
- `agent_id`
- `session_key`
- `turn_id`
- `channel`
- `account`
- `chat_id`
- `topic_id`
- `space_id`
- `space_type`
- `chat_type`
- `sender_id`
- `message_id`
- `trace_id`
- `parent_turn_id`
- `request_id`
- `reply_to_id`
Agent events add safe payload summaries:
| Event | Summary fields |
| ----- | -------------- |
| `agent.turn.start` | `user_len`, `media_count` |
| `agent.turn.end` | `status`, `iterations_total`, `duration_ms`, `final_len` |
| `agent.llm.request` | `model`, `messages`, `tools`, `max_tokens` |
| `agent.llm.delta` | `content_delta_len`, `reasoning_delta_len` |
| `agent.llm.response` | `content_len`, `tool_calls`, `has_reasoning` |
| `agent.llm.retry` | `attempt`, `max_retries`, `reason`, `error`, `backoff_ms` |
| `agent.context.compress` | `reason`, `dropped_messages`, `remaining_messages` |
| `agent.session.summarize` | `summarized_messages`, `kept_messages`, `summary_len`, `omitted_oversized` |
| `agent.tool.exec_start` | `tool`, `args_count` |
| `agent.tool.exec_end` | `tool`, `duration_ms`, `for_llm_len`, `for_user_len`, `is_error`, `async` |
| `agent.tool.exec_skipped` | `tool`, `reason` |
| `agent.steering.injected` | `count`, `total_content_len` |
| `agent.follow_up.queued` | `source_tool`, `content_len` |
| `agent.interrupt.received` | `interrupt_kind`, `role`, `content_len`, `queue_depth`, `hint_len` |
| `agent.subturn.spawn` | `child_agent_id`, `label` |
| `agent.subturn.end` | `child_agent_id`, `status` |
| `agent.subturn.result_delivered` | `target_channel`, `target_chat_id`, `content_len` |
| `agent.subturn.orphan` | `parent_turn_id`, `child_turn_id`, `reason` |
| `agent.error` | `stage`, `error` |
## Event Domains
Runtime event kinds are defined in `pkg/events/kind.go`. Event logging can select these domains:
- `agent.*`: agent turn, LLM, tool, context, steering, interrupt, subturn, and error events.
- `channel.*`: channel lifecycle, webhook registration, outbound queued/sent/failed, and rate limiting.
- `bus.*`: publish failures and close lifecycle.
- `gateway.*`: start, ready, shutdown, and reload lifecycle.
- `mcp.*`: MCP server connection, tool discovery, and tool call events.
See [`../../config/config.example.json`](../../config/config.example.json) for the default event logging example.
+216
View File
@@ -0,0 +1,216 @@
# Runtime Events 与事件日志
PicoClaw 的 runtime event 是运行时观察面,用来描述 agent、channel、gateway、message bus、MCP 等组件发生了什么。事件发布和日志打印是两件事:
- 事件发布:组件把 `pkg/events.Event` 发布到 runtime event bus,供 hook、测试、调试工具或后续 UI 消费。
- 事件日志:内置 runtime event logger 订阅同一个 bus,并按配置把匹配的事件打印到日志。
这样可以让业务流程继续只负责发布事件,日志策略统一收口到一个地方。
## 默认行为
默认配置只打印 `agent.*` 事件:
```json
{
"events": {
"logging": {
"enabled": true,
"include": ["agent.*"],
"min_severity": "info",
"include_payload": false
}
}
}
```
这个默认值保持了旧行为:agent turn、LLM、tool、steering、subturn、error 等事件会出现在日志中;channel、gateway、bus、MCP 事件仍会发布到 runtime event bus,但默认不打印,避免网关启动和消息投递日志过于嘈杂。
## 配置项
配置位于 `config.json``events.logging`
| 字段 | 类型 | 默认值 | 说明 |
| ---- | ---- | ------ | ---- |
| `enabled` | bool | `true` | 是否启用内置事件日志订阅器 |
| `include` | string[] | `["agent.*"]` | 允许打印的事件 kind,支持精确匹配、`*``agent.*` 这类 glob/prefix |
| `exclude` | string[] | `[]` | 在 include 命中后排除的事件 kind,匹配规则同 include |
| `min_severity` | string | `info` | 最低打印级别:`debug``info``warn``error` |
| `include_payload` | bool | `false` | 是否把原始 payload 放进日志字段 |
`include_payload` 默认关闭。agent 事件日志会输出安全摘要字段,例如 `user_len``args_count``content_len`,不会默认输出完整用户消息或工具参数。只有在排查问题、并且确认日志存储环境可信时,才建议临时打开 `include_payload`
## 匹配规则
`include``exclude` 都匹配 `Event.Kind` 字符串:
```json
{
"events": {
"logging": {
"include": ["gateway.*", "channel.lifecycle.*", "agent.error"],
"exclude": ["gateway.ready"],
"min_severity": "info"
}
}
}
```
常用写法:
- `["agent.*"]`:只打印 agent 事件。
- `["*"]`:打印所有 runtime events。
- `["gateway.*", "channel.*"]`:只打印 gateway 和 channel 事件。
- `exclude: ["agent.llm.delta"]`:排除高频流式 delta 事件。
- `min_severity: "warn"`:只打印 warn/error 事件。
## 环境变量
同一组配置也可以通过环境变量覆盖,适合临时调试:
```bash
PICOCLAW_EVENTS_LOGGING_ENABLED=true
PICOCLAW_EVENTS_LOGGING_INCLUDE="gateway.*,channel.lifecycle.*"
PICOCLAW_EVENTS_LOGGING_EXCLUDE="gateway.ready"
PICOCLAW_EVENTS_LOGGING_MIN_SEVERITY=info
PICOCLAW_EVENTS_LOGGING_INCLUDE_PAYLOAD=false
```
`include``exclude` 的环境变量使用逗号分隔。
## 事件名称与触发时机
下面列出当前 runtime event kind、触发时机和主要事件详情。`Source``Scope``Correlation` 是所有事件都可能携带的 envelope 字段;表里的“主要详情”指 payload 或日志摘要中最有用的字段。
### Agent
| 事件名 | 触发时机 | 主要详情 |
| ------ | -------- | -------- |
| `agent.turn.start` | agent 开始处理一次用户输入或系统输入,turn scope 已创建时 | `user_len`, `media_count`; scope 通常包含 `agent_id`, `session_key`, `turn_id`, `channel`, `chat_id`, `message_id` |
| `agent.turn.end` | 一次 turn 退出时,无论完成、报错还是 hard abort | `status` (`completed`/`error`/`aborted`), `iterations_total`, `duration_ms`, `final_len` |
| `agent.llm.request` | 每次调用 LLM provider 前 | `model`, `messages`, `tools`, `max_tokens` |
| `agent.llm.delta` | 预留给流式 LLM delta;当前实现已定义但没有自然发送点 | `content_delta_len`, `reasoning_delta_len` |
| `agent.llm.response` | LLM provider 返回完整响应后 | `content_len`, `tool_calls`, `has_reasoning` |
| `agent.llm.retry` | LLM 请求因上下文、限流、临时错误等原因准备重试前 | `attempt`, `max_retries`, `reason`, `error`, `backoff_ms` |
| `agent.context.compress` | 上下文历史被压缩时,例如主动预算检查或 LLM retry 处理 | `reason`, `dropped_messages`, `remaining_messages` |
| `agent.session.summarize` | 会话历史异步摘要完成时 | `summarized_messages`, `kept_messages`, `summary_len`, `omitted_oversized` |
| `agent.tool.exec_start` | agent 准备执行一个工具调用前 | `tool`, `args_count`; 默认不打印完整参数 |
| `agent.tool.exec_end` | 工具调用完成后,包括成功、工具错误和 async 结果 | `tool`, `duration_ms`, `for_llm_len`, `for_user_len`, `is_error`, `async` |
| `agent.tool.exec_skipped` | 工具调用被跳过时,例如工具不可用、参数无效或 turn 控制逻辑要求跳过 | `tool`, `reason` |
| `agent.steering.injected` | queued steering message 被注入下一轮 LLM 上下文时 | `count`, `total_content_len` |
| `agent.follow_up.queued` | async 工具结果被重新排入 inbound/follow-up 流程时 | `source_tool`, `content_len` |
| `agent.interrupt.received` | turn 接受 steering、graceful interrupt 或 hard abort 指令时 | `interrupt_kind`, `role`, `content_len`, `queue_depth`, `hint_len` |
| `agent.subturn.spawn` | 父 turn 创建子 turn/subagent 时 | `child_agent_id`, `label`, `parent_turn_id` |
| `agent.subturn.end` | 子 turn 结束时 | `child_agent_id`, `status` |
| `agent.subturn.result_delivered` | 子 turn 结果成功投递到目标 channel/chat 时 | `target_channel`, `target_chat_id`, `content_len` |
| `agent.subturn.orphan` | 子 turn 结果无法投递或无法关联回父 turn 时 | `parent_turn_id`, `child_turn_id`, `reason` |
| `agent.error` | agent 执行流程报告错误时 | `stage`, `error` |
### Channel
| 事件名 | 触发时机 | 主要详情 |
| ------ | -------- | -------- |
| `channel.lifecycle.initialized` | channel manager 根据配置创建并注册 channel 实例后 | `type`; scope 包含 `channel` |
| `channel.lifecycle.started` | channel `Start()` 成功,worker 已启动时;热重载新增 channel 也会触发 | `type` |
| `channel.lifecycle.start_failed` | channel `Start()` 失败时 | `type`, `error`; severity 为 `error` |
| `channel.lifecycle.stopped` | channel `Stop()` 成功后 | `type` |
| `channel.webhook.registered` | channel 的 webhook handler 被注册到共享 HTTP mux 时 | `type`; scope 包含 `channel` |
| `channel.webhook.unregistered` | channel 的 webhook handler 从共享 HTTP mux 移除时 | `type`; scope 包含 `channel` |
| `channel.message.outbound_queued` | outbound 文本或媒体消息被放入对应 channel worker 队列时 | `media`, `content_len`, `reply_to_message_id`; scope 来自原 inbound context |
| `channel.message.outbound_sent` | outbound 文本或媒体消息成功发送,或 placeholder edit 已处理响应时 | `media`, `content_len`, `message_ids`, `reply_to_message_id` |
| `channel.message.outbound_failed` | outbound 文本或媒体消息重试耗尽或遇到永久失败时 | `media`, `content_len`, `retries`, `error`, `reply_to_message_id`; severity 为 `error` |
| `channel.rate_limited` | channel worker 等待 rate limiter token 时被 context 取消,导致本次发送被限流/中断 | `media`, `content_len`, `error`, `reply_to_message_id`; severity 为 `warn` |
### Message Bus
| 事件名 | 触发时机 | 主要详情 |
| ------ | -------- | -------- |
| `bus.publish.failed` | inbound、outbound、media、audio 或 voice control 发布失败,或缺少必要 context 时 | `stream`, `error`; scope 尽量来自消息 context |
| `bus.close.started` | message bus 开始关闭时 | `drained` 通常为 `0` |
| `bus.close.drained` | close 期间等待队列 drain,并且 drain 到至少一条 buffered message 时 | `drained` |
| `bus.close.completed` | message bus 完成关闭时 | `drained` |
### Gateway
| 事件名 | 触发时机 | 主要详情 |
| ------ | -------- | -------- |
| `gateway.start` | gateway 完成 agent/runtime event bus/bootstrap 绑定后 | `duration_ms` |
| `gateway.ready` | gateway 服务、channel manager、HTTP 等关键服务启动完成后 | `duration_ms` |
| `gateway.shutdown` | gateway 开始关闭流程时 | 无固定 payload,可能只有 envelope 字段 |
| `gateway.reload.started` | 热重载开始执行时 | `duration_ms` |
| `gateway.reload.completed` | 热重载成功完成时 | `duration_ms` |
| `gateway.reload.failed` | 热重载失败时 | `duration_ms`, `error`; severity 为 `error` |
### MCP
| 事件名 | 触发时机 | 主要详情 |
| ------ | -------- | -------- |
| `mcp.server.connecting` | MCP manager 准备连接某个 server 前 | `server`, `type`, `url`, `command` |
| `mcp.server.connected` | MCP server 连接成功并完成工具列表初始化后 | `server`, `type`, `url`, `command`, `tool_count` |
| `mcp.server.failed` | MCP server 连接失败,或 manager 已关闭导致无法连接时 | `server`, `type`, `url`, `command`, `error`; severity 为 `error` |
| `mcp.tool.discovered` | MCP server 的某个工具被发现并注册时 | `server`, `type`, `url`, `command`, `tool` |
| `mcp.tool.call.start` | MCP tool wrapper 开始执行一次远端工具调用前 | `server`, `tool`; 如果在 agent turn 内触发,scope 会带上对应 turn/chat 信息 |
| `mcp.tool.call.end` | MCP tool wrapper 完成一次远端工具调用后,包括失败结果 | `server`, `tool`, `duration_ms`, `is_error`, `error` |
## 日志字段
所有事件日志都会尽量包含稳定 envelope 字段:
- `event_id`
- `event_kind`
- `severity`
- `event_time`
- `source_component`
- `source_name`
- `agent_id`
- `session_key`
- `turn_id`
- `channel`
- `account`
- `chat_id`
- `topic_id`
- `space_id`
- `space_type`
- `chat_type`
- `sender_id`
- `message_id`
- `trace_id`
- `parent_turn_id`
- `request_id`
- `reply_to_id`
agent 事件还会追加 payload 摘要字段:
| 事件 | 摘要字段 |
| ---- | -------- |
| `agent.turn.start` | `user_len`, `media_count` |
| `agent.turn.end` | `status`, `iterations_total`, `duration_ms`, `final_len` |
| `agent.llm.request` | `model`, `messages`, `tools`, `max_tokens` |
| `agent.llm.delta` | `content_delta_len`, `reasoning_delta_len` |
| `agent.llm.response` | `content_len`, `tool_calls`, `has_reasoning` |
| `agent.llm.retry` | `attempt`, `max_retries`, `reason`, `error`, `backoff_ms` |
| `agent.context.compress` | `reason`, `dropped_messages`, `remaining_messages` |
| `agent.session.summarize` | `summarized_messages`, `kept_messages`, `summary_len`, `omitted_oversized` |
| `agent.tool.exec_start` | `tool`, `args_count` |
| `agent.tool.exec_end` | `tool`, `duration_ms`, `for_llm_len`, `for_user_len`, `is_error`, `async` |
| `agent.tool.exec_skipped` | `tool`, `reason` |
| `agent.steering.injected` | `count`, `total_content_len` |
| `agent.follow_up.queued` | `source_tool`, `content_len` |
| `agent.interrupt.received` | `interrupt_kind`, `role`, `content_len`, `queue_depth`, `hint_len` |
| `agent.subturn.spawn` | `child_agent_id`, `label` |
| `agent.subturn.end` | `child_agent_id`, `status` |
| `agent.subturn.result_delivered` | `target_channel`, `target_chat_id`, `content_len` |
| `agent.subturn.orphan` | `parent_turn_id`, `child_turn_id`, `reason` |
| `agent.error` | `stage`, `error` |
## 可打印的事件域
当前 runtime event kind 定义在 `pkg/events/kind.go`。事件日志配置可以选择这些域:
- `agent.*`agent turn、LLM、tool、context、steering、interrupt、subturn、error。
- `channel.*`channel lifecycle、webhook 注册、outbound queued/sent/failed、rate limited。
- `bus.*`publish failed、close started/drained/completed。
- `gateway.*`start、ready、shutdown、reload started/completed/failed。
- `mcp.*`server connecting/connected/failed、tool discovered、tool call start/end。
默认事件日志示例见 [`../../config/config.example.json`](../../config/config.example.json)。
+8 -8
View File
@@ -135,16 +135,16 @@ The agent loop polls for async SubTurn results at two points per iteration:
All active turns are registered in `AgentLoop.activeTurnStates` (`sync.Map`, keyed by session key). A reservation sentinel is stored atomically via `LoadOrStore` before the worker starts, then replaced with the real `*turnState` when `runTurn` registers. This prevents a TOCTOU race where multiple messages for the same session could spawn concurrent workers. The sentinel is cleaned up by the worker's deferred cleanup. This allows `HardAbort` and `/subagents` observability commands to find and operate on active turns.
## Event Bus Integration
## Runtime Event Integration
SubTurns emit specific events to the PicoClaw `EventBus` for observability and debugging:
SubTurns emit runtime events through `pkg/events` for observability and debugging:
| Event Kind | When Emitted | Payload |
|:------|:-------------|:--------|
| `subturn_spawn` | Sub-turn successfully initialized | `SubTurnSpawnPayload{AgentID, Label, ParentTurnID}` |
| `subturn_end` | Sub-turn finishes (success or error) | `SubTurnEndPayload{AgentID, Status}` |
| `subturn_result_delivered` | Async result successfully delivered to parent | `SubTurnResultDeliveredPayload{TargetChannel, TargetChatID, ContentLen}` |
| `subturn_orphan` | Result cannot be delivered (parent finished or channel full) | `SubTurnOrphanPayload{ParentTurnID, ChildTurnID, Reason}` |
| `agent.subturn.spawn` | Sub-turn successfully initialized | `SubTurnSpawnPayload{AgentID, Label, ParentTurnID}` |
| `agent.subturn.end` | Sub-turn finishes (success or error) | `SubTurnEndPayload{AgentID, Status}` |
| `agent.subturn.result_delivered` | Async result successfully delivered to parent | `SubTurnResultDeliveredPayload{TargetChannel, TargetChatID, ContentLen}` |
| `agent.subturn.orphan` | Result cannot be delivered (parent finished or channel full) | `SubTurnOrphanPayload{ParentTurnID, ChildTurnID, Reason}` |
## API Reference
@@ -240,13 +240,13 @@ An orphan result occurs when:
2. The `pendingResults` channel is full (buffer size: 16)
When a result becomes orphan:
- `SubTurnOrphanResultEvent` is emitted to EventBus
- `agent.subturn.orphan` is emitted to the runtime event bus
- The result is **NOT** delivered to the LLM context
- External systems can listen to this event for custom handling
### Preventing Orphan Results
- Use `Critical: true` for important SubTurns that must complete
- Monitor `SubTurnOrphanResultEvent` for observability
- Monitor `agent.subturn.orphan` for observability
- Consider the 16-buffer limit when spawning many async SubTurns
## Tool Inheritance
+37 -7
View File
@@ -8,26 +8,56 @@ Discord is a free voice, video, and text chat application designed for communiti
```json
{
"agents": {
"defaults": {
"tool_feedback": {
"enabled": true,
"max_args_length": 300
}
}
},
"channel_list": {
"discord": {
"enabled": true,
"type": "discord",
"token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"],
"placeholder": {
"enabled": true,
"text": ["Thinking... 💭"]
},
"group_trigger": {
"mention_only": false
}
},
"reasoning_channel_id": ""
}
}
}
```
| Field | Type | Required | Description |
| ------------- | ------ | -------- | --------------------------------------------------------------------------- |
| enabled | bool | Yes | Whether to enable the Discord channel |
| token | string | Yes | Discord Bot Token |
| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed |
| group_trigger | object | No | Group trigger settings (example: { "mention_only": false }) |
| Field | Type | Required | Description |
| -------------------- | ------ | -------- | --------------------------------------------------------------------------- |
| enabled | bool | Yes | Whether to enable the Discord channel |
| token | string | Yes | Discord Bot Token |
| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed |
| placeholder | object | No | Placeholder message config shown while the agent is working |
| group_trigger | object | No | Group trigger settings (example: { "mention_only": false }) |
| reasoning_channel_id | string | No | Optional target channel ID for reasoning/thinking output |
## Visible Execution Feedback
Discord can show three different kinds of "working" feedback:
1. Typing indicator: automatic, no extra config needed.
2. Placeholder message: enable `channel_list.discord.placeholder.enabled` to send a visible `Thinking...` message that is later edited into the final reply.
3. Tool execution feedback: enable `agents.defaults.tool_feedback.enabled` to send a short message before each tool call, for example:
```text
🔧 `web_search`
Checking the latest PicoClaw release notes before I answer.
```
If you only see `Bot is typing`, check that `placeholder.enabled` or `tool_feedback.enabled` is actually set in your runtime config.
## Setup
+140
View File
@@ -0,0 +1,140 @@
# 📡 Canal MQTT
PicoClaw prend en charge n'importe quel client MQTT comme canal de messagerie. Les appareils ou services publient des requêtes vers un broker ; PicoClaw s'abonne, les traite et publie les réponses en retour.
## 🚀 Démarrage rapide
**1. Ajouter le canal dans `~/.picoclaw/config.json` :**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "tcp://localhost:1883",
"agent_id": "assistant"
}
}
}
}
```
**2. Démarrer la passerelle :**
```bash
picoclaw gateway
```
**3. Envoyer un message depuis n'importe quel client MQTT :**
```bash
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "Quel est l'\''usage CPU ?"}'
```
**4. S'abonner pour recevoir la réponse :**
```bash
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
---
## 📨 Structure des topics
```
{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client
```
| Segment | Description |
|---------|-------------|
| `prefix` | Préfixe de topic configuré côté serveur. Défaut : `/picoclaw` |
| `agent_id` | Identifiant de l'instance PicoClaw, défini dans le champ `agent_id` |
| `client_id` | Identifiant de session défini par le client — utiliser un ID stable par appareil pour maintenir le contexte |
### Payload du message (JSON)
```json
{ "text": "votre message ici" }
```
---
## ⚙️ Configuration
### config.json
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://votre-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"client_id": "",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
### .security.yml (identifiants)
Le nom d'utilisateur et le mot de passe sont stockés dans `~/.picoclaw/.security.yml`, pas dans `config.json` :
```yaml
channel_list:
mqtt:
settings:
username: votre_utilisateur
password: votre_mot_de_passe
```
### Champs de configuration
| Champ | Emplacement | Requis | Défaut | Description |
|-------|-------------|--------|--------|-------------|
| `broker` | `settings` | Oui | — | URL du broker MQTT, ex. `tcp://host:1883`, `ssl://host:8883` |
| `agent_id` | `settings` | Oui | — | Identifiant de l'agent, utilisé dans le chemin du topic |
| `topic_prefix` | `settings` | Non | `/picoclaw` | Préfixe de l'espace de noms des topics |
| `username` | `.security.yml` | Non | — | Nom d'utilisateur pour l'authentification au broker |
| `password` | `.security.yml` | Non | — | Mot de passe pour l'authentification au broker |
| `client_id` | `settings` | Non | auto-généré | ID client paho envoyé au broker. Auto-généré sous la forme `picoclaw-mqtt-{agent_id}-{8 hex}` ; fixe pour la durée du processus, réutilisé à la reconnexion |
| `keep_alive` | `settings` | Non | `60` | Intervalle keepalive MQTT en secondes |
| `qos` | `settings` | Non | `0` | Niveau QoS pour la publication et l'abonnement : `0`, `1` ou `2` |
### Variables d'environnement
| Variable | Champ |
|----------|-------|
| `PICOCLAW_CHANNELS_MQTT_BROKER` | `broker` |
| `PICOCLAW_CHANNELS_MQTT_AGENT_ID` | `agent_id` |
| `PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX` | `topic_prefix` |
| `PICOCLAW_CHANNELS_MQTT_USERNAME` | `username` |
| `PICOCLAW_CHANNELS_MQTT_PASSWORD` | `password` |
| `PICOCLAW_CHANNELS_MQTT_CLIENT_ID` | `client_id` |
| `PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE` | `keep_alive` |
| `PICOCLAW_CHANNELS_MQTT_QOS` | `qos` |
---
## 🔄 Reconnexion
PicoClaw se reconnecte automatiquement au broker en cas de perte de connexion, avec un intervalle de 5 secondes. L'abonnement est rétabli automatiquement. L'ID client côté broker reste identique à chaque reconnexion.
---
## ⚠️ Remarques
- **TLS** : SSL/TLS est supporté (URL broker en `ssl://`). La vérification du certificat est désactivée par défaut.
- **Réponses en streaming** : Les réponses en streaming envoient plusieurs messages vers le topic de réponse ; les concaténer dans l'ordre pour obtenir la réponse complète.
- **client_id vs ID de session** : Le `client_id` dans le chemin du topic est défini par votre application cliente. Il est distinct de l'ID client paho utilisé par PicoClaw pour se connecter au broker.
- **Instances multiples** : Si plusieurs instances PicoClaw utilisent le même `agent_id` sur le même broker, définir des `client_id` distincts pour éviter les conflits.
+140
View File
@@ -0,0 +1,140 @@
# 📡 MQTT チャンネル
PicoClaw は任意の MQTT クライアントをメッセージチャンネルとして使用できます。デバイスやサービスがブローカーにリクエストをパブリッシュし、PicoClaw がサブスクライブして処理し、レスポンスをパブリッシュして返します。
## 🚀 クイックスタート
**1. `~/.picoclaw/config.json` にチャンネルを追加:**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "tcp://localhost:1883",
"agent_id": "assistant"
}
}
}
}
```
**2. ゲートウェイを起動:**
```bash
picoclaw gateway
```
**3. 任意の MQTT クライアントからメッセージを送信:**
```bash
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "CPU使用率を確認してください"}'
```
**4. レスポンスを受信するためにサブスクライブ:**
```bash
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
---
## 📨 トピック構造
```
{prefix}/{agent_id}/{client_id}/request # クライアント → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → クライアント
```
| セグメント | 説明 |
|-----------|------|
| `prefix` | トピックのプレフィックス。サーバー側で設定。デフォルト:`/picoclaw` |
| `agent_id` | PicoClaw インスタンスの識別子。`agent_id` フィールドに設定 |
| `client_id` | クライアントが定義するセッション識別子。デバイスごとに同一の ID を使用するとコンテキストが維持される |
### メッセージペイロード(JSON)
```json
{ "text": "メッセージ内容" }
```
---
## ⚙️ 設定
### config.json
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://your-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"client_id": "",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
### .security.yml(認証情報)
ユーザー名とパスワードは `config.json` ではなく `~/.picoclaw/.security.yml` に保存します:
```yaml
channel_list:
mqtt:
settings:
username: your_username
password: your_password
```
### 設定フィールド
| フィールド | 場所 | 必須 | デフォルト | 説明 |
|-----------|------|------|-----------|------|
| `broker` | `settings` | はい | — | MQTT ブローカー URL。例:`tcp://host:1883``ssl://host:8883` |
| `agent_id` | `settings` | はい | — | エージェント識別子。トピックパスの一部として使用される |
| `topic_prefix` | `settings` | いいえ | `/picoclaw` | トピックの名前空間プレフィックス |
| `username` | `.security.yml` | いいえ | — | ブローカー認証のユーザー名 |
| `password` | `.security.yml` | いいえ | — | ブローカー認証のパスワード |
| `client_id` | `settings` | いいえ | 自動生成 | ブローカーに送信する paho クライアント ID。未設定の場合 `picoclaw-mqtt-{agent_id}-{8桁hex}` で自動生成。プロセスの生存期間中は固定され、再接続時も同じ ID を使用 |
| `keep_alive` | `settings` | いいえ | `60` | MQTT キープアライブ間隔(秒) |
| `qos` | `settings` | いいえ | `0` | パブリッシュおよびサブスクライブの QoS レベル:`0``1``2` |
### 環境変数
| 変数 | フィールド |
|------|----------|
| `PICOCLAW_CHANNELS_MQTT_BROKER` | `broker` |
| `PICOCLAW_CHANNELS_MQTT_AGENT_ID` | `agent_id` |
| `PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX` | `topic_prefix` |
| `PICOCLAW_CHANNELS_MQTT_USERNAME` | `username` |
| `PICOCLAW_CHANNELS_MQTT_PASSWORD` | `password` |
| `PICOCLAW_CHANNELS_MQTT_CLIENT_ID` | `client_id` |
| `PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE` | `keep_alive` |
| `PICOCLAW_CHANNELS_MQTT_QOS` | `qos` |
---
## 🔄 再接続
接続が切断された場合、PicoClaw は 5 秒間隔で自動的にブローカーに再接続します。再接続後はサブスクリプションも自動的に再確立されます。再接続時はブローカー側のクライアント ID が同一に保たれるため、ブローカーは同じセッションとして認識します。
---
## ⚠️ 注意事項
- **TLS**SSL/TLS をサポートしています(ブローカー URL に `ssl://` を使用)。デフォルトでは証明書検証をスキップします。
- **ストリーミングレスポンス**:ストリーミング出力時はレスポンストピックに複数のメッセージが送信されます。順番に結合すると完全なレスポンスになります。
- **client_id とセッション ID の違い**:トピックパスの `client_id` はクライアントアプリケーションが設定するセッション識別子です。PicoClaw がブローカーへの接続に使用する paho クライアント ID とは別の概念です。
- **複数インスタンス**:同じ `agent_id` で複数の PicoClaw インスタンスを同一ブローカーに接続する場合、ブローカーレベルの競合を避けるために各インスタンスに異なる `client_id` を設定してください。
+142
View File
@@ -0,0 +1,142 @@
# 📡 MQTT Channel
PicoClaw supports any MQTT client as a chat channel. Devices or services publish requests to a broker; PicoClaw subscribes, processes them, and publishes responses back.
## 🚀 Quick Start
**1. Add the channel to `~/.picoclaw/config.json`:**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "tcp://localhost:1883",
"agent_id": "assistant"
}
}
}
}
```
**2. Start the gateway:**
```bash
picoclaw gateway
```
**3. Send a message from any MQTT client:**
```bash
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "What is the CPU usage?"}'
```
**4. Subscribe to receive the response:**
```bash
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
---
## 📨 Topic Structure
```
{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client
```
| Segment | Description |
|---------|-------------|
| `prefix` | Topic prefix, configured server-side. Default: `/picoclaw` |
| `agent_id` | PicoClaw instance identifier, set in `agent_id` config field |
| `client_id` | Client-defined session identifier — use a stable ID per device to maintain conversation context |
### Message Payload (JSON)
```json
{ "text": "your message here" }
```
---
## ⚙️ Configuration
### config.json
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://your-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"client_id": "",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
### .security.yml (credentials)
Username and password are stored in `~/.picoclaw/.security.yml`, not in `config.json`:
```yaml
channel_list:
mqtt:
settings:
username: your_username
password: your_password
```
### Configuration Fields
| Field | Location | Required | Default | Description |
|-------|----------|----------|---------|-------------|
| `broker` | `settings` | Yes | — | MQTT broker URL, e.g. `tcp://host:1883`, `ssl://host:8883` |
| `agent_id` | `settings` | Yes | — | Agent identifier, used as part of the topic path |
| `topic_prefix` | `settings` | No | `/picoclaw` | Topic namespace prefix |
| `username` | `.security.yml` | No | — | Broker authentication username |
| `password` | `.security.yml` | No | — | Broker authentication password |
| `client_id` | `settings` | No | auto-generated | Paho client ID sent to the broker. Auto-generated as `picoclaw-mqtt-{agent_id}-{8-char hex}` if not set; stays fixed for the process lifetime so reconnects reuse the same ID |
| `keep_alive` | `settings` | No | `60` | MQTT keepalive interval in seconds |
| `qos` | `settings` | No | `0` | QoS level for publish and subscribe: `0`, `1`, or `2` |
### Environment Variables
All fields can be set via environment variables:
| Variable | Field |
|----------|-------|
| `PICOCLAW_CHANNELS_MQTT_BROKER` | `broker` |
| `PICOCLAW_CHANNELS_MQTT_AGENT_ID` | `agent_id` |
| `PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX` | `topic_prefix` |
| `PICOCLAW_CHANNELS_MQTT_USERNAME` | `username` |
| `PICOCLAW_CHANNELS_MQTT_PASSWORD` | `password` |
| `PICOCLAW_CHANNELS_MQTT_CLIENT_ID` | `client_id` |
| `PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE` | `keep_alive` |
| `PICOCLAW_CHANNELS_MQTT_QOS` | `qos` |
---
## 🔄 Reconnection
PicoClaw automatically reconnects to the broker if the connection is lost, with a 5-second retry interval. On reconnect, the subscription is re-established automatically. The broker-side client ID stays the same across reconnects so the broker correctly identifies it as the same session.
---
## ⚠️ Notes
- **TLS**: SSL/TLS is supported (`ssl://` broker URL). Certificate verification is skipped by default.
- **Streaming**: Streaming responses send multiple messages to the response topic; concatenate them in order.
- **client_id vs session ID**: The `client_id` in the topic path is set by your client application and identifies the conversation session. It is separate from the broker-level client ID used by PicoClaw's paho connection.
- **Multiple instances**: If you run multiple PicoClaw instances against the same broker with the same `agent_id`, set distinct `client_id` values to avoid broker-level conflicts.
+140
View File
@@ -0,0 +1,140 @@
# 📡 Canal MQTT
O PicoClaw suporta qualquer cliente MQTT como canal de mensagens. Dispositivos ou serviços publicam requisições para um broker; o PicoClaw assina, processa e publica as respostas de volta.
## 🚀 Início rápido
**1. Adicione o canal ao `~/.picoclaw/config.json`:**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "tcp://localhost:1883",
"agent_id": "assistant"
}
}
}
}
```
**2. Inicie o gateway:**
```bash
picoclaw gateway
```
**3. Envie uma mensagem de qualquer cliente MQTT:**
```bash
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "Qual é o uso de CPU?"}'
```
**4. Assine para receber a resposta:**
```bash
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
---
## 📨 Estrutura de tópicos
```
{prefix}/{agent_id}/{client_id}/request # Cliente → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Cliente
```
| Segmento | Descrição |
|----------|-----------|
| `prefix` | Prefixo do tópico configurado no servidor. Padrão: `/picoclaw` |
| `agent_id` | Identificador da instância do PicoClaw, definido no campo `agent_id` |
| `client_id` | Identificador de sessão definido pelo cliente — use um ID estável por dispositivo para manter o contexto da conversa |
### Payload da mensagem (JSON)
```json
{ "text": "sua mensagem aqui" }
```
---
## ⚙️ Configuração
### config.json
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://seu-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"client_id": "",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
### .security.yml (credenciais)
O nome de usuário e a senha são armazenados em `~/.picoclaw/.security.yml`, não no `config.json`:
```yaml
channel_list:
mqtt:
settings:
username: seu_usuario
password: sua_senha
```
### Campos de configuração
| Campo | Local | Obrigatório | Padrão | Descrição |
|-------|-------|-------------|--------|-----------|
| `broker` | `settings` | Sim | — | URL do broker MQTT, ex. `tcp://host:1883`, `ssl://host:8883` |
| `agent_id` | `settings` | Sim | — | Identificador do agente, usado como parte do caminho do tópico |
| `topic_prefix` | `settings` | Não | `/picoclaw` | Prefixo do namespace dos tópicos |
| `username` | `.security.yml` | Não | — | Nome de usuário para autenticação no broker |
| `password` | `.security.yml` | Não | — | Senha para autenticação no broker |
| `client_id` | `settings` | Não | gerado automaticamente | ID de cliente paho enviado ao broker. Gerado automaticamente como `picoclaw-mqtt-{agent_id}-{8 hex}` se não definido; fixo durante o tempo de vida do processo e reutilizado nas reconexões |
| `keep_alive` | `settings` | Não | `60` | Intervalo de keepalive MQTT em segundos |
| `qos` | `settings` | Não | `0` | Nível de QoS para publicação e assinatura: `0`, `1` ou `2` |
### Variáveis de ambiente
| Variável | Campo |
|----------|-------|
| `PICOCLAW_CHANNELS_MQTT_BROKER` | `broker` |
| `PICOCLAW_CHANNELS_MQTT_AGENT_ID` | `agent_id` |
| `PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX` | `topic_prefix` |
| `PICOCLAW_CHANNELS_MQTT_USERNAME` | `username` |
| `PICOCLAW_CHANNELS_MQTT_PASSWORD` | `password` |
| `PICOCLAW_CHANNELS_MQTT_CLIENT_ID` | `client_id` |
| `PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE` | `keep_alive` |
| `PICOCLAW_CHANNELS_MQTT_QOS` | `qos` |
---
## 🔄 Reconexão
O PicoClaw reconecta automaticamente ao broker se a conexão for perdida, com intervalo de 5 segundos. Após a reconexão, a assinatura é restabelecida automaticamente. O ID de cliente no broker permanece o mesmo nas reconexões, permitindo que o broker identifique corretamente a mesma sessão.
---
## ⚠️ Observações
- **TLS**: SSL/TLS é suportado (URL do broker com `ssl://`). A verificação de certificado é ignorada por padrão.
- **Respostas em streaming**: Respostas em streaming enviam múltiplas mensagens para o tópico de resposta; concatene-as na ordem recebida para obter a resposta completa.
- **client_id vs ID de sessão**: O `client_id` no caminho do tópico é definido pela sua aplicação cliente e identifica a sessão. É separado do ID de cliente paho usado pelo PicoClaw para se conectar ao broker.
- **Múltiplas instâncias**: Se várias instâncias do PicoClaw usarem o mesmo `agent_id` no mesmo broker, defina `client_id` distintos para evitar conflitos no nível do broker.
+140
View File
@@ -0,0 +1,140 @@
# 📡 Kênh MQTT
PicoClaw hỗ trợ bất kỳ client MQTT nào làm kênh nhắn tin. Thiết bị hoặc dịch vụ publish yêu cầu lên broker; PicoClaw subscribe, xử lý và publish phản hồi trở lại.
## 🚀 Bắt đầu nhanh
**1. Thêm kênh vào `~/.picoclaw/config.json`:**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "tcp://localhost:1883",
"agent_id": "assistant"
}
}
}
}
```
**2. Khởi động gateway:**
```bash
picoclaw gateway
```
**3. Gửi tin nhắn từ bất kỳ client MQTT nào:**
```bash
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "CPU đang dùng bao nhiêu phần trăm?"}'
```
**4. Subscribe để nhận phản hồi:**
```bash
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
---
## 📨 Cấu trúc topic
```
{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client
```
| Phân đoạn | Mô tả |
|-----------|-------|
| `prefix` | Tiền tố topic, cấu hình phía server. Mặc định: `/picoclaw` |
| `agent_id` | Định danh instance PicoClaw, đặt trong trường `agent_id` |
| `client_id` | Định danh phiên do client xác định — dùng ID ổn định cho mỗi thiết bị để duy trì ngữ cảnh hội thoại |
### Payload tin nhắn (JSON)
```json
{ "text": "nội dung tin nhắn" }
```
---
## ⚙️ Cấu hình
### config.json
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://your-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"client_id": "",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
### .security.yml (thông tin xác thực)
Tên người dùng và mật khẩu được lưu trong `~/.picoclaw/.security.yml`, không phải trong `config.json`:
```yaml
channel_list:
mqtt:
settings:
username: ten_nguoi_dung
password: mat_khau
```
### Các trường cấu hình
| Trường | Vị trí | Bắt buộc | Mặc định | Mô tả |
|--------|--------|----------|----------|-------|
| `broker` | `settings` | Có | — | URL của MQTT broker, ví dụ `tcp://host:1883`, `ssl://host:8883` |
| `agent_id` | `settings` | Có | — | Định danh agent, dùng làm một phần của đường dẫn topic |
| `topic_prefix` | `settings` | Không | `/picoclaw` | Tiền tố không gian tên topic |
| `username` | `.security.yml` | Không | — | Tên người dùng xác thực với broker |
| `password` | `.security.yml` | Không | — | Mật khẩu xác thực với broker |
| `client_id` | `settings` | Không | tự động tạo | Client ID paho gửi đến broker. Tự động tạo dạng `picoclaw-mqtt-{agent_id}-{8 hex}` nếu không đặt; cố định trong suốt vòng đời tiến trình, tái sử dụng khi kết nối lại |
| `keep_alive` | `settings` | Không | `60` | Khoảng thời gian keepalive MQTT (giây) |
| `qos` | `settings` | Không | `0` | Mức QoS cho publish và subscribe: `0`, `1` hoặc `2` |
### Biến môi trường
| Biến | Trường |
|------|--------|
| `PICOCLAW_CHANNELS_MQTT_BROKER` | `broker` |
| `PICOCLAW_CHANNELS_MQTT_AGENT_ID` | `agent_id` |
| `PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX` | `topic_prefix` |
| `PICOCLAW_CHANNELS_MQTT_USERNAME` | `username` |
| `PICOCLAW_CHANNELS_MQTT_PASSWORD` | `password` |
| `PICOCLAW_CHANNELS_MQTT_CLIENT_ID` | `client_id` |
| `PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE` | `keep_alive` |
| `PICOCLAW_CHANNELS_MQTT_QOS` | `qos` |
---
## 🔄 Kết nối lại
PicoClaw tự động kết nối lại với broker nếu mất kết nối, với khoảng thời gian thử lại 5 giây. Sau khi kết nối lại, subscription được tái thiết lập tự động. Client ID phía broker giữ nguyên qua các lần kết nối lại, giúp broker nhận diện chính xác cùng một phiên.
---
## ⚠️ Lưu ý
- **TLS**: Hỗ trợ SSL/TLS (URL broker dùng `ssl://`). Mặc định bỏ qua xác minh chứng chỉ.
- **Phản hồi streaming**: Phản hồi streaming gửi nhiều tin nhắn đến topic response; ghép nối chúng theo thứ tự để có phản hồi đầy đủ.
- **client_id và ID phiên**: `client_id` trong đường dẫn topic được đặt bởi ứng dụng client của bạn và xác định phiên hội thoại. Nó khác với client ID paho mà PicoClaw dùng để kết nối broker.
- **Nhiều instance**: Nếu nhiều instance PicoClaw dùng cùng `agent_id` trên cùng broker, hãy đặt `client_id` riêng biệt cho từng instance để tránh xung đột ở tầng broker.
+142
View File
@@ -0,0 +1,142 @@
# 📡 MQTT 渠道
PicoClaw 支持将任意 MQTT 客户端作为消息渠道。设备或服务向 Broker 发布请求,PicoClaw 订阅后处理并将响应发布回去。
## 🚀 快速开始
**1. 在 `~/.picoclaw/config.json` 中添加渠道:**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "tcp://localhost:1883",
"agent_id": "assistant"
}
}
}
}
```
**2. 启动网关:**
```bash
picoclaw gateway
```
**3. 用任意 MQTT 客户端发送消息:**
```bash
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "查一下CPU使用率"}'
```
**4. 订阅响应:**
```bash
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
---
## 📨 Topic 结构
```
{prefix}/{agent_id}/{client_id}/request # 客户端 → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → 客户端
```
| 段 | 说明 |
|----|------|
| `prefix` | Topic 前缀,由服务端配置,默认 `/picoclaw` |
| `agent_id` | PicoClaw 实例标识,对应配置中的 `agent_id` 字段 |
| `client_id` | 客户端自定义会话标识——同一设备保持相同 ID 可维持上下文连续性 |
### 消息体(JSON
```json
{ "text": "你的消息内容" }
```
---
## ⚙️ 配置说明
### config.json
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://your-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"client_id": "",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
### .security.yml(用户名和密码)
用户名和密码存储于 `~/.picoclaw/.security.yml`,不写入 `config.json`
```yaml
channel_list:
mqtt:
settings:
username: your_username
password: your_password
```
### 字段说明
| 字段 | 位置 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| `broker` | `settings` | 是 | — | MQTT Broker 地址,如 `tcp://host:1883``ssl://host:8883` |
| `agent_id` | `settings` | 是 | — | Agent 标识,作为 topic 路径的一部分 |
| `topic_prefix` | `settings` | 否 | `/picoclaw` | Topic 命名空间前缀 |
| `username` | `.security.yml` | 否 | — | Broker 认证用户名 |
| `password` | `.security.yml` | 否 | — | Broker 认证密码 |
| `client_id` | `settings` | 否 | 自动生成 | 发送给 Broker 的 paho 客户端 ID。未配置时自动生成为 `picoclaw-mqtt-{agent_id}-{8位hex}`,进程生命周期内固定不变,断线重连时复用同一 ID |
| `keep_alive` | `settings` | 否 | `60` | MQTT 心跳间隔(秒) |
| `qos` | `settings` | 否 | `0` | 发布和订阅的 QoS 级别:`0``1``2` |
### 环境变量
所有字段均可通过环境变量配置:
| 环境变量 | 对应字段 |
|----------|----------|
| `PICOCLAW_CHANNELS_MQTT_BROKER` | `broker` |
| `PICOCLAW_CHANNELS_MQTT_AGENT_ID` | `agent_id` |
| `PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX` | `topic_prefix` |
| `PICOCLAW_CHANNELS_MQTT_USERNAME` | `username` |
| `PICOCLAW_CHANNELS_MQTT_PASSWORD` | `password` |
| `PICOCLAW_CHANNELS_MQTT_CLIENT_ID` | `client_id` |
| `PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE` | `keep_alive` |
| `PICOCLAW_CHANNELS_MQTT_QOS` | `qos` |
---
## 🔄 断线重连
连接断开后 PicoClaw 会自动以 5 秒间隔重连 Broker,重连成功后自动重新订阅。断线重连时复用相同的 Broker 客户端 ID,Broker 能正确识别为同一连接。
---
## ⚠️ 注意事项
- **TLS**:支持 SSL/TLSBroker 地址使用 `ssl://`),默认跳过证书验证。
- **流式响应**:流式输出时会向 response topic 发送多条消息,客户端按顺序拼接即为完整回复。
- **client_id 与会话 ID 的区别**topic 路径中的 `client_id` 由客户端应用自行设置,用于区分会话;它与 PicoClaw paho 连接 Broker 时使用的客户端 ID 是两个独立的概念。
- **多实例部署**:若多个 PicoClaw 实例使用相同 `agent_id` 连接同一 Broker,需为每个实例配置不同的 `client_id` 以避免 Broker 层面的冲突。
+7 -1
View File
@@ -15,7 +15,8 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
"allow_from": ["123456789"],
"proxy": "",
"use_markdown_v2": false
"use_markdown_v2": false,
"media_group_delay_ms": 500
}
}
}
@@ -28,6 +29,7 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co
| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed |
| proxy | string | No | Proxy URL for connecting to the Telegram API (e.g. http://127.0.0.1:7890) |
| use_markdown_v2 | bool | No | Enable Telegram MarkdownV2 formatting |
| media_group_delay_ms | int | No | Idle delay before processing Telegram media groups/albums. Defaults to 500 ms |
## Setup
@@ -44,6 +46,8 @@ Telegram auto-registers PicoClaw's top-level bot commands at startup, including
Skill-related commands:
- `/list skills` lists the installed skills visible to the current agent.
- `/list mcp` lists configured MCP servers and whether they are deferred/connected.
- `/show mcp <server>` lists the active tools for a connected MCP server.
- `/use <skill> <message>` forces a skill for a single request.
- `/use <skill>` arms the skill for your next message in the same chat.
- `/use clear` clears a pending skill override.
@@ -52,6 +56,8 @@ Examples:
```text
/list skills
/list mcp
/show mcp github
/use git explain how to squash the last 3 commits
/use git
explain how to squash the last 3 commits
@@ -0,0 +1,91 @@
# 当前硬件支持现状与串口 Tool 方案
## 现状结论
当前项目已有的硬件相关能力主要分为两条线:
1. 设备事件监控
- `pkg/devices` 已实现设备事件服务。
- 当前只有 Linux USB 热插拔事件源 `pkg/devices/sources/usb_linux.go`
- 能力定位是“发现和通知”,不是“总线读写控制”。
2. 硬件控制 Tool
- `pkg/tools/hardware/i2c*.go`I2C Tool,支持 `detect``scan``read``write`
- `pkg/tools/hardware/spi*.go`SPI Tool,支持 `list``transfer``read`
- 这两类 Tool 当前都只在 Linux 主机上启用,直接依赖 `/dev/i2c-*``/dev/spidev*`
因此,项目在“硬件支持能力”上已经具备:
- Linux USB 设备插拔感知
- Linux I2C 总线控制
- Linux SPI 总线控制
但还缺少:
- 串口/UART 控制
- macOS / Windows 下可直接使用的硬件控制 Tool
- 面向统一硬件抽象的跨总线能力模型
## 本次新增
本次新增内建 `serial` Tool,并接入现有 Tool 体系:
- 配置项:`tools.serial.enabled`
- Tool 注册:`pkg/agent/agent_init.go`
- Web 工具页:`/api/tools` 能展示与切换 `serial`
- 前端状态文案:新增 `requires_serial_platform`
## Serial Tool 设计
`serial` 采用无状态调用模型,每次请求都自行打开和关闭端口,避免在 agent 回合之间维护串口会话状态。
支持动作:
- `list`:枚举主机串口
- `read`:从串口读取指定长度字节
- `write`:向串口写入字节或文本
公共参数:
- `port`
- `baud`
- `data_bits`
- `parity`
- `stop_bits`
- `timeout_ms`
当前波特率实现边界:
- Windows 允许配置工具层接受的范围 `50-4000000`
- Linux / macOS 当前仅支持标准 termios 波特率,实际支持到 `230400`
- 因此 `baud` 的跨平台可移植取值应优先使用 `230400` 及以下的常见标准速率
安全约束:
- `write` 必须显式传 `confirm: true`
- 单次读写负载限制为 `4096` 字节
- `port` 只接受白名单串口名:
- Linux / macOS 仅允许 `/dev/tty*``/dev/cu.*` 及对应简写设备名
- Windows 仅允许 `COM\d+``\\.\COM\d+`
- 明确拒绝 `..`、普通文件绝对路径、盘符路径等非串口设备路径,避免路径穿越或误打开任意文件
## 跨平台实现边界
- Linux / macOS
- 基于 `golang.org/x/sys/unix` 和 termios 配置串口参数。
- 当前仅接入标准 termios 波特率映射,最高到 `230400`,尚未扩展 `460800``921600``1000000``2000000` 等更高速率。
- 通过 `/dev/...` 枚举和访问设备。
- Windows
- 基于 `kernel32` 串口 API 配置 `DCB``COMMTIMEOUTS`
- 当前读写仍使用同步 `ReadFile` / `WriteFile`;一旦 syscall 已进入执行,turn context cancellation 不能立即打断,只能等待 `COMMTIMEOUTS` 触发后返回。
- 通过注册表 `HARDWARE\\DEVICEMAP\\SERIALCOMM` 枚举端口。
- 其他平台:
- `serial` Tool 显式返回 unsupported,不做静默降级。
## 后续建议
1. 如果需要持续交互式串口会话,建议再增加 session 型 Tool,而不是让 LLM 反复做短连接轮询。
2. 如果后续要支持 CAN、GPIO、PWM,建议抽出统一的硬件 capability 描述层,而不是继续只靠 Tool 名称区分。
3. 若需要生产级稳定性,建议补真实串口回环测试,至少覆盖 Linux PTY 和 Windows COM 模拟场景。
+37 -35
View File
@@ -1,11 +1,15 @@
# PicoClaw Hook 系统设计(基于 `refactor/agent`
> 当前状态:本文是 hook 系统的早期设计记录。事件系统升级后,观察型 hook 的主路径已经切到
> `pkg/events.Event`、`RuntimeEventObserver` 和进程 hook 的 `hook.runtime_event`。
> 旧 `agent.Event`、`EventKind`、`hook.event` 兼容层已经删除。
## 背景
本设计围绕两个议题展开:
- `#1316`:把 agent loop 重构为事件驱动、可中断、可追加、可观测
- `#1796`:在 EventBus 稳定后,把 hooks 设计为 EventBus 的 consumer,而不是重新发明一套事件模型
- `#1796`:在 runtime event bus 稳定后,把 hooks 设计为事件 consumer,而不是重新发明一套事件模型
当前分支已经完成了第一步里的“事件系统基础”,但还没有真正的 hook 挂载层。因此这里的目标不是重新设计 event,而是在已有实现上补出一层可扩展、可拦截、可外挂的 HookManager。
@@ -52,20 +56,18 @@ pi-mono 的核心思路更接近当前分支:
当前分支已经具备 hook 系统的地基:
- `pkg/agent/events.go` 定义了稳定的 `EventKind``EventMeta` 和 payload
- `pkg/agent/eventbus.go` 提供了非阻塞 fan-out 的 `EventBus`
- `pkg/events` 定义 runtime event envelope、kind、scope、source、severity 和 fan-out bus
- `pkg/agent/event_payloads.go` 保留 agent domain payload
- agent domain payload 保留在 `pkg/agent/event_payloads.go`
- `pkg/agent/loop.go` 中的 `runTurn()` 已在 turn、llm、tool、interrupt、follow-up、summary 等节点发射事件
- `pkg/agent/steering.go` 已支持 steering、graceful interrupt、hard abort
- `pkg/agent/turn.go` 已维护 turn phase、恢复点、active turn、abort 状态
### 现有缺口
当前分支还缺四件事:
- 没有 HookManager,只有 EventBus
- 没有 Before/After LLM、Before/After Tool 这种同步拦截点
- 没有审批型 hook
- 子 agent 仍走 `pkg/tools/SubagentManager + RunToolLoop`,没有接入 `pkg/agent` 的 turn tree 和事件流
早期设计时的缺口包括 HookManager、Before/After LLM、Before/After Tool、审批型 hook
以及 sub-turn 接入。当前实现已经覆盖主 turn 的 HookManager、LLM/Tool 拦截和审批;
sub-turn 事件已接入 runtime event bus
### 一个关键现实
@@ -73,19 +75,19 @@ pi-mono 的核心思路更接近当前分支:
## 设计原则
- Hook 必须建立在 `pkg/agent` 的 EventBus 和 turn 上下文之上
- EventBus 负责广播,HookManager 负责拦截,两者职责分离
- Hook 必须建立在 `pkg/events` runtime event bus 和 turn 上下文之上
- runtime event bus 负责广播,HookManager 负责拦截,两者职责分离
- 项目内挂载要简单,项目外挂载必须走 IPC
- 观察型 hook 不能阻塞 loop;拦截型 hook 必须有超时
- 先覆盖主 turn,不把 sub-turn 一次做满
- 不新增第二套用户事件命名系统,优先复用 `EventKind.String()`
- 不新增第二套用户事件命名系统,新观察点统一使用 `pkg/events.Kind`
## 总体架构
分成三层:
1. `EventBus`
负责广播只读事件,现有实现直接复用
1. `pkg/events` runtime event bus
负责广播只读事件,覆盖 agent、channel、gateway、bus、MCP 等运行时组件
2. `HookManager`
负责管理 hook、排序、超时、错误隔离,并在 `runTurn()` 的明确检查点执行同步拦截
@@ -97,7 +99,7 @@ pi-mono 的核心思路更接近当前分支:
换句话说:
- EventBus 是“发生了什么”
- runtime event bus 是“发生了什么”
- HookManager 是“谁能介入”
- HookMount 是“这些 hook 从哪里来”
@@ -113,11 +115,11 @@ pi-mono 的核心思路更接近当前分支:
```go
type EventObserver interface {
OnEvent(ctx context.Context, evt agent.Event) error
OnRuntimeEvent(ctx context.Context, evt events.Event) error
}
```
这类 hook 直接订阅 EventBus 即可。
这类 hook 直接订阅 runtime event bus 即可。
适用场景:
@@ -156,7 +158,7 @@ type ToolApprover interface {
## 对外暴露的最小 hook 面
V1 不需要把所有 EventKind 都变成可拦截点。
V1 不需要把所有 runtime event kind 都变成可拦截点。
建议只开放这些同步 hook
@@ -168,19 +170,19 @@ V1 不需要把所有 EventKind 都变成可拦截点。
其余节点继续作为只读事件暴露:
- `turn_start`
- `turn_end`
- `llm_request`
- `llm_response`
- `tool_exec_start`
- `tool_exec_end`
- `tool_exec_skipped`
- `steering_injected`
- `follow_up_queued`
- `interrupt_received`
- `context_compress`
- `session_summarize`
- `error`
- `agent.turn.start`
- `agent.turn.end`
- `agent.llm.request`
- `agent.llm.response`
- `agent.tool.exec_start`
- `agent.tool.exec_end`
- `agent.tool.exec_skipped`
- `agent.steering.injected`
- `agent.follow_up.queued`
- `agent.interrupt.received`
- `agent.context.compress`
- `agent.session.summarize`
- `agent.error`
`subturn_*` 在 V1 中保留名字,但不承诺一定触发,直到子 turn 迁移完成。
@@ -369,7 +371,7 @@ PicoClaw 启动外部进程,并在其 stdin/stdout 上跑协议。
### 观察链路
```text
runTurn() -> emitEvent() -> EventBus -> observers
runTurn() -> emitEvent() -> runtime event bus -> observers
```
### 拦截链路
@@ -453,7 +455,7 @@ V1 不做复杂自动发现。
### Phase 3
-`SubagentManager` 迁移到 `runTurn/sub-turn`
- 接通 `subturn_spawn` / `subturn_end` / `subturn_result_delivered`
- 接通 `agent.subturn.spawn` / `agent.subturn.end` / `agent.subturn.result_delivered`
### Phase 4
@@ -464,13 +466,13 @@ V1 不做复杂自动发现。
最适合 PicoClaw 当前分支的方案,不是直接复制 OpenClaw 的 hooks,也不是完整照搬 pi-mono 的 extension system,而是:
-现有 `EventBus` 为只读观察面
-`pkg/events` runtime event bus 为只读观察面
- 以新增 `HookManager` 为同步拦截面
- 项目内通过 Go 对象直接挂载
- 项目外通过 `stdio JSON-RPC` 进程通信挂载
这样做有三个好处:
-`#1796` 一致,hooks 只是 EventBus 之上的消费层
-`#1796` 一致,hooks 只是 runtime event bus 之上的消费层
- 和当前 `refactor/agent` 实现一致,不需要推翻已有事件系统
- 同时满足“仓内简单挂载”和“仓外进程通信挂载”两个硬需求
+5 -5
View File
@@ -78,17 +78,17 @@ Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
"model_name": "deepseek-chat",
"model": "openai/deepseek-chat",
"api_base": "https://api.deepseek.com/v1",
"api_key": "sk-xxx"
"api_keys": ["sk-xxx"]
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-xxx"
"api_keys": ["sk-xxx"]
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"api_key": "sk-xxx"
"api_keys": ["sk-xxx"]
},
{
"model_name": "gemini-3-flash",
@@ -99,7 +99,7 @@ Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
"model_name": "my-company-llm",
"model": "openai/company-model-v1",
"api_base": "https://llm.company.com/v1",
"api_key": "xxx"
"api_keys": ["xxx"]
}
],
@@ -252,7 +252,7 @@ func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {
{
"providers": {
"deepseek": {
"api_key": "sk-xxx",
"api_keys": ["sk-xxx"],
"api_base": "https://api.deepseek.com/v1"
}
},
+66 -1
View File
@@ -4,7 +4,7 @@
## 💬 Applications de Chat
Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot ou MaixCam.
Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MQTT 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é.
@@ -23,6 +23,7 @@ Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, Din
| **Feishu (飞书)** | ⭐⭐⭐ Avancé | Collaboration entreprise, fonctionnalités riches | [Documentation](../channels/feishu/README.fr.md) |
| **IRC** | ⭐⭐ Moyen | Serveur + configuration TLS | [Documentation](#irc) |
| **OneBot** | ⭐⭐ Moyen | Compatible NapCat/Go-CQHTTP, écosystème communautaire | [Documentation](../channels/onebot/README.fr.md) |
| **MQTT** | ⭐ Facile | N'importe quel client MQTT via broker pub/sub | [Documentation](../channels/mqtt/README.fr.md) |
| **MaixCam** | ⭐ Facile | Canal d'intégration matérielle pour caméras AI Sipeed | [Documentation](../channels/maixcam/README.fr.md) |
| **Pico** | ⭐ Facile | Canal protocole natif PicoClaw | |
@@ -681,3 +682,67 @@ picoclaw gateway
```
</details>
<a id="mqtt"></a>
<details>
<summary><b>MQTT</b></summary>
N'importe quel client MQTT peut communiquer avec PicoClaw via un broker. Les appareils ou services publient des requêtes vers le broker ; PicoClaw s'abonne, les traite et publie les réponses en retour.
**1. Configurer**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://votre-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
Nom d'utilisateur et mot de passe dans `~/.picoclaw/.security.yml` :
```yaml
channel_list:
mqtt:
settings:
username: votre_utilisateur
password: votre_mot_de_passe
```
**Format des topics**
```
{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client
```
Le `client_id` est défini par votre application cliente pour identifier les appareils ou sessions.
**2. Lancer**
```bash
picoclaw gateway
```
**3. Tester**
```bash
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "Bonjour"}'
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
Pour les options complètes, voir [Documentation du canal MQTT](../channels/mqtt/README.fr.md).
</details>
+65
View File
@@ -25,6 +25,7 @@ PicoClaw は複数のチャットプラットフォームをサポートして
| **Feishu (飛書)** | ⭐⭐⭐ やや難 | エンタープライズコラボレーション、機能豊富 | [ドキュメント](../channels/feishu/README.ja.md) |
| **IRC** | ⭐⭐ 中程度 | サーバー + TLS 設定 | [ドキュメント](#irc) |
| **OneBot** | ⭐⭐ 中程度 | NapCat/Go-CQHTTP 互換、コミュニティエコシステム充実 | [ドキュメント](../channels/onebot/README.ja.md) |
| **MQTT** | ⭐ 簡単 | ブローカー経由で任意の MQTT クライアントと通信 | [ドキュメント](../channels/mqtt/README.ja.md) |
| **MaixCam** | ⭐ 簡単 | Sipeed AI カメラハードウェア統合チャネル | [ドキュメント](../channels/maixcam/README.ja.md) |
| **Pico** | ⭐ 簡単 | PicoClaw ネイティブプロトコルチャネル | |
@@ -670,3 +671,67 @@ picoclaw gateway
```
</details>
<a id="mqtt"></a>
<details>
<summary><b>MQTT</b></summary>
任意の MQTT クライアントがブローカーを介して PicoClaw と通信できます。デバイスやサービスがブローカーにリクエストをパブリッシュし、PicoClaw がサブスクライブして処理し、レスポンスをパブリッシュして返します。
**1. 設定**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://your-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
ユーザー名とパスワードは `~/.picoclaw/.security.yml` に記載します:
```yaml
channel_list:
mqtt:
settings:
username: your_username
password: your_password
```
**トピック形式**
```
{prefix}/{agent_id}/{client_id}/request # クライアント → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → クライアント
```
`client_id` はクライアントアプリケーションがデバイスやセッションを識別するために設定します。
**2. 起動**
```bash
picoclaw gateway
```
**3. テスト**
```bash
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "こんにちは"}'
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
完全な設定オプションは [MQTT チャンネルドキュメント](../channels/mqtt/README.ja.md) を参照してください。
</details>
+71 -2
View File
@@ -4,7 +4,7 @@
## 💬 Chat Apps
Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MaixCam, or Pico (native protocol)
Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MQTT, MaixCam, or Pico (native protocol)
> **Note**: Channels that rely on HTTP callbacks share a single Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Socket/stream-based channels such as Feishu, DingTalk, and WeCom do not rely on the shared webhook server for inbound delivery.
@@ -23,6 +23,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk,
| **Feishu (飞书)** | ⭐⭐⭐ Advanced | Enterprise collaboration, feature-rich | [Docs](../channels/feishu/README.md) |
| **IRC** | ⭐⭐ Medium | Server + TLS configuration | [Docs](#irc) |
| **OneBot** | ⭐⭐ Medium | NapCat/Go-CQHTTP compatible, community ecosystem | [Docs](../channels/onebot/README.md) |
| **MQTT** | ⭐ Easy | Any MQTT client via broker pub/sub | [Docs](../channels/mqtt/README.md) |
| **MaixCam** | ⭐ Easy | Hardware integration channel for Sipeed AI cameras | [Docs](../channels/maixcam/README.md) |
| **Pico** | ⭐ Easy | Native PicoClaw protocol channel | |
@@ -67,9 +68,11 @@ Telegram command menu registration remains channel-local discovery UX; generic c
If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background.
You can also manage installed skills directly from Telegram:
You can also inspect skills and MCP servers directly from Telegram:
- `/list skills`
- `/list mcp`
- `/show mcp <server>`
- `/use <skill> <message>`
- `/use <skill>` and then send the actual request in the next message
- `/use clear`
@@ -585,3 +588,69 @@ picoclaw gateway
```
</details>
<a id="mqtt"></a>
<details>
<summary><b>MQTT</b></summary>
Any MQTT client can communicate with PicoClaw via a broker. Devices or services publish requests to the broker; PicoClaw subscribes, processes them, and publishes responses back.
**1. Configure**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://your-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
Username and password go in `~/.picoclaw/.security.yml`:
```yaml
channel_list:
mqtt:
settings:
username: your_username
password: your_password
```
**Topic format**
```
{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client
```
`client_id` is set by your client application to identify different devices or sessions.
**2. Run**
```bash
picoclaw gateway
```
**3. Test**
```bash
# Send a message
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "Hello"}'
# Subscribe to responses
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
For full configuration options see [MQTT Channel Docs](../channels/mqtt/README.md).
</details>
+66 -1
View File
@@ -4,7 +4,7 @@
## 💬 Aplikasi Sembang
Berbual dengan picoclaw anda melalui Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MaixCam, atau Pico (protokol asli)
Berbual dengan picoclaw anda melalui Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MQTT, MaixCam, atau Pico (protokol asli)
> **Nota**: Semua saluran berasaskan webhook (LINE, WeCom, dan sebagainya) diservis pada satu pelayan HTTP Gateway yang dikongsi (`gateway.host`:`gateway.port`, lalai `127.0.0.1:18790`). Tiada port khusus per saluran untuk dikonfigurasikan. Nota: Feishu menggunakan mod WebSocket/SDK dan tidak menggunakan pelayan HTTP webhook yang dikongsi.
@@ -22,6 +22,7 @@ Berbual dengan picoclaw anda melalui Telegram, Discord, WhatsApp, Matrix, QQ, Di
| **Slack** | Sederhana (Bot token + App token) |
| **IRC** | Sederhana (pelayan + konfigurasi TLS) |
| **OneBot** | Sederhana (QQ melalui protokol OneBot) |
| **MQTT** | Mudah (broker + agent_id) |
| **MaixCam** | Mudah (integrasi perkakasan Sipeed) |
| **Pico** | Protokol PicoClaw asli |
@@ -445,3 +446,67 @@ picoclaw gateway
> **Nota**: WeCom AI Bot menggunakan protokol streaming pull — tiada isu timeout balasan. Tugasan panjang (>30 saat) akan bertukar secara automatik kepada penghantaran push `response_url`.
</details>
<a id="mqtt"></a>
<details>
<summary><b>MQTT</b></summary>
Mana-mana client MQTT boleh berkomunikasi dengan PicoClaw melalui broker. Peranti atau perkhidmatan menerbitkan permintaan ke broker; PicoClaw melanggan, memproses dan menerbitkan respons kembali.
**1. Konfigurasi**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://your-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
Nama pengguna dan kata laluan dalam `~/.picoclaw/.security.yml`:
```yaml
channel_list:
mqtt:
settings:
username: nama_pengguna
password: kata_laluan
```
**Format topik**
```
{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client
```
`client_id` ditetapkan oleh aplikasi client anda untuk mengenal pasti peranti atau sesi.
**2. Jalankan**
```bash
picoclaw gateway
```
**3. Uji**
```bash
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "Helo"}'
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
Untuk semua pilihan konfigurasi, lihat [Dokumentasi Saluran MQTT](../channels/mqtt/README.md).
</details>
+66 -1
View File
@@ -4,7 +4,7 @@
## 💬 Aplicativos de Chat
Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot ou MaixCam
Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MQTT 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.
@@ -23,6 +23,7 @@ Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, D
| **Feishu (飞书)** | ⭐⭐⭐ Avançado | Colaboração empresarial, rico em recursos | [Documentação](../channels/feishu/README.pt-br.md) |
| **IRC** | ⭐⭐ Médio | Servidor + configuração TLS | [Documentação](#irc) |
| **OneBot** | ⭐⭐ Médio | Compatível com NapCat/Go-CQHTTP, ecossistema comunitário | [Documentação](../channels/onebot/README.pt-br.md) |
| **MQTT** | ⭐ Fácil | Qualquer cliente MQTT via broker pub/sub | [Documentação](../channels/mqtt/README.pt-br.md) |
| **MaixCam** | ⭐ Fácil | Canal de integração de hardware para câmeras AI Sipeed | [Documentação](../channels/maixcam/README.pt-br.md) |
| **Pico** | ⭐ Fácil | Canal de protocolo nativo PicoClaw | |
@@ -695,3 +696,67 @@ picoclaw gateway
```
</details>
<a id="mqtt"></a>
<details>
<summary><b>MQTT</b></summary>
Qualquer cliente MQTT pode se comunicar com o PicoClaw via broker. Dispositivos ou serviços publicam requisições para o broker; o PicoClaw assina, processa e publica as respostas de volta.
**1. Configurar**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://seu-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
Nome de usuário e senha em `~/.picoclaw/.security.yml`:
```yaml
channel_list:
mqtt:
settings:
username: seu_usuario
password: sua_senha
```
**Formato dos tópicos**
```
{prefix}/{agent_id}/{client_id}/request # Cliente → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Cliente
```
O `client_id` é definido pela sua aplicação cliente para identificar dispositivos ou sessões.
**2. Iniciar**
```bash
picoclaw gateway
```
**3. Testar**
```bash
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "Olá"}'
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
Para todas as opções de configuração, veja a [Documentação do Canal MQTT](../channels/mqtt/README.pt-br.md).
</details>
+66 -1
View File
@@ -4,7 +4,7 @@
## 💬 Ứ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
Trò chuyện với picoclaw của bạn qua Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MQTT 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.
@@ -23,6 +23,7 @@ Trò chuyện với picoclaw của bạn qua Telegram, Discord, WhatsApp, Matrix
| **Feishu (飞书)** | ⭐⭐⭐ Nâng cao | Cộng tác doanh nghiệp, nhiều tính năng | [Tài liệu](../channels/feishu/README.vi.md) |
| **IRC** | ⭐⭐ Trung bình | Máy chủ + cấu hình TLS | [Tài liệu](#irc) |
| **OneBot** | ⭐⭐ Trung bình | Tương thích NapCat/Go-CQHTTP, hệ sinh thái cộng đồng | [Tài liệu](../channels/onebot/README.vi.md) |
| **MQTT** | ⭐ Dễ | Bất kỳ client MQTT nào qua broker pub/sub | [Tài liệu](../channels/mqtt/README.vi.md) |
| **MaixCam** | ⭐ Dễ | Kênh tích hợp phần cứng cho camera AI Sipeed | [Tài liệu](../channels/maixcam/README.vi.md) |
| **Pico** | ⭐ Dễ | Kênh giao thức bản địa PicoClaw | |
@@ -696,3 +697,67 @@ picoclaw gateway
```
</details>
<a id="mqtt"></a>
<details>
<summary><b>MQTT</b></summary>
Bất kỳ client MQTT nào đều có thể giao tiếp với PicoClaw qua broker. Thiết bị hoặc dịch vụ publish yêu cầu lên broker; PicoClaw subscribe, xử lý và publish phản hồi trở lại.
**1. Cấu hình**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://your-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
Tên người dùng và mật khẩu trong `~/.picoclaw/.security.yml`:
```yaml
channel_list:
mqtt:
settings:
username: ten_nguoi_dung
password: mat_khau
```
**Định dạng topic**
```
{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client
```
`client_id` do ứng dụng client đặt để phân biệt thiết bị hoặc phiên.
**2. Khởi động**
```bash
picoclaw gateway
```
**3. Kiểm tra**
```bash
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "Xin chào"}'
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
Xem đầy đủ tùy chọn cấu hình tại [Tài liệu Kênh MQTT](../channels/mqtt/README.vi.md).
</details>
+68 -1
View File
@@ -4,7 +4,7 @@
## 💬 聊天应用集成 (Chat Apps)
PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。
PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方,包括 Telegram、Discord、WhatsApp、微信、QQ、钉钉、LINE、企业微信、飞书、Slack、IRC、OneBot、MQTT、MaixCam 等
> **注意**: 依赖 HTTP 回调的渠道共用同一个 Gateway HTTP 服务器(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。飞书、钉钉、企业微信这类 Socket/Stream 模式渠道不依赖共享 webhook 服务器来接收入站消息。
@@ -25,6 +25,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](../channels/feishu/README.zh.md) |
| **IRC** | ⭐⭐ 中等 | 服务器 + TLS 配置 | [查看文档](#irc) |
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](../channels/onebot/README.zh.md) |
| **MQTT** | ⭐ 简单 | 任意 MQTT 客户端通过 Broker 收发消息 | [查看文档](../channels/mqtt/README.zh.md) |
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](../channels/maixcam/README.zh.md) |
| **Pico** | ⭐ 简单 | PicoClaw 原生协议通道 | |
@@ -610,3 +611,69 @@ picoclaw gateway
```
</details>
<a id="mqtt"></a>
<details>
<summary><b>MQTT</b></summary>
任意 MQTT 客户端均可通过 Broker 与 PicoClaw 通信。设备或服务向 Broker 发布请求,PicoClaw 订阅后处理并将响应发布回去。
**1. 配置**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://your-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
用户名和密码存储于 `~/.picoclaw/.security.yml`
```yaml
channel_list:
mqtt:
settings:
username: your_username
password: your_password
```
**Topic 格式**
```
{prefix}/{agent_id}/{client_id}/request # 客户端 → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → 客户端
```
`client_id` 由客户端自行指定,用于区分不同设备或会话。
**2. 运行**
```bash
picoclaw gateway
```
**3. 测试**
```bash
# 发送消息
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "你好"}'
# 订阅响应
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
完整配置选项请参考 [MQTT 渠道文档](../channels/mqtt/README.zh.md)。
</details>
+49
View File
@@ -364,6 +364,55 @@ Configurez plusieurs endpoints pour le même nom de modèle — PicoClaw effectu
L'ancienne configuration `providers` est **dépréciée** et a été supprimée dans V2. Les configs V0/V1 existantes sont auto-migrées. Voir [docs/migration/model-list-migration.md](../migration/model-list-migration.md).
#### Configuration du Streaming
Le streaming provider utilise un double opt-in et est désactivé par défaut. L'agent ne tente le streaming que lorsque le channel courant a `settings.streaming.enabled: true`, que l'entrée de modèle active a `streaming.enabled: true`, et que le provider comme le channel prennent en charge le streaming. Si une condition manque, PicoClaw utilise le chemin de requête non-streaming normal.
Pico WebUI est le premier channel entièrement câblé. Pico crée le premier message assistant avec le message wire existant `message.create`, puis met à jour ce même message avec `message.update`; aucun nouveau type de message Pico n'est introduit.
Laissez `streaming` absent si vous ne voulez pas de streaming. Un bloc `streaming` omis signifie désactivé; il n'est pas nécessaire d'écrire `"streaming": {"enabled": false}`.
Exemple d'activation :
```json
{
"model_list": [
{
"model_name": "gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-your-openai-key"],
"streaming": {
"enabled": true
}
}
],
"channel_list": {
"pico": {
"enabled": true,
"type": "pico",
"settings": {
"token": "YOUR_PICO_TOKEN",
"streaming": {
"enabled": true
}
}
}
}
}
```
| Champ | Type | Défaut | Description |
| ----- | ---- | ------ | ----------- |
| `channel_list.<name>.settings.streaming.enabled` | bool | `false` | Autorise ce channel à afficher la sortie streaming du provider |
| `channel_list.<name>.settings.streaming.throttle_seconds` | int | Défaut Pico après activation : `0` | Intervalle minimal entre les mises à jour intermédiaires; le contenu final est toujours envoyé |
| `channel_list.<name>.settings.streaming.min_growth_chars` | int | Défaut Pico après activation : `1` | Croissance minimale du texte avant une mise à jour intermédiaire; le contenu final est toujours envoyé |
| `model_list[].streaming.enabled` | bool | `false` | Autorise cette entrée de modèle à tenter des requêtes provider en streaming |
Les anciennes variables d'environnement Telegram restent compatibles : `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_ENABLED`, `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_THROTTLE_SECONDS` et `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS`. Elles s'appliquent uniquement aux settings Telegram et n'activent ni ne modifient `settings.streaming` de Pico.
Le comportement d'échec est volontairement conservateur : si le streaming échoue avant l'envoi d'un chunk visible, PicoClaw réessaie une fois via le chemin `Chat()` normal. Si un chunk a déjà été affiché à l'utilisateur, PicoClaw n'envoie pas une deuxième réponse non-streaming, afin d'éviter une sortie dupliquée.
### Architecture des Providers
PicoClaw route les providers par famille de protocole :
+330
View File
@@ -0,0 +1,330 @@
# ⚙️ 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
```
### Configurazione Streaming
Lo streaming del provider usa un double opt-in ed è disattivato per impostazione predefinita. L'agent prova lo streaming solo quando il canale corrente ha `settings.streaming.enabled: true`, l'entry del modello attivo ha `streaming.enabled: true`, e sia il provider sia il canale supportano lo streaming. Se manca una qualsiasi condizione, PicoClaw usa il normale percorso di richiesta non streaming.
Pico WebUI è il primo canale completamente collegato. Pico crea il primo messaggio assistant con il wire message esistente `message.create`, poi aggiorna lo stesso messaggio con `message.update`; non viene introdotto alcun nuovo tipo di wire message Pico.
Lascia `streaming` assente quando non vuoi usare lo streaming. Un blocco `streaming` omesso significa disattivato; non è necessario scrivere `"streaming": {"enabled": false}`.
Esempio di attivazione:
```json
{
"model_list": [
{
"model_name": "gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-your-openai-key"],
"streaming": {
"enabled": true
}
}
],
"channel_list": {
"pico": {
"enabled": true,
"type": "pico",
"settings": {
"token": "YOUR_PICO_TOKEN",
"streaming": {
"enabled": true
}
}
}
}
}
```
| Campo | Tipo | Predefinito | Descrizione |
| ----- | ---- | ----------- | ----------- |
| `channel_list.<name>.settings.streaming.enabled` | bool | `false` | Permette a questo canale di mostrare l'output streaming del provider |
| `channel_list.<name>.settings.streaming.throttle_seconds` | int | Predefinito Pico dopo l'attivazione: `0` | Intervallo minimo tra aggiornamenti intermedi; il contenuto finale viene sempre inviato |
| `channel_list.<name>.settings.streaming.min_growth_chars` | int | Predefinito Pico dopo l'attivazione: `1` | Crescita minima del testo prima di inviare un aggiornamento intermedio; il contenuto finale viene sempre inviato |
| `model_list[].streaming.enabled` | bool | `false` | Permette a questa entry di modello di provare richieste provider streaming |
Le variabili d'ambiente legacy di Telegram restano compatibili: `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_ENABLED`, `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_THROTTLE_SECONDS` e `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS`. Si applicano solo alle settings Telegram e non attivano né modificano `settings.streaming` di Pico.
Il comportamento in caso di errore è intenzionalmente conservativo: se lo streaming fallisce prima che venga inviato un chunk visibile, PicoClaw riprova una volta tramite il normale percorso `Chat()`. Se un chunk è già stato mostrato all'utente, PicoClaw non invia una seconda risposta non streaming, evitando output duplicato.
### 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
├── AGENT.md # Guida al comportamento dell'agent
├── HEARTBEAT.md # Prompt per task periodici (controllato ogni 30 min)
├── SOUL.md # Anima dell'agent
└── USER.md # Preferenze dell'utente
```
> **Nota:** Le modifiche a `AGENT.md`, `SOUL.md`, `USER.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. `<current-working-directory>/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.
### Allowlist dei Tool per Agent
La dichiarazione dei tool per-agent vive nel frontmatter di `AGENT.md`, non in `config.json`.
Se `tools` è omesso nel frontmatter, l'agent riceve il normale set globale dei tool abilitati. Se `tools` è presente, PicoClaw registra per quell'agent solo i tool runtime elencati.
```md
---
name: Research Agent
description: Specialista per ricerca web e analisi approfondita.
tools: [read_file, write_file, web_search, web_fetch, message]
skills: [deep-research]
mcpServers: [web-index]
---
Sei l'agent di ricerca.
```
Note:
- È una allowlist reale, non un suggerimento per l'LLM.
- I nomi dei tool fanno match 1:1 con il nome runtime del tool.
- Se ti serve controllo preciso, usa i nomi runtime effettivi come `web_search`, `web_fetch`, `spawn`, `subagent`, `send_file`.
- Le dichiarazioni dei tool in `AGENT.md` sono usate dal runtime e dai tool, ma non vengono iniettate nel prompt di discovery.
### Discovery Multi-Agent (Automatica)
Quando un agent ha peer spawnabili, PicoClaw inietta automaticamente nel suo system prompt un registry strutturato dei peer. Non serve una chiamata aggiuntiva a un tool `list_agents`.
Questa discovery serve soprattutto a rendere affidabile la delega tramite `spawn` con `agent_id` esplicito.
Ogni entry include:
| Campo | Significato |
|-------|-------------|
| `id` | ID stabile dell'agent |
| `name` | Nome identitario da `AGENT.md` frontmatter |
| `description` | Descrizione identitaria da `AGENT.md` frontmatter |
Dettagli importanti:
- La sezione include solo i peer che l'agent corrente può spawnare tramite `subagents.allow_agents`.
- L'agent corrente e i peer non spawnabili vengono omessi, così il modello non pianifica contro agent non disponibili.
- La discovery è volutamente leggera. Fornisce al modello solo l'identità necessaria per scegliere un peer: `id`, `name`, `description`.
- `config.json` resta il layer infrastrutturale: workspace, agent di default, routing e permessi di subagent. Questi permessi controllano anche la visibilità nella discovery.
- `AGENT.md` resta il layer di identità. Il codice runtime e i tool possono comunque usare `tools`, `skills`, `mcpServers` e `model` quando avviene la delega.
Forma dell'oggetto iniettato:
```json
{
"agents": [
{
"id": "research",
"name": "Research Agent",
"description": "Specialista per investigazioni e lavoro web."
}
]
}
```
In pratica, un agent generalista sceglie un peer in base alla descrizione del suo ruolo, poi chiama `spawn` con l'`agent_id` del peer. Il runtime risolve il resto.
### 🔒 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
```
+49
View File
@@ -365,6 +365,55 @@ HEARTBEAT_OK を返信 ユーザーが直接結果を受信
`providers` 設定は**非推奨**となり、V2 で削除されました。既存の V0/V1 設定は自動的に移行されます。[docs/migration/model-list-migration.md](../migration/model-list-migration.md) を参照してください。
#### ストリーミング設定
Provider ストリーミングは二重の opt-in 方式で、デフォルトでは無効です。現在の channel に `settings.streaming.enabled: true` があり、アクティブなモデルエントリに `streaming.enabled: true` があり、さらに provider と channel の両方がストリーミングをサポートしている場合にのみ、agent はストリーミングリクエストを試行します。いずれかの条件が欠ける場合、PicoClaw は通常の非ストリーミングリクエスト経路を使います。
Pico WebUI が最初に完全対応した channel です。Pico は既存の `message.create` wire message で最初の assistant メッセージを作成し、その後 `message.update` で同じメッセージを更新します。新しい Pico wire message type は追加されません。
ストリーミングを使わない場合は `streaming` を省略してください。`streaming` ブロックの省略は無効を意味するため、`"streaming": {"enabled": false}` を書く必要はありません。
有効化例:
```json
{
"model_list": [
{
"model_name": "gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-your-openai-key"],
"streaming": {
"enabled": true
}
}
],
"channel_list": {
"pico": {
"enabled": true,
"type": "pico",
"settings": {
"token": "YOUR_PICO_TOKEN",
"streaming": {
"enabled": true
}
}
}
}
}
```
| フィールド | 型 | デフォルト | 説明 |
| ---------- | -- | ---------- | ---- |
| `channel_list.<name>.settings.streaming.enabled` | bool | `false` | この channel で provider のストリーミング出力を表示できるようにします |
| `channel_list.<name>.settings.streaming.throttle_seconds` | int | Pico で有効化後のデフォルト:`0` | 中間更新の最小間隔。最終内容は常に flush されます |
| `channel_list.<name>.settings.streaming.min_growth_chars` | int | Pico で有効化後のデフォルト:`1` | 次の中間更新を送るために必要な最小文字増加数。最終内容は常に flush されます |
| `model_list[].streaming.enabled` | bool | `false` | このモデルエントリで provider ストリーミングリクエストを試行できるようにします |
既存の Telegram 環境変数 `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_ENABLED``PICOCLAW_CHANNELS_TELEGRAM_STREAMING_THROTTLE_SECONDS``PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS` は互換性のため引き続き使えます。これらは Telegram settings にのみ適用され、Pico の `settings.streaming` を有効化または変更しません。
失敗時の動作は保守的です。可視 chunk が送信される前にストリーミングが失敗した場合、PicoClaw は通常の `Chat()` 経路で一度だけ再試行します。すでに chunk がユーザーに表示されている場合は、表示済み出力の重複を避けるため、二つ目の非ストリーミング回答は送信しません。
### Provider アーキテクチャ
PicoClaw はプロトコルファミリーで Provider をルーティングします:
+216 -46
View File
@@ -69,6 +69,36 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
> **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.
### Agent Self-Evolution
The `evolution` block controls PicoClaw's self-evolution runtime. When enabled, the agent records completed turns as learning records. In higher modes it can group repeated successful patterns, generate skill drafts, and optionally apply accepted drafts into workspace skills.
```json
{
"evolution": {
"enabled": false,
"mode": "observe",
"state_dir": "",
"min_task_count": 2,
"min_success_ratio": 0.7,
"cold_path_trigger": "after_turn",
"cold_path_times": []
}
}
```
| Field | Default | Description |
|-------|---------|-------------|
| `enabled` | `false` | Enables learning-record capture for completed agent turns. Heartbeat turns are ignored. |
| `mode` | `observe` | `observe` records data only. `draft` can generate candidate skill drafts. `apply` can apply accepted drafts to workspace skills. |
| `state_dir` | `""` | Optional directory for evolution state. Leave empty to use the default under the workspace. |
| `min_task_count` | `2` | Minimum related task records required before a pattern is eligible for draft generation. |
| `min_success_ratio` | `0.7` | Minimum success ratio for a task cluster. Use a value greater than `0` and up to `1`. |
| `cold_path_trigger` | `after_turn` | Runs draft generation `after_turn`, on a `scheduled` cadence, or disables automatic cold-path runs when set to `manual`. There is no user-facing manual trigger yet. Applies only in `draft` and `apply` modes. |
| `cold_path_times` | `[]` | Scheduled run times used when `cold_path_trigger` is `scheduled`, written as `HH:MM` strings. |
Use `observe` first if you want to inspect learning records without generating skill changes. Use `draft` when you want PicoClaw to prepare reviewable improvements. Use `apply` only when you are comfortable letting accepted drafts update workspace skills.
### Web launcher dashboard
**picoclaw-launcher** serves a browser UI that requires password sign-in first. On first run, open `/launcher-setup` to create the dashboard password. Later manual sign-ins use `/launcher-login`.
@@ -98,9 +128,11 @@ export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
### Using Skills From Chat Channels
Once skills are installed, you can inspect and force them directly from a chat channel:
Once skills are installed, and MCP servers are configured, you can inspect and force them directly from a chat channel:
- `/list skills` shows the installed skill names available to the current agent.
- `/list mcp` shows configured MCP servers with enabled/deferred/connected status.
- `/show mcp <server>` shows the active tools exposed by a connected MCP server.
- `/use <skill> <message>` forces a specific skill for a single request.
- `/use <skill>` arms that skill for your next message in the same chat session.
- `/use clear` cancels a pending skill override created by `/use <skill>`.
@@ -110,6 +142,8 @@ Examples:
```text
/list skills
/list mcp
/show mcp github
/use git explain how to squash the last 3 commits
/btw remind me what we already decided about the deploy plan
/use italiapersonalfinance
@@ -207,6 +241,69 @@ earlier and broader fallback rules later.
For more complete routing and model-tier examples, see the [Routing Guide](routing-guide.md).
### Agent Tool Allowlist
Per-agent tool declarations live in `AGENT.md` frontmatter, not in `config.json`.
If `tools` is omitted from frontmatter, the agent gets the normal globally enabled tool set. If `tools` is present, PicoClaw registers only the listed runtime tools for that agent.
```md
---
name: Research Agent
description: Specialist for web research and in-depth analysis.
tools: [read_file, write_file, web_search, web_fetch, message]
skills: [deep-research]
mcpServers: [web-index]
---
You are the research agent.
```
Notes:
- This is an allowlist, not a preference hint.
- Tool names are matched against the runtime tool name 1:1.
- Use runtime tool names such as `web_search`, `web_fetch`, `spawn`, `subagent`, `send_file`.
- Tool declarations in `AGENT.md` are used by runtime/tooling, but they are not injected into the discovery prompt.
### Agent Discovery (Automatic)
When an agent has spawnable peers and can call `spawn`, PicoClaw injects a structured agent registry into that agent's system prompt on every turn. No extra `list_agents` tool call is required.
This registry is intended to make delegation concrete and reliable, especially when using `spawn` with a target `agent_id`.
Each entry includes:
| Field | Meaning |
|-------|---------|
| `id` | Stable agent id |
| `name` | Agent identity name from `AGENT.md` frontmatter |
| `description` | Agent identity description from `AGENT.md` frontmatter |
Important behavior:
- The discovery section appears only when the current agent has the `spawn` tool and includes only peer agents it is permitted to spawn via `subagents.allow_agents`.
- The current agent and non-spawnable peers are omitted, so the model does not plan against unavailable agents.
- Discovery is intentionally lightweight. It gives the model only the identity it needs to choose a peer: `id`, `name`, and `description`.
- `config.json` remains the infrastructure layer: workspace, default agent selection, routing, and subagent permissions. Those permissions also gate discovery visibility.
- `AGENT.md` remains the identity layer. Runtime/tool code can still use its `tools`, `skills`, `mcpServers`, and `model` fields when delegation happens.
Example injected shape:
```json
{
"agents": [
{
"id": "research",
"name": "Research Agent",
"description": "Specialist for long-form investigation and web work."
}
]
}
```
In practice, this means a generalist agent can choose a peer based on its role description, then call `spawn` with the peer's `agent_id`. The runtime resolves the rest.
### 🔒 Security Sandbox
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
@@ -494,7 +591,7 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
### 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!**
> **What's New?** PicoClaw now prefers explicit `provider` + native `model` configuration (for example `"provider": "zhipu", "model": "glm-4.7"`). The legacy single-field `provider/model` form remains supported for compatibility when `provider` is omitted.
This design also enables **multi-agent support** with flexible provider selection:
@@ -547,7 +644,8 @@ chmod 600 ~/.picoclaw/.security.yml
"model_list": [
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4"
"provider": "openai",
"model": "gpt-5.4"
// api_key loaded from .security.yml
}
],
@@ -571,31 +669,31 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
#### All Supported Vendors
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
| Vendor | `provider` Value | 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` | Gemini | [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) |
| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) |
| **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 | — |
| **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` | Gemini | [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) |
| **LM Studio** | `lmstudio` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) |
| **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 | — |
#### Basic Configuration
@@ -604,22 +702,26 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
"model_list": [
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["sk-your-api-key"]
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-your-openai-key"]
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"provider": "anthropic",
"model": "claude-sonnet-4.6",
"api_keys": ["sk-ant-your-key"]
},
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"provider": "zhipu",
"model": "glm-4.7",
"api_keys": ["your-zhipu-key"]
}
],
@@ -635,6 +737,62 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
>
> **Note**: The `enabled` field can be set to `false` to disable a model entry without removing it. When omitted, it defaults to `true` during migration for models that have API keys.
Resolution rules:
- Prefer explicit `"provider": "openai", "model": "gpt-5.4"`.
- If `provider` is set, PicoClaw sends `model` unchanged.
- If `provider` is omitted, PicoClaw treats the first `/` segment in `model` as the provider and everything after that first `/` as the runtime model ID.
- This means `"model": "openrouter/openai/gpt-5.4"` still works as a compatibility form and sends `openai/gpt-5.4` to OpenRouter.
#### Streaming Configuration
Provider streaming uses a double opt-in and is disabled by default. The agent only tries streaming when the current channel has `settings.streaming.enabled: true`, the active model entry has `streaming.enabled: true`, and both the provider and channel support streaming. If any condition is missing, PicoClaw uses the normal non-streaming request path.
Pico WebUI is the first fully wired channel. Pico creates the first assistant message with the existing `message.create` wire message, then updates that same message with `message.update`; no new Pico wire message type is introduced.
Leave `streaming` unset when you do not want streaming. An omitted `streaming` block means disabled; you do not need to write `"streaming": {"enabled": false}`.
Opt-in example:
```json
{
"model_list": [
{
"model_name": "gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-your-openai-key"],
"streaming": {
"enabled": true
}
}
],
"channel_list": {
"pico": {
"enabled": true,
"type": "pico",
"settings": {
"token": "YOUR_PICO_TOKEN",
"streaming": {
"enabled": true
}
}
}
}
}
```
| Field | Type | Default | Description |
| ----- | ---- | ------- | ----------- |
| `channel_list.<name>.settings.streaming.enabled` | bool | `false` | Allows this channel to display provider streaming output |
| `channel_list.<name>.settings.streaming.throttle_seconds` | int | Pico default after enabling: `0` | Minimum interval for intermediate updates; final content is always flushed |
| `channel_list.<name>.settings.streaming.min_growth_chars` | int | Pico default after enabling: `1` | Minimum character growth before sending an intermediate update; final content is always flushed |
| `model_list[].streaming.enabled` | bool | `false` | Allows this model entry to try provider streaming requests |
Legacy Telegram environment variables remain compatible: `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_ENABLED`, `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_THROTTLE_SECONDS`, and `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS`. They only apply to Telegram settings and do not enable or modify Pico `settings.streaming`.
Failure behavior is intentionally conservative: if streaming fails before any visible chunk is sent, PicoClaw retries once through the normal `Chat()` path. If a chunk has already been shown to the user, PicoClaw does not send a second non-streaming answer, because that would duplicate visible output.
#### Vendor-Specific Examples
> **Tip**: You can omit `api_key` fields and store them in `.security.yml` for better security. See [Security Configuration](#-security-configuration-recommended).
@@ -645,7 +803,8 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
```json
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4"
"provider": "openai",
"model": "gpt-5.4"
// api_key: set in .security.yml
}
```
@@ -658,7 +817,8 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest"
"provider": "volcengine",
"model": "ark-code-latest"
// api_key: set in .security.yml
}
```
@@ -671,7 +831,8 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
```json
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7"
"provider": "zhipu",
"model": "glm-4.7"
// api_key: set in .security.yml
}
```
@@ -684,7 +845,8 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
```json
{
"model_name": "deepseek-chat",
"model": "deepseek/deepseek-chat"
"provider": "deepseek",
"model": "deepseek-chat"
// api_key: set in .security.yml
}
```
@@ -697,7 +859,8 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
```json
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6"
"provider": "anthropic",
"model": "claude-sonnet-4.6"
// api_key: set in .security.yml
}
```
@@ -709,7 +872,8 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
```json
{
"model_name": "claude-opus-4-6",
"model": "anthropic-messages/claude-opus-4-6",
"provider": "anthropic-messages",
"model": "claude-opus-4-6",
"api_keys": ["sk-ant-your-key"],
"api_base": "https://api.anthropic.com"
}
@@ -725,7 +889,8 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
```json
{
"model_name": "llama3",
"model": "ollama/llama3"
"provider": "ollama",
"model": "llama3"
}
```
@@ -737,12 +902,13 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
```json
{
"model_name": "lmstudio-local",
"model": "lmstudio/openai/gpt-oss-20b"
"provider": "lmstudio",
"model": "openai/gpt-oss-20b"
}
```
`api_base` defaults to `http://localhost:1234/v1`. API key is optional unless your LM Studio server enables authentication.<br/>
PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio/` prefix before sending requests, so `lmstudio/openai/gpt-oss-20b` sends `openai/gpt-oss-20b` to the LM Studio server.
With explicit `provider`, PicoClaw sends `openai/gpt-oss-20b` unchanged to LM Studio. The legacy compatibility form `"model": "lmstudio/openai/gpt-oss-20b"` still resolves to the same upstream model ID when `provider` is omitted.
</details>
@@ -752,13 +918,14 @@ PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio
```json
{
"model_name": "my-custom-model",
"model": "openai/custom-model",
"provider": "openai",
"model": "custom-model",
"api_base": "https://my-proxy.com/v1"
// api_key: set in .security.yml
}
```
PicoClaw strips only the outer `litellm/` prefix before sending the request, so `litellm/lite-gpt4` sends `lite-gpt4`, while `litellm/openai/gpt-4o` sends `openai/gpt-4o`.
With explicit `provider`, PicoClaw sends `model` unchanged. That means `"provider": "litellm", "model": "lite-gpt4"` sends `lite-gpt4`, while `"provider": "litellm", "model": "openai/gpt-4o"` sends `openai/gpt-4o`. The legacy compatibility forms `litellm/lite-gpt4` and `litellm/openai/gpt-4o` still resolve the same way when `provider` is omitted.
</details>
@@ -783,7 +950,8 @@ model_list:
"model_list": [
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api.openai.com/v1"
// api_keys loaded from .security.yml
}
@@ -798,13 +966,15 @@ model_list:
"model_list": [
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_keys": ["sk-key1"]
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_keys": ["sk-key2"]
}
@@ -864,7 +1034,7 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m
{
"agents": {
"defaults": {
"model": "anthropic/claude-opus-4-5"
"model_name": "claude-opus-4-5"
}
},
"session": {
+49
View File
@@ -31,6 +31,55 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
```
### Konfigurasi Streaming
Provider streaming menggunakan double opt-in dan dimatikan secara lalai. Agent hanya mencuba streaming apabila saluran semasa mempunyai `settings.streaming.enabled: true`, entry model aktif mempunyai `streaming.enabled: true`, dan kedua-dua provider serta saluran menyokong streaming. Jika mana-mana syarat tiada, PicoClaw menggunakan laluan permintaan bukan streaming biasa.
Pico WebUI ialah saluran pertama yang disambungkan sepenuhnya. Pico mencipta mesej assistant pertama dengan wire message sedia ada `message.create`, kemudian mengemas kini mesej yang sama dengan `message.update`; tiada jenis wire message Pico baharu ditambah.
Biarkan `streaming` tidak ditetapkan jika anda tidak mahu streaming. Blok `streaming` yang tiada bermaksud dimatikan; anda tidak perlu menulis `"streaming": {"enabled": false}`.
Contoh mengaktifkan streaming:
```json
{
"model_list": [
{
"model_name": "gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-your-openai-key"],
"streaming": {
"enabled": true
}
}
],
"channel_list": {
"pico": {
"enabled": true,
"type": "pico",
"settings": {
"token": "YOUR_PICO_TOKEN",
"streaming": {
"enabled": true
}
}
}
}
}
```
| Kunci | Jenis | Lalai | Penerangan |
| ----- | ----- | ----- | ---------- |
| `channel_list.<name>.settings.streaming.enabled` | bool | `false` | Membenarkan saluran ini memaparkan output streaming provider |
| `channel_list.<name>.settings.streaming.throttle_seconds` | int | Lalai Pico selepas diaktifkan: `0` | Jarak masa minimum antara kemas kini pertengahan; kandungan akhir sentiasa dihantar |
| `channel_list.<name>.settings.streaming.min_growth_chars` | int | Lalai Pico selepas diaktifkan: `1` | Pertambahan aksara minimum sebelum menghantar kemas kini pertengahan; kandungan akhir sentiasa dihantar |
| `model_list[].streaming.enabled` | bool | `false` | Membenarkan entry model ini mencuba permintaan provider streaming |
Pemboleh ubah persekitaran Telegram lama masih serasi: `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_ENABLED`, `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_THROTTLE_SECONDS`, dan `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS`. Ia hanya digunakan untuk settings Telegram dan tidak mengaktifkan atau mengubah `settings.streaming` Pico.
Tingkah laku kegagalan adalah konservatif: jika streaming gagal sebelum mana-mana chunk kelihatan dihantar, PicoClaw mencuba semula sekali melalui laluan `Chat()` biasa. Jika chunk sudah dipaparkan kepada pengguna, PicoClaw tidak menghantar jawapan bukan streaming kedua untuk mengelakkan output berganda.
### Susun Atur Workspace
PicoClaw menyimpan data dalam workspace yang dikonfigurasikan (lalai: `~/.picoclaw/workspace`):
+49
View File
@@ -365,6 +365,55 @@ Configure múltiplos endpoints para o mesmo nome de modelo — PicoClaw fará ro
A configuração antiga `providers` está **depreciada** e foi removida no V2. Configs V0/V1 existentes são auto-migradas. Veja [docs/migration/model-list-migration.md](../migration/model-list-migration.md).
#### Configuração de Streaming
O streaming do provider usa double opt-in e fica desativado por padrão. O agent só tenta streaming quando o canal atual tem `settings.streaming.enabled: true`, a entrada de modelo ativa tem `streaming.enabled: true`, e tanto o provider quanto o canal suportam streaming. Se qualquer condição estiver ausente, o PicoClaw usa o caminho normal de requisição sem streaming.
O Pico WebUI é o primeiro canal totalmente integrado. O Pico cria a primeira mensagem assistant com o wire message existente `message.create` e depois atualiza a mesma mensagem com `message.update`; nenhum novo tipo de wire message do Pico é introduzido.
Deixe `streaming` ausente quando não quiser streaming. Um bloco `streaming` omitido significa desativado; você não precisa escrever `"streaming": {"enabled": false}`.
Exemplo de ativação:
```json
{
"model_list": [
{
"model_name": "gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-your-openai-key"],
"streaming": {
"enabled": true
}
}
],
"channel_list": {
"pico": {
"enabled": true,
"type": "pico",
"settings": {
"token": "YOUR_PICO_TOKEN",
"streaming": {
"enabled": true
}
}
}
}
}
```
| Campo | Tipo | Padrão | Descrição |
| ----- | ---- | ------ | --------- |
| `channel_list.<name>.settings.streaming.enabled` | bool | `false` | Permite que este canal exiba output streaming do provider |
| `channel_list.<name>.settings.streaming.throttle_seconds` | int | Padrão do Pico após ativar: `0` | Intervalo mínimo entre atualizações intermediárias; o conteúdo final sempre é enviado |
| `channel_list.<name>.settings.streaming.min_growth_chars` | int | Padrão do Pico após ativar: `1` | Crescimento mínimo de texto antes de enviar outra atualização intermediária; o conteúdo final sempre é enviado |
| `model_list[].streaming.enabled` | bool | `false` | Permite que esta entrada de modelo tente requisições de provider streaming |
As variáveis de ambiente legadas do Telegram continuam compatíveis: `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_ENABLED`, `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_THROTTLE_SECONDS` e `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS`. Elas se aplicam apenas às settings do Telegram e não ativam nem modificam `settings.streaming` do Pico.
O comportamento de falha é intencionalmente conservador: se o streaming falhar antes de qualquer chunk visível ser enviado, o PicoClaw tenta novamente uma vez pelo caminho normal `Chat()`. Se um chunk já foi mostrado ao usuário, o PicoClaw não envia uma segunda resposta sem streaming, evitando output duplicado.
### Arquitetura de Providers
PicoClaw roteia providers por família de protocolo:
+49
View File
@@ -365,6 +365,55 @@ Cấu hình nhiều endpoint cho cùng tên mô hình — PicoClaw sẽ tự đ
Cấu hình `providers` cũ đã **bị deprecated** và đã được loại bỏ trong V2. Các cấu hình V0/V1 hiện có sẽ được tự động migrate. Xem [docs/migration/model-list-migration.md](../migration/model-list-migration.md).
#### Cấu Hình Streaming
Provider streaming dùng cơ chế double opt-in và bị tắt theo mặc định. Agent chỉ thử streaming khi channel hiện tại có `settings.streaming.enabled: true`, entry model đang dùng có `streaming.enabled: true`, và cả provider lẫn channel đều hỗ trợ streaming. Nếu thiếu bất kỳ điều kiện nào, PicoClaw dùng đường dẫn yêu cầu không streaming thông thường.
Pico WebUI là channel đầu tiên được nối đầy đủ. Pico tạo message assistant đầu tiên bằng wire message hiện có `message.create`, sau đó cập nhật chính message đó bằng `message.update`; không thêm loại wire message Pico mới.
Hãy để trống `streaming` khi bạn không muốn dùng streaming. Bỏ qua block `streaming` nghĩa là đã tắt; bạn không cần viết `"streaming": {"enabled": false}`.
Ví dụ bật streaming:
```json
{
"model_list": [
{
"model_name": "gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-your-openai-key"],
"streaming": {
"enabled": true
}
}
],
"channel_list": {
"pico": {
"enabled": true,
"type": "pico",
"settings": {
"token": "YOUR_PICO_TOKEN",
"streaming": {
"enabled": true
}
}
}
}
}
```
| Trường | Kiểu | Mặc định | Mô tả |
| ------ | ---- | -------- | ----- |
| `channel_list.<name>.settings.streaming.enabled` | bool | `false` | Cho phép channel này hiển thị output streaming từ provider |
| `channel_list.<name>.settings.streaming.throttle_seconds` | int | Mặc định Pico sau khi bật: `0` | Khoảng cách tối thiểu giữa các cập nhật trung gian; nội dung cuối luôn được flush |
| `channel_list.<name>.settings.streaming.min_growth_chars` | int | Mặc định Pico sau khi bật: `1` | Số ký tự tăng tối thiểu trước khi gửi cập nhật trung gian; nội dung cuối luôn được flush |
| `model_list[].streaming.enabled` | bool | `false` | Cho phép entry model này thử yêu cầu provider streaming |
Các biến môi trường Telegram cũ vẫn tương thích: `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_ENABLED`, `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_THROTTLE_SECONDS`, và `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS`. Chúng chỉ áp dụng cho Telegram settings và không bật hoặc thay đổi `settings.streaming` của Pico.
Hành vi lỗi được giữ thận trọng: nếu streaming lỗi trước khi gửi bất kỳ chunk hiển thị nào, PicoClaw thử lại một lần qua đường dẫn `Chat()` thông thường. Nếu đã có chunk hiển thị cho người dùng, PicoClaw không gửi thêm một câu trả lời non-streaming thứ hai để tránh lặp output.
### Kiến Trúc Provider
PicoClaw định tuyến provider theo họ giao thức:
+182 -44
View File
@@ -67,6 +67,36 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
> **提示:**`AGENT.md``SOUL.md``USER.md``memory/MEMORY.md` 的修改会通过文件修改时间(mtime)在运行时自动检测。**无需重启 gateway**,Agent 将在下一次请求时自动加载最新内容。
### Agent 自进化
`evolution` 配置块控制 PicoClaw 的自进化运行时。启用后,Agent 会把已完成的回合记录为学习记录。在更高模式下,它可以聚类重复出现的成功模式、生成技能草稿,并可选择把已接受的草稿应用到工作区技能中。
```json
{
"evolution": {
"enabled": false,
"mode": "observe",
"state_dir": "",
"min_task_count": 2,
"min_success_ratio": 0.7,
"cold_path_trigger": "after_turn",
"cold_path_times": []
}
}
```
| 字段 | 默认值 | 说明 |
|------|--------|------|
| `enabled` | `false` | 启用已完成 Agent 回合的学习记录采集。Heartbeat 回合会被忽略。 |
| `mode` | `observe` | `observe` 只记录数据;`draft` 可生成候选技能草稿;`apply` 可将已接受草稿应用到工作区技能。 |
| `state_dir` | `""` | 自进化状态的可选目录。留空时使用工作区下的默认位置。 |
| `min_task_count` | `2` | 一个模式具备生成草稿资格前所需的最小相关任务记录数。 |
| `min_success_ratio` | `0.7` | 任务聚类所需的最小成功率,取值需大于 `0`,且不超过 `1`。 |
| `cold_path_trigger` | `after_turn` | 草稿生成可在 `after_turn` 后运行、按 `scheduled` 定时运行;设置为 `manual` 时会关闭自动冷路径运行。目前还没有用户可用的手动触发入口。仅在 `draft``apply` 模式下生效。 |
| `cold_path_times` | `[]` | 当 `cold_path_trigger``scheduled` 时使用的运行时间,格式为 `HH:MM` 字符串。 |
如果你只想先检查学习记录,建议从 `observe` 开始。需要生成可审查改进时使用 `draft`。只有在你接受让已通过的草稿更新工作区技能时,才使用 `apply`
### Web 启动器控制台
**picoclaw-launcher** 打开浏览器控制台前需要先使用密码登录。首次启动时打开 `/launcher-setup` 创建 dashboard 登录密码;后续手动登录使用 `/launcher-login`
@@ -263,7 +293,7 @@ PicoClaw 默认在沙箱环境中运行。Agent 只能访问配置的工作区
| `tools.exec.custom_deny_patterns` | string[] | `[]` | 自定义阻止的正则表达式模式 |
| `tools.exec.custom_allow_patterns` | string[] | `[]` | 自定义允许的正则表达式模式 |
> **安全提示:** Symlink 保护默认启用——所有文件路径在白名单匹配前都会通过 `filepath.EvalSymlinks` 解析,防止符号链接逃逸攻击。
> **安全提示:** Symlink 保护默认启用——所有文件路径在允许列表匹配前都会通过 `filepath.EvalSymlinks` 解析,防止符号链接逃逸攻击。
#### 已知限制:构建工具的子进程
@@ -425,7 +455,7 @@ Agent 读取 HEARTBEAT.md
### 模型配置 (model_list)
> **新特性:** PicoClaw 现在采用**以模型为中心**的配置方式。只需指定 `vendor/model` 格式(例如 `zhipu/glm-4.7`)即可接入新提供商——**无需修改任何代码!**
> **新特性:** PicoClaw 现在优先推荐显式 `provider` + 原生 `model` 的配置方式,例如 `"provider": "zhipu", "model": "glm-4.7"`。如果未设置 `provider`,旧的单字段 `provider/model` 写法仍然兼容。
这一设计同时支持**多 Agent**场景,灵活选择提供商:
@@ -436,31 +466,31 @@ Agent 读取 HEARTBEAT.md
#### 所有支持的厂商
| 厂商 | `model` 前缀 | 默认 API Base | 协议 | API Key |
| 厂商 | `provider` | 默认 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` | Gemini | [获取](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 | 本地(无需 Key |
| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) |
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取](https://openrouter.ai/keys) |
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理 Key |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取](https://cerebras.ai) |
| **火山引擎 (豆包)** | `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 | Custom | 仅 OAuth |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | — |
| **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` | Gemini | [获取](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 | 本地(无需 Key |
| **LM Studio** | `lmstudio` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) |
| **OpenRouter** | `openrouter` | `https://openrouter.ai/api/v1` | OpenAI | [获取](https://openrouter.ai/keys) |
| **LiteLLM Proxy** | `litellm` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理 Key |
| **VLLM** | `vllm` | `http://localhost:8000/v1` | OpenAI | 本地 |
| **Cerebras** | `cerebras` | `https://api.cerebras.ai/v1` | OpenAI | [获取](https://cerebras.ai) |
| **火山引擎 (豆包)** | `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 | Custom | 仅 OAuth |
| **GitHub Copilot** | `github-copilot` | `localhost:4321` | gRPC | — |
#### 基础配置
@@ -469,22 +499,26 @@ Agent 读取 HEARTBEAT.md
"model_list": [
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["sk-your-api-key"]
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-your-openai-key"]
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"provider": "anthropic",
"model": "claude-sonnet-4.6",
"api_keys": ["sk-ant-your-key"]
},
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"provider": "zhipu",
"model": "glm-4.7",
"api_keys": ["your-zhipu-key"]
}
],
@@ -496,6 +530,62 @@ Agent 读取 HEARTBEAT.md
}
```
解析规则:
- 推荐显式写成 `"provider": "openai", "model": "gpt-5.4"`
- 如果设置了 `provider`PicoClaw 会将 `model` 原样发送。
- 如果未设置 `provider`PicoClaw 会把 `model` 第一个 `/` 之前的字段当作 provider,并把第一个 `/` 之后的全部内容当作最终模型 ID。
- 这意味着 `"model": "openrouter/openai/gpt-5.4"` 这样的兼容写法仍然可用,并会把 `openai/gpt-5.4` 发送给 OpenRouter。
#### 流式输出配置
Provider 流式输出采用双开关,默认关闭。只有当前 channel 的 `settings.streaming.enabled` 和当前模型条目的 `streaming.enabled` 都为 `true`,并且 provider 与 channel 都支持流式能力时,Agent 才会尝试流式请求;任一条件不满足时仍使用普通非流式请求。
当前完整落地的是 Pico WebUI。Pico 使用已有的 `message.create` 创建第一条 assistant 消息,随后用 `message.update` 更新同一条消息,不新增协议消息类型。
不需要流式时请省略 `streaming` 配置块。省略表示关闭,不需要写 `"streaming": {"enabled": false}`
开启示例:
```json
{
"model_list": [
{
"model_name": "gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-your-openai-key"],
"streaming": {
"enabled": true
}
}
],
"channel_list": {
"pico": {
"enabled": true,
"type": "pico",
"settings": {
"token": "YOUR_PICO_TOKEN",
"streaming": {
"enabled": true
}
}
}
}
}
```
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `channel_list.<name>.settings.streaming.enabled` | bool | `false` | 是否允许该 channel 尝试展示 provider 流式输出 |
| `channel_list.<name>.settings.streaming.throttle_seconds` | int | Pico 开启后默认 `0` | 中间更新的最小时间间隔,最终内容不受此限制 |
| `channel_list.<name>.settings.streaming.min_growth_chars` | int | Pico 开启后默认 `1` | 中间更新相比上次发送至少增长的字符数,最终内容不受此限制 |
| `model_list[].streaming.enabled` | bool | `false` | 是否允许该模型条目尝试 provider 流式请求 |
Telegram 旧环境变量仍兼容:`PICOCLAW_CHANNELS_TELEGRAM_STREAMING_ENABLED``PICOCLAW_CHANNELS_TELEGRAM_STREAMING_THROTTLE_SECONDS``PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS`。这些环境变量只作用于 Telegram settings,不会开启或修改 Pico 的 `settings.streaming`
失败处理保持保守:如果还没有任何可见 chunk 就失败,PicoClaw 会回退到普通 `Chat()` 路径重试一次;如果已经有 chunk 展示给用户,则不会再发送一条非流式最终答案,避免界面重复输出。
#### 各厂商配置示例
<details>
@@ -504,7 +594,8 @@ Agent 读取 HEARTBEAT.md
```json
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-..."]
}
```
@@ -517,7 +608,8 @@ Agent 读取 HEARTBEAT.md
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["sk-..."]
}
```
@@ -530,7 +622,8 @@ Agent 读取 HEARTBEAT.md
```json
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"provider": "zhipu",
"model": "glm-4.7",
"api_keys": ["your-key"]
}
```
@@ -543,7 +636,8 @@ Agent 读取 HEARTBEAT.md
```json
{
"model_name": "deepseek-chat",
"model": "deepseek/deepseek-chat",
"provider": "deepseek",
"model": "deepseek-chat",
"api_keys": ["sk-..."]
}
```
@@ -556,7 +650,8 @@ Agent 读取 HEARTBEAT.md
```json
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"provider": "anthropic",
"model": "claude-sonnet-4.6",
"api_keys": ["sk-ant-your-key"]
}
```
@@ -568,7 +663,8 @@ Agent 读取 HEARTBEAT.md
```json
{
"model_name": "claude-opus-4-6",
"model": "anthropic-messages/claude-opus-4-6",
"provider": "anthropic-messages",
"model": "claude-opus-4-6",
"api_keys": ["sk-ant-your-key"],
"api_base": "https://api.anthropic.com"
}
@@ -584,7 +680,8 @@ Agent 读取 HEARTBEAT.md
```json
{
"model_name": "llama3",
"model": "ollama/llama3"
"provider": "ollama",
"model": "llama3"
}
```
@@ -596,12 +693,13 @@ Agent 读取 HEARTBEAT.md
```json
{
"model_name": "lmstudio-local",
"model": "lmstudio/openai/gpt-oss-20b"
"provider": "lmstudio",
"model": "openai/gpt-oss-20b"
}
```
`api_base` 默认是 `http://localhost:1234/v1`。除非你在 LM Studio 侧启用了认证,否则不需要配置 API Key。
PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首个 `lmstudio/` 前缀,因此 `lmstudio/openai/gpt-oss-20b` 会发送 `openai/gpt-oss-20b`
显式设置 `provider` 后,PicoClaw 会把 `openai/gpt-oss-20b` 原样发送给 LM Studio。旧的兼容写法 `"model": "lmstudio/openai/gpt-oss-20b"` 在未设置 `provider` 时也会解析成相同的上游模型 ID
</details>
@@ -611,13 +709,14 @@ PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首
```json
{
"model_name": "my-custom-model",
"model": "openai/custom-model",
"provider": "openai",
"model": "custom-model",
"api_base": "https://my-proxy.com/v1",
"api_keys": ["sk-..."]
}
```
PicoClaw 只剥离最外层的 `litellm/` 前缀再发送请求,因此 `litellm/lite-gpt4` 发送 `lite-gpt4`,而 `litellm/openai/gpt-4o` 发送 `openai/gpt-4o`
显式设置 `provider` 后,PicoClaw 会将 `model` 原样发送。因此 `"provider": "litellm", "model": "lite-gpt4"` 发送 `lite-gpt4`,而 `"provider": "litellm", "model": "openai/gpt-4o"` 发送 `openai/gpt-4o`旧的兼容写法 `litellm/lite-gpt4``litellm/openai/gpt-4o` 在未设置 `provider` 时也会得到相同结果。
</details>
@@ -630,13 +729,15 @@ PicoClaw 只剥离最外层的 `litellm/` 前缀再发送请求,因此 `litell
"model_list": [
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_keys": ["sk-key1"]
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_keys": ["sk-key2"]
}
@@ -691,7 +792,7 @@ PicoClaw 按协议族路由提供商:
{
"agents": {
"defaults": {
"model": "anthropic/claude-opus-4-5"
"model_name": "claude-opus-4-5"
}
},
"session": {
@@ -731,6 +832,42 @@ PicoClaw 按协议族路由提供商:
</details>
### 事件日志
PicoClaw 的 runtime events 会覆盖 agent、channel、gateway、message bus 和 MCP 等运行时组件。默认只打印 `agent.*` 事件,其他事件仍会发布到 runtime event bus,但不会进入日志。
```json
{
"events": {
"logging": {
"enabled": true,
"include": ["agent.*"],
"exclude": [],
"min_severity": "info",
"include_payload": false
}
}
}
```
常用配置:
```json
{
"events": {
"logging": {
"include": ["*"],
"exclude": ["agent.llm.delta"],
"min_severity": "warn"
}
}
}
```
`include` / `exclude` 支持精确事件名和 `gateway.*``channel.lifecycle.*` 这类模式。`include_payload` 默认关闭,避免把完整用户消息或工具参数写入日志;agent 事件会默认输出长度、计数、状态等摘要字段。
更多字段说明和示例见 [Runtime Events 与事件日志](../architecture/runtime-events.zh.md)。
### 定时任务 / 提醒
PicoClaw 通过 `cron` 工具支持 cron 风格的定时任务。Agent 可以设置、列出和取消在指定时间触发的提醒或周期性任务。
@@ -753,6 +890,7 @@ PicoClaw 通过 `cron` 工具支持 cron 风格的定时任务。Agent 可以设
| 主题 | 说明 |
| ---- | ---- |
| [敏感数据过滤](../security/sensitive_data_filtering.zh.md) | 在发送给 LLM 前,从工具结果中过滤 API 密钥和令牌 |
| [Runtime Events 与事件日志](../architecture/runtime-events.zh.md) | 统一运行时事件、日志过滤和调试配置 |
| [Hook 系统](../architecture/hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook |
| [Steering](../architecture/steering.md) | 在工具调用间向运行中的 Agent 注入消息 |
| [SubTurn](../architecture/subturn.md) | 子 Agent 协调、并发控制、生命周期管理 |
+1 -1
View File
@@ -36,7 +36,7 @@ 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.
L'image `launcher` inclut les deux binaires (`picoclaw`, `picoclaw-launcher`) 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
+1 -1
View File
@@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down
### Launcher モード (Web コンソール)
`launcher` イメージには 3 つのバイナリ(`picoclaw``picoclaw-launcher``picoclaw-launcher-tui`)がすべて含まれており、デフォルトで Web コンソールを起動します。ブラウザベースの設定・チャット画面を提供します。
`launcher` イメージには 2 つのバイナリ(`picoclaw``picoclaw-launcher`)が含まれており、デフォルトで Web コンソールを起動します。ブラウザベースの設定・チャット画面を提供します。
```bash
docker compose -f docker/docker-compose.yml --profile launcher up -d
+7 -4
View File
@@ -39,7 +39,7 @@ 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.
The `launcher` image includes both binaries (`picoclaw`, `picoclaw-launcher`) 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
@@ -94,19 +94,22 @@ picoclaw onboard
"model_list": [
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["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",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["your-api-key"],
"request_timeout": 300
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"provider": "anthropic",
"model": "claude-sonnet-4.6",
"api_keys": ["your-anthropic-key"]
}
],
+1 -1
View File
@@ -35,7 +35,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down
### Mod Launcher (Konsol Web)
Imej `launcher` merangkumi ketiga-tiga binari (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) dan memulakan konsol web secara lalai, yang menyediakan UI berasaskan pelayar untuk konfigurasi dan sembang.
Imej `launcher` merangkumi kedua-dua binari (`picoclaw`, `picoclaw-launcher`) dan memulakan konsol web secara lalai, yang menyediakan UI berasaskan pelayar untuk konfigurasi dan sembang.
```bash
docker compose -f docker/docker-compose.yml --profile launcher up -d
+1 -1
View File
@@ -36,7 +36,7 @@ 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.
A imagem `launcher` inclui ambos os binários (`picoclaw`, `picoclaw-launcher`) 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
+1 -1
View File
@@ -36,7 +36,7 @@ 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.
Image `launcher` bao gồm cả hai binary (`picoclaw`, `picoclaw-launcher`) 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
+7 -4
View File
@@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down
### Launcher 模式 (Web 控制台)
`launcher` 镜像包含所有三个二进制文件(`picoclaw``picoclaw-launcher``picoclaw-launcher-tui`),默认启动 Web 控制台,提供基于浏览器的配置和聊天界面。
`launcher` 镜像包含个二进制文件(`picoclaw``picoclaw-launcher`),默认启动 Web 控制台,提供基于浏览器的配置和聊天界面。
```bash
docker compose -f docker/docker-compose.yml --profile launcher up -d
@@ -93,19 +93,22 @@ picoclaw onboard
"model_list": [
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["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",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["your-api-key"],
"request_timeout": 300
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"provider": "anthropic",
"model": "claude-sonnet-4.6",
"api_keys": ["your-anthropic-key"]
}
],
+3
View File
@@ -113,10 +113,13 @@ Cette conception permet également le **support multi-agents** avec une sélecti
| `max_tokens_field` | string | Non | Remplace le nom du champ max tokens dans le corps de la requête (ex : `max_completion_tokens` pour les modèles o1) |
| `thinking_level` | string | Non | Niveau de pensée étendue : `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` |
| `extra_body` | object | Non | Champs supplémentaires à injecter dans chaque corps de requête |
| `streaming.enabled` | bool | Non | Opt-in pour le streaming provider sur cette entrée de modèle. Par défaut `false`, et le channel actif doit aussi avoir `settings.streaming.enabled` à `true` |
| `rpm` | int | Non | Limite de requêtes par minute |
| `fallbacks` | string[] | Non | Noms des modèles de secours pour le basculement automatique |
| `enabled` | bool | Non | Activer ou désactiver cette entrée de modèle (par défaut : `true`) |
Lorsque le streaming est désactivé, omettez le bloc `streaming`. Écrire `"streaming": {"enabled": false}` est optionnel et n'est pas nécessaire.
#### Exemples par Vendor
**OpenAI**
+3
View File
@@ -114,10 +114,13 @@
| `max_tokens_field` | string | いいえ | リクエストボディの max tokens フィールド名を上書き(例:o1 モデルでは `max_completion_tokens` |
| `thinking_level` | string | いいえ | 拡張思考レベル:`off``low``medium``high``xhigh``adaptive` |
| `extra_body` | object | いいえ | 各リクエストボディに注入する追加フィールド |
| `streaming.enabled` | bool | いいえ | このモデルエントリで provider ストリーミングを試行するための opt-in。デフォルトは `false` で、アクティブな channel の `settings.streaming.enabled``true` である必要があります |
| `rpm` | int | いいえ | 1 分あたりのリクエストレート制限 |
| `fallbacks` | string[] | いいえ | 自動フェイルオーバーのフォールバックモデル名 |
| `enabled` | bool | いいえ | このモデルエントリを有効にするかどうか(デフォルト:`true` |
ストリーミングを無効にする場合は `streaming` ブロックを省略してください。`"streaming": {"enabled": false}` を書くことは任意であり、必須ではありません。
#### ベンダー別設定例
**OpenAI**
+139 -74
View File
@@ -33,7 +33,7 @@
### 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!**
> **What's New?** PicoClaw now prefers explicit `provider` + native `model` configuration (for example `"provider": "zhipu", "model": "glm-4.7"`). The legacy single-field `provider/model` form remains supported for compatibility when `provider` is omitted.
For agent dispatch and light-model routing examples, see the [Routing Guide](routing-guide.md).
@@ -46,35 +46,35 @@ This design also enables **multi-agent support** with flexible provider selectio
#### 📋 All Supported Vendors
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
| Vendor | `provider` Value | Default API Base | Protocol | API Key |
| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- |
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
| **Venice AI** | `venice/` | `https://api.venice.ai/api/v1` | OpenAI | [Get Key](https://venice.ai) |
| **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) |
| **Z.AI Coding Plan** | `openai/` | `https://api.z.ai/api/coding/paas/v4` | OpenAI | [Get Key](https://z.ai/manage-apikey/apikey-list) |
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [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) |
| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) |
| **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) |
| **Xiaomi MiMo** | `mimo/` | `https://api.xiaomimimo.com/v1` | OpenAI | [Get Key](https://platform.xiaomimimo.com) |
| **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 | - |
| **OpenAI** | `openai` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
| **Venice AI** | `venice` | `https://api.venice.ai/api/v1` | OpenAI | [Get Key](https://venice.ai) |
| **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) |
| **Z.AI Coding Plan** | `openai` | `https://api.z.ai/api/coding/paas/v4` | OpenAI | [Get Key](https://z.ai/manage-apikey/apikey-list) |
| **DeepSeek** | `deepseek` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [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) |
| **LM Studio** | `lmstudio` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) |
| **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) |
| **Xiaomi MiMo** | `mimo` | `https://api.xiaomimimo.com/v1` | OpenAI | [Get Key](https://platform.xiaomimimo.com) |
| **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
@@ -83,22 +83,26 @@ This design also enables **multi-agent support** with flexible provider selectio
"model_list": [
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["sk-your-api-key"]
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-your-openai-key"]
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"provider": "anthropic",
"model": "claude-sonnet-4.6",
"api_keys": ["sk-ant-your-key"]
},
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"provider": "zhipu",
"model": "glm-4.7",
"api_keys": ["your-zhipu-key"]
}
],
@@ -112,22 +116,66 @@ This design also enables **multi-agent support** with flexible provider selectio
#### `model_list` Entry Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `model_name` | string | Yes | Unique name used to reference this model in agent config |
| `model` | string | Yes | Vendor/model identifier (e.g., `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) |
| `api_keys` | string[] | Yes* | API key(s) for authentication. Multiple keys enable per-request rotation. Not required for local providers (Ollama, LM Studio, VLLM) |
| `api_base` | string | No | Override the default API endpoint URL |
| `proxy` | string | No | HTTP proxy URL for this model entry |
| `user_agent` | string | No | Custom `User-Agent` header sent with API requests (supported by OpenAI-compatible, Gemini, Anthropic, and Azure providers) |
| `request_timeout` | int | No | Request timeout in seconds (default varies by provider) |
| `max_tokens_field` | string | No | Override the max tokens field name in request body (e.g., `max_completion_tokens` for o1 models) |
| `thinking_level` | string | No | Extended thinking level: `off`, `low`, `medium`, `high`, `xhigh`, or `adaptive` |
| `extra_body` | object | No | Additional fields to inject into every request body |
| Field | Type | Required | Description |
|-------|------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `model_name` | string | Yes | Unique name used to reference this model in agent config |
| `provider` | string | No | Preferred provider identifier. When present, PicoClaw sends `model` unchanged to that provider |
| `model` | string | Yes | Native model ID when `provider` is set. If `provider` is omitted, the legacy `provider/model` form is still supported |
| `api_keys` | string[] | Yes* | API key(s) for authentication. Multiple keys enable per-request rotation. Not required for local providers (Ollama, LM Studio, VLLM) |
| `api_base` | string | No | Override the default API endpoint URL |
| `proxy` | string | No | HTTP proxy URL for this model entry |
| `user_agent` | string | No | Custom `User-Agent` header sent with API requests (supported by OpenAI-compatible, Gemini, Anthropic, and Azure providers) |
| `request_timeout` | int | No | Request timeout in seconds (default varies by provider) |
| `max_tokens_field` | string | No | Override the max tokens field name in request body (e.g., `max_completion_tokens` for o1 models) |
| `thinking_level` | string | No | Extended thinking level: `off`, `low`, `medium`, `high`, `xhigh`, or `adaptive` |
| `tool_schema_transform` | string | No | Optional compatibility transform for tool parameter schemas. Default: disabled. Supported values: `simple`. |
| `extra_body` | object | No | Additional fields to inject into every request body |
| `custom_headers` | object | No | Additional HTTP headers to inject into every request (e.g., `{"X-Source":"coding-plan"}`). If a key matches a built-in header, the custom value overrides the built-in one (e.g., `Authorization`, `User-Agent`, `Content-Type`, `Accept`). |
| `rpm` | int | No | Per-minute request rate limit |
| `fallbacks` | string[] | No | Fallback model names for automatic failover |
| `enabled` | bool | No | Whether this model entry is active (default: `true`) |
| `streaming.enabled` | bool | No | Opt-in for provider streaming on this model entry. Defaults to `false` and also requires the active channel's `settings.streaming.enabled` to be `true`. |
| `rpm` | int | No | Per-minute request rate limit |
| `fallbacks` | string[] | No | Fallback model names for automatic failover |
| `enabled` | bool | No | Whether this model entry is active (default: `true`) |
When streaming is disabled, omit the `streaming` block. Writing `"streaming": {"enabled": false}` is optional and not needed in generated or hand-written config.
#### Tool Schema Compatibility
By default, PicoClaw now forwards tool JSON Schemas unchanged.
Some providers reject advanced JSON Schema features such as `$ref`, `$defs`, `anyOf`, `oneOf`, `allOf`, `pattern`, or numeric/string constraints inside tool declarations. For those models, you can opt into a compatibility transform per model entry with `tool_schema_transform`.
Use `simple` when the upstream provider expects the conservative style function schema subset:
```json
{
"model_name": "gemini-2.5-flash-safe-tools",
"provider": "gemini",
"model": "gemini-2.5-flash",
"api_keys": ["your-gemini-key"],
"tool_schema_transform": "simple"
}
```
Notes:
- Default behavior is disabled. If you omit `tool_schema_transform`, PicoClaw sends the original tool schema.
- The setting is per model entry, so you can enable it only for the providers that need it.
#### Provider / Model Resolution
PicoClaw resolves `provider` and the runtime model ID using these rules:
- If `provider` is set, `model` is used as-is.
- If `provider` is omitted, PicoClaw treats the first `/` segment in `model` as the provider and everything after that first `/` as the runtime model ID.
Examples:
| Config | Resolved Provider | Model Sent Upstream |
| --- | --- | --- |
| `"provider": "openai", "model": "gpt-5.4"` | `openai` | `gpt-5.4` |
| `"model": "openai/gpt-5.4"` | `openai` | `gpt-5.4` |
| `"provider": "openrouter", "model": "openai/gpt-5.4"` | `openrouter` | `openai/gpt-5.4` |
| `"model": "openrouter/openai/gpt-5.4"` | `openrouter` | `openai/gpt-5.4` |
#### Voice Transcription
@@ -140,7 +188,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
"model_list": [
{
"model_name": "voice-gemini",
"model": "gemini/gemini-2.5-flash",
"provider": "gemini",
"model": "gemini-2.5-flash",
"api_keys": ["your-gemini-key"]
}
],
@@ -163,7 +212,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
```json
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-..."]
}
```
@@ -173,7 +223,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["sk-..."]
}
```
@@ -183,7 +234,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
```json
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"provider": "zhipu",
"model": "glm-4.7",
"api_keys": ["your-key"]
}
```
@@ -193,7 +245,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
```json
{
"model_name": "glm-4.7",
"model": "openai/glm-4.7",
"provider": "openai",
"model": "glm-4.7",
"api_keys": ["your-z.ai-key"],
"api_base": "https://api.z.ai/api/coding/paas/v4"
}
@@ -204,7 +257,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
```json
{
"model_name": "deepseek-chat",
"model": "deepseek/deepseek-chat",
"provider": "deepseek",
"model": "deepseek-chat",
"api_keys": ["sk-..."]
}
```
@@ -214,7 +268,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
```json
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"provider": "anthropic",
"model": "claude-sonnet-4.6",
"api_keys": ["sk-ant-your-key"]
}
```
@@ -228,7 +283,8 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
```json
{
"model_name": "claude-opus-4-6",
"model": "anthropic-messages/claude-opus-4-6",
"provider": "anthropic-messages",
"model": "claude-opus-4-6",
"api_keys": ["sk-ant-your-key"],
"api_base": "https://api.anthropic.com"
}
@@ -246,7 +302,8 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
```json
{
"model_name": "llama3",
"model": "ollama/llama3"
"provider": "ollama",
"model": "llama3"
}
```
@@ -255,19 +312,21 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
```json
{
"model_name": "lmstudio-local",
"model": "lmstudio/openai/gpt-oss-20b"
"provider": "lmstudio",
"model": "openai/gpt-oss-20b"
}
```
`api_base` defaults to `http://localhost:1234/v1`. API key is optional unless your LM Studio server enables authentication.<br/>
PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio/` prefix before sending requests, so `lmstudio/openai/gpt-oss-20b` sends `openai/gpt-oss-20b` to the LM Studio server.
With explicit `provider`, PicoClaw sends `openai/gpt-oss-20b` unchanged to the LM Studio server. The legacy compatibility form `"model": "lmstudio/openai/gpt-oss-20b"` still resolves to the same upstream model ID when `provider` is omitted.
**Custom Proxy/API**
```json
{
"model_name": "my-custom-model",
"model": "openai/custom-model",
"provider": "openai",
"model": "custom-model",
"api_base": "https://my-proxy.com/v1",
"api_keys": ["sk-..."],
"user_agent": "MyApp/1.0",
@@ -280,13 +339,14 @@ PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio
```json
{
"model_name": "lite-gpt4",
"model": "litellm/lite-gpt4",
"provider": "litellm",
"model": "lite-gpt4",
"api_base": "http://localhost:4000/v1",
"api_keys": ["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`.
With explicit `provider`, PicoClaw sends `model` unchanged. That means `"provider": "litellm", "model": "lite-gpt4"` sends `lite-gpt4`, while `"provider": "litellm", "model": "openai/gpt-4o"` sends `openai/gpt-4o`. The legacy compatibility forms `litellm/lite-gpt4` and `litellm/openai/gpt-4o` still resolve the same way when `provider` is omitted.
**Z.AI Coding Plan**
@@ -295,7 +355,8 @@ If the standard Zhipu endpoint (`https://open.bigmodel.cn/api/paas/v4`) returns
```json
{
"model_name": "glm-4.7",
"model": "openai/glm-4.7",
"provider": "openai",
"model": "glm-4.7",
"api_keys": ["your-zhipu-api-key"],
"api_base": "https://api.z.ai/api/coding/paas/v4"
}
@@ -312,13 +373,15 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical
"model_list": [
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_keys": ["sk-key1"]
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_keys": ["sk-key2"]
}
@@ -337,27 +400,28 @@ It also applies cooldown tracking per candidate to avoid immediately retrying a
"model_list": [
{
"model_name": "qwen-main",
"model": "openai/qwen3.5:cloud",
"provider": "openai",
"model": "qwen3.5:cloud",
"api_base": "https://api.example.com/v1",
"api_keys": ["sk-main"]
},
{
"model_name": "deepseek-backup",
"model": "deepseek/deepseek-chat",
"provider": "deepseek",
"model": "deepseek-chat",
"api_keys": ["sk-backup-1"]
},
{
"model_name": "gemini-backup",
"model": "gemini/gemini-2.5-flash",
"provider": "gemini",
"model": "gemini-2.5-flash",
"api_keys": ["sk-backup-2"]
}
],
"agents": {
"defaults": {
"model": {
"primary": "qwen-main",
"fallbacks": ["deepseek-backup", "gemini-backup"]
}
"model_name": "qwen-main",
"model_fallbacks": ["deepseek-backup", "gemini-backup"]
}
}
}
@@ -396,7 +460,8 @@ The old `providers` configuration is **deprecated** and has been removed in V2.
"model_list": [
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"provider": "zhipu",
"model": "glm-4.7",
"api_keys": ["your-key"]
}
],
@@ -465,7 +530,7 @@ picoclaw agent -m "Hello"
{
"agents": {
"defaults": {
"model_name": "anthropic/claude-opus-4-5"
"model_name": "claude-opus-4-5"
}
},
"session": {

Some files were not shown because too many files have changed in this diff Show More