diff --git a/.github/workflows/create_dmg.yml b/.github/workflows/create_dmg.yml index 67fded40a..626318619 100644 --- a/.github/workflows/create_dmg.yml +++ b/.github/workflows/create_dmg.yml @@ -24,7 +24,7 @@ jobs: go-version-file: go.mod - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10.33.0 run_install: false diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 0e619dd27..39ad8810e 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -48,7 +48,7 @@ jobs: go-version-file: go.mod - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10.33.0 run_install: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c887bf493..1480d410d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,7 +66,7 @@ jobs: go-version-file: go.mod - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10.33.0 run_install: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cbb6a6347..a78c41c36 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,8 @@ We are committed to maintaining a welcoming and respectful community. Be kind, c For substantial new features, please open an issue first to discuss the design before writing code. This prevents wasted effort and ensures alignment with the project's direction. +For documentation contributions, prefer the layout and naming conventions in [`docs/README.md`](docs/README.md). Run `make lint-docs` after adding or moving Markdown files to catch common consistency issues early. + --- ## Getting Started @@ -64,7 +66,7 @@ For substantial new features, please open an issue first to discuss the design b ```bash make build # Build binary (runs go generate first) make generate # Run go generate only -make check # Full pre-commit check: deps + fmt + vet + test +make check # Full pre-commit check: deps + fmt + vet + test + docs consistency checks ``` ### Running Tests @@ -81,9 +83,10 @@ go test -bench=. -benchmem -run='^$' ./... # Run benchmarks make fmt # Format code make vet # Static analysis make lint # Full linter run +make lint-docs # Check common documentation layout and naming conventions ``` -All CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early. +All CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early, including the common docs consistency checks from `make lint-docs`. --- diff --git a/Makefile b/Makefile index afaa7c29a..c5d691c29 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build install uninstall clean help test build-all +.PHONY: all build install uninstall clean help test build-all lint-docs # Build variables BINARY_NAME=picoclaw @@ -308,9 +308,14 @@ test: generate fmt: @$(GOLANGCI_LINT) fmt +## lint-docs: Check common documentation layout and naming conventions +lint-docs: + @./scripts/lint-docs.sh + ## lint: Run linters lint: @$(GOLANGCI_LINT) run --build-tags $(GO_BUILD_TAGS) + @./scripts/lint-docs.sh ## fix: Fix linting issues fix: @@ -326,8 +331,8 @@ update-deps: @$(GO) get -u ./... @$(GO) mod tidy -## check: Run vet, fmt, and verify dependencies -check: deps fmt vet test +## check: Run deps, fmt, vet, tests, and docs consistency checks +check: deps fmt vet test lint-docs ## run: Build and run picoclaw run: build diff --git a/README.md b/README.md index 1ab514a29..5aac4bbc9 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | **English** +[中文](docs/project/README.zh.md) | [日本語](docs/project/README.ja.md) | [한국어](docs/project/README.ko.md) | [Português](docs/project/README.pt-br.md) | [Tiếng Việt](docs/project/README.vi.md) | [Français](docs/project/README.fr.md) | [Italiano](docs/project/README.it.md) | [Bahasa Indonesia](docs/project/README.id.md) | [Malay](docs/project/README.ms.md) | **English** @@ -112,7 +112,7 @@ _*Recent builds may use 10-20MB due to rapid PR merges. Resource optimization is -> **[Hardware Compatibility List](docs/hardware-compatibility.md)** — See all tested boards, from $5 RISC-V to Raspberry Pi to Android phones. Your board not listed? Submit a PR! +> **[Hardware Compatibility List](docs/guides/hardware-compatibility.md)** — See all tested boards, from $5 RISC-V to Raspberry Pi to Android phones. Your board not listed? Submit a PR!

PicoClaw Hardware Compatibility @@ -309,6 +309,7 @@ Use the TUI menus to: **1)** Configure a Provider -> **2)** Configure a Channel For detailed TUI documentation, see [docs.picoclaw.io](https://docs.picoclaw.io). + ### 📱 Android Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. @@ -379,7 +380,7 @@ This creates `~/.picoclaw/config.json` and the workspace directory. > See `config/config.example.json` in the repo for a complete configuration template with all available options. > -> Please note: config.example.json format is version 0, with sensitive codes in it, and will be auto migrated to version 1+, then, the config.json will only store insensitive data, the sensitive codes will be stored in .security.yml, if you need manually modify the codes, please see `docs/security_configuration.md` for more details. +> Please note: config.example.json format is version 0, with sensitive codes in it, and will be auto migrated to version 1+, then, the config.json will only store insensitive data, the sensitive codes will be stored in .security.yml, if you need manually modify the codes, please see `docs/security/security_configuration.md` for more details. **3. Chat** @@ -458,7 +459,7 @@ PicoClaw supports 30+ LLM providers through the `model_list` configuration. Use } ``` -For full provider configuration details, see [Providers & Models](docs/providers.md). +For full provider configuration details, see [Providers & Models](docs/guides/providers.md). @@ -470,8 +471,8 @@ Talk to your PicoClaw through 18+ messaging platforms: |---------|-------|----------|------| | **Telegram** | Easy (bot token) | Long polling | [Guide](docs/channels/telegram/README.md) | | **Discord** | Easy (bot token + intents) | WebSocket | [Guide](docs/channels/discord/README.md) | -| **WhatsApp** | Easy (QR scan or bridge URL) | Native / Bridge | [Guide](docs/chat-apps.md#whatsapp) | -| **Weixin** | Easy (Native QR scan) | iLink API | [Guide](docs/chat-apps.md#weixin) | +| **WhatsApp** | Easy (QR scan or bridge URL) | Native / Bridge | [Guide](docs/guides/chat-apps.md#whatsapp) | +| **Weixin** | Easy (Native QR scan) | iLink API | [Guide](docs/guides/chat-apps.md#weixin) | | **QQ** | Easy (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.md) | | **Slack** | Easy (bot + app token) | Socket Mode | [Guide](docs/channels/slack/README.md) | | **Matrix** | Medium (homeserver + token) | Sync API | [Guide](docs/channels/matrix/README.md) | @@ -480,7 +481,7 @@ Talk to your PicoClaw through 18+ messaging platforms: | **LINE** | Medium (credentials + webhook) | Webhook | [Guide](docs/channels/line/README.md) | | **WeCom** | Easy (QR login or manual) | WebSocket | [Guide](docs/channels/wecom/README.md) | | **VK** | Easy (group token) | Long Poll | [Guide](docs/channels/vk/README.md) | -| **IRC** | Medium (server + nick) | IRC protocol | [Guide](docs/chat-apps.md#irc) | +| **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) | | **MaixCam** | Easy (enable) | TCP socket | [Guide](docs/channels/maixcam/README.md) | | **Pico** | Easy (enable) | Native protocol | Built-in | @@ -488,9 +489,9 @@ Talk to your PicoClaw through 18+ messaging platforms: > All webhook-based channels share a single Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu uses WebSocket/SDK mode and does not use the shared HTTP server. -> Log verbosity is controlled by `gateway.log_level` (default: `warn`). Supported values: `debug`, `info`, `warn`, `error`, `fatal`. Can also be set via `PICOCLAW_LOG_LEVEL`. See [Configuration](docs/configuration.md#gateway-log-level) for details. +> Log verbosity is controlled by `gateway.log_level` (default: `warn`). Supported values: `debug`, `info`, `warn`, `error`, `fatal`. Can also be set via `PICOCLAW_LOG_LEVEL`. See [Configuration](docs/guides/configuration.md#gateway-log-level) for details. -For detailed channel setup instructions, see [Chat Apps Configuration](docs/chat-apps.md). +For detailed channel setup instructions, see [Chat Apps Configuration](docs/guides/chat-apps.md). ## 🔧 Tools @@ -510,7 +511,7 @@ PicoClaw can search the web to provide up-to-date information. Configure in `too ### ⚙️ Other Tools -PicoClaw includes built-in tools for file operations, code execution, scheduling, and more. See [Tools Configuration](docs/tools_configuration.md) for details. +PicoClaw includes built-in tools for file operations, code execution, scheduling, and more. See [Tools Configuration](docs/reference/tools_configuration.md) for details. ## 🎯 Skills @@ -547,7 +548,7 @@ Add to your `config.json`: `tools.skills.github.*` is deprecated. Use `tools.skills.registries.github.*` instead. -For more details, see [Tools Configuration - Skills](docs/tools_configuration.md#skills-tool). +For more details, see [Tools Configuration - Skills](docs/reference/tools_configuration.md#skills-tool). ## 🔗 MCP (Model Context Protocol) @@ -570,7 +571,7 @@ PicoClaw natively supports [MCP](https://modelcontextprotocol.io/) — connect a } ``` -For full MCP configuration (stdio, SSE, HTTP transports, Tool Discovery), see [Tools Configuration - MCP](docs/tools_configuration.md#mcp-tool). +For full MCP configuration (stdio, SSE, HTTP transports, Tool Discovery), see [Tools Configuration - MCP](docs/reference/tools_configuration.md#mcp-tool). ## ClawdChat Join the Agent Social Network @@ -607,7 +608,7 @@ PicoClaw supports scheduled reminders and recurring tasks through the `cron` too * **Recurring tasks**: "Remind me every 2 hours" -> triggers every 2 hours * **Cron expressions**: "Remind me at 9am daily" -> uses cron expression -See [docs/cron.md](docs/cron.md) for current schedule types, execution modes, command-job gates, and persistence details. +See [docs/reference/cron.md](docs/reference/cron.md) for current schedule types, execution modes, command-job gates, and persistence details. ## 📚 Documentation @@ -615,18 +616,18 @@ For detailed guides beyond this README: | Topic | Description | |-------|-------------| -| [Docker & Quick Start](docs/docker.md) | Docker Compose setup, Launcher/Agent modes | -| [Chat Apps](docs/chat-apps.md) | All 17+ channel setup guides | -| [Configuration](docs/configuration.md) | Environment variables, workspace layout, security sandbox | -| [Scheduled Tasks and Cron Jobs](docs/cron.md) | Cron schedule types, deliver modes, command gates, job storage | -| [Providers & Models](docs/providers.md) | 30+ LLM providers, model routing, model_list configuration | -| [Spawn & Async Tasks](docs/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration | -| [Hooks](docs/hooks/README.md) | Event-driven hook system: observers, interceptors, approval hooks | -| [Steering](docs/steering.md) | Inject messages into a running agent loop between tool calls | -| [SubTurn](docs/subturn.md) | Subagent coordination, concurrency control, lifecycle | -| [Troubleshooting](docs/troubleshooting.md) | Common issues and solutions | -| [Tools Configuration](docs/tools_configuration.md) | Per-tool enable/disable, exec policies, MCP, Skills | -| [Hardware Compatibility](docs/hardware-compatibility.md) | Tested boards, minimum requirements | +| [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 | +| [Configuration](docs/guides/configuration.md) | Environment variables, workspace layout, security sandbox | +| [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 | +| [Hooks](docs/architecture/hooks/README.md) | Event-driven hook system: observers, interceptors, approval hooks | +| [Steering](docs/architecture/steering.md) | Inject messages into a running agent loop between tool calls | +| [SubTurn](docs/architecture/subturn.md) | Subagent coordination, concurrency control, lifecycle | +| [Troubleshooting](docs/operations/troubleshooting.md) | Common issues and solutions | +| [Tools Configuration](docs/reference/tools_configuration.md) | Per-tool enable/disable, exec policies, MCP, Skills | +| [Hardware Compatibility](docs/guides/hardware-compatibility.md) | Tested boards, minimum requirements | ## 🤝 Contribute & Roadmap diff --git a/assets/wechat.png b/assets/wechat.png index d538f40e6..c41288547 100644 Binary files a/assets/wechat.png and b/assets/wechat.png differ diff --git a/cmd/picoclaw/internal/auth/helpers.go b/cmd/picoclaw/internal/auth/helpers.go index 531cb76aa..523f6a16a 100644 --- a/cmd/picoclaw/internal/auth/helpers.go +++ b/cmd/picoclaw/internal/auth/helpers.go @@ -17,24 +17,24 @@ import ( ) const ( - supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity" + supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity, antigravity" defaultAnthropicModel = "claude-sonnet-4.6" ) -func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error { +func authLoginCmd(provider string, useDeviceCode bool, useOauth bool, noBrowser bool) error { switch provider { case "openai": - return authLoginOpenAI(useDeviceCode) + return authLoginOpenAI(useDeviceCode, noBrowser) case "anthropic": return authLoginAnthropic(useOauth) case "google-antigravity", "antigravity": - return authLoginGoogleAntigravity() + return authLoginGoogleAntigravity(noBrowser) default: return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg) } } -func authLoginOpenAI(useDeviceCode bool) error { +func authLoginOpenAI(useDeviceCode bool, noBrowser bool) error { cfg := auth.OpenAIOAuthConfig() var cred *auth.AuthCredential @@ -43,7 +43,7 @@ func authLoginOpenAI(useDeviceCode bool) error { if useDeviceCode { cred, err = auth.LoginDeviceCode(cfg) } else { - cred, err = auth.LoginBrowser(cfg) + cred, err = auth.LoginBrowserWithOptions(cfg, auth.LoginBrowserOptions{NoBrowser: noBrowser}) } if err != nil { @@ -92,10 +92,10 @@ func authLoginOpenAI(useDeviceCode bool) error { return nil } -func authLoginGoogleAntigravity() error { +func authLoginGoogleAntigravity(noBrowser bool) error { cfg := auth.GoogleAntigravityOAuthConfig() - cred, err := auth.LoginBrowser(cfg) + cred, err := auth.LoginBrowserWithOptions(cfg, auth.LoginBrowserOptions{NoBrowser: noBrowser}) if err != nil { return fmt.Errorf("login failed: %w", err) } diff --git a/cmd/picoclaw/internal/auth/login.go b/cmd/picoclaw/internal/auth/login.go index afbe098aa..b9b44db34 100644 --- a/cmd/picoclaw/internal/auth/login.go +++ b/cmd/picoclaw/internal/auth/login.go @@ -7,6 +7,7 @@ func newLoginCommand() *cobra.Command { provider string useDeviceCode bool useOauth bool + noBrowser bool ) cmd := &cobra.Command{ @@ -14,12 +15,15 @@ func newLoginCommand() *cobra.Command { Short: "Login via OAuth or paste token", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - return authLoginCmd(provider, useDeviceCode, useOauth) + return authLoginCmd(provider, useDeviceCode, useOauth, noBrowser) }, } - cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)") + cmd.Flags().StringVarP( + &provider, "provider", "p", "", "Provider to login with (openai, anthropic, google-antigravity, antigravity)", + ) cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)") + cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Do not auto-open a browser during OAuth login") cmd.Flags().BoolVar( &useOauth, "setup-token", false, "Use setup-token flow for Anthropic (from `claude setup-token`)", diff --git a/cmd/picoclaw/internal/auth/login_test.go b/cmd/picoclaw/internal/auth/login_test.go index d6a03c25b..5129d9aaf 100644 --- a/cmd/picoclaw/internal/auth/login_test.go +++ b/cmd/picoclaw/internal/auth/login_test.go @@ -18,6 +18,7 @@ func TestNewLoginSubCommand(t *testing.T) { assert.True(t, cmd.HasFlags()) assert.NotNil(t, cmd.Flags().Lookup("device-code")) + assert.NotNil(t, cmd.Flags().Lookup("no-browser")) providerFlag := cmd.Flags().Lookup("provider") require.NotNil(t, providerFlag) diff --git a/cmd/picoclaw/internal/auth/status_test.go b/cmd/picoclaw/internal/auth/status_test.go index 7748ba502..2f9a70721 100644 --- a/cmd/picoclaw/internal/auth/status_test.go +++ b/cmd/picoclaw/internal/auth/status_test.go @@ -1,12 +1,53 @@ package auth import ( + "bytes" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + pkgauth "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" ) +func captureAuthStdout(t *testing.T, fn func()) string { + t.Helper() + + oldStdout := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + t.Cleanup(func() { + os.Stdout = oldStdout + }) + + fn() + + require.NoError(t, w.Close()) + os.Stdout = oldStdout + + var buf bytes.Buffer + _, err = io.Copy(&buf, r) + require.NoError(t, err) + require.NoError(t, r.Close()) + return buf.String() +} + +func setAuthStatusTestHome(t *testing.T) string { + t.Helper() + + tmpDir := t.TempDir() + t.Setenv(config.EnvHome, filepath.Join(tmpDir, ".picoclaw")) + return tmpDir +} + func TestNewStatusSubcommand(t *testing.T) { cmd := newStatusCommand() @@ -16,3 +57,47 @@ func TestNewStatusSubcommand(t *testing.T) { assert.False(t, cmd.HasFlags()) } + +func TestAuthStatusCmdShowsCanonicalGoogleAntigravityAfterLegacyRefresh(t *testing.T) { + tmpDir := setAuthStatusTestHome(t) + + legacyExpiry := time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC) + legacyStore := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "expires_at": legacyExpiry.Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + "project_id": "legacy-project", + }, + }, + } + data, err := json.Marshal(legacyStore) + require.NoError(t, err) + + authPath := filepath.Join(tmpDir, ".picoclaw", "auth.json") + require.NoError(t, os.MkdirAll(filepath.Dir(authPath), 0o755)) + require.NoError(t, os.WriteFile(authPath, data, 0o600)) + + refreshedExpiry := time.Date(2026, 4, 16, 12, 30, 0, 0, time.UTC) + err = pkgauth.SetCredential("google-antigravity", &pkgauth.AuthCredential{ + AccessToken: "fresh-token", + ExpiresAt: refreshedExpiry, + Provider: "google-antigravity", + AuthMethod: "oauth", + ProjectID: "fresh-project", + }) + require.NoError(t, err) + + output := captureAuthStdout(t, func() { + require.NoError(t, authStatusCmd()) + }) + + assert.Contains(t, output, "\nAuthenticated Providers:") + assert.Contains(t, output, "\n google-antigravity:\n") + assert.NotContains(t, output, "\n antigravity:\n") + assert.Contains(t, output, " Project: fresh-project") + assert.Contains(t, output, " Expires: 2026-04-16 12:30") + assert.Equal(t, 1, strings.Count(output, ":\n Method: oauth")) +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..529eb49ec --- /dev/null +++ b/docs/README.md @@ -0,0 +1,132 @@ +# PicoClaw Documentation + +PicoClaw documentation is organized by document type first and language second. + +This file describes the recommended documentation layout, how translated files should be named, and what `make lint-docs` currently checks locally. + +These conventions are intended as contributor guidance for new or moved docs. Existing docs may still have historical exceptions, and `make lint-docs` only checks a common subset of the patterns described here. + +## Reader Navigation + +If you are browsing docs rather than reorganizing them, start with these directory indexes: + +- [Guides](guides/README.md): setup, configuration, provider, and workflow guides. +- [Reference](reference/README.md): precise configuration and behavior reference. +- [Operations](operations/README.md): debugging and troubleshooting material. +- [Security](security/README.md): security-focused guides and controls. +- [Architecture](architecture/README.md): implementation notes and internal design docs. +- [Migration](migration/README.md): upgrade and migration notes. + +For channel-specific setup, start with [Chat Apps Configuration](guides/chat-apps.md) and then drill into `docs/channels//README.md` as needed. + +## Principles + +- Choose the document type directory first. Do not create language buckets such as `docs/zh/` or `docs/fr/`. +- Keep each translated document next to its English source document. +- Use English as the base filename with no locale suffix. +- Use lowercase locale suffixes for translations, for example `configuration.zh.md` or `README.pt-br.md`. +- Keep module-specific docs next to the code they describe instead of moving them into `docs/`. + +## Recommended Directories + +- `README.md`: English project entry document at the repository root. +- `docs/project/`: translated project entry documents such as `README.zh.md` and `CONTRIBUTING.zh.md`. +- `docs/guides/`: setup and usage guides. +- `docs/reference/`: reference material and detailed configuration docs. +- `docs/operations/`: debugging and troubleshooting docs. +- `docs/security/`: security-related documentation. +- `docs/architecture/`: architecture and internal design notes. +- `docs/channels/`: channel-specific integration guides. +- `docs/design/`: design proposals and investigations. +- `docs/migration/`: migration notes. + +## Recommended Naming + +- English documents use the base filename: + - `README.md` + - `configuration.md` +- Translations use `..md`: + - `README.zh.md` + - `configuration.fr.md` + - `README.pt-br.md` +- Code-adjacent translated READMEs follow the same rule: + - `pkg/audio/asr/README.zh.md` + - `pkg/isolation/README.zh.md` + +## Common Patterns To Avoid + +- Root-level translated entry docs such as `README.zh.md` or `CONTRIBUTING.fr.md` + - Use `docs/project/README.zh.md` or `docs/project/CONTRIBUTING.fr.md` instead. +- Language directories under `docs/` such as `docs/zh/`, `docs/ZH/`, `docs/ja/`, or `docs/fr/` + - Use `docs//..md` instead. +- Nested locale buckets such as `docs/guides/zh/configuration.md` or `docs/channels/telegram/zh/README.md` + - Keep translations beside the English source file instead. +- Legacy translation filenames such as `README_zh.md` or `README_CN.md` + - Use `README.zh.md`. +- Non-canonical locale suffixes such as `configuration_zh.md` or `configuration.ZH.md` + - Use lowercase `..md`, for example `configuration.zh.md`. + +## Translation Placement + +- For docs under `docs/guides`, `docs/reference`, `docs/operations`, `docs/security`, `docs/architecture`, `docs/channels`, and `docs/migration`, keep translations beside the English source file. +- For project entry translations, keep translated files in `docs/project/` and keep the English source in the repository root. +- In most cases, each translated file should have an English source document: + - `docs/guides/configuration.zh.md` usually sits beside `docs/guides/configuration.md` + - `docs/project/README.zh.md` usually corresponds to `README.md` +- Exception: `docs/design/` may contain locale-specific working notes without an English source document. The naming rules still apply there. + +## Code-Adjacent Docs + +Keep documentation next to the implementation when it primarily describes a package, command, example, or subproject. + +Examples: + +- `pkg/**/README.md` +- `cmd/**/README.md` +- `web/README.md` +- `examples/**/README.md` + +These files still follow the same translation naming rules. + +## Adding a New Document + +1. Pick the correct document type directory. +2. Create the English source file first. +3. Add translated siblings after the English source exists when that source is part of the same docs set. +4. Update links from existing docs when the new doc becomes a navigation target. +5. Run `make lint-docs` locally when adding or moving docs. + +## Examples + +- New setup guide: + - `docs/guides/launcher-setup.md` + - `docs/guides/launcher-setup.zh.md` +- New security guide: + - `docs/security/token-rotation.md` +- New translated package README: + - `pkg/channels/README.zh.md` + +## Validation + +Run: + +```bash +make lint-docs +``` + +The local docs linter currently checks these common cases: + +- no root-level translated `README` or `CONTRIBUTING` files +- no `docs//` language buckets, regardless of case +- no nested locale buckets under typed docs directories +- no legacy `README_*.md` filenames +- no non-canonical translation-like filenames such as `_zh.md` or `.ZH.md` +- no extra Markdown files directly under `docs/` except `docs/README.md` +- every translated Markdown file has a matching English source file + - except for locale-specific working notes under `docs/design/` + +`make lint-docs` is a local consistency check for common naming and placement mistakes. It helps contributors stay close to the recommended layout, but it is not intended to describe every acceptable documentation pattern in the repository. + +When a check fails, `make lint-docs` prints the failing path, the reason, and a suggested fix. + +If you change these recommendations or want the local linter to reflect them more closely, update this file and `scripts/lint-docs.sh` together. diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 000000000..6df7447a7 --- /dev/null +++ b/docs/architecture/README.md @@ -0,0 +1,12 @@ +# Architecture + +Internal architecture notes for major runtime mechanisms and subsystem design. + +- [Steering](steering.md): injecting messages into a running agent loop between tool calls. +- [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)) +- [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. + +For proposal-style or exploratory docs, also see [`../design/`](../design/). diff --git a/docs/agent-refactor/README.md b/docs/architecture/agent-refactor/README.md similarity index 100% rename from docs/agent-refactor/README.md rename to docs/architecture/agent-refactor/README.md diff --git a/docs/architecture/agent-refactor/agent-rename-plan.md b/docs/architecture/agent-refactor/agent-rename-plan.md new file mode 100644 index 000000000..f4ab408fe --- /dev/null +++ b/docs/architecture/agent-refactor/agent-rename-plan.md @@ -0,0 +1,100 @@ +# Agent File Rename Plan + +## Goal + +Unify `pkg/agent/` package file naming to resolve the `loop_*` prefix naming confusion and unclear responsibility boundaries. + +## Change Overview + +### File Renames (12 files) + +| Original | New | Description | +|----------|-----|-------------| +| `loop.go` | `agent.go` | AgentLoop main body + lifecycle methods | +| `loop_message.go` | `agent_message.go` | Message handling and routing | +| `loop_outbound.go` | `agent_outbound.go` | Response publishing | +| `loop_event.go` | `agent_event.go` | Event system | +| `loop_command.go` | `agent_command.go` | Command processing | +| `loop_steering.go` | `agent_steering.go` | Steering message handling | +| `loop_transcribe.go` | `agent_transcribe.go` | Audio transcription | +| `loop_media.go` | `agent_media.go` | Media processing | +| `loop_mcp.go` | `agent_mcp.go` | MCP initialization | +| `loop_utils.go` | `agent_utils.go` | Utility functions | +| `loop_inject.go` | `agent_inject.go` | Dependency injection | +| `loop_turn.go` | `turn_coord.go` | Turn coordinator | + +### File Merges (2 → 1) + +| Original | New | Description | +|----------|-----|-------------| +| `turn.go` + `turn_exec.go` | `turn_state.go` | Turn-related type definitions | + +## Final File Structure + +``` +pkg/agent/ +├── agent.go # AgentLoop + Run/Stop/Close lifecycle +├── agent_message.go # Message processing +├── agent_outbound.go # Response publishing +├── agent_event.go # Event system +├── agent_command.go # Command processing +├── agent_steering.go # Steering +├── agent_transcribe.go # Transcription +├── agent_media.go # Media processing +├── agent_mcp.go # MCP +├── agent_utils.go # Utility functions +├── agent_inject.go # Dependency injection +├── turn_coord.go # runTurn + coordinator +├── turn_state.go # turnState + turnExecution + Control + ToolControl + LLMPhase +├── pipeline.go # Pipeline struct + NewPipeline +├── pipeline_setup.go +├── pipeline_llm.go +├── pipeline_execute.go +└── pipeline_finalize.go +``` + +## Naming Convention + +| Prefix | Content | Example | +|--------|---------|---------| +| `agent_*` | AgentLoop method files | `agent_message.go`, `agent_event.go` | +| `turn_*` | Turn lifecycle related | `turn_coord.go`, `turn_state.go` | +| `pipeline_*` | Pipeline methods | `pipeline_setup.go`, `pipeline_llm.go` | +| `context_*` | Context management | `context_manager.go`, `context_legacy.go` | +| `hook_*` | Hook system | `hook_process.go`, `hook_mount.go` | + +## Architecture Layers + +``` +┌─────────────────────────────────────────────────────────┐ +│ AgentLoop (agent.go) │ +│ - Message loop Run/Stop/Close │ +│ - Dependency injection (agent_inject.go) │ +│ - Message routing (agent_message.go) │ +│ - Response publishing (agent_outbound.go) │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Turn Coordinator (turn_coord.go) │ +│ - runTurn(): main coordinator │ +│ - abortTurn(): abort │ +│ - askSideQuestion(): side question │ +│ - selectCandidates(): model selection │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Pipeline (pipeline_*.go) │ +│ - SetupTurn(): initialization │ +│ - CallLLM(): LLM call │ +│ - ExecuteTools(): tool execution │ +│ - Finalize(): finalization │ +└─────────────────────────────────────────────────────────┘ +``` + +## Verification Results + +- ✅ `go build ./pkg/agent/...` - Pass +- ✅ `go vet ./pkg/agent/...` - No warnings +- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - Pass diff --git a/docs/architecture/agent-refactor/agent-rename-plan.zh.md b/docs/architecture/agent-refactor/agent-rename-plan.zh.md new file mode 100644 index 000000000..938817e10 --- /dev/null +++ b/docs/architecture/agent-refactor/agent-rename-plan.zh.md @@ -0,0 +1,100 @@ +# Agent 文件重命名计划 + +## 目标 + +统一 `pkg/agent/` 包的文件命名,解决 `loop_*` 前缀命名混乱、职责边界不清晰的问题。 + +## 变更概览 + +### 文件重命名(12 个) + +| 原文件 | 新文件 | 说明 | +|--------|--------|------| +| `loop.go` | `agent.go` | AgentLoop 主体 + 生命周期方法 | +| `loop_message.go` | `agent_message.go` | 消息处理和路由 | +| `loop_outbound.go` | `agent_outbound.go` | 响应发布 | +| `loop_event.go` | `agent_event.go` | 事件系统 | +| `loop_command.go` | `agent_command.go` | 命令处理 | +| `loop_steering.go` | `agent_steering.go` | Steering 消息处理 | +| `loop_transcribe.go` | `agent_transcribe.go` | 音频转录 | +| `loop_media.go` | `agent_media.go` | 媒体处理 | +| `loop_mcp.go` | `agent_mcp.go` | MCP 初始化 | +| `loop_utils.go` | `agent_utils.go` | 工具函数 | +| `loop_inject.go` | `agent_inject.go` | 依赖注入 | +| `loop_turn.go` | `turn_coord.go` | Turn 协调器 | + +### 文件合并(2 → 1) + +| 原文件 | 新文件 | 说明 | +|--------|--------|------| +| `turn.go` + `turn_exec.go` | `turn_state.go` | Turn 相关类型定义 | + +## 最终文件结构 + +``` +pkg/agent/ +├── agent.go # AgentLoop + Run/Stop/Close 生命周期 +├── agent_message.go # 消息处理 +├── agent_outbound.go # 响应发布 +├── agent_event.go # 事件系统 +├── agent_command.go # 命令处理 +├── agent_steering.go # Steering +├── agent_transcribe.go # 转录 +├── agent_media.go # 媒体处理 +├── agent_mcp.go # MCP +├── agent_utils.go # 工具函数 +├── agent_inject.go # 依赖注入 +├── turn_coord.go # runTurn + 协调器 +├── turn_state.go # turnState + turnExecution + Control + ToolControl + LLMPhase +├── pipeline.go # Pipeline struct + NewPipeline +├── pipeline_setup.go +├── pipeline_llm.go +├── pipeline_execute.go +└── pipeline_finalize.go +``` + +## 命名约定 + +| 前缀 | 内容 | 示例 | +|------|------|------| +| `agent_*` | AgentLoop 的方法文件 | `agent_message.go`, `agent_event.go` | +| `turn_*` | Turn 生命周期相关 | `turn_coord.go`, `turn_state.go` | +| `pipeline_*` | Pipeline 方法 | `pipeline_setup.go`, `pipeline_llm.go` | +| `context_*` | 上下文管理 | `context_manager.go`, `context_legacy.go` | +| `hook_*` | Hook 系统 | `hook_process.go`, `hook_mount.go` | + +## 架构层次 + +``` +┌─────────────────────────────────────────────────────────┐ +│ AgentLoop (agent.go) │ +│ - 消息循环 Run/Stop/Close │ +│ - 依赖注入 (agent_inject.go) │ +│ - 消息路由 (agent_message.go) │ +│ - 响应发布 (agent_outbound.go) │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Turn Coordinator (turn_coord.go) │ +│ - runTurn(): 主协调器 │ +│ - abortTurn(): 中止 │ +│ - askSideQuestion(): 侧问 │ +│ - selectCandidates(): 模型选择 │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Pipeline (pipeline_*.go) │ +│ - SetupTurn(): 初始化 │ +│ - CallLLM(): LLM 调用 │ +│ - ExecuteTools(): 工具执行 │ +│ - Finalize(): 终结 │ +└─────────────────────────────────────────────────────────┘ +``` + +## 验证结果 + +- ✅ `go build ./pkg/agent/...` - 通过 +- ✅ `go vet ./pkg/agent/...` - 无警告 +- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - 通过 diff --git a/docs/agent-refactor/context.md b/docs/architecture/agent-refactor/context.md similarity index 100% rename from docs/agent-refactor/context.md rename to docs/architecture/agent-refactor/context.md diff --git a/docs/architecture/agent-refactor/loop-split.md b/docs/architecture/agent-refactor/loop-split.md new file mode 100644 index 000000000..5395baeeb --- /dev/null +++ b/docs/architecture/agent-refactor/loop-split.md @@ -0,0 +1,77 @@ +# AgentLoop File Split + +> **Note:** This document describes the file split that was completed in a previous phase. The `loop_*` naming has since been renamed to `agent_*` and `turn_*`. See [agent-rename-plan.md](./agent-rename-plan.md) for the current file structure. + +## Overview + +The `pkg/agent/loop.go` file (originally 4384 lines) has been split into 12 focused source files. This is a pure refactoring with no behavioral changes. + +## Goals + +- Reduce cognitive load when navigating agent loop code +- Enable parallel work by decoupling concerns +- Maintain all existing functionality and tests +- Keep imports minimal per file + +## Original File Map (Renamed in Phase 2) + +| Old File | New File | Responsibility | +|----------|----------|----------------| +| `loop.go` | `agent.go` | Core `AgentLoop` struct, `Run`, `Stop`, `Close` | +| `loop_turn.go` | `turn_coord.go` + `pipeline_*.go` | Turn execution: coordinator + Pipeline methods | +| `loop_utils.go` | `agent_utils.go` | Standalone utility functions | +| `loop_init.go` | `agent_init.go` | `NewAgentLoop` constructor and tool registration | +| `loop_message.go` | `agent_message.go` | Message handling and routing | +| `loop_command.go` | `agent_command.go` | Command processing | +| `loop_mcp.go` | `agent_mcp.go` | MCP runtime | +| `loop_event.go` | `agent_event.go` | Event system helpers | +| `loop_media.go` | `agent_media.go` | Media resolution | +| `loop_outbound.go` | `agent_outbound.go` | Response publishing | +| `loop_transcribe.go` | `agent_transcribe.go` | Audio transcription | +| `loop_steering.go` | `agent_steering.go` | Steering queue | +| `loop_inject.go` | `agent_inject.go` | Setter injection | + +## Current File Structure + +See [agent-rename-plan.md](./agent-rename-plan.md) for the complete current file structure. + +## Phase 2: Rename and Pipeline Restructuring + +Phase 2 completed the following: + +1. **File renaming**: All `loop_*` files renamed to `agent_*` or `turn_*` +2. **Turn state merging**: `turn.go` + `turn_exec.go` → `turn_state.go` +3. **Pipeline extraction**: Split large `runTurn` into Pipeline methods + +### Pipeline Architecture + +The Pipeline methods provide structured turn execution: + +| Method | File | Responsibility | +|--------|------|----------------| +| `SetupTurn()` | `pipeline_setup.go` | History assembly, message building, candidate selection | +| `CallLLM()` | `pipeline_llm.go` | PreLLM hooks, fallback, retry, AfterLLM hooks | +| `ExecuteTools()` | `pipeline_execute.go` | Tool execution with hooks | +| `Finalize()` | `pipeline_finalize.go` | Session persistence, compression | + +## Core Principles Applied + +### 1. Same Package, Independent Files +All files belong to the `agent` package and compile together. This preserves the original visibility rules. + +### 2. No Logic Changes +All functions were moved verbatim. The extraction preserved behavioral equivalence. + +### 3. Shared Types in turn_state.go +The `turnState`, `turnExecution`, `Control`, `ToolControl`, and `LLMPhase` types are centralized in `turn_state.go`. + +## Testing + +All existing tests pass. The 5 failing tests (`TestGlobalSkillFileContentChange` and 4 Seahorse tests) are pre-existing failures unrelated to this refactor. + +Build status: `go build ./pkg/agent/...` passes with no errors. + +## See Also + +- [agent-rename-plan.md](./agent-rename-plan.md) — Current file naming convention +- [context.md](context.md) — context management and session handling diff --git a/docs/architecture/agent-refactor/pipeline-restructuring-plan.md b/docs/architecture/agent-refactor/pipeline-restructuring-plan.md new file mode 100644 index 000000000..b77987af1 --- /dev/null +++ b/docs/architecture/agent-refactor/pipeline-restructuring-plan.md @@ -0,0 +1,68 @@ +# Pipeline Restructuring Plan + +## Goal + +Split `agent/pipeline.go` (~1400 lines) into multiple logical files, organizing code by responsibility. + +## Final File Structure + +``` +pkg/agent/ +├── pipeline.go # Pipeline struct + NewPipeline (~39 lines) +├── pipeline_setup.go # SetupTurn method (~115 lines) +├── pipeline_llm.go # CallLLM method (~519 lines) +├── pipeline_execute.go # ExecuteTools method (~693 lines) +└── pipeline_finalize.go # Finalize method (~78 lines) +``` + +## Actual Line Counts + +| File | Lines | +|------|-------| +| `pipeline.go` | 39 | +| `pipeline_setup.go` | 115 | +| `pipeline_llm.go` | 519 | +| `pipeline_execute.go` | 693 | +| `pipeline_finalize.go` | 78 | +| **Total** | **1444** | + +## Responsibility Matrix + +| File | Method | Responsibility | +|------|--------|----------------| +| `pipeline.go` | `Pipeline` struct, `NewPipeline()` | Pipeline dependency container | +| `pipeline_setup.go` | `SetupTurn()` | Turn initialization: history assembly, message building, candidate selection | +| `pipeline_llm.go` | `CallLLM()` | LLM call: PreLLM hooks, fallback, retry, AfterLLM hooks | +| `pipeline_execute.go` | `ExecuteTools()` | Tool execution: BeforeTool/ApproveTool/AfterTool hooks, media sending, steering handling | +| `pipeline_finalize.go` | `Finalize()` | Turn finalization: session save, compression, status setting | + +## Relationship Between Pipeline and Turn Coordinator + +``` +AgentLoop (agent.go) + │ + ├── runAgentLoop() ──────────────────┐ + │ │ + │ ┌───────────────────────────────▼───────────────────────────────┐ + │ │ Turn Coordinator (turn_coord.go) │ + │ │ │ + │ │ runTurn() { │ + │ │ exec = pipeline.SetupTurn() │ + │ │ loop { │ + │ │ ctrl = pipeline.CallLLM() ──► Pipeline (pipeline_*.go) │ + │ │ if ctrl == ToolLoop { │ + │ │ toolCtrl = pipeline.ExecuteTools() │ + │ │ } │ + │ │ } │ + │ │ return pipeline.Finalize() │ + │ │ } │ + │ └─────────────────────────────────────────────────────────────┘ + │ + └── Publish response (agent_outbound.go) +``` + +## Verification Results + +- ✅ `go build ./pkg/agent/...` - Pass +- ✅ `go vet ./pkg/agent/...` - No warnings +- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - Pass diff --git a/docs/architecture/agent-refactor/pipeline-restructuring-plan.zh.md b/docs/architecture/agent-refactor/pipeline-restructuring-plan.zh.md new file mode 100644 index 000000000..2de1396ad --- /dev/null +++ b/docs/architecture/agent-refactor/pipeline-restructuring-plan.zh.md @@ -0,0 +1,68 @@ +# Pipeline 重构文档 + +## 目标 + +将 `agent/pipeline.go` (1400行) 拆分为多个逻辑文件,代码按职责组织。 + +## 最终文件结构 + +``` +pkg/agent/ +├── pipeline.go # Pipeline struct + NewPipeline (~39行) +├── pipeline_setup.go # SetupTurn 方法 (~115行) +├── pipeline_llm.go # CallLLM 方法 (~519行) +├── pipeline_execute.go # ExecuteTools 方法 (~693行) +└── pipeline_finalize.go # Finalize 方法 (~78行) +``` + +## 实际行数 + +| 文件 | 行数 | +|------|------| +| `pipeline.go` | 39 | +| `pipeline_setup.go` | 115 | +| `pipeline_llm.go` | 519 | +| `pipeline_execute.go` | 693 | +| `pipeline_finalize.go` | 78 | +| **总计** | **1444** | + +## 职责说明 + +| 文件 | 方法 | 职责 | +|------|------|------| +| `pipeline.go` | `Pipeline` struct, `NewPipeline()` | Pipeline 依赖容器 | +| `pipeline_setup.go` | `SetupTurn()` | Turn 初始化:历史组装、消息构建、候选人选择 | +| `pipeline_llm.go` | `CallLLM()` | LLM 调用:PreLLM hook、fallback、重试、AfterLLM hook | +| `pipeline_execute.go` | `ExecuteTools()` | 工具执行:BeforeTool/ApproveTool/AfterTool hook、媒体发送、steering 处理 | +| `pipeline_finalize.go` | `Finalize()` | Turn 终结:会话保存、压缩、状态设置 | + +## Pipeline 与 Turn Coordinator 的关系 + +``` +AgentLoop (agent.go) + │ + ├── runAgentLoop() ──────────────────┐ + │ │ + │ ┌───────────────────────────────▼───────────────────────────────┐ + │ │ Turn Coordinator (turn_coord.go) │ + │ │ │ + │ │ runTurn() { │ + │ │ exec = pipeline.SetupTurn() │ + │ │ loop { │ + │ │ ctrl = pipeline.CallLLM() ──► Pipeline (pipeline_*.go) │ + │ │ if ctrl == ToolLoop { │ + │ │ toolCtrl = pipeline.ExecuteTools() │ + │ │ } │ + │ │ } │ + │ │ return pipeline.Finalize() │ + │ │ } │ + │ └─────────────────────────────────────────────────────────────┘ + │ + └── 发布响应 (agent_outbound.go) +``` + +## 验证结果 + +- ✅ `go build ./pkg/agent/...` - 通过 +- ✅ `go vet ./pkg/agent/...` - 无警告 +- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - 通过 diff --git a/docs/hooks/README.md b/docs/architecture/hooks/README.md similarity index 100% rename from docs/hooks/README.md rename to docs/architecture/hooks/README.md diff --git a/docs/hooks/README.zh.md b/docs/architecture/hooks/README.zh.md similarity index 100% rename from docs/hooks/README.zh.md rename to docs/architecture/hooks/README.zh.md diff --git a/docs/hooks/hook-json-protocol.md b/docs/architecture/hooks/hook-json-protocol.md similarity index 100% rename from docs/hooks/hook-json-protocol.md rename to docs/architecture/hooks/hook-json-protocol.md diff --git a/docs/hooks/hook-json-protocol.zh.md b/docs/architecture/hooks/hook-json-protocol.zh.md similarity index 100% rename from docs/hooks/hook-json-protocol.zh.md rename to docs/architecture/hooks/hook-json-protocol.zh.md diff --git a/docs/hooks/plugin-tool-injection.md b/docs/architecture/hooks/plugin-tool-injection.md similarity index 100% rename from docs/hooks/plugin-tool-injection.md rename to docs/architecture/hooks/plugin-tool-injection.md diff --git a/docs/hooks/plugin-tool-injection.zh.md b/docs/architecture/hooks/plugin-tool-injection.zh.md similarity index 100% rename from docs/hooks/plugin-tool-injection.zh.md rename to docs/architecture/hooks/plugin-tool-injection.zh.md diff --git a/docs/architecture/routing-system.md b/docs/architecture/routing-system.md new file mode 100644 index 000000000..ad6c3abfc --- /dev/null +++ b/docs/architecture/routing-system.md @@ -0,0 +1,282 @@ +# Routing System + +> Back to [README](../README.md) + +In PicoClaw, the runtime "routing system" is not just one decision. +It is the combined pipeline that decides: + +1. which agent handles an inbound message +2. which session dimensions should isolate that conversation +3. whether the turn should use the agent's primary model or a configured light model + +This document covers the runtime path in `pkg/routing` and its integration in `pkg/agent`. +It does not describe the launcher's HTTP `ServeMux` routes or the frontend's TanStack Router files under `web/`. + +## Routing Layers + +| Layer | Files | Responsibility | +| --- | --- | --- | +| Agent dispatch | `pkg/routing/route.go`, `pkg/routing/agent_id.go` | Choose the target agent for the inbound message. | +| Session policy selection | `pkg/routing/route.go` | Decide which dimensions should define session isolation for that routed turn. | +| Model routing | `pkg/routing/router.go`, `pkg/routing/features.go`, `pkg/routing/classifier.go` | Choose between the primary model and a configured light model based on message complexity. | +| Runtime integration | `pkg/agent/registry.go`, `pkg/agent/agent_message.go`, `pkg/agent/turn_coord.go` | Apply the route result, allocate session scope, and select model candidates before provider execution. | + +## End-To-End Flow + +The normal path for a user message is: + +```text +InboundMessage + -> NormalizeInboundContext + -> RouteResolver.ResolveRoute(...) + -> session.AllocateRouteSession(...) + -> ensureSessionMetadata(...) + -> Router.SelectModel(...) + -> provider execution +``` + +The first half answers "who should handle this message and what session does it belong to". +The second half answers "which model tier should that agent use for this turn". + +## Agent Dispatch + +`routing.RouteResolver` turns a normalized `bus.InboundContext` into a `ResolvedRoute`: + +```go +type ResolvedRoute struct { + AgentID string + Channel string + AccountID string + SessionPolicy SessionPolicy + MatchedBy string +} +``` + +`MatchedBy` is a debugging aid. +Typical values are: + +- `default` +- `dispatch.rule` +- `dispatch.rule:` + +## Dispatch Input View + +Before matching rules, the resolver builds a normalized `dispatchView`. +Each field is normalized to the exact shape expected by rule matching. + +| Selector field | Runtime shape | +| --- | --- | +| `channel` | lowercased channel name | +| `account` | normalized account ID | +| `space` | `:` | +| `chat` | `:` | +| `topic` | `topic:` | +| `sender` | lowercased canonical sender ID | +| `mentioned` | boolean copied from inbound context | + +This means dispatch rules must match the normalized shape, for example: + +```json +{ + "agents": { + "dispatch": { + "rules": [ + { + "name": "support-group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-100123" + } + }, + { + "name": "slack-mentions", + "agent": "support", + "when": { + "channel": "slack", + "space": "workspace:t001", + "mentioned": true + } + } + ] + } + } +} +``` + +## Dispatch Algorithm + +`ResolveRoute(...)` follows this sequence: + +1. Normalize `channel` and `account`. +2. Clone `session.identity_links` from config. +3. Build the normalized dispatch view. +4. Scan `agents.dispatch.rules` in order. +5. Skip rules with no constraints at all. +6. Return the first rule whose selector fields all match exactly. +7. If no rule matches, fall back to the default agent. + +Important consequences: + +- first match wins +- there is no score or priority field beyond list order +- invalid target agent IDs fall back to the default agent +- sender matching can see canonical identities produced by `identity_links` + +## Default Agent Resolution + +If no dispatch rule wins, or if a rule points at an unknown agent, the resolver picks a default agent using this order: + +1. the agent marked `default: true` +2. otherwise the first entry in `agents.list` +3. otherwise implicit `main` + +Both agent IDs and account IDs are normalized through the helpers in `pkg/routing/agent_id.go`. + +## Session Policy Handoff + +Agent dispatch does not directly build a session key. +Instead it emits a `SessionPolicy`: + +```go +type SessionPolicy struct { + Dimensions []string + IdentityLinks map[string][]string +} +``` + +The dimensions come from: + +- global `session.dimensions` +- or `dispatch_rule.session_dimensions` when the matching rule overrides them + +Only these dimension names survive normalization: + +- `space` +- `chat` +- `topic` +- `sender` + +Invalid or duplicated entries are silently dropped. + +`pkg/session/AllocateRouteSession(...)` then turns that policy into: + +- a structured `SessionScope` +- a canonical routed session key +- legacy compatibility aliases + +So the routing package owns "what should isolate this conversation", while the session package owns "how that isolation becomes keys and durable storage". + +## Identity Links + +`session.identity_links` is shared between dispatch and session allocation. +That is intentional: a sender canonicalized for routing should also map to the same session identity. + +Without that symmetry, the system could route two messages to the same agent but still fragment their history into different sessions. + +## Model Routing + +The second routing stage decides whether a turn can use a cheaper or faster light model. + +Config shape: + +```json +{ + "routing": { + "enabled": true, + "light_model": "gemini-2.0-flash", + "threshold": 0.35 + } +} +``` + +`pkg/routing.Router` compares the current turn against structural features and returns: + +- chosen model name +- whether the light model was used +- computed complexity score + +If the score is below the threshold, the light model wins. +Otherwise the agent's primary model is used. +At runtime this only matters when the agent actually has light-model candidates configured; otherwise execution stays on the primary candidate set. + +## Complexity Features + +`ExtractFeatures(...)` computes a language-agnostic feature vector: + +| Feature | Meaning | +| --- | --- | +| `TokenEstimate` | Approximate token count; CJK runes count more accurately than a flat rune split. | +| `CodeBlockCount` | Number of fenced code blocks in the current message. | +| `RecentToolCalls` | Tool-call count across the last six history entries. | +| `ConversationDepth` | Total history length. | +| `HasAttachments` | Detects embedded media or common media URL/file extensions. | + +This is intentionally structural rather than keyword-based, so the router behaves the same across languages. + +## RuleClassifier Scoring + +The current classifier is `RuleClassifier`. +It uses a weighted sum capped to `[0, 1]`. + +| Signal | Score | +| --- | --- | +| attachments present | `1.00` | +| token estimate `> 200` | `0.35` | +| token estimate `> 50` | `0.15` | +| code block present | `0.40` | +| recent tool calls `> 3` | `0.25` | +| recent tool calls `1..3` | `0.10` | +| conversation depth `> 10` | `0.10` | + +The default threshold is `0.35`. +That makes the following behavior intentional: + +- trivial chat stays on the light model +- code tasks usually jump to the heavy model immediately +- attachments always force the heavy model +- long, plain-text prompts cross the heavy-model boundary at the default threshold + +## Runtime Integration + +Agent dispatch and model routing happen in different places: + +- `pkg/agent/registry.go` owns `RouteResolver` +- `pkg/agent/agent_message.go` resolves the route and allocates session scope +- `pkg/agent/turn_coord.go:selectCandidates` calls `agent.Router.SelectModel(...)` + +When the light model is selected, the agent loop swaps to `agent.LightCandidates`. +When it is not selected, execution stays on the agent's primary provider candidate set. + +## Explicit Session Keys + +One nuance sits just outside `pkg/routing` but matters for the full routing story. + +After a route is allocated, `pkg/agent/agent_utils.go:resolveScopeKey` preserves an explicit incoming session key when the caller already supplied: + +- an opaque canonical key +- a legacy `agent:...` key + +That makes manual system flows, tests, and compatibility paths deterministic even when the normal routed scope would have produced a different key. + +## What This Document Does Not Cover + +The repository also contains two unrelated route systems: + +- backend HTTP routes registered in `web/backend/api/router.go` +- frontend file routes under `web/frontend/src/routes/` + +Those are launcher implementation details. +They are separate from the runtime routing system described here. + +## Related Files + +- `pkg/routing/route.go` +- `pkg/routing/router.go` +- `pkg/routing/classifier.go` +- `pkg/routing/features.go` +- `pkg/routing/agent_id.go` +- `pkg/session/allocator.go` +- `pkg/agent/registry.go` +- `pkg/agent/agent_message.go` +- `pkg/agent/turn_coord.go` diff --git a/docs/architecture/routing-system.zh.md b/docs/architecture/routing-system.zh.md new file mode 100644 index 000000000..018b9e7b2 --- /dev/null +++ b/docs/architecture/routing-system.zh.md @@ -0,0 +1,281 @@ +# 路由系统 + +> 返回 [README](../README.md) + +在 PicoClaw 里,“路由系统”不是单一判断。 +它实际上是组合起来的一条运行时决策链,负责决定: + +1. 哪个 agent 来处理一条入站消息 +2. 这条消息应该落在哪种 session 隔离维度下 +3. 这一轮该使用 agent 的主模型,还是配置中的轻量模型 + +本文覆盖 `pkg/routing` 及其在 `pkg/agent` 中的集成方式。 +它不讨论 `web/` 目录下 launcher 的 HTTP `ServeMux` 路由,也不讨论前端 TanStack Router 文件路由。 + +## 路由分层 + +| 层次 | 文件 | 作用 | +| --- | --- | --- | +| Agent 分发 | `pkg/routing/route.go`、`pkg/routing/agent_id.go` | 为入站消息选择目标 agent。 | +| Session 策略选择 | `pkg/routing/route.go` | 决定该 turn 的会话隔离维度。 | +| 模型路由 | `pkg/routing/router.go`、`pkg/routing/features.go`、`pkg/routing/classifier.go` | 根据消息复杂度在主模型和轻量模型之间做选择。 | +| 运行时集成 | `pkg/agent/registry.go`、`pkg/agent/loop_message.go`、`pkg/agent/loop_turn.go` | 应用 route 结果、分配 session scope,并在真正调用 provider 前选出模型候选集。 | + +## 端到端流程 + +普通用户消息的路径如下: + +```text +InboundMessage + -> NormalizeInboundContext + -> RouteResolver.ResolveRoute(...) + -> session.AllocateRouteSession(...) + -> ensureSessionMetadata(...) + -> Router.SelectModel(...) + -> provider execution +``` + +前半段回答的是“谁来处理,以及属于哪段会话”。 +后半段回答的是“这个 agent 这一轮该走哪一档模型”。 + +## Agent 分发 + +`routing.RouteResolver` 会把归一化后的 `bus.InboundContext` 转成 `ResolvedRoute`: + +```go +type ResolvedRoute struct { + AgentID string + Channel string + AccountID string + SessionPolicy SessionPolicy + MatchedBy string +} +``` + +`MatchedBy` 主要用于日志和调试,常见值包括: + +- `default` +- `dispatch.rule` +- `dispatch.rule:` + +## Dispatch 输入视图 + +真正做规则匹配前,resolver 会先构造一个归一化后的 `dispatchView`。 +每个字段都会变成规则匹配所期待的固定形状。 + +| Selector 字段 | 运行时形状 | +| --- | --- | +| `channel` | 小写 channel 名称 | +| `account` | 归一化后的 account ID | +| `space` | `:` | +| `chat` | `:` | +| `topic` | `topic:` | +| `sender` | 小写 canonical sender ID | +| `mentioned` | 直接来自 inbound context 的布尔值 | + +这意味着 dispatch rule 必须写成归一化后的形状,例如: + +```json +{ + "agents": { + "dispatch": { + "rules": [ + { + "name": "support-group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-100123" + } + }, + { + "name": "slack-mentions", + "agent": "support", + "when": { + "channel": "slack", + "space": "workspace:t001", + "mentioned": true + } + } + ] + } + } +} +``` + +## Dispatch 算法 + +`ResolveRoute(...)` 的流程是: + +1. 归一化 `channel` 和 `account`。 +2. 从配置复制 `session.identity_links`。 +3. 构建归一化后的 dispatch view。 +4. 按顺序扫描 `agents.dispatch.rules`。 +5. 没有任何约束条件的 rule 会被跳过。 +6. 第一个所有 selector 字段都精确匹配的 rule 胜出。 +7. 如果没有 rule 匹配,则回退到默认 agent。 + +这带来几个重要结论: + +- 第一条命中的规则优先,没有额外 priority 字段 +- rule 顺序本身就是优先级 +- 指向无效 agent 的 rule 最终会回退到默认 agent +- sender 匹配看到的是经过 `identity_links` 归一化后的身份 + +## 默认 Agent 解析 + +如果没有 dispatch rule 命中,或者 rule 指向了不存在的 agent,resolver 会按以下顺序选择默认 agent: + +1. `default: true` 的 agent +2. 否则取 `agents.list` 的第一项 +3. 如果配置里没有 agent,则使用隐式 `main` + +Agent ID 和 Account ID 都会经过 `pkg/routing/agent_id.go` 中的归一化逻辑。 + +## Session 策略交接 + +Agent 分发本身不会直接生成 session key。 +它只会产出一个 `SessionPolicy`: + +```go +type SessionPolicy struct { + Dimensions []string + IdentityLinks map[string][]string +} +``` + +维度来源有两种: + +- 全局 `session.dimensions` +- 如果命中的 dispatch rule 指定了 `session_dimensions`,则用 rule 覆盖 + +最终只有这些维度名会被保留下来: + +- `space` +- `chat` +- `topic` +- `sender` + +非法项或重复项会被静默丢弃。 + +随后 `pkg/session/AllocateRouteSession(...)` 再把这份策略转成: + +- 结构化 `SessionScope` +- canonical routed session key +- legacy 兼容 alias + +所以可以把职责边界理解为: + +- `pkg/routing` 决定“这段对话应该按什么维度隔离” +- `pkg/session` 决定“这些维度如何变成 key 和持久化状态” + +## Identity Links + +`session.identity_links` 会同时被 dispatch 和 session allocation 使用。 +这是刻意保持一致的设计:如果某个 sender 在路由阶段已经被规范化,那么 session 阶段也应该落到同一个身份上。 + +否则就会出现“消息路由到了同一个 agent,但上下文仍被拆成多个 session”的问题。 + +## 模型路由 + +第二阶段路由决定这一轮能否使用更便宜或更快的轻量模型。 + +配置形状如下: + +```json +{ + "routing": { + "enabled": true, + "light_model": "gemini-2.0-flash", + "threshold": 0.35 + } +} +``` + +`pkg/routing.Router` 会根据当前 turn 的结构特征,返回: + +- 选中的模型名 +- 是否使用了 light model +- 复杂度分数 + +当分数低于阈值时,走轻量模型;否则仍使用 agent 的主模型。 +但在运行时,只有当 agent 实际配置了 light-model candidates 时,这个判断才会产生效果;否则仍会停留在主模型候选集上。 + +## 复杂度特征 + +`ExtractFeatures(...)` 会计算一个与自然语言内容无关、偏结构化的特征向量: + +| 特征 | 含义 | +| --- | --- | +| `TokenEstimate` | 估算 token 数;对 CJK 文本比简单 rune 平分更准确。 | +| `CodeBlockCount` | 当前消息中 fenced code block 的数量。 | +| `RecentToolCalls` | 最近 6 条历史消息中的 tool call 总数。 | +| `ConversationDepth` | 整体历史长度。 | +| `HasAttachments` | 是否检测到嵌入媒体或常见媒体 URL / 文件扩展名。 | + +这样做的目的,是让模型路由不依赖关键词,从而在不同语言下都保持一致行为。 + +## RuleClassifier 评分 + +当前分类器是 `RuleClassifier`,使用加权求和并把结果截断到 `[0, 1]`。 + +| 信号 | 分值 | +| --- | --- | +| 存在附件 | `1.00` | +| token 估计 `> 200` | `0.35` | +| token 估计 `> 50` | `0.15` | +| 存在代码块 | `0.40` | +| 最近 tool calls `> 3` | `0.25` | +| 最近 tool calls `1..3` | `0.10` | +| 会话深度 `> 10` | `0.10` | + +默认阈值是 `0.35`。 +这意味着以下行为是刻意设计出来的: + +- 很轻的闲聊仍走轻量模型 +- 编码类请求通常会立刻切到重模型 +- 带附件的请求一定走重模型 +- 很长的纯文本请求在默认阈值下也会跨过重模型边界 + +## 运行时集成 + +Agent 分发和模型路由发生在不同位置: + +- `pkg/agent/registry.go` 持有 `RouteResolver` +- `pkg/agent/loop_message.go` 负责 resolve route 并分配 session scope +- `pkg/agent/loop_turn.go:selectCandidates` 调用 `agent.Router.SelectModel(...)` + +当 light model 被选中时,agent loop 会切换到 `agent.LightCandidates`。 +如果没有被选中,则继续使用 agent 的主 provider 候选集。 + +## 显式 Session Key + +还有一个不在 `pkg/routing` 内部、但对整体“路由语义”很重要的细节。 + +在 route 分配完成后,`pkg/agent/loop_utils.go:resolveScopeKey` 会优先保留调用方显式传入的 session key,只要它属于以下格式之一: + +- 不透明 canonical key +- legacy `agent:...` key + +这样一来,手工系统流、测试和兼容路径即使在正常路由 scope 会生成不同 key 的情况下,仍然能保持确定性。 + +## 本文不覆盖的内容 + +仓库里还存在两套和这里无关的“route”系统: + +- `web/backend/api/router.go` 注册的后端 HTTP 路由 +- `web/frontend/src/routes/` 下的前端文件路由 + +它们属于 launcher 的实现细节,和本文描述的运行时路由系统是两回事。 + +## 相关文件 + +- `pkg/routing/route.go` +- `pkg/routing/router.go` +- `pkg/routing/classifier.go` +- `pkg/routing/features.go` +- `pkg/routing/agent_id.go` +- `pkg/session/allocator.go` +- `pkg/agent/registry.go` +- `pkg/agent/loop_message.go` +- `pkg/agent/loop_turn.go` diff --git a/docs/architecture/session-system.md b/docs/architecture/session-system.md new file mode 100644 index 000000000..b87f9c38e --- /dev/null +++ b/docs/architecture/session-system.md @@ -0,0 +1,255 @@ +# Session System + +> Back to [README](../README.md) + +This document describes the runtime session system used by PicoClaw to: + +- map inbound messages onto stable conversation scopes +- persist message history and summaries +- preserve compatibility with legacy `agent:...` session keys while the runtime uses opaque canonical keys + +This document covers the core runtime path in `pkg/session`, `pkg/memory`, and `pkg/agent`. +It does not describe launcher login cookies or dashboard authentication sessions in `web/backend/middleware`. + +## Responsibilities + +The session system has four jobs: + +1. Decide which messages should share the same conversation context. +2. Persist that context durably across turns and restarts. +3. Expose a small `SessionStore` interface to the agent loop. +4. Keep older session-key formats working during storage and routing migrations. + +## Main Components + +| Layer | Files | Responsibility | +| --- | --- | --- | +| Session contract | `pkg/session/session_store.go` | Defines the `SessionStore` interface used by the agent loop. | +| Legacy backend | `pkg/session/manager.go` | Stores one JSON file per session. Still used as a fallback. | +| Session adapter | `pkg/session/jsonl_backend.go` | Adapts `pkg/memory.Store` to `SessionStore`, including alias and scope metadata support. | +| Durable storage | `pkg/memory/jsonl.go` | Append-only JSONL storage plus `.meta.json` sidecar metadata. | +| Scope and key building | `pkg/session/scope.go`, `pkg/session/key.go`, `pkg/session/allocator.go` | Builds structured scopes, opaque canonical keys, and legacy aliases from routing results. | +| Runtime integration | `pkg/agent/instance.go`, `pkg/agent/agent.go`, `pkg/agent/agent_message.go` | Initializes the store, allocates session scope, and persists metadata before turns run. | + +## Session Data Model + +The structured session identity is represented by `session.SessionScope`: + +| Field | Meaning | +| --- | --- | +| `Version` | Schema version. Current value is `ScopeVersionV1`. | +| `AgentID` | Routed agent handling the turn. | +| `Channel` | Normalized inbound channel name. | +| `Account` | Normalized account or bot identifier. | +| `Dimensions` | Ordered list of active partition dimensions such as `chat` or `sender`. | +| `Values` | Concrete normalized values for each selected dimension. | + +Only four dimensions are currently recognized by the allocator: + +- `space` +- `chat` +- `topic` +- `sender` + +The default config uses: + +```json +{ + "session": { + "dimensions": ["chat"] + } +} +``` + +That means one shared conversation per chat unless a dispatch rule overrides it. + +## Canonical Keys And Legacy Aliases + +The runtime now prefers opaque canonical keys: + +```text +sk_v1_ +``` + +These keys are built from a canonical scope signature in `pkg/session/key.go`. +The goal is to make storage keys stable while decoupling them from any specific legacy text format. + +For compatibility, the allocator also emits legacy aliases such as: + +```text +agent:main:direct:user123 +agent:main:slack:channel:c001 +agent:main:pico:direct:pico:session-123 +``` + +These aliases matter because older sessions, tests, and some tools still refer to the legacy shape. +The JSONL backend resolves aliases back to the canonical key before reads and writes. + +The agent loop also preserves explicit incoming session keys when the caller already supplied one of the recognized explicit formats: + +- opaque canonical key +- legacy `agent:...` key + +That behavior lives in `pkg/agent/agent_utils.go:resolveScopeKey`. + +## Allocation Flow + +The end-to-end flow for a normal inbound message is: + +```text +InboundMessage + -> RouteResolver.ResolveRoute(...) + -> session.AllocateRouteSession(...) + -> resolveScopeKey(...) + -> ensureSessionMetadata(...) + -> AgentLoop turn execution + -> SessionStore read/write operations +``` + +More concretely: + +1. `pkg/agent/agent_message.go` resolves the agent route from normalized inbound context. +2. `session.AllocateRouteSession` converts the route's `SessionPolicy` plus inbound context into a structured `SessionScope`. +3. The allocator builds: + - `SessionKey`: canonical routed session key + - `SessionAliases`: compatibility aliases for that routed scope + - `MainSessionKey`: agent-level main session key + - `MainAliases`: legacy alias for the main session +4. `runAgentLoop` persists scope metadata and aliases through `ensureSessionMetadata`. +5. During later reads or writes, `JSONLBackend.ResolveSessionKey` maps aliases back onto the canonical key. + +The main session key is separate from routed chat sessions. +It is mainly used for agent-level or system-style flows that need one stable per-agent conversation, for example `processSystemMessage`. + +## Scope Construction Rules + +`pkg/session/allocator.go` builds scope values from normalized inbound context. +Important rules: + +- `space` becomes `:` +- `chat` becomes `:` +- `topic` becomes `topic:` +- `sender` is canonicalized through `session.identity_links` before being stored + +There are two special cases worth calling out. + +### Telegram forum isolation + +Telegram forum topics must stay isolated even when the configured dimensions only mention `chat`. +To preserve that behavior, the allocator appends `/` to the `chat` value for Telegram forum messages unless `topic` is already an explicit dimension. + +Example: + +```text +group:-1001234567890/42 +group:-1001234567890/99 +``` + +Those produce different session keys. + +### Identity links + +`session.identity_links` lets multiple sender identifiers collapse into one canonical identity. +Both dispatch matching and session allocation use that mapping so that the same person can keep one conversation even if their raw sender IDs differ across channels or accounts. + +## Storage Format + +The default runtime backend is `pkg/memory.JSONLStore`, wrapped by `session.JSONLBackend`. + +Each session uses two files: + +```text +{sanitized_key}.jsonl +{sanitized_key}.meta.json +``` + +The files store: + +- `.jsonl`: one `providers.Message` per line, append-only +- `.meta.json`: summary, timestamps, line counts, logical truncation offset, scope, aliases + +`SessionMeta` currently includes: + +- `Key` +- `Summary` +- `Skip` +- `Count` +- `CreatedAt` +- `UpdatedAt` +- `Scope` +- `Aliases` + +## Write And Crash Semantics + +The JSONL store is designed around append-first durability and stale-over-loss recovery: + +- `AddMessage` and `AddFullMessage` append one JSON line, `fsync`, then update metadata. +- `TruncateHistory` is logical first: it only advances `meta.Skip`. +- `Compact` physically rewrites the JSONL file to remove skipped lines. +- `SetHistory` and `Compact` write metadata before rewriting JSONL so a crash may temporarily expose old data, but should not lose data. +- Corrupt JSONL lines are skipped during reads instead of failing the entire session. + +`JSONLBackend.Save` maps onto `store.Compact(...)`. +In other words, `Save` is no longer "flush dirty memory to disk"; it is now "reclaim dead lines after logical truncation". + +## Concurrency Model + +`pkg/memory.JSONLStore` uses a fixed 64-shard mutex array keyed by session hash. +That gives per-session serialization without keeping an unbounded mutex map in memory. + +The legacy `SessionManager` uses a single in-memory map guarded by an RW mutex. + +Both backends satisfy the same `SessionStore` interface, which is why the agent loop does not need storage-specific code. + +## Compatibility And Migration + +`pkg/agent/instance.go:initSessionStore` prefers the JSONL backend. + +Startup sequence: + +1. Create `memory.NewJSONLStore(dir)`. +2. Run `memory.MigrateFromJSON(...)` to import legacy `.json` sessions. +3. Wrap the store with `session.NewJSONLBackend(store)`. +4. If JSONL initialization or migration fails, fall back to `session.NewSessionManager(dir)`. + +This fallback is intentional: a partial migration would be worse than staying on the legacy store for one run. + +### Alias promotion + +When canonical metadata is first created, `EnsureSessionMetadata` may promote history from a non-empty legacy alias into the canonical session. +That promotion only happens when the canonical session is still empty, so active canonical history is not overwritten. + +This is how the system preserves old histories such as: + +- legacy direct-message keys +- older Pico direct-session keys + +while moving the runtime onto opaque canonical keys. + +## Other SessionStore Implementations + +`pkg/agent/subturn.go` defines an `ephemeralSessionStore`. +It satisfies the same `SessionStore` interface, but keeps data in memory only and is destroyed when the sub-turn ends. + +That lets SubTurn reuse the same session-facing APIs without writing child-session history into the parent's durable storage. + +## Operational Consumers + +The session system is consumed by more than the agent loop: + +- `web/backend/api/session.go` reads JSONL metadata and legacy JSON sessions to expose session history in the launcher UI. +- `pkg/agent/steering.go` can recover scope metadata for active steering flows. +- tooling and tests can still refer to legacy aliases because alias resolution is handled below the agent loop. + +## Related Files + +- `pkg/session/session_store.go` +- `pkg/session/manager.go` +- `pkg/session/jsonl_backend.go` +- `pkg/session/scope.go` +- `pkg/session/key.go` +- `pkg/session/allocator.go` +- `pkg/memory/jsonl.go` +- `pkg/agent/instance.go` +- `pkg/agent/agent.go` +- `pkg/agent/agent_message.go` diff --git a/docs/architecture/session-system.zh.md b/docs/architecture/session-system.zh.md new file mode 100644 index 000000000..8de4e515c --- /dev/null +++ b/docs/architecture/session-system.zh.md @@ -0,0 +1,254 @@ +# Session 系统 + +> 返回 [README](../README.md) + +本文说明 PicoClaw 运行时的 Session 系统如何完成以下事情: + +- 把入站消息映射到稳定的会话作用域 +- 持久化消息历史与摘要 +- 在运行时使用不透明 canonical key 的同时,继续兼容旧的 `agent:...` session key + +本文覆盖 `pkg/session`、`pkg/memory` 和 `pkg/agent` 中的核心运行时链路。 +它不讨论 `web/backend/middleware` 中 launcher 登录 Cookie 或 dashboard 鉴权 session。 + +## 职责 + +Session 系统承担四件事: + +1. 决定哪些消息应该共享同一段上下文。 +2. 让这段上下文能跨 turn、跨进程重启持久存在。 +3. 向 agent loop 暴露一个足够小的 `SessionStore` 抽象。 +4. 在存储层和路由层迁移期间继续兼容旧 session key。 + +## 主要组件 + +| 层次 | 文件 | 作用 | +| --- | --- | --- | +| Session 抽象 | `pkg/session/session_store.go` | 定义 agent loop 依赖的 `SessionStore` 接口。 | +| 旧后端 | `pkg/session/manager.go` | 每个 session 一个 JSON 文件的旧实现,仍作为回退方案保留。 | +| Session 适配层 | `pkg/session/jsonl_backend.go` | 把 `pkg/memory.Store` 适配成 `SessionStore`,并支持 alias 与 scope metadata。 | +| 持久化存储 | `pkg/memory/jsonl.go` | Append-only JSONL 存储与 `.meta.json` 元数据侧文件。 | +| Scope / Key 构建 | `pkg/session/scope.go`、`pkg/session/key.go`、`pkg/session/allocator.go` | 从路由结果生成结构化 scope、不透明 canonical key 和 legacy alias。 | +| 运行时集成 | `pkg/agent/instance.go`、`pkg/agent/loop.go`、`pkg/agent/loop_message.go` | 初始化存储、分配 session scope,并在 turn 执行前落 metadata。 | + +## Session 数据模型 + +结构化的会话身份由 `session.SessionScope` 表示: + +| 字段 | 含义 | +| --- | --- | +| `Version` | Scope 模式版本,当前为 `ScopeVersionV1`。 | +| `AgentID` | 处理该 turn 的路由 agent。 | +| `Channel` | 归一化后的入站 channel 名称。 | +| `Account` | 归一化后的 bot / account 标识。 | +| `Dimensions` | 当前启用的隔离维度顺序,例如 `chat` 或 `sender`。 | +| `Values` | 每个维度对应的具体归一化值。 | + +Allocator 当前只识别四个维度: + +- `space` +- `chat` +- `topic` +- `sender` + +默认配置是: + +```json +{ + "session": { + "dimensions": ["chat"] + } +} +``` + +也就是默认按 chat 共享上下文;如果 dispatch rule 覆盖了维度,则以 rule 为准。 + +## Canonical Key 与 Legacy Alias + +运行时现在优先使用不透明 canonical key: + +```text +sk_v1_ +``` + +它由 `pkg/session/key.go` 中的 scope signature 计算得到。 +这样可以让存储 key 稳定,同时不再把持久化格式和某一种旧文本 key 绑定死。 + +为了兼容旧数据,allocator 还会生成 legacy alias,例如: + +```text +agent:main:direct:user123 +agent:main:slack:channel:c001 +agent:main:pico:direct:pico:session-123 +``` + +这些 alias 很重要,因为旧 session、部分测试以及某些工具仍然会引用这种格式。 +JSONL backend 会在读写前先把 alias 解析回 canonical key。 + +此外,如果调用方已经显式传入了受支持的 session key,agent loop 会保留它,不强行改成新分配的 routed key。 +这条逻辑在 `pkg/agent/loop_utils.go:resolveScopeKey` 中: + +- 不透明 canonical key +- legacy `agent:...` key + +都属于“显式 key”。 + +## 分配流程 + +普通入站消息的完整链路如下: + +```text +InboundMessage + -> RouteResolver.ResolveRoute(...) + -> session.AllocateRouteSession(...) + -> resolveScopeKey(...) + -> ensureSessionMetadata(...) + -> AgentLoop turn 执行 + -> SessionStore 读写 +``` + +具体来说: + +1. `pkg/agent/loop_message.go` 先用归一化后的 inbound context 解析 agent route。 +2. `session.AllocateRouteSession` 把 route 的 `SessionPolicy` 和 inbound context 组合成结构化 `SessionScope`。 +3. Allocator 会生成: + - `SessionKey`:当前路由会话的 canonical key + - `SessionAliases`:该路由会话的兼容 alias + - `MainSessionKey`:agent 级主会话 key + - `MainAliases`:主会话对应的 legacy alias +4. `runAgentLoop` 通过 `ensureSessionMetadata` 持久化 scope metadata 和 alias。 +5. 后续读写时,`JSONLBackend.ResolveSessionKey` 会先把 alias 映射回 canonical key。 + +`MainSessionKey` 和普通聊天会话是分开的。 +它主要服务于 agent 级、系统级的上下文场景,比如 `processSystemMessage`。 + +## Scope 构建规则 + +`pkg/session/allocator.go` 会从归一化后的 inbound context 生成 scope 值。 +关键规则如下: + +- `space` 变成 `:` +- `chat` 变成 `:` +- `topic` 变成 `topic:` +- `sender` 会先经过 `session.identity_links` 归一化再写入 + +其中有两个需要单独记住的特殊规则。 + +### Telegram forum 隔离 + +Telegram forum topic 必须默认保持隔离,即使配置只写了 `chat` 维度。 +为此,如果消息来自 Telegram forum 且策略里没有显式包含 `topic`,allocator 会把 `/` 拼到 `chat` 值后面。 + +例如: + +```text +group:-1001234567890/42 +group:-1001234567890/99 +``` + +这两者会得到不同的 session key。 + +### Identity links + +`session.identity_links` 可以把多个 sender 标识折叠为一个 canonical identity。 +dispatch 匹配和 session 分配都会使用这套映射,因此同一个人即使跨 channel 或 account 使用不同原始 sender ID,也可以继续落到同一段上下文里。 + +## 存储格式 + +默认运行时后端是 `pkg/memory.JSONLStore`,外面包了一层 `session.JSONLBackend`。 + +每个 session 使用两类文件: + +```text +{sanitized_key}.jsonl +{sanitized_key}.meta.json +``` + +各自保存: + +- `.jsonl`:一行一个 `providers.Message`,append-only +- `.meta.json`:摘要、时间戳、行数、逻辑截断偏移、scope、aliases + +`SessionMeta` 当前包含: + +- `Key` +- `Summary` +- `Skip` +- `Count` +- `CreatedAt` +- `UpdatedAt` +- `Scope` +- `Aliases` + +## 写入与崩溃语义 + +JSONL store 的设计核心是“追加优先、宁可暂时读到旧数据也不要丢数据”: + +- `AddMessage` / `AddFullMessage` 先追加一行 JSON,再 `fsync`,最后更新 metadata。 +- `TruncateHistory` 先做逻辑截断,本质上只是推进 `meta.Skip`。 +- `Compact` 才会真正重写 JSONL 文件,把被跳过的旧行物理移除。 +- `SetHistory` 和 `Compact` 都会先写 metadata 再改写 JSONL;如果中途崩溃,最多短时间暴露旧数据,不应丢数据。 +- 读取 JSONL 时如果碰到损坏行,会跳过该行,而不是让整个 session 读取失败。 + +`JSONLBackend.Save` 对应到底层的 `store.Compact(...)`。 +也就是说,`Save` 在新实现里不再是“把内存脏数据刷盘”,而是“在逻辑截断后回收无效行占用的磁盘空间”。 + +## 并发模型 + +`pkg/memory.JSONLStore` 使用固定 64 分片 mutex,按 session key 的 hash 做串行化。 +这样既能做到“按 session 串行”,又不会因为 session 数量增长而把 mutex map 做成无界结构。 + +旧的 `SessionManager` 则是一个内存 map 加 RW mutex。 + +这两个实现都满足同一个 `SessionStore` 接口,所以 agent loop 不需要写任何存储后端特化逻辑。 + +## 兼容与迁移 + +`pkg/agent/instance.go:initSessionStore` 会优先初始化 JSONL 后端。 + +启动过程如下: + +1. 创建 `memory.NewJSONLStore(dir)`。 +2. 执行 `memory.MigrateFromJSON(...)`,把旧 `.json` session 迁入新格式。 +3. 用 `session.NewJSONLBackend(store)` 包装。 +4. 如果 JSONL 初始化或迁移失败,则回退到 `session.NewSessionManager(dir)`。 + +这个回退是刻意设计的:做一半的迁移,比整轮继续使用旧后端更危险。 + +### Alias 提升 + +第一次为 canonical key 建 metadata 时,`EnsureSessionMetadata` 会尝试把某个非空 legacy alias 的历史提升到 canonical session。 +但这件事只会在 canonical session 仍然为空时发生,因此不会覆盖已经存在的 canonical 历史。 + +这保证了系统在迁移到 opaque key 的同时,仍能保留旧历史,例如: + +- 旧的 direct-message key +- 旧的 Pico direct-session key + +## 其他 SessionStore 实现 + +`pkg/agent/subturn.go` 里定义了 `ephemeralSessionStore`。 +它同样实现 `SessionStore`,但只存在于内存里,在 sub-turn 结束时销毁。 + +这样 SubTurn 就能复用相同的 session 接口,而不会把子任务历史写进父会话的持久存储。 + +## 运行时消费者 + +Session 系统不只被 agent loop 使用: + +- `web/backend/api/session.go` 会读取 JSONL metadata 和旧 JSON session,并把历史暴露给 launcher UI。 +- `pkg/agent/steering.go` 可以在 steering 场景下恢复 scope metadata。 +- 因为 alias 解析发生在 agent loop 之下,测试和工具仍然可以继续使用 legacy alias。 + +## 相关文件 + +- `pkg/session/session_store.go` +- `pkg/session/manager.go` +- `pkg/session/jsonl_backend.go` +- `pkg/session/scope.go` +- `pkg/session/key.go` +- `pkg/session/allocator.go` +- `pkg/memory/jsonl.go` +- `pkg/agent/instance.go` +- `pkg/agent/loop.go` +- `pkg/agent/loop_message.go` diff --git a/docs/steering.md b/docs/architecture/steering.md similarity index 100% rename from docs/steering.md rename to docs/architecture/steering.md diff --git a/docs/subturn.md b/docs/architecture/subturn.md similarity index 100% rename from docs/subturn.md rename to docs/architecture/subturn.md diff --git a/docs/channels/dingtalk/README.fr.md b/docs/channels/dingtalk/README.fr.md index eec59f6f2..ea0d45194 100644 --- a/docs/channels/dingtalk/README.fr.md +++ b/docs/channels/dingtalk/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # DingTalk diff --git a/docs/channels/dingtalk/README.ja.md b/docs/channels/dingtalk/README.ja.md index c465b6e2f..4796038f9 100644 --- a/docs/channels/dingtalk/README.ja.md +++ b/docs/channels/dingtalk/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # DingTalk diff --git a/docs/channels/dingtalk/README.pt-br.md b/docs/channels/dingtalk/README.pt-br.md index a96480342..c4a3da804 100644 --- a/docs/channels/dingtalk/README.pt-br.md +++ b/docs/channels/dingtalk/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # DingTalk diff --git a/docs/channels/dingtalk/README.vi.md b/docs/channels/dingtalk/README.vi.md index b760e28f7..83550a14e 100644 --- a/docs/channels/dingtalk/README.vi.md +++ b/docs/channels/dingtalk/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # DingTalk diff --git a/docs/channels/dingtalk/README.zh.md b/docs/channels/dingtalk/README.zh.md index 13c7080b3..7c672c383 100644 --- a/docs/channels/dingtalk/README.zh.md +++ b/docs/channels/dingtalk/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # 钉钉 diff --git a/docs/channels/discord/README.fr.md b/docs/channels/discord/README.fr.md index e8ac64668..951eb59be 100644 --- a/docs/channels/discord/README.fr.md +++ b/docs/channels/discord/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # Discord diff --git a/docs/channels/discord/README.ja.md b/docs/channels/discord/README.ja.md index e4d71f41b..212abc1a3 100644 --- a/docs/channels/discord/README.ja.md +++ b/docs/channels/discord/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # Discord diff --git a/docs/channels/discord/README.pt-br.md b/docs/channels/discord/README.pt-br.md index b782a944b..32d828b76 100644 --- a/docs/channels/discord/README.pt-br.md +++ b/docs/channels/discord/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # Discord diff --git a/docs/channels/discord/README.vi.md b/docs/channels/discord/README.vi.md index ea25dc003..e9ad6f5cc 100644 --- a/docs/channels/discord/README.vi.md +++ b/docs/channels/discord/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # Discord diff --git a/docs/channels/discord/README.zh.md b/docs/channels/discord/README.zh.md index 30fe3d28b..d6785ac3b 100644 --- a/docs/channels/discord/README.zh.md +++ b/docs/channels/discord/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # Discord diff --git a/docs/channels/feishu/README.fr.md b/docs/channels/feishu/README.fr.md index 8f9fdafcc..0d82c9655 100644 --- a/docs/channels/feishu/README.fr.md +++ b/docs/channels/feishu/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # Feishu diff --git a/docs/channels/feishu/README.ja.md b/docs/channels/feishu/README.ja.md index 955ecc233..c19e9fbec 100644 --- a/docs/channels/feishu/README.ja.md +++ b/docs/channels/feishu/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # 飛書(Feishu) diff --git a/docs/channels/feishu/README.pt-br.md b/docs/channels/feishu/README.pt-br.md index 11089cf2c..73ab981e0 100644 --- a/docs/channels/feishu/README.pt-br.md +++ b/docs/channels/feishu/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # Feishu diff --git a/docs/channels/feishu/README.vi.md b/docs/channels/feishu/README.vi.md index abe51db97..1db4c1146 100644 --- a/docs/channels/feishu/README.vi.md +++ b/docs/channels/feishu/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # Feishu diff --git a/docs/channels/feishu/README.zh.md b/docs/channels/feishu/README.zh.md index 882ee3d3f..afe117286 100644 --- a/docs/channels/feishu/README.zh.md +++ b/docs/channels/feishu/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # 飞书 diff --git a/docs/channels/line/README.fr.md b/docs/channels/line/README.fr.md index 522ff1d2f..c37e1c3a0 100644 --- a/docs/channels/line/README.fr.md +++ b/docs/channels/line/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # Line diff --git a/docs/channels/line/README.ja.md b/docs/channels/line/README.ja.md index a751d61e9..ed374c5e3 100644 --- a/docs/channels/line/README.ja.md +++ b/docs/channels/line/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # Line diff --git a/docs/channels/line/README.pt-br.md b/docs/channels/line/README.pt-br.md index 73a1ab837..5feea3153 100644 --- a/docs/channels/line/README.pt-br.md +++ b/docs/channels/line/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # Line diff --git a/docs/channels/line/README.vi.md b/docs/channels/line/README.vi.md index d799a934d..e834610e8 100644 --- a/docs/channels/line/README.vi.md +++ b/docs/channels/line/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # Line diff --git a/docs/channels/line/README.zh.md b/docs/channels/line/README.zh.md index cdc4380c3..5b353de1b 100644 --- a/docs/channels/line/README.zh.md +++ b/docs/channels/line/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # Line diff --git a/docs/channels/maixcam/README.fr.md b/docs/channels/maixcam/README.fr.md index c4871f10a..23f8c11cc 100644 --- a/docs/channels/maixcam/README.fr.md +++ b/docs/channels/maixcam/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # MaixCam diff --git a/docs/channels/maixcam/README.ja.md b/docs/channels/maixcam/README.ja.md index 6d06370d7..adec19445 100644 --- a/docs/channels/maixcam/README.ja.md +++ b/docs/channels/maixcam/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # MaixCam diff --git a/docs/channels/maixcam/README.pt-br.md b/docs/channels/maixcam/README.pt-br.md index 6243bb67b..dd606ff53 100644 --- a/docs/channels/maixcam/README.pt-br.md +++ b/docs/channels/maixcam/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # MaixCam diff --git a/docs/channels/maixcam/README.vi.md b/docs/channels/maixcam/README.vi.md index 7f0dc5812..09aba3540 100644 --- a/docs/channels/maixcam/README.vi.md +++ b/docs/channels/maixcam/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # MaixCam diff --git a/docs/channels/maixcam/README.zh.md b/docs/channels/maixcam/README.zh.md index f9e434976..2b4fdb87a 100644 --- a/docs/channels/maixcam/README.zh.md +++ b/docs/channels/maixcam/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # MaixCam diff --git a/docs/channels/matrix/README.fr.md b/docs/channels/matrix/README.fr.md index e4e1341c1..5ff329a28 100644 --- a/docs/channels/matrix/README.fr.md +++ b/docs/channels/matrix/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # Guide de configuration du canal Matrix diff --git a/docs/channels/matrix/README.ja.md b/docs/channels/matrix/README.ja.md index fb80cd484..adb14a1f9 100644 --- a/docs/channels/matrix/README.ja.md +++ b/docs/channels/matrix/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # Matrix チャンネル設定ガイド diff --git a/docs/channels/matrix/README.pt-br.md b/docs/channels/matrix/README.pt-br.md index 22deaf861..4f606f3ed 100644 --- a/docs/channels/matrix/README.pt-br.md +++ b/docs/channels/matrix/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # Guia de Configuração do Canal Matrix diff --git a/docs/channels/matrix/README.vi.md b/docs/channels/matrix/README.vi.md index d01b5ae3d..27f2ce746 100644 --- a/docs/channels/matrix/README.vi.md +++ b/docs/channels/matrix/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # Hướng dẫn Cấu hình Kênh Matrix diff --git a/docs/channels/matrix/README.zh.md b/docs/channels/matrix/README.zh.md index 08a746d7f..97634e2e6 100644 --- a/docs/channels/matrix/README.zh.md +++ b/docs/channels/matrix/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # Matrix 通道配置指南 diff --git a/docs/channels/onebot/README.fr.md b/docs/channels/onebot/README.fr.md index 209dd529d..8a2aec8d2 100644 --- a/docs/channels/onebot/README.fr.md +++ b/docs/channels/onebot/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # OneBot diff --git a/docs/channels/onebot/README.ja.md b/docs/channels/onebot/README.ja.md index d08908d69..d2616e582 100644 --- a/docs/channels/onebot/README.ja.md +++ b/docs/channels/onebot/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # OneBot diff --git a/docs/channels/onebot/README.pt-br.md b/docs/channels/onebot/README.pt-br.md index 7043cc867..2e037361f 100644 --- a/docs/channels/onebot/README.pt-br.md +++ b/docs/channels/onebot/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # OneBot diff --git a/docs/channels/onebot/README.vi.md b/docs/channels/onebot/README.vi.md index 5ee1f37fd..3dfcf8161 100644 --- a/docs/channels/onebot/README.vi.md +++ b/docs/channels/onebot/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # OneBot diff --git a/docs/channels/onebot/README.zh.md b/docs/channels/onebot/README.zh.md index 6f9f07c0d..4e5210b82 100644 --- a/docs/channels/onebot/README.zh.md +++ b/docs/channels/onebot/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # OneBot diff --git a/docs/channels/qq/README.fr.md b/docs/channels/qq/README.fr.md index e46bd7ebd..2202fa09d 100644 --- a/docs/channels/qq/README.fr.md +++ b/docs/channels/qq/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # QQ diff --git a/docs/channels/qq/README.ja.md b/docs/channels/qq/README.ja.md index 791428cc2..d9e86a061 100644 --- a/docs/channels/qq/README.ja.md +++ b/docs/channels/qq/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # QQ diff --git a/docs/channels/qq/README.pt-br.md b/docs/channels/qq/README.pt-br.md index d5eb0080b..b0a7e5568 100644 --- a/docs/channels/qq/README.pt-br.md +++ b/docs/channels/qq/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # QQ diff --git a/docs/channels/qq/README.vi.md b/docs/channels/qq/README.vi.md index d3973df41..cf940d05d 100644 --- a/docs/channels/qq/README.vi.md +++ b/docs/channels/qq/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # QQ diff --git a/docs/channels/qq/README.zh.md b/docs/channels/qq/README.zh.md index fa3b129e0..dc40f6225 100644 --- a/docs/channels/qq/README.zh.md +++ b/docs/channels/qq/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # QQ diff --git a/docs/channels/slack/README.fr.md b/docs/channels/slack/README.fr.md index 7d0d09f5d..be533052a 100644 --- a/docs/channels/slack/README.fr.md +++ b/docs/channels/slack/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # Slack diff --git a/docs/channels/slack/README.ja.md b/docs/channels/slack/README.ja.md index b2184310e..38cfc0134 100644 --- a/docs/channels/slack/README.ja.md +++ b/docs/channels/slack/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # Slack diff --git a/docs/channels/slack/README.pt-br.md b/docs/channels/slack/README.pt-br.md index 6d1b7c520..d2676d44a 100644 --- a/docs/channels/slack/README.pt-br.md +++ b/docs/channels/slack/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # Slack diff --git a/docs/channels/slack/README.vi.md b/docs/channels/slack/README.vi.md index dff55b9ad..3bbbe3132 100644 --- a/docs/channels/slack/README.vi.md +++ b/docs/channels/slack/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # Slack diff --git a/docs/channels/slack/README.zh.md b/docs/channels/slack/README.zh.md index e8dba16b8..8ecfe88bf 100644 --- a/docs/channels/slack/README.zh.md +++ b/docs/channels/slack/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # Slack diff --git a/docs/channels/telegram/README.fr.md b/docs/channels/telegram/README.fr.md index 944b0091f..51db2082f 100644 --- a/docs/channels/telegram/README.fr.md +++ b/docs/channels/telegram/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # Telegram diff --git a/docs/channels/telegram/README.ja.md b/docs/channels/telegram/README.ja.md index 58e4cbdfa..03303f255 100644 --- a/docs/channels/telegram/README.ja.md +++ b/docs/channels/telegram/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # Telegram diff --git a/docs/channels/telegram/README.md b/docs/channels/telegram/README.md index e4b298176..3b114ebef 100644 --- a/docs/channels/telegram/README.md +++ b/docs/channels/telegram/README.md @@ -2,7 +2,7 @@ # Telegram -The Telegram channel uses long polling via the Telegram Bot API for bot-based communication. It supports text messages, media attachments (photos, voice, audio, documents), voice transcription ([setup](../../providers.md#voice-transcription)), and built-in command handling. +The Telegram channel uses long polling via the Telegram Bot API for bot-based communication. It supports text messages, media attachments (photos, voice, audio, documents), voice transcription ([setup](../../guides/providers.md#voice-transcription)), and built-in command handling. ## Configuration diff --git a/docs/channels/telegram/README.pt-br.md b/docs/channels/telegram/README.pt-br.md index 2cd4c99c7..4af8d7a25 100644 --- a/docs/channels/telegram/README.pt-br.md +++ b/docs/channels/telegram/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # Telegram diff --git a/docs/channels/telegram/README.vi.md b/docs/channels/telegram/README.vi.md index efe6cf821..c6a276754 100644 --- a/docs/channels/telegram/README.vi.md +++ b/docs/channels/telegram/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # Telegram diff --git a/docs/channels/telegram/README.zh.md b/docs/channels/telegram/README.zh.md index fa5dc42d6..543e16e47 100644 --- a/docs/channels/telegram/README.zh.md +++ b/docs/channels/telegram/README.zh.md @@ -1,8 +1,8 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # Telegram -Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器人的通信。它支持文本消息、媒体附件(照片、语音、音频、文档)、语音转录(配置见[提供商与模型配置](../../zh/providers.md#语音转录)),以及内置命令处理器。 +Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器人的通信。它支持文本消息、媒体附件(照片、语音、音频、文档)、语音转录(配置见[提供商与模型配置](../../guides/providers.zh.md#语音转录)),以及内置命令处理器。 ## 配置 diff --git a/docs/channels/vk/README.md b/docs/channels/vk/README.md index c3f4b80e4..5e0c72bce 100644 --- a/docs/channels/vk/README.md +++ b/docs/channels/vk/README.md @@ -101,7 +101,7 @@ The VK channel supports both voice message reception and text-to-speech capabili - **ASR (Automatic Speech Recognition)**: Voice messages can be transcribed to text using configured voice models - **TTS (Text-to-Speech)**: Text responses can be converted to voice messages -To enable voice transcription, configure a voice model in your providers setup. See [Voice Transcription](../../providers.md#voice-transcription) for details. +To enable voice transcription, configure a voice model in your providers setup. See [Voice Transcription](../../guides/providers.md#voice-transcription) for details. ### Group Chat Support diff --git a/docs/channels/wecom/README.fr.md b/docs/channels/wecom/README.fr.md index b2cad168e..843943bdf 100644 --- a/docs/channels/wecom/README.fr.md +++ b/docs/channels/wecom/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # WeCom diff --git a/docs/channels/wecom/README.ja.md b/docs/channels/wecom/README.ja.md index 02224b6a9..459a922a6 100644 --- a/docs/channels/wecom/README.ja.md +++ b/docs/channels/wecom/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # WeCom diff --git a/docs/channels/wecom/README.pt-br.md b/docs/channels/wecom/README.pt-br.md index d20631910..07a5e23b9 100644 --- a/docs/channels/wecom/README.pt-br.md +++ b/docs/channels/wecom/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # WeCom diff --git a/docs/channels/wecom/README.vi.md b/docs/channels/wecom/README.vi.md index 08d571e24..4769fd6d6 100644 --- a/docs/channels/wecom/README.vi.md +++ b/docs/channels/wecom/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # WeCom diff --git a/docs/channels/wecom/README.zh.md b/docs/channels/wecom/README.zh.md index 736ef969a..8303a8f8a 100644 --- a/docs/channels/wecom/README.zh.md +++ b/docs/channels/wecom/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # 企业微信(WeCom) diff --git a/docs/design/provider-refactoring.md b/docs/design/provider-refactoring.md index 38f379c50..3c31f610f 100644 --- a/docs/design/provider-refactoring.md +++ b/docs/design/provider-refactoring.md @@ -154,7 +154,7 @@ Identify protocol via prefix in `model` field: | `openai/` | OpenAI-compatible | Most common, includes DeepSeek, Qwen, Groq, etc. | | `anthropic/` | Anthropic | Claude series specific | | `antigravity/` | Antigravity | Google Cloud Code Assist | -| `gemini/` | Gemini | Google Gemini native API (if needed) | +| `gemini/` | Gemini | Google Gemini native API | --- diff --git a/docs/fr/ANTIGRAVITY_USAGE.md b/docs/guides/ANTIGRAVITY_USAGE.fr.md similarity index 98% rename from docs/fr/ANTIGRAVITY_USAGE.md rename to docs/guides/ANTIGRAVITY_USAGE.fr.md index d6d0a2bd4..5672952d3 100644 --- a/docs/fr/ANTIGRAVITY_USAGE.md +++ b/docs/guides/ANTIGRAVITY_USAGE.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) # Utiliser le fournisseur Antigravity dans PicoClaw diff --git a/docs/ja/ANTIGRAVITY_USAGE.md b/docs/guides/ANTIGRAVITY_USAGE.ja.md similarity index 98% rename from docs/ja/ANTIGRAVITY_USAGE.md rename to docs/guides/ANTIGRAVITY_USAGE.ja.md index c044c1970..bd221ed1c 100644 --- a/docs/ja/ANTIGRAVITY_USAGE.md +++ b/docs/guides/ANTIGRAVITY_USAGE.ja.md @@ -1,4 +1,4 @@ -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る # PicoClaw で Antigravity プロバイダーを使用する diff --git a/docs/ANTIGRAVITY_USAGE.md b/docs/guides/ANTIGRAVITY_USAGE.md similarity index 100% rename from docs/ANTIGRAVITY_USAGE.md rename to docs/guides/ANTIGRAVITY_USAGE.md diff --git a/docs/pt-br/ANTIGRAVITY_USAGE.md b/docs/guides/ANTIGRAVITY_USAGE.pt-br.md similarity index 98% rename from docs/pt-br/ANTIGRAVITY_USAGE.md rename to docs/guides/ANTIGRAVITY_USAGE.pt-br.md index d4b681ad0..e5108916a 100644 --- a/docs/pt-br/ANTIGRAVITY_USAGE.md +++ b/docs/guides/ANTIGRAVITY_USAGE.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) # Usando o provedor Antigravity no PicoClaw diff --git a/docs/vi/ANTIGRAVITY_USAGE.md b/docs/guides/ANTIGRAVITY_USAGE.vi.md similarity index 98% rename from docs/vi/ANTIGRAVITY_USAGE.md rename to docs/guides/ANTIGRAVITY_USAGE.vi.md index 4a696f770..54b4a6add 100644 --- a/docs/vi/ANTIGRAVITY_USAGE.md +++ b/docs/guides/ANTIGRAVITY_USAGE.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) # Sử dụng nhà cung cấp Antigravity trong PicoClaw diff --git a/docs/zh/ANTIGRAVITY_USAGE.md b/docs/guides/ANTIGRAVITY_USAGE.zh.md similarity index 98% rename from docs/zh/ANTIGRAVITY_USAGE.md rename to docs/guides/ANTIGRAVITY_USAGE.zh.md index 2218618a9..b4dde6ea3 100644 --- a/docs/zh/ANTIGRAVITY_USAGE.md +++ b/docs/guides/ANTIGRAVITY_USAGE.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) # 在 PicoClaw 中使用 Antigravity 提供商 diff --git a/docs/guides/README.md b/docs/guides/README.md new file mode 100644 index 000000000..1a50a5062 --- /dev/null +++ b/docs/guides/README.md @@ -0,0 +1,15 @@ +# Guides + +Task-oriented guides for setup, configuration, and common PicoClaw workflows. + +- [Docker & Quick Start Guide](docker.md): install and run PicoClaw with Docker or the launcher. +- [Configuration Guide](configuration.md): environment variables, workspace layout, routing, and sandbox settings. +- [Session Guide](session-guide.md): how session scope affects memory sharing, summaries, and isolation. +- [Routing Guide](routing-guide.md): agent dispatch, session overrides, and light-model routing. +- [Chat Apps Configuration](chat-apps.md): supported chat platforms and channel-specific setup paths. +- [Providers & Model Configuration](providers.md): `model_list`, providers, and model routing. +- [Spawn & Async Tasks](spawn-tasks.md): background work, long-running tasks, and sub-agent orchestration. +- [PicoClaw Hardware Compatibility List](hardware-compatibility.md): tested boards and platform notes. +- [Using Antigravity Provider in PicoClaw](ANTIGRAVITY_USAGE.md): Google Cloud Code Assist setup and usage. + +Translations usually live beside the English source when available. diff --git a/docs/fr/chat-apps.md b/docs/guides/chat-apps.fr.md similarity index 98% rename from docs/fr/chat-apps.md rename to docs/guides/chat-apps.fr.md index 35330ed92..d9112c595 100644 --- a/docs/fr/chat-apps.md +++ b/docs/guides/chat-apps.fr.md @@ -1,6 +1,6 @@ # 💬 Configuration des Applications de Chat -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) ## 💬 Applications de Chat @@ -19,7 +19,7 @@ Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, Din | **QQ** | ⭐⭐ Moyen | API bot officielle, communauté chinoise | [Documentation](../channels/qq/README.fr.md) | | **DingTalk** | ⭐⭐ Moyen | Mode Stream (pas d'IP publique requise), entreprise | [Documentation](../channels/dingtalk/README.fr.md) | | **LINE** | ⭐⭐⭐ Avancé | HTTPS Webhook requis | [Documentation](../channels/line/README.fr.md) | -| **WeCom (企业微信)** | ⭐⭐⭐ Avancé | Bot groupe (Webhook), app personnalisée (API), AI Bot | [Bot](../channels/wecom/wecom_bot/README.fr.md) / [App](../channels/wecom/wecom_app/README.fr.md) / [AI Bot](../channels/wecom/wecom_aibot/README.fr.md) | +| **WeCom (企业微信)** | ⭐⭐⭐ Avancé | Bot groupe (Webhook), app personnalisée (API), AI Bot | [Guide](../channels/wecom/README.fr.md) | | **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) | @@ -391,7 +391,7 @@ PicoClaw prend en charge trois types d'intégration WeCom : **Option 2 : WeCom App (Application personnalisée)** - Plus de fonctionnalités, messagerie proactive, chat privé uniquement **Option 3 : WeCom AI Bot (Bot IA)** - Bot IA officiel, réponses en streaming, prend en charge les discussions de groupe et privées -Voir le [Guide de Configuration WeCom AI Bot](../channels/wecom/wecom_aibot/README.fr.md) pour les instructions détaillées. +Voir le [Guide de Configuration WeCom](../channels/wecom/README.fr.md) pour les instructions détaillées. **Configuration rapide - WeCom Bot :** diff --git a/docs/ja/chat-apps.md b/docs/guides/chat-apps.ja.md similarity index 98% rename from docs/ja/chat-apps.md rename to docs/guides/chat-apps.ja.md index b143a5fc6..49c41a66e 100644 --- a/docs/ja/chat-apps.md +++ b/docs/guides/chat-apps.ja.md @@ -1,6 +1,6 @@ # 💬 チャットアプリ設定 -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る ## 💬 チャットアプリ連携 @@ -21,7 +21,7 @@ PicoClaw は複数のチャットプラットフォームをサポートして | **QQ** | ⭐⭐ 中程度 | 公式ボット API、中国コミュニティ向け | [ドキュメント](../channels/qq/README.ja.md) | | **DingTalk** | ⭐⭐ 中程度 | Stream モード(公開 IP 不要)、企業向け | [ドキュメント](../channels/dingtalk/README.ja.md) | | **LINE** | ⭐⭐⭐ やや難 | HTTPS Webhook が必要 | [ドキュメント](../channels/line/README.ja.md) | -| **WeCom (企業微信)** | ⭐⭐⭐ やや難 | グループ Bot (Webhook)、カスタムアプリ (API)、AI Bot 対応 | [Bot](../channels/wecom/wecom_bot/README.ja.md) / [App](../channels/wecom/wecom_app/README.ja.md) / [AI Bot](../channels/wecom/wecom_aibot/README.ja.md) | +| **WeCom (企業微信)** | ⭐⭐⭐ やや難 | グループ Bot (Webhook)、カスタムアプリ (API)、AI Bot 対応 | [ガイド](../channels/wecom/README.ja.md) | | **Feishu (飛書)** | ⭐⭐⭐ やや難 | エンタープライズコラボレーション、機能豊富 | [ドキュメント](../channels/feishu/README.ja.md) | | **IRC** | ⭐⭐ 中程度 | サーバー + TLS 設定 | [ドキュメント](#irc) | | **OneBot** | ⭐⭐ 中程度 | NapCat/Go-CQHTTP 互換、コミュニティエコシステム充実 | [ドキュメント](../channels/onebot/README.ja.md) | @@ -502,7 +502,7 @@ PicoClaw は 3 種類の WeCom 統合をサポートしています: **方式 2: カスタムアプリ (App)** — より多機能、プロアクティブメッセージング、プライベートチャットのみ **方式 3: AI Bot** — 公式 AI Bot、ストリーミング返信、グループ・プライベートチャット対応 -詳細なセットアップ手順は [WeCom AI Bot 設定ガイド](../channels/wecom/wecom_aibot/README.ja.md) を参照してください。 +詳細なセットアップ手順は [WeCom 設定ガイド](../channels/wecom/README.ja.md) を参照してください。 **クイックセットアップ — グループ Bot:** diff --git a/docs/chat-apps.md b/docs/guides/chat-apps.md similarity index 90% rename from docs/chat-apps.md rename to docs/guides/chat-apps.md index 698633642..140a659d1 100644 --- a/docs/chat-apps.md +++ b/docs/guides/chat-apps.md @@ -10,20 +10,20 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, | Channel | Difficulty | Description | Documentation | | -------------------- | ------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -| **Telegram** | ⭐ Easy | Recommended, voice-to-text, long polling (no public IP needed) | [Docs](channels/telegram/README.md) | -| **Discord** | ⭐ Easy | Socket Mode, group/DM support, rich bot ecosystem | [Docs](channels/discord/README.md) | +| **Telegram** | ⭐ Easy | Recommended, voice-to-text, long polling (no public IP needed) | [Docs](../channels/telegram/README.md) | +| **Discord** | ⭐ Easy | Socket Mode, group/DM support, rich bot ecosystem | [Docs](../channels/discord/README.md) | | **WhatsApp** | ⭐ Easy | Native (QR scan) or Bridge URL | [Docs](#whatsapp) | | **Weixin** | ⭐ Easy | Native QR scan (Tencent iLink API) | [Docs](#weixin) | -| **Slack** | ⭐ Easy | **Socket Mode** (no public IP needed), enterprise | [Docs](channels/slack/README.md) | -| **Matrix** | ⭐⭐ Medium | Federated protocol, self-hosting supported | [Docs](channels/matrix/README.md) | -| **QQ** | ⭐⭐ Medium | Official bot API, Chinese community | [Docs](channels/qq/README.md) | -| **DingTalk** | ⭐⭐ Medium | Stream mode (no public IP needed), enterprise | [Docs](channels/dingtalk/README.md) | -| **LINE** | ⭐⭐⭐ Advanced | HTTPS Webhook required | [Docs](channels/line/README.md) | -| **WeCom (企业微信)** | ⭐⭐⭐ Advanced | Official AI Bot over WebSocket, streaming + media | [Docs](channels/wecom/README.md) | -| **Feishu (飞书)** | ⭐⭐⭐ Advanced | Enterprise collaboration, feature-rich | [Docs](channels/feishu/README.md) | +| **Slack** | ⭐ Easy | **Socket Mode** (no public IP needed), enterprise | [Docs](../channels/slack/README.md) | +| **Matrix** | ⭐⭐ Medium | Federated protocol, self-hosting supported | [Docs](../channels/matrix/README.md) | +| **QQ** | ⭐⭐ Medium | Official bot API, Chinese community | [Docs](../channels/qq/README.md) | +| **DingTalk** | ⭐⭐ Medium | Stream mode (no public IP needed), enterprise | [Docs](../channels/dingtalk/README.md) | +| **LINE** | ⭐⭐⭐ Advanced | HTTPS Webhook required | [Docs](../channels/line/README.md) | +| **WeCom (企业微信)** | ⭐⭐⭐ Advanced | Official AI Bot over WebSocket, streaming + media | [Docs](../channels/wecom/README.md) | +| **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) | -| **MaixCam** | ⭐ Easy | Hardware integration channel for Sipeed AI cameras | [Docs](channels/maixcam/README.md) | +| **OneBot** | ⭐⭐ Medium | NapCat/Go-CQHTTP compatible, community ecosystem | [Docs](../channels/onebot/README.md) | +| **MaixCam** | ⭐ Easy | Hardware integration channel for Sipeed AI cameras | [Docs](../channels/maixcam/README.md) | | **Pico** | ⭐ Easy | Native PicoClaw protocol channel | | @@ -331,7 +331,7 @@ picoclaw gateway picoclaw gateway ``` -For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](channels/matrix/README.md). +For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](../channels/matrix/README.md). @@ -392,7 +392,7 @@ picoclaw gateway PicoClaw now exposes WeCom as a single AI Bot channel over WebSocket. No public webhook callback URL is required. -See [WeCom Configuration Guide](channels/wecom/README.md) for the full configuration reference and migration notes. +See [WeCom Configuration Guide](../channels/wecom/README.md) for the full configuration reference and migration notes. **Quick Setup - Recommended** @@ -472,7 +472,7 @@ picoclaw gateway Open Feishu, search for your bot name, and start chatting. You can also add the bot to a group — use `group_trigger.mention_only: true` to only respond when @mentioned. -For full options, see [Feishu Channel Configuration Guide](channels/feishu/README.md). +For full options, see [Feishu Channel Configuration Guide](../channels/feishu/README.md). diff --git a/docs/my/chat-apps.md b/docs/guides/chat-apps.ms.md similarity index 98% rename from docs/my/chat-apps.md rename to docs/guides/chat-apps.ms.md index 531c19cbb..6bfa7565e 100644 --- a/docs/my/chat-apps.md +++ b/docs/guides/chat-apps.ms.md @@ -1,6 +1,6 @@ # 💬 Konfigurasi Aplikasi Sembang -> Kembali ke [README](../../README.my.md) +> Kembali ke [README](../project/README.ms.md) ## 💬 Aplikasi Sembang @@ -279,7 +279,7 @@ picoclaw gateway picoclaw gateway ``` -Untuk pilihan penuh (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), lihat [Panduan Konfigurasi Saluran Matrix](docs/channels/matrix/README.md). +Untuk pilihan penuh (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), lihat [Panduan Konfigurasi Saluran Matrix](../channels/matrix/README.md). @@ -341,7 +341,7 @@ PicoClaw menyokong tiga jenis integrasi WeCom: **Pilihan 2: WeCom App (Custom App)** - Lebih banyak ciri, pemesejan proaktif, sembang peribadi sahaja **Pilihan 3: WeCom AI Bot (AI Bot)** - AI Bot rasmi, balasan streaming, menyokong sembang kumpulan & peribadi -Lihat [Panduan Konfigurasi WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) untuk arahan penyediaan terperinci. +Lihat [Panduan Konfigurasi WeCom](../channels/wecom/README.zh.md) untuk arahan penyediaan terperinci. **Quick Setup - WeCom Bot:** diff --git a/docs/pt-br/chat-apps.md b/docs/guides/chat-apps.pt-br.md similarity index 98% rename from docs/pt-br/chat-apps.md rename to docs/guides/chat-apps.pt-br.md index 5d7e5990b..6d4fbdc23 100644 --- a/docs/pt-br/chat-apps.md +++ b/docs/guides/chat-apps.pt-br.md @@ -1,6 +1,6 @@ # 💬 Configuração de Aplicativos de Chat -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) ## 💬 Aplicativos de Chat @@ -19,7 +19,7 @@ Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, D | **QQ** | ⭐⭐ Médio | API bot oficial, comunidade chinesa | [Documentação](../channels/qq/README.pt-br.md) | | **DingTalk** | ⭐⭐ Médio | Modo Stream (sem IP público), empresarial | [Documentação](../channels/dingtalk/README.pt-br.md) | | **LINE** | ⭐⭐⭐ Avançado | HTTPS Webhook obrigatório | [Documentação](../channels/line/README.pt-br.md) | -| **WeCom (企业微信)** | ⭐⭐⭐ Avançado | Bot de grupo (Webhook), app personalizado (API), AI Bot | [Bot](../channels/wecom/wecom_bot/README.pt-br.md) / [App](../channels/wecom/wecom_app/README.pt-br.md) / [AI Bot](../channels/wecom/wecom_aibot/README.pt-br.md) | +| **WeCom (企业微信)** | ⭐⭐⭐ Avançado | Bot de grupo (Webhook), app personalizado (API), AI Bot | [Guia](../channels/wecom/README.pt-br.md) | | **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) | @@ -416,7 +416,7 @@ O PicoClaw suporta três tipos de integração WeCom: **Opção 2: WeCom App (App Personalizado)** - Mais recursos, mensagens proativas, apenas chat privado **Opção 3: WeCom AI Bot (AI Bot)** - AI Bot oficial, respostas em streaming, suporta chat de grupo e privado -Veja o [Guia de Configuração do WeCom AI Bot](../channels/wecom/wecom_aibot/README.pt-br.md) para instruções detalhadas de configuração. +Veja o [Guia de Configuração do WeCom](../channels/wecom/README.pt-br.md) para instruções detalhadas de configuração. **Configuração Rápida - WeCom Bot:** diff --git a/docs/vi/chat-apps.md b/docs/guides/chat-apps.vi.md similarity index 98% rename from docs/vi/chat-apps.md rename to docs/guides/chat-apps.vi.md index 5dc4f8f01..8d0b4ee32 100644 --- a/docs/vi/chat-apps.md +++ b/docs/guides/chat-apps.vi.md @@ -1,6 +1,6 @@ # 💬 Cấu Hình Ứng Dụng Chat -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) ## 💬 Ứng Dụng Chat @@ -19,7 +19,7 @@ Trò chuyện với picoclaw của bạn qua Telegram, Discord, WhatsApp, Matrix | **QQ** | ⭐⭐ Trung bình | API bot chính thức, cộng đồng Trung Quốc | [Tài liệu](../channels/qq/README.vi.md) | | **DingTalk** | ⭐⭐ Trung bình | Chế độ Stream (không cần IP công khai), doanh nghiệp | [Tài liệu](../channels/dingtalk/README.vi.md) | | **LINE** | ⭐⭐⭐ Nâng cao | Yêu cầu HTTPS Webhook | [Tài liệu](../channels/line/README.vi.md) | -| **WeCom (企业微信)** | ⭐⭐⭐ Nâng cao | Bot nhóm (Webhook), ứng dụng tùy chỉnh (API), AI Bot | [Bot](../channels/wecom/wecom_bot/README.vi.md) / [App](../channels/wecom/wecom_app/README.vi.md) / [AI Bot](../channels/wecom/wecom_aibot/README.vi.md) | +| **WeCom (企业微信)** | ⭐⭐⭐ Nâng cao | Bot nhóm (Webhook), ứng dụng tùy chỉnh (API), AI Bot | [Hướng dẫn](../channels/wecom/README.vi.md) | | **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) | @@ -416,7 +416,7 @@ PicoClaw hỗ trợ ba loại tích hợp WeCom: **Tùy chọn 2: WeCom App (App Tùy chỉnh)** - Nhiều tính năng hơn, nhắn tin chủ động, chỉ chat riêng **Tùy chọn 3: WeCom AI Bot (AI Bot)** - AI Bot chính thức, phản hồi streaming, hỗ trợ chat nhóm & riêng -Xem [Hướng Dẫn Cấu Hình WeCom AI Bot](../channels/wecom/wecom_aibot/README.vi.md) để biết hướng dẫn thiết lập chi tiết. +Xem [Hướng Dẫn Cấu Hình WeCom](../channels/wecom/README.vi.md) để biết hướng dẫn thiết lập chi tiết. **Thiết Lập Nhanh - WeCom Bot:** diff --git a/docs/zh/chat-apps.md b/docs/guides/chat-apps.zh.md similarity index 99% rename from docs/zh/chat-apps.md rename to docs/guides/chat-apps.zh.md index bb71e7c1c..b5891dc69 100644 --- a/docs/zh/chat-apps.md +++ b/docs/guides/chat-apps.zh.md @@ -1,6 +1,6 @@ # 💬 聊天应用配置 -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) ## 💬 聊天应用集成 (Chat Apps) diff --git a/docs/fr/configuration.md b/docs/guides/configuration.fr.md similarity index 95% rename from docs/fr/configuration.md rename to docs/guides/configuration.fr.md index b26b8c4f7..786a0c28f 100644 --- a/docs/fr/configuration.md +++ b/docs/guides/configuration.fr.md @@ -1,6 +1,6 @@ # ⚙️ Guide de Configuration -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) ## ⚙️ Configuration @@ -339,7 +339,7 @@ Répond HEARTBEAT_OK Utilisateur reçoit le résultat | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obtenir](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obtenir](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obtenir](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Obtenir](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Obtenir](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obtenir](https://console.groq.com) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obtenir](https://dashscope.console.aliyun.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (pas de clé) | @@ -369,9 +369,12 @@ L'ancienne configuration `providers` est **dépréciée** et a été supprimée PicoClaw route les providers par famille de protocole : - **Compatible OpenAI** : OpenRouter, Groq, Zhipu, endpoints vLLM et la plupart des autres. +- **Gemini natif** : Google Gemini via les endpoints natifs `models/*:generateContent` et `models/*:streamGenerateContent`. - **Anthropic** : Comportement natif de l'API Claude. - **Codex/OAuth** : Route d'authentification OAuth/token OpenAI. +Cela maintient le runtime léger tout en faisant des nouveaux backends compatibles OpenAI principalement une opération de configuration (`api_base` + `api_keys`). + ### Tâches Planifiées / Rappels PicoClaw supporte les tâches planifiées via l'outil `cron`. L'agent peut définir, lister et annuler des rappels ou tâches récurrentes. @@ -393,7 +396,7 @@ Les tâches planifiées persistent après redémarrage dans `~/.picoclaw/workspa | Sujet | Description | | ----- | ----------- | -| [Système de Hooks](../hooks/README.md) | Hooks événementiels : observateurs, intercepteurs, hooks d'approbation | -| [Steering](../steering.md) | Injecter des messages dans une boucle agent en cours d'exécution | -| [SubTurn](../subturn.md) | Coordination de subagents, contrôle de concurrence, cycle de vie | -| [Gestion du Contexte](../agent-refactor/context.md) | Détection des limites de contexte, compression | +| [Système de Hooks](../architecture/hooks/README.md) | Hooks événementiels : observateurs, intercepteurs, hooks d'approbation | +| [Steering](../architecture/steering.md) | Injecter des messages dans une boucle agent en cours d'exécution | +| [SubTurn](../architecture/subturn.md) | Coordination de subagents, contrôle de concurrence, cycle de vie | +| [Gestion du Contexte](../architecture/agent-refactor/context.md) | Détection des limites de contexte, compression | diff --git a/docs/ja/configuration.md b/docs/guides/configuration.ja.md similarity index 95% rename from docs/ja/configuration.md rename to docs/guides/configuration.ja.md index bf2392585..0234edbd7 100644 --- a/docs/ja/configuration.md +++ b/docs/guides/configuration.ja.md @@ -1,6 +1,6 @@ # ⚙️ 設定ガイド -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る ## ⚙️ 設定詳細 @@ -340,7 +340,7 @@ HEARTBEAT_OK を返信 ユーザーが直接結果を受信 | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [取得](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [取得](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [取得](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [取得](https://aistudio.google.com/api-keys) | +| **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) | | **通義千問 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [取得](https://dashscope.console.aliyun.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | ローカル(キー不要) | @@ -370,9 +370,12 @@ HEARTBEAT_OK を返信 ユーザーが直接結果を受信 PicoClaw はプロトコルファミリーで Provider をルーティングします: - **OpenAI 互換**:OpenRouter、Groq、Zhipu、vLLM スタイルのエンドポイントなど。 +- **Gemini ネイティブ**:Google Gemini のネイティブ `models/*:generateContent` / `models/*:streamGenerateContent` エンドポイント。 - **Anthropic**:Claude ネイティブ API の動作。 - **Codex/OAuth**:OpenAI OAuth/トークン認証ルート。 +これによりランタイムを軽量に保ちつつ、新しい OpenAI 互換バックエンドの追加をほぼ設定操作(`api_base` + `api_keys`)のみで実現します。 + ### スケジュールタスク / リマインダー PicoClaw は `cron` ツールを通じて cron スタイルのスケジュールタスクをサポートします。 @@ -394,7 +397,7 @@ PicoClaw は `cron` ツールを通じて cron スタイルのスケジュール | トピック | 説明 | | -------- | ---- | -| [Hook システム](../hooks/README.md) | イベント駆動 Hook:オブザーバー、インターセプター、承認 Hook | -| [Steering](../steering.md) | 実行中の Agent ループにメッセージを注入 | -| [SubTurn](../subturn.md) | サブ Agent の調整、並行制御、ライフサイクル | -| [コンテキスト管理](../agent-refactor/context.md) | コンテキスト境界検出、圧縮戦略 | +| [Hook システム](../architecture/hooks/README.md) | イベント駆動 Hook:オブザーバー、インターセプター、承認 Hook | +| [Steering](../architecture/steering.md) | 実行中の Agent ループにメッセージを注入 | +| [SubTurn](../architecture/subturn.md) | サブ Agent の調整、並行制御、ライフサイクル | +| [コンテキスト管理](../architecture/agent-refactor/context.md) | コンテキスト境界検出、圧縮戦略 | diff --git a/docs/configuration.md b/docs/guides/configuration.md similarity index 95% rename from docs/configuration.md rename to docs/guides/configuration.md index 88999b8a3..ef3b14b24 100644 --- a/docs/configuration.md +++ b/docs/guides/configuration.md @@ -6,7 +6,7 @@ Config file: `~/.picoclaw/config.json` -> **Security Configuration:** For storing API keys, tokens, and other sensitive data, see the [Security Configuration Guide](security_configuration.md). +> **Security Configuration:** For storing API keys, tokens, and other sensitive data, see the [Security Configuration Guide](../security/security_configuration.md). ### Environment Variables @@ -122,6 +122,15 @@ dammi le ultime news - Unknown slash command (for example `/foo`) passes through to normal LLM processing. - Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing. +### Session Isolation + +Session scope controls how much memory is shared between chats, users, threads, and spaces. + +- Use `session.dimensions` for the global default. +- Use `session_dimensions` on a dispatch rule for one routed exception. + +For step-by-step recipes and isolation patterns, see the [Session Guide](session-guide.md). + ### Routing Routing is configured through `agents.dispatch.rules`. @@ -195,6 +204,8 @@ In the example above, the VIP rule must appear before the broader group rule. Because routing is strictly ordered, more specific rules should be placed earlier and broader fallback rules later. +For more complete routing and model-tier examples, see the [Routing Guide](routing-guide.md). + ### 🔒 Security Sandbox PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace. @@ -555,7 +566,7 @@ chmod 600 ~/.picoclaw/.security.yml - If a field exists in both files, `.security.yml` value takes precedence - You can mix direct values in config.json with security values -For complete documentation, see [`security_configuration.md`](security_configuration.md). +For complete documentation, see [`../security/security_configuration.md`](../security/security_configuration.md). #### All Supported Vendors @@ -565,7 +576,7 @@ For complete documentation, see [`security_configuration.md`](security_configura | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | +| **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) | @@ -809,6 +820,7 @@ The old `providers` configuration is **deprecated** and has been removed in V2. PicoClaw routes providers by protocol family: - **OpenAI-compatible**: OpenRouter, Groq, Zhipu, vLLM-style endpoints, and most others. +- **Gemini native**: Google Gemini via the native `models/*:generateContent` and `models/*:streamGenerateContent` endpoints. - **Anthropic**: Claude-native API behavior. - **Codex/OAuth**: OpenAI OAuth/token authentication route. @@ -840,7 +852,7 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m > **Note**: The `providers` format is deprecated. Use the new `model_list` format with `.security.yml` for better security. > -> **`max_parallel_turns`**: Controls concurrent processing of messages from different sessions. `1` (default) = sequential; `>1` = parallel. Messages from the same session are always serialized. See [Steering docs](../steering.md) for details. +> **`max_parallel_turns`**: Controls concurrent processing of messages from different sessions. `1` (default) = sequential; `>1` = parallel. Messages from the same session are always serialized. See [Steering docs](../architecture/steering.md) for details. @@ -906,9 +918,9 @@ Scheduled tasks persist across restarts and are stored in `~/.picoclaw/workspace | Topic | Description | | ----- | ----------- | -| [Security Configuration](security_configuration.md) | Store API keys and secrets in separate `.security.yml` file | -| [Sensitive Data Filtering](sensitive_data_filtering.md) | Filter API keys and tokens from tool results before sending to LLM | -| [Hook System](hooks/README.md) | Event-driven hooks: observers, interceptors, approval hooks | -| [Steering](steering.md) | Inject messages into a running agent loop between tool calls | -| [SubTurn](subturn.md) | Subagent coordination, concurrency control, lifecycle | -| [Context Management](agent-refactor/context.md) | Context boundary detection, proactive budget check, compression | +| [Security Configuration](../security/security_configuration.md) | Store API keys and secrets in separate `.security.yml` file | +| [Sensitive Data Filtering](../security/sensitive_data_filtering.md) | Filter API keys and tokens from tool results before sending to LLM | +| [Hook System](../architecture/hooks/README.md) | Event-driven hooks: observers, interceptors, approval hooks | +| [Steering](../architecture/steering.md) | Inject messages into a running agent loop between tool calls | +| [SubTurn](../architecture/subturn.md) | Subagent coordination, concurrency control, lifecycle | +| [Context Management](../architecture/agent-refactor/context.md) | Context boundary detection, proactive budget check, compression | diff --git a/docs/my/configuration.md b/docs/guides/configuration.ms.md similarity index 99% rename from docs/my/configuration.md rename to docs/guides/configuration.ms.md index 75bdd71a6..bcd17afa8 100644 --- a/docs/my/configuration.md +++ b/docs/guides/configuration.ms.md @@ -1,6 +1,6 @@ # ⚙️ Panduan Konfigurasi -> Kembali ke [README](../../README.my.md) +> Kembali ke [README](../project/README.ms.md) ## ⚙️ Konfigurasi diff --git a/docs/pt-br/configuration.md b/docs/guides/configuration.pt-br.md similarity index 95% rename from docs/pt-br/configuration.md rename to docs/guides/configuration.pt-br.md index 7bf5f4026..e5d904e29 100644 --- a/docs/pt-br/configuration.md +++ b/docs/guides/configuration.pt-br.md @@ -1,6 +1,6 @@ # ⚙️ Guia de Configuração -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) ## ⚙️ Configuração @@ -340,7 +340,7 @@ Responde HEARTBEAT_OK Usuário recebe resultado diretamente | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obter](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obter](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obter](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Obter](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Obter](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obter](https://console.groq.com) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obter](https://dashscope.console.aliyun.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (sem chave) | @@ -370,9 +370,12 @@ A configuração antiga `providers` está **depreciada** e foi removida no V2. C PicoClaw roteia providers por família de protocolo: - **Compatível com OpenAI**: OpenRouter, Groq, Zhipu, endpoints vLLM e a maioria dos outros. +- **Gemini nativo**: Google Gemini via endpoints nativos `models/*:generateContent` e `models/*:streamGenerateContent`. - **Anthropic**: Comportamento nativo da API Claude. - **Codex/OAuth**: Rota de autenticação OAuth/token OpenAI. +Isso mantém o runtime leve enquanto torna novos backends compatíveis com OpenAI basicamente uma operação de configuração (`api_base` + `api_keys`). + ### Tarefas Agendadas / Lembretes PicoClaw suporta tarefas agendadas via ferramenta `cron`. @@ -394,7 +397,7 @@ As tarefas agendadas persistem após reinicializações em `~/.picoclaw/workspac | Tópico | Descrição | | ------ | --------- | -| [Sistema de Hooks](../hooks/README.md) | Hooks orientados a eventos: observadores, interceptores, hooks de aprovação | -| [Steering](../steering.md) | Injetar mensagens em um loop de agente em execução | -| [SubTurn](../subturn.md) | Coordenação de subagentes, controle de concorrência, ciclo de vida | -| [Gerenciamento de Contexto](../agent-refactor/context.md) | Detecção de limites de contexto, compressão | +| [Sistema de Hooks](../architecture/hooks/README.md) | Hooks orientados a eventos: observadores, interceptores, hooks de aprovação | +| [Steering](../architecture/steering.md) | Injetar mensagens em um loop de agente em execução | +| [SubTurn](../architecture/subturn.md) | Coordenação de subagentes, controle de concorrência, ciclo de vida | +| [Gerenciamento de Contexto](../architecture/agent-refactor/context.md) | Detecção de limites de contexto, compressão | diff --git a/docs/vi/configuration.md b/docs/guides/configuration.vi.md similarity index 95% rename from docs/vi/configuration.md rename to docs/guides/configuration.vi.md index ea897bc28..d905b6d2b 100644 --- a/docs/vi/configuration.md +++ b/docs/guides/configuration.vi.md @@ -1,6 +1,6 @@ # ⚙️ Hướng Dẫn Cấu Hình -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) ## ⚙️ Cấu Hình @@ -340,7 +340,7 @@ Trả lời HEARTBEAT_OK Người dùng nhận kết quả trực tiếp | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Lấy](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Lấy](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Lấy](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Lấy](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Lấy](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Lấy](https://console.groq.com) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Lấy](https://dashscope.console.aliyun.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Cục bộ (không cần key) | @@ -370,9 +370,12 @@ Cấu hình `providers` cũ đã **bị deprecated** và đã được loại b PicoClaw định tuyến provider theo họ giao thức: - **Tương thích OpenAI**: OpenRouter, Groq, Zhipu, endpoint kiểu vLLM và hầu hết các provider khác. +- **Gemini native**: Google Gemini qua các endpoint native `models/*:generateContent` và `models/*:streamGenerateContent`. - **Anthropic**: Hành vi API Claude gốc. - **Codex/OAuth**: Tuyến xác thực OAuth/token OpenAI. +Điều này giữ runtime nhẹ trong khi khiến backend OpenAI-compatible mới chủ yếu chỉ là thao tác cấu hình (`api_base` + `api_keys`). + ### Tác Vụ Đã Lên Lịch / Nhắc Nhở PicoClaw hỗ trợ tác vụ theo lịch qua công cụ `cron`. @@ -394,7 +397,7 @@ Tác vụ đã lên lịch được lưu trữ bền vững sau khi khởi độ | Chủ đề | Mô tả | | ------ | ----- | -| [Hệ Thống Hook](../hooks/README.md) | Hook hướng sự kiện: observer, interceptor, approval hook | -| [Steering](../steering.md) | Chèn tin nhắn vào vòng lặp agent đang chạy | -| [SubTurn](../subturn.md) | Điều phối subagent, kiểm soát đồng thời, vòng đời | -| [Quản Lý Ngữ Cảnh](../agent-refactor/context.md) | Phát hiện ranh giới ngữ cảnh, nén | +| [Hệ Thống Hook](../architecture/hooks/README.md) | Hook hướng sự kiện: observer, interceptor, approval hook | +| [Steering](../architecture/steering.md) | Chèn tin nhắn vào vòng lặp agent đang chạy | +| [SubTurn](../architecture/subturn.md) | Điều phối subagent, kiểm soát đồng thời, vòng đời | +| [Quản Lý Ngữ Cảnh](../architecture/agent-refactor/context.md) | Phát hiện ranh giới ngữ cảnh, nén | diff --git a/docs/zh/configuration.md b/docs/guides/configuration.zh.md similarity index 89% rename from docs/zh/configuration.md rename to docs/guides/configuration.zh.md index 9a8d39262..adbe77db0 100644 --- a/docs/zh/configuration.md +++ b/docs/guides/configuration.zh.md @@ -1,6 +1,6 @@ # ⚙️ 配置指南 -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) ## ⚙️ 配置详解 @@ -120,6 +120,86 @@ dammi le ultime news - 未注册的斜杠命令(例如 `/foo`)会透传给 LLM 按普通输入处理。 - 已注册但当前 channel 不支持的命令(例如 WhatsApp 上的 `/show`)会返回明确的用户可见错误,并停止后续处理。 +### Session 隔离 + +Session scope 决定了聊天、用户、线程和 space 之间共享多少上下文。 + +- 全局默认值使用 `session.dimensions` +- 如果只想让某条路由例外,使用 dispatch rule 上的 `session_dimensions` + +如果你想看完整的隔离方案和配置配方,请看 [Session 使用指南](session-guide.zh.md)。 + +### Routing + +Routing 通过 `agents.dispatch.rules` 配置。 + +每条规则都针对 channel 归一化后的 inbound context 做匹配。 +规则按从上到下顺序检查,第一条命中的规则立即生效。若没有规则命中,PicoClaw 会回退到默认 agent。 + +支持的匹配字段: + +* `channel` +* `account` +* `space` +* `chat` +* `topic` +* `sender` +* `mentioned` + +这些值使用和 session system 一致的归一化词汇: + +* `space`: `workspace:t001`、`guild:123456` +* `chat`: `direct:user123`、`group:-100123`、`channel:c123` +* `topic`: `topic:42` +* `sender`: 平台归一化后的 sender 标识 + +规则也可以通过 `session_dimensions` 覆盖全局 `session.dimensions`,这样路由和会话隔离就能保持一致,而不必回到旧的 `bindings` 或 `dm_scope` 配置。 + +示例: + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" }, + { "id": "sales" } + ], + "dispatch": { + "rules": [ + { + "name": "vip in support group", + "agent": "sales", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890", + "sender": "12345" + }, + "session_dimensions": ["chat", "sender"] + }, + { + "name": "telegram support group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890" + }, + "session_dimensions": ["chat"] + } + ] + } + }, + "session": { + "dimensions": ["chat"] + } +} +``` + +在这个例子里,VIP 规则必须放在更宽泛的群规则前面。 +因为 routing 是严格按顺序执行的,所以更具体的规则要放前面,兜底规则放后面。 + +如果你想看更完整的 agent 路由和模型分层示例,请看 [路由使用指南](routing-guide.zh.md)。 + ### 🔒 安全沙箱 (Security Sandbox) PicoClaw 默认在沙箱环境中运行。Agent 只能访问配置的工作区内的文件和执行命令。 @@ -361,7 +441,7 @@ Agent 读取 HEARTBEAT.md | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取](https://aistudio.google.com/api-keys) | +| **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) | @@ -572,10 +652,11 @@ PicoClaw 只剥离最外层的 `litellm/` 前缀再发送请求,因此 `litell PicoClaw 按协议族路由提供商: - **OpenAI 兼容**:OpenRouter、Groq、智谱、vLLM 风格端点及大多数其他提供商。 +- **Gemini 原生**:Google Gemini 通过原生 `models/*:generateContent` 和 `models/*:streamGenerateContent` 端点接入。 - **Anthropic**:Claude 原生 API 行为。 - **Codex/OAuth**:OpenAI OAuth/Token 认证路由。 -这使运行时保持轻量,同时让接入新的 OpenAI 兼容后端基本只需配置 `api_base` + `api_key`。 +这使运行时保持轻量,同时让接入新的 OpenAI 兼容后端基本只需配置 `api_base` + `api_keys`。

智谱(旧版 providers 格式) @@ -670,8 +751,8 @@ PicoClaw 通过 `cron` 工具支持 cron 风格的定时任务。Agent 可以设 | 主题 | 说明 | | ---- | ---- | -| [敏感数据过滤](../sensitive_data_filtering.md) | 在发送给 LLM 前,从工具结果中过滤 API 密钥和令牌 | -| [Hook 系统](../hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook | -| [Steering](../steering.md) | 在工具调用间向运行中的 Agent 注入消息 | -| [SubTurn](../subturn.md) | 子 Agent 协调、并发控制、生命周期管理 | -| [上下文管理](../agent-refactor/context.md) | 上下文边界检测、主动预算检查、压缩策略 | +| [敏感数据过滤](../security/sensitive_data_filtering.zh.md) | 在发送给 LLM 前,从工具结果中过滤 API 密钥和令牌 | +| [Hook 系统](../architecture/hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook | +| [Steering](../architecture/steering.md) | 在工具调用间向运行中的 Agent 注入消息 | +| [SubTurn](../architecture/subturn.md) | 子 Agent 协调、并发控制、生命周期管理 | +| [上下文管理](../architecture/agent-refactor/context.md) | 上下文边界检测、主动预算检查、压缩策略 | diff --git a/docs/fr/docker.md b/docs/guides/docker.fr.md similarity index 99% rename from docs/fr/docker.md rename to docs/guides/docker.fr.md index 9605440bc..f8c821570 100644 --- a/docs/fr/docker.md +++ b/docs/guides/docker.fr.md @@ -1,6 +1,6 @@ # 🐳 Docker et Démarrage Rapide -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) ## 🐳 Docker Compose diff --git a/docs/ja/docker.md b/docs/guides/docker.ja.md similarity index 97% rename from docs/ja/docker.md rename to docs/guides/docker.ja.md index a585c5e80..f5885e775 100644 --- a/docs/ja/docker.md +++ b/docs/guides/docker.ja.md @@ -1,6 +1,6 @@ # 🐳 Docker とクイックスタート -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る ## 🐳 Docker Compose @@ -143,7 +143,7 @@ picoclaw onboard } ``` -> **新機能**: `model_list` 設定形式により、コード変更なしで provider を追加できます。詳細は[モデル設定](providers.md#モデル設定-model_list)を参照してください。 +> **新機能**: `model_list` 設定形式により、コード変更なしで provider を追加できます。詳細は[モデル設定](providers.ja.md#モデル設定-model_list)を参照してください。 > `request_timeout` はオプションで、単位は秒です。省略または `<= 0` に設定した場合、PicoClaw はデフォルトのタイムアウト(120 秒)を使用します。 **3. API Key の取得** diff --git a/docs/docker.md b/docs/guides/docker.md similarity index 97% rename from docs/docker.md rename to docs/guides/docker.md index 6c32879a6..3ccc7a2a7 100644 --- a/docs/docker.md +++ b/docs/guides/docker.md @@ -27,7 +27,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d > **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`. > [!NOTE] -> The `gateway` profile only serves the webhook handlers (including Pico when enabled) and health endpoints on the gateway port, so it does not expose generic REST chat endpoints such as `/chat` or `/a2a`. Launcher mode adds the browser UI plus `/api/pico/token` and a `/pico/ws` proxy on the launcher port, but `/pico/ws` is also available directly on the gateway whenever the Pico channel is enabled. +> The `gateway` profile only serves the webhook handlers (including Pico when enabled) and health endpoints on the gateway port, so it does not expose generic REST chat endpoints such as `/chat` or `/a2a`. Launcher mode adds the browser UI plus `/api/pico/info` and an authenticated `/pico/ws` proxy on the launcher port, but `/pico/ws` is also available directly on the gateway whenever the Pico channel is enabled. ```bash # 5. Check logs diff --git a/docs/my/docker.md b/docs/guides/docker.ms.md similarity index 99% rename from docs/my/docker.md rename to docs/guides/docker.ms.md index 2f9cac3fd..05725e195 100644 --- a/docs/my/docker.md +++ b/docs/guides/docker.ms.md @@ -1,6 +1,6 @@ # 🐳 Panduan Docker & Quick Start -> Kembali ke [README](../../README.my.md) +> Kembali ke [README](../project/README.ms.md) ## 🐳 Docker Compose diff --git a/docs/pt-br/docker.md b/docs/guides/docker.pt-br.md similarity index 99% rename from docs/pt-br/docker.md rename to docs/guides/docker.pt-br.md index a17dc64ec..46d273bee 100644 --- a/docs/pt-br/docker.md +++ b/docs/guides/docker.pt-br.md @@ -1,6 +1,6 @@ # 🐳 Docker e Início Rápido -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) ## 🐳 Docker Compose diff --git a/docs/vi/docker.md b/docs/guides/docker.vi.md similarity index 99% rename from docs/vi/docker.md rename to docs/guides/docker.vi.md index e6bc74b1a..716c81544 100644 --- a/docs/vi/docker.md +++ b/docs/guides/docker.vi.md @@ -1,6 +1,6 @@ # 🐳 Docker và Bắt Đầu Nhanh -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) ## 🐳 Docker Compose diff --git a/docs/zh/docker.md b/docs/guides/docker.zh.md similarity index 97% rename from docs/zh/docker.md rename to docs/guides/docker.zh.md index f840290a7..521747d16 100644 --- a/docs/zh/docker.md +++ b/docs/guides/docker.zh.md @@ -1,6 +1,6 @@ # 🐳 Docker 与快速开始 -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) ## 🐳 Docker Compose @@ -143,7 +143,7 @@ picoclaw onboard } ``` -> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](providers.md#模型配置-model_list)章节。 +> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](providers.zh.md#模型配置-model_list)章节。 > `request_timeout` 为可选项,单位为秒。若省略或设置为 `<= 0`,PicoClaw 使用默认超时(120 秒)。 **3. 获取 API Key** diff --git a/docs/fr/hardware-compatibility.md b/docs/guides/hardware-compatibility.fr.md similarity index 98% rename from docs/fr/hardware-compatibility.md rename to docs/guides/hardware-compatibility.fr.md index c1f397e80..bb2d92d57 100644 --- a/docs/fr/hardware-compatibility.md +++ b/docs/guides/hardware-compatibility.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) # 🖥️ PicoClaw Liste de compatibilité matérielle @@ -99,7 +99,7 @@ Produits grand public, routeurs et appareils industriels testés avec PicoClaw. Tout téléphone Android ARM64 (2015+) avec 1 Go+ de RAM. Installez [Termux](https://github.com/termux/termux-app), utilisez `proot` pour exécuter PicoClaw. -> Voir [README : Exécuter sur d'anciens téléphones Android](../../README.fr.md#-run-on-old-android-phones) pour les instructions de configuration. +> Voir [README : Exécuter sur d'anciens téléphones Android](../project/README.fr.md#-run-on-old-android-phones) pour les instructions de configuration. ### Bureau / Serveur / Cloud diff --git a/docs/ja/hardware-compatibility.md b/docs/guides/hardware-compatibility.ja.md similarity index 98% rename from docs/ja/hardware-compatibility.md rename to docs/guides/hardware-compatibility.ja.md index 96ccd1cd1..c86684f84 100644 --- a/docs/ja/hardware-compatibility.md +++ b/docs/guides/hardware-compatibility.ja.md @@ -1,4 +1,4 @@ -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る # 🖥️ PicoClaw ハードウェア互換性リスト @@ -99,7 +99,7 @@ PicoClaw でテスト済みのコンシューマー製品、ルーター、産 1GB 以上の RAM を搭載した ARM64 Android スマートフォン(2015年以降)。[Termux](https://github.com/termux/termux-app) をインストールし、`proot` を使用して PicoClaw を実行します。 -> セットアップ手順は [README:古い Android スマートフォンで実行](../../README.ja.md#-run-on-old-android-phones) を参照してください。 +> セットアップ手順は [README:古い Android スマートフォンで実行](../project/README.ja.md#-run-on-old-android-phones) を参照してください。 ### デスクトップ / サーバー / クラウド diff --git a/docs/hardware-compatibility.md b/docs/guides/hardware-compatibility.md similarity index 98% rename from docs/hardware-compatibility.md rename to docs/guides/hardware-compatibility.md index c11849822..a07bb5116 100644 --- a/docs/hardware-compatibility.md +++ b/docs/guides/hardware-compatibility.md @@ -97,7 +97,7 @@ Consumer products, routers, and industrial devices that have been tested with Pi Any ARM64 Android phone (2015+) with 1GB+ RAM. Install [Termux](https://github.com/termux/termux-app), use `proot` to run PicoClaw. -> See [README: Run on old Android Phones](../README.md#-run-on-old-android-phones) for setup instructions. +> See [README: Run on old Android Phones](../../README.md#-run-on-old-android-phones) for setup instructions. ### Desktop / Server / Cloud diff --git a/docs/pt-br/hardware-compatibility.md b/docs/guides/hardware-compatibility.pt-br.md similarity index 97% rename from docs/pt-br/hardware-compatibility.md rename to docs/guides/hardware-compatibility.pt-br.md index 771621014..1fc8ee25e 100644 --- a/docs/pt-br/hardware-compatibility.md +++ b/docs/guides/hardware-compatibility.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) # 🖥️ PicoClaw Lista de compatibilidade de hardware @@ -99,7 +99,7 @@ Produtos de consumo, roteadores e dispositivos industriais testados com o PicoCl Qualquer celular Android ARM64 (2015+) com 1GB+ de RAM. Instale o [Termux](https://github.com/termux/termux-app), use `proot` para rodar o PicoClaw. -> Veja [README: Rodar em celulares Android antigos](../../README.pt-br.md#-run-on-old-android-phones) para instruções de configuração. +> Veja [README: Rodar em celulares Android antigos](../project/README.pt-br.md#-run-on-old-android-phones) para instruções de configuração. ### Desktop / Servidor / Nuvem diff --git a/docs/vi/hardware-compatibility.md b/docs/guides/hardware-compatibility.vi.md similarity index 97% rename from docs/vi/hardware-compatibility.md rename to docs/guides/hardware-compatibility.vi.md index 8315c049e..5566a4248 100644 --- a/docs/vi/hardware-compatibility.md +++ b/docs/guides/hardware-compatibility.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) # 🖥️ PicoClaw Danh sách tương thích phần cứng @@ -99,7 +99,7 @@ Sản phẩm tiêu dùng, router và thiết bị công nghiệp đã được k Bất kỳ điện thoại Android ARM64 nào (2015+) với 1GB+ RAM. Cài đặt [Termux](https://github.com/termux/termux-app), sử dụng `proot` để chạy PicoClaw. -> Xem [README: Chạy trên điện thoại Android cũ](../../README.vi.md#-run-on-old-android-phones) để biết hướng dẫn cài đặt. +> Xem [README: Chạy trên điện thoại Android cũ](../project/README.vi.md#-run-on-old-android-phones) để biết hướng dẫn cài đặt. ### Desktop / Máy chủ / Đám mây diff --git a/docs/zh/hardware-compatibility.md b/docs/guides/hardware-compatibility.zh.md similarity index 97% rename from docs/zh/hardware-compatibility.md rename to docs/guides/hardware-compatibility.zh.md index 66bd08072..d563f3ebe 100644 --- a/docs/zh/hardware-compatibility.md +++ b/docs/guides/hardware-compatibility.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) # 🖥️ PicoClaw 硬件兼容性列表 @@ -99,7 +99,7 @@ PicoClaw 几乎可以在任何 Linux 设备上运行。本页面记录了已验 任何 ARM64 Android 手机(2015 年以后),1GB 以上内存。安装 [Termux](https://github.com/termux/termux-app),使用 `proot` 运行 PicoClaw。 -> 参见 [README:在旧 Android 手机上运行](../../README.zh.md#-run-on-old-android-phones) 获取设置说明。 +> 参见 [README:在旧 Android 手机上运行](../project/README.zh.md#-run-on-old-android-phones) 获取设置说明。 ### 桌面 / 服务器 / 云 diff --git a/docs/fr/providers.md b/docs/guides/providers.fr.md similarity index 97% rename from docs/fr/providers.md rename to docs/guides/providers.fr.md index f053d5d57..aff600351 100644 --- a/docs/fr/providers.md +++ b/docs/guides/providers.fr.md @@ -1,6 +1,6 @@ # 🔌 Fournisseurs et Configuration des Modèles -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) ### Fournisseurs @@ -46,7 +46,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | +| **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) | @@ -108,7 +108,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti | `api_keys` | string[] | Oui* | Clé(s) API pour l'authentification. Plusieurs clés permettent la rotation par requête. Non requis pour les fournisseurs locaux (Ollama, LM Studio, VLLM) | | `api_base` | string | Non | Remplace l'URL de base API par défaut | | `proxy` | string | Non | URL du proxy HTTP pour cette entrée de modèle | -| `user_agent` | string | Non | En-tête `User-Agent` personnalisé pour les requêtes API (supporté par les providers OpenAI-compatible, Anthropic et Azure) | +| `user_agent` | string | Non | En-tête `User-Agent` personnalisé pour les requêtes API (supporté par les providers compatibles OpenAI, Gemini, Anthropic et Azure) | | `request_timeout` | int | Non | Délai d'expiration de la requête en secondes (la valeur par défaut varie selon le provider) | | `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` | @@ -299,10 +299,11 @@ Pour un guide de migration détaillé, voir [migration/model-list-migration.md]( PicoClaw route les fournisseurs par famille de protocoles : - Protocole compatible OpenAI : OpenRouter, passerelles compatibles OpenAI, Groq, Zhipu et endpoints de type vLLM. +- Protocole Gemini natif : Google Gemini via les endpoints natifs `models/*:generateContent` et `models/*:streamGenerateContent`. - Protocole Anthropic : Comportement natif de l'API Claude. - Chemin Codex/OAuth : Route d'authentification OAuth/token OpenAI. -Cela maintient le runtime léger tout en faisant des nouveaux backends compatibles OpenAI principalement une opération de configuration (`api_base` + `api_key`). +Cela maintient le runtime léger tout en faisant des nouveaux backends compatibles OpenAI principalement une opération de configuration (`api_base` + `api_keys`).
Zhipu @@ -454,5 +455,5 @@ picoclaw agent -m "Hello" ---
- PicoClaw Meme + PicoClaw Meme
diff --git a/docs/ja/providers.md b/docs/guides/providers.ja.md similarity index 97% rename from docs/ja/providers.md rename to docs/guides/providers.ja.md index b22e1f7ba..fecc74519 100644 --- a/docs/ja/providers.md +++ b/docs/guides/providers.ja.md @@ -1,6 +1,6 @@ # 🔌 プロバイダーとモデル設定 -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る ### プロバイダー @@ -27,6 +27,7 @@ | `longcat` | LLM (Longcat 直接接続) | [longcat.ai](https://longcat.ai) | | `modelscope` | LLM (ModelScope 直接接続) | [modelscope.cn](https://modelscope.cn) | + ### モデル設定 (model_list) > **新機能!** PicoClaw は**モデル中心**の設定方式を採用しました。`ベンダー/モデル` 形式(例: `zhipu/glm-4.7`)を指定するだけで新しい provider を追加できます——**コード変更は一切不要です!** @@ -46,7 +47,7 @@ | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [キーを取得](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [キーを取得](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [キーを取得](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [キーを取得](https://aistudio.google.com/api-keys) | +| **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) | @@ -108,7 +109,7 @@ | `api_keys` | string[] | はい* | 認証キー。複数キーでリクエストごとのローテーションが可能。ローカル provider(Ollama、LM Studio、VLLM)には不要 | | `api_base` | string | いいえ | デフォルトの API エンドポイント URL を上書き | | `proxy` | string | いいえ | このモデルエントリの HTTP プロキシ URL | -| `user_agent` | string | いいえ | カスタム `User-Agent` リクエストヘッダー(OpenAI 互換、Anthropic、Azure provider で対応) | +| `user_agent` | string | いいえ | カスタム `User-Agent` リクエストヘッダー(OpenAI 互換、Gemini、Anthropic、Azure provider で対応) | | `request_timeout` | int | いいえ | リクエストタイムアウト(秒)。デフォルト値は provider により異なる | | `max_tokens_field` | string | いいえ | リクエストボディの max tokens フィールド名を上書き(例:o1 モデルでは `max_completion_tokens`) | | `thinking_level` | string | いいえ | 拡張思考レベル:`off`、`low`、`medium`、`high`、`xhigh`、`adaptive` | @@ -310,6 +311,7 @@ PicoClaw はリクエスト送信前に外側の `litellm/` プレフィック PicoClaw はプロトコルファミリーごとに Provider をルーティングします: - OpenAI 互換プロトコル:OpenRouter、OpenAI 互換ゲートウェイ、Groq、Zhipu、vLLM スタイルのエンドポイント。 +- Gemini ネイティブプロトコル:Google Gemini のネイティブ `models/*:generateContent` / `models/*:streamGenerateContent` エンドポイント。 - Anthropic プロトコル:Claude ネイティブ API 動作。 - Codex/OAuth パス:OpenAI OAuth/Token 認証ルート。 @@ -465,5 +467,5 @@ picoclaw agent -m "こんにちは" ---
- PicoClaw Meme + PicoClaw Meme
diff --git a/docs/providers.md b/docs/guides/providers.md similarity index 97% rename from docs/providers.md rename to docs/guides/providers.md index ca1678c7e..81c12cd4a 100644 --- a/docs/providers.md +++ b/docs/guides/providers.md @@ -35,6 +35,8 @@ > **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!** +For agent dispatch and light-model routing examples, see the [Routing Guide](routing-guide.md). + This design also enables **multi-agent support** with flexible provider selection: - **Different agents, different providers**: Each agent can use its own LLM provider @@ -52,7 +54,7 @@ This design also enables **multi-agent support** with flexible provider selectio | **智谱 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` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | +| **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) | @@ -117,7 +119,7 @@ This design also enables **multi-agent support** with flexible provider selectio | `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, Anthropic, and Azure providers) | +| `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` | @@ -406,17 +408,18 @@ The old `providers` configuration is **deprecated** and has been removed in V2. } ``` -For detailed migration guide, see [migration/model-list-migration.md](migration/model-list-migration.md). +For detailed migration guide, see [migration/model-list-migration.md](../migration/model-list-migration.md). ### Provider Architecture PicoClaw routes providers by protocol family: - OpenAI-compatible protocol: OpenRouter, OpenAI-compatible gateways, Groq, Zhipu, and vLLM-style endpoints. +- Gemini native protocol: Google Gemini via the native `models/*:generateContent` and `models/*:streamGenerateContent` endpoints. - Anthropic protocol: Claude-native API behavior. - Codex/OAuth path: OpenAI OAuth/token authentication route. -This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`). +This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_keys`).
Zhipu @@ -572,5 +575,5 @@ picoclaw agent -m "Hello" ---
- PicoClaw Meme + PicoClaw Meme
diff --git a/docs/pt-br/providers.md b/docs/guides/providers.pt-br.md similarity index 98% rename from docs/pt-br/providers.md rename to docs/guides/providers.pt-br.md index ebe911b65..0d45dc309 100644 --- a/docs/pt-br/providers.md +++ b/docs/guides/providers.pt-br.md @@ -1,6 +1,6 @@ # 🔌 Provedores e Configuração de Modelos -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) ### Provedores @@ -46,7 +46,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | +| **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) | @@ -108,7 +108,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de | `api_keys` | string[] | Sim* | Chave(s) API para autenticação. Múltiplas chaves permitem rotação por requisição. Não necessário para providers locais (Ollama, LM Studio, VLLM) | | `api_base` | string | Não | Substitui a URL base da API padrão | | `proxy` | string | Não | URL do proxy HTTP para esta entrada de modelo | -| `user_agent` | string | Não | Cabeçalho `User-Agent` personalizado enviado com requisições API (suportado por providers OpenAI-compatible, Anthropic e Azure) | +| `user_agent` | string | Não | Cabeçalho `User-Agent` personalizado enviado com requisições API (suportado por providers OpenAI-compatible, Gemini, Anthropic e Azure) | | `request_timeout` | int | Não | Timeout de requisição em segundos (o padrão varia por provider) | | `max_tokens_field` | string | Não | Substitui o nome do campo max tokens no corpo da requisição (ex: `max_completion_tokens` para modelos o1) | | `thinking_level` | string | Não | Nível de pensamento estendido: `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` | @@ -299,6 +299,7 @@ Para guia de migração detalhado, veja [migration/model-list-migration.md](../m O PicoClaw roteia provedores por família de protocolo: - Protocolo compatível com OpenAI: OpenRouter, gateways compatíveis com OpenAI, Groq, Zhipu e endpoints estilo vLLM. +- Protocolo Gemini nativo: Google Gemini via endpoints nativos `models/*:generateContent` e `models/*:streamGenerateContent`. - Protocolo Anthropic: Comportamento nativo da API Claude. - Caminho Codex/OAuth: Rota de autenticação OAuth/token da OpenAI. @@ -454,5 +455,5 @@ picoclaw agent -m "Hello" ---
- PicoClaw Meme + PicoClaw Meme
diff --git a/docs/vi/providers.md b/docs/guides/providers.vi.md similarity index 98% rename from docs/vi/providers.md rename to docs/guides/providers.vi.md index 5178ad197..c354461cf 100644 --- a/docs/vi/providers.md +++ b/docs/guides/providers.vi.md @@ -1,6 +1,6 @@ # 🔌 Nhà Cung Cấp và Cấu Hình Mô Hình -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) ### Nhà Cung Cấp @@ -46,7 +46,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | +| **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) | @@ -108,7 +108,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr | `api_keys` | string[] | Có* | Khóa API xác thực. Nhiều khóa cho phép xoay vòng theo yêu cầu. Không cần thiết cho provider nội bộ (Ollama, LM Studio, VLLM) | | `api_base` | string | Không | Ghi đè URL endpoint API mặc định | | `proxy` | string | Không | URL proxy HTTP cho entry model này | -| `user_agent` | string | Không | Header `User-Agent` tùy chỉnh gửi với yêu cầu API (được hỗ trợ bởi provider OpenAI-compatible, Anthropic và Azure) | +| `user_agent` | string | Không | Header `User-Agent` tùy chỉnh gửi với yêu cầu API (được hỗ trợ bởi provider OpenAI-compatible, Gemini, Anthropic và Azure) | | `request_timeout` | int | Không | Timeout yêu cầu tính bằng giây (mặc định khác nhau tùy provider) | | `max_tokens_field` | string | Không | Ghi đè tên trường max tokens trong request body (ví dụ: `max_completion_tokens` cho model o1) | | `thinking_level` | string | Không | Mức độ tư duy mở rộng: `off`, `low`, `medium`, `high`, `xhigh` hoặc `adaptive` | @@ -299,6 +299,7 @@ Cấu hình `providers` cũ đã **bị deprecated** và đã được loại b PicoClaw định tuyến provider theo họ giao thức: - Giao thức tương thích OpenAI: OpenRouter, gateway tương thích OpenAI, Groq, Zhipu, và endpoint kiểu vLLM. +- Giao thức Gemini native: Google Gemini qua các endpoint native `models/*:generateContent` và `models/*:streamGenerateContent`. - Giao thức Anthropic: Hành vi API native của Claude. - Đường dẫn Codex/OAuth: Tuyến xác thực OAuth/token của OpenAI. @@ -454,5 +455,5 @@ picoclaw agent -m "Hello" ---
- PicoClaw Meme + PicoClaw Meme
diff --git a/docs/zh/providers.md b/docs/guides/providers.zh.md similarity index 97% rename from docs/zh/providers.md rename to docs/guides/providers.zh.md index 155fbe11b..c08d7171b 100644 --- a/docs/zh/providers.md +++ b/docs/guides/providers.zh.md @@ -1,6 +1,6 @@ # 🔌 提供商与模型配置 -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) ### 提供商 (Providers) @@ -29,10 +29,13 @@ | `modelscope` | LLM (ModelScope 直连) | [modelscope.cn](https://modelscope.cn) | | `mimo` | LLM (小米 MiMo 直连) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) | + ### 模型配置 (model_list) > **新功能!** PicoClaw 现在采用**以模型为中心**的配置方式。只需使用 `厂商/模型` 格式(如 `zhipu/glm-4.7`)即可添加新的 provider——**无需修改任何代码!** +如果你想看 agent 分发和轻量模型路由的完整示例,请看 [路由使用指南](routing-guide.zh.md)。 + 该设计同时支持**多 Agent 场景**,提供灵活的 Provider 选择: - **不同 Agent 使用不同 Provider**:每个 Agent 可以使用自己的 LLM provider @@ -49,7 +52,7 @@ | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取密钥](https://aistudio.google.com/api-keys) | +| **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) | @@ -113,7 +116,7 @@ | `api_keys` | string[] | 是* | 认证密钥。多个密钥可按请求轮换。本地 provider(Ollama、LM Studio、VLLM)不需要 | | `api_base` | string | 否 | 覆盖默认的 API 端点 URL | | `proxy` | string | 否 | 此模型条目的 HTTP 代理 URL | -| `user_agent` | string | 否 | 自定义 `User-Agent` 请求头(支持 OpenAI 兼容、Anthropic 和 Azure provider) | +| `user_agent` | string | 否 | 自定义 `User-Agent` 请求头(支持 OpenAI 兼容、Gemini、Anthropic 和 Azure provider) | | `request_timeout` | int | 否 | 请求超时时间(秒),默认值因 provider 而异 | | `max_tokens_field` | string | 否 | 覆盖请求体中 max tokens 的字段名(如 o1 模型使用 `max_completion_tokens`) | | `thinking_level` | string | 否 | 扩展思考级别:`off`、`low`、`medium`、`high`、`xhigh` 或 `adaptive` | @@ -383,10 +386,11 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l PicoClaw 按协议族路由 Provider: - OpenAI 兼容协议:OpenRouter、OpenAI 兼容网关、Groq、智谱、vLLM 风格端点。 +- Gemini 原生协议:Google Gemini 通过原生 `models/*:generateContent` 和 `models/*:streamGenerateContent` 端点接入。 - Anthropic 协议:Claude 原生 API 行为。 - Codex/OAuth 路径:OpenAI OAuth/Token 认证路由。 -这使得运行时保持轻量,同时让新的 OpenAI 兼容后端基本只需配置操作(`api_base` + `api_key`)。 +这使得运行时保持轻量,同时让新的 OpenAI 兼容后端基本只需配置操作(`api_base` + `api_keys`)。
智谱 (Zhipu) 配置示例 diff --git a/docs/guides/routing-guide.md b/docs/guides/routing-guide.md new file mode 100644 index 000000000..abeaf0285 --- /dev/null +++ b/docs/guides/routing-guide.md @@ -0,0 +1,331 @@ +# Routing Guide + +> Back to [README](../README.md) + +In PicoClaw, routing has two user-facing parts: + +- **agent routing**: choose which agent should handle a message +- **model routing**: choose whether a turn should use the primary model or the configured light model + +This guide explains how to configure both for real deployments. + +## Quick Start + +### Route one Telegram group to a support agent + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" } + ], + "dispatch": { + "rules": [ + { + "name": "telegram support group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890" + } + } + ] + } + } +} +``` + +### Route only Slack mentions in one workspace + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" } + ], + "dispatch": { + "rules": [ + { + "name": "slack mentions", + "agent": "support", + "when": { + "channel": "slack", + "space": "workspace:t001", + "mentioned": true + } + } + ] + } + } +} +``` + +### Use a light model for simple turns + +```json +{ + "model_list": [ + { + "model_name": "gpt-main", + "model": "openai/gpt-5.4", + "api_keys": ["sk-main"] + }, + { + "model_name": "flash-light", + "model": "gemini/gemini-2.0-flash-exp", + "api_keys": ["sk-light"] + } + ], + "agents": { + "defaults": { + "model_name": "gpt-main", + "routing": { + "enabled": true, + "light_model": "flash-light", + "threshold": 0.35 + } + } + } +} +``` + +## Agent Routing + +Agent routing is configured with: + +```text +agents.dispatch.rules +``` + +Rules are evaluated from top to bottom. +The **first matching rule wins**. +If no rule matches, PicoClaw falls back to the default agent. + +## Supported Match Fields + +| Field | Meaning | Example | +| --- | --- | --- | +| `channel` | Channel name | `telegram`, `slack`, `discord` | +| `account` | Normalized account ID | `default`, `bot2` | +| `space` | Workspace, guild, or similar container | `workspace:t001`, `guild:123456` | +| `chat` | Direct chat, group, or channel | `direct:user123`, `group:-100123`, `channel:c123` | +| `topic` | Thread or topic | `topic:42` | +| `sender` | Normalized sender identity | `12345`, `john` | +| `mentioned` | Whether the bot was explicitly mentioned | `true` | + +Values must match the normalized runtime shape, not the raw incoming payload. + +## Rule Ordering + +Put more specific rules before broader rules. + +Good: + +1. VIP sender inside one group +2. all traffic for that group +3. channel-wide fallback + +Bad: + +1. all traffic for that group +2. VIP sender inside the same group + +In the bad ordering, the broad rule wins first and the VIP rule never runs. + +## Session Interaction + +Routing and sessions are related but different. + +- routing decides which agent handles the message +- session settings decide which messages share memory + +You can override the global `session.dimensions` value for one matched rule with `session_dimensions`. + +Example: + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" }, + { "id": "sales" } + ], + "dispatch": { + "rules": [ + { + "name": "vip in support group", + "agent": "sales", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890", + "sender": "12345" + }, + "session_dimensions": ["chat", "sender"] + }, + { + "name": "support group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890" + }, + "session_dimensions": ["chat"] + } + ] + } + }, + "session": { + "dimensions": ["chat"] + } +} +``` + +In this configuration: + +- the VIP gets routed to `sales` +- everyone else in the group goes to `support` +- the VIP route also gets per-user session isolation + +## Identity Links + +`session.identity_links` also affects routing when you match on `sender`. +Use it when the same real user may appear under multiple raw sender IDs. + +Example: + +```json +{ + "session": { + "identity_links": { + "john": ["slack:u123", "legacy-user-42"] + } + }, + "agents": { + "dispatch": { + "rules": [ + { + "name": "john goes to sales", + "agent": "sales", + "when": { + "sender": "john" + } + } + ] + } + } +} +``` + +## Model Routing + +Model routing is configured under: + +```text +agents.defaults.routing +``` + +Current fields: + +| Field | Meaning | +| --- | --- | +| `enabled` | Turn model routing on or off | +| `light_model` | `model_name` from `model_list` used for simple turns | +| `threshold` | Complexity cutoff in `[0, 1]` | + +Important behavior: + +- the light model must exist in `model_list` +- PicoClaw resolves the light model at startup; if it is invalid, routing is disabled +- one turn stays on one model tier, even if it later calls tools + +## What Affects The Complexity Score + +The current model router looks at structural signals such as: + +- message length +- fenced code blocks +- recent tool calls in the same session +- conversation depth +- media or attachments + +This means a "simple" turn may still go to the primary model if it includes: + +- code +- images or audio +- a very long prompt +- a tool-heavy ongoing workflow + +## Choosing A Threshold + +Recommended starting point: + +```json +{ + "agents": { + "defaults": { + "routing": { + "enabled": true, + "light_model": "flash-light", + "threshold": 0.35 + } + } + } +} +``` + +General rule: + +- lower threshold: use the primary model more often +- higher threshold: use the light model more aggressively + +Practical suggestions: + +- `0.25` if you want safer routing with fewer light-model turns +- `0.35` as the default starting point +- `0.50+` only if your light model is already strong enough for most chat traffic + +## Troubleshooting + +### A rule is not matching + +Check: + +- rule order +- normalized value shape such as `group:-100123` instead of just `-100123` +- whether the channel actually provides `space`, `topic`, or `mentioned` + +### The wrong agent handles a message + +The most common cause is ordering. +Remember: first match wins. + +### The light model is never used + +Check: + +- `agents.defaults.routing.enabled` is `true` +- `light_model` exists in `model_list` +- the light model can actually initialize +- your threshold is not too low + +### The primary model is still chosen for short messages + +That can still happen when the turn includes: + +- a code block +- media or attachments +- recent tool-heavy history + +### Routing works, but the conversation memory is still too shared + +Adjust `session.dimensions` globally or `session_dimensions` on the specific route. +Routing chooses the agent, but sessions decide context sharing. + +## Related Guides + +- [Session Guide](session-guide.md) +- [Configuration Guide](configuration.md) +- [Providers & Model Configuration](providers.md) diff --git a/docs/guides/routing-guide.zh.md b/docs/guides/routing-guide.zh.md new file mode 100644 index 000000000..58c9f14e2 --- /dev/null +++ b/docs/guides/routing-guide.zh.md @@ -0,0 +1,331 @@ +# 路由使用指南 + +> 返回 [README](../project/README.zh.md) + +PicoClaw 里用户能直接感知到的“路由”主要有两部分: + +- **agent 路由**:决定哪一个 agent 处理一条消息 +- **模型路由**:决定这一轮是走主模型,还是走轻量模型 + +这份文档面向真实部署中的配置使用场景。 + +## 快速开始 + +### 把一个 Telegram 群路由给 support agent + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" } + ], + "dispatch": { + "rules": [ + { + "name": "telegram support group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890" + } + } + ] + } + } +} +``` + +### 只处理某个 Slack workspace 里的 @提及 + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" } + ], + "dispatch": { + "rules": [ + { + "name": "slack mentions", + "agent": "support", + "when": { + "channel": "slack", + "space": "workspace:t001", + "mentioned": true + } + } + ] + } + } +} +``` + +### 给简单请求启用轻量模型 + +```json +{ + "model_list": [ + { + "model_name": "gpt-main", + "model": "openai/gpt-5.4", + "api_keys": ["sk-main"] + }, + { + "model_name": "flash-light", + "model": "gemini/gemini-2.0-flash-exp", + "api_keys": ["sk-light"] + } + ], + "agents": { + "defaults": { + "model_name": "gpt-main", + "routing": { + "enabled": true, + "light_model": "flash-light", + "threshold": 0.35 + } + } + } +} +``` + +## Agent 路由 + +Agent 路由通过下面这个配置项定义: + +```text +agents.dispatch.rules +``` + +规则从上到下依次检查。 +**第一条匹配的规则直接生效**。 +如果没有规则命中,PicoClaw 会回退到默认 agent。 + +## 支持的匹配字段 + +| 字段 | 含义 | 示例 | +| --- | --- | --- | +| `channel` | Channel 名称 | `telegram`、`slack`、`discord` | +| `account` | 归一化后的 account ID | `default`、`bot2` | +| `space` | workspace、guild 等上层容器 | `workspace:t001`、`guild:123456` | +| `chat` | 私聊、群或频道 | `direct:user123`、`group:-100123`、`channel:c123` | +| `topic` | 线程或话题 | `topic:42` | +| `sender` | 归一化后的发送者身份 | `12345`、`john` | +| `mentioned` | 是否显式 @ 了 bot | `true` | + +注意,配置里要写的是运行时归一化后的值,不是原始 webhook / SDK payload。 + +## 规则顺序 + +把更具体的规则放前面,把更宽泛的规则放后面。 + +正确顺序: + +1. 某个群里的 VIP 用户 +2. 这个群的全部消息 +3. 某个 channel 的更宽泛兜底 + +错误顺序: + +1. 这个群的全部消息 +2. 同一个群里的 VIP 用户 + +在错误顺序下,宽泛规则会先命中,VIP 规则永远不会生效。 + +## 和 Session 的关系 + +路由和 Session 是相关但不同的两件事: + +- 路由决定由哪个 agent 处理 +- Session 决定这些消息是否共享同一段记忆 + +如果你想让某条命中的路由使用不同的会话策略,可以用 `session_dimensions` 覆盖全局 `session.dimensions`。 + +示例: + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" }, + { "id": "sales" } + ], + "dispatch": { + "rules": [ + { + "name": "vip in support group", + "agent": "sales", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890", + "sender": "12345" + }, + "session_dimensions": ["chat", "sender"] + }, + { + "name": "support group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890" + }, + "session_dimensions": ["chat"] + } + ] + } + }, + "session": { + "dimensions": ["chat"] + } +} +``` + +在这个配置里: + +- VIP 用户会被路由到 `sales` +- 其他群成员会进入 `support` +- VIP 路由还会额外按 `chat + sender` 做每用户隔离 + +## Identity Links + +当你用 `sender` 做匹配时,`session.identity_links` 也会影响路由结果。 +适合这种场景:同一个真实用户可能出现为多个原始 sender ID。 + +示例: + +```json +{ + "session": { + "identity_links": { + "john": ["slack:u123", "legacy-user-42"] + } + }, + "agents": { + "dispatch": { + "rules": [ + { + "name": "john goes to sales", + "agent": "sales", + "when": { + "sender": "john" + } + } + ] + } + } +} +``` + +## 模型路由 + +模型路由配置在: + +```text +agents.defaults.routing +``` + +当前支持字段: + +| 字段 | 含义 | +| --- | --- | +| `enabled` | 开启或关闭模型路由 | +| `light_model` | `model_list` 中用于简单请求的 `model_name` | +| `threshold` | `[0, 1]` 范围内的复杂度阈值 | + +关键行为: + +- `light_model` 必须存在于 `model_list` +- PicoClaw 会在启动时解析轻量模型;如果模型无效,路由会被禁用 +- 同一轮 turn 只会使用同一档模型,不会中途切档 + +## 什么会影响复杂度分数 + +当前模型路由会看一些结构化信号,例如: + +- 消息长度 +- fenced code block +- 同一 session 最近是否频繁调用工具 +- 会话深度 +- 是否带有媒体或附件 + +因此,看起来“很简单”的消息,在以下情况下仍可能走主模型: + +- 带代码 +- 带图片或音频 +- prompt 很长 +- 当前是一个工具调用很多的工作流 + +## 阈值怎么选 + +推荐起点: + +```json +{ + "agents": { + "defaults": { + "routing": { + "enabled": true, + "light_model": "flash-light", + "threshold": 0.35 + } + } + } +} +``` + +通用规律: + +- 阈值越低,越容易回到主模型 +- 阈值越高,越积极地使用轻量模型 + +实用建议: + +- `0.25`:更保守,更少轻量模型 turn +- `0.35`:默认推荐起点 +- `0.50+`:只有当你的轻量模型已经能覆盖大多数聊天任务时再考虑 + +## 常见问题 + +### 某条规则没有命中 + +优先检查: + +- 规则顺序 +- 值的形状是否写成了归一化格式,例如 `group:-100123` 而不是裸 `-100123` +- 当前 channel 是否真的提供了 `space`、`topic` 或 `mentioned` + +### 消息被错误的 agent 处理了 + +最常见原因还是顺序。 +记住:第一条匹配的规则直接生效。 + +### 轻量模型从来没有被用到 + +检查: + +- `agents.defaults.routing.enabled` 是否为 `true` +- `light_model` 是否存在于 `model_list` +- 轻量模型能否成功初始化 +- 阈值是不是设得太低 + +### 明明是短消息,还是走了主模型 + +这通常是因为当前 turn 同时满足了其他“复杂”信号,例如: + +- 带代码块 +- 带媒体或附件 +- 最近的 session 历史里工具调用很多 + +### 路由没问题,但上下文还是共享得太多 + +去调整 `session.dimensions` 或某条 route 上的 `session_dimensions`。 +路由只决定“谁来处理”,session 才决定“记忆怎么共享”。 + +## 相关文档 + +- [Session 使用指南](session-guide.zh.md) +- [配置指南](configuration.zh.md) +- [Provider 与模型配置](providers.zh.md) diff --git a/docs/guides/session-guide.md b/docs/guides/session-guide.md new file mode 100644 index 000000000..3f3759260 --- /dev/null +++ b/docs/guides/session-guide.md @@ -0,0 +1,273 @@ +# Session Guide + +> Back to [README](../README.md) + +PicoClaw sessions decide which messages share the same conversation history. +If your bot "remembers too much" or "forgets too much", the first thing to check is the session configuration. + +This guide is for users configuring session behavior in `config.json`. +For implementation details, see the architecture docs instead. + +## What Sessions Control + +A session controls: + +- which previous messages are visible to the agent +- when summarization starts for that conversation +- whether two users in the same group share context +- whether different chats, threads, or spaces stay isolated + +Session data is stored under your workspace, typically: + +```text +~/.picoclaw/workspace/sessions/ +``` + +## Quick Start + +### Default: one context per chat + +This is the default and is the right choice for most bots. + +```json +{ + "session": { + "dimensions": ["chat"] + } +} +``` + +Use this when: + +- each group/channel should have its own shared memory +- each direct message should have its own separate memory + +### Separate each user inside a group + +If users in the same group should not share memory, add `sender`: + +```json +{ + "session": { + "dimensions": ["chat", "sender"] + } +} +``` + +Use this when: + +- one shared assistant sits in a busy group +- each user should keep a private thread of context even inside the same room + +### Share one context across multiple rooms in the same workspace or guild + +If your channel exposes a `space` value, you can route by workspace or guild instead of by room: + +```json +{ + "session": { + "dimensions": ["space"] + } +} +``` + +Use this when: + +- a Slack workspace assistant should share context across channels +- a Discord guild assistant should share context across channels + +### Split by thread or forum topic + +If your channel exposes `topic`, you can isolate per thread: + +```json +{ + "session": { + "dimensions": ["chat", "topic"] + } +} +``` + +Use this when: + +- each forum topic should keep its own history +- each threaded discussion should stay separate + +## Available Dimensions + +| Dimension | What it means | Good for | +| --- | --- | --- | +| `space` | Workspace, guild, or similar top-level container | One shared assistant across many rooms | +| `chat` | Direct chat, group, or channel | Default per-room isolation | +| `topic` | Thread, topic, or forum sub-channel | Keep threaded discussions separate | +| `sender` | The message sender after normalization | Per-user context inside shared rooms | + +Not every channel provides every field. +If a channel does not supply `space` or `topic`, those dimensions simply have no effect for that message. + +## Important Behavior + +### Sessions are always separated by agent + +Even if two agents receive messages from the same chat, they do not share one session. + +### Sessions are still separated by channel and account + +`session.dimensions` adds finer-grained isolation, but PicoClaw still keeps a baseline separation by: + +- agent +- channel +- account + +That means an empty or very small `dimensions` list does **not** create one global memory across every platform. + +### Telegram forum topics already stay isolated in the default `chat` mode + +Telegram forum messages keep topic isolation by default even when `dimensions` only contains `chat`. +You usually do not need a special workaround for Telegram forums. + +### Summaries happen per session + +`summarize_message_threshold` and `summarize_token_percent` apply inside each session independently. +If you create smaller sessions, summarization also happens on smaller per-session histories. + +## Common Recipes + +### One shared assistant per group or direct chat + +```json +{ + "session": { + "dimensions": ["chat"] + } +} +``` + +### One context per user inside each chat + +```json +{ + "session": { + "dimensions": ["chat", "sender"] + } +} +``` + +### One context per sender across one workspace or guild + +```json +{ + "session": { + "dimensions": ["space", "sender"] + } +} +``` + +This is useful for workspace-wide assistants where each user should keep their own memory while moving across rooms in the same workspace. + +### Use a different session policy for one routed agent only + +You can keep the global default and override it for one dispatch rule: + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" } + ], + "dispatch": { + "rules": [ + { + "name": "support group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890" + }, + "session_dimensions": ["chat", "sender"] + } + ] + } + }, + "session": { + "dimensions": ["chat"] + } +} +``` + +In this example: + +- most traffic uses one shared context per chat +- the support group uses one context per user inside that chat + +## Identity Links + +`session.identity_links` helps when the same user may appear under multiple raw sender IDs and you want PicoClaw to treat them as one sender identity. + +Example: + +```json +{ + "session": { + "dimensions": ["chat", "sender"], + "identity_links": { + "john": ["slack:u123", "u123", "legacy-user-42"] + } + } +} +``` + +This is mainly useful for: + +- migrated sender IDs +- platform-specific ID aliases +- cleanup after changing channel adapters or account naming + +Current limitation: + +- `identity_links` does not make one user share memory across different channels automatically +- channel and account remain part of the baseline session scope + +## Troubleshooting + +### Users in one group are sharing memory + +Your current session is probably keyed only by `chat`. +Switch to: + +```json +{ + "session": { + "dimensions": ["chat", "sender"] + } +} +``` + +### The same user does not share memory across Slack and Telegram + +That is expected. +PicoClaw still separates sessions by channel even if you use `sender`. + +### Threads are mixing together + +Add `topic` when the channel provides one: + +```json +{ + "session": { + "dimensions": ["chat", "topic"] + } +} +``` + +### Old sessions seem to use legacy keys + +That is normal during migration. +PicoClaw keeps compatibility with older `agent:...` session keys while moving runtime storage to opaque canonical keys. + +## Related Guides + +- [Configuration Guide](configuration.md) +- [Routing Guide](routing-guide.md) +- [Providers & Model Configuration](providers.md) diff --git a/docs/guides/session-guide.zh.md b/docs/guides/session-guide.zh.md new file mode 100644 index 000000000..679a7f68d --- /dev/null +++ b/docs/guides/session-guide.zh.md @@ -0,0 +1,273 @@ +# Session 使用指南 + +> 返回 [README](../project/README.zh.md) + +PicoClaw 的 Session 决定了哪些消息会共享同一段对话历史。 +如果你的 bot 表现为“记得太多”或“忘得太快”,首先就该检查 session 配置。 + +这份文档面向编辑 `config.json` 的普通用户。 +如果你想看内部实现细节,请看 architecture 文档,而不是这里。 + +## Session 控制什么 + +一个 session 会影响: + +- Agent 能看到哪些历史消息 +- 这段对话何时开始触发摘要 +- 同一个群里的不同用户是否共享上下文 +- 不同聊天、不同线程、不同空间是否保持隔离 + +Session 数据保存在工作区目录下,通常是: + +```text +~/.picoclaw/workspace/sessions/ +``` + +## 快速开始 + +### 默认:每个 chat 一段上下文 + +这是默认值,也是大多数 bot 的正确起点。 + +```json +{ + "session": { + "dimensions": ["chat"] + } +} +``` + +适用场景: + +- 每个群 / 频道都有自己的共享记忆 +- 每个私聊都有各自独立的记忆 + +### 在同一个群里按用户分开 + +如果同一个群里的不同用户不应该共享上下文,增加 `sender`: + +```json +{ + "session": { + "dimensions": ["chat", "sender"] + } +} +``` + +适用场景: + +- 一个群里挂着一个共享 assistant,但不希望用户之间串上下文 +- 希望每个用户在同一个房间里保留自己的独立记忆 + +### 在同一个 workspace / guild 下跨多个房间共享上下文 + +如果你的 channel 会提供 `space`,可以按 workspace 或 guild 共享,而不是按单个房间共享: + +```json +{ + "session": { + "dimensions": ["space"] + } +} +``` + +适用场景: + +- Slack workspace 里的 assistant 想跨多个 channel 共享上下文 +- Discord guild 里的 assistant 想跨多个 channel 共享上下文 + +### 按线程或论坛 topic 隔离 + +如果 channel 会提供 `topic`,可以显式按线程隔离: + +```json +{ + "session": { + "dimensions": ["chat", "topic"] + } +} +``` + +适用场景: + +- 每个论坛 topic 都要保留独立历史 +- 每个 threaded discussion 都不能串上下文 + +## 可用维度 + +| 维度 | 含义 | 适合什么场景 | +| --- | --- | --- | +| `space` | workspace、guild 或类似的上层容器 | 一个 assistant 跨多个房间共享上下文 | +| `chat` | 私聊、群聊或频道 | 默认按房间隔离 | +| `topic` | 线程、topic 或 forum 子通道 | 让 threaded discussion 保持隔离 | +| `sender` | 归一化后的消息发送者 | 在共享房间内按用户隔离 | + +并不是每个 channel 都会提供全部字段。 +如果某个 channel 没有 `space` 或 `topic`,对应维度对那条消息就不会生效。 + +## 关键行为 + +### Session 总是按 agent 分开 + +即使两个 agent 处理同一个 chat,它们也不会共享同一段 session。 + +### Session 仍然会按 channel 和 account 分开 + +`session.dimensions` 只是添加更细的隔离维度,PicoClaw 仍然保留一层基础隔离: + +- agent +- channel +- account + +这意味着即使 `dimensions` 为空,系统也**不会**把所有平台的消息都混成一个全局记忆。 + +### Telegram forum topic 在默认 `chat` 模式下也会保持隔离 + +Telegram forum 消息在默认 `chat` 模式下就会保留 topic 隔离。 +通常不需要额外为 Telegram forum 单独写 workaround。 + +### 摘要是按 session 触发的 + +`summarize_message_threshold` 和 `summarize_token_percent` 都是针对单个 session 生效。 +如果你把 session 切得更小,摘要也会按更小的历史范围触发。 + +## 常见配置方案 + +### 每个群 / 私聊共享一段上下文 + +```json +{ + "session": { + "dimensions": ["chat"] + } +} +``` + +### 每个 chat 内再按用户拆分 + +```json +{ + "session": { + "dimensions": ["chat", "sender"] + } +} +``` + +### 在同一个 workspace / guild 内按用户保留上下文 + +```json +{ + "session": { + "dimensions": ["space", "sender"] + } +} +``` + +这适合做 workspace 级 assistant:用户在同一个 workspace 里跨多个房间移动,但仍保留自己的上下文。 + +### 只给某个路由出来的 agent 覆盖 session 策略 + +你可以保留全局默认值,再在某条 dispatch rule 上单独覆盖: + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" } + ], + "dispatch": { + "rules": [ + { + "name": "support group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890" + }, + "session_dimensions": ["chat", "sender"] + } + ] + } + }, + "session": { + "dimensions": ["chat"] + } +} +``` + +在这个例子里: + +- 大部分流量仍然按 `chat` 共享上下文 +- 只有 support 群按 `chat + sender` 拆成每人一段上下文 + +## Identity Links + +`session.identity_links` 适合处理这种场景:同一个人可能会以多个原始 sender ID 出现,但你希望 PicoClaw 把它们视为同一个发送者身份。 + +示例: + +```json +{ + "session": { + "dimensions": ["chat", "sender"], + "identity_links": { + "john": ["slack:u123", "u123", "legacy-user-42"] + } + } +} +``` + +这主要适用于: + +- sender ID 迁移 +- 同一平台下的多个 ID 别名 +- 调整 channel adapter 或 account 命名后的兼容清理 + +当前限制: + +- `identity_links` 不会自动让同一个用户跨不同 channel 共享记忆 +- channel 和 account 仍然属于基础 session scope 的一部分 + +## 常见问题 + +### 同一个群里的用户在共享记忆 + +大概率是当前 session 只按 `chat` 建。 +改成: + +```json +{ + "session": { + "dimensions": ["chat", "sender"] + } +} +``` + +### 同一个用户在 Slack 和 Telegram 之间没有共享记忆 + +这是当前实现下的预期行为。 +即使使用了 `sender`,PicoClaw 仍然会按 channel 做基础隔离。 + +### 不同线程混在一起了 + +如果这个 channel 提供 `topic`,加上它: + +```json +{ + "session": { + "dimensions": ["chat", "topic"] + } +} +``` + +### 升级后看到旧的 session key + +这属于正常兼容行为。 +PicoClaw 在迁移到新的 opaque canonical key 时,仍会兼容旧的 `agent:...` session key。 + +## 相关文档 + +- [配置指南](configuration.zh.md) +- [路由指南](routing-guide.zh.md) +- [Provider 与模型配置](providers.zh.md) diff --git a/docs/fr/spawn-tasks.md b/docs/guides/spawn-tasks.fr.md similarity index 97% rename from docs/fr/spawn-tasks.md rename to docs/guides/spawn-tasks.fr.md index 5635cd645..40a7a3ded 100644 --- a/docs/fr/spawn-tasks.md +++ b/docs/guides/spawn-tasks.fr.md @@ -1,6 +1,6 @@ # 🔄 Tâches Asynchrones et Spawn -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) ## Tâches Rapides (réponse directe) diff --git a/docs/ja/spawn-tasks.md b/docs/guides/spawn-tasks.ja.md similarity index 98% rename from docs/ja/spawn-tasks.md rename to docs/guides/spawn-tasks.ja.md index a13aab9eb..598654242 100644 --- a/docs/ja/spawn-tasks.md +++ b/docs/guides/spawn-tasks.ja.md @@ -1,6 +1,6 @@ # 🔄 非同期タスクと Spawn -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る ### Spawn を使用した非同期タスク diff --git a/docs/spawn-tasks.md b/docs/guides/spawn-tasks.md similarity index 100% rename from docs/spawn-tasks.md rename to docs/guides/spawn-tasks.md diff --git a/docs/my/spawn-tasks.md b/docs/guides/spawn-tasks.ms.md similarity index 97% rename from docs/my/spawn-tasks.md rename to docs/guides/spawn-tasks.ms.md index c0c3e8f92..055ebf20d 100644 --- a/docs/my/spawn-tasks.md +++ b/docs/guides/spawn-tasks.ms.md @@ -1,6 +1,6 @@ # 🔄 Spawn & Tugasan Async -> Kembali ke [README](../../README.my.md) +> Kembali ke [README](../project/README.ms.md) ## Tugasan Cepat (balas terus) diff --git a/docs/pt-br/spawn-tasks.md b/docs/guides/spawn-tasks.pt-br.md similarity index 97% rename from docs/pt-br/spawn-tasks.md rename to docs/guides/spawn-tasks.pt-br.md index d6b539cb1..0de929821 100644 --- a/docs/pt-br/spawn-tasks.md +++ b/docs/guides/spawn-tasks.pt-br.md @@ -1,6 +1,6 @@ # 🔄 Tarefas Assíncronas e Spawn -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) ## Tarefas Rápidas (resposta direta) diff --git a/docs/vi/spawn-tasks.md b/docs/guides/spawn-tasks.vi.md similarity index 97% rename from docs/vi/spawn-tasks.md rename to docs/guides/spawn-tasks.vi.md index 78f728040..e8533750b 100644 --- a/docs/vi/spawn-tasks.md +++ b/docs/guides/spawn-tasks.vi.md @@ -1,6 +1,6 @@ # 🔄 Tác Vụ Bất Đồng Bộ và Spawn -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) ## Tác Vụ Nhanh (phản hồi trực tiếp) diff --git a/docs/zh/spawn-tasks.md b/docs/guides/spawn-tasks.zh.md similarity index 98% rename from docs/zh/spawn-tasks.md rename to docs/guides/spawn-tasks.zh.md index 781462af2..ee5f1580e 100644 --- a/docs/zh/spawn-tasks.md +++ b/docs/guides/spawn-tasks.zh.md @@ -1,6 +1,6 @@ # 🔄 异步任务与 Spawn -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) PicoClaw 通过 `spawn` 工具支持**异步任务执行**。主要由 **Heartbeat(心跳)** 系统使用,在不阻塞主 Agent 循环的情况下运行耗时任务。 diff --git a/docs/migration/README.md b/docs/migration/README.md new file mode 100644 index 000000000..eb37eec20 --- /dev/null +++ b/docs/migration/README.md @@ -0,0 +1,5 @@ +# Migration + +Migration notes for major configuration and behavior changes across PicoClaw versions. + +- [Migration Guide: From `providers` to `model_list`](model-list-migration.md): update legacy provider config to the current `model_list` format. diff --git a/docs/operations/README.md b/docs/operations/README.md new file mode 100644 index 000000000..b775ca3d9 --- /dev/null +++ b/docs/operations/README.md @@ -0,0 +1,6 @@ +# Operations + +Operational docs for debugging, diagnosis, and production troubleshooting. + +- [Troubleshooting](troubleshooting.md): common failures, symptoms, and recovery steps. +- [Debugging PicoClaw](debug.md): logs, runtime visibility, and debugging workflow. diff --git a/docs/fr/debug.md b/docs/operations/debug.fr.md similarity index 97% rename from docs/fr/debug.md rename to docs/operations/debug.fr.md index 5753ccf8c..331f7c4ba 100644 --- a/docs/fr/debug.md +++ b/docs/operations/debug.fr.md @@ -1,6 +1,6 @@ # Débogage de PicoClaw -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) PicoClaw effectue de multiples interactions complexes en arrière-plan pour chaque requête qu'il reçoit — du routage des messages et de l'évaluation de la complexité, à l'exécution des outils et à l'adaptation aux défaillances de modèle. Pouvoir voir exactement ce qui se passe est crucial, non seulement pour résoudre les problèmes potentiels, mais aussi pour véritablement comprendre le fonctionnement de l'agent. diff --git a/docs/ja/debug.md b/docs/operations/debug.ja.md similarity index 97% rename from docs/ja/debug.md rename to docs/operations/debug.ja.md index ecc52f454..5b3365bf8 100644 --- a/docs/ja/debug.md +++ b/docs/operations/debug.ja.md @@ -1,6 +1,6 @@ # PicoClaw のデバッグ -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る PicoClaw は、受信するすべてのリクエストに対して、メッセージのルーティングや複雑度の評価、ツールの実行、モデル障害への適応など、多くの複雑な処理をバックグラウンドで実行しています。何が起きているかを正確に把握できることは、潜在的な問題のトラブルシューティングだけでなく、エージェントの動作を真に理解するためにも非常に重要です。 diff --git a/docs/debug.md b/docs/operations/debug.md similarity index 100% rename from docs/debug.md rename to docs/operations/debug.md diff --git a/docs/my/debug.md b/docs/operations/debug.ms.md similarity index 100% rename from docs/my/debug.md rename to docs/operations/debug.ms.md diff --git a/docs/pt-br/debug.md b/docs/operations/debug.pt-br.md similarity index 97% rename from docs/pt-br/debug.md rename to docs/operations/debug.pt-br.md index 8614cd5ed..655385840 100644 --- a/docs/pt-br/debug.md +++ b/docs/operations/debug.pt-br.md @@ -1,6 +1,6 @@ # Depuração do PicoClaw -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) O PicoClaw realiza múltiplas interações complexas nos bastidores para cada requisição que recebe — desde o roteamento de mensagens e avaliação de complexidade, até a execução de ferramentas e adaptação a falhas de modelo. Poder ver exatamente o que está acontecendo é crucial, não apenas para solucionar problemas potenciais, mas também para realmente entender como o agente opera. diff --git a/docs/vi/debug.md b/docs/operations/debug.vi.md similarity index 97% rename from docs/vi/debug.md rename to docs/operations/debug.vi.md index 69583d486..76d555648 100644 --- a/docs/vi/debug.md +++ b/docs/operations/debug.vi.md @@ -1,6 +1,6 @@ # Gỡ lỗi PicoClaw -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) PicoClaw thực hiện nhiều tương tác phức tạp ở hậu trường cho mỗi yêu cầu nhận được — từ định tuyến tin nhắn và đánh giá độ phức tạp, đến thực thi công cụ và thích ứng với lỗi mô hình. Khả năng xem chính xác những gì đang xảy ra là rất quan trọng, không chỉ để khắc phục các sự cố tiềm ẩn, mà còn để thực sự hiểu cách agent hoạt động. diff --git a/docs/zh/debug.md b/docs/operations/debug.zh.md similarity index 97% rename from docs/zh/debug.md rename to docs/operations/debug.zh.md index e7f20d777..8e544c03b 100644 --- a/docs/zh/debug.md +++ b/docs/operations/debug.zh.md @@ -1,6 +1,6 @@ # 调试 PicoClaw -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) PicoClaw 在处理每一个请求时,都会在后台执行多个复杂的交互操作——从消息路由和复杂度评估,到工具执行和模型故障适配。能够准确地看到正在发生什么至关重要,这不仅有助于排查潜在问题,也有助于真正理解代理的运作方式。 diff --git a/docs/fr/troubleshooting.md b/docs/operations/troubleshooting.fr.md similarity index 97% rename from docs/fr/troubleshooting.md rename to docs/operations/troubleshooting.fr.md index d2d099ad3..630f69627 100644 --- a/docs/fr/troubleshooting.md +++ b/docs/operations/troubleshooting.fr.md @@ -1,6 +1,6 @@ # 🐛 Dépannage -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) ## "model ... not found in model_list" ou OpenRouter "free is not a valid model ID" diff --git a/docs/ja/troubleshooting.md b/docs/operations/troubleshooting.ja.md similarity index 97% rename from docs/ja/troubleshooting.md rename to docs/operations/troubleshooting.ja.md index f18b456db..f1d244c92 100644 --- a/docs/ja/troubleshooting.md +++ b/docs/operations/troubleshooting.ja.md @@ -1,6 +1,6 @@ # 🐛 トラブルシューティング -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る ## "model ... not found in model_list" または OpenRouter "free is not a valid model ID" diff --git a/docs/troubleshooting.md b/docs/operations/troubleshooting.md similarity index 100% rename from docs/troubleshooting.md rename to docs/operations/troubleshooting.md diff --git a/docs/my/troubleshooting.md b/docs/operations/troubleshooting.ms.md similarity index 100% rename from docs/my/troubleshooting.md rename to docs/operations/troubleshooting.ms.md diff --git a/docs/pt-br/troubleshooting.md b/docs/operations/troubleshooting.pt-br.md similarity index 96% rename from docs/pt-br/troubleshooting.md rename to docs/operations/troubleshooting.pt-br.md index 286ad2ac8..eec64d9d8 100644 --- a/docs/pt-br/troubleshooting.md +++ b/docs/operations/troubleshooting.pt-br.md @@ -1,6 +1,6 @@ # 🐛 Solução de Problemas -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) ## "model ... not found in model_list" ou OpenRouter "free is not a valid model ID" diff --git a/docs/vi/troubleshooting.md b/docs/operations/troubleshooting.vi.md similarity index 97% rename from docs/vi/troubleshooting.md rename to docs/operations/troubleshooting.vi.md index 961c932aa..8aa5e2ae4 100644 --- a/docs/vi/troubleshooting.md +++ b/docs/operations/troubleshooting.vi.md @@ -1,6 +1,6 @@ # 🐛 Khắc Phục Sự Cố -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) ## "model ... not found in model_list" hoặc OpenRouter "free is not a valid model ID" diff --git a/docs/zh/troubleshooting.md b/docs/operations/troubleshooting.zh.md similarity index 97% rename from docs/zh/troubleshooting.md rename to docs/operations/troubleshooting.zh.md index be4d4f5d7..fd519a8b2 100644 --- a/docs/zh/troubleshooting.md +++ b/docs/operations/troubleshooting.zh.md @@ -1,6 +1,6 @@ # 🐛 疑难解答 -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) ## "model ... not found in model_list" 或 OpenRouter "free is not a valid model ID" diff --git a/CONTRIBUTING.zh.md b/docs/project/CONTRIBUTING.zh.md similarity index 100% rename from CONTRIBUTING.zh.md rename to docs/project/CONTRIBUTING.zh.md diff --git a/README.fr.md b/docs/project/README.fr.md similarity index 82% rename from README.fr.md rename to docs/project/README.fr.md index 8fa67fa02..1e2f59bee 100644 --- a/README.fr.md +++ b/docs/project/README.fr.md @@ -1,5 +1,5 @@
- PicoClaw + PicoClaw

PicoClaw : Assistant IA Ultra-Efficace en Go

@@ -14,11 +14,11 @@ Wiki
Twitter - + Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md) +[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
@@ -35,12 +35,12 @@

- +

- +

@@ -72,7 +72,7 @@ 2026-02-26 🎉 PicoClaw atteint **20K Stars** en seulement 17 jours ! L'orchestration automatique des channels et les interfaces de capacités sont disponibles. -2026-02-16 🎉 PicoClaw dépasse 12K Stars en une semaine ! Rôles de mainteneurs communautaires et [Roadmap](ROADMAP.md) officiellement lancés. +2026-02-16 🎉 PicoClaw dépasse 12K Stars en une semaine ! Rôles de mainteneurs communautaires et [Roadmap](../../ROADMAP.md) officiellement lancés. 2026-02-13 🎉 PicoClaw dépasse 5000 Stars en 4 jours ! Roadmap du projet et groupes de développeurs en cours. @@ -110,14 +110,14 @@ _*Les builds récents peuvent utiliser 10-20 Mo en raison des fusions rapides de | **Temps de démarrage**
(cœur 0,8 GHz) | >500s | >30s | **<1s** | | **Coût** | Mac Mini $599 | La plupart des cartes Linux ~$50 | **N'importe quelle carte Linux**
**à partir de $10** | -PicoClaw +PicoClaw -> **[Liste de compatibilité matérielle](docs/fr/hardware-compatibility.md)** — Voir toutes les cartes testées, du RISC-V à $5 au Raspberry Pi en passant par les téléphones Android. Votre carte n'est pas listée ? Soumettez une PR ! +> **[Liste de compatibilité matérielle](../guides/hardware-compatibility.fr.md)** — Voir toutes les cartes testées, du RISC-V à $5 au Raspberry Pi en passant par les téléphones Android. Votre carte n'est pas listée ? Soumettez une PR !

-PicoClaw Hardware Compatibility +PicoClaw Hardware Compatibility

## 🦾 Démonstration @@ -131,9 +131,9 @@ _*Les builds récents peuvent utiliser 10-20 Mo en raison des fusions rapides de

Recherche Web & Apprentissage

-

-

-

+

+

+

Développer · Déployer · Mettre à l'échelle @@ -223,7 +223,7 @@ picoclaw-launcher > ```

-WebUI Launcher +WebUI Launcher

**Pour commencer :** @@ -277,7 +277,7 @@ macOS peut bloquer `picoclaw-launcher` au premier lancement car il est télécha **Étape 1 :** Double-cliquez sur `picoclaw-launcher`. Un avertissement de sécurité s'affiche :

-Avertissement macOS Gatekeeper +Avertissement macOS Gatekeeper

> *"picoclaw-launcher" n'a pas pu être ouvert — Apple n'a pas pu vérifier que "picoclaw-launcher" ne contient pas de logiciel malveillant susceptible de nuire à votre Mac ou de compromettre votre confidentialité.* @@ -285,7 +285,7 @@ macOS peut bloquer `picoclaw-launcher` au premier lancement car il est télécha **Étape 2 :** Ouvrez **Réglages Système** → **Confidentialité et sécurité** → faites défiler jusqu'à la section **Sécurité** → cliquez sur **Ouvrir quand même** → confirmez en cliquant sur **Ouvrir quand même** dans la boîte de dialogue.

-macOS Confidentialité et sécurité — Ouvrir quand même +macOS Confidentialité et sécurité — Ouvrir quand même

Après cette étape unique, `picoclaw-launcher` s'ouvrira normalement lors des lancements suivants. @@ -301,7 +301,7 @@ picoclaw-launcher-tui ```

-TUI Launcher +TUI Launcher

**Pour commencer :** @@ -310,6 +310,7 @@ Utilisez les menus TUI pour : **1)** Configurer un Provider -> **2)** Configurer Pour la documentation détaillée du TUI, voir [docs.picoclaw.io](https://docs.picoclaw.io). + ### 📱 Android Donnez une seconde vie à votre téléphone vieux de dix ans ! Transformez-le en assistant IA intelligent avec PicoClaw. @@ -320,10 +321,10 @@ Aperçu : - - - - + + + +
@@ -347,7 +348,7 @@ termux-chroot ./picoclaw onboard # chroot fournit une arborescence Linux stand Suivez ensuite la section Terminal Launcher ci-dessous pour terminer la configuration. -PicoClaw on Termux +PicoClaw on Termux Pour les environnements minimaux où seul le binaire principal `picoclaw` est disponible (sans Launcher UI), vous pouvez tout configurer via la ligne de commande et un fichier de configuration JSON. @@ -454,7 +455,7 @@ PicoClaw supporte plus de 30 providers LLM via la configuration `model_list`. Ut } ``` -Pour les détails complets de configuration des providers, voir [Providers & Models](docs/fr/providers.md). +Pour les détails complets de configuration des providers, voir [Providers & Models](../guides/providers.fr.md).
@@ -464,28 +465,28 @@ Parlez à votre PicoClaw via plus de 17 plateformes de messagerie : | Channel | Configuration | Protocole | Docs | |---------|---------------|-----------|------| -| **Telegram** | Facile (token bot) | Long polling | [Guide](docs/channels/telegram/README.fr.md) | -| **Discord** | Facile (token bot + intents) | WebSocket | [Guide](docs/channels/discord/README.fr.md) | -| **WhatsApp** | Facile (scan QR ou URL bridge) | Natif / Bridge | [Guide](docs/fr/chat-apps.md#whatsapp) | -| **Weixin** | Facile (scan QR natif) | iLink API | [Guide](docs/fr/chat-apps.md#weixin) | -| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.fr.md) | -| **Slack** | Facile (token bot + app) | Socket Mode | [Guide](docs/channels/slack/README.fr.md) | -| **Matrix** | Moyen (homeserver + token) | Sync API | [Guide](docs/channels/matrix/README.fr.md) | -| **DingTalk** | Moyen (identifiants client) | Stream | [Guide](docs/channels/dingtalk/README.fr.md) | -| **Feishu / Lark** | Moyen (App ID + Secret) | WebSocket/SDK | [Guide](docs/channels/feishu/README.fr.md) | -| **LINE** | Moyen (identifiants + webhook) | Webhook | [Guide](docs/channels/line/README.fr.md) | -| **WeCom** | Facile (QR login ou manuel) | WebSocket | [Guide](docs/channels/wecom/README.md) | -| **IRC** | Moyen (serveur + pseudo) | Protocole IRC | [Guide](docs/fr/chat-apps.md#irc) | -| **OneBot** | Moyen (URL WebSocket) | OneBot v11 | [Guide](docs/channels/onebot/README.fr.md) | -| **MaixCam** | Facile (activer) | Socket TCP | [Guide](docs/channels/maixcam/README.fr.md) | +| **Telegram** | Facile (token bot) | Long polling | [Guide](../channels/telegram/README.fr.md) | +| **Discord** | Facile (token bot + intents) | WebSocket | [Guide](../channels/discord/README.fr.md) | +| **WhatsApp** | Facile (scan QR ou URL bridge) | Natif / Bridge | [Guide](../guides/chat-apps.fr.md#whatsapp) | +| **Weixin** | Facile (scan QR natif) | iLink API | [Guide](../guides/chat-apps.fr.md#weixin) | +| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guide](../channels/qq/README.fr.md) | +| **Slack** | Facile (token bot + app) | Socket Mode | [Guide](../channels/slack/README.fr.md) | +| **Matrix** | Moyen (homeserver + token) | Sync API | [Guide](../channels/matrix/README.fr.md) | +| **DingTalk** | Moyen (identifiants client) | Stream | [Guide](../channels/dingtalk/README.fr.md) | +| **Feishu / Lark** | Moyen (App ID + Secret) | WebSocket/SDK | [Guide](../channels/feishu/README.fr.md) | +| **LINE** | Moyen (identifiants + webhook) | Webhook | [Guide](../channels/line/README.fr.md) | +| **WeCom** | Facile (QR login ou manuel) | WebSocket | [Guide](../channels/wecom/README.fr.md) | +| **IRC** | Moyen (serveur + pseudo) | Protocole IRC | [Guide](../guides/chat-apps.fr.md#irc) | +| **OneBot** | Moyen (URL WebSocket) | OneBot v11 | [Guide](../channels/onebot/README.fr.md) | +| **MaixCam** | Facile (activer) | Socket TCP | [Guide](../channels/maixcam/README.fr.md) | | **Pico** | Facile (activer) | Protocole natif | Intégré | | **Pico Client** | Facile (URL WebSocket) | WebSocket | Intégré | > Tous les channels basés sur webhook partagent un seul serveur HTTP Gateway (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). Feishu utilise le mode WebSocket/SDK et n'utilise pas le serveur HTTP partagé. -> La verbosité des logs est contrôlée par `gateway.log_level` (par défaut : `warn`). Valeurs supportées : `debug`, `info`, `warn`, `error`, `fatal`. Peut aussi être défini via `PICOCLAW_LOG_LEVEL`. Voir [Configuration](docs/fr/configuration.md#niveau-de-log-du-gateway) pour plus de détails. +> La verbosité des logs est contrôlée par `gateway.log_level` (par défaut : `warn`). Valeurs supportées : `debug`, `info`, `warn`, `error`, `fatal`. Peut aussi être défini via `PICOCLAW_LOG_LEVEL`. Voir [Configuration](../guides/configuration.fr.md#niveau-de-log-du-gateway) pour plus de détails. -Pour les instructions détaillées de configuration des channels, voir [Configuration des applications de chat](docs/fr/chat-apps.md). +Pour les instructions détaillées de configuration des channels, voir [Configuration des applications de chat](../guides/chat-apps.fr.md). ## 🔧 Outils @@ -505,7 +506,7 @@ PicoClaw peut effectuer des recherches sur le web pour fournir des informations ### ⚙️ Autres outils -PicoClaw inclut des outils intégrés pour les opérations sur fichiers, l'exécution de code, la planification et plus encore. Voir [Configuration des outils](docs/fr/tools_configuration.md) pour les détails. +PicoClaw inclut des outils intégrés pour les opérations sur fichiers, l'exécution de code, la planification et plus encore. Voir [Configuration des outils](../reference/tools_configuration.fr.md) pour les détails. ## 🎯 Skills @@ -535,7 +536,7 @@ Ajoutez à votre `config.json` : } ``` -Pour plus de détails, voir [Configuration des outils - Skills](docs/fr/tools_configuration.md#skills-tool). +Pour plus de détails, voir [Configuration des outils - Skills](../reference/tools_configuration.fr.md#skills-tool). ## 🔗 MCP (Model Context Protocol) @@ -558,9 +559,9 @@ PicoClaw supporte nativement [MCP](https://modelcontextprotocol.io/) — connect } ``` -Pour la configuration MCP complète (transports stdio, SSE, HTTP, Tool Discovery), voir [Configuration des outils - MCP](docs/fr/tools_configuration.md#mcp-tool). +Pour la configuration MCP complète (transports stdio, SSE, HTTP, Tool Discovery), voir [Configuration des outils - MCP](../reference/tools_configuration.fr.md#mcp-tool). -## ClawdChat Rejoignez le réseau social des Agents +## ClawdChat Rejoignez le réseau social des Agents Connectez PicoClaw au réseau social des Agents simplement en envoyant un seul message via le CLI ou n'importe quelle application de chat intégrée. @@ -601,23 +602,23 @@ Pour des guides détaillés au-delà de ce README : | Sujet | Description | |-------|-------------| -| [Docker & Démarrage rapide](docs/fr/docker.md) | Configuration Docker Compose, modes Launcher/Agent | -| [Applications de chat](docs/fr/chat-apps.md) | Guides de configuration pour les 17+ channels | -| [Configuration](docs/fr/configuration.md) | Variables d'environnement, structure du workspace, sandbox de sécurité | -| [Providers & Modèles](docs/fr/providers.md) | 30+ providers LLM, routage de modèles, configuration model_list | -| [Spawn & Tâches asynchrones](docs/fr/spawn-tasks.md) | Tâches rapides, tâches longues avec spawn, orchestration de sous-agents asynchrones | -| [Hooks](docs/hooks/README.md) | Système de hooks événementiels : observateurs, intercepteurs, hooks d'approbation | -| [Steering](docs/steering.md) | Injecter des messages dans une boucle agent en cours d'exécution | -| [SubTurn](docs/subturn.md) | Coordination de subagents, contrôle de concurrence, cycle de vie | -| [Dépannage](docs/fr/troubleshooting.md) | Problèmes courants et solutions | -| [Configuration des outils](docs/fr/tools_configuration.md) | Activation/désactivation par outil, politiques d'exécution, MCP, Skills | -| [Compatibilité matérielle](docs/fr/hardware-compatibility.md) | Cartes testées, exigences minimales | +| [Docker & Démarrage rapide](../guides/docker.fr.md) | Configuration Docker Compose, modes Launcher/Agent | +| [Applications de chat](../guides/chat-apps.fr.md) | Guides de configuration pour les 17+ channels | +| [Configuration](../guides/configuration.fr.md) | Variables d'environnement, structure du workspace, sandbox de sécurité | +| [Providers & Modèles](../guides/providers.fr.md) | 30+ providers LLM, routage de modèles, configuration model_list | +| [Spawn & Tâches asynchrones](../guides/spawn-tasks.fr.md) | Tâches rapides, tâches longues avec spawn, orchestration de sous-agents asynchrones | +| [Hooks](../architecture/hooks/README.md) | Système de hooks événementiels : observateurs, intercepteurs, hooks d'approbation | +| [Steering](../architecture/steering.md) | Injecter des messages dans une boucle agent en cours d'exécution | +| [SubTurn](../architecture/subturn.md) | Coordination de subagents, contrôle de concurrence, cycle de vie | +| [Dépannage](../operations/troubleshooting.fr.md) | Problèmes courants et solutions | +| [Configuration des outils](../reference/tools_configuration.fr.md) | Activation/désactivation par outil, politiques d'exécution, MCP, Skills | +| [Compatibilité matérielle](../guides/hardware-compatibility.fr.md) | Cartes testées, exigences minimales | ## 🤝 Contribuer & Roadmap Les PRs sont les bienvenues ! Le code source est intentionnellement petit et lisible. -Consultez notre [Roadmap communautaire](https://github.com/sipeed/picoclaw/issues/988) et [CONTRIBUTING.md](CONTRIBUTING.md) pour les directives. +Consultez notre [Roadmap communautaire](https://github.com/sipeed/picoclaw/issues/988) et [CONTRIBUTING.md](../../CONTRIBUTING.md) pour les directives. Groupe de développeurs en construction, rejoignez-le après votre première PR fusionnée ! @@ -626,4 +627,4 @@ Groupes d'utilisateurs : Discord : WeChat : -WeChat group QR code +WeChat group QR code diff --git a/README.id.md b/docs/project/README.id.md similarity index 83% rename from README.id.md rename to docs/project/README.id.md index 525d4dc72..244e6e49a 100644 --- a/README.id.md +++ b/docs/project/README.id.md @@ -1,5 +1,5 @@
-PicoClaw +PicoClaw

PicoClaw: Asisten AI Super Ringan berbasis Go

@@ -14,11 +14,11 @@ Wiki
Twitter - + Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | **Bahasa Indonesia** | [Malay](README.my.md) | [English](README.md) +[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | **Bahasa Indonesia** | [Malay](README.ms.md) | [English](../../README.md)
@@ -34,12 +34,12 @@

- +

- +

@@ -71,7 +71,7 @@ 2026-02-26 🎉 PicoClaw mencapai **20K Stars** hanya dalam 17 hari! Orkestrasi channel otomatis dan antarmuka kapabilitas kini aktif. -2026-02-16 🎉 PicoClaw menembus 12K Stars dalam satu minggu! Peran maintainer komunitas dan [Roadmap](ROADMAP.md) resmi diluncurkan. +2026-02-16 🎉 PicoClaw menembus 12K Stars dalam satu minggu! Peran maintainer komunitas dan [Roadmap](../../ROADMAP.md) resmi diluncurkan. 2026-02-13 🎉 PicoClaw menembus 5000 Stars dalam 4 hari! Roadmap proyek dan grup pengembang sedang dalam proses. @@ -108,14 +108,14 @@ _*Build terbaru mungkin menggunakan 10-20MB karena penggabungan PR yang cepat. O | **Waktu Boot**
(core 0,8GHz) | >500d | >30d | **<1d** | | **Biaya** | Mac Mini $599 | Kebanyakan board Linux ~$50 | **Board Linux mana pun**
**mulai $10** | -PicoClaw +PicoClaw -> **[Daftar Kompatibilitas Hardware](docs/hardware-compatibility.md)** — Lihat semua board yang telah diuji, dari RISC-V $5 hingga Raspberry Pi hingga ponsel Android. Board Anda belum terdaftar? Kirim PR! +> **[Daftar Kompatibilitas Hardware](../guides/hardware-compatibility.md)** — Lihat semua board yang telah diuji, dari RISC-V $5 hingga Raspberry Pi hingga ponsel Android. Board Anda belum terdaftar? Kirim PR!

-PicoClaw Hardware Compatibility +PicoClaw Hardware Compatibility

## 🦾 Demonstrasi @@ -129,9 +129,9 @@ _*Build terbaru mungkin menggunakan 10-20MB karena penggabungan PR yang cepat. O

Pencarian Web & Pembelajaran

-

-

-

+

+

+

Develop · Deploy · Scale @@ -220,7 +220,7 @@ picoclaw-launcher > ```

-WebUI Launcher +WebUI Launcher

**Memulai:** @@ -274,7 +274,7 @@ macOS mungkin memblokir `picoclaw-launcher` saat pertama kali diluncurkan karena **Langkah 1:** Klik dua kali `picoclaw-launcher`. Anda akan melihat peringatan keamanan:

-Peringatan macOS Gatekeeper +Peringatan macOS Gatekeeper

> *"picoclaw-launcher" Tidak Dapat Dibuka — Apple tidak dapat memverifikasi bahwa "picoclaw-launcher" bebas dari malware yang dapat membahayakan Mac Anda atau mengancam privasi Anda.* @@ -282,7 +282,7 @@ macOS mungkin memblokir `picoclaw-launcher` saat pertama kali diluncurkan karena **Langkah 2:** Buka **Pengaturan Sistem** → **Privasi & Keamanan** → gulir ke bawah ke bagian **Keamanan** → klik **Tetap Buka** → konfirmasi dengan mengklik **Tetap Buka** pada dialog.

-macOS Privasi & Keamanan — Tetap Buka +macOS Privasi & Keamanan — Tetap Buka

Setelah langkah satu kali ini, `picoclaw-launcher` akan terbuka secara normal pada peluncuran berikutnya. @@ -298,7 +298,7 @@ picoclaw-launcher-tui ```

-TUI Launcher +TUI Launcher

**Memulai:** @@ -317,10 +317,10 @@ Pratinjau: - - - - + + + +
@@ -344,7 +344,7 @@ termux-chroot ./picoclaw onboard # chroot menyediakan tata letak filesystem Li Kemudian ikuti bagian Terminal Launcher di bawah untuk menyelesaikan konfigurasi. -PicoClaw on Termux +PicoClaw on Termux Untuk lingkungan minimal di mana hanya binary inti `picoclaw` yang tersedia (tanpa Launcher UI), Anda dapat mengonfigurasi semuanya melalui command line dan file konfigurasi JSON. @@ -450,7 +450,7 @@ PicoClaw mendukung 30+ provider LLM melalui konfigurasi `model_list`. Gunakan fo } ``` -Untuk detail konfigurasi provider lengkap, lihat [Providers & Models](docs/providers.md). +Untuk detail konfigurasi provider lengkap, lihat [Providers & Models](../guides/providers.md).
@@ -460,28 +460,28 @@ Bicara dengan PicoClaw Anda melalui 17+ platform pesan: | Channel | Pengaturan | Protocol | Dokumentasi | |---------|------------|----------|-------------| -| **Telegram** | Mudah (bot token) | Long polling | [Panduan](docs/channels/telegram/README.md) | -| **Discord** | Mudah (bot token + intents) | WebSocket | [Panduan](docs/channels/discord/README.md) | -| **WhatsApp** | Mudah (scan QR atau bridge URL) | Native / Bridge | [Panduan](docs/chat-apps.md#whatsapp) | -| **Weixin** | Mudah (scan QR native) | iLink API | [Panduan](docs/chat-apps.md#weixin) | -| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](docs/channels/qq/README.md) | -| **Slack** | Mudah (bot + app token) | Socket Mode | [Panduan](docs/channels/slack/README.md) | -| **Matrix** | Sedang (homeserver + token) | Sync API | [Panduan](docs/channels/matrix/README.md) | -| **DingTalk** | Sedang (client credentials) | Stream | [Panduan](docs/channels/dingtalk/README.md) | -| **Feishu / Lark** | Sedang (App ID + Secret) | WebSocket/SDK | [Panduan](docs/channels/feishu/README.md) | -| **LINE** | Sedang (credentials + webhook) | Webhook | [Panduan](docs/channels/line/README.md) | -| **WeCom** | Mudah (login QR atau manual) | WebSocket | [Panduan](docs/channels/wecom/README.md) | -| **IRC** | Sedang (server + nick) | IRC protocol | [Panduan](docs/chat-apps.md#irc) | -| **OneBot** | Sedang (WebSocket URL) | OneBot v11 | [Panduan](docs/channels/onebot/README.md) | -| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](docs/channels/maixcam/README.md) | +| **Telegram** | Mudah (bot token) | Long polling | [Panduan](../channels/telegram/README.md) | +| **Discord** | Mudah (bot token + intents) | WebSocket | [Panduan](../channels/discord/README.md) | +| **WhatsApp** | Mudah (scan QR atau bridge URL) | Native / Bridge | [Panduan](../guides/chat-apps.md#whatsapp) | +| **Weixin** | Mudah (scan QR native) | iLink API | [Panduan](../guides/chat-apps.md#weixin) | +| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](../channels/qq/README.md) | +| **Slack** | Mudah (bot + app token) | Socket Mode | [Panduan](../channels/slack/README.md) | +| **Matrix** | Sedang (homeserver + token) | Sync API | [Panduan](../channels/matrix/README.md) | +| **DingTalk** | Sedang (client credentials) | Stream | [Panduan](../channels/dingtalk/README.md) | +| **Feishu / Lark** | Sedang (App ID + Secret) | WebSocket/SDK | [Panduan](../channels/feishu/README.md) | +| **LINE** | Sedang (credentials + webhook) | Webhook | [Panduan](../channels/line/README.md) | +| **WeCom** | Mudah (login QR atau manual) | WebSocket | [Panduan](../channels/wecom/README.md) | +| **IRC** | Sedang (server + nick) | IRC protocol | [Panduan](../guides/chat-apps.md#irc) | +| **OneBot** | Sedang (WebSocket URL) | OneBot v11 | [Panduan](../channels/onebot/README.md) | +| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](../channels/maixcam/README.md) | | **Pico** | Mudah (aktifkan) | Native protocol | Bawaan | | **Pico Client** | Mudah (WebSocket URL) | WebSocket | Bawaan | > Semua channel berbasis webhook berbagi satu server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu menggunakan mode WebSocket/SDK dan tidak menggunakan server HTTP bersama. -> Verbositas log dikontrol oleh `gateway.log_level` (default: `warn`). Nilai yang didukung: `debug`, `info`, `warn`, `error`, `fatal`. Juga dapat diatur melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](docs/configuration.md#gateway-log-level) untuk detail. +> Verbositas log dikontrol oleh `gateway.log_level` (default: `warn`). Nilai yang didukung: `debug`, `info`, `warn`, `error`, `fatal`. Juga dapat diatur melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](../guides/configuration.md#gateway-log-level) untuk detail. -Untuk instruksi pengaturan channel lengkap, lihat [Konfigurasi Aplikasi Chat](docs/chat-apps.md). +Untuk instruksi pengaturan channel lengkap, lihat [Konfigurasi Aplikasi Chat](../guides/chat-apps.md). ## 🔧 Tools @@ -501,7 +501,7 @@ PicoClaw dapat mencari web untuk memberikan informasi terkini. Konfigurasi di `t ### ⚙️ Tools Lainnya -PicoClaw menyertakan tools bawaan untuk operasi file, eksekusi kode, penjadwalan, dan lainnya. Lihat [Konfigurasi Tools](docs/tools_configuration.md) untuk detail. +PicoClaw menyertakan tools bawaan untuk operasi file, eksekusi kode, penjadwalan, dan lainnya. Lihat [Konfigurasi Tools](../reference/tools_configuration.md) untuk detail. ## 🎯 Skills @@ -531,7 +531,7 @@ Tambahkan ke `config.json` Anda: } ``` -Untuk detail lebih lanjut, lihat [Konfigurasi Tools - Skills](docs/tools_configuration.md#skills-tool). +Untuk detail lebih lanjut, lihat [Konfigurasi Tools - Skills](../reference/tools_configuration.md#skills-tool). ## 🔗 MCP (Model Context Protocol) @@ -554,9 +554,9 @@ PicoClaw mendukung [MCP](https://modelcontextprotocol.io/) secara native — hub } ``` -Untuk konfigurasi MCP lengkap (transport stdio, SSE, HTTP, Tool Discovery), lihat [Konfigurasi Tools - MCP](docs/tools_configuration.md#mcp-tool). +Untuk konfigurasi MCP lengkap (transport stdio, SSE, HTTP, Tool Discovery), lihat [Konfigurasi Tools - MCP](../reference/tools_configuration.md#mcp-tool). -## ClawdChat Bergabung dengan Jaringan Sosial Agent +## ClawdChat Bergabung dengan Jaringan Sosial Agent Hubungkan PicoClaw ke Jaringan Sosial Agent hanya dengan mengirim satu pesan melalui CLI atau Aplikasi Chat terintegrasi mana pun. @@ -597,23 +597,23 @@ Untuk panduan lengkap di luar README ini: | Topik | Deskripsi | |-------|-----------| -| [Docker & Panduan Cepat](docs/docker.md) | Pengaturan Docker Compose, mode Launcher/Agent | -| [Aplikasi Chat](docs/chat-apps.md) | Semua 17+ panduan pengaturan channel | -| [Konfigurasi](docs/configuration.md) | Variabel environment, tata letak workspace, sandbox keamanan | -| [Providers & Models](docs/providers.md) | 30+ provider LLM, routing model, konfigurasi model_list | -| [Spawn & Tugas Async](docs/spawn-tasks.md) | Tugas cepat, tugas panjang dengan spawn, orkestrasi sub-agent async | -| [Hooks](docs/hooks/README.md) | Sistem hook berbasis event: observer, interceptor, approval hook | -| [Steering](docs/steering.md) | Menyuntikkan pesan ke dalam loop agent yang sedang berjalan | -| [SubTurn](docs/subturn.md) | Koordinasi subagent, kontrol konkurensi, siklus hidup | -| [Pemecahan Masalah](docs/troubleshooting.md) | Masalah umum dan solusinya | -| [Konfigurasi Tools](docs/tools_configuration.md) | Aktifkan/nonaktifkan per-tool, kebijakan exec, MCP, Skills | -| [Kompatibilitas Hardware](docs/hardware-compatibility.md) | Board yang telah diuji, persyaratan minimum | +| [Docker & Panduan Cepat](../guides/docker.md) | Pengaturan Docker Compose, mode Launcher/Agent | +| [Aplikasi Chat](../guides/chat-apps.md) | Semua 17+ panduan pengaturan channel | +| [Konfigurasi](../guides/configuration.md) | Variabel environment, tata letak workspace, sandbox keamanan | +| [Providers & Models](../guides/providers.md) | 30+ provider LLM, routing model, konfigurasi model_list | +| [Spawn & Tugas Async](../guides/spawn-tasks.md) | Tugas cepat, tugas panjang dengan spawn, orkestrasi sub-agent async | +| [Hooks](../architecture/hooks/README.md) | Sistem hook berbasis event: observer, interceptor, approval hook | +| [Steering](../architecture/steering.md) | Menyuntikkan pesan ke dalam loop agent yang sedang berjalan | +| [SubTurn](../architecture/subturn.md) | Koordinasi subagent, kontrol konkurensi, siklus hidup | +| [Pemecahan Masalah](../operations/troubleshooting.md) | Masalah umum dan solusinya | +| [Konfigurasi Tools](../reference/tools_configuration.md) | Aktifkan/nonaktifkan per-tool, kebijakan exec, MCP, Skills | +| [Kompatibilitas Hardware](../guides/hardware-compatibility.md) | Board yang telah diuji, persyaratan minimum | ## 🤝 Kontribusi & Roadmap PR sangat diterima! Codebase sengaja dibuat kecil dan mudah dibaca. -Lihat [Roadmap Komunitas](https://github.com/sipeed/picoclaw/issues/988) dan [CONTRIBUTING.md](CONTRIBUTING.md) untuk panduan. +Lihat [Roadmap Komunitas](https://github.com/sipeed/picoclaw/issues/988) dan [CONTRIBUTING.md](../../CONTRIBUTING.md) untuk panduan. Grup pengembang sedang dibangun, bergabunglah setelah PR pertama Anda di-merge! @@ -622,4 +622,4 @@ Grup Pengguna: Discord: WeChat: -Kode QR grup WeChat +Kode QR grup WeChat diff --git a/README.it.md b/docs/project/README.it.md similarity index 82% rename from README.it.md rename to docs/project/README.it.md index c560976cf..eb2f7c95b 100644 --- a/README.it.md +++ b/docs/project/README.it.md @@ -1,5 +1,5 @@
-PicoClaw +PicoClaw

PicoClaw: Assistente IA Ultra-Efficiente in Go

@@ -14,11 +14,11 @@ Wiki
Twitter - + Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md) +[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
@@ -34,12 +34,12 @@

- +

- +

@@ -71,7 +71,7 @@ 2026-02-26 🎉 PicoClaw raggiunge **20K stelle** in soli 17 giorni! Orchestrazione automatica dei canali e interfacce di capacità sono attive. -2026-02-16 🎉 PicoClaw supera 12K stelle in una settimana! Ruoli di maintainer della community e [Roadmap](ROADMAP.md) pubblicati ufficialmente. +2026-02-16 🎉 PicoClaw supera 12K stelle in una settimana! Ruoli di maintainer della community e [Roadmap](../../ROADMAP.md) pubblicati ufficialmente. 2026-02-13 🎉 PicoClaw supera 5000 stelle in 4 giorni! Roadmap del progetto e gruppi sviluppatori in fase di avvio. @@ -108,14 +108,14 @@ _*Le build recenti potrebbero usare 10-20MB a causa delle fusioni rapide di PR. | **Avvio**
(core 0,8 GHz) | >500s | >30s | **<1s** | | **Costo** | Mac Mini $599 | La maggior parte degli SBC Linux ~$50 | **Qualsiasi scheda Linux**
**a partire da $10** | -PicoClaw +PicoClaw -> **[Lista di Compatibilità Hardware](docs/hardware-compatibility.md)** — Vedi tutte le schede testate, dai $5 RISC-V al Raspberry Pi ai telefoni Android. La tua scheda non è elencata? Invia una PR! +> **[Lista di Compatibilità Hardware](../guides/hardware-compatibility.md)** — Vedi tutte le schede testate, dai $5 RISC-V al Raspberry Pi ai telefoni Android. La tua scheda non è elencata? Invia una PR!

-PicoClaw Hardware Compatibility +PicoClaw Hardware Compatibility

## 🦾 Dimostrazione @@ -129,9 +129,9 @@ _*Le build recenti potrebbero usare 10-20MB a causa delle fusioni rapide di PR.

Ricerca Web & Apprendimento

-

-

-

+

+

+

Sviluppa · Distribuisci · Scala @@ -220,7 +220,7 @@ picoclaw-launcher > ```

-WebUI Launcher +WebUI Launcher

**Per iniziare:** @@ -274,7 +274,7 @@ macOS potrebbe bloccare `picoclaw-launcher` al primo avvio perché è stato scar **Passo 1:** Fai doppio clic su `picoclaw-launcher`. Verrà visualizzato un avviso di sicurezza:

-Avviso macOS Gatekeeper +Avviso macOS Gatekeeper

> *"picoclaw-launcher" Non Aperto — Apple non è riuscita a verificare che "picoclaw-launcher" sia privo di malware che potrebbe danneggiare il Mac o compromettere la privacy.* @@ -282,7 +282,7 @@ macOS potrebbe bloccare `picoclaw-launcher` al primo avvio perché è stato scar **Passo 2:** Apri **Impostazioni di Sistema** → **Privacy e sicurezza** → scorri fino alla sezione **Sicurezza** → clicca su **Apri comunque** → conferma cliccando su **Apri comunque** nella finestra di dialogo.

-macOS Privacy e sicurezza — Apri comunque +macOS Privacy e sicurezza — Apri comunque

Dopo questo passaggio una tantum, `picoclaw-launcher` si aprirà normalmente ai lanci successivi. @@ -298,7 +298,7 @@ picoclaw-launcher-tui ```

-TUI Launcher +TUI Launcher

**Per iniziare:** @@ -317,10 +317,10 @@ Anteprima: - - - - + + + +
@@ -344,7 +344,7 @@ termux-chroot ./picoclaw onboard # chroot fornisce un layout standard del file Poi segui la sezione Terminal Launcher qui sotto per completare la configurazione. -PicoClaw on Termux +PicoClaw on Termux Per ambienti minimali dove è disponibile solo il binario core `picoclaw` (senza Launcher UI), puoi configurare tutto tramite riga di comando e un file di configurazione JSON. @@ -450,7 +450,7 @@ PicoClaw supporta 30+ provider LLM tramite la configurazione `model_list`. Usa i } ``` -Per i dettagli completi sulla configurazione dei provider, vedi [Provider & Modelli](docs/providers.md). +Per i dettagli completi sulla configurazione dei provider, vedi [Provider & Modelli](../guides/providers.md).
@@ -460,28 +460,28 @@ Parla con il tuo PicoClaw attraverso 17+ piattaforme di messaggistica: | Channel | Configurazione | Protocollo | Docs | |---------|----------------|------------|------| -| **Telegram** | Facile (bot token) | Long polling | [Guida](docs/channels/telegram/README.md) | -| **Discord** | Facile (bot token + intents) | WebSocket | [Guida](docs/channels/discord/README.md) | -| **WhatsApp** | Facile (QR scan o bridge URL) | Nativo / Bridge | [Guida](docs/chat-apps.md#whatsapp) | -| **Weixin** | Facile (scan QR nativo) | iLink API | [Guida](docs/chat-apps.md#weixin) | -| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guida](docs/channels/qq/README.md) | -| **Slack** | Facile (bot + app token) | Socket Mode | [Guida](docs/channels/slack/README.md) | -| **Matrix** | Medio (homeserver + token) | Sync API | [Guida](docs/channels/matrix/README.md) | -| **DingTalk** | Medio (credenziali client) | Stream | [Guida](docs/channels/dingtalk/README.md) | -| **Feishu / Lark** | Medio (App ID + Secret) | WebSocket/SDK | [Guida](docs/channels/feishu/README.md) | -| **LINE** | Medio (credenziali + webhook) | Webhook | [Guida](docs/channels/line/README.md) | -| **WeCom** | Facile (login QR o manuale) | WebSocket | [Guida](docs/channels/wecom/README.md) | -| **IRC** | Medio (server + nick) | Protocollo IRC | [Guida](docs/chat-apps.md#irc) | -| **OneBot** | Medio (WebSocket URL) | OneBot v11 | [Guida](docs/channels/onebot/README.md) | -| **MaixCam** | Facile (abilita) | TCP socket | [Guida](docs/channels/maixcam/README.md) | +| **Telegram** | Facile (bot token) | Long polling | [Guida](../channels/telegram/README.md) | +| **Discord** | Facile (bot token + intents) | WebSocket | [Guida](../channels/discord/README.md) | +| **WhatsApp** | Facile (QR scan o bridge URL) | Nativo / Bridge | [Guida](../guides/chat-apps.md#whatsapp) | +| **Weixin** | Facile (scan QR nativo) | iLink API | [Guida](../guides/chat-apps.md#weixin) | +| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guida](../channels/qq/README.md) | +| **Slack** | Facile (bot + app token) | Socket Mode | [Guida](../channels/slack/README.md) | +| **Matrix** | Medio (homeserver + token) | Sync API | [Guida](../channels/matrix/README.md) | +| **DingTalk** | Medio (credenziali client) | Stream | [Guida](../channels/dingtalk/README.md) | +| **Feishu / Lark** | Medio (App ID + Secret) | WebSocket/SDK | [Guida](../channels/feishu/README.md) | +| **LINE** | Medio (credenziali + webhook) | Webhook | [Guida](../channels/line/README.md) | +| **WeCom** | Facile (login QR o manuale) | WebSocket | [Guida](../channels/wecom/README.md) | +| **IRC** | Medio (server + nick) | Protocollo IRC | [Guida](../guides/chat-apps.md#irc) | +| **OneBot** | Medio (WebSocket URL) | OneBot v11 | [Guida](../channels/onebot/README.md) | +| **MaixCam** | Facile (abilita) | TCP socket | [Guida](../channels/maixcam/README.md) | | **Pico** | Facile (abilita) | Protocollo nativo | Integrato | | **Pico Client** | Facile (WebSocket URL) | WebSocket | Integrato | > Tutti i channel basati su webhook condividono un singolo server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu usa la modalità WebSocket/SDK e non usa il server HTTP condiviso. -> La verbosità dei log è controllata da `gateway.log_level` (default: `warn`). Valori supportati: `debug`, `info`, `warn`, `error`, `fatal`. Può essere impostato anche tramite `PICOCLAW_LOG_LEVEL`. Vedi [Configurazione](docs/configuration.md#gateway-log-level) per i dettagli. +> La verbosità dei log è controllata da `gateway.log_level` (default: `warn`). Valori supportati: `debug`, `info`, `warn`, `error`, `fatal`. Può essere impostato anche tramite `PICOCLAW_LOG_LEVEL`. Vedi [Configurazione](../guides/configuration.md#gateway-log-level) per i dettagli. -Per istruzioni dettagliate sulla configurazione dei channel, vedi [Configurazione App di Chat](docs/chat-apps.md). +Per istruzioni dettagliate sulla configurazione dei channel, vedi [Configurazione App di Chat](../guides/chat-apps.md). ## 🔧 Strumenti @@ -501,7 +501,7 @@ PicoClaw può cercare sul web per fornire informazioni aggiornate. Configura in ### ⚙️ Altri Strumenti -PicoClaw include strumenti integrati per operazioni su file, esecuzione di codice, pianificazione e altro. Vedi [Configurazione degli Strumenti](docs/tools_configuration.md) per i dettagli. +PicoClaw include strumenti integrati per operazioni su file, esecuzione di codice, pianificazione e altro. Vedi [Configurazione degli Strumenti](../reference/tools_configuration.md) per i dettagli. ## 🎯 Skill @@ -531,7 +531,7 @@ Aggiungi al tuo `config.json`: } ``` -Per maggiori dettagli, vedi [Configurazione degli Strumenti - Skill](docs/tools_configuration.md#skills-tool). +Per maggiori dettagli, vedi [Configurazione degli Strumenti - Skill](../reference/tools_configuration.md#skills-tool). ## 🔗 MCP (Model Context Protocol) @@ -554,9 +554,9 @@ PicoClaw supporta nativamente [MCP](https://modelcontextprotocol.io/) — connet } ``` -Per la configurazione MCP completa (trasporti stdio, SSE, HTTP, Tool Discovery), vedi [Configurazione degli Strumenti - MCP](docs/tools_configuration.md#mcp-tool). +Per la configurazione MCP completa (trasporti stdio, SSE, HTTP, Tool Discovery), vedi [Configurazione degli Strumenti - MCP](../reference/tools_configuration.md#mcp-tool). -## ClawdChat Unisciti al Social Network degli Agent +## ClawdChat Unisciti al Social Network degli Agent Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singolo messaggio tramite CLI o qualsiasi app di chat integrata. @@ -597,23 +597,23 @@ Per guide dettagliate oltre questo README: | Argomento | Descrizione | |-----------|-------------| -| [Docker & Avvio Rapido](docs/docker.md) | Configurazione Docker Compose, modalità Launcher/Agent | -| [App di Chat](docs/chat-apps.md) | Tutte le guide di configurazione per 17+ channel | -| [Configurazione](docs/configuration.md) | Variabili d'ambiente, struttura del workspace, sandbox di sicurezza | -| [Provider & Modelli](docs/providers.md) | 30+ provider LLM, routing dei modelli, configurazione model_list | -| [Spawn & Task Asincroni](docs/spawn-tasks.md) | Task veloci, task lunghi con spawn, orchestrazione asincrona di sub-agent | -| [Hooks](docs/hooks/README.md) | Sistema di hook event-driven: observer, interceptor, approval hook | -| [Steering](docs/steering.md) | Iniettare messaggi in un loop agent in esecuzione | -| [SubTurn](docs/subturn.md) | Coordinamento subagent, controllo concorrenza, ciclo di vita | -| [Risoluzione Problemi](docs/troubleshooting.md) | Problemi comuni e soluzioni | -| [Configurazione degli Strumenti](docs/tools_configuration.md) | Abilitazione/disabilitazione per strumento, politiche exec, MCP, Skill | -| [Compatibilità Hardware](docs/hardware-compatibility.md) | Schede testate, requisiti minimi | +| [Docker & Avvio Rapido](../guides/docker.md) | Configurazione Docker Compose, modalità Launcher/Agent | +| [App di Chat](../guides/chat-apps.md) | Tutte le guide di configurazione per 17+ channel | +| [Configurazione](../guides/configuration.md) | Variabili d'ambiente, struttura del workspace, sandbox di sicurezza | +| [Provider & Modelli](../guides/providers.md) | 30+ provider LLM, routing dei modelli, configurazione model_list | +| [Spawn & Task Asincroni](../guides/spawn-tasks.md) | Task veloci, task lunghi con spawn, orchestrazione asincrona di sub-agent | +| [Hooks](../architecture/hooks/README.md) | Sistema di hook event-driven: observer, interceptor, approval hook | +| [Steering](../architecture/steering.md) | Iniettare messaggi in un loop agent in esecuzione | +| [SubTurn](../architecture/subturn.md) | Coordinamento subagent, controllo concorrenza, ciclo di vita | +| [Risoluzione Problemi](../operations/troubleshooting.md) | Problemi comuni e soluzioni | +| [Configurazione degli Strumenti](../reference/tools_configuration.md) | Abilitazione/disabilitazione per strumento, politiche exec, MCP, Skill | +| [Compatibilità Hardware](../guides/hardware-compatibility.md) | Schede testate, requisiti minimi | ## 🤝 Contribuisci & Roadmap Le PR sono benvenute! Il codice è volutamente piccolo e leggibile. -Consulta la nostra [Roadmap della Community](https://github.com/sipeed/picoclaw/issues/988) e [CONTRIBUTING.md](CONTRIBUTING.md) per le linee guida. +Consulta la nostra [Roadmap della Community](https://github.com/sipeed/picoclaw/issues/988) e [CONTRIBUTING.md](../../CONTRIBUTING.md) per le linee guida. Gruppo sviluppatori in costruzione, unisciti dopo la tua prima PR accettata! @@ -622,4 +622,4 @@ Gruppi utenti: Discord: WeChat: -WeChat group QR code +WeChat group QR code diff --git a/README.ja.md b/docs/project/README.ja.md similarity index 84% rename from README.ja.md rename to docs/project/README.ja.md index d09eb436d..66d06ba5e 100644 --- a/README.ja.md +++ b/docs/project/README.ja.md @@ -1,5 +1,5 @@
- PicoClaw + PicoClaw

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

@@ -14,11 +14,11 @@ Wiki
Twitter - + Discord

-[中文](README.zh.md) | **日本語** | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md) +[中文](README.zh.md) | **日本語** | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
@@ -34,12 +34,12 @@

- +

- +

@@ -71,7 +71,7 @@ 2026-02-26 🎉 PicoClaw がわずか 17 日で **20K スター** 達成!Channel 自動オーケストレーションとケイパビリティインターフェースが実装されました。 -2026-02-16 🎉 PicoClaw が 1 週間で 12K スター達成!コミュニティメンテナーの役割と[ロードマップ](ROADMAP.md)が正式に公開されました。 +2026-02-16 🎉 PicoClaw が 1 週間で 12K スター達成!コミュニティメンテナーの役割と[ロードマップ](../../ROADMAP.md)が正式に公開されました。 2026-02-13 🎉 PicoClaw が 4 日間で 5000 スター達成!プロジェクトロードマップと開発者グループの準備が進行中。 @@ -108,14 +108,14 @@ _*最近のバージョンでは急速な PR マージにより 10〜20MB にな | **起動時間**
(0.8GHz コア) | >500秒 | >30秒 | **<1秒** | | **コスト** | Mac Mini $599 | 大半の Linux ボード ~$50 | **あらゆる Linux ボード**
**最安 $10** | -PicoClaw +PicoClaw -> **[ハードウェア互換性リスト](docs/ja/hardware-compatibility.md)** — テスト済みの全ボード一覧($5 RISC-V から Raspberry Pi、Android スマートフォンまで)。お使いのボードが未掲載?PR を送ってください! +> **[ハードウェア互換性リスト](../guides/hardware-compatibility.ja.md)** — テスト済みの全ボード一覧($5 RISC-V から Raspberry Pi、Android スマートフォンまで)。お使いのボードが未掲載?PR を送ってください!

-PicoClaw Hardware Compatibility +PicoClaw Hardware Compatibility

## 🦾 デモンストレーション @@ -129,9 +129,9 @@ _*最近のバージョンでは急速な PR マージにより 10〜20MB にな

Web 検索&学習

-

-

-

+

+

+

開発 · デプロイ · スケール @@ -220,7 +220,7 @@ picoclaw-launcher > ```

-WebUI Launcher +WebUI Launcher

**始め方:** @@ -274,7 +274,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d **ステップ 1:** `picoclaw-launcher` をダブルクリックすると、セキュリティ警告が表示されます:

-macOS Gatekeeper 警告 +macOS Gatekeeper 警告

> *"picoclaw-launcher" は開けません — "picoclaw-launcher" がMacに害を与えたりプライバシーを侵害するマルウェアを含まないことをAppleは確認できません。* @@ -282,7 +282,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d **ステップ 2:** **システム設定** → **プライバシーとセキュリティ** を開き、**セキュリティ** セクションまでスクロールして **このまま開く** をクリック → ダイアログで再度 **開く** をクリックします。

-macOS プライバシーとセキュリティ — このまま開く +macOS プライバシーとセキュリティ — このまま開く

この操作を一度行うと、以降の起動では警告が表示されなくなります。 @@ -298,7 +298,7 @@ picoclaw-launcher-tui ```

-TUI Launcher +TUI Launcher

**始め方:** @@ -307,6 +307,7 @@ TUI メニューを使って:**1)** Provider を設定 → **2)** Channel を TUI の詳細なドキュメントは [docs.picoclaw.io](https://docs.picoclaw.io) を参照してください。 + ### 📱 Android 10 年前のスマホに第二の人生を!PicoClaw でスマート AI アシスタントに変身させましょう。 @@ -317,10 +318,10 @@ TUI の詳細なドキュメントは [docs.picoclaw.io](https://docs.picoclaw.i - - - - + + + +
@@ -344,7 +345,7 @@ termux-chroot ./picoclaw onboard # chroot で標準的な Linux ファイル その後、下記の Terminal Launcher セクションの手順に従って設定を完了してください。 -PicoClaw on Termux +PicoClaw on Termux `picoclaw` コアバイナリのみが利用可能な最小環境(Launcher UI なし)では、コマンドラインと JSON 設定ファイルですべてを設定できます。 @@ -450,7 +451,7 @@ PicoClaw は `model_list` 設定を通じて 30 以上の LLM Provider をサポ } ``` -Provider の完全な設定詳細は [Provider とモデル](docs/ja/providers.md) を参照してください。 +Provider の完全な設定詳細は [Provider とモデル](../guides/providers.ja.md) を参照してください。
@@ -460,28 +461,28 @@ Provider の完全な設定詳細は [Provider とモデル](docs/ja/providers.m | Channel | セットアップ | Protocol | ドキュメント | |---------|------------|----------|------------| -| **Telegram** | 簡単(bot トークン) | Long polling | [ガイド](docs/channels/telegram/README.ja.md) | -| **Discord** | 簡単(bot トークン + intents) | WebSocket | [ガイド](docs/channels/discord/README.ja.md) | -| **WhatsApp** | 簡単(QR スキャンまたは bridge URL) | Native / Bridge | [ガイド](docs/ja/chat-apps.md#whatsapp) | -| **微信 (Weixin)** | 簡単(QR スキャン) | iLink API | [ガイド](docs/ja/chat-apps.md#weixin) | -| **QQ** | 簡単(AppID + AppSecret) | WebSocket | [ガイド](docs/channels/qq/README.ja.md) | -| **Slack** | 簡単(bot + app トークン) | Socket Mode | [ガイド](docs/channels/slack/README.ja.md) | -| **Matrix** | 中級(homeserver + トークン) | Sync API | [ガイド](docs/channels/matrix/README.ja.md) | -| **DingTalk** | 中級(クライアント認証情報) | Stream | [ガイド](docs/channels/dingtalk/README.ja.md) | -| **Feishu / Lark** | 中級(App ID + Secret) | WebSocket/SDK | [ガイド](docs/channels/feishu/README.ja.md) | -| **LINE** | 中級(認証情報 + webhook) | Webhook | [ガイド](docs/channels/line/README.ja.md) | -| **WeCom** | 簡単(QR ログインまたは手動) | WebSocket | [ガイド](docs/channels/wecom/README.md) | -| **IRC** | 中級(サーバー + nick) | IRC protocol | [ガイド](docs/ja/chat-apps.md#irc) | -| **OneBot** | 中級(WebSocket URL) | OneBot v11 | [ガイド](docs/channels/onebot/README.ja.md) | -| **MaixCam** | 簡単(有効化) | TCP socket | [ガイド](docs/channels/maixcam/README.ja.md) | +| **Telegram** | 簡単(bot トークン) | Long polling | [ガイド](../channels/telegram/README.ja.md) | +| **Discord** | 簡単(bot トークン + intents) | WebSocket | [ガイド](../channels/discord/README.ja.md) | +| **WhatsApp** | 簡単(QR スキャンまたは bridge URL) | Native / Bridge | [ガイド](../guides/chat-apps.ja.md#whatsapp) | +| **微信 (Weixin)** | 簡単(QR スキャン) | iLink API | [ガイド](../guides/chat-apps.ja.md#weixin) | +| **QQ** | 簡単(AppID + AppSecret) | WebSocket | [ガイド](../channels/qq/README.ja.md) | +| **Slack** | 簡単(bot + app トークン) | Socket Mode | [ガイド](../channels/slack/README.ja.md) | +| **Matrix** | 中級(homeserver + トークン) | Sync API | [ガイド](../channels/matrix/README.ja.md) | +| **DingTalk** | 中級(クライアント認証情報) | Stream | [ガイド](../channels/dingtalk/README.ja.md) | +| **Feishu / Lark** | 中級(App ID + Secret) | WebSocket/SDK | [ガイド](../channels/feishu/README.ja.md) | +| **LINE** | 中級(認証情報 + webhook) | Webhook | [ガイド](../channels/line/README.ja.md) | +| **WeCom** | 簡単(QR ログインまたは手動) | WebSocket | [ガイド](../channels/wecom/README.ja.md) | +| **IRC** | 中級(サーバー + nick) | IRC protocol | [ガイド](../guides/chat-apps.ja.md#irc) | +| **OneBot** | 中級(WebSocket URL) | OneBot v11 | [ガイド](../channels/onebot/README.ja.md) | +| **MaixCam** | 簡単(有効化) | TCP socket | [ガイド](../channels/maixcam/README.ja.md) | | **Pico** | 簡単(有効化) | Native protocol | 内蔵 | | **Pico Client** | 簡単(WebSocket URL) | WebSocket | 内蔵 | > webhook ベースのすべての Channel は単一の Gateway HTTP サーバー(`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`)を共有します。Feishu は WebSocket/SDK モードを使用し、共有 HTTP サーバーを使用しません。 -> ログの詳細度は `gateway.log_level` で制御します(デフォルト:`warn`)。サポートされる値:`debug`、`info`、`warn`、`error`、`fatal`。`PICOCLAW_LOG_LEVEL` 環境変数でも設定可能です。詳細は[設定ガイド](docs/ja/configuration.md#gateway-ログレベル)を参照してください。 +> ログの詳細度は `gateway.log_level` で制御します(デフォルト:`warn`)。サポートされる値:`debug`、`info`、`warn`、`error`、`fatal`。`PICOCLAW_LOG_LEVEL` 環境変数でも設定可能です。詳細は[設定ガイド](../guides/configuration.ja.md#gateway-ログレベル)を参照してください。 -Channel の詳細なセットアップ手順は [チャットアプリ設定](docs/ja/chat-apps.md) を参照してください。 +Channel の詳細なセットアップ手順は [チャットアプリ設定](../guides/chat-apps.ja.md) を参照してください。 ## 🔧 ツール @@ -501,7 +502,7 @@ PicoClaw は最新情報を提供するために Web を検索できます。`to ### ⚙️ その他のツール -PicoClaw にはファイル操作、コード実行、スケジューリングなどの組み込みツールが含まれています。詳細は [ツール設定](docs/ja/tools_configuration.md) を参照してください。 +PicoClaw にはファイル操作、コード実行、スケジューリングなどの組み込みツールが含まれています。詳細は [ツール設定](../reference/tools_configuration.ja.md) を参照してください。 ## 🎯 Skill @@ -531,7 +532,7 @@ picoclaw skills install } ``` -詳細は [ツール設定 - Skill](docs/ja/tools_configuration.md#skills-tool) を参照してください。 +詳細は [ツール設定 - Skill](../reference/tools_configuration.ja.md#skills-tool) を参照してください。 ## 🔗 MCP(Model Context Protocol) @@ -554,9 +555,9 @@ PicoClaw は [MCP](https://modelcontextprotocol.io/) をネイティブサポー } ``` -MCP の完全な設定(stdio、SSE、HTTP トランスポート、Tool Discovery)は [ツール設定 - MCP](docs/ja/tools_configuration.md#mcp-tool) を参照してください。 +MCP の完全な設定(stdio、SSE、HTTP トランスポート、Tool Discovery)は [ツール設定 - MCP](../reference/tools_configuration.ja.md#mcp-tool) を参照してください。 -## ClawdChat エージェントソーシャルネットワークに参加 +## ClawdChat エージェントソーシャルネットワークに参加 CLI または統合チャットアプリからメッセージを 1 つ送るだけで、PicoClaw をエージェントソーシャルネットワークに接続できます。 @@ -597,23 +598,23 @@ PicoClaw は `cron` ツールによるスケジュールリマインダーと定 | トピック | 説明 | |---------|------| -| [Docker & クイックスタート](docs/ja/docker.md) | Docker Compose セットアップ、Launcher/Agent モード | -| [チャットアプリ](docs/ja/chat-apps.md) | 17 以上の Channel セットアップガイド | -| [設定](docs/ja/configuration.md) | 環境変数、ワークスペース構成、セキュリティサンドボックス | -| [Provider とモデル](docs/ja/providers.md) | 30 以上の LLM Provider、モデルルーティング、model_list 設定 | -| [Spawn & 非同期タスク](docs/ja/spawn-tasks.md) | クイックタスク、spawn による長時間タスク、非同期サブエージェントオーケストレーション | -| [Hook システム](docs/hooks/README.md) | イベント駆動 Hook:オブザーバー、インターセプター、承認 Hook | -| [Steering](docs/steering.md) | 実行中の Agent ループにメッセージを注入 | -| [SubTurn](docs/subturn.md) | サブ Agent の調整、並行制御、ライフサイクル | -| [トラブルシューティング](docs/ja/troubleshooting.md) | よくある問題と解決策 | -| [ツール設定](docs/ja/tools_configuration.md) | ツールごとの有効/無効、exec ポリシー、MCP、Skill | -| [ハードウェア互換性](docs/ja/hardware-compatibility.md) | テスト済みボード、最小要件 | +| [Docker & クイックスタート](../guides/docker.ja.md) | Docker Compose セットアップ、Launcher/Agent モード | +| [チャットアプリ](../guides/chat-apps.ja.md) | 17 以上の Channel セットアップガイド | +| [設定](../guides/configuration.ja.md) | 環境変数、ワークスペース構成、セキュリティサンドボックス | +| [Provider とモデル](../guides/providers.ja.md) | 30 以上の LLM Provider、モデルルーティング、model_list 設定 | +| [Spawn & 非同期タスク](../guides/spawn-tasks.ja.md) | クイックタスク、spawn による長時間タスク、非同期サブエージェントオーケストレーション | +| [Hook システム](../architecture/hooks/README.md) | イベント駆動 Hook:オブザーバー、インターセプター、承認 Hook | +| [Steering](../architecture/steering.md) | 実行中の Agent ループにメッセージを注入 | +| [SubTurn](../architecture/subturn.md) | サブ Agent の調整、並行制御、ライフサイクル | +| [トラブルシューティング](../operations/troubleshooting.ja.md) | よくある問題と解決策 | +| [ツール設定](../reference/tools_configuration.ja.md) | ツールごとの有効/無効、exec ポリシー、MCP、Skill | +| [ハードウェア互換性](../guides/hardware-compatibility.ja.md) | テスト済みボード、最小要件 | ## 🤝 コントリビュート&ロードマップ PR 歓迎!コードベースは意図的に小さく読みやすくしています。 -[コミュニティロードマップ](https://github.com/sipeed/picoclaw/issues/988)と[CONTRIBUTING.md](CONTRIBUTING.md)をご覧ください。 +[コミュニティロードマップ](https://github.com/sipeed/picoclaw/issues/988)と[CONTRIBUTING.md](../../CONTRIBUTING.md)をご覧ください。 開発者グループ構築中、最初の PR がマージされたら参加できます! @@ -622,4 +623,4 @@ PR 歓迎!コードベースは意図的に小さく読みやすくしてい Discord: WeChat: -WeChat group QR code +WeChat group QR code diff --git a/README.ko.md b/docs/project/README.ko.md similarity index 83% rename from README.ko.md rename to docs/project/README.ko.md index 9095a9240..cfc985688 100644 --- a/README.ko.md +++ b/docs/project/README.ko.md @@ -1,5 +1,5 @@
-PicoClaw +PicoClaw

PicoClaw: Go로 작성된 초고효율 AI 어시스턴트

@@ -14,11 +14,11 @@ Wiki
Twitter - + Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | **한국어** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md) +[中文](README.zh.md) | [日本語](README.ja.md) | **한국어** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
@@ -34,12 +34,12 @@

- +

- +

@@ -71,7 +71,7 @@ 2026-02-26 🎉 PicoClaw가 단 17일 만에 **20K 스타**를 달성했습니다! 채널 자동 오케스트레이션과 기능 인터페이스가 적용되었습니다. -2026-02-16 🎉 PicoClaw가 1주일 만에 **12K 스타**를 돌파했습니다! 커뮤니티 메인터너 역할과 [로드맵](ROADMAP.md)이 공식적으로 공개되었습니다. +2026-02-16 🎉 PicoClaw가 1주일 만에 **12K 스타**를 돌파했습니다! 커뮤니티 메인터너 역할과 [로드맵](../../ROADMAP.md)이 공식적으로 공개되었습니다. 2026-02-13 🎉 PicoClaw가 4일 만에 **5000 스타**를 돌파했습니다! 프로젝트 로드맵과 개발자 그룹이 준비 중입니다. @@ -108,14 +108,14 @@ _*최근 빌드는 급격한 PR 병합으로 인해 10~20MB를 사용할 수 있 | **부팅 시간**
(0.8GHz 코어) | >500초 | >30초 | **<1초** | | **비용** | Mac Mini $599 | 대부분의 Linux 보드 ~$50 | **모든 Linux 보드**
**최저 $10부터** | -PicoClaw +PicoClaw -> **[하드웨어 호환 목록](docs/hardware-compatibility.md)** — 테스트된 모든 보드를 확인하세요. $5 RISC-V 보드부터 Raspberry Pi, Android 스마트폰까지 포함됩니다. 사용 중인 보드가 없나요? PR을 보내주세요! +> **[하드웨어 호환 목록](../guides/hardware-compatibility.md)** — 테스트된 모든 보드를 확인하세요. $5 RISC-V 보드부터 Raspberry Pi, Android 스마트폰까지 포함됩니다. 사용 중인 보드가 없나요? PR을 보내주세요!

-PicoClaw Hardware Compatibility +PicoClaw Hardware Compatibility

## 🦾 데모 @@ -129,9 +129,9 @@ _*최근 빌드는 급격한 PR 병합으로 인해 10~20MB를 사용할 수 있

웹 검색 및 학습

-

-

-

+

+

+

개발 · 배포 · 확장 @@ -220,7 +220,7 @@ picoclaw-launcher > ```

-WebUI Launcher +WebUI Launcher

**시작 방법:** @@ -274,7 +274,7 @@ macOS에서는 인터넷에서 다운로드한 앱이고 Mac App Store 공증을 **1단계:** `picoclaw-launcher`를 더블클릭합니다. 그러면 보안 경고가 표시됩니다.

-macOS Gatekeeper warning +macOS Gatekeeper warning

> *"picoclaw-launcher"을(를) 열 수 없습니다. Apple에서 이 앱이 악성 소프트웨어가 없으며 Mac이나 개인 정보를 해치지 않는다고 확인할 수 없습니다.* @@ -282,7 +282,7 @@ macOS에서는 인터넷에서 다운로드한 앱이고 Mac App Store 공증을 **2단계:** **시스템 설정** -> **개인정보 보호 및 보안** 으로 이동한 뒤 **보안** 섹션까지 스크롤하여 **그래도 열기(Open Anyway)** 를 클릭하고, 대화상자에서 다시 한 번 **그래도 열기**를 확인합니다.

-macOS Privacy & Security — Open Anyway +macOS Privacy & Security — Open Anyway

이 과정을 한 번만 거치면 이후에는 `picoclaw-launcher`가 정상적으로 열립니다. @@ -298,7 +298,7 @@ picoclaw-launcher-tui ```

-TUI Launcher +TUI Launcher

**시작 방법:** @@ -317,10 +317,10 @@ TUI 메뉴를 사용해 다음 순서로 진행하세요. **1)** 프로바이더 - - - - + + + +
@@ -344,7 +344,7 @@ termux-chroot ./picoclaw onboard # chroot가 표준 Linux 파일시스템 레 그다음 아래의 터미널 런처 섹션을 따라 설정을 마무리하세요. -PicoClaw on Termux +PicoClaw on Termux 런처 UI 없이 `picoclaw` 코어 바이너리만 있는 최소 환경에서는 명령줄과 JSON 설정 파일만으로도 모든 설정을 마칠 수 있습니다. @@ -377,7 +377,7 @@ picoclaw onboard > 사용 가능한 모든 옵션이 포함된 전체 설정 템플릿은 저장소의 `config/config.example.json`을 참고하세요. > -> 참고: `config.example.json` 형식은 버전 0이며 민감 정보가 포함되어 있습니다. 실행 시 자동으로 버전 1+로 마이그레이션되며, 이후 `config.json`에는 비민감 정보만 저장되고 민감 정보는 `.security.yml`에 저장됩니다. 민감 정보를 직접 수정해야 한다면 `docs/security_configuration.md`를 참고하세요. +> 참고: `config.example.json` 형식은 버전 0이며 민감 정보가 포함되어 있습니다. 실행 시 자동으로 버전 1+로 마이그레이션되며, 이후 `config.json`에는 비민감 정보만 저장되고 민감 정보는 `.security.yml`에 저장됩니다. 민감 정보를 직접 수정해야 한다면 `../security/security_configuration.md`를 참고하세요. **3. 채팅** @@ -455,7 +455,7 @@ PicoClaw는 `model_list` 설정을 통해 30개 이상의 LLM 프로바이더를 } ``` -프로바이더 전체 설정은 [프로바이더와 모델](docs/providers.md)을 참고하세요. +프로바이더 전체 설정은 [프로바이더와 모델](../guides/providers.md)을 참고하세요. @@ -465,29 +465,29 @@ PicoClaw는 `model_list` 설정을 통해 30개 이상의 LLM 프로바이더를 | 채널 | 설정 | 프로토콜 | 문서 | |---------|------|----------|------| -| **Telegram** | 쉬움(봇 토큰) | Long polling | [가이드](docs/channels/telegram/README.md) | -| **Discord** | 쉬움(봇 토큰 + intents) | WebSocket | [가이드](docs/channels/discord/README.md) | -| **WhatsApp** | 쉬움(QR 스캔 또는 브리지 URL) | Native / Bridge | [가이드](docs/chat-apps.md#whatsapp) | -| **Weixin** | 쉬움(네이티브 QR 스캔) | iLink API | [가이드](docs/chat-apps.md#weixin) | -| **QQ** | 쉬움(AppID + AppSecret) | WebSocket | [가이드](docs/channels/qq/README.md) | -| **Slack** | 쉬움(봇 + 앱 토큰) | Socket Mode | [가이드](docs/channels/slack/README.md) | -| **Matrix** | 중간(homeserver + 토큰) | Sync API | [가이드](docs/channels/matrix/README.md) | -| **DingTalk** | 중간(클라이언트 자격 증명) | Stream | [가이드](docs/channels/dingtalk/README.md) | -| **Feishu / Lark** | 중간(App ID + Secret) | WebSocket/SDK | [가이드](docs/channels/feishu/README.md) | -| **LINE** | 중간(인증 정보 + webhook) | Webhook | [가이드](docs/channels/line/README.md) | -| **WeCom** | 쉬움(QR 로그인 또는 수동 설정) | WebSocket | [가이드](docs/channels/wecom/README.md) | -| **VK** | 쉬움(그룹 토큰) | Long Poll | [가이드](docs/channels/vk/README.md) | -| **IRC** | 중간(서버 + 닉네임) | IRC protocol | [가이드](docs/chat-apps.md#irc) | -| **OneBot** | 중간(WebSocket URL) | OneBot v11 | [가이드](docs/channels/onebot/README.md) | -| **MaixCam** | 쉬움(활성화) | TCP socket | [가이드](docs/channels/maixcam/README.md) | +| **Telegram** | 쉬움(봇 토큰) | Long polling | [가이드](../channels/telegram/README.md) | +| **Discord** | 쉬움(봇 토큰 + intents) | WebSocket | [가이드](../channels/discord/README.md) | +| **WhatsApp** | 쉬움(QR 스캔 또는 브리지 URL) | Native / Bridge | [가이드](../guides/chat-apps.md#whatsapp) | +| **Weixin** | 쉬움(네이티브 QR 스캔) | iLink API | [가이드](../guides/chat-apps.md#weixin) | +| **QQ** | 쉬움(AppID + AppSecret) | WebSocket | [가이드](../channels/qq/README.md) | +| **Slack** | 쉬움(봇 + 앱 토큰) | Socket Mode | [가이드](../channels/slack/README.md) | +| **Matrix** | 중간(homeserver + 토큰) | Sync API | [가이드](../channels/matrix/README.md) | +| **DingTalk** | 중간(클라이언트 자격 증명) | Stream | [가이드](../channels/dingtalk/README.md) | +| **Feishu / Lark** | 중간(App ID + Secret) | WebSocket/SDK | [가이드](../channels/feishu/README.md) | +| **LINE** | 중간(인증 정보 + webhook) | Webhook | [가이드](../channels/line/README.md) | +| **WeCom** | 쉬움(QR 로그인 또는 수동 설정) | WebSocket | [가이드](../channels/wecom/README.md) | +| **VK** | 쉬움(그룹 토큰) | Long Poll | [가이드](../channels/vk/README.md) | +| **IRC** | 중간(서버 + 닉네임) | IRC protocol | [가이드](../guides/chat-apps.md#irc) | +| **OneBot** | 중간(WebSocket URL) | OneBot v11 | [가이드](../channels/onebot/README.md) | +| **MaixCam** | 쉬움(활성화) | TCP socket | [가이드](../channels/maixcam/README.md) | | **Pico** | 쉬움(활성화) | 네이티브 프로토콜 | 내장 | | **Pico Client** | 쉬움(WebSocket URL) | WebSocket | 내장 | > webhook 기반 채널은 모두 하나의 게이트웨이 HTTP 서버(`gateway.host`:`gateway.port`, 기본값 `127.0.0.1:18790`)를 공유합니다. Feishu는 WebSocket/SDK 모드를 사용하며 이 공용 HTTP 서버를 사용하지 않습니다. -> 로그 상세도는 `gateway.log_level`(기본값: `warn`)로 제어됩니다. 지원 값은 `debug`, `info`, `warn`, `error`, `fatal`입니다. `PICOCLAW_LOG_LEVEL` 환경 변수로도 설정할 수 있습니다. 자세한 내용은 [설정 문서](docs/configuration.md#gateway-log-level)를 참고하세요. +> 로그 상세도는 `gateway.log_level`(기본값: `warn`)로 제어됩니다. 지원 값은 `debug`, `info`, `warn`, `error`, `fatal`입니다. `PICOCLAW_LOG_LEVEL` 환경 변수로도 설정할 수 있습니다. 자세한 내용은 [설정 문서](../guides/configuration.md#gateway-log-level)를 참고하세요. -자세한 채널 설정 방법은 [채팅 앱 설정 가이드](docs/chat-apps.md)를 참고하세요. +자세한 채널 설정 방법은 [채팅 앱 설정 가이드](../guides/chat-apps.md)를 참고하세요. ## 🔧 도구 @@ -507,7 +507,7 @@ PicoClaw는 최신 정보를 제공하기 위해 웹 검색을 수행할 수 있 ### ⚙️ 기타 도구 -PicoClaw에는 파일 작업, 코드 실행, 스케줄링 등을 위한 내장 도구가 포함되어 있습니다. 자세한 내용은 [도구 설정](docs/tools_configuration.md)을 참고하세요. +PicoClaw에는 파일 작업, 코드 실행, 스케줄링 등을 위한 내장 도구가 포함되어 있습니다. 자세한 내용은 [도구 설정](../reference/tools_configuration.md)을 참고하세요. ## 🎯 스킬 @@ -537,7 +537,7 @@ picoclaw skills install } ``` -자세한 내용은 [도구 설정 - 스킬](docs/tools_configuration.md#skills-tool)를 참고하세요. +자세한 내용은 [도구 설정 - 스킬](../reference/tools_configuration.md#skills-tool)를 참고하세요. ## 🔗 MCP (Model Context Protocol) @@ -560,9 +560,9 @@ PicoClaw는 [MCP](https://modelcontextprotocol.io/)를 기본 지원합니다. } ``` -MCP 전체 설정(stdio, SSE, HTTP 전송 방식, 도구 탐색)은 [도구 설정 - MCP](docs/tools_configuration.md#mcp-tool)를 참고하세요. +MCP 전체 설정(stdio, SSE, HTTP 전송 방식, 도구 탐색)은 [도구 설정 - MCP](../reference/tools_configuration.md#mcp-tool)를 참고하세요. -## ClawdChat 에이전트 소셜 네트워크 참여하기 +## ClawdChat 에이전트 소셜 네트워크 참여하기 CLI 또는 통합된 채팅 앱에서 메시지를 한 번만 보내면 PicoClaw를 에이전트 소셜 네트워크에 연결할 수 있습니다. @@ -597,7 +597,7 @@ PicoClaw는 `cron` 도구를 통해 예약 리마인더와 반복 작업을 지 * **반복 작업**: "2시간마다 알려줘" -> 2시간마다 실행 * **Cron 표현식**: "매일 오전 9시에 알려줘" -> cron 표현식 사용 -현재 지원하는 스케줄 유형, 실행 모드, 명령 작업 게이트, 저장 방식은 [docs/cron.md](docs/cron.md)를 참고하세요. +현재 지원하는 스케줄 유형, 실행 모드, 명령 작업 게이트, 저장 방식은 [docs/reference/cron.md](../reference/cron.md)를 참고하세요. ## 📚 문서 @@ -605,24 +605,24 @@ PicoClaw는 `cron` 도구를 통해 예약 리마인더와 반복 작업을 지 | 주제 | 설명 | |------|------| -| [도커 & 빠른 시작](docs/docker.md) | Docker Compose 설정, 런처/에이전트 모드 | -| [채팅 앱](docs/chat-apps.md) | 17개 이상의 채널 설정 가이드 | -| [설정](docs/configuration.md) | 환경 변수, 워크스페이스 레이아웃, 보안 샌드박스 | -| [예약 작업과 Cron](docs/cron.md) | Cron 스케줄 유형, 전달 모드, 명령 게이트, 작업 저장 | -| [프로바이더와 모델](docs/providers.md) | 30개 이상의 LLM 프로바이더, 모델 라우팅, model_list 설정 | -| [Spawn & 비동기 작업](docs/spawn-tasks.md) | 빠른 작업, spawn을 이용한 장기 작업, 비동기 서브에이전트 오케스트레이션 | -| [Hooks](docs/hooks/README.md) | 이벤트 기반 Hook 시스템: 관찰자, 인터셉터, 승인 훅 | -| [Steering](docs/steering.md) | 실행 중인 에이전트 루프에서 도구 호출 사이에 메시지 주입 | -| [SubTurn](docs/subturn.md) | 서브에이전트 조정, 동시성 제어, 생명주기 | -| [문제 해결](docs/troubleshooting.md) | 자주 발생하는 문제와 해결 방법 | -| [도구 설정](docs/tools_configuration.md) | 도구별 활성화/비활성화, exec 정책, MCP, 스킬 | -| [하드웨어 호환성](docs/hardware-compatibility.md) | 테스트된 보드, 최소 요구사항 | +| [도커 & 빠른 시작](../guides/docker.md) | Docker Compose 설정, 런처/에이전트 모드 | +| [채팅 앱](../guides/chat-apps.md) | 17개 이상의 채널 설정 가이드 | +| [설정](../guides/configuration.md) | 환경 변수, 워크스페이스 레이아웃, 보안 샌드박스 | +| [예약 작업과 Cron](../reference/cron.md) | Cron 스케줄 유형, 전달 모드, 명령 게이트, 작업 저장 | +| [프로바이더와 모델](../guides/providers.md) | 30개 이상의 LLM 프로바이더, 모델 라우팅, model_list 설정 | +| [Spawn & 비동기 작업](../guides/spawn-tasks.md) | 빠른 작업, spawn을 이용한 장기 작업, 비동기 서브에이전트 오케스트레이션 | +| [Hooks](../architecture/hooks/README.md) | 이벤트 기반 Hook 시스템: 관찰자, 인터셉터, 승인 훅 | +| [Steering](../architecture/steering.md) | 실행 중인 에이전트 루프에서 도구 호출 사이에 메시지 주입 | +| [SubTurn](../architecture/subturn.md) | 서브에이전트 조정, 동시성 제어, 생명주기 | +| [문제 해결](../operations/troubleshooting.md) | 자주 발생하는 문제와 해결 방법 | +| [도구 설정](../reference/tools_configuration.md) | 도구별 활성화/비활성화, exec 정책, MCP, 스킬 | +| [하드웨어 호환성](../guides/hardware-compatibility.md) | 테스트된 보드, 최소 요구사항 | ## 🤝 기여 & 로드맵 PR은 언제든 환영합니다! 코드베이스는 의도적으로 작고 읽기 쉽게 유지하고 있습니다. -가이드라인은 [커뮤니티 로드맵](https://github.com/sipeed/picoclaw/issues/988)과 [CONTRIBUTING.md](CONTRIBUTING.md)를 참고하세요. +가이드라인은 [커뮤니티 로드맵](https://github.com/sipeed/picoclaw/issues/988)과 [CONTRIBUTING.md](../../CONTRIBUTING.md)를 참고하세요. 개발자 그룹도 준비 중입니다. 첫 PR이 머지되면 함께할 수 있습니다! @@ -631,4 +631,4 @@ PR은 언제든 환영합니다! 코드베이스는 의도적으로 작고 읽 Discord: WeChat: -WeChat group QR code +WeChat group QR code diff --git a/README.my.md b/docs/project/README.ms.md similarity index 85% rename from README.my.md rename to docs/project/README.ms.md index bbe003deb..f8c9e95e7 100644 --- a/README.my.md +++ b/docs/project/README.ms.md @@ -1,5 +1,5 @@
-PicoClaw +PicoClaw

PicoClaw: Pembantu AI Ultra-Cekap dalam Go

@@ -14,11 +14,11 @@ Wiki
Twitter - + Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **Malay** | [English](README.md) +[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **Malay** | [English](../../README.md)
@@ -34,12 +34,12 @@

- +

- +

@@ -71,7 +71,7 @@ 2026-02-26 🎉 PicoClaw mencapai **20K Stars** hanya dalam 17 hari! Orkestrasi saluran automatik dan antara muka keupayaan kini aktif. -2026-02-16 🎉 PicoClaw melepasi 12K Stars dalam seminggu! Peranan penyelenggara komuniti dan [Peta Jalan](ROADMAP.md) dilancarkan secara rasmi. +2026-02-16 🎉 PicoClaw melepasi 12K Stars dalam seminggu! Peranan penyelenggara komuniti dan [Peta Jalan](../../ROADMAP.md) dilancarkan secara rasmi. 2026-02-13 🎉 PicoClaw melepasi 5000 Stars dalam 4 hari! Peta jalan projek dan kumpulan pembangun sedang dalam proses. @@ -108,14 +108,14 @@ _*Binaan terkini mungkin menggunakan 10-20MB disebabkan penggabungan PR yang pes | **Masa Boot** (teras 0.8GHz) | >500s | >30s | **<1s** | | **Kos** | Mac Mini $599 | Kebanyakan papan Linux ~$50 | **Mana-mana papan Linux dari $10** | -PicoClaw +PicoClaw -> **[Senarai Keserasian Perkakasan](docs/hardware-compatibility.md)** — Lihat semua papan yang diuji, dari RISC-V $5 hingga Raspberry Pi hingga telefon Android. +> **[Senarai Keserasian Perkakasan](../guides/hardware-compatibility.md)** — Lihat semua papan yang diuji, dari RISC-V $5 hingga Raspberry Pi hingga telefon Android.

-Keserasian Perkakasan PicoClaw +Keserasian Perkakasan PicoClaw

## 🦾 Demonstrasi @@ -129,9 +129,9 @@ _*Binaan terkini mungkin menggunakan 10-20MB disebabkan penggabungan PR yang pes

Carian Web & Pembelajaran

-

-

-

+

+

+

Bangun · Deploy · Skala @@ -220,7 +220,7 @@ picoclaw-launcher > ```

-Pelancar WebUI +Pelancar WebUI

**Memulakan:** Buka WebUI, kemudian: **1)** Konfigurasikan Penyedia (tambah kunci API LLM) -> **2)** Konfigurasikan Saluran (cth. Telegram) -> **3)** Mulakan Gateway -> **4)** Sembang! @@ -271,7 +271,7 @@ macOS mungkin menyekat `picoclaw-launcher` pada pelancaran pertama kerana ia dim **Langkah 1:** Klik dua kali `picoclaw-launcher`. Anda akan melihat amaran keselamatan:

-Amaran macOS Gatekeeper +Amaran macOS Gatekeeper

> *"picoclaw-launcher" Tidak Dibuka — Apple tidak dapat mengesahkan "picoclaw-launcher" bebas daripada perisian hasad yang mungkin membahayakan Mac anda atau menjejaskan privasi anda.* @@ -279,7 +279,7 @@ macOS mungkin menyekat `picoclaw-launcher` pada pelancaran pertama kerana ia dim **Langkah 2:** Buka **Tetapan Sistem** → **Privasi & Keselamatan** → tatal ke bawah ke bahagian **Keselamatan** → klik **Buka Juga** → sahkan dengan mengklik **Buka Juga** dalam dialog.

-macOS Privasi & Keselamatan — Buka Juga +macOS Privasi & Keselamatan — Buka Juga

Selepas langkah sekali ini, `picoclaw-launcher` akan dibuka secara normal pada pelancaran seterusnya. @@ -295,7 +295,7 @@ picoclaw-launcher-tui ```

-Pelancar TUI +Pelancar TUI

**Memulakan:** @@ -314,10 +314,10 @@ Pratonton: - - - - + + + +
@@ -341,7 +341,7 @@ termux-chroot ./picoclaw onboard # chroot menyediakan susun atur sistem fail L Kemudian ikuti bahagian Pelancar Terminal di bawah untuk melengkapkan konfigurasi. -PicoClaw pada Termux +PicoClaw pada Termux Untuk persekitaran minimal di mana hanya binari teras `picoclaw` tersedia (tiada UI Pelancar), anda boleh mengkonfigurasi semua melalui baris arahan dan fail konfigurasi JSON. @@ -449,7 +449,7 @@ PicoClaw menyokong 30+ penyedia LLM melalui konfigurasi `model_list`. Gunakan fo } ``` -Untuk butiran konfigurasi penyedia penuh, lihat [Penyedia & Model](docs/providers.md). +Untuk butiran konfigurasi penyedia penuh, lihat [Penyedia & Model](../guides/providers.md). @@ -460,28 +460,28 @@ Bercakap dengan PicoClaw anda melalui 17+ platform pemesejan: | Saluran | Persediaan | Protokol | Dok | |---------|-----------|----------|-----| -| **Telegram** | Mudah (token bot) | Long polling | [Panduan](docs/channels/telegram/README.md) | -| **Discord** | Mudah (token bot + intents) | WebSocket | [Panduan](docs/channels/discord/README.md) | -| **WhatsApp** | Mudah (imbas QR atau URL jambatan) | Natif / Jambatan | [Panduan](docs/chat-apps.md#whatsapp) | -| **Weixin** | Mudah (imbas QR natif) | iLink API | [Panduan](docs/chat-apps.md#weixin) | -| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](docs/channels/qq/README.md) | -| **Slack** | Mudah (token bot + app) | Socket Mode | [Panduan](docs/channels/slack/README.md) | -| **Matrix** | Sederhana (homeserver + token) | Sync API | [Panduan](docs/channels/matrix/README.md) | -| **DingTalk** | Sederhana (kelayakan klien) | Stream | [Panduan](docs/channels/dingtalk/README.md) | -| **Feishu / Lark** | Sederhana (App ID + Secret) | WebSocket/SDK | [Panduan](docs/channels/feishu/README.md) | -| **LINE** | Sederhana (kelayakan + webhook) | Webhook | [Panduan](docs/channels/line/README.md) | -| **WeCom** | Mudah (log masuk QR atau manual) | WebSocket | [Panduan](docs/channels/wecom/README.md) | -| **IRC** | Sederhana (pelayan + nick) | Protokol IRC | [Panduan](docs/chat-apps.md#irc) | -| **OneBot** | Sederhana (URL WebSocket) | OneBot v11 | [Panduan](docs/channels/onebot/README.md) | -| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](docs/channels/maixcam/README.md) | +| **Telegram** | Mudah (token bot) | Long polling | [Panduan](../channels/telegram/README.md) | +| **Discord** | Mudah (token bot + intents) | WebSocket | [Panduan](../channels/discord/README.md) | +| **WhatsApp** | Mudah (imbas QR atau URL jambatan) | Natif / Jambatan | [Panduan](../guides/chat-apps.ms.md#whatsapp) | +| **Weixin** | Mudah (imbas QR natif) | iLink API | [Panduan](../guides/chat-apps.ms.md#weixin) | +| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](../channels/qq/README.md) | +| **Slack** | Mudah (token bot + app) | Socket Mode | [Panduan](../channels/slack/README.md) | +| **Matrix** | Sederhana (homeserver + token) | Sync API | [Panduan](../channels/matrix/README.md) | +| **DingTalk** | Sederhana (kelayakan klien) | Stream | [Panduan](../channels/dingtalk/README.md) | +| **Feishu / Lark** | Sederhana (App ID + Secret) | WebSocket/SDK | [Panduan](../channels/feishu/README.md) | +| **LINE** | Sederhana (kelayakan + webhook) | Webhook | [Panduan](../channels/line/README.md) | +| **WeCom** | Mudah (log masuk QR atau manual) | WebSocket | [Panduan](../channels/wecom/README.md) | +| **IRC** | Sederhana (pelayan + nick) | Protokol IRC | [Panduan](../guides/chat-apps.ms.md#irc) | +| **OneBot** | Sederhana (URL WebSocket) | OneBot v11 | [Panduan](../channels/onebot/README.md) | +| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](../channels/maixcam/README.md) | | **Pico** | Mudah (aktifkan) | Protokol natif | Terbina dalam | | **Pico Client** | Mudah (URL WebSocket) | WebSocket | Terbina dalam | > Semua saluran berasaskan webhook berkongsi satu pelayan HTTP Gateway (`gateway.host`:`gateway.port`, lalai `127.0.0.1:18790`). Feishu menggunakan mod WebSocket/SDK dan tidak menggunakan pelayan HTTP yang dikongsi. -> Tahap perincian log dikawal oleh `gateway.log_level` (lalai: `warn`). Nilai yang disokong: `debug`, `info`, `warn`, `error`, `fatal`. Boleh juga ditetapkan melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](docs/configuration.md#gateway-log-level) untuk butiran. +> Tahap perincian log dikawal oleh `gateway.log_level` (lalai: `warn`). Nilai yang disokong: `debug`, `info`, `warn`, `error`, `fatal`. Boleh juga ditetapkan melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](../guides/configuration.ms.md#gateway-log-level) untuk butiran. -Untuk arahan persediaan saluran terperinci, lihat [Konfigurasi Aplikasi Sembang](docs/my/chat-apps.md). +Untuk arahan persediaan saluran terperinci, lihat [Konfigurasi Aplikasi Sembang](../guides/chat-apps.ms.md). ## 🔧 Alat @@ -501,7 +501,7 @@ PicoClaw boleh mencari web untuk menyediakan maklumat terkini. Konfigurasikan da ### ⚙️ Alat Lain -PicoClaw menyertakan alat terbina dalam untuk operasi fail, pelaksanaan kod, penjadualan, dan banyak lagi. Lihat [Konfigurasi Alat](docs/tools_configuration.md) untuk butiran. +PicoClaw menyertakan alat terbina dalam untuk operasi fail, pelaksanaan kod, penjadualan, dan banyak lagi. Lihat [Konfigurasi Alat](../reference/tools_configuration.md) untuk butiran. ## 🎯 Kemahiran @@ -531,7 +531,7 @@ Tambah ke `config.json` anda: } ``` -Untuk butiran lanjut, lihat [Konfigurasi Alat - Kemahiran](docs/tools_configuration.md#skills-tool). +Untuk butiran lanjut, lihat [Konfigurasi Alat - Kemahiran](../reference/tools_configuration.md#skills-tool). ## 🔗 MCP (Protokol Konteks Model) @@ -554,9 +554,9 @@ PicoClaw menyokong [MCP](https://modelcontextprotocol.io/) secara natif — samb } ``` -Untuk konfigurasi MCP penuh (pengangkutan stdio, SSE, HTTP, Penemuan Alat), lihat [Konfigurasi Alat - MCP](docs/tools_configuration.md#mcp-tool). +Untuk konfigurasi MCP penuh (pengangkutan stdio, SSE, HTTP, Penemuan Alat), lihat [Konfigurasi Alat - MCP](../reference/tools_configuration.md#mcp-tool). -## ClawdChat Sertai Rangkaian Sosial Agent +## ClawdChat Sertai Rangkaian Sosial Agent Sambungkan PicoClaw ke Rangkaian Sosial Agent dengan menghantar satu mesej melalui CLI atau mana-mana Aplikasi Sembang yang disepadukan. @@ -597,20 +597,20 @@ Untuk panduan terperinci melebihi README ini: | Topik | Penerangan | |-------|------------| -| [Docker & Permulaan Pantas](docs/my/docker.md) | Persediaan Docker Compose, mod Launcher/Agent | -| [Aplikasi Sembang](docs/my/chat-apps.md) | Panduan persediaan 17+ saluran | -| [Konfigurasi](docs/my/configuration.md) | Pemboleh ubah persekitaran, susun atur ruang kerja | -| [Penyedia & Model](docs/providers.md) | 30+ penyedia LLM, penghalaan model | -| [Spawn & Tugasan Async](docs/my/spawn-tasks.md) | Tugasan pantas, tugasan panjang dengan spawn | -| [Penyelesaian Masalah](docs/my/troubleshooting.md) | Isu biasa dan penyelesaian | -| [Konfigurasi Alat](docs/tools_configuration.md) | Aktif/nyahaktif alat, dasar exec, MCP, Kemahiran | -| [Keserasian Perkakasan](docs/hardware-compatibility.md) | Papan yang diuji, keperluan minimum | +| [Docker & Permulaan Pantas](../guides/docker.ms.md) | Persediaan Docker Compose, mod Launcher/Agent | +| [Aplikasi Sembang](../guides/chat-apps.ms.md) | Panduan persediaan 17+ saluran | +| [Konfigurasi](../guides/configuration.ms.md) | Pemboleh ubah persekitaran, susun atur ruang kerja | +| [Penyedia & Model](../guides/providers.md) | 30+ penyedia LLM, penghalaan model | +| [Spawn & Tugasan Async](../guides/spawn-tasks.ms.md) | Tugasan pantas, tugasan panjang dengan spawn | +| [Penyelesaian Masalah](../operations/troubleshooting.ms.md) | Isu biasa dan penyelesaian | +| [Konfigurasi Alat](../reference/tools_configuration.md) | Aktif/nyahaktif alat, dasar exec, MCP, Kemahiran | +| [Keserasian Perkakasan](../guides/hardware-compatibility.md) | Papan yang diuji, keperluan minimum | ## 🤝 Sumbangan & Peta Jalan PR dialu-alukan! Kod sumber sengaja dibuat kecil dan mudah dibaca. -Lihat [Peta Jalan Komuniti](https://github.com/sipeed/picoclaw/issues/988) dan [CONTRIBUTING.md](CONTRIBUTING.md) untuk panduan. +Lihat [Peta Jalan Komuniti](https://github.com/sipeed/picoclaw/issues/988) dan [CONTRIBUTING.md](../../CONTRIBUTING.md) untuk panduan. Kumpulan pembangun sedang dibina, sertai selepas PR pertama anda digabungkan! @@ -619,4 +619,4 @@ Kumpulan Pengguna: Discord: WeChat: -Kod QR kumpulan WeChat +Kod QR kumpulan WeChat diff --git a/README.pt-br.md b/docs/project/README.pt-br.md similarity index 82% rename from README.pt-br.md rename to docs/project/README.pt-br.md index 25f82a180..56d4ddd63 100644 --- a/README.pt-br.md +++ b/docs/project/README.pt-br.md @@ -1,5 +1,5 @@
-PicoClaw +PicoClaw

PicoClaw: Assistente de IA Ultra-Eficiente em Go

@@ -14,11 +14,11 @@ Wiki
Twitter - + Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md) +[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
@@ -34,12 +34,12 @@

- +

- +

@@ -71,7 +71,7 @@ 2026-02-26 🎉 O PicoClaw atinge **20K Stars** em apenas 17 dias! Orquestração automática de channels e interfaces de capacidade estão disponíveis. -2026-02-16 🎉 O PicoClaw ultrapassa 12K Stars em uma semana! Funções de mantenedor da comunidade e [Roadmap](ROADMAP.md) lançados oficialmente. +2026-02-16 🎉 O PicoClaw ultrapassa 12K Stars em uma semana! Funções de mantenedor da comunidade e [Roadmap](../../ROADMAP.md) lançados oficialmente. 2026-02-13 🎉 O PicoClaw ultrapassa 5000 Stars em 4 dias! Roadmap do projeto e grupos de desenvolvedores em andamento. @@ -108,14 +108,14 @@ _*Builds recentes podem usar 10-20MB devido a merges rápidos de PRs. Otimizaç | **Tempo de boot**
(core 0,8GHz) | >500s | >30s | **<1s** | | **Custo** | Mac Mini $599 | Maioria das placas Linux ~$50 | **Qualquer placa Linux**
**a partir de $10** | -PicoClaw +PicoClaw -> **[Lista de Compatibilidade de Hardware](docs/pt-br/hardware-compatibility.md)** — Veja todas as placas testadas, de RISC-V de $5 ao Raspberry Pi e celulares Android. Sua placa não está listada? Envie um PR! +> **[Lista de Compatibilidade de Hardware](../guides/hardware-compatibility.pt-br.md)** — Veja todas as placas testadas, de RISC-V de $5 ao Raspberry Pi e celulares Android. Sua placa não está listada? Envie um PR!

-PicoClaw Hardware Compatibility +PicoClaw Hardware Compatibility

## 🦾 Demonstração @@ -129,9 +129,9 @@ _*Builds recentes podem usar 10-20MB devido a merges rápidos de PRs. Otimizaç

Busca na Web e Aprendizado

-

-

-

+

+

+

Desenvolver · Implantar · Escalar @@ -220,7 +220,7 @@ picoclaw-launcher > ```

-WebUI Launcher +WebUI Launcher

**Primeiros passos:** @@ -274,7 +274,7 @@ O macOS pode bloquear o `picoclaw-launcher` no primeiro lançamento porque ele f **Passo 1:** Dê um duplo clique em `picoclaw-launcher`. Você verá um aviso de segurança:

-Aviso do macOS Gatekeeper +Aviso do macOS Gatekeeper

> *"picoclaw-launcher" não foi aberto — A Apple não conseguiu verificar se "picoclaw-launcher" está livre de malware que possa prejudicar seu Mac ou comprometer sua privacidade.* @@ -282,7 +282,7 @@ O macOS pode bloquear o `picoclaw-launcher` no primeiro lançamento porque ele f **Passo 2:** Abra **Configurações do Sistema** → **Privacidade e Segurança** → role até a seção **Segurança** → clique em **Abrir Mesmo Assim** → confirme clicando em **Abrir Mesmo Assim** na caixa de diálogo.

-macOS Privacidade e Segurança — Abrir Mesmo Assim +macOS Privacidade e Segurança — Abrir Mesmo Assim

Após esta etapa única, o `picoclaw-launcher` abrirá normalmente nos lançamentos seguintes. @@ -298,7 +298,7 @@ picoclaw-launcher-tui ```

-TUI Launcher +TUI Launcher

**Primeiros passos:** @@ -307,6 +307,7 @@ Use os menus do TUI para: **1)** Configurar um Provider -> **2)** Configurar um Para documentação detalhada do TUI, veja [docs.picoclaw.io](https://docs.picoclaw.io). + ### 📱 Android Dê uma segunda vida ao seu celular de uma década! Transforme-o em um Assistente de IA inteligente com o PicoClaw. @@ -317,10 +318,10 @@ Pré-visualização: - - - - + + + +
@@ -344,7 +345,7 @@ termux-chroot ./picoclaw onboard # chroot fornece um layout padrão de sistema Em seguida, siga a seção Terminal Launcher abaixo para concluir a configuração. -PicoClaw on Termux +PicoClaw on Termux Para ambientes mínimos onde apenas o binário principal `picoclaw` está disponível (sem Launcher UI), você pode configurar tudo via linha de comando e um arquivo de configuração JSON. @@ -450,7 +451,7 @@ O PicoClaw suporta mais de 30 providers de LLM através da configuração `model } ``` -Para detalhes completos de configuração de providers, veja [Providers & Models](docs/pt-br/providers.md). +Para detalhes completos de configuração de providers, veja [Providers & Models](../guides/providers.pt-br.md). @@ -460,28 +461,28 @@ Converse com seu PicoClaw por meio de mais de 17 plataformas de mensagens: | Channel | Configuração | Protocolo | Docs | |---------|--------------|-----------|------| -| **Telegram** | Fácil (bot token) | Long polling | [Guia](docs/channels/telegram/README.pt-br.md) | -| **Discord** | Fácil (bot token + intents) | WebSocket | [Guia](docs/channels/discord/README.pt-br.md) | -| **WhatsApp** | Fácil (QR scan ou bridge URL) | Nativo / Bridge | [Guia](docs/pt-br/chat-apps.md#whatsapp) | -| **Weixin** | Fácil (scan QR nativo) | iLink API | [Guia](docs/pt-br/chat-apps.md#weixin) | -| **QQ** | Fácil (AppID + AppSecret) | WebSocket | [Guia](docs/channels/qq/README.pt-br.md) | -| **Slack** | Fácil (bot + app token) | Socket Mode | [Guia](docs/channels/slack/README.pt-br.md) | -| **Matrix** | Médio (homeserver + token) | Sync API | [Guia](docs/channels/matrix/README.pt-br.md) | -| **DingTalk** | Médio (credenciais do cliente) | Stream | [Guia](docs/channels/dingtalk/README.pt-br.md) | -| **Feishu / Lark** | Médio (App ID + Secret) | WebSocket/SDK | [Guia](docs/channels/feishu/README.pt-br.md) | -| **LINE** | Médio (credenciais + webhook) | Webhook | [Guia](docs/channels/line/README.pt-br.md) | -| **WeCom** | Fácil (login QR ou manual) | WebSocket | [Guia](docs/channels/wecom/README.md) | -| **IRC** | Médio (servidor + nick) | Protocolo IRC | [Guia](docs/pt-br/chat-apps.md#irc) | -| **OneBot** | Médio (WebSocket URL) | OneBot v11 | [Guia](docs/channels/onebot/README.pt-br.md) | -| **MaixCam** | Fácil (habilitar) | TCP socket | [Guia](docs/channels/maixcam/README.pt-br.md) | +| **Telegram** | Fácil (bot token) | Long polling | [Guia](../channels/telegram/README.pt-br.md) | +| **Discord** | Fácil (bot token + intents) | WebSocket | [Guia](../channels/discord/README.pt-br.md) | +| **WhatsApp** | Fácil (QR scan ou bridge URL) | Nativo / Bridge | [Guia](../guides/chat-apps.pt-br.md#whatsapp) | +| **Weixin** | Fácil (scan QR nativo) | iLink API | [Guia](../guides/chat-apps.pt-br.md#weixin) | +| **QQ** | Fácil (AppID + AppSecret) | WebSocket | [Guia](../channels/qq/README.pt-br.md) | +| **Slack** | Fácil (bot + app token) | Socket Mode | [Guia](../channels/slack/README.pt-br.md) | +| **Matrix** | Médio (homeserver + token) | Sync API | [Guia](../channels/matrix/README.pt-br.md) | +| **DingTalk** | Médio (credenciais do cliente) | Stream | [Guia](../channels/dingtalk/README.pt-br.md) | +| **Feishu / Lark** | Médio (App ID + Secret) | WebSocket/SDK | [Guia](../channels/feishu/README.pt-br.md) | +| **LINE** | Médio (credenciais + webhook) | Webhook | [Guia](../channels/line/README.pt-br.md) | +| **WeCom** | Fácil (login QR ou manual) | WebSocket | [Guia](../channels/wecom/README.pt-br.md) | +| **IRC** | Médio (servidor + nick) | Protocolo IRC | [Guia](../guides/chat-apps.pt-br.md#irc) | +| **OneBot** | Médio (WebSocket URL) | OneBot v11 | [Guia](../channels/onebot/README.pt-br.md) | +| **MaixCam** | Fácil (habilitar) | TCP socket | [Guia](../channels/maixcam/README.pt-br.md) | | **Pico** | Fácil (habilitar) | Protocolo nativo | Integrado | | **Pico Client** | Fácil (WebSocket URL) | WebSocket | Integrado | > Todos os channels baseados em webhook compartilham um único servidor HTTP do Gateway (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). O Feishu usa modo WebSocket/SDK e não utiliza o servidor HTTP compartilhado. -> A verbosidade dos logs é controlada por `gateway.log_level` (padrão: `warn`). Valores suportados: `debug`, `info`, `warn`, `error`, `fatal`. Também pode ser definido via `PICOCLAW_LOG_LEVEL`. Veja [Configuração](docs/pt-br/configuration.md#nível-de-log-do-gateway) para detalhes. +> A verbosidade dos logs é controlada por `gateway.log_level` (padrão: `warn`). Valores suportados: `debug`, `info`, `warn`, `error`, `fatal`. Também pode ser definido via `PICOCLAW_LOG_LEVEL`. Veja [Configuração](../guides/configuration.pt-br.md#nível-de-log-do-gateway) para detalhes. -Para instruções detalhadas de configuração de channels, veja [Configuração de Apps de Chat](docs/pt-br/chat-apps.md). +Para instruções detalhadas de configuração de channels, veja [Configuração de Apps de Chat](../guides/chat-apps.pt-br.md). ## 🔧 Ferramentas @@ -501,7 +502,7 @@ O PicoClaw pode pesquisar na web para fornecer informações atualizadas. Config ### ⚙️ Outras Ferramentas -O PicoClaw inclui ferramentas integradas para operações de arquivo, execução de código, agendamento e mais. Veja [Configuração de Ferramentas](docs/pt-br/tools_configuration.md) para detalhes. +O PicoClaw inclui ferramentas integradas para operações de arquivo, execução de código, agendamento e mais. Veja [Configuração de Ferramentas](../reference/tools_configuration.pt-br.md) para detalhes. ## 🎯 Skills @@ -531,7 +532,7 @@ Adicione ao seu `config.json`: } ``` -Para mais detalhes, veja [Configuração de Ferramentas - Skills](docs/pt-br/tools_configuration.md#skills-tool). +Para mais detalhes, veja [Configuração de Ferramentas - Skills](../reference/tools_configuration.pt-br.md#skills-tool). ## 🔗 MCP (Model Context Protocol) @@ -554,9 +555,9 @@ O PicoClaw suporta nativamente o [MCP](https://modelcontextprotocol.io/) — con } ``` -Para configuração completa de MCP (transportes stdio, SSE, HTTP, Tool Discovery), veja [Configuração de Ferramentas - MCP](docs/pt-br/tools_configuration.md#mcp-tool). +Para configuração completa de MCP (transportes stdio, SSE, HTTP, Tool Discovery), veja [Configuração de Ferramentas - MCP](../reference/tools_configuration.pt-br.md#mcp-tool). -## ClawdChat Junte-se à Rede Social de Agents +## ClawdChat Junte-se à Rede Social de Agents Conecte o PicoClaw à Rede Social de Agents simplesmente enviando uma única mensagem via CLI ou qualquer App de Chat integrado. @@ -597,23 +598,23 @@ Para guias detalhados além deste README: | Tópico | Descrição | |--------|-----------| -| [Docker & Início Rápido](docs/pt-br/docker.md) | Configuração do Docker Compose, modos Launcher/Agent | -| [Apps de Chat](docs/pt-br/chat-apps.md) | Guias de configuração para todos os 17+ channels | -| [Configuração](docs/pt-br/configuration.md) | Variáveis de ambiente, layout do workspace, sandbox de segurança | -| [Providers & Models](docs/pt-br/providers.md) | 30+ providers de LLM, roteamento de modelos, configuração de model_list | -| [Spawn & Tarefas Assíncronas](docs/pt-br/spawn-tasks.md) | Tarefas rápidas, tarefas longas com spawn, orquestração assíncrona de sub-agents | -| [Hooks](docs/hooks/README.md) | Sistema de hooks orientado a eventos: observadores, interceptores, hooks de aprovação | -| [Steering](docs/steering.md) | Injetar mensagens em um loop de agente em execução | -| [SubTurn](docs/subturn.md) | Coordenação de subagentes, controle de concorrência, ciclo de vida | -| [Solução de Problemas](docs/pt-br/troubleshooting.md) | Problemas comuns e soluções | -| [Configuração de Ferramentas](docs/pt-br/tools_configuration.md) | Habilitar/desabilitar por ferramenta, políticas de exec, MCP, Skills | -| [Compatibilidade de Hardware](docs/pt-br/hardware-compatibility.md) | Placas testadas, requisitos mínimos | +| [Docker & Início Rápido](../guides/docker.pt-br.md) | Configuração do Docker Compose, modos Launcher/Agent | +| [Apps de Chat](../guides/chat-apps.pt-br.md) | Guias de configuração para todos os 17+ channels | +| [Configuração](../guides/configuration.pt-br.md) | Variáveis de ambiente, layout do workspace, sandbox de segurança | +| [Providers & Models](../guides/providers.pt-br.md) | 30+ providers de LLM, roteamento de modelos, configuração de model_list | +| [Spawn & Tarefas Assíncronas](../guides/spawn-tasks.pt-br.md) | Tarefas rápidas, tarefas longas com spawn, orquestração assíncrona de sub-agents | +| [Hooks](../architecture/hooks/README.md) | Sistema de hooks orientado a eventos: observadores, interceptores, hooks de aprovação | +| [Steering](../architecture/steering.md) | Injetar mensagens em um loop de agente em execução | +| [SubTurn](../architecture/subturn.md) | Coordenação de subagentes, controle de concorrência, ciclo de vida | +| [Solução de Problemas](../operations/troubleshooting.pt-br.md) | Problemas comuns e soluções | +| [Configuração de Ferramentas](../reference/tools_configuration.pt-br.md) | Habilitar/desabilitar por ferramenta, políticas de exec, MCP, Skills | +| [Compatibilidade de Hardware](../guides/hardware-compatibility.pt-br.md) | Placas testadas, requisitos mínimos | ## 🤝 Contribuir & Roadmap PRs são bem-vindos! O código-fonte é intencionalmente pequeno e legível. -Veja nosso [Roadmap da Comunidade](https://github.com/sipeed/picoclaw/issues/988) e [CONTRIBUTING.md](CONTRIBUTING.md) para diretrizes. +Veja nosso [Roadmap da Comunidade](https://github.com/sipeed/picoclaw/issues/988) e [CONTRIBUTING.md](../../CONTRIBUTING.md) para diretrizes. Grupo de desenvolvedores em formação, entre após seu primeiro PR mesclado! @@ -622,4 +623,4 @@ Grupos de Usuários: Discord: WeChat: -WeChat group QR code +WeChat group QR code diff --git a/README.vi.md b/docs/project/README.vi.md similarity index 84% rename from README.vi.md rename to docs/project/README.vi.md index 98e0b9bc9..52a56796b 100644 --- a/README.vi.md +++ b/docs/project/README.vi.md @@ -1,5 +1,5 @@
-PicoClaw +PicoClaw

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

@@ -14,11 +14,11 @@ Wiki
Twitter - + Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md) +[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
@@ -34,12 +34,12 @@

- +

- +

@@ -71,7 +71,7 @@ 2026-02-26 🎉 PicoClaw đạt **20K Stars** chỉ trong 17 ngày! Tự động điều phối Channel và giao diện khả năng đã hoạt động. -2026-02-16 🎉 PicoClaw vượt 12K Stars trong một tuần! Vai trò người duy trì cộng đồng và [Lộ trình](ROADMAP.md) chính thức ra mắt. +2026-02-16 🎉 PicoClaw vượt 12K Stars trong một tuần! Vai trò người duy trì cộng đồng và [Lộ trình](../../ROADMAP.md) chính thức ra mắt. 2026-02-13 🎉 PicoClaw vượt 5000 Stars trong 4 ngày! Lộ trình dự án và nhóm nhà phát triển đang được xây dựng. @@ -108,14 +108,14 @@ _*Các bản build gần đây có thể dùng 10-20MB do merge PR nhanh. Tối | **Thời gian khởi động**
(lõi 0.8GHz) | >500s | >30s | **<1s** | | **Chi phí** | Mac Mini $599 | Hầu hết board Linux ~$50 | **Bất kỳ board Linux**
**từ $10** | -PicoClaw +PicoClaw -> **[Danh sách Tương thích Phần cứng](docs/vi/hardware-compatibility.md)** — Xem tất cả các board đã được kiểm tra, từ RISC-V $5 đến Raspberry Pi đến điện thoại Android. Board của bạn chưa có trong danh sách? Gửi PR! +> **[Danh sách Tương thích Phần cứng](../guides/hardware-compatibility.vi.md)** — Xem tất cả các board đã được kiểm tra, từ RISC-V $5 đến Raspberry Pi đến điện thoại Android. Board của bạn chưa có trong danh sách? Gửi PR!

-PicoClaw Hardware Compatibility +PicoClaw Hardware Compatibility

## 🦾 Minh họa @@ -129,9 +129,9 @@ _*Các bản build gần đây có thể dùng 10-20MB do merge PR nhanh. Tối

Tìm kiếm Web & Học tập

-

-

-

+

+

+

Phát triển · Triển khai · Mở rộng @@ -220,7 +220,7 @@ picoclaw-launcher > ```

-WebUI Launcher +WebUI Launcher

**Bắt đầu:** @@ -274,7 +274,7 @@ macOS có thể chặn `picoclaw-launcher` khi khởi chạy lần đầu vì n **Bước 1:** Nhấp đúp vào `picoclaw-launcher`. Bạn sẽ thấy cảnh báo bảo mật:

-Cảnh báo macOS Gatekeeper +Cảnh báo macOS Gatekeeper

> *"picoclaw-launcher" Không Mở Được — Apple không thể xác minh "picoclaw-launcher" không chứa phần mềm độc hại có thể gây hại cho Mac hoặc xâm phạm quyền riêng tư của bạn.* @@ -282,7 +282,7 @@ macOS có thể chặn `picoclaw-launcher` khi khởi chạy lần đầu vì n **Bước 2:** Mở **Cài đặt Hệ thống** → **Quyền riêng tư & Bảo mật** → cuộn xuống phần **Bảo mật** → nhấp **Vẫn Mở** → xác nhận bằng cách nhấp **Vẫn Mở** trong hộp thoại.

-macOS Quyền riêng tư & Bảo mật — Vẫn Mở +macOS Quyền riêng tư & Bảo mật — Vẫn Mở

Sau bước này, `picoclaw-launcher` sẽ mở bình thường trong các lần khởi chạy tiếp theo. @@ -298,7 +298,7 @@ picoclaw-launcher-tui ```

-TUI Launcher +TUI Launcher

**Bắt đầu:** @@ -307,6 +307,7 @@ Sử dụng menu TUI để: **1)** Cấu hình Provider -> **2)** Cấu hình Ch Để biết tài liệu TUI chi tiết, xem [docs.picoclaw.io](https://docs.picoclaw.io). + ### 📱 Android Hãy cho chiếc điện thoại cũ của bạn một cuộc sống mới! Biến nó thành Trợ lý AI thông minh với PicoClaw. @@ -317,10 +318,10 @@ Xem trước: - - - - + + + +
@@ -344,7 +345,7 @@ termux-chroot ./picoclaw onboard # chroot provides a standard Linux filesystem Sau đó làm theo phần Terminal Launcher bên dưới để hoàn tất cấu hình. -PicoClaw on Termux +PicoClaw on Termux Đối với các môi trường tối giản chỉ có binary lõi `picoclaw` (không có Launcher UI), bạn có thể cấu hình mọi thứ qua dòng lệnh và tệp cấu hình JSON. @@ -450,7 +451,7 @@ PicoClaw hỗ trợ 30+ Provider LLM thông qua cấu hình `model_list`. Sử d } ``` -Để biết chi tiết cấu hình provider đầy đủ, xem [Providers & Models](docs/vi/providers.md). +Để biết chi tiết cấu hình provider đầy đủ, xem [Providers & Models](../guides/providers.vi.md). @@ -460,28 +461,28 @@ Trò chuyện với PicoClaw của bạn qua 17+ nền tảng nhắn tin: | Channel | Thiết lập | Protocol | Tài liệu | |---------|-----------|----------|----------| -| **Telegram** | Dễ (bot token) | Long polling | [Hướng dẫn](docs/channels/telegram/README.vi.md) | -| **Discord** | Dễ (bot token + intents) | WebSocket | [Hướng dẫn](docs/channels/discord/README.vi.md) | -| **WhatsApp** | Dễ (quét QR hoặc bridge URL) | Native / Bridge | [Hướng dẫn](docs/vi/chat-apps.md#whatsapp) | -| **Weixin** | Dễ (quét QR gốc) | iLink API | [Hướng dẫn](docs/vi/chat-apps.md#weixin) | -| **QQ** | Dễ (AppID + AppSecret) | WebSocket | [Hướng dẫn](docs/channels/qq/README.vi.md) | -| **Slack** | Dễ (bot + app token) | Socket Mode | [Hướng dẫn](docs/channels/slack/README.vi.md) | -| **Matrix** | Trung bình (homeserver + token) | Sync API | [Hướng dẫn](docs/channels/matrix/README.vi.md) | -| **DingTalk** | Trung bình (client credentials) | Stream | [Hướng dẫn](docs/channels/dingtalk/README.vi.md) | -| **Feishu / Lark** | Trung bình (App ID + Secret) | WebSocket/SDK | [Hướng dẫn](docs/channels/feishu/README.vi.md) | -| **LINE** | Trung bình (credentials + webhook) | Webhook | [Hướng dẫn](docs/channels/line/README.vi.md) | -| **WeCom** | Dễ (đăng nhập QR hoặc thủ công) | WebSocket | [Hướng dẫn](docs/channels/wecom/README.md) | -| **IRC** | Trung bình (server + nick) | IRC protocol | [Hướng dẫn](docs/vi/chat-apps.md#irc) | -| **OneBot** | Trung bình (WebSocket URL) | OneBot v11 | [Hướng dẫn](docs/channels/onebot/README.vi.md) | -| **MaixCam** | Dễ (bật) | TCP socket | [Hướng dẫn](docs/channels/maixcam/README.vi.md) | +| **Telegram** | Dễ (bot token) | Long polling | [Hướng dẫn](../channels/telegram/README.vi.md) | +| **Discord** | Dễ (bot token + intents) | WebSocket | [Hướng dẫn](../channels/discord/README.vi.md) | +| **WhatsApp** | Dễ (quét QR hoặc bridge URL) | Native / Bridge | [Hướng dẫn](../guides/chat-apps.vi.md#whatsapp) | +| **Weixin** | Dễ (quét QR gốc) | iLink API | [Hướng dẫn](../guides/chat-apps.vi.md#weixin) | +| **QQ** | Dễ (AppID + AppSecret) | WebSocket | [Hướng dẫn](../channels/qq/README.vi.md) | +| **Slack** | Dễ (bot + app token) | Socket Mode | [Hướng dẫn](../channels/slack/README.vi.md) | +| **Matrix** | Trung bình (homeserver + token) | Sync API | [Hướng dẫn](../channels/matrix/README.vi.md) | +| **DingTalk** | Trung bình (client credentials) | Stream | [Hướng dẫn](../channels/dingtalk/README.vi.md) | +| **Feishu / Lark** | Trung bình (App ID + Secret) | WebSocket/SDK | [Hướng dẫn](../channels/feishu/README.vi.md) | +| **LINE** | Trung bình (credentials + webhook) | Webhook | [Hướng dẫn](../channels/line/README.vi.md) | +| **WeCom** | Dễ (đăng nhập QR hoặc thủ công) | WebSocket | [Hướng dẫn](../channels/wecom/README.vi.md) | +| **IRC** | Trung bình (server + nick) | IRC protocol | [Hướng dẫn](../guides/chat-apps.vi.md#irc) | +| **OneBot** | Trung bình (WebSocket URL) | OneBot v11 | [Hướng dẫn](../channels/onebot/README.vi.md) | +| **MaixCam** | Dễ (bật) | TCP socket | [Hướng dẫn](../channels/maixcam/README.vi.md) | | **Pico** | Dễ (bật) | Native protocol | Tích hợp sẵn | | **Pico Client** | Dễ (WebSocket URL) | WebSocket | Tích hợp sẵn | > Tất cả các Channel dựa trên webhook dùng chung một Gateway HTTP server (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). Feishu sử dụng chế độ WebSocket/SDK và không dùng HTTP server chung. -> Mức độ chi tiết log được kiểm soát bởi `gateway.log_level` (mặc định: `warn`). Các giá trị được hỗ trợ: `debug`, `info`, `warn`, `error`, `fatal`. Cũng có thể đặt qua `PICOCLAW_LOG_LEVEL`. Xem [Cấu hình](docs/vi/configuration.md#mức-log-của-gateway) để biết thêm chi tiết. +> Mức độ chi tiết log được kiểm soát bởi `gateway.log_level` (mặc định: `warn`). Các giá trị được hỗ trợ: `debug`, `info`, `warn`, `error`, `fatal`. Cũng có thể đặt qua `PICOCLAW_LOG_LEVEL`. Xem [Cấu hình](../guides/configuration.vi.md#mức-log-của-gateway) để biết thêm chi tiết. -Để biết hướng dẫn thiết lập Channel chi tiết, xem [Cấu hình Ứng dụng Chat](docs/vi/chat-apps.md). +Để biết hướng dẫn thiết lập Channel chi tiết, xem [Cấu hình Ứng dụng Chat](../guides/chat-apps.vi.md). ## 🔧 Tools @@ -501,7 +502,7 @@ PicoClaw có thể tìm kiếm web để cung cấp thông tin cập nhật. C ### ⚙️ Các Tools Khác -PicoClaw bao gồm các tool tích hợp sẵn cho thao tác tệp, thực thi mã, lên lịch và nhiều hơn nữa. Xem [Cấu hình Tools](docs/vi/tools_configuration.md) để biết chi tiết. +PicoClaw bao gồm các tool tích hợp sẵn cho thao tác tệp, thực thi mã, lên lịch và nhiều hơn nữa. Xem [Cấu hình Tools](../reference/tools_configuration.vi.md) để biết chi tiết. ## 🎯 Skills @@ -531,7 +532,7 @@ Thêm vào `config.json` của bạn: } ``` -Để biết thêm chi tiết, xem [Cấu hình Tools - Skills](docs/vi/tools_configuration.md#skills-tool). +Để biết thêm chi tiết, xem [Cấu hình Tools - Skills](../reference/tools_configuration.vi.md#skills-tool). ## 🔗 MCP (Model Context Protocol) @@ -554,9 +555,9 @@ PicoClaw hỗ trợ [MCP](https://modelcontextprotocol.io/) gốc — kết nố } ``` -Để biết cấu hình MCP đầy đủ (stdio, SSE, HTTP transports, Tool Discovery), xem [Cấu hình Tools - MCP](docs/vi/tools_configuration.md#mcp-tool). +Để biết cấu hình MCP đầy đủ (stdio, SSE, HTTP transports, Tool Discovery), xem [Cấu hình Tools - MCP](../reference/tools_configuration.vi.md#mcp-tool). -## ClawdChat Tham gia Mạng xã hội Agent +## ClawdChat Tham gia Mạng xã hội Agent Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một tin nhắn duy nhất qua CLI hoặc bất kỳ Ứng dụng Chat nào đã tích hợp. @@ -597,23 +598,23 @@ PicoClaw hỗ trợ nhắc nhở đã lên lịch và tác vụ định kỳ th | Chủ đề | Mô tả | |--------|-------| -| [Docker & Khởi động Nhanh](docs/vi/docker.md) | Thiết lập Docker Compose, chế độ Launcher/Agent | -| [Ứng dụng Chat](docs/vi/chat-apps.md) | Hướng dẫn thiết lập 17+ Channel | -| [Cấu hình](docs/vi/configuration.md) | Biến môi trường, bố cục workspace, sandbox bảo mật | -| [Providers & Models](docs/vi/providers.md) | 30+ Provider LLM, định tuyến mô hình, cấu hình model_list | -| [Spawn & Tác vụ Bất đồng bộ](docs/vi/spawn-tasks.md) | Tác vụ nhanh, tác vụ dài với spawn, điều phối sub-agent bất đồng bộ | -| [Hooks](docs/hooks/README.md) | Hệ thống hook hướng sự kiện: observer, interceptor, approval hook | -| [Steering](docs/steering.md) | Chèn tin nhắn vào vòng lặp agent đang chạy | -| [SubTurn](docs/subturn.md) | Điều phối subagent, kiểm soát đồng thời, vòng đời | -| [Khắc phục sự cố](docs/vi/troubleshooting.md) | Các vấn đề thường gặp và giải pháp | -| [Cấu hình Tools](docs/vi/tools_configuration.md) | Bật/tắt từng tool, chính sách exec, MCP, Skills | -| [Tương thích Phần cứng](docs/vi/hardware-compatibility.md) | Các board đã kiểm tra, yêu cầu tối thiểu | +| [Docker & Khởi động Nhanh](../guides/docker.vi.md) | Thiết lập Docker Compose, chế độ Launcher/Agent | +| [Ứng dụng Chat](../guides/chat-apps.vi.md) | Hướng dẫn thiết lập 17+ Channel | +| [Cấu hình](../guides/configuration.vi.md) | Biến môi trường, bố cục workspace, sandbox bảo mật | +| [Providers & Models](../guides/providers.vi.md) | 30+ Provider LLM, định tuyến mô hình, cấu hình model_list | +| [Spawn & Tác vụ Bất đồng bộ](../guides/spawn-tasks.vi.md) | Tác vụ nhanh, tác vụ dài với spawn, điều phối sub-agent bất đồng bộ | +| [Hooks](../architecture/hooks/README.md) | Hệ thống hook hướng sự kiện: observer, interceptor, approval hook | +| [Steering](../architecture/steering.md) | Chèn tin nhắn vào vòng lặp agent đang chạy | +| [SubTurn](../architecture/subturn.md) | Điều phối subagent, kiểm soát đồng thời, vòng đời | +| [Khắc phục sự cố](../operations/troubleshooting.vi.md) | Các vấn đề thường gặp và giải pháp | +| [Cấu hình Tools](../reference/tools_configuration.vi.md) | Bật/tắt từng tool, chính sách exec, MCP, Skills | +| [Tương thích Phần cứng](../guides/hardware-compatibility.vi.md) | Các board đã kiểm tra, yêu cầu tối thiểu | ## 🤝 Đóng góp & Lộ trình PR luôn được chào đón! Codebase được thiết kế nhỏ gọn và dễ đọc. -Xem [Lộ trình Cộng đồng](https://github.com/sipeed/picoclaw/issues/988) và [CONTRIBUTING.md](CONTRIBUTING.md) để biết hướng dẫn. +Xem [Lộ trình Cộng đồng](https://github.com/sipeed/picoclaw/issues/988) và [CONTRIBUTING.md](../../CONTRIBUTING.md) để biết hướng dẫn. Nhóm nhà phát triển đang được xây dựng, tham gia sau khi PR đầu tiên của bạn được merge! @@ -622,4 +623,4 @@ Nhóm Người dùng: Discord: WeChat: -WeChat group QR code +WeChat group QR code diff --git a/README.zh.md b/docs/project/README.zh.md similarity index 82% rename from README.zh.md rename to docs/project/README.zh.md index 1a0659e22..a4fc892bd 100644 --- a/README.zh.md +++ b/docs/project/README.zh.md @@ -1,5 +1,5 @@
-PicoClaw +PicoClaw

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

@@ -14,11 +14,11 @@ Wiki
Twitter - + Discord

-**中文** | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md) +**中文** | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
@@ -34,12 +34,12 @@

- +

- +

@@ -71,7 +71,7 @@ 2026-02-26 🎉 PicoClaw 仅 17 天突破 **20K Stars**!频道自动编排和能力接口上线。 -2026-02-16 🎉 PicoClaw 一周内突破 12K Stars!社区维护者角色和 [路线图](ROADMAP.md) 正式发布。 +2026-02-16 🎉 PicoClaw 一周内突破 12K Stars!社区维护者角色和 [路线图](../../ROADMAP.md) 正式发布。 2026-02-13 🎉 PicoClaw 4 天内突破 5000 Stars!项目路线图和开发者群组筹建中。 @@ -108,14 +108,14 @@ _*近期版本因快速合并 PR 可能占用 10–20MB,资源优化已列入 | **启动时间**
(0.8GHz core) | >500s | >30s | **<1s** | | **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板**
**低至 $10** | -PicoClaw +PicoClaw -> 📋 **[硬件兼容列表](docs/zh/hardware-compatibility.md)** — 查看所有已测试的板卡,从 $5 RISC-V 到树莓派到安卓手机。你的板卡没在列表中?欢迎提交 PR! +> 📋 **[硬件兼容列表](../guides/hardware-compatibility.zh.md)** — 查看所有已测试的板卡,从 $5 RISC-V 到树莓派到安卓手机。你的板卡没在列表中?欢迎提交 PR!

-PicoClaw Hardware Compatibility +PicoClaw Hardware Compatibility

## 🦾 演示 @@ -129,9 +129,9 @@ _*近期版本因快速合并 PR 可能占用 10–20MB,资源优化已列入

🔎 网络搜索与学习

-

-

-

+

+

+

开发 • 部署 • 扩展 @@ -220,7 +220,7 @@ picoclaw-launcher > ```

-WebUI Launcher +WebUI Launcher

**开始使用:** @@ -274,7 +274,7 @@ macOS 可能会在首次启动时拦截 `picoclaw-launcher`,因为它从互联 **第一步:** 双击 `picoclaw-launcher`,会出现安全警告:

-macOS Gatekeeper 警告 +macOS Gatekeeper 警告

> *"picoclaw-launcher" 无法打开 — Apple 无法验证 "picoclaw-launcher" 不含可能损害 Mac 或危及隐私的恶意软件。* @@ -282,7 +282,7 @@ macOS 可能会在首次启动时拦截 `picoclaw-launcher`,因为它从互联 **第二步:** 打开**系统设置** → **隐私与安全性** → 向下滚动找到**安全性**部分 → 点击**仍要打开** → 在弹窗中再次点击**打开**。

-macOS 隐私与安全性 — 仍要打开 +macOS 隐私与安全性 — 仍要打开

完成这一次操作后,后续启动 `picoclaw-launcher` 将不再弹出警告。 @@ -298,7 +298,7 @@ picoclaw-launcher-tui ```

-TUI Launcher +TUI Launcher

**开始使用:** @@ -307,6 +307,7 @@ picoclaw-launcher-tui 详细 TUI 文档请参阅 [docs.picoclaw.io](https://docs.picoclaw.io)。 + ### 📱 Android 让你十年前的旧手机焕发新生!将它变成你的 AI 助手。 @@ -317,10 +318,10 @@ picoclaw-launcher-tui - - - - + + + +
@@ -344,7 +345,7 @@ termux-chroot ./picoclaw onboard # chroot 提供标准 Linux 文件系统布 然后跟随下面的"Terminal Launcher"章节继续配置。 -PicoClaw on Termux +PicoClaw on Termux 对于只有 `picoclaw` 核心二进制文件的极简环境(无 Launcher UI),可通过命令行和 JSON 配置文件完成所有配置。 @@ -450,7 +451,7 @@ PicoClaw 通过 `model_list` 配置支持 30+ LLM Provider,使用 `协议/模 } ``` -完整 Provider 配置详情请参阅 [Providers & Models](docs/zh/providers.md)。 +完整 Provider 配置详情请参阅 [Providers & Models](../guides/providers.zh.md)。 @@ -460,29 +461,29 @@ PicoClaw 通过 `model_list` 配置支持 30+ LLM Provider,使用 `协议/模 | Channel | 配置难度 | 协议 | 文档 | |---------|----------|------|------| -| **Telegram** | 简单(bot token) | 长轮询 | [指南](docs/channels/telegram/README.zh.md) | -| **Discord** | 简单(bot token + intents) | WebSocket | [指南](docs/channels/discord/README.zh.md) | -| **WhatsApp** | 简单(扫码或 bridge URL) | 原生 / Bridge | [指南](docs/zh/chat-apps.md#whatsapp) | -| **微信 (Weixin)** | 简单(扫码登录) | iLink API | [指南](docs/zh/chat-apps.md#weixin) | -| **QQ** | 简单(AppID + AppSecret) | WebSocket | [指南](docs/channels/qq/README.zh.md) | -| **Slack** | 简单(bot + app token) | Socket Mode | [指南](docs/channels/slack/README.zh.md) | -| **Matrix** | 中等(homeserver + token) | Sync API | [指南](docs/channels/matrix/README.zh.md) | -| **钉钉** | 中等(client credentials) | Stream | [指南](docs/channels/dingtalk/README.zh.md) | -| **飞书 / Lark** | 中等(App ID + Secret) | WebSocket/SDK | [指南](docs/channels/feishu/README.zh.md) | -| **LINE** | 中等(credentials + webhook) | Webhook | [指南](docs/channels/line/README.zh.md) | -| **企业微信** | 简单(扫码登录或手动配置) | WebSocket | [指南](docs/channels/wecom/README.zh.md) | -| **VK** | 简单(群组 token) | Long Poll | [指南](docs/channels/vk/README.md) | -| **IRC** | 中等(server + nick) | IRC 协议 | [指南](docs/zh/chat-apps.md#irc) | -| **OneBot** | 中等(WebSocket URL) | OneBot v11 | [指南](docs/channels/onebot/README.zh.md) | -| **MaixCam** | 简单(启用即可) | TCP socket | [指南](docs/channels/maixcam/README.zh.md) | +| **Telegram** | 简单(bot token) | 长轮询 | [指南](../channels/telegram/README.zh.md) | +| **Discord** | 简单(bot token + intents) | WebSocket | [指南](../channels/discord/README.zh.md) | +| **WhatsApp** | 简单(扫码或 bridge URL) | 原生 / Bridge | [指南](../guides/chat-apps.zh.md#whatsapp) | +| **微信 (Weixin)** | 简单(扫码登录) | iLink API | [指南](../guides/chat-apps.zh.md#weixin) | +| **QQ** | 简单(AppID + AppSecret) | WebSocket | [指南](../channels/qq/README.zh.md) | +| **Slack** | 简单(bot + app token) | Socket Mode | [指南](../channels/slack/README.zh.md) | +| **Matrix** | 中等(homeserver + token) | Sync API | [指南](../channels/matrix/README.zh.md) | +| **钉钉** | 中等(client credentials) | Stream | [指南](../channels/dingtalk/README.zh.md) | +| **飞书 / Lark** | 中等(App ID + Secret) | WebSocket/SDK | [指南](../channels/feishu/README.zh.md) | +| **LINE** | 中等(credentials + webhook) | Webhook | [指南](../channels/line/README.zh.md) | +| **企业微信** | 简单(扫码登录或手动配置) | WebSocket | [指南](../channels/wecom/README.zh.md) | +| **VK** | 简单(群组 token) | Long Poll | [指南](../channels/vk/README.md) | +| **IRC** | 中等(server + nick) | IRC 协议 | [指南](../guides/chat-apps.zh.md#irc) | +| **OneBot** | 中等(WebSocket URL) | OneBot v11 | [指南](../channels/onebot/README.zh.md) | +| **MaixCam** | 简单(启用即可) | TCP socket | [指南](../channels/maixcam/README.zh.md) | | **Pico** | 简单(启用即可) | 原生协议 | 内置 | | **Pico Client** | 简单(WebSocket URL) | WebSocket | 内置 | > 所有基于 Webhook 的 Channel 共用同一个 Gateway HTTP 服务器(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`)。飞书使用 WebSocket/SDK 模式,不使用共享 HTTP 服务器。 -> 日志详细程度通过 `gateway.log_level` 控制(默认:`warn`)。支持的值:`debug`、`info`、`warn`、`error`、`fatal`。也可通过 `PICOCLAW_LOG_LEVEL` 环境变量设置。详见[配置指南](docs/zh/configuration.md#gateway-日志等级)。 +> 日志详细程度通过 `gateway.log_level` 控制(默认:`warn`)。支持的值:`debug`、`info`、`warn`、`error`、`fatal`。也可通过 `PICOCLAW_LOG_LEVEL` 环境变量设置。详见[配置指南](../guides/configuration.zh.md#gateway-日志等级)。 -详细 Channel 配置说明请参阅 [聊天应用配置](docs/zh/chat-apps.md)。 +详细 Channel 配置说明请参阅 [聊天应用配置](../guides/chat-apps.zh.md)。 ## 🔧 Tools @@ -502,7 +503,7 @@ PicoClaw 可以搜索网络以提供最新信息。在 `tools.web` 中配置: ### ⚙️ 其他工具 -PicoClaw 内置文件操作、代码执行、定时任务等工具。详情请参阅 [工具配置](docs/zh/tools_configuration.md)。 +PicoClaw 内置文件操作、代码执行、定时任务等工具。详情请参阅 [工具配置](../reference/tools_configuration.zh.md)。 ## 🎯 Skills @@ -539,7 +540,7 @@ picoclaw skills install `tools.skills.github.*` 已废弃,请改用 `tools.skills.registries.github.*`。 -更多详情请参阅 [工具配置 - Skills](docs/zh/tools_configuration.md#skills-tool)。 +更多详情请参阅 [工具配置 - Skills](../reference/tools_configuration.zh.md#skills-tool)。 ## 🔗 MCP (Model Context Protocol) @@ -562,9 +563,9 @@ PicoClaw 原生支持 [MCP](https://modelcontextprotocol.io/) — 连接任意 M } ``` -完整 MCP 配置(stdio、SSE、HTTP 传输、Tool Discovery)请参阅 [工具配置 - MCP](docs/zh/tools_configuration.md#mcp-tool)。 +完整 MCP 配置(stdio、SSE、HTTP 传输、Tool Discovery)请参阅 [工具配置 - MCP](../reference/tools_configuration.zh.md#mcp-tool)。 -## ClawdChat 加入 Agent 社交网络 +## ClawdChat 加入 Agent 社交网络 通过 CLI 或任何已集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。 @@ -605,23 +606,23 @@ PicoClaw 通过 `cron` 工具支持定时提醒和重复任务: | 主题 | 说明 | |------|------| -| 🐳 [Docker 与快速开始](docs/zh/docker.md) | Docker Compose 配置、Launcher/Agent 模式、快速开始 | -| 💬 [聊天应用配置](docs/zh/chat-apps.md) | 全部 17+ Channel 配置指南 | -| ⚙️ [配置指南](docs/zh/configuration.md) | 环境变量、工作区布局、安全沙箱 | -| 🔌 [提供商与模型配置](docs/zh/providers.md) | 30+ LLM Provider、模型路由、model_list 配置 | -| 🔄 [异步任务与 Spawn](docs/zh/spawn-tasks.md) | 快速任务、长任务与 Spawn、异步子 Agent 编排 | -| 🪝 [Hook 系统](docs/hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook | -| 🎯 [Steering](docs/steering.md) | 在工具调用间向运行中的 Agent 注入消息 | -| 🔀 [SubTurn](docs/subturn.md) | 子 Agent 协调、并发控制、生命周期管理 | -| 🐛 [疑难解答](docs/zh/troubleshooting.md) | 常见问题与解决方案 | -| 🔧 [工具配置](docs/zh/tools_configuration.md) | 工具启用/禁用、执行策略、MCP、Skills | -| 📋 [硬件兼容列表](docs/zh/hardware-compatibility.md) | 已测试板卡、最低要求 | +| 🐳 [Docker 与快速开始](../guides/docker.zh.md) | Docker Compose 配置、Launcher/Agent 模式、快速开始 | +| 💬 [聊天应用配置](../guides/chat-apps.zh.md) | 全部 17+ Channel 配置指南 | +| ⚙️ [配置指南](../guides/configuration.zh.md) | 环境变量、工作区布局、安全沙箱 | +| 🔌 [提供商与模型配置](../guides/providers.zh.md) | 30+ LLM Provider、模型路由、model_list 配置 | +| 🔄 [异步任务与 Spawn](../guides/spawn-tasks.zh.md) | 快速任务、长任务与 Spawn、异步子 Agent 编排 | +| 🪝 [Hook 系统](../architecture/hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook | +| 🎯 [Steering](../architecture/steering.md) | 在工具调用间向运行中的 Agent 注入消息 | +| 🔀 [SubTurn](../architecture/subturn.md) | 子 Agent 协调、并发控制、生命周期管理 | +| 🐛 [疑难解答](../operations/troubleshooting.zh.md) | 常见问题与解决方案 | +| 🔧 [工具配置](../reference/tools_configuration.zh.md) | 工具启用/禁用、执行策略、MCP、Skills | +| 📋 [硬件兼容列表](../guides/hardware-compatibility.zh.md) | 已测试板卡、最低要求 | ## 🤝 贡献与路线图 欢迎提交 PR!代码库刻意保持小巧和可读。🤗 -查看完整的 [社区路线图](https://github.com/sipeed/picoclaw/issues/988) 和 [CONTRIBUTING.md](CONTRIBUTING.md)。 +查看完整的 [社区路线图](https://github.com/sipeed/picoclaw/issues/988) 和 [CONTRIBUTING.md](../../CONTRIBUTING.md)。 开发者群组正在组建中,入群门槛:至少合并过 1 个 PR。 @@ -630,4 +631,4 @@ PicoClaw 通过 `cron` 工具支持定时提醒和重复任务: Discord: WeChat: -WeChat group QR code +WeChat group QR code diff --git a/docs/reference/README.md b/docs/reference/README.md new file mode 100644 index 000000000..eec5c09b4 --- /dev/null +++ b/docs/reference/README.md @@ -0,0 +1,8 @@ +# Reference + +Reference docs for precise configuration, runtime behavior, and tool semantics. + +- [Tools Configuration](tools_configuration.md): per-tool configuration, execution policies, MCP, and Skills. +- [Scheduled Tasks and Cron Jobs](cron.md): schedule types, delivery modes, command gates, and storage. +- [Config Schema Versioning Guide](config-versioning.md): config schema migration and compatibility notes. +- [Dynamic Rate Limiting](rate-limiting.md): request throttling behavior for LLM providers. diff --git a/docs/config-versioning.md b/docs/reference/config-versioning.md similarity index 100% rename from docs/config-versioning.md rename to docs/reference/config-versioning.md diff --git a/docs/cron.md b/docs/reference/cron.md similarity index 100% rename from docs/cron.md rename to docs/reference/cron.md diff --git a/docs/rate-limiting.md b/docs/reference/rate-limiting.md similarity index 100% rename from docs/rate-limiting.md rename to docs/reference/rate-limiting.md diff --git a/docs/fr/tools_configuration.md b/docs/reference/tools_configuration.fr.md similarity index 99% rename from docs/fr/tools_configuration.md rename to docs/reference/tools_configuration.fr.md index e64217c46..109c9cd6f 100644 --- a/docs/fr/tools_configuration.md +++ b/docs/reference/tools_configuration.fr.md @@ -1,6 +1,6 @@ # 🔧 Configuration des Outils -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) La configuration des outils de PicoClaw se trouve dans le champ `tools` de `config.json`. @@ -207,6 +207,7 @@ L'outil cron est utilisé pour planifier des tâches périodiques. |------------------------|------|------------|----------------------------------------------------| | `exec_timeout_minutes` | int | 5 | Délai d'expiration en minutes, 0 signifie sans limite | + ## Outil MCP L'outil MCP permet l'intégration avec des serveurs Model Context Protocol externes. @@ -362,6 +363,7 @@ Au lieu de charger tous les outils, le LLM reçoit un outil de recherche léger } ``` + ## Outil Skills L'outil skills configure la découverte et l'installation de compétences via des registres comme ClawHub. diff --git a/docs/ja/tools_configuration.md b/docs/reference/tools_configuration.ja.md similarity index 99% rename from docs/ja/tools_configuration.md rename to docs/reference/tools_configuration.ja.md index a31e58984..a331c869e 100644 --- a/docs/ja/tools_configuration.md +++ b/docs/reference/tools_configuration.ja.md @@ -1,6 +1,6 @@ # 🔧 ツール設定 -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る PicoClaw のツール設定は `config.json` の `tools` フィールドにあります。 @@ -207,6 +207,7 @@ Cron ツールは定期タスクのスケジューリングに使用されます |------------------------|-----|------------|-----------------------------------------| | `exec_timeout_minutes` | int | 5 | 実行タイムアウト(分)、0 は無制限 | + ## MCP ツール MCP ツールは外部の Model Context Protocol サーバーとの統合を可能にします。 @@ -362,6 +363,7 @@ MCP ツールは外部の Model Context Protocol サーバーとの統合を可 } ``` + ## Skills ツール Skills ツールは ClawHub などのレジストリを通じたスキルの発見とインストールを設定します。 diff --git a/docs/tools_configuration.md b/docs/reference/tools_configuration.md similarity index 99% rename from docs/tools_configuration.md rename to docs/reference/tools_configuration.md index b043716ed..fa33f0bb4 100644 --- a/docs/tools_configuration.md +++ b/docs/reference/tools_configuration.md @@ -30,7 +30,7 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`. Before tool results are sent to the LLM, PicoClaw can filter sensitive values (API keys, tokens, secrets) from the output. This prevents the LLM from seeing its own credentials. -See [Sensitive Data Filtering](../sensitive_data_filtering.md) for full documentation. +See [Sensitive Data Filtering](../security/sensitive_data_filtering.md) for full documentation. | Config | Type | Default | Description | |--------|------|---------|-------------| diff --git a/docs/pt-br/tools_configuration.md b/docs/reference/tools_configuration.pt-br.md similarity index 99% rename from docs/pt-br/tools_configuration.md rename to docs/reference/tools_configuration.pt-br.md index 0eea7209a..3dae0f908 100644 --- a/docs/pt-br/tools_configuration.md +++ b/docs/reference/tools_configuration.pt-br.md @@ -1,6 +1,6 @@ # 🔧 Configuração de Ferramentas -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) A configuração de ferramentas do PicoClaw está localizada no campo `tools` do `config.json`. @@ -207,6 +207,7 @@ A ferramenta cron é usada para agendar tarefas periódicas. |------------------------|------|--------|-----------------------------------------------------| | `exec_timeout_minutes` | int | 5 | Tempo limite de execução em minutos, 0 significa sem limite | + ## Ferramenta MCP A ferramenta MCP permite a integração com servidores Model Context Protocol externos. @@ -362,6 +363,7 @@ Em vez de carregar todas as ferramentas, o LLM recebe uma ferramenta de pesquisa } ``` + ## Ferramenta Skills A ferramenta skills configura a descoberta e instalação de habilidades via registros como o ClawHub. diff --git a/docs/vi/tools_configuration.md b/docs/reference/tools_configuration.vi.md similarity index 99% rename from docs/vi/tools_configuration.md rename to docs/reference/tools_configuration.vi.md index 14abbfba7..7d65ca377 100644 --- a/docs/vi/tools_configuration.md +++ b/docs/reference/tools_configuration.vi.md @@ -1,6 +1,6 @@ # 🔧 Cấu Hình Công Cụ -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) Cấu hình công cụ của PicoClaw nằm trong trường `tools` của `config.json`. @@ -207,6 +207,7 @@ Công cụ cron được sử dụng để lên lịch các tác vụ định k |--------------------------|------|----------|-----------------------------------------------------| | `exec_timeout_minutes` | int | 5 | Thời gian chờ thực thi tính bằng phút, 0 nghĩa là không giới hạn | + ## Công cụ MCP Công cụ MCP cho phép tích hợp với các máy chủ Model Context Protocol bên ngoài. @@ -362,6 +363,7 @@ Thay vì tải tất cả các công cụ, LLM được cung cấp một công c } ``` + ## Công cụ Skills Công cụ skills cấu hình khám phá và cài đặt kỹ năng thông qua các registry như ClawHub. diff --git a/docs/zh/tools_configuration.md b/docs/reference/tools_configuration.zh.md similarity index 99% rename from docs/zh/tools_configuration.md rename to docs/reference/tools_configuration.zh.md index 9b3bfe4cf..3937a6254 100644 --- a/docs/zh/tools_configuration.md +++ b/docs/reference/tools_configuration.zh.md @@ -1,6 +1,6 @@ # 🔧 工具配置 -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) PicoClaw 的工具配置位于 `config.json` 的 `tools` 字段中。 @@ -32,7 +32,7 @@ PicoClaw 的工具配置位于 `config.json` 的 `tools` 字段中。 在将工具结果发送给 LLM 之前,PicoClaw 可以从输出中过滤敏感值(API 密钥、令牌、密码)。这可以防止 LLM 看到自己的凭据。 -详细说明请参阅[敏感数据过滤](../sensitive_data_filtering.md)。 +详细说明请参阅[敏感数据过滤](../security/sensitive_data_filtering.zh.md)。 | 配置项 | 类型 | 默认值 | 描述 | |--------|------|--------|------| @@ -234,6 +234,7 @@ Cron 工具用于调度周期性任务。 | `exec_timeout_minutes` | int | 5 | 执行超时时间(分钟),0 表示无限制 | | `allow_command` | bool | false | 允许 cron 任务执行 shell 命令 | + ## MCP 工具 MCP 工具支持与外部 Model Context Protocol 服务器集成。 @@ -389,6 +390,7 @@ LLM 不会加载所有工具,而是获得一个轻量级搜索工具(使用 } ``` + ## Skills 工具 Skills 工具配置通过 ClawHub 等注册表进行技能发现和安装。 diff --git a/docs/fr/ANTIGRAVITY_AUTH.md b/docs/security/ANTIGRAVITY_AUTH.fr.md similarity index 99% rename from docs/fr/ANTIGRAVITY_AUTH.md rename to docs/security/ANTIGRAVITY_AUTH.fr.md index 6cadf5238..8550c94e3 100644 --- a/docs/fr/ANTIGRAVITY_AUTH.md +++ b/docs/security/ANTIGRAVITY_AUTH.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) # Guide d'authentification et d'intégration Antigravity diff --git a/docs/ja/ANTIGRAVITY_AUTH.md b/docs/security/ANTIGRAVITY_AUTH.ja.md similarity index 99% rename from docs/ja/ANTIGRAVITY_AUTH.md rename to docs/security/ANTIGRAVITY_AUTH.ja.md index b55e4ab1b..e5ba91f8e 100644 --- a/docs/ja/ANTIGRAVITY_AUTH.md +++ b/docs/security/ANTIGRAVITY_AUTH.ja.md @@ -1,4 +1,4 @@ -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る # Antigravity 認証・統合ガイド diff --git a/docs/ANTIGRAVITY_AUTH.md b/docs/security/ANTIGRAVITY_AUTH.md similarity index 100% rename from docs/ANTIGRAVITY_AUTH.md rename to docs/security/ANTIGRAVITY_AUTH.md diff --git a/docs/pt-br/ANTIGRAVITY_AUTH.md b/docs/security/ANTIGRAVITY_AUTH.pt-br.md similarity index 99% rename from docs/pt-br/ANTIGRAVITY_AUTH.md rename to docs/security/ANTIGRAVITY_AUTH.pt-br.md index d243783cb..626dc7433 100644 --- a/docs/pt-br/ANTIGRAVITY_AUTH.md +++ b/docs/security/ANTIGRAVITY_AUTH.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) # Guia de Autenticação e Integração do Antigravity diff --git a/docs/vi/ANTIGRAVITY_AUTH.md b/docs/security/ANTIGRAVITY_AUTH.vi.md similarity index 99% rename from docs/vi/ANTIGRAVITY_AUTH.md rename to docs/security/ANTIGRAVITY_AUTH.vi.md index 783dc5181..0800ce0f2 100644 --- a/docs/vi/ANTIGRAVITY_AUTH.md +++ b/docs/security/ANTIGRAVITY_AUTH.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) # Hướng dẫn Xác thực và Tích hợp Antigravity diff --git a/docs/zh/ANTIGRAVITY_AUTH.md b/docs/security/ANTIGRAVITY_AUTH.zh.md similarity index 99% rename from docs/zh/ANTIGRAVITY_AUTH.md rename to docs/security/ANTIGRAVITY_AUTH.zh.md index db7c81dea..5ae5c8afe 100644 --- a/docs/zh/ANTIGRAVITY_AUTH.md +++ b/docs/security/ANTIGRAVITY_AUTH.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) # Antigravity 认证与集成指南 diff --git a/docs/security/README.md b/docs/security/README.md new file mode 100644 index 000000000..7bd42da18 --- /dev/null +++ b/docs/security/README.md @@ -0,0 +1,8 @@ +# Security + +Security-focused docs covering configuration, secrets handling, and provider auth. + +- [Security Configuration](security_configuration.md): security-related config knobs and hardening guidance. +- [Sensitive Data Filtering](sensitive_data_filtering.md): filtering secrets from tool output before model use. +- [Credential Encryption](credential_encryption.md): encrypting stored API keys and credentials. +- [Antigravity Authentication & Integration Guide](ANTIGRAVITY_AUTH.md): auth flow and integration notes for the Antigravity provider. diff --git a/docs/fr/credential_encryption.md b/docs/security/credential_encryption.fr.md similarity index 99% rename from docs/fr/credential_encryption.md rename to docs/security/credential_encryption.fr.md index eec765039..67e2ed123 100644 --- a/docs/fr/credential_encryption.md +++ b/docs/security/credential_encryption.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) # Chiffrement des identifiants diff --git a/docs/ja/credential_encryption.md b/docs/security/credential_encryption.ja.md similarity index 99% rename from docs/ja/credential_encryption.md rename to docs/security/credential_encryption.ja.md index ea74b65d2..9eeba98b4 100644 --- a/docs/ja/credential_encryption.md +++ b/docs/security/credential_encryption.ja.md @@ -1,4 +1,4 @@ -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る # クレデンシャル暗号化 diff --git a/docs/credential_encryption.md b/docs/security/credential_encryption.md similarity index 100% rename from docs/credential_encryption.md rename to docs/security/credential_encryption.md diff --git a/docs/pt-br/credential_encryption.md b/docs/security/credential_encryption.pt-br.md similarity index 99% rename from docs/pt-br/credential_encryption.md rename to docs/security/credential_encryption.pt-br.md index 59a31e438..d4a84be8e 100644 --- a/docs/pt-br/credential_encryption.md +++ b/docs/security/credential_encryption.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) # Criptografia de Credenciais diff --git a/docs/vi/credential_encryption.md b/docs/security/credential_encryption.vi.md similarity index 99% rename from docs/vi/credential_encryption.md rename to docs/security/credential_encryption.vi.md index 9ba24588b..38d568b94 100644 --- a/docs/vi/credential_encryption.md +++ b/docs/security/credential_encryption.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) # Mã hóa Thông tin Xác thực diff --git a/docs/zh/credential_encryption.md b/docs/security/credential_encryption.zh.md similarity index 99% rename from docs/zh/credential_encryption.md rename to docs/security/credential_encryption.zh.md index 2105e4307..5083eee18 100644 --- a/docs/zh/credential_encryption.md +++ b/docs/security/credential_encryption.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) # 凭据加密 diff --git a/docs/security_configuration.md b/docs/security/security_configuration.md similarity index 100% rename from docs/security_configuration.md rename to docs/security/security_configuration.md diff --git a/docs/sensitive_data_filtering.md b/docs/security/sensitive_data_filtering.md similarity index 98% rename from docs/sensitive_data_filtering.md rename to docs/security/sensitive_data_filtering.md index 0c10ff01d..e2d9de427 100644 --- a/docs/sensitive_data_filtering.md +++ b/docs/security/sensitive_data_filtering.md @@ -104,4 +104,4 @@ The model is using API key [FILTERED] and Telegram bot [FILTERED] ## Related - [Credential Encryption](./credential_encryption.md) — encrypting API keys in config -- [Tools Configuration](./tools_configuration.md) +- [Tools Configuration](../reference/tools_configuration.md) diff --git a/docs/zh/sensitive_data_filtering.md b/docs/security/sensitive_data_filtering.zh.md similarity index 95% rename from docs/zh/sensitive_data_filtering.md rename to docs/security/sensitive_data_filtering.zh.md index 4382706ed..6ff1acc20 100644 --- a/docs/zh/sensitive_data_filtering.md +++ b/docs/security/sensitive_data_filtering.zh.md @@ -103,5 +103,5 @@ The model is using API key [FILTERED] and Telegram bot [FILTERED] ## 相关文档 -- [凭据加密](../credential_encryption.md) — 配置中 API 密钥的加密 -- [工具配置](../tools_configuration.md) +- [凭据加密](./credential_encryption.zh.md) — 配置中 API 密钥的加密 +- [工具配置](../reference/tools_configuration.zh.md) diff --git a/go.mod b/go.mod index b7259bde7..a8b540662 100644 --- a/go.mod +++ b/go.mod @@ -19,15 +19,15 @@ require ( github.com/ergochat/irc-go v0.6.0 github.com/ergochat/readline v0.1.3 github.com/gdamore/tcell/v2 v2.13.8 - github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab + github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/h2non/filetype v1.1.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/mdp/qrterminal/v3 v3.2.1 github.com/minio/selfupdate v0.6.0 - github.com/muesli/termenv v0.16.0 github.com/modelcontextprotocol/go-sdk v1.5.0 + github.com/muesli/termenv v0.16.0 github.com/mymmrac/telego v1.8.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 github.com/openai/openai-go/v3 v3.22.0 @@ -40,14 +40,14 @@ require ( github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 github.com/tencent-connect/botgo v0.2.1 - go.mau.fi/util v0.9.7 + go.mau.fi/util v0.9.8 go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 golang.org/x/oauth2 v0.36.0 - golang.org/x/term v0.41.0 + golang.org/x/term v0.42.0 golang.org/x/time v0.15.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 - maunium.net/go/mautrix v0.26.4 + maunium.net/go/mautrix v0.27.0 modernc.org/sqlite v1.48.2 rsc.io/qr v0.2.0 ) @@ -88,9 +88,9 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mattn/go-sqlite3 v1.14.34 // indirect + github.com/mattn/go-sqlite3 v1.14.42 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect + github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect @@ -106,8 +106,8 @@ require ( go.opentelemetry.io/otel v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect go.opentelemetry.io/otel/trace v1.35.0 // indirect - golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/text v0.36.0 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect @@ -136,8 +136,8 @@ require ( github.com/valyala/fastjson v1.6.10 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/arch v0.24.0 // indirect - golang.org/x/crypto v0.49.0 - golang.org/x/net v0.52.0 + golang.org/x/crypto v0.50.0 + golang.org/x/net v0.53.0 golang.org/x/sync v0.20.0 golang.org/x/sys v0.43.0 ) diff --git a/go.sum b/go.sum index 8306976c4..f63c7b44e 100644 --- a/go.sum +++ b/go.sum @@ -140,6 +140,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc= github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= +github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207 h1:p7t34F7K4OCRQblcDhNJnP46Uaarz3z2cLcvOZYxWn8= +github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -193,16 +195,16 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= -github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= +github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU= github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU= github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/mymmrac/telego v1.8.0 h1:EvIprWo9Cn0MHgumvvqNXPAXO1yJj3pu2cdCCeDxbow= github.com/mymmrac/telego v1.8.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= @@ -219,8 +221,8 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU= github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys= github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= -github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 h1:rh2lKw/P/EqHa724vYH2+VVQ1YnW4u6EOXl0PMAovZE= -github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= +github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA= @@ -293,12 +295,12 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s= github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532 h1:gxFHYeUDGziRb0zXYEqBFohC+NJbIW9L0tddaXMWr2o= @@ -310,8 +312,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0= go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU= -go.mau.fi/util v0.9.7 h1:AWGNbJfz1zRcQOKeOEYhKUG2fT+/26Gy6kyqcH8tnBg= -go.mau.fi/util v0.9.7/go.mod h1:5T2f3ZWZFAGgmFwg3dGw7YK6kIsb9lryDzvynoR98pE= +go.mau.fi/util v0.9.8 h1:+/jf8eM2dAT2wx9UidmaneH28r/CSCKCniCyby1qWz8= +go.mau.fi/util v0.9.8/go.mod h1:up/5mbzH2M1pSBNXqRxODn8dg/hEKbLJu92W4/SNAX0= go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ= go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= @@ -334,16 +336,16 @@ golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWP golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -358,8 +360,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= @@ -400,8 +402,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -409,8 +411,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -420,8 +422,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -451,8 +453,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -maunium.net/go/mautrix v0.26.4 h1:enHSnkf0L2V9+VnfJfNhKSReSW6pBKS/x3Su+v+Vovs= -maunium.net/go/mautrix v0.26.4/go.mod h1:YWw8NWTszsbyFAznboicBObwHPgTSLcuTbVX2kY7U2M= +maunium.net/go/mautrix v0.27.0 h1:yfEYwoIluVWkofUgbZl9gP4i5nQTF+QNsxtb+r5bKlM= +maunium.net/go/mautrix v0.27.0/go.mod h1:7QpEQiTy6p4LHkXXaZI+N46tGYy8HMhD0JjzZAFoFWs= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= diff --git a/pkg/agent/adapters/channelmanager.go b/pkg/agent/adapters/channelmanager.go new file mode 100644 index 000000000..8265ef99d --- /dev/null +++ b/pkg/agent/adapters/channelmanager.go @@ -0,0 +1,45 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package adapters + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/agent/interfaces" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" +) + +// channelManagerAdapter wraps *channels.Manager to implement interfaces.ChannelManager. +type channelManagerAdapter struct { + inner *channels.Manager +} + +// NewChannelManager creates an adapter for *channels.Manager. +func NewChannelManager(inner *channels.Manager) interfaces.ChannelManager { + return &channelManagerAdapter{inner: inner} +} + +func (a *channelManagerAdapter) GetChannel(name string) (channels.Channel, bool) { + return a.inner.GetChannel(name) +} + +func (a *channelManagerAdapter) GetEnabledChannels() []string { + return a.inner.GetEnabledChannels() +} + +func (a *channelManagerAdapter) InvokeTypingStop(channel, chatID string) { + a.inner.InvokeTypingStop(channel, chatID) +} + +func (a *channelManagerAdapter) SendMessage(ctx context.Context, msg bus.OutboundMessage) error { + return a.inner.SendMessage(ctx, msg) +} + +func (a *channelManagerAdapter) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + return a.inner.SendMedia(ctx, msg) +} + +func (a *channelManagerAdapter) SendPlaceholder(ctx context.Context, channel, chatID string) bool { + return a.inner.SendPlaceholder(ctx, channel, chatID) +} diff --git a/pkg/agent/adapters/messagebus.go b/pkg/agent/adapters/messagebus.go new file mode 100644 index 000000000..ccae7e8bc --- /dev/null +++ b/pkg/agent/adapters/messagebus.go @@ -0,0 +1,36 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package adapters + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/agent/interfaces" + "github.com/sipeed/picoclaw/pkg/bus" +) + +// messageBusAdapter wraps *bus.MessageBus to implement interfaces.MessageBus. +type messageBusAdapter struct { + inner *bus.MessageBus +} + +// NewMessageBus creates an adapter for *bus.MessageBus. +func NewMessageBus(inner *bus.MessageBus) interfaces.MessageBus { + return &messageBusAdapter{inner: inner} +} + +func (a *messageBusAdapter) PublishInbound(ctx context.Context, msg bus.InboundMessage) error { + return a.inner.PublishInbound(ctx, msg) +} + +func (a *messageBusAdapter) PublishOutbound(ctx context.Context, msg bus.OutboundMessage) error { + return a.inner.PublishOutbound(ctx, msg) +} + +func (a *messageBusAdapter) PublishOutboundMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + return a.inner.PublishOutboundMedia(ctx, msg) +} + +func (a *messageBusAdapter) InboundChan() <-chan bus.InboundMessage { + return a.inner.InboundChan() +} diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go new file mode 100644 index 000000000..3c242eecb --- /dev/null +++ b/pkg/agent/agent.go @@ -0,0 +1,606 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package agent + +import ( + "context" + "fmt" + "regexp" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/sipeed/picoclaw/pkg/agent/interfaces" + "github.com/sipeed/picoclaw/pkg/audio/asr" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/commands" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/constants" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/routing" + "github.com/sipeed/picoclaw/pkg/session" + "github.com/sipeed/picoclaw/pkg/state" + "github.com/sipeed/picoclaw/pkg/utils" +) + +type AgentLoop struct { + // Core dependencies + bus interfaces.MessageBus + cfg *config.Config + registry *AgentRegistry + state *state.Manager + + // Event system (from Incoming) + eventBus *EventBus + hooks *HookManager + + // Runtime state + running atomic.Bool + contextManager ContextManager + fallback *providers.FallbackChain + channelManager interfaces.ChannelManager + mediaStore media.MediaStore + transcriber asr.Transcriber + cmdRegistry *commands.Registry + mcp mcpRuntime + hookRuntime hookRuntime + steering *steeringQueue + pendingSkills sync.Map + mu sync.RWMutex + + // workerSem limits concurrent turn processing workers. + workerSem chan struct{} + + // activeTurnStates tracks active turns per session to prevent duplicates. + activeTurnStates sync.Map + subTurnCounter atomic.Int64 + + turnSeq atomic.Uint64 + activeRequests sync.WaitGroup + + reloadFunc func() error + + providerFactory func(*config.ModelConfig) (providers.LLMProvider, string, error) +} + +// processOptions configures how a message is processed +type processOptions struct { + Dispatch DispatchRequest // Normalized routed request boundary for this turn + SessionKey string // Session identifier for history/context + SessionAliases []string // Compatibility aliases for the session key + Channel string // Target channel for tool execution + ChatID string // Target chat ID for tool execution + MessageID string // Current inbound platform message ID + ReplyToMessageID string // Current inbound reply target message ID + SenderID string // Current sender ID for dynamic context + SenderDisplayName string // Current sender display name for dynamic context + UserMessage string // User message content (may include prefix) + ForcedSkills []string // Skills explicitly requested for this message + SystemPromptOverride string // Override the default system prompt (Used by SubTurns) + Media []string // media:// refs from inbound message + InitialSteeringMessages []providers.Message // Steering messages from refactor/agent + DefaultResponse string // Response when LLM returns empty + EnableSummary bool // Whether to trigger summarization + SendResponse bool // Whether to send response via bus + AllowInterimPicoPublish bool // Whether pico tool-call interim text can be published when SendResponse is false + SuppressToolFeedback bool // Whether to suppress inline tool feedback messages + NoHistory bool // If true, don't load session history (for heartbeat) + SkipInitialSteeringPoll bool // If true, skip the steering poll at loop start (used by Continue) + InboundContext *bus.InboundContext // Normalized inbound facts for events/hooks + RouteResult *routing.ResolvedRoute // Route decision snapshot for events/hooks + SessionScope *session.SessionScope // Session scope snapshot for events/hooks +} + +type continuationTarget struct { + SessionKey string + Channel string + ChatID string +} + +const ( + defaultResponse = "The model returned an empty response. This may indicate a provider error or token limit." + toolLimitResponse = "I've reached `max_tool_iterations` without a final response. Increase `max_tool_iterations` in config.json if this task needs more tool steps." + handledToolResponseSummary = "Requested output delivered via tool attachment." + sessionKeyAgentPrefix = "agent:" + pendingTurnPrefix = "pending-" + metadataKeyMessageKind = "message_kind" + messageKindThought = "thought" + metadataKeyAccountID = "account_id" + metadataKeyGuildID = "guild_id" + metadataKeyTeamID = "team_id" + metadataKeyReplyToMessage = "reply_to_message_id" + metadataKeyParentPeerKind = "parent_peer_kind" + metadataKeyParentPeerID = "parent_peer_id" +) + +// registerSharedTools registers tools that are shared across all agents (web, message, spawn). + +func (al *AgentLoop) Run(ctx context.Context) error { + al.running.Store(true) + + if err := al.ensureHooksInitialized(ctx); err != nil { + return err + } + if err := al.ensureMCPInitialized(ctx); err != nil { + return err + } + + idleTicker := time.NewTicker(100 * time.Millisecond) + defer idleTicker.Stop() + + for { + select { + case <-ctx.Done(): + return nil + case <-idleTicker.C: + if !al.running.Load() { + return nil + } + case msg, ok := <-al.bus.InboundChan(): + if !ok { + return nil + } + + // Resolve the session key for this message + sessionKey, agentID, ok := al.resolveSteeringTarget(msg) + if !ok { + // Non-routable message (e.g., system) — process immediately. + // Note: system messages are processed in the main goroutine, + // so they block the receive loop but guarantee session serialization. + al.processMessageSync(ctx, msg) + continue + } + + // Atomically claim the session key with a unique placeholder sentinel + // to prevent a TOCTOU race where multiple messages for the same session + // pass the Load check before either registers. + // The placeholder ensures GetActiveTurnBySession() never returns nil + // during turn setup. Each placeholder has a unique turnID to prevent + // cross-worker cleanup issues. + placeholder := &turnState{ + turnID: makePendingTurnID(sessionKey, al.turnSeq.Add(1)), + phase: TurnPhaseSetup, + } + if _, loaded := al.activeTurnStates.LoadOrStore(sessionKey, placeholder); loaded { + // Another turn is already active (or reserved) for this session — enqueue + if err := al.enqueueSteeringMessage(sessionKey, agentID, providers.Message{ + Role: "user", + Content: msg.Content, + Media: append([]string(nil), msg.Media...), + }); err != nil { + logger.WarnCF("agent", "Failed to enqueue steering message", + map[string]any{ + "error": err.Error(), + "channel": msg.Channel, + "chat_id": msg.ChatID, + "session_key": sessionKey, + }) + } + continue + } + + // Session claimed — spawn a worker goroutine that acquires a semaphore + // slot. The goroutine is spawned immediately so the main loop keeps + // draining the inbound channel. The goroutine blocks on the semaphore. + go func(m bus.InboundMessage) { + // Acquire semaphore slot (blocks if at capacity) + select { + case al.workerSem <- struct{}{}: + // Got slot, start worker + case <-ctx.Done(): + // Context canceled while waiting for a slot — clean up the + // placeholder to prevent session-level deadlock. + al.activeTurnStates.Delete(sessionKey) + return + } + + // Safety-net cleanup: if the placeholder was never replaced by a real + // turnState (e.g., error before runTurn), delete it here. When runTurn + // completes normally, clearActiveTurn deletes the real turnState and + // this becomes a no-op (the key is already gone). + defer func() { + if actual, ok := al.activeTurnStates.Load(sessionKey); ok { + if ts, ok := actual.(*turnState); ok && strings.HasPrefix(ts.turnID, pendingTurnPrefix) { + // Placeholder still present — runTurn never replaced it. + al.activeTurnStates.Delete(sessionKey) + } + } + }() + + defer func() { + if r := recover(); r != nil { + logger.RecoverPanicNoExit(r) + logger.ErrorCF("agent", "Worker goroutine panicked", + map[string]any{ + "session_key": sessionKey, + "channel": m.Channel, + "chat_id": m.ChatID, + "panic": fmt.Sprintf("%v", r), + }) + } + }() + defer func() { <-al.workerSem }() // Release slot + + if al.channelManager != nil { + defer al.channelManager.InvokeTypingStop(m.Channel, m.ChatID) + } + + al.runTurnWithSteering(ctx, m) + }(msg) + + // TODO: Re-enable media cleanup after inbound media is properly consumed by the agent. + // Currently disabled because files are deleted before the LLM can access their content. + // defer func() { + // if al.mediaStore != nil && msg.MediaScope != "" { + // if releaseErr := al.mediaStore.ReleaseAll(msg.MediaScope); releaseErr != nil { + // logger.WarnCF("agent", "Failed to release media", map[string]any{ + // "scope": msg.MediaScope, + // "error": releaseErr.Error(), + // }) + // } + // } + // }() + } + } +} + +// processMessageSync processes a message synchronously (for non-routable/system messages). + +// runTurnWithSteering runs a complete turn for a message and drains its steering queue. + +// maybePublishError publishes an error response unless the error is context.Canceled. +// Returns true if processing should continue (non-cancellation error or no error), +// false if context was canceled and the caller should return. + +// publishResponseOrError publishes the response, or an error message if processing failed. + +func (al *AgentLoop) Stop() { + al.running.Store(false) +} + +// Close releases resources held by agent session stores. Call after Stop. +func (al *AgentLoop) Close() { + mcpManager := al.mcp.takeManager() + + if mcpManager != nil { + if err := mcpManager.Close(); err != nil { + logger.ErrorCF("agent", "Failed to close MCP manager", + map[string]any{ + "error": err.Error(), + }) + } + } + + al.GetRegistry().Close() + if al.hooks != nil { + al.hooks.Close() + } + if al.eventBus != nil { + al.eventBus.Close() + } +} + +// MountHook registers an in-process hook on the agent loop. + +// UnmountHook removes a previously registered in-process hook. + +// SubscribeEvents registers a subscriber for agent-loop events. + +// UnsubscribeEvents removes a previously registered event subscriber. + +// EventDrops returns the number of dropped events for the given kind. + +type turnEventScope struct { + agentID string + sessionKey string + turnID string + context *TurnContext +} + +// ReloadProviderAndConfig atomically swaps the provider and config with proper synchronization. +// It uses a context to allow timeout control from the caller. +// Returns an error if the reload fails or context is canceled. +func (al *AgentLoop) ReloadProviderAndConfig( + ctx context.Context, + provider providers.LLMProvider, + cfg *config.Config, +) error { + // Validate inputs + if provider == nil { + return fmt.Errorf("provider cannot be nil") + } + if cfg == nil { + return fmt.Errorf("config cannot be nil") + } + + // Create new registry with updated config and provider + // Wrap in defer/recover to handle any panics gracefully + var registry *AgentRegistry + var panicErr error + done := make(chan struct{}, 1) + + go func() { + defer func() { + if r := recover(); r != nil { + logger.RecoverPanicNoExit(r) + panicErr = fmt.Errorf("panic during registry creation: %v", r) + logger.ErrorCF("agent", "Panic during registry creation", + map[string]any{"panic": r}) + } + close(done) + }() + + registry = NewAgentRegistry(cfg, provider) + }() + + // Wait for completion or context cancellation + select { + case <-done: + if registry == nil { + if panicErr != nil { + return fmt.Errorf("registry creation failed: %w", panicErr) + } + return fmt.Errorf("registry creation failed (nil result)") + } + case <-ctx.Done(): + return fmt.Errorf("context canceled during registry creation: %w", ctx.Err()) + } + + // Check context again before proceeding + if err := ctx.Err(); err != nil { + return fmt.Errorf("context canceled after registry creation: %w", err) + } + + // Ensure shared tools are re-registered on the new registry + registerSharedTools(al, cfg, al.bus, registry, provider) + + // Atomically swap the config and registry under write lock + // This ensures readers see a consistent pair + al.mu.Lock() + oldRegistry := al.registry + + // Store new values + al.cfg = cfg + al.registry = registry + + // Also update fallback chain with new config; rebuild rate limiter registry. + newRL := providers.NewRateLimiterRegistry() + for _, agentID := range registry.ListAgentIDs() { + if agent, ok := registry.GetAgent(agentID); ok { + newRL.RegisterCandidates(agent.Candidates) + newRL.RegisterCandidates(agent.LightCandidates) + } + } + al.fallback = providers.NewFallbackChain(providers.NewCooldownTracker(), newRL) + + al.mu.Unlock() + + oldMCPManager := al.mcp.reset() + al.hookRuntime.reset(al) + configureHookManagerFromConfig(al.hooks, cfg) + if err := al.ensureHooksInitialized(ctx); err != nil { + logger.WarnCF("agent", "Configured hooks failed to reinitialize after reload", + map[string]any{"error": err.Error()}) + } + if oldMCPManager != nil { + if err := oldMCPManager.Close(); err != nil { + logger.WarnCF("agent", "Failed to close previous MCP manager during reload", + map[string]any{"error": err.Error()}) + } + } + if err := al.ensureMCPInitialized(ctx); err != nil { + logger.WarnCF("agent", "MCP failed to reinitialize after reload", + map[string]any{"error": err.Error()}) + } + + // Close old provider after releasing the lock + // This prevents blocking readers while closing + if oldProvider, ok := extractProvider(oldRegistry); ok { + if stateful, ok := oldProvider.(providers.StatefulProvider); ok { + // Give in-flight requests a moment to complete + // Use a reasonable timeout that balances cleanup vs resource usage + select { + case <-time.After(100 * time.Millisecond): + stateful.Close() + case <-ctx.Done(): + // Context canceled, close immediately but log warning + logger.WarnCF("agent", "Context canceled during provider cleanup, forcing close", + map[string]any{"error": ctx.Err()}) + stateful.Close() + } + } + } + + logger.InfoCF("agent", "Provider and config reloaded successfully", + map[string]any{ + "model": cfg.Agents.Defaults.GetModelName(), + }) + + return nil +} + +// GetRegistry returns the current registry (thread-safe) + +// GetConfig returns the current config (thread-safe) + +// SetMediaStore injects a MediaStore for media lifecycle management. + +// SetTranscriber injects a voice transcriber for agent-level audio transcription. + +// SetReloadFunc sets the callback function for triggering config reload. + +var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`) + +// transcribeAudioInMessage resolves audio media refs, transcribes them, and +// replaces audio annotations in msg.Content with the transcribed text. +// Returns the (possibly modified) message and true if audio was transcribed. + +// sendTranscriptionFeedback sends feedback to the user with the result of +// audio transcription if the option is enabled. It uses Manager.SendMessage +// which executes synchronously (rate limiting, splitting, retry) so that +// ordering with the subsequent placeholder is guaranteed. + +// inferMediaType determines the media type ("image", "audio", "video", "file") +// from a filename and MIME content type. + +// RecordLastChannel records the last active channel for this workspace. +// This uses the atomic state save mechanism to prevent data loss on crash. + +// RecordLastChatID records the last active chat ID for this workspace. +// This uses the atomic state save mechanism to prevent data loss on crash. + +// ProcessHeartbeat processes a heartbeat request without session history. +// Each heartbeat is independent and doesn't accumulate context. + +// runAgentLoop remains the top-level shell that starts a turn and publishes +// any post-turn work. runTurn owns the full turn lifecycle. +func (al *AgentLoop) runAgentLoop( + ctx context.Context, + agent *AgentInstance, + opts processOptions, +) (string, error) { + opts = normalizeProcessOptions(opts) + + // Record last channel for heartbeat notifications (skip internal channels and cli) + if opts.Dispatch.Channel() != "" && + opts.Dispatch.ChatID() != "" && + !constants.IsInternalChannel(opts.Dispatch.Channel()) { + channelKey := fmt.Sprintf("%s:%s", opts.Dispatch.Channel(), opts.Dispatch.ChatID()) + if err := al.RecordLastChannel(channelKey); err != nil { + logger.WarnCF( + "agent", + "Failed to record last channel", + map[string]any{"error": err.Error()}, + ) + } + } + + ensureSessionMetadata( + agent.Sessions, + opts.Dispatch.SessionKey, + opts.Dispatch.SessionScope, + opts.Dispatch.SessionAliases, + ) + + turnScope := al.newTurnEventScope( + agent.ID, + opts.Dispatch.SessionKey, + newTurnContext(opts.Dispatch.InboundContext, opts.Dispatch.RouteResult, opts.Dispatch.SessionScope), + ) + ts := newTurnState(agent, opts, turnScope) + pipeline := NewPipeline(al) + result, err := al.runTurn(ctx, ts, pipeline) + if err != nil { + return "", err + } + if result.status == TurnEndStatusAborted { + return "", nil + } + + for _, followUp := range result.followUps { + if pubErr := al.bus.PublishInbound(ctx, followUp); pubErr != nil { + logger.WarnCF("agent", "Failed to publish follow-up after turn", + map[string]any{ + "turn_id": ts.turnID, + "error": pubErr.Error(), + }) + } + } + + if opts.SendResponse && result.finalContent != "" { + agentID, sessionKey, scope := outboundTurnMetadata( + agent.ID, + opts.Dispatch.SessionKey, + opts.Dispatch.SessionScope, + ) + al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + Context: outboundContextFromInbound( + opts.Dispatch.InboundContext, + opts.Dispatch.Channel(), + opts.Dispatch.ChatID(), + opts.Dispatch.ReplyToMessageID(), + ), + AgentID: agentID, + SessionKey: sessionKey, + Scope: scope, + Content: result.finalContent, + ContextUsage: computeContextUsage(agent, opts.Dispatch.SessionKey), + }) + } + + if result.finalContent != "" { + responsePreview := utils.Truncate(result.finalContent, 120) + logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview), + map[string]any{ + "agent_id": agent.ID, + "session_key": opts.Dispatch.SessionKey, + "iterations": ts.currentIteration(), + "final_length": len(result.finalContent), + }) + } + + return result.finalContent, nil +} + +// selectCandidates returns the model candidates and resolved model name to use +// for a conversation turn. When model routing is configured and the incoming +// message scores below the complexity threshold, it returns the light model +// candidates instead of the primary ones. +// +// The returned (candidates, model) pair is used for all LLM calls within one +// turn — tool follow-up iterations use the same tier as the initial call so +// that a multi-step tool chain doesn't switch models mid-way. + +// resolveContextManager selects the ContextManager implementation based on config. + +// GetStartupInfo returns information about loaded tools and skills for logging. + +// formatMessagesForLog formats messages for logging + +// formatToolsForLog formats tool definitions for logging + +// summarizeSession summarizes the conversation history for a session. +// findNearestUserMessage finds the nearest user message to the given index. +// It searches backward first, then forward if no user message is found. +// retryLLMCall calls the LLM with retry logic. +// summarizeBatch summarizes a batch of messages. +// estimateTokens estimates the number of tokens in a message list. +// Counts Content, ToolCalls arguments, and ToolCallID metadata so that +// tool-heavy conversations are not systematically undercounted. + +// askSideQuestion handles /btw commands by creating an isolated provider instance +// that doesn't share state with the main conversation provider. + +// shallowCloneLLMOptions creates a shallow copy of LLM options map. +// Note: This is a shallow copy - nested maps/slices are shared. + +// hasMediaRefs checks if any message has media references. + +// isolatedSideQuestionProvider creates a separate provider instance for /btw commands +// to avoid sharing state with the main conversation provider. + +// sideQuestionModelConfig resolves the model config for side questions. + +// sideQuestionModelName determines which model name to use for side questions. + +// modelNameFromIdentityKey extracts the model name from an identity key. + +// closeProviderIfStateful closes a provider if it implements StatefulProvider. + +// makePendingTurnID generates a unique turn ID for placeholder turns. +// Format: "pending-{sessionKey}-{sequence}" + +// isNativeSearchProvider reports whether the given LLM provider implements +// NativeSearchCapable and returns true for SupportsNativeSearch. + +// filterClientWebSearch returns a copy of tools with the client-side +// web_search tool removed. Used when native provider search is preferred. + +// Helper to extract provider from registry for cleanup diff --git a/pkg/agent/agent_command.go b/pkg/agent/agent_command.go new file mode 100644 index 000000000..277ef77cd --- /dev/null +++ b/pkg/agent/agent_command.go @@ -0,0 +1,284 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/commands" + "github.com/sipeed/picoclaw/pkg/providers" +) + +func (al *AgentLoop) handleCommand( + ctx context.Context, + msg bus.InboundMessage, + agent *AgentInstance, + opts *processOptions, +) (string, bool) { + normalizeProcessOptionsInPlace(opts) + + if !commands.HasCommandPrefix(msg.Content) { + return "", false + } + + if matched, handled, reply := al.applyExplicitSkillCommand(msg.Content, agent, opts); matched { + return reply, handled + } + + if al.cmdRegistry == nil { + return "", false + } + + rt := al.buildCommandsRuntime(ctx, agent, opts) + executor := commands.NewExecutor(al.cmdRegistry, rt) + + var commandReply string + result := executor.Execute(ctx, commands.Request{ + Channel: msg.Channel, + ChatID: msg.ChatID, + SenderID: msg.SenderID, + Text: msg.Content, + Reply: func(text string) error { + commandReply = text + return nil + }, + }) + + switch result.Outcome { + case commands.OutcomeHandled: + if result.Err != nil { + return mapCommandError(result), true + } + if commandReply != "" { + return commandReply, true + } + return "", true + default: // OutcomePassthrough — let the message fall through to LLM + return "", false + } +} + +func (al *AgentLoop) applyExplicitSkillCommand( + raw string, + agent *AgentInstance, + opts *processOptions, +) (matched bool, handled bool, reply string) { + normalizeProcessOptionsInPlace(opts) + + cmdName, ok := commands.CommandName(raw) + if !ok || cmdName != "use" { + return false, false, "" + } + + if agent == nil || agent.ContextBuilder == nil { + return true, true, commandsUnavailableSkillMessage() + } + + parts := strings.Fields(strings.TrimSpace(raw)) + if len(parts) < 2 { + return true, true, buildUseCommandHelp(agent) + } + + arg := strings.TrimSpace(parts[1]) + if strings.EqualFold(arg, "clear") || strings.EqualFold(arg, "off") { + if opts != nil { + al.clearPendingSkills(opts.Dispatch.SessionKey) + } + return true, true, "Cleared pending skill override." + } + + skillName, ok := agent.ContextBuilder.ResolveSkillName(arg) + if !ok { + return true, true, fmt.Sprintf("Unknown skill: %s\nUse /list skills to see installed skills.", arg) + } + + if len(parts) < 3 { + if opts == nil || strings.TrimSpace(opts.Dispatch.SessionKey) == "" { + return true, true, commandsUnavailableSkillMessage() + } + al.setPendingSkills(opts.Dispatch.SessionKey, []string{skillName}) + return true, true, fmt.Sprintf( + "Skill %q is armed for your next message. Send your next prompt normally, or use /use clear to cancel.", + skillName, + ) + } + + message := strings.TrimSpace(strings.Join(parts[2:], " ")) + if message == "" { + return true, true, buildUseCommandHelp(agent) + } + + if opts != nil { + opts.ForcedSkills = append(opts.ForcedSkills, skillName) + opts.Dispatch.UserMessage = message + opts.UserMessage = message + } + + return true, false, "" +} + +func (al *AgentLoop) buildCommandsRuntime( + ctx context.Context, + agent *AgentInstance, + opts *processOptions, +) *commands.Runtime { + normalizeProcessOptionsInPlace(opts) + + registry := al.GetRegistry() + cfg := al.GetConfig() + rt := &commands.Runtime{ + Config: cfg, + ListAgentIDs: registry.ListAgentIDs, + ListDefinitions: al.cmdRegistry.Definitions, + GetEnabledChannels: func() []string { + if al.channelManager == nil { + return nil + } + return al.channelManager.GetEnabledChannels() + }, + GetActiveTurn: func() any { + info := al.GetActiveTurn() + if info == nil { + return nil + } + return info + }, + SwitchChannel: func(value string) error { + if al.channelManager == nil { + return fmt.Errorf("channel manager not initialized") + } + if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" { + return fmt.Errorf("channel '%s' not found or not enabled", value) + } + return nil + }, + } + if agent != nil && agent.ContextBuilder != nil { + rt.ListSkillNames = agent.ContextBuilder.ListSkillNames + } + rt.ReloadConfig = func() error { + if al.reloadFunc == nil { + return fmt.Errorf("reload not configured") + } + return al.reloadFunc() + } + if agent != nil { + if agent.ContextBuilder != nil { + rt.ListSkillNames = agent.ContextBuilder.ListSkillNames + } + rt.GetModelInfo = func() (string, string) { + return agent.Model, resolvedCandidateProvider(agent.Candidates, cfg.Agents.Defaults.Provider) + } + rt.SwitchModel = func(value string) (string, error) { + value = strings.TrimSpace(value) + modelCfg, err := resolvedModelConfig(cfg, value, agent.Workspace) + if err != nil { + return "", err + } + + nextProvider, _, err := providers.CreateProviderFromConfig(modelCfg) + if err != nil { + return "", fmt.Errorf("failed to initialize model %q: %w", value, err) + } + + nextCandidates := resolveModelCandidates(cfg, cfg.Agents.Defaults.Provider, value, agent.Fallbacks) + if len(nextCandidates) == 0 { + return "", fmt.Errorf("model %q did not resolve to any provider candidates", value) + } + + oldModel := agent.Model + oldProvider := agent.Provider + agent.Model = value + agent.Provider = nextProvider + agent.Candidates = nextCandidates + agent.ThinkingLevel = parseThinkingLevel(modelCfg.ThinkingLevel) + + if oldProvider != nil && oldProvider != nextProvider { + if stateful, ok := oldProvider.(providers.StatefulProvider); ok { + stateful.Close() + } + } + return oldModel, nil + } + + rt.ClearHistory = func() error { + if opts == nil { + return fmt.Errorf("process options not available") + } + return al.contextManager.Clear(ctx, opts.SessionKey) + } + + rt.AskSideQuestion = func(ctx context.Context, question string) (string, error) { + return al.askSideQuestion(ctx, agent, opts, question) + } + + rt.GetContextStats = func() *commands.ContextStats { + if opts == nil || agent.Sessions == nil { + return nil + } + usage := computeContextUsage(agent, opts.SessionKey) + if usage == nil { + return nil + } + history := agent.Sessions.GetHistory(opts.SessionKey) + return &commands.ContextStats{ + UsedTokens: usage.UsedTokens, + TotalTokens: usage.TotalTokens, + CompressAtTokens: usage.CompressAtTokens, + UsedPercent: usage.UsedPercent, + MessageCount: len(history), + } + } + } + return rt +} + +func (al *AgentLoop) setPendingSkills(sessionKey string, skillNames []string) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" || len(skillNames) == 0 { + return + } + + filtered := make([]string, 0, len(skillNames)) + for _, name := range skillNames { + name = strings.TrimSpace(name) + if name != "" { + filtered = append(filtered, name) + } + } + if len(filtered) == 0 { + return + } + + al.pendingSkills.Store(sessionKey, filtered) +} + +func (al *AgentLoop) takePendingSkills(sessionKey string) []string { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return nil + } + + value, ok := al.pendingSkills.LoadAndDelete(sessionKey) + if !ok { + return nil + } + + skills, ok := value.([]string) + if !ok { + return nil + } + + return append([]string(nil), skills...) +} + +func (al *AgentLoop) clearPendingSkills(sessionKey string) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return + } + al.pendingSkills.Delete(sessionKey) +} diff --git a/pkg/agent/agent_event.go b/pkg/agent/agent_event.go new file mode 100644 index 000000000..9b8625df1 --- /dev/null +++ b/pkg/agent/agent_event.go @@ -0,0 +1,188 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "fmt" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +func (al *AgentLoop) newTurnEventScope(agentID, sessionKey string, turnCtx *TurnContext) turnEventScope { + seq := al.turnSeq.Add(1) + return turnEventScope{ + agentID: agentID, + sessionKey: sessionKey, + turnID: fmt.Sprintf("%s-turn-%d", agentID, seq), + context: cloneTurnContext(turnCtx), + } +} + +func (ts turnEventScope) meta(iteration int, source, tracePath string) EventMeta { + return EventMeta{ + AgentID: ts.agentID, + TurnID: ts.turnID, + SessionKey: ts.sessionKey, + Iteration: iteration, + Source: source, + TracePath: tracePath, + turnContext: cloneTurnContext(ts.context), + } +} + +func (al *AgentLoop) emitEvent(kind EventKind, meta EventMeta, payload any) { + clonedMeta := cloneEventMeta(meta) + evt := Event{ + Kind: kind, + Meta: clonedMeta, + Context: cloneTurnContext(clonedMeta.turnContext), + Payload: payload, + } + + if al == nil || al.eventBus == nil { + return + } + + al.logEvent(evt) + + al.eventBus.Emit(evt) +} + +func (al *AgentLoop) logEvent(evt Event) { + fields := map[string]any{ + "event_kind": evt.Kind.String(), + "agent_id": evt.Meta.AgentID, + "turn_id": evt.Meta.TurnID, + "session_key": evt.Meta.SessionKey, + "iteration": evt.Meta.Iteration, + } + + if evt.Meta.TracePath != "" { + fields["trace"] = evt.Meta.TracePath + } + if evt.Meta.Source != "" { + fields["source"] = evt.Meta.Source + } + + appendEventContextFields(fields, evt.Context) + + switch payload := evt.Payload.(type) { + case TurnStartPayload: + fields["user_len"] = len(payload.UserMessage) + fields["media_count"] = payload.MediaCount + case TurnEndPayload: + fields["status"] = payload.Status + fields["iterations_total"] = payload.Iterations + fields["duration_ms"] = payload.Duration.Milliseconds() + fields["final_len"] = payload.FinalContentLen + case LLMRequestPayload: + fields["model"] = payload.Model + fields["messages"] = payload.MessagesCount + fields["tools"] = payload.ToolsCount + fields["max_tokens"] = payload.MaxTokens + case LLMDeltaPayload: + fields["content_delta_len"] = payload.ContentDeltaLen + fields["reasoning_delta_len"] = payload.ReasoningDeltaLen + case LLMResponsePayload: + fields["content_len"] = payload.ContentLen + fields["tool_calls"] = payload.ToolCalls + fields["has_reasoning"] = payload.HasReasoning + case LLMRetryPayload: + fields["attempt"] = payload.Attempt + fields["max_retries"] = payload.MaxRetries + fields["reason"] = payload.Reason + fields["error"] = payload.Error + fields["backoff_ms"] = payload.Backoff.Milliseconds() + case ContextCompressPayload: + fields["reason"] = payload.Reason + fields["dropped_messages"] = payload.DroppedMessages + fields["remaining_messages"] = payload.RemainingMessages + case SessionSummarizePayload: + fields["summarized_messages"] = payload.SummarizedMessages + fields["kept_messages"] = payload.KeptMessages + fields["summary_len"] = payload.SummaryLen + fields["omitted_oversized"] = payload.OmittedOversized + case ToolExecStartPayload: + fields["tool"] = payload.Tool + fields["args_count"] = len(payload.Arguments) + case ToolExecEndPayload: + fields["tool"] = payload.Tool + fields["duration_ms"] = payload.Duration.Milliseconds() + fields["for_llm_len"] = payload.ForLLMLen + fields["for_user_len"] = payload.ForUserLen + fields["is_error"] = payload.IsError + fields["async"] = payload.Async + case ToolExecSkippedPayload: + fields["tool"] = payload.Tool + fields["reason"] = payload.Reason + case SteeringInjectedPayload: + fields["count"] = payload.Count + fields["total_content_len"] = payload.TotalContentLen + case FollowUpQueuedPayload: + fields["source_tool"] = payload.SourceTool + fields["content_len"] = payload.ContentLen + case InterruptReceivedPayload: + fields["interrupt_kind"] = payload.Kind + fields["role"] = payload.Role + fields["content_len"] = payload.ContentLen + fields["queue_depth"] = payload.QueueDepth + fields["hint_len"] = payload.HintLen + case SubTurnSpawnPayload: + fields["child_agent_id"] = payload.AgentID + fields["label"] = payload.Label + case SubTurnEndPayload: + fields["child_agent_id"] = payload.AgentID + fields["status"] = payload.Status + case SubTurnResultDeliveredPayload: + fields["target_channel"] = payload.TargetChannel + fields["target_chat_id"] = payload.TargetChatID + fields["content_len"] = payload.ContentLen + case ErrorPayload: + fields["stage"] = payload.Stage + fields["error"] = payload.Message + } + + logger.InfoCF("eventbus", fmt.Sprintf("Agent event: %s", evt.Kind.String()), fields) +} + +// MountHook registers an in-process hook on the agent loop. +func (al *AgentLoop) MountHook(reg HookRegistration) error { + if al == nil || al.hooks == nil { + return fmt.Errorf("hook manager is not initialized") + } + return al.hooks.Mount(reg) +} + +// UnmountHook removes a previously registered in-process hook. +func (al *AgentLoop) UnmountHook(name string) { + if al == nil || al.hooks == nil { + return + } + al.hooks.Unmount(name) +} + +// SubscribeEvents registers a subscriber for agent-loop events. +func (al *AgentLoop) SubscribeEvents(buffer int) EventSubscription { + if al == nil || al.eventBus == nil { + ch := make(chan Event) + close(ch) + return EventSubscription{C: ch} + } + return al.eventBus.Subscribe(buffer) +} + +// UnsubscribeEvents removes a previously registered event subscriber. +func (al *AgentLoop) UnsubscribeEvents(id uint64) { + if al == nil || al.eventBus == nil { + return + } + al.eventBus.Unsubscribe(id) +} + +// EventDrops returns the number of dropped events for the given kind. +func (al *AgentLoop) EventDrops(kind EventKind) int64 { + if al == nil || al.eventBus == nil { + return 0 + } + return al.eventBus.Dropped(kind) +} diff --git a/pkg/agent/agent_init.go b/pkg/agent/agent_init.go new file mode 100644 index 000000000..d7bfc22c7 --- /dev/null +++ b/pkg/agent/agent_init.go @@ -0,0 +1,354 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "fmt" + "time" + + "github.com/sipeed/picoclaw/pkg/agent/interfaces" + "github.com/sipeed/picoclaw/pkg/audio/tts" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/commands" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/skills" + "github.com/sipeed/picoclaw/pkg/state" + "github.com/sipeed/picoclaw/pkg/tools" +) + +func NewAgentLoop( + cfg *config.Config, + msgBus *bus.MessageBus, + provider providers.LLMProvider, +) *AgentLoop { + registry := NewAgentRegistry(cfg, provider) + + // Set up shared fallback chain with rate limiting. + cooldown := providers.NewCooldownTracker() + rl := providers.NewRateLimiterRegistry() + // Register rate limiters for all agents' candidates so that RPM limits + // configured in ModelConfig are enforced before each LLM call. + for _, agentID := range registry.ListAgentIDs() { + if agent, ok := registry.GetAgent(agentID); ok { + rl.RegisterCandidates(agent.Candidates) + rl.RegisterCandidates(agent.LightCandidates) + } + } + fallbackChain := providers.NewFallbackChain(cooldown, rl) + + // Create state manager using default agent's workspace for channel recording + defaultAgent := registry.GetDefaultAgent() + var stateManager *state.Manager + if defaultAgent != nil { + stateManager = state.NewManager(defaultAgent.Workspace) + } + + eventBus := NewEventBus() + + // Determine worker pool size from config (default: 1 = sequential) + workerPoolSize := cfg.Agents.Defaults.MaxParallelTurns + if workerPoolSize <= 0 { + workerPoolSize = 1 + } + + al := &AgentLoop{ + bus: msgBus, + cfg: cfg, + registry: registry, + state: stateManager, + eventBus: eventBus, + fallback: fallbackChain, + cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()), + steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)), + workerSem: make(chan struct{}, workerPoolSize), + } + al.providerFactory = providers.CreateProviderFromConfig + al.hooks = NewHookManager(eventBus) + configureHookManagerFromConfig(al.hooks, cfg) + al.contextManager = al.resolveContextManager() + + // Register shared tools to all agents (now that al is created) + registerSharedTools(al, cfg, msgBus, registry, provider) + + return al +} + +func registerSharedTools( + al *AgentLoop, + cfg *config.Config, + msgBus interfaces.MessageBus, + registry *AgentRegistry, + provider providers.LLMProvider, +) { + allowReadPaths := buildAllowReadPatterns(cfg) + var ttsProvider tts.TTSProvider + if cfg.Tools.IsToolEnabled("send_tts") { + ttsProvider = tts.DetectTTS(cfg) + if ttsProvider == nil { + logger.WarnCF("voice-tts", "send_tts enabled but no TTS provider configured", nil) + } + } + + for _, agentID := range registry.ListAgentIDs() { + agent, ok := registry.GetAgent(agentID) + if !ok { + continue + } + + if cfg.Tools.IsToolEnabled("web") { + searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{ + BraveAPIKeys: cfg.Tools.Web.Brave.APIKeys.Values(), + BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, + BraveEnabled: cfg.Tools.Web.Brave.Enabled, + TavilyAPIKeys: cfg.Tools.Web.Tavily.APIKeys.Values(), + TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, + TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, + TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, + DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, + DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, + PerplexityAPIKeys: cfg.Tools.Web.Perplexity.APIKeys.Values(), + PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, + PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, + SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL, + SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults, + SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled, + GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey.String(), + GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL, + GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine, + GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults, + GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled, + BaiduSearchAPIKey: cfg.Tools.Web.BaiduSearch.APIKey.String(), + BaiduSearchBaseURL: cfg.Tools.Web.BaiduSearch.BaseURL, + BaiduSearchMaxResults: cfg.Tools.Web.BaiduSearch.MaxResults, + BaiduSearchEnabled: cfg.Tools.Web.BaiduSearch.Enabled, + Proxy: cfg.Tools.Web.Proxy, + }) + if err != nil { + logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()}) + } else if searchTool != nil { + agent.Tools.Register(searchTool) + } + } + if cfg.Tools.IsToolEnabled("web_fetch") { + fetchTool, err := tools.NewWebFetchToolWithProxy( + 50000, + cfg.Tools.Web.Proxy, + cfg.Tools.Web.Format, + cfg.Tools.Web.FetchLimitBytes, + cfg.Tools.Web.PrivateHostWhitelist) + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } else { + agent.Tools.Register(fetchTool) + } + } + + // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms + if cfg.Tools.IsToolEnabled("i2c") { + agent.Tools.Register(tools.NewI2CTool()) + } + if cfg.Tools.IsToolEnabled("spi") { + agent.Tools.Register(tools.NewSPITool()) + } + + // Message tool + if cfg.Tools.IsToolEnabled("message") { + messageTool := tools.NewMessageTool() + messageTool.SetSendCallback(func( + ctx context.Context, + channel, chatID, content, replyToMessageID string, + ) error { + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + outboundCtx := bus.NewOutboundContext(channel, chatID, replyToMessageID) + outboundAgentID, outboundSessionKey, outboundScope := outboundTurnMetadata( + tools.ToolAgentID(ctx), + tools.ToolSessionKey(ctx), + tools.ToolSessionScope(ctx), + ) + return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ + Context: outboundCtx, + AgentID: outboundAgentID, + SessionKey: outboundSessionKey, + Scope: outboundScope, + Content: content, + ReplyToMessageID: replyToMessageID, + }) + }) + agent.Tools.Register(messageTool) + } + if cfg.Tools.IsToolEnabled("reaction") { + reactionTool := tools.NewReactionTool() + reactionTool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error { + if al.channelManager == nil { + return fmt.Errorf("channel manager not configured") + } + ch, ok := al.channelManager.GetChannel(channel) + if !ok { + return fmt.Errorf("channel %s not found", channel) + } + rc, ok := ch.(channels.ReactionCapable) + if !ok { + return fmt.Errorf("channel %s does not support reactions", channel) + } + _, err := rc.ReactToMessage(ctx, chatID, messageID) + return err + }) + agent.Tools.Register(reactionTool) + } + + // Send file tool (outbound media via MediaStore — store injected later by SetMediaStore) + if cfg.Tools.IsToolEnabled("send_file") { + sendFileTool := tools.NewSendFileTool( + agent.Workspace, + cfg.Agents.Defaults.RestrictToWorkspace, + cfg.Agents.Defaults.GetMaxMediaSize(), + nil, + allowReadPaths, + ) + agent.Tools.Register(sendFileTool) + } + + if ttsProvider != nil { + agent.Tools.Register(tools.NewSendTTSTool(ttsProvider, nil)) + } + + if cfg.Tools.IsToolEnabled("load_image") { + loadImageTool := tools.NewLoadImageTool( + agent.Workspace, + cfg.Agents.Defaults.RestrictToWorkspace, + cfg.Agents.Defaults.GetMaxMediaSize(), + nil, + allowReadPaths, + ) + agent.Tools.Register(loadImageTool) + } + + // Skill discovery and installation tools + skills_enabled := cfg.Tools.IsToolEnabled("skills") + find_skills_enable := cfg.Tools.IsToolEnabled("find_skills") + install_skills_enable := cfg.Tools.IsToolEnabled("install_skill") + if skills_enabled && (find_skills_enable || install_skills_enable) { + registryMgr := skills.NewRegistryManagerFromToolsConfig(cfg.Tools.Skills) + + if find_skills_enable { + searchCache := skills.NewSearchCache( + cfg.Tools.Skills.SearchCache.MaxSize, + time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second, + ) + agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache)) + } + + if install_skills_enable { + agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace)) + } + } + + // Spawn and spawn_status tools share a SubagentManager. + // Construct it when either tool is enabled (both require subagent). + spawnEnabled := cfg.Tools.IsToolEnabled("spawn") + spawnStatusEnabled := cfg.Tools.IsToolEnabled("spawn_status") + if (spawnEnabled || spawnStatusEnabled) && cfg.Tools.IsToolEnabled("subagent") { + subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace) + subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) + + // Inject a media resolver so the legacy RunToolLoop fallback path can + // resolve media:// refs in the same way the main AgentLoop does. + // This keeps subagent vision support working even when the optimized + // sub-turn spawner path is unavailable. + subagentManager.SetMediaResolver(func(msgs []providers.Message) []providers.Message { + return resolveMediaRefs(msgs, al.mediaStore, cfg.Agents.Defaults.GetMaxMediaSize()) + }) + + // Set the spawner that links into AgentLoop's turnState + subagentManager.SetSpawner(func( + ctx context.Context, + task, label, targetAgentID string, + tls *tools.ToolRegistry, + maxTokens int, + temperature float64, + hasMaxTokens, hasTemperature bool, + ) (*tools.ToolResult, error) { + // 1. Recover parent Turn State from Context + parentTS := turnStateFromContext(ctx) + if parentTS == nil { + // Fallback: If no turnState exists in context, create an isolated ad-hoc root turn state + // so that the tool can still function outside of an agent loop (e.g. tests, raw invocations). + parentTS = &turnState{ + ctx: ctx, + turnID: "adhoc-root", + depth: 0, + session: nil, // Ephemeral session not needed for adhoc spawn + pendingResults: make(chan *tools.ToolResult, 16), + concurrencySem: make(chan struct{}, 5), + } + } + + // 2. Build Tools slice from registry + var tlSlice []tools.Tool + for _, name := range tls.List() { + if t, ok := tls.Get(name); ok { + tlSlice = append(tlSlice, t) + } + } + + // 3. System Prompt + systemPrompt := "You are a subagent. Complete the given task independently and report the result.\n" + + "You have access to tools - use them as needed to complete your task.\n" + + "After completing the task, provide a clear summary of what was done.\n\n" + + "Task: " + task + + // 4. Resolve Model + modelToUse := agent.Model + if targetAgentID != "" { + if targetAgent, ok := al.GetRegistry().GetAgent(targetAgentID); ok { + modelToUse = targetAgent.Model + } + } + + // 5. Build SubTurnConfig + cfg := SubTurnConfig{ + Model: modelToUse, + Tools: tlSlice, + SystemPrompt: systemPrompt, + } + if hasMaxTokens { + cfg.MaxTokens = maxTokens + } + + // 6. Spawn SubTurn + return spawnSubTurn(ctx, al, parentTS, cfg) + }) + + // Clone the parent's tool registry so subagents can use all + // tools registered so far (file, web, etc.) but NOT spawn/ + // spawn_status which are added below — preventing recursive + // subagent spawning. + subagentManager.SetTools(agent.Tools.Clone()) + if spawnEnabled { + spawnTool := tools.NewSpawnTool(subagentManager) + spawnTool.SetSpawner(NewSubTurnSpawner(al)) + currentAgentID := agentID + spawnTool.SetAllowlistChecker(func(targetAgentID string) bool { + return registry.CanSpawnSubagent(currentAgentID, targetAgentID) + }) + + agent.Tools.Register(spawnTool) + + // Also register the synchronous subagent tool + subagentTool := tools.NewSubagentTool(subagentManager) + subagentTool.SetSpawner(NewSubTurnSpawner(al)) + agent.Tools.Register(subagentTool) + } + if spawnStatusEnabled { + agent.Tools.Register(tools.NewSpawnStatusTool(subagentManager)) + } + } else if (spawnEnabled || spawnStatusEnabled) && !cfg.Tools.IsToolEnabled("subagent") { + logger.WarnCF("agent", "spawn/spawn_status tools require subagent to be enabled", nil) + } + } +} diff --git a/pkg/agent/agent_inject.go b/pkg/agent/agent_inject.go new file mode 100644 index 000000000..6c0ad10da --- /dev/null +++ b/pkg/agent/agent_inject.go @@ -0,0 +1,103 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "github.com/sipeed/picoclaw/pkg/audio/asr" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/tools" +) + +func (al *AgentLoop) RegisterTool(tool tools.Tool) { + registry := al.GetRegistry() + for _, agentID := range registry.ListAgentIDs() { + if agent, ok := registry.GetAgent(agentID); ok { + agent.Tools.Register(tool) + } + } +} + +func (al *AgentLoop) SetChannelManager(cm *channels.Manager) { + al.channelManager = cm +} + +func (al *AgentLoop) GetRegistry() *AgentRegistry { + al.mu.RLock() + defer al.mu.RUnlock() + return al.registry +} + +func (al *AgentLoop) GetConfig() *config.Config { + al.mu.RLock() + defer al.mu.RUnlock() + return al.cfg +} + +func (al *AgentLoop) SetMediaStore(s media.MediaStore) { + al.mediaStore = s + + // Propagate store to all registered tools that can emit media. + registry := al.GetRegistry() + for _, agentID := range registry.ListAgentIDs() { + if agent, ok := registry.GetAgent(agentID); ok { + agent.Tools.SetMediaStore(s) + } + } + registry.ForEachTool("send_tts", func(t tools.Tool) { + if st, ok := t.(*tools.SendTTSTool); ok { + st.SetMediaStore(s) + } + }) +} + +func (al *AgentLoop) SetTranscriber(t asr.Transcriber) { + al.transcriber = t +} + +func (al *AgentLoop) SetReloadFunc(fn func() error) { + al.reloadFunc = fn +} + +func (al *AgentLoop) RecordLastChannel(channel string) error { + if al.state == nil { + return nil + } + return al.state.SetLastChannel(channel) +} + +func (al *AgentLoop) RecordLastChatID(chatID string) error { + if al.state == nil { + return nil + } + return al.state.SetLastChatID(chatID) +} + +func (al *AgentLoop) GetStartupInfo() map[string]any { + info := make(map[string]any) + + registry := al.GetRegistry() + agent := registry.GetDefaultAgent() + if agent == nil { + return info + } + + // Tools info + toolsList := agent.Tools.List() + info["tools"] = map[string]any{ + "count": len(toolsList), + "names": toolsList, + } + + // Skills info + info["skills"] = agent.ContextBuilder.GetSkillsInfo() + + // Agents info + info["agents"] = map[string]any{ + "count": len(registry.ListAgentIDs()), + "ids": registry.ListAgentIDs(), + } + + return info +} diff --git a/pkg/agent/loop_mcp.go b/pkg/agent/agent_mcp.go similarity index 100% rename from pkg/agent/loop_mcp.go rename to pkg/agent/agent_mcp.go diff --git a/pkg/agent/loop_mcp_test.go b/pkg/agent/agent_mcp_test.go similarity index 100% rename from pkg/agent/loop_mcp_test.go rename to pkg/agent/agent_mcp_test.go diff --git a/pkg/agent/loop_media.go b/pkg/agent/agent_media.go similarity index 89% rename from pkg/agent/loop_media.go rename to pkg/agent/agent_media.go index e8314c10d..a773d2ebb 100644 --- a/pkg/agent/loop_media.go +++ b/pkg/agent/agent_media.go @@ -105,6 +105,25 @@ func buildArtifactTags(store media.MediaStore, refs []string) []string { return tags } +func buildProviderAttachments(store media.MediaStore, refs []string) []providers.Attachment { + if store == nil || len(refs) == 0 { + return nil + } + + attachments := make([]providers.Attachment, 0, len(refs)) + for _, ref := range refs { + attachment := providers.Attachment{Ref: ref} + if _, meta, err := store.ResolveWithMeta(ref); err == nil { + attachment.Filename = meta.Filename + attachment.ContentType = meta.ContentType + attachment.Type = inferMediaType(meta.Filename, meta.ContentType) + } + attachments = append(attachments, attachment) + } + + return attachments +} + // detectMIME determines the MIME type from metadata or magic-bytes detection. // Returns empty string if detection fails. func detectMIME(localPath string, meta media.MediaMeta) string { diff --git a/pkg/agent/agent_message.go b/pkg/agent/agent_message.go new file mode 100644 index 000000000..96b0b0817 --- /dev/null +++ b/pkg/agent/agent_message.go @@ -0,0 +1,302 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/constants" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/routing" + "github.com/sipeed/picoclaw/pkg/session" + "github.com/sipeed/picoclaw/pkg/utils" +) + +func (al *AgentLoop) buildContinuationTarget(msg bus.InboundMessage) (*continuationTarget, error) { + if msg.Channel == "system" { + return nil, nil + } + + route, _, err := al.resolveMessageRoute(msg) + if err != nil { + return nil, err + } + allocation := al.allocateRouteSession(route, msg) + + return &continuationTarget{ + SessionKey: resolveScopeKey(allocation.SessionKey, msg.SessionKey), + Channel: msg.Channel, + ChatID: msg.ChatID, + }, nil +} + +func (al *AgentLoop) ProcessDirect( + ctx context.Context, + content, sessionKey string, +) (string, error) { + return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct") +} + +func (al *AgentLoop) ProcessDirectWithChannel( + ctx context.Context, + content, sessionKey, channel, chatID string, +) (string, error) { + if err := al.ensureHooksInitialized(ctx); err != nil { + return "", err + } + if err := al.ensureMCPInitialized(ctx); err != nil { + return "", err + } + + msg := bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: channel, + ChatID: chatID, + ChatType: "direct", + SenderID: "cron", + }, + Content: content, + SessionKey: sessionKey, + } + + return al.processMessage(ctx, msg) +} + +func (al *AgentLoop) ProcessHeartbeat( + ctx context.Context, + content, channel, chatID string, +) (string, error) { + if err := al.ensureHooksInitialized(ctx); err != nil { + return "", err + } + if err := al.ensureMCPInitialized(ctx); err != nil { + return "", err + } + + agent := al.GetRegistry().GetDefaultAgent() + if agent == nil { + return "", fmt.Errorf("no default agent for heartbeat") + } + dispatch := DispatchRequest{ + SessionKey: "heartbeat", + UserMessage: content, + } + if channel != "" || chatID != "" { + dispatch.InboundContext = &bus.InboundContext{ + Channel: channel, + ChatID: chatID, + ChatType: "direct", + SenderID: "heartbeat", + } + } + return al.runAgentLoop(ctx, agent, processOptions{ + Dispatch: dispatch, + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + SuppressToolFeedback: true, + NoHistory: true, // Don't load session history for heartbeat + }) +} + +func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { + msg = bus.NormalizeInboundMessage(msg) + + // Add message preview to log (show full content for error messages) + var logContent string + if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") { + logContent = msg.Content // Full content for errors + } else { + logContent = utils.Truncate(msg.Content, 80) + } + logger.InfoCF( + "agent", + fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent), + map[string]any{ + "channel": msg.Channel, + "chat_id": msg.ChatID, + "sender_id": msg.SenderID, + "session_key": msg.SessionKey, + }, + ) + + var hadAudio bool + msg, hadAudio = al.transcribeAudioInMessage(ctx, msg) + + // For audio messages the placeholder was deferred by the channel. + // Now that transcription (and optional feedback) is done, send it. + if hadAudio && al.channelManager != nil { + al.channelManager.SendPlaceholder(ctx, msg.Channel, msg.ChatID) + } + + // Route system messages to processSystemMessage + if msg.Channel == "system" { + return al.processSystemMessage(ctx, msg) + } + + route, agent, routeErr := al.resolveMessageRoute(msg) + if routeErr != nil { + return "", routeErr + } + + allocation := al.allocateRouteSession(route, msg) + + // Resolve session key from the route allocation, while preserving explicit + // agent-scoped keys supplied by the caller. + scopeKey := resolveScopeKey(allocation.SessionKey, msg.SessionKey) + sessionKey := scopeKey + + // Reset message-tool state for this round so we don't skip publishing due to a previous round. + if tool, ok := agent.Tools.Get("message"); ok { + if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok { + resetter.ResetSentInRound(sessionKey) + } + } + + logger.InfoCF("agent", "Routed message", + map[string]any{ + "agent_id": agent.ID, + "scope_key": scopeKey, + "session_key": sessionKey, + "matched_by": route.MatchedBy, + "route_agent": route.AgentID, + "route_channel": route.Channel, + "route_main_session": allocation.MainSessionKey, + }) + + opts := processOptions{ + Dispatch: DispatchRequest{ + SessionKey: sessionKey, + SessionAliases: buildSessionAliases(sessionKey, append(allocation.SessionAliases, msg.SessionKey)...), + InboundContext: cloneInboundContext(&msg.Context), + RouteResult: cloneResolvedRoute(&route), + SessionScope: session.CloneScope(&allocation.Scope), + UserMessage: msg.Content, + Media: append([]string(nil), msg.Media...), + }, + SenderID: msg.SenderID, + SenderDisplayName: msg.Sender.DisplayName, + DefaultResponse: defaultResponse, + EnableSummary: true, + SendResponse: false, + AllowInterimPicoPublish: true, + } + + // context-dependent commands check their own Runtime fields and report + // "unavailable" when the required capability is nil. + if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled { + return response, nil + } + + if pending := al.takePendingSkills(opts.Dispatch.SessionKey); len(pending) > 0 { + opts.ForcedSkills = append(opts.ForcedSkills, pending...) + logger.InfoCF("agent", "Applying pending skill override", + map[string]any{ + "session_key": opts.Dispatch.SessionKey, + "skills": strings.Join(pending, ","), + }) + } + + return al.runAgentLoop(ctx, agent, opts) +} + +func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) { + registry := al.GetRegistry() + inboundCtx := normalizedInboundContext(msg) + route := registry.ResolveRoute(inboundCtx) + + agent, ok := registry.GetAgent(route.AgentID) + if !ok { + agent = registry.GetDefaultAgent() + } + if agent == nil { + return routing.ResolvedRoute{}, nil, fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID) + } + + return route, agent, nil +} + +func (al *AgentLoop) allocateRouteSession(route routing.ResolvedRoute, msg bus.InboundMessage) session.Allocation { + return session.AllocateRouteSession(session.AllocationInput{ + AgentID: route.AgentID, + Context: normalizedInboundContext(msg), + SessionPolicy: route.SessionPolicy, + }) +} + +func (al *AgentLoop) processSystemMessage( + ctx context.Context, + msg bus.InboundMessage, +) (string, error) { + if msg.Channel != "system" { + return "", fmt.Errorf( + "processSystemMessage called with non-system message channel: %s", + msg.Channel, + ) + } + + logger.InfoCF("agent", "Processing system message", + map[string]any{ + "sender_id": msg.SenderID, + "chat_id": msg.ChatID, + }) + + // Parse origin channel from chat_id (format: "channel:chat_id") + var originChannel, originChatID string + if idx := strings.Index(msg.ChatID, ":"); idx > 0 { + originChannel = msg.ChatID[:idx] + originChatID = msg.ChatID[idx+1:] + } else { + originChannel = "cli" + originChatID = msg.ChatID + } + + // Extract subagent result from message content + // Format: "Task 'label' completed.\n\nResult:\n" + content := msg.Content + if idx := strings.Index(content, "Result:\n"); idx >= 0 { + content = content[idx+8:] // Extract just the result part + } + + // Skip internal channels - only log, don't send to user + if constants.IsInternalChannel(originChannel) { + logger.InfoCF("agent", "Subagent completed (internal channel)", + map[string]any{ + "sender_id": msg.SenderID, + "content_len": len(content), + "channel": originChannel, + }) + return "", nil + } + + // Use default agent for system messages + agent := al.GetRegistry().GetDefaultAgent() + if agent == nil { + return "", fmt.Errorf("no default agent for system message") + } + + // Use the origin session for context + sessionKey := session.BuildMainSessionKey(agent.ID) + dispatch := DispatchRequest{ + SessionKey: sessionKey, + UserMessage: fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content), + } + if originChannel != "" || originChatID != "" { + dispatch.InboundContext = &bus.InboundContext{ + Channel: originChannel, + ChatID: originChatID, + ChatType: "direct", + SenderID: msg.SenderID, + } + } + + return al.runAgentLoop(ctx, agent, processOptions{ + Dispatch: dispatch, + DefaultResponse: "Background task completed.", + EnableSummary: false, + SendResponse: true, + }) +} diff --git a/pkg/agent/agent_outbound.go b/pkg/agent/agent_outbound.go new file mode 100644 index 000000000..7e36e4ad8 --- /dev/null +++ b/pkg/agent/agent_outbound.go @@ -0,0 +1,169 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/tools" +) + +func (al *AgentLoop) maybePublishError(ctx context.Context, channel, chatID, sessionKey string, err error) bool { + if errors.Is(err, context.Canceled) { + return false + } + al.PublishResponseIfNeeded(ctx, channel, chatID, sessionKey, fmt.Sprintf("Error processing message: %v", err)) + return true +} + +func (al *AgentLoop) publishResponseOrError( + ctx context.Context, + channel, chatID, sessionKey string, + response string, + err error, +) { + if err != nil { + if !al.maybePublishError(ctx, channel, chatID, sessionKey, err) { + return + } + response = "" + } + al.PublishResponseIfNeeded(ctx, channel, chatID, sessionKey, response) +} + +func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatID, sessionKey, response string) { + if response == "" { + return + } + + alreadySentToSameChat := false + defaultAgent := al.GetRegistry().GetDefaultAgent() + if defaultAgent != nil { + if tool, ok := defaultAgent.Tools.Get("message"); ok { + if mt, ok := tool.(*tools.MessageTool); ok { + alreadySentToSameChat = mt.HasSentTo(sessionKey, channel, chatID) + } + } + } + + if alreadySentToSameChat { + logger.DebugCF( + "agent", + "Skipped outbound (message tool already sent to same chat)", + map[string]any{"channel": channel, "chat_id": chatID}, + ) + return + } + + msg := bus.OutboundMessage{ + Context: bus.NewOutboundContext(channel, chatID, ""), + Content: response, + } + if sessionKey != "" { + msg.ContextUsage = computeContextUsage(al.agentForSession(sessionKey), sessionKey) + } + al.bus.PublishOutbound(ctx, msg) + logger.InfoCF("agent", "Published outbound response", + map[string]any{ + "channel": channel, + "chat_id": chatID, + "content_len": len(response), + }) +} + +func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string) { + if al.channelManager == nil { + return "" + } + if ch, ok := al.channelManager.GetChannel(channelName); ok { + return ch.ReasoningChannelID() + } + return "" +} + +func (al *AgentLoop) publishPicoReasoning(ctx context.Context, reasoningContent, chatID string) { + if reasoningContent == "" || chatID == "" { + return + } + + if ctx.Err() != nil { + return + } + + pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second) + defer pubCancel() + + if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ + Context: bus.InboundContext{ + Channel: "pico", + ChatID: chatID, + Raw: map[string]string{ + metadataKeyMessageKind: messageKindThought, + }, + }, + Content: reasoningContent, + }); err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || + errors.Is(err, bus.ErrBusClosed) { + logger.DebugCF("agent", "Pico reasoning publish skipped (timeout/cancel)", map[string]any{ + "channel": "pico", + "error": err.Error(), + }) + } else { + logger.WarnCF("agent", "Failed to publish pico reasoning (best-effort)", map[string]any{ + "channel": "pico", + "error": err.Error(), + }) + } + } +} + +func (al *AgentLoop) handleReasoning( + ctx context.Context, + reasoningContent, channelName, channelID string, +) { + if reasoningContent == "" || channelName == "" || channelID == "" { + return + } + + // Check context cancellation before attempting to publish, + // since PublishOutbound's select may race between send and ctx.Done(). + if ctx.Err() != nil { + return + } + + // Use a short timeout so the goroutine does not block indefinitely when + // the outbound bus is full. Reasoning output is best-effort; dropping it + // is acceptable to avoid goroutine accumulation. + pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second) + defer pubCancel() + + if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ + Context: bus.NewOutboundContext(channelName, channelID, ""), + Content: reasoningContent, + }); err != nil { + // Treat context.DeadlineExceeded / context.Canceled as expected + // (bus full under load, or parent canceled). Check the error + // itself rather than ctx.Err(), because pubCtx may time out + // (5 s) while the parent ctx is still active. + // Also treat ErrBusClosed as expected — it occurs during normal + // shutdown when the bus is closed before all goroutines finish. + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || + errors.Is(err, bus.ErrBusClosed) { + logger.DebugCF("agent", "Reasoning publish skipped (timeout/cancel)", map[string]any{ + "channel": channelName, + "error": err.Error(), + }) + } else { + logger.WarnCF("agent", "Failed to publish reasoning (best-effort)", map[string]any{ + "channel": channelName, + "error": err.Error(), + }) + } + } +} diff --git a/pkg/agent/agent_steering.go b/pkg/agent/agent_steering.go new file mode 100644 index 000000000..c674bcafa --- /dev/null +++ b/pkg/agent/agent_steering.go @@ -0,0 +1,96 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/logger" +) + +func (al *AgentLoop) processMessageSync(ctx context.Context, msg bus.InboundMessage) { + if al.channelManager != nil { + defer al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID) + } + + response, err := al.processMessage(ctx, msg) + al.publishResponseOrError(ctx, msg.Channel, msg.ChatID, msg.SessionKey, response, err) +} + +func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.InboundMessage) { + // Process the initial message + response, err := al.processMessage(ctx, initialMsg) + if err != nil { + if !al.maybePublishError(ctx, initialMsg.Channel, initialMsg.ChatID, initialMsg.SessionKey, err) { + return // context canceled + } + response = "" + } + finalResponse := response + + // Build continuation target + target, targetErr := al.buildContinuationTarget(initialMsg) + if targetErr != nil { + logger.WarnCF("agent", "Failed to build steering continuation target", + map[string]any{ + "channel": initialMsg.Channel, + "error": targetErr.Error(), + }) + return + } + if target == nil { + // System message or non-routable, response already published + return + } + + // Drain steering queue using existing Continue mechanism + for al.pendingSteeringCountForScope(target.SessionKey) > 0 { + // Check for context cancellation between iterations + if ctx.Err() != nil { + return + } + + logger.InfoCF("agent", "Continuing queued steering after turn end", + map[string]any{ + "channel": target.Channel, + "chat_id": target.ChatID, + "session_key": target.SessionKey, + "queue_depth": al.pendingSteeringCountForScope(target.SessionKey), + }) + + continued, continueErr := al.Continue(ctx, target.SessionKey, target.Channel, target.ChatID) + if continueErr != nil { + logger.WarnCF("agent", "Failed to continue queued steering", + map[string]any{ + "channel": target.Channel, + "chat_id": target.ChatID, + "error": continueErr.Error(), + }) + break + } + if continued == "" { + break + } + finalResponse = continued + } + + // Publish final response + if finalResponse != "" { + al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, finalResponse) + } +} + +func (al *AgentLoop) resolveSteeringTarget(msg bus.InboundMessage) (string, string, bool) { + if msg.Channel == "system" { + return "", "", false + } + + route, agent, err := al.resolveMessageRoute(msg) + if err != nil || agent == nil { + return "", "", false + } + allocation := al.allocateRouteSession(route, msg) + + return resolveScopeKey(allocation.SessionKey, msg.SessionKey), agent.ID, true +} diff --git a/pkg/agent/loop_test.go b/pkg/agent/agent_test.go similarity index 99% rename from pkg/agent/loop_test.go rename to pkg/agent/agent_test.go index 5cdac186c..61c8afa37 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/agent_test.go @@ -1051,6 +1051,9 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing. if last.Role != "assistant" || last.Content != "Requested output delivered via tool attachment." { t.Fatalf("expected handled assistant summary in history, got %+v", last) } + if len(last.Attachments) != 1 { + t.Fatalf("expected handled assistant summary attachments in history, got %+v", last.Attachments) + } } func TestProcessMessage_HandledToolProcessesQueuedSteeringBeforeReturning(t *testing.T) { diff --git a/pkg/agent/agent_transcribe.go b/pkg/agent/agent_transcribe.go new file mode 100644 index 000000000..0ab328f36 --- /dev/null +++ b/pkg/agent/agent_transcribe.go @@ -0,0 +1,109 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "strings" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/utils" +) + +func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) (bus.InboundMessage, bool) { + if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 { + return msg, false + } + + // Transcribe each audio media ref in order. + var transcriptions []string + var keptMedia []string + for _, ref := range msg.Media { + path, meta, err := al.mediaStore.ResolveWithMeta(ref) + if err != nil { + logger.WarnCF("voice", "Failed to resolve media ref", map[string]any{"ref": ref, "error": err}) + keptMedia = append(keptMedia, ref) + continue + } + if !utils.IsAudioFile(meta.Filename, meta.ContentType) { + keptMedia = append(keptMedia, ref) + continue + } + result, err := al.transcriber.Transcribe(ctx, path) + if err != nil { + logger.WarnCF("voice", "Transcription failed", map[string]any{"ref": ref, "error": err}) + transcriptions = append(transcriptions, "") + keptMedia = append(keptMedia, ref) + continue + } + transcriptions = append(transcriptions, result.Text) + } + + if len(transcriptions) == 0 { + return msg, false + } + + al.sendTranscriptionFeedback(ctx, msg.Channel, msg.ChatID, msg.MessageID, transcriptions) + + // Replace audio annotations sequentially with transcriptions. + idx := 0 + newContent := audioAnnotationRe.ReplaceAllStringFunc(msg.Content, func(match string) string { + if idx >= len(transcriptions) { + return match + } + text := transcriptions[idx] + idx++ + if text == "" { + return match + } + return "[voice: " + text + "]" + }) + + // Append any remaining transcriptions not matched by an annotation. + for ; idx < len(transcriptions); idx++ { + if transcriptions[idx] != "" { + newContent += "\n[voice: " + transcriptions[idx] + "]" + } + } + + msg.Content = newContent + msg.Media = keptMedia + return msg, true +} + +func (al *AgentLoop) sendTranscriptionFeedback( + ctx context.Context, + channel, chatID, messageID string, + validTexts []string, +) { + if !al.cfg.Voice.EchoTranscription { + return + } + if al.channelManager == nil { + return + } + + var nonEmpty []string + for _, t := range validTexts { + if t != "" { + nonEmpty = append(nonEmpty, t) + } + } + + var feedbackMsg string + if len(nonEmpty) > 0 { + feedbackMsg = "Transcript: " + strings.Join(nonEmpty, "\n") + } else { + feedbackMsg = "No voice detected in the audio" + } + + err := al.channelManager.SendMessage(ctx, bus.OutboundMessage{ + Context: bus.NewOutboundContext(channel, chatID, messageID), + Content: feedbackMsg, + ReplyToMessageID: messageID, + }) + if err != nil { + logger.WarnCF("voice", "Failed to send transcription feedback", map[string]any{"error": err.Error()}) + } +} diff --git a/pkg/agent/agent_utils.go b/pkg/agent/agent_utils.go new file mode 100644 index 000000000..2574f0222 --- /dev/null +++ b/pkg/agent/agent_utils.go @@ -0,0 +1,482 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/commands" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/session" + "github.com/sipeed/picoclaw/pkg/utils" +) + +func outboundContextFromInbound( + inbound *bus.InboundContext, + channel, chatID, replyToMessageID string, +) bus.InboundContext { + if inbound == nil { + return bus.NewOutboundContext(channel, chatID, replyToMessageID) + } + + outboundCtx := *cloneInboundContext(inbound) + if outboundCtx.Channel == "" { + outboundCtx.Channel = channel + } + if outboundCtx.ChatID == "" { + outboundCtx.ChatID = chatID + } + if outboundCtx.ReplyToMessageID == "" { + outboundCtx.ReplyToMessageID = replyToMessageID + } + return outboundCtx +} + +func outboundScopeFromSessionScope(scope *session.SessionScope) *bus.OutboundScope { + if scope == nil { + return nil + } + outboundScope := &bus.OutboundScope{ + Version: scope.Version, + AgentID: scope.AgentID, + Channel: scope.Channel, + Account: scope.Account, + } + if len(scope.Dimensions) > 0 { + outboundScope.Dimensions = append([]string(nil), scope.Dimensions...) + } + if len(scope.Values) > 0 { + outboundScope.Values = make(map[string]string, len(scope.Values)) + for key, value := range scope.Values { + outboundScope.Values[key] = value + } + } + return outboundScope +} + +func outboundTurnMetadata( + agentID, sessionKey string, + scope *session.SessionScope, +) (string, string, *bus.OutboundScope) { + return agentID, sessionKey, outboundScopeFromSessionScope(scope) +} + +func outboundMessageForTurn(ts *turnState, content string) bus.OutboundMessage { + agentID, sessionKey, scope := outboundTurnMetadata(ts.agent.ID, ts.sessionKey, ts.opts.Dispatch.SessionScope) + return bus.OutboundMessage{ + Channel: ts.channel, + ChatID: ts.chatID, + Context: outboundContextFromInbound( + ts.opts.Dispatch.InboundContext, + ts.channel, + ts.chatID, + ts.opts.Dispatch.ReplyToMessageID(), + ), + AgentID: agentID, + SessionKey: sessionKey, + Scope: scope, + Content: content, + } +} + +func cloneEventArguments(args map[string]any) map[string]any { + if len(args) == 0 { + return nil + } + + cloned := make(map[string]any, len(args)) + for k, v := range args { + cloned[k] = v + } + return cloned +} + +func hookDeniedToolContent(prefix, reason string) string { + if reason == "" { + return prefix + } + return prefix + ": " + reason +} + +func appendEventContextFields(fields map[string]any, turnCtx *TurnContext) { + if turnCtx == nil { + return + } + + if inbound := turnCtx.Inbound; inbound != nil { + if inbound.Channel != "" { + fields["inbound_channel"] = inbound.Channel + } + if inbound.Account != "" { + fields["inbound_account"] = inbound.Account + } + if inbound.ChatID != "" { + fields["inbound_chat_id"] = inbound.ChatID + } + if inbound.ChatType != "" { + fields["inbound_chat_type"] = inbound.ChatType + } + if inbound.TopicID != "" { + fields["inbound_topic_id"] = inbound.TopicID + } + if inbound.SpaceType != "" { + fields["inbound_space_type"] = inbound.SpaceType + } + if inbound.SpaceID != "" { + fields["inbound_space_id"] = inbound.SpaceID + } + if inbound.SenderID != "" { + fields["inbound_sender_id"] = inbound.SenderID + } + if inbound.Mentioned { + fields["inbound_mentioned"] = true + } + } + + if route := turnCtx.Route; route != nil { + if route.AgentID != "" { + fields["route_agent_id"] = route.AgentID + } + if route.Channel != "" { + fields["route_channel"] = route.Channel + } + if route.AccountID != "" { + fields["route_account_id"] = route.AccountID + } + if route.MatchedBy != "" { + fields["route_matched_by"] = route.MatchedBy + } + if len(route.SessionPolicy.Dimensions) > 0 { + fields["route_dimensions"] = strings.Join(route.SessionPolicy.Dimensions, ",") + } + if count := len(route.SessionPolicy.IdentityLinks); count > 0 { + fields["route_identity_link_count"] = count + } + } + + if scope := turnCtx.Scope; scope != nil { + if scope.Version > 0 { + fields["scope_version"] = scope.Version + } + if scope.AgentID != "" { + fields["scope_agent_id"] = scope.AgentID + } + if scope.Channel != "" { + fields["scope_channel"] = scope.Channel + } + if scope.Account != "" { + fields["scope_account"] = scope.Account + } + if len(scope.Dimensions) > 0 { + fields["scope_dimensions"] = strings.Join(scope.Dimensions, ",") + } + for dim, value := range scope.Values { + if dim == "" || value == "" { + continue + } + fields["scope_"+dim] = value + } + } +} + +func inferMediaType(filename, contentType string) string { + ct := strings.ToLower(contentType) + fn := strings.ToLower(filename) + + if strings.HasPrefix(ct, "image/") { + return "image" + } + if strings.HasPrefix(ct, "audio/") || ct == "application/ogg" { + return "audio" + } + if strings.HasPrefix(ct, "video/") { + return "video" + } + + // Fallback: infer from extension + ext := filepath.Ext(fn) + switch ext { + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg": + return "image" + case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus": + return "audio" + case ".mp4", ".avi", ".mov", ".webm", ".mkv": + return "video" + } + + return "file" +} + +func normalizedInboundContext(msg bus.InboundMessage) bus.InboundContext { + return bus.NormalizeInboundMessage(msg).Context +} + +func resolveScopeKey(routeSessionKey, msgSessionKey string) string { + if isExplicitSessionKey(msgSessionKey) { + return msgSessionKey + } + return routeSessionKey +} + +func isExplicitSessionKey(sessionKey string) bool { + return session.IsExplicitSessionKey(sessionKey) +} + +func buildSessionAliases(canonicalKey string, keys ...string) []string { + if len(keys) == 0 { + return nil + } + aliases := make([]string, 0, len(keys)) + seen := make(map[string]struct{}, len(keys)) + canonicalKey = strings.TrimSpace(canonicalKey) + for _, key := range keys { + key = strings.TrimSpace(key) + if key == "" || key == canonicalKey { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + aliases = append(aliases, key) + } + if len(aliases) == 0 { + return nil + } + return aliases +} + +func ensureSessionMetadata(store session.SessionStore, key string, scope *session.SessionScope, aliases []string) { + if key == "" || scope == nil { + return + } + metaStore, ok := store.(interface { + EnsureSessionMetadata(sessionKey string, scope *session.SessionScope, aliases []string) + }) + if !ok { + return + } + metaStore.EnsureSessionMetadata(key, scope, aliases) +} + +func sleepWithContext(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func formatMessagesForLog(messages []providers.Message) string { + if len(messages) == 0 { + return "[]" + } + + var sb strings.Builder + sb.WriteString("[\n") + for i, msg := range messages { + fmt.Fprintf(&sb, " [%d] Role: %s\n", i, msg.Role) + if len(msg.ToolCalls) > 0 { + sb.WriteString(" ToolCalls:\n") + for _, tc := range msg.ToolCalls { + fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name) + if tc.Function != nil { + fmt.Fprintf( + &sb, + " Arguments: %s\n", + utils.Truncate(tc.Function.Arguments, 200), + ) + } + } + } + if msg.Content != "" { + content := utils.Truncate(msg.Content, 200) + fmt.Fprintf(&sb, " Content: %s\n", content) + } + if msg.ToolCallID != "" { + fmt.Fprintf(&sb, " ToolCallID: %s\n", msg.ToolCallID) + } + sb.WriteString("\n") + } + sb.WriteString("]") + return sb.String() +} + +func formatToolsForLog(toolDefs []providers.ToolDefinition) string { + if len(toolDefs) == 0 { + return "[]" + } + + var sb strings.Builder + sb.WriteString("[\n") + for i, tool := range toolDefs { + fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name) + fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description) + if len(tool.Function.Parameters) > 0 { + fmt.Fprintf( + &sb, + " Parameters: %s\n", + utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200), + ) + } + } + sb.WriteString("]") + return sb.String() +} + +func activeSkillNames(agent *AgentInstance, opts processOptions) []string { + if agent == nil { + return nil + } + + combined := make([]string, 0, len(agent.SkillsFilter)+len(opts.ForcedSkills)) + combined = append(combined, agent.SkillsFilter...) + combined = append(combined, opts.ForcedSkills...) + if len(combined) == 0 { + return nil + } + + var resolved []string + seen := make(map[string]struct{}, len(combined)) + for _, name := range combined { + name = strings.TrimSpace(name) + if name == "" { + continue + } + if agent.ContextBuilder != nil { + if canonical, ok := agent.ContextBuilder.ResolveSkillName(name); ok { + name = canonical + } + } + key := strings.ToLower(name) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + resolved = append(resolved, name) + } + + return resolved +} + +func sideQuestionResponseContent(response *providers.LLMResponse) string { + if response == nil { + return "" + } + if response.Content != "" { + return response.Content + } + return response.ReasoningContent +} + +func shallowCloneLLMOptions(opts map[string]any) map[string]any { + clone := make(map[string]any, len(opts)) + for k, v := range opts { + clone[k] = v + } + return clone +} + +func hasMediaRefs(messages []providers.Message) bool { + for _, msg := range messages { + if len(msg.Media) > 0 { + return true + } + } + return false +} + +func sideQuestionModelName(agent *AgentInstance, usedLight bool) string { + if usedLight && len(agent.LightCandidates) > 0 { + // Use the first light candidate's model + return agent.LightCandidates[0].Model + } + return agent.Model +} + +func modelNameFromIdentityKey(identityKey string) string { + if identityKey == "" { + return "" + } + parts := strings.SplitN(identityKey, "/", 2) + if len(parts) == 2 { + return parts[1] + } + return identityKey +} + +func closeProviderIfStateful(provider providers.LLMProvider) { + if stateful, ok := provider.(providers.StatefulProvider); ok { + stateful.Close() + } +} + +func makePendingTurnID(sessionKey string, seq uint64) string { + return pendingTurnPrefix + sessionKey + "-" + fmt.Sprintf("%d", seq) +} + +func commandsUnavailableSkillMessage() string { + return "Skill selection is unavailable in the current context." +} + +func buildUseCommandHelp(agent *AgentInstance) string { + if agent == nil || agent.ContextBuilder == nil { + return "Usage: /use [message]" + } + + names := agent.ContextBuilder.ListSkillNames() + if len(names) == 0 { + return "Usage: /use [message]\nNo installed skills found." + } + + return fmt.Sprintf( + "Usage: /use [message]\n\nInstalled Skills:\n- %s\n\nUse /use to apply a skill to your next message, or /use to force it immediately.", + strings.Join(names, "\n- "), + ) +} + +func mapCommandError(result commands.ExecuteResult) string { + if result.Command == "" { + return fmt.Sprintf("Failed to execute command: %v", result.Err) + } + return fmt.Sprintf("Failed to execute /%s: %v", result.Command, result.Err) +} + +func isNativeSearchProvider(p providers.LLMProvider) bool { + if ns, ok := p.(providers.NativeSearchCapable); ok { + return ns.SupportsNativeSearch() + } + return false +} + +func filterClientWebSearch(tools []providers.ToolDefinition) []providers.ToolDefinition { + result := make([]providers.ToolDefinition, 0, len(tools)) + for _, t := range tools { + if strings.EqualFold(t.Function.Name, "web_search") { + continue + } + result = append(result, t) + } + return result +} + +func extractProvider(registry *AgentRegistry) (providers.LLMProvider, bool) { + if registry == nil { + return nil, false + } + // Get any agent to access the provider + defaultAgent := registry.GetDefaultAgent() + if defaultAgent == nil { + return nil, false + } + return defaultAgent.Provider, true +} diff --git a/pkg/agent/context.go b/pkg/agent/context.go index ecf5da3dc..1e5a75d92 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -11,6 +11,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" @@ -210,6 +211,36 @@ func (cb *ContextBuilder) BuildSystemPromptWithCache() string { return prompt } +// EstimateSystemTokens estimates the token count of the full system message +// that would be sent to the LLM, mirroring the composition logic in BuildMessages. +// It includes: static prompt, dynamic context, active skills, and summary with +// wrapping prefixes and separators. This avoids needing all per-request parameters +// that BuildMessages requires (media, channel, chatID, sender, etc.). +func (cb *ContextBuilder) EstimateSystemTokens(summary string, activeSkills []string) int { + staticPrompt := cb.BuildSystemPromptWithCache() + + // Dynamic context is small and varies per request; use a representative estimate. + // Actual buildDynamicContext produces ~200-400 chars of time/runtime/session info. + const dynamicContextChars = 300 + + totalChars := utf8.RuneCountInString(staticPrompt) + dynamicContextChars + + if skillsText := cb.buildActiveSkillsContext(activeSkills); skillsText != "" { + totalChars += utf8.RuneCountInString(skillsText) + totalChars += 7 // separator \n\n---\n\n + } + + if summary != "" { + // Matches the CONTEXT_SUMMARY: prefix added in BuildMessages + const summaryPrefix = "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation " + + "for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n" + totalChars += utf8.RuneCountInString(summaryPrefix) + utf8.RuneCountInString(summary) + totalChars += 7 // separator + } + + return totalChars * 2 / 5 // same heuristic as tokenizer.EstimateMessageTokens +} + // InvalidateCache clears the cached system prompt. // Normally not needed because the cache auto-invalidates via mtime checks, // but this is useful for tests or explicit reload commands. diff --git a/pkg/agent/context_usage.go b/pkg/agent/context_usage.go new file mode 100644 index 000000000..39d4f3dee --- /dev/null +++ b/pkg/agent/context_usage.go @@ -0,0 +1,78 @@ +package agent + +import ( + "github.com/sipeed/picoclaw/pkg/bus" +) + +// computeContextUsage estimates current context window consumption for the +// given agent and session. Includes history, system prompt (with dynamic context, +// summary, and skills — mirroring BuildMessages composition), and tool definitions. +// The output reserve (MaxTokens) is not counted as "used" but reduces the +// effective budget, matching isOverContextBudget's compression trigger: +// +// compress when: history + system + tools + maxTokens > contextWindow +// equivalent to: history + system + tools > contextWindow - maxTokens +// +// Returns nil when the agent or session is unavailable. +func computeContextUsage(agent *AgentInstance, sessionKey string) *bus.ContextUsage { + if agent == nil || agent.Sessions == nil { + return nil + } + contextWindow := agent.ContextWindow + if contextWindow <= 0 { + return nil + } + + // History tokens + history := agent.Sessions.GetHistory(sessionKey) + historyTokens := 0 + for _, m := range history { + historyTokens += EstimateMessageTokens(m) + } + + // System message tokens: uses EstimateSystemTokens which mirrors + // the full system message composition in BuildMessages (static prompt, + // dynamic context, active skills, summary with wrapping prefix). + systemTokens := 0 + if agent.ContextBuilder != nil { + summary := agent.Sessions.GetSummary(sessionKey) + // Pass nil for active skills: skills are only injected when the user + // explicitly activates them via /use, which is rare. Using nil matches + // the common case and avoids over-counting all installed skills. + systemTokens = agent.ContextBuilder.EstimateSystemTokens(summary, nil) + } + + // Tool definition tokens + toolTokens := 0 + if agent.Tools != nil { + toolTokens = EstimateToolDefsTokens(agent.Tools.ToProviderDefs()) + } + + // Used = history + system (includes summary) + tools + usedTokens := historyTokens + systemTokens + toolTokens + + // Effective budget = contextWindow minus output reserve (maxTokens) + effectiveWindow := contextWindow - agent.MaxTokens + if effectiveWindow < 0 { + effectiveWindow = contextWindow + } + + // compressAt = effectiveWindow: aligns with isOverContextBudget's + // proactive trigger (msgTokens + toolTokens + maxTokens > contextWindow). + compressAt := effectiveWindow + + usedPercent := 0 + if compressAt > 0 { + usedPercent = usedTokens * 100 / compressAt + } + if usedPercent > 100 { + usedPercent = 100 + } + + return &bus.ContextUsage{ + UsedTokens: usedTokens, + TotalTokens: contextWindow, + CompressAtTokens: compressAt, + UsedPercent: usedPercent, + } +} diff --git a/pkg/agent/hooks_test.go b/pkg/agent/hooks_test.go index eb76c4da8..cd1586e75 100644 --- a/pkg/agent/hooks_test.go +++ b/pkg/agent/hooks_test.go @@ -709,9 +709,10 @@ func TestAgentLoop_HookRespond_MediaError(t *testing.T) { t.Fatalf("MountHook failed: %v", err) } - al.channelManager = newStartedTestChannelManager(t, al.bus, al.mediaStore, "discord", &errorMediaChannel{ - sendErr: errors.New("channel unavailable"), - }) + al.channelManager = newStartedTestChannelManager(t, + al.bus.(*bus.MessageBus), al.mediaStore, "discord", &errorMediaChannel{ + sendErr: errors.New("channel unavailable"), + }) sub := al.SubscribeEvents(16) defer al.UnsubscribeEvents(sub.ID) diff --git a/pkg/agent/interfaces/interfaces.go b/pkg/agent/interfaces/interfaces.go new file mode 100644 index 000000000..bdf483e20 --- /dev/null +++ b/pkg/agent/interfaces/interfaces.go @@ -0,0 +1,47 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package interfaces + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" +) + +// MessageBus publishes inbound and outbound messages. +// It is the primary communication channel for the agent loop. +type MessageBus interface { + // PublishInbound sends an inbound message to be processed. + PublishInbound(ctx context.Context, msg bus.InboundMessage) error + + // PublishOutbound sends an outbound message to the appropriate channel. + PublishOutbound(ctx context.Context, msg bus.OutboundMessage) error + + // PublishOutboundMedia sends an outbound media message. + PublishOutboundMedia(ctx context.Context, msg bus.OutboundMediaMessage) error + + // InboundChan returns the channel for receiving inbound messages. + InboundChan() <-chan bus.InboundMessage +} + +// ChannelManager manages channel lifecycle and provides channel access. +type ChannelManager interface { + // GetChannel returns the channel with the given name. + GetChannel(name string) (channels.Channel, bool) + + // GetEnabledChannels returns the list of enabled channel names. + GetEnabledChannels() []string + + // InvokeTypingStop signals that typing has stopped. + InvokeTypingStop(channel, chatID string) + + // SendMessage sends a text message to the specified channel and chat. + SendMessage(ctx context.Context, msg bus.OutboundMessage) error + + // SendMedia sends a media message to the specified channel and chat. + SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error + + // SendPlaceholder sends a placeholder message (e.g., for audio transcription). + SendPlaceholder(ctx context.Context, channel, chatID string) bool +} diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go deleted file mode 100644 index 7f7d2a3fe..000000000 --- a/pkg/agent/loop.go +++ /dev/null @@ -1,4408 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package agent - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "path/filepath" - "regexp" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/sipeed/picoclaw/pkg/audio/asr" - "github.com/sipeed/picoclaw/pkg/audio/tts" - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/channels" - "github.com/sipeed/picoclaw/pkg/commands" - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/constants" - "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/media" - "github.com/sipeed/picoclaw/pkg/providers" - "github.com/sipeed/picoclaw/pkg/routing" - "github.com/sipeed/picoclaw/pkg/session" - "github.com/sipeed/picoclaw/pkg/skills" - "github.com/sipeed/picoclaw/pkg/state" - "github.com/sipeed/picoclaw/pkg/tools" - "github.com/sipeed/picoclaw/pkg/utils" -) - -type AgentLoop struct { - // Core dependencies - bus *bus.MessageBus - cfg *config.Config - registry *AgentRegistry - state *state.Manager - - // Event system (from Incoming) - eventBus *EventBus - hooks *HookManager - - // Runtime state - running atomic.Bool - contextManager ContextManager - fallback *providers.FallbackChain - channelManager *channels.Manager - mediaStore media.MediaStore - transcriber asr.Transcriber - cmdRegistry *commands.Registry - mcp mcpRuntime - hookRuntime hookRuntime - steering *steeringQueue - pendingSkills sync.Map - mu sync.RWMutex - - // workerSem limits concurrent turn processing workers. - workerSem chan struct{} - - // activeTurnStates tracks active turns per session to prevent duplicates. - activeTurnStates sync.Map - subTurnCounter atomic.Int64 - - turnSeq atomic.Uint64 - activeRequests sync.WaitGroup - - reloadFunc func() error - - providerFactory func(*config.ModelConfig) (providers.LLMProvider, string, error) -} - -// processOptions configures how a message is processed -type processOptions struct { - Dispatch DispatchRequest // Normalized routed request boundary for this turn - SessionKey string // Session identifier for history/context - SessionAliases []string // Compatibility aliases for the session key - Channel string // Target channel for tool execution - ChatID string // Target chat ID for tool execution - MessageID string // Current inbound platform message ID - ReplyToMessageID string // Current inbound reply target message ID - SenderID string // Current sender ID for dynamic context - SenderDisplayName string // Current sender display name for dynamic context - UserMessage string // User message content (may include prefix) - ForcedSkills []string // Skills explicitly requested for this message - SystemPromptOverride string // Override the default system prompt (Used by SubTurns) - Media []string // media:// refs from inbound message - InitialSteeringMessages []providers.Message // Steering messages from refactor/agent - DefaultResponse string // Response when LLM returns empty - EnableSummary bool // Whether to trigger summarization - SendResponse bool // Whether to send response via bus - AllowInterimPicoPublish bool // Whether pico tool-call interim text can be published when SendResponse is false - SuppressToolFeedback bool // Whether to suppress inline tool feedback messages - NoHistory bool // If true, don't load session history (for heartbeat) - SkipInitialSteeringPoll bool // If true, skip the steering poll at loop start (used by Continue) - InboundContext *bus.InboundContext // Normalized inbound facts for events/hooks - RouteResult *routing.ResolvedRoute // Route decision snapshot for events/hooks - SessionScope *session.SessionScope // Session scope snapshot for events/hooks -} - -type continuationTarget struct { - SessionKey string - Channel string - ChatID string -} - -const ( - defaultResponse = "The model returned an empty response. This may indicate a provider error or token limit." - toolLimitResponse = "I've reached `max_tool_iterations` without a final response. Increase `max_tool_iterations` in config.json if this task needs more tool steps." - handledToolResponseSummary = "Requested output delivered via tool attachment." - sessionKeyAgentPrefix = "agent:" - pendingTurnPrefix = "pending-" - metadataKeyMessageKind = "message_kind" - messageKindThought = "thought" - metadataKeyAccountID = "account_id" - metadataKeyGuildID = "guild_id" - metadataKeyTeamID = "team_id" - metadataKeyReplyToMessage = "reply_to_message_id" - metadataKeyParentPeerKind = "parent_peer_kind" - metadataKeyParentPeerID = "parent_peer_id" -) - -func NewAgentLoop( - cfg *config.Config, - msgBus *bus.MessageBus, - provider providers.LLMProvider, -) *AgentLoop { - registry := NewAgentRegistry(cfg, provider) - - // Set up shared fallback chain with rate limiting. - cooldown := providers.NewCooldownTracker() - rl := providers.NewRateLimiterRegistry() - // Register rate limiters for all agents' candidates so that RPM limits - // configured in ModelConfig are enforced before each LLM call. - for _, agentID := range registry.ListAgentIDs() { - if agent, ok := registry.GetAgent(agentID); ok { - rl.RegisterCandidates(agent.Candidates) - rl.RegisterCandidates(agent.LightCandidates) - } - } - fallbackChain := providers.NewFallbackChain(cooldown, rl) - - // Create state manager using default agent's workspace for channel recording - defaultAgent := registry.GetDefaultAgent() - var stateManager *state.Manager - if defaultAgent != nil { - stateManager = state.NewManager(defaultAgent.Workspace) - } - - eventBus := NewEventBus() - - // Determine worker pool size from config (default: 1 = sequential) - workerPoolSize := cfg.Agents.Defaults.MaxParallelTurns - if workerPoolSize <= 0 { - workerPoolSize = 1 - } - - al := &AgentLoop{ - bus: msgBus, - cfg: cfg, - registry: registry, - state: stateManager, - eventBus: eventBus, - fallback: fallbackChain, - cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()), - steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)), - workerSem: make(chan struct{}, workerPoolSize), - } - al.providerFactory = providers.CreateProviderFromConfig - al.hooks = NewHookManager(eventBus) - configureHookManagerFromConfig(al.hooks, cfg) - al.contextManager = al.resolveContextManager() - - // Register shared tools to all agents (now that al is created) - registerSharedTools(al, cfg, msgBus, registry, provider) - - return al -} - -// registerSharedTools registers tools that are shared across all agents (web, message, spawn). -func registerSharedTools( - al *AgentLoop, - cfg *config.Config, - msgBus *bus.MessageBus, - registry *AgentRegistry, - provider providers.LLMProvider, -) { - allowReadPaths := buildAllowReadPatterns(cfg) - var ttsProvider tts.TTSProvider - if cfg.Tools.IsToolEnabled("send_tts") { - ttsProvider = tts.DetectTTS(cfg) - if ttsProvider == nil { - logger.WarnCF("voice-tts", "send_tts enabled but no TTS provider configured", nil) - } - } - - for _, agentID := range registry.ListAgentIDs() { - agent, ok := registry.GetAgent(agentID) - if !ok { - continue - } - - if cfg.Tools.IsToolEnabled("web") { - searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{ - BraveAPIKeys: cfg.Tools.Web.Brave.APIKeys.Values(), - BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, - BraveEnabled: cfg.Tools.Web.Brave.Enabled, - TavilyAPIKeys: cfg.Tools.Web.Tavily.APIKeys.Values(), - TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, - TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, - TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, - DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, - DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, - PerplexityAPIKeys: cfg.Tools.Web.Perplexity.APIKeys.Values(), - PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, - PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, - SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL, - SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults, - SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled, - GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey.String(), - GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL, - GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine, - GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults, - GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled, - BaiduSearchAPIKey: cfg.Tools.Web.BaiduSearch.APIKey.String(), - BaiduSearchBaseURL: cfg.Tools.Web.BaiduSearch.BaseURL, - BaiduSearchMaxResults: cfg.Tools.Web.BaiduSearch.MaxResults, - BaiduSearchEnabled: cfg.Tools.Web.BaiduSearch.Enabled, - Proxy: cfg.Tools.Web.Proxy, - }) - if err != nil { - logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()}) - } else if searchTool != nil { - agent.Tools.Register(searchTool) - } - } - if cfg.Tools.IsToolEnabled("web_fetch") { - fetchTool, err := tools.NewWebFetchToolWithProxy( - 50000, - cfg.Tools.Web.Proxy, - cfg.Tools.Web.Format, - cfg.Tools.Web.FetchLimitBytes, - cfg.Tools.Web.PrivateHostWhitelist) - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } else { - agent.Tools.Register(fetchTool) - } - } - - // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms - if cfg.Tools.IsToolEnabled("i2c") { - agent.Tools.Register(tools.NewI2CTool()) - } - if cfg.Tools.IsToolEnabled("spi") { - agent.Tools.Register(tools.NewSPITool()) - } - - // Message tool - if cfg.Tools.IsToolEnabled("message") { - messageTool := tools.NewMessageTool() - messageTool.SetSendCallback(func( - ctx context.Context, - channel, chatID, content, replyToMessageID string, - ) error { - pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer pubCancel() - outboundCtx := bus.NewOutboundContext(channel, chatID, replyToMessageID) - outboundAgentID, outboundSessionKey, outboundScope := outboundTurnMetadata( - tools.ToolAgentID(ctx), - tools.ToolSessionKey(ctx), - tools.ToolSessionScope(ctx), - ) - return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ - Context: outboundCtx, - AgentID: outboundAgentID, - SessionKey: outboundSessionKey, - Scope: outboundScope, - Content: content, - ReplyToMessageID: replyToMessageID, - }) - }) - agent.Tools.Register(messageTool) - } - if cfg.Tools.IsToolEnabled("reaction") { - reactionTool := tools.NewReactionTool() - reactionTool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error { - if al.channelManager == nil { - return fmt.Errorf("channel manager not configured") - } - ch, ok := al.channelManager.GetChannel(channel) - if !ok { - return fmt.Errorf("channel %s not found", channel) - } - rc, ok := ch.(channels.ReactionCapable) - if !ok { - return fmt.Errorf("channel %s does not support reactions", channel) - } - _, err := rc.ReactToMessage(ctx, chatID, messageID) - return err - }) - agent.Tools.Register(reactionTool) - } - - // Send file tool (outbound media via MediaStore — store injected later by SetMediaStore) - if cfg.Tools.IsToolEnabled("send_file") { - sendFileTool := tools.NewSendFileTool( - agent.Workspace, - cfg.Agents.Defaults.RestrictToWorkspace, - cfg.Agents.Defaults.GetMaxMediaSize(), - nil, - allowReadPaths, - ) - agent.Tools.Register(sendFileTool) - } - - if ttsProvider != nil { - agent.Tools.Register(tools.NewSendTTSTool(ttsProvider, nil)) - } - - if cfg.Tools.IsToolEnabled("load_image") { - loadImageTool := tools.NewLoadImageTool( - agent.Workspace, - cfg.Agents.Defaults.RestrictToWorkspace, - cfg.Agents.Defaults.GetMaxMediaSize(), - nil, - allowReadPaths, - ) - agent.Tools.Register(loadImageTool) - } - - // Skill discovery and installation tools - skills_enabled := cfg.Tools.IsToolEnabled("skills") - find_skills_enable := cfg.Tools.IsToolEnabled("find_skills") - install_skills_enable := cfg.Tools.IsToolEnabled("install_skill") - if skills_enabled && (find_skills_enable || install_skills_enable) { - registryMgr := skills.NewRegistryManagerFromToolsConfig(cfg.Tools.Skills) - - if find_skills_enable { - searchCache := skills.NewSearchCache( - cfg.Tools.Skills.SearchCache.MaxSize, - time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second, - ) - agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache)) - } - - if install_skills_enable { - agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace)) - } - } - - // Spawn and spawn_status tools share a SubagentManager. - // Construct it when either tool is enabled (both require subagent). - spawnEnabled := cfg.Tools.IsToolEnabled("spawn") - spawnStatusEnabled := cfg.Tools.IsToolEnabled("spawn_status") - if (spawnEnabled || spawnStatusEnabled) && cfg.Tools.IsToolEnabled("subagent") { - subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace) - subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) - - // Inject a media resolver so the legacy RunToolLoop fallback path can - // resolve media:// refs in the same way the main AgentLoop does. - // This keeps subagent vision support working even when the optimized - // sub-turn spawner path is unavailable. - subagentManager.SetMediaResolver(func(msgs []providers.Message) []providers.Message { - return resolveMediaRefs(msgs, al.mediaStore, cfg.Agents.Defaults.GetMaxMediaSize()) - }) - - // Set the spawner that links into AgentLoop's turnState - subagentManager.SetSpawner(func( - ctx context.Context, - task, label, targetAgentID string, - tls *tools.ToolRegistry, - maxTokens int, - temperature float64, - hasMaxTokens, hasTemperature bool, - ) (*tools.ToolResult, error) { - // 1. Recover parent Turn State from Context - parentTS := turnStateFromContext(ctx) - if parentTS == nil { - // Fallback: If no turnState exists in context, create an isolated ad-hoc root turn state - // so that the tool can still function outside of an agent loop (e.g. tests, raw invocations). - parentTS = &turnState{ - ctx: ctx, - turnID: "adhoc-root", - depth: 0, - session: nil, // Ephemeral session not needed for adhoc spawn - pendingResults: make(chan *tools.ToolResult, 16), - concurrencySem: make(chan struct{}, 5), - } - } - - // 2. Build Tools slice from registry - var tlSlice []tools.Tool - for _, name := range tls.List() { - if t, ok := tls.Get(name); ok { - tlSlice = append(tlSlice, t) - } - } - - // 3. System Prompt - systemPrompt := "You are a subagent. Complete the given task independently and report the result.\n" + - "You have access to tools - use them as needed to complete your task.\n" + - "After completing the task, provide a clear summary of what was done.\n\n" + - "Task: " + task - - // 4. Resolve Model - modelToUse := agent.Model - if targetAgentID != "" { - if targetAgent, ok := al.GetRegistry().GetAgent(targetAgentID); ok { - modelToUse = targetAgent.Model - } - } - - // 5. Build SubTurnConfig - cfg := SubTurnConfig{ - Model: modelToUse, - Tools: tlSlice, - SystemPrompt: systemPrompt, - } - if hasMaxTokens { - cfg.MaxTokens = maxTokens - } - - // 6. Spawn SubTurn - return spawnSubTurn(ctx, al, parentTS, cfg) - }) - - // Clone the parent's tool registry so subagents can use all - // tools registered so far (file, web, etc.) but NOT spawn/ - // spawn_status which are added below — preventing recursive - // subagent spawning. - subagentManager.SetTools(agent.Tools.Clone()) - if spawnEnabled { - spawnTool := tools.NewSpawnTool(subagentManager) - spawnTool.SetSpawner(NewSubTurnSpawner(al)) - currentAgentID := agentID - spawnTool.SetAllowlistChecker(func(targetAgentID string) bool { - return registry.CanSpawnSubagent(currentAgentID, targetAgentID) - }) - - agent.Tools.Register(spawnTool) - - // Also register the synchronous subagent tool - subagentTool := tools.NewSubagentTool(subagentManager) - subagentTool.SetSpawner(NewSubTurnSpawner(al)) - agent.Tools.Register(subagentTool) - } - if spawnStatusEnabled { - agent.Tools.Register(tools.NewSpawnStatusTool(subagentManager)) - } - } else if (spawnEnabled || spawnStatusEnabled) && !cfg.Tools.IsToolEnabled("subagent") { - logger.WarnCF("agent", "spawn/spawn_status tools require subagent to be enabled", nil) - } - } -} - -func (al *AgentLoop) Run(ctx context.Context) error { - al.running.Store(true) - - if err := al.ensureHooksInitialized(ctx); err != nil { - return err - } - if err := al.ensureMCPInitialized(ctx); err != nil { - return err - } - - idleTicker := time.NewTicker(100 * time.Millisecond) - defer idleTicker.Stop() - - for { - select { - case <-ctx.Done(): - return nil - case <-idleTicker.C: - if !al.running.Load() { - return nil - } - case msg, ok := <-al.bus.InboundChan(): - if !ok { - return nil - } - - // Resolve the session key for this message - sessionKey, agentID, ok := al.resolveSteeringTarget(msg) - if !ok { - // Non-routable message (e.g., system) — process immediately. - // Note: system messages are processed in the main goroutine, - // so they block the receive loop but guarantee session serialization. - al.processMessageSync(ctx, msg) - continue - } - - // Atomically claim the session key with a unique placeholder sentinel - // to prevent a TOCTOU race where multiple messages for the same session - // pass the Load check before either registers. - // The placeholder ensures GetActiveTurnBySession() never returns nil - // during turn setup. Each placeholder has a unique turnID to prevent - // cross-worker cleanup issues. - placeholder := &turnState{ - turnID: makePendingTurnID(sessionKey, al.turnSeq.Add(1)), - phase: TurnPhaseSetup, - } - if _, loaded := al.activeTurnStates.LoadOrStore(sessionKey, placeholder); loaded { - // Another turn is already active (or reserved) for this session — enqueue - if err := al.enqueueSteeringMessage(sessionKey, agentID, providers.Message{ - Role: "user", - Content: msg.Content, - Media: append([]string(nil), msg.Media...), - }); err != nil { - logger.WarnCF("agent", "Failed to enqueue steering message", - map[string]any{ - "error": err.Error(), - "channel": msg.Channel, - "chat_id": msg.ChatID, - "session_key": sessionKey, - }) - } - continue - } - - // Session claimed — spawn a worker goroutine that acquires a semaphore - // slot. The goroutine is spawned immediately so the main loop keeps - // draining the inbound channel. The goroutine blocks on the semaphore. - go func(m bus.InboundMessage) { - // Acquire semaphore slot (blocks if at capacity) - select { - case al.workerSem <- struct{}{}: - // Got slot, start worker - case <-ctx.Done(): - // Context canceled while waiting for a slot — clean up the - // placeholder to prevent session-level deadlock. - al.activeTurnStates.Delete(sessionKey) - return - } - - // Safety-net cleanup: if the placeholder was never replaced by a real - // turnState (e.g., error before runTurn), delete it here. When runTurn - // completes normally, clearActiveTurn deletes the real turnState and - // this becomes a no-op (the key is already gone). - defer func() { - if actual, ok := al.activeTurnStates.Load(sessionKey); ok { - if ts, ok := actual.(*turnState); ok && strings.HasPrefix(ts.turnID, pendingTurnPrefix) { - // Placeholder still present — runTurn never replaced it. - al.activeTurnStates.Delete(sessionKey) - } - } - }() - - defer func() { - if r := recover(); r != nil { - logger.RecoverPanicNoExit(r) - logger.ErrorCF("agent", "Worker goroutine panicked", - map[string]any{ - "session_key": sessionKey, - "channel": m.Channel, - "chat_id": m.ChatID, - "panic": fmt.Sprintf("%v", r), - }) - } - }() - defer func() { <-al.workerSem }() // Release slot - - if al.channelManager != nil { - defer al.channelManager.InvokeTypingStop(m.Channel, m.ChatID) - } - - al.runTurnWithSteering(ctx, m) - }(msg) - - // TODO: Re-enable media cleanup after inbound media is properly consumed by the agent. - // Currently disabled because files are deleted before the LLM can access their content. - // defer func() { - // if al.mediaStore != nil && msg.MediaScope != "" { - // if releaseErr := al.mediaStore.ReleaseAll(msg.MediaScope); releaseErr != nil { - // logger.WarnCF("agent", "Failed to release media", map[string]any{ - // "scope": msg.MediaScope, - // "error": releaseErr.Error(), - // }) - // } - // } - // }() - } - } -} - -// processMessageSync processes a message synchronously (for non-routable/system messages). -func (al *AgentLoop) processMessageSync(ctx context.Context, msg bus.InboundMessage) { - if al.channelManager != nil { - defer al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID) - } - - response, err := al.processMessage(ctx, msg) - al.publishResponseOrError(ctx, msg.Channel, msg.ChatID, msg.SessionKey, response, err) -} - -// runTurnWithSteering runs a complete turn for a message and drains its steering queue. -func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.InboundMessage) { - // Process the initial message - response, err := al.processMessage(ctx, initialMsg) - if err != nil { - if !al.maybePublishError(ctx, initialMsg.Channel, initialMsg.ChatID, initialMsg.SessionKey, err) { - return // context canceled - } - response = "" - } - finalResponse := response - - // Build continuation target - target, targetErr := al.buildContinuationTarget(initialMsg) - if targetErr != nil { - logger.WarnCF("agent", "Failed to build steering continuation target", - map[string]any{ - "channel": initialMsg.Channel, - "error": targetErr.Error(), - }) - return - } - if target == nil { - // System message or non-routable, response already published - return - } - - // Drain steering queue using existing Continue mechanism - for al.pendingSteeringCountForScope(target.SessionKey) > 0 { - // Check for context cancellation between iterations - if ctx.Err() != nil { - return - } - - logger.InfoCF("agent", "Continuing queued steering after turn end", - map[string]any{ - "channel": target.Channel, - "chat_id": target.ChatID, - "session_key": target.SessionKey, - "queue_depth": al.pendingSteeringCountForScope(target.SessionKey), - }) - - continued, continueErr := al.Continue(ctx, target.SessionKey, target.Channel, target.ChatID) - if continueErr != nil { - logger.WarnCF("agent", "Failed to continue queued steering", - map[string]any{ - "channel": target.Channel, - "chat_id": target.ChatID, - "error": continueErr.Error(), - }) - break - } - if continued == "" { - break - } - finalResponse = continued - } - - // Publish final response - if finalResponse != "" { - al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, finalResponse) - } -} - -// maybePublishError publishes an error response unless the error is context.Canceled. -// Returns true if processing should continue (non-cancellation error or no error), -// false if context was canceled and the caller should return. -func (al *AgentLoop) maybePublishError(ctx context.Context, channel, chatID, sessionKey string, err error) bool { - if errors.Is(err, context.Canceled) { - return false - } - al.PublishResponseIfNeeded(ctx, channel, chatID, sessionKey, fmt.Sprintf("Error processing message: %v", err)) - return true -} - -// publishResponseOrError publishes the response, or an error message if processing failed. -func (al *AgentLoop) publishResponseOrError( - ctx context.Context, - channel, chatID, sessionKey string, - response string, - err error, -) { - if err != nil { - if !al.maybePublishError(ctx, channel, chatID, sessionKey, err) { - return - } - response = "" - } - al.PublishResponseIfNeeded(ctx, channel, chatID, sessionKey, response) -} - -func (al *AgentLoop) Stop() { - al.running.Store(false) -} - -func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatID, sessionKey, response string) { - if response == "" { - return - } - - alreadySentToSameChat := false - defaultAgent := al.GetRegistry().GetDefaultAgent() - if defaultAgent != nil { - if tool, ok := defaultAgent.Tools.Get("message"); ok { - if mt, ok := tool.(*tools.MessageTool); ok { - alreadySentToSameChat = mt.HasSentTo(sessionKey, channel, chatID) - } - } - } - - if alreadySentToSameChat { - logger.DebugCF( - "agent", - "Skipped outbound (message tool already sent to same chat)", - map[string]any{"channel": channel, "chat_id": chatID}, - ) - return - } - - al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Context: bus.NewOutboundContext(channel, chatID, ""), - Content: response, - }) - logger.InfoCF("agent", "Published outbound response", - map[string]any{ - "channel": channel, - "chat_id": chatID, - "content_len": len(response), - }) -} - -func (al *AgentLoop) buildContinuationTarget(msg bus.InboundMessage) (*continuationTarget, error) { - if msg.Channel == "system" { - return nil, nil - } - - route, _, err := al.resolveMessageRoute(msg) - if err != nil { - return nil, err - } - allocation := al.allocateRouteSession(route, msg) - - return &continuationTarget{ - SessionKey: resolveScopeKey(allocation.SessionKey, msg.SessionKey), - Channel: msg.Channel, - ChatID: msg.ChatID, - }, nil -} - -// Close releases resources held by agent session stores. Call after Stop. -func (al *AgentLoop) Close() { - mcpManager := al.mcp.takeManager() - - if mcpManager != nil { - if err := mcpManager.Close(); err != nil { - logger.ErrorCF("agent", "Failed to close MCP manager", - map[string]any{ - "error": err.Error(), - }) - } - } - - al.GetRegistry().Close() - if al.hooks != nil { - al.hooks.Close() - } - if al.eventBus != nil { - al.eventBus.Close() - } -} - -func outboundContextFromInbound( - inbound *bus.InboundContext, - channel, chatID, replyToMessageID string, -) bus.InboundContext { - if inbound == nil { - return bus.NewOutboundContext(channel, chatID, replyToMessageID) - } - - outboundCtx := *cloneInboundContext(inbound) - if outboundCtx.Channel == "" { - outboundCtx.Channel = channel - } - if outboundCtx.ChatID == "" { - outboundCtx.ChatID = chatID - } - if outboundCtx.ReplyToMessageID == "" { - outboundCtx.ReplyToMessageID = replyToMessageID - } - return outboundCtx -} - -func outboundScopeFromSessionScope(scope *session.SessionScope) *bus.OutboundScope { - if scope == nil { - return nil - } - outboundScope := &bus.OutboundScope{ - Version: scope.Version, - AgentID: scope.AgentID, - Channel: scope.Channel, - Account: scope.Account, - } - if len(scope.Dimensions) > 0 { - outboundScope.Dimensions = append([]string(nil), scope.Dimensions...) - } - if len(scope.Values) > 0 { - outboundScope.Values = make(map[string]string, len(scope.Values)) - for key, value := range scope.Values { - outboundScope.Values[key] = value - } - } - return outboundScope -} - -func outboundTurnMetadata( - agentID, sessionKey string, - scope *session.SessionScope, -) (string, string, *bus.OutboundScope) { - return agentID, sessionKey, outboundScopeFromSessionScope(scope) -} - -func outboundMessageForTurn(ts *turnState, content string) bus.OutboundMessage { - agentID, sessionKey, scope := outboundTurnMetadata(ts.agent.ID, ts.sessionKey, ts.opts.Dispatch.SessionScope) - return bus.OutboundMessage{ - Channel: ts.channel, - ChatID: ts.chatID, - Context: outboundContextFromInbound( - ts.opts.Dispatch.InboundContext, - ts.channel, - ts.chatID, - ts.opts.Dispatch.ReplyToMessageID(), - ), - AgentID: agentID, - SessionKey: sessionKey, - Scope: scope, - Content: content, - } -} - -// MountHook registers an in-process hook on the agent loop. -func (al *AgentLoop) MountHook(reg HookRegistration) error { - if al == nil || al.hooks == nil { - return fmt.Errorf("hook manager is not initialized") - } - return al.hooks.Mount(reg) -} - -// UnmountHook removes a previously registered in-process hook. -func (al *AgentLoop) UnmountHook(name string) { - if al == nil || al.hooks == nil { - return - } - al.hooks.Unmount(name) -} - -// SubscribeEvents registers a subscriber for agent-loop events. -func (al *AgentLoop) SubscribeEvents(buffer int) EventSubscription { - if al == nil || al.eventBus == nil { - ch := make(chan Event) - close(ch) - return EventSubscription{C: ch} - } - return al.eventBus.Subscribe(buffer) -} - -// UnsubscribeEvents removes a previously registered event subscriber. -func (al *AgentLoop) UnsubscribeEvents(id uint64) { - if al == nil || al.eventBus == nil { - return - } - al.eventBus.Unsubscribe(id) -} - -// EventDrops returns the number of dropped events for the given kind. -func (al *AgentLoop) EventDrops(kind EventKind) int64 { - if al == nil || al.eventBus == nil { - return 0 - } - return al.eventBus.Dropped(kind) -} - -type turnEventScope struct { - agentID string - sessionKey string - turnID string - context *TurnContext -} - -func (al *AgentLoop) newTurnEventScope(agentID, sessionKey string, turnCtx *TurnContext) turnEventScope { - seq := al.turnSeq.Add(1) - return turnEventScope{ - agentID: agentID, - sessionKey: sessionKey, - turnID: fmt.Sprintf("%s-turn-%d", agentID, seq), - context: cloneTurnContext(turnCtx), - } -} - -func (ts turnEventScope) meta(iteration int, source, tracePath string) EventMeta { - return EventMeta{ - AgentID: ts.agentID, - TurnID: ts.turnID, - SessionKey: ts.sessionKey, - Iteration: iteration, - Source: source, - TracePath: tracePath, - turnContext: cloneTurnContext(ts.context), - } -} - -func (al *AgentLoop) emitEvent(kind EventKind, meta EventMeta, payload any) { - clonedMeta := cloneEventMeta(meta) - evt := Event{ - Kind: kind, - Meta: clonedMeta, - Context: cloneTurnContext(clonedMeta.turnContext), - Payload: payload, - } - - if al == nil || al.eventBus == nil { - return - } - - al.logEvent(evt) - - al.eventBus.Emit(evt) -} - -func cloneEventArguments(args map[string]any) map[string]any { - if len(args) == 0 { - return nil - } - - cloned := make(map[string]any, len(args)) - for k, v := range args { - cloned[k] = v - } - return cloned -} - -func (al *AgentLoop) hookAbortError(ts *turnState, stage string, decision HookDecision) error { - reason := decision.Reason - if reason == "" { - reason = "hook requested turn abort" - } - - err := fmt.Errorf("hook aborted turn during %s: %s", stage, reason) - al.emitEvent( - EventKindError, - ts.eventMeta("hooks", "turn.error"), - ErrorPayload{ - Stage: "hook." + stage, - Message: err.Error(), - }, - ) - return err -} - -func hookDeniedToolContent(prefix, reason string) string { - if reason == "" { - return prefix - } - return prefix + ": " + reason -} - -func (al *AgentLoop) logEvent(evt Event) { - fields := map[string]any{ - "event_kind": evt.Kind.String(), - "agent_id": evt.Meta.AgentID, - "turn_id": evt.Meta.TurnID, - "session_key": evt.Meta.SessionKey, - "iteration": evt.Meta.Iteration, - } - - if evt.Meta.TracePath != "" { - fields["trace"] = evt.Meta.TracePath - } - if evt.Meta.Source != "" { - fields["source"] = evt.Meta.Source - } - - appendEventContextFields(fields, evt.Context) - - switch payload := evt.Payload.(type) { - case TurnStartPayload: - fields["user_len"] = len(payload.UserMessage) - fields["media_count"] = payload.MediaCount - case TurnEndPayload: - fields["status"] = payload.Status - fields["iterations_total"] = payload.Iterations - fields["duration_ms"] = payload.Duration.Milliseconds() - fields["final_len"] = payload.FinalContentLen - case LLMRequestPayload: - fields["model"] = payload.Model - fields["messages"] = payload.MessagesCount - fields["tools"] = payload.ToolsCount - fields["max_tokens"] = payload.MaxTokens - case LLMDeltaPayload: - fields["content_delta_len"] = payload.ContentDeltaLen - fields["reasoning_delta_len"] = payload.ReasoningDeltaLen - case LLMResponsePayload: - fields["content_len"] = payload.ContentLen - fields["tool_calls"] = payload.ToolCalls - fields["has_reasoning"] = payload.HasReasoning - case LLMRetryPayload: - fields["attempt"] = payload.Attempt - fields["max_retries"] = payload.MaxRetries - fields["reason"] = payload.Reason - fields["error"] = payload.Error - fields["backoff_ms"] = payload.Backoff.Milliseconds() - case ContextCompressPayload: - fields["reason"] = payload.Reason - fields["dropped_messages"] = payload.DroppedMessages - fields["remaining_messages"] = payload.RemainingMessages - case SessionSummarizePayload: - fields["summarized_messages"] = payload.SummarizedMessages - fields["kept_messages"] = payload.KeptMessages - fields["summary_len"] = payload.SummaryLen - fields["omitted_oversized"] = payload.OmittedOversized - case ToolExecStartPayload: - fields["tool"] = payload.Tool - fields["args_count"] = len(payload.Arguments) - case ToolExecEndPayload: - fields["tool"] = payload.Tool - fields["duration_ms"] = payload.Duration.Milliseconds() - fields["for_llm_len"] = payload.ForLLMLen - fields["for_user_len"] = payload.ForUserLen - fields["is_error"] = payload.IsError - fields["async"] = payload.Async - case ToolExecSkippedPayload: - fields["tool"] = payload.Tool - fields["reason"] = payload.Reason - case SteeringInjectedPayload: - fields["count"] = payload.Count - fields["total_content_len"] = payload.TotalContentLen - case FollowUpQueuedPayload: - fields["source_tool"] = payload.SourceTool - fields["content_len"] = payload.ContentLen - case InterruptReceivedPayload: - fields["interrupt_kind"] = payload.Kind - fields["role"] = payload.Role - fields["content_len"] = payload.ContentLen - fields["queue_depth"] = payload.QueueDepth - fields["hint_len"] = payload.HintLen - case SubTurnSpawnPayload: - fields["child_agent_id"] = payload.AgentID - fields["label"] = payload.Label - case SubTurnEndPayload: - fields["child_agent_id"] = payload.AgentID - fields["status"] = payload.Status - case SubTurnResultDeliveredPayload: - fields["target_channel"] = payload.TargetChannel - fields["target_chat_id"] = payload.TargetChatID - fields["content_len"] = payload.ContentLen - case ErrorPayload: - fields["stage"] = payload.Stage - fields["error"] = payload.Message - } - - logger.InfoCF("eventbus", fmt.Sprintf("Agent event: %s", evt.Kind.String()), fields) -} - -func appendEventContextFields(fields map[string]any, turnCtx *TurnContext) { - if turnCtx == nil { - return - } - - if inbound := turnCtx.Inbound; inbound != nil { - if inbound.Channel != "" { - fields["inbound_channel"] = inbound.Channel - } - if inbound.Account != "" { - fields["inbound_account"] = inbound.Account - } - if inbound.ChatID != "" { - fields["inbound_chat_id"] = inbound.ChatID - } - if inbound.ChatType != "" { - fields["inbound_chat_type"] = inbound.ChatType - } - if inbound.TopicID != "" { - fields["inbound_topic_id"] = inbound.TopicID - } - if inbound.SpaceType != "" { - fields["inbound_space_type"] = inbound.SpaceType - } - if inbound.SpaceID != "" { - fields["inbound_space_id"] = inbound.SpaceID - } - if inbound.SenderID != "" { - fields["inbound_sender_id"] = inbound.SenderID - } - if inbound.Mentioned { - fields["inbound_mentioned"] = true - } - } - - if route := turnCtx.Route; route != nil { - if route.AgentID != "" { - fields["route_agent_id"] = route.AgentID - } - if route.Channel != "" { - fields["route_channel"] = route.Channel - } - if route.AccountID != "" { - fields["route_account_id"] = route.AccountID - } - if route.MatchedBy != "" { - fields["route_matched_by"] = route.MatchedBy - } - if len(route.SessionPolicy.Dimensions) > 0 { - fields["route_dimensions"] = strings.Join(route.SessionPolicy.Dimensions, ",") - } - if count := len(route.SessionPolicy.IdentityLinks); count > 0 { - fields["route_identity_link_count"] = count - } - } - - if scope := turnCtx.Scope; scope != nil { - if scope.Version > 0 { - fields["scope_version"] = scope.Version - } - if scope.AgentID != "" { - fields["scope_agent_id"] = scope.AgentID - } - if scope.Channel != "" { - fields["scope_channel"] = scope.Channel - } - if scope.Account != "" { - fields["scope_account"] = scope.Account - } - if len(scope.Dimensions) > 0 { - fields["scope_dimensions"] = strings.Join(scope.Dimensions, ",") - } - for dim, value := range scope.Values { - if dim == "" || value == "" { - continue - } - fields["scope_"+dim] = value - } - } -} - -func (al *AgentLoop) RegisterTool(tool tools.Tool) { - registry := al.GetRegistry() - for _, agentID := range registry.ListAgentIDs() { - if agent, ok := registry.GetAgent(agentID); ok { - agent.Tools.Register(tool) - } - } -} - -func (al *AgentLoop) SetChannelManager(cm *channels.Manager) { - al.channelManager = cm -} - -// ReloadProviderAndConfig atomically swaps the provider and config with proper synchronization. -// It uses a context to allow timeout control from the caller. -// Returns an error if the reload fails or context is canceled. -func (al *AgentLoop) ReloadProviderAndConfig( - ctx context.Context, - provider providers.LLMProvider, - cfg *config.Config, -) error { - // Validate inputs - if provider == nil { - return fmt.Errorf("provider cannot be nil") - } - if cfg == nil { - return fmt.Errorf("config cannot be nil") - } - - // Create new registry with updated config and provider - // Wrap in defer/recover to handle any panics gracefully - var registry *AgentRegistry - var panicErr error - done := make(chan struct{}, 1) - - go func() { - defer func() { - if r := recover(); r != nil { - logger.RecoverPanicNoExit(r) - panicErr = fmt.Errorf("panic during registry creation: %v", r) - logger.ErrorCF("agent", "Panic during registry creation", - map[string]any{"panic": r}) - } - close(done) - }() - - registry = NewAgentRegistry(cfg, provider) - }() - - // Wait for completion or context cancellation - select { - case <-done: - if registry == nil { - if panicErr != nil { - return fmt.Errorf("registry creation failed: %w", panicErr) - } - return fmt.Errorf("registry creation failed (nil result)") - } - case <-ctx.Done(): - return fmt.Errorf("context canceled during registry creation: %w", ctx.Err()) - } - - // Check context again before proceeding - if err := ctx.Err(); err != nil { - return fmt.Errorf("context canceled after registry creation: %w", err) - } - - // Ensure shared tools are re-registered on the new registry - registerSharedTools(al, cfg, al.bus, registry, provider) - - // Atomically swap the config and registry under write lock - // This ensures readers see a consistent pair - al.mu.Lock() - oldRegistry := al.registry - - // Store new values - al.cfg = cfg - al.registry = registry - - // Also update fallback chain with new config; rebuild rate limiter registry. - newRL := providers.NewRateLimiterRegistry() - for _, agentID := range registry.ListAgentIDs() { - if agent, ok := registry.GetAgent(agentID); ok { - newRL.RegisterCandidates(agent.Candidates) - newRL.RegisterCandidates(agent.LightCandidates) - } - } - al.fallback = providers.NewFallbackChain(providers.NewCooldownTracker(), newRL) - - al.mu.Unlock() - - oldMCPManager := al.mcp.reset() - al.hookRuntime.reset(al) - configureHookManagerFromConfig(al.hooks, cfg) - if err := al.ensureHooksInitialized(ctx); err != nil { - logger.WarnCF("agent", "Configured hooks failed to reinitialize after reload", - map[string]any{"error": err.Error()}) - } - if oldMCPManager != nil { - if err := oldMCPManager.Close(); err != nil { - logger.WarnCF("agent", "Failed to close previous MCP manager during reload", - map[string]any{"error": err.Error()}) - } - } - if err := al.ensureMCPInitialized(ctx); err != nil { - logger.WarnCF("agent", "MCP failed to reinitialize after reload", - map[string]any{"error": err.Error()}) - } - - // Close old provider after releasing the lock - // This prevents blocking readers while closing - if oldProvider, ok := extractProvider(oldRegistry); ok { - if stateful, ok := oldProvider.(providers.StatefulProvider); ok { - // Give in-flight requests a moment to complete - // Use a reasonable timeout that balances cleanup vs resource usage - select { - case <-time.After(100 * time.Millisecond): - stateful.Close() - case <-ctx.Done(): - // Context canceled, close immediately but log warning - logger.WarnCF("agent", "Context canceled during provider cleanup, forcing close", - map[string]any{"error": ctx.Err()}) - stateful.Close() - } - } - } - - logger.InfoCF("agent", "Provider and config reloaded successfully", - map[string]any{ - "model": cfg.Agents.Defaults.GetModelName(), - }) - - return nil -} - -// GetRegistry returns the current registry (thread-safe) -func (al *AgentLoop) GetRegistry() *AgentRegistry { - al.mu.RLock() - defer al.mu.RUnlock() - return al.registry -} - -// GetConfig returns the current config (thread-safe) -func (al *AgentLoop) GetConfig() *config.Config { - al.mu.RLock() - defer al.mu.RUnlock() - return al.cfg -} - -// SetMediaStore injects a MediaStore for media lifecycle management. -func (al *AgentLoop) SetMediaStore(s media.MediaStore) { - al.mediaStore = s - - // Propagate store to all registered tools that can emit media. - registry := al.GetRegistry() - for _, agentID := range registry.ListAgentIDs() { - if agent, ok := registry.GetAgent(agentID); ok { - agent.Tools.SetMediaStore(s) - } - } - registry.ForEachTool("send_tts", func(t tools.Tool) { - if st, ok := t.(*tools.SendTTSTool); ok { - st.SetMediaStore(s) - } - }) -} - -// SetTranscriber injects a voice transcriber for agent-level audio transcription. -func (al *AgentLoop) SetTranscriber(t asr.Transcriber) { - al.transcriber = t -} - -// SetReloadFunc sets the callback function for triggering config reload. -func (al *AgentLoop) SetReloadFunc(fn func() error) { - al.reloadFunc = fn -} - -var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`) - -// transcribeAudioInMessage resolves audio media refs, transcribes them, and -// replaces audio annotations in msg.Content with the transcribed text. -// Returns the (possibly modified) message and true if audio was transcribed. -func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) (bus.InboundMessage, bool) { - if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 { - return msg, false - } - - // Transcribe each audio media ref in order. - var transcriptions []string - var keptMedia []string - for _, ref := range msg.Media { - path, meta, err := al.mediaStore.ResolveWithMeta(ref) - if err != nil { - logger.WarnCF("voice", "Failed to resolve media ref", map[string]any{"ref": ref, "error": err}) - keptMedia = append(keptMedia, ref) - continue - } - if !utils.IsAudioFile(meta.Filename, meta.ContentType) { - keptMedia = append(keptMedia, ref) - continue - } - result, err := al.transcriber.Transcribe(ctx, path) - if err != nil { - logger.WarnCF("voice", "Transcription failed", map[string]any{"ref": ref, "error": err}) - transcriptions = append(transcriptions, "") - keptMedia = append(keptMedia, ref) - continue - } - transcriptions = append(transcriptions, result.Text) - } - - if len(transcriptions) == 0 { - return msg, false - } - - al.sendTranscriptionFeedback(ctx, msg.Channel, msg.ChatID, msg.MessageID, transcriptions) - - // Replace audio annotations sequentially with transcriptions. - idx := 0 - newContent := audioAnnotationRe.ReplaceAllStringFunc(msg.Content, func(match string) string { - if idx >= len(transcriptions) { - return match - } - text := transcriptions[idx] - idx++ - if text == "" { - return match - } - return "[voice: " + text + "]" - }) - - // Append any remaining transcriptions not matched by an annotation. - for ; idx < len(transcriptions); idx++ { - if transcriptions[idx] != "" { - newContent += "\n[voice: " + transcriptions[idx] + "]" - } - } - - msg.Content = newContent - msg.Media = keptMedia - return msg, true -} - -// sendTranscriptionFeedback sends feedback to the user with the result of -// audio transcription if the option is enabled. It uses Manager.SendMessage -// which executes synchronously (rate limiting, splitting, retry) so that -// ordering with the subsequent placeholder is guaranteed. -func (al *AgentLoop) sendTranscriptionFeedback( - ctx context.Context, - channel, chatID, messageID string, - validTexts []string, -) { - if !al.cfg.Voice.EchoTranscription { - return - } - if al.channelManager == nil { - return - } - - var nonEmpty []string - for _, t := range validTexts { - if t != "" { - nonEmpty = append(nonEmpty, t) - } - } - - var feedbackMsg string - if len(nonEmpty) > 0 { - feedbackMsg = "Transcript: " + strings.Join(nonEmpty, "\n") - } else { - feedbackMsg = "No voice detected in the audio" - } - - err := al.channelManager.SendMessage(ctx, bus.OutboundMessage{ - Context: bus.NewOutboundContext(channel, chatID, messageID), - Content: feedbackMsg, - ReplyToMessageID: messageID, - }) - if err != nil { - logger.WarnCF("voice", "Failed to send transcription feedback", map[string]any{"error": err.Error()}) - } -} - -// inferMediaType determines the media type ("image", "audio", "video", "file") -// from a filename and MIME content type. -func inferMediaType(filename, contentType string) string { - ct := strings.ToLower(contentType) - fn := strings.ToLower(filename) - - if strings.HasPrefix(ct, "image/") { - return "image" - } - if strings.HasPrefix(ct, "audio/") || ct == "application/ogg" { - return "audio" - } - if strings.HasPrefix(ct, "video/") { - return "video" - } - - // Fallback: infer from extension - ext := filepath.Ext(fn) - switch ext { - case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg": - return "image" - case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus": - return "audio" - case ".mp4", ".avi", ".mov", ".webm", ".mkv": - return "video" - } - - return "file" -} - -func buildProviderAttachments(store media.MediaStore, refs []string) []providers.Attachment { - if store == nil || len(refs) == 0 { - return nil - } - - attachments := make([]providers.Attachment, 0, len(refs)) - for _, ref := range refs { - attachment := providers.Attachment{Ref: ref} - if _, meta, err := store.ResolveWithMeta(ref); err == nil { - attachment.Filename = meta.Filename - attachment.ContentType = meta.ContentType - attachment.Type = inferMediaType(meta.Filename, meta.ContentType) - } - attachments = append(attachments, attachment) - } - return attachments -} - -// RecordLastChannel records the last active channel for this workspace. -// This uses the atomic state save mechanism to prevent data loss on crash. -func (al *AgentLoop) RecordLastChannel(channel string) error { - if al.state == nil { - return nil - } - return al.state.SetLastChannel(channel) -} - -// RecordLastChatID records the last active chat ID for this workspace. -// This uses the atomic state save mechanism to prevent data loss on crash. -func (al *AgentLoop) RecordLastChatID(chatID string) error { - if al.state == nil { - return nil - } - return al.state.SetLastChatID(chatID) -} - -func (al *AgentLoop) ProcessDirect( - ctx context.Context, - content, sessionKey string, -) (string, error) { - return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct") -} - -func (al *AgentLoop) ProcessDirectWithChannel( - ctx context.Context, - content, sessionKey, channel, chatID string, -) (string, error) { - if err := al.ensureHooksInitialized(ctx); err != nil { - return "", err - } - if err := al.ensureMCPInitialized(ctx); err != nil { - return "", err - } - - msg := bus.InboundMessage{ - Context: bus.InboundContext{ - Channel: channel, - ChatID: chatID, - ChatType: "direct", - SenderID: "cron", - }, - Content: content, - SessionKey: sessionKey, - } - - return al.processMessage(ctx, msg) -} - -// ProcessHeartbeat processes a heartbeat request without session history. -// Each heartbeat is independent and doesn't accumulate context. -func (al *AgentLoop) ProcessHeartbeat( - ctx context.Context, - content, channel, chatID string, -) (string, error) { - if err := al.ensureHooksInitialized(ctx); err != nil { - return "", err - } - if err := al.ensureMCPInitialized(ctx); err != nil { - return "", err - } - - agent := al.GetRegistry().GetDefaultAgent() - if agent == nil { - return "", fmt.Errorf("no default agent for heartbeat") - } - dispatch := DispatchRequest{ - SessionKey: "heartbeat", - UserMessage: content, - } - if channel != "" || chatID != "" { - dispatch.InboundContext = &bus.InboundContext{ - Channel: channel, - ChatID: chatID, - ChatType: "direct", - SenderID: "heartbeat", - } - } - return al.runAgentLoop(ctx, agent, processOptions{ - Dispatch: dispatch, - DefaultResponse: defaultResponse, - EnableSummary: false, - SendResponse: false, - SuppressToolFeedback: true, - NoHistory: true, // Don't load session history for heartbeat - }) -} - -func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { - msg = bus.NormalizeInboundMessage(msg) - - // Add message preview to log (show full content for error messages) - var logContent string - if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") { - logContent = msg.Content // Full content for errors - } else { - logContent = utils.Truncate(msg.Content, 80) - } - logger.InfoCF( - "agent", - fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent), - map[string]any{ - "channel": msg.Channel, - "chat_id": msg.ChatID, - "sender_id": msg.SenderID, - "session_key": msg.SessionKey, - }, - ) - - var hadAudio bool - msg, hadAudio = al.transcribeAudioInMessage(ctx, msg) - - // For audio messages the placeholder was deferred by the channel. - // Now that transcription (and optional feedback) is done, send it. - if hadAudio && al.channelManager != nil { - al.channelManager.SendPlaceholder(ctx, msg.Channel, msg.ChatID) - } - - // Route system messages to processSystemMessage - if msg.Channel == "system" { - return al.processSystemMessage(ctx, msg) - } - - route, agent, routeErr := al.resolveMessageRoute(msg) - if routeErr != nil { - return "", routeErr - } - - allocation := al.allocateRouteSession(route, msg) - - // Resolve session key from the route allocation, while preserving explicit - // agent-scoped keys supplied by the caller. - scopeKey := resolveScopeKey(allocation.SessionKey, msg.SessionKey) - sessionKey := scopeKey - - // Reset message-tool state for this round so we don't skip publishing due to a previous round. - if tool, ok := agent.Tools.Get("message"); ok { - if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok { - resetter.ResetSentInRound(sessionKey) - } - } - - logger.InfoCF("agent", "Routed message", - map[string]any{ - "agent_id": agent.ID, - "scope_key": scopeKey, - "session_key": sessionKey, - "matched_by": route.MatchedBy, - "route_agent": route.AgentID, - "route_channel": route.Channel, - "route_main_session": allocation.MainSessionKey, - }) - - opts := processOptions{ - Dispatch: DispatchRequest{ - SessionKey: sessionKey, - SessionAliases: buildSessionAliases(sessionKey, append(allocation.SessionAliases, msg.SessionKey)...), - InboundContext: cloneInboundContext(&msg.Context), - RouteResult: cloneResolvedRoute(&route), - SessionScope: session.CloneScope(&allocation.Scope), - UserMessage: msg.Content, - Media: append([]string(nil), msg.Media...), - }, - SenderID: msg.SenderID, - SenderDisplayName: msg.Sender.DisplayName, - DefaultResponse: defaultResponse, - EnableSummary: true, - SendResponse: false, - AllowInterimPicoPublish: true, - } - - // context-dependent commands check their own Runtime fields and report - // "unavailable" when the required capability is nil. - if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled { - return response, nil - } - - if pending := al.takePendingSkills(opts.Dispatch.SessionKey); len(pending) > 0 { - opts.ForcedSkills = append(opts.ForcedSkills, pending...) - logger.InfoCF("agent", "Applying pending skill override", - map[string]any{ - "session_key": opts.Dispatch.SessionKey, - "skills": strings.Join(pending, ","), - }) - } - - return al.runAgentLoop(ctx, agent, opts) -} - -func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) { - registry := al.GetRegistry() - inboundCtx := normalizedInboundContext(msg) - route := registry.ResolveRoute(inboundCtx) - - agent, ok := registry.GetAgent(route.AgentID) - if !ok { - agent = registry.GetDefaultAgent() - } - if agent == nil { - return routing.ResolvedRoute{}, nil, fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID) - } - - return route, agent, nil -} - -func normalizedInboundContext(msg bus.InboundMessage) bus.InboundContext { - return bus.NormalizeInboundMessage(msg).Context -} - -func resolveScopeKey(routeSessionKey, msgSessionKey string) string { - if isExplicitSessionKey(msgSessionKey) { - return msgSessionKey - } - return routeSessionKey -} - -func isExplicitSessionKey(sessionKey string) bool { - return session.IsExplicitSessionKey(sessionKey) -} - -func buildSessionAliases(canonicalKey string, keys ...string) []string { - if len(keys) == 0 { - return nil - } - aliases := make([]string, 0, len(keys)) - seen := make(map[string]struct{}, len(keys)) - canonicalKey = strings.TrimSpace(canonicalKey) - for _, key := range keys { - key = strings.TrimSpace(key) - if key == "" || key == canonicalKey { - continue - } - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - aliases = append(aliases, key) - } - if len(aliases) == 0 { - return nil - } - return aliases -} - -func ensureSessionMetadata(store session.SessionStore, key string, scope *session.SessionScope, aliases []string) { - if key == "" || scope == nil { - return - } - metaStore, ok := store.(interface { - EnsureSessionMetadata(sessionKey string, scope *session.SessionScope, aliases []string) - }) - if !ok { - return - } - metaStore.EnsureSessionMetadata(key, scope, aliases) -} - -func (al *AgentLoop) allocateRouteSession(route routing.ResolvedRoute, msg bus.InboundMessage) session.Allocation { - return session.AllocateRouteSession(session.AllocationInput{ - AgentID: route.AgentID, - Context: normalizedInboundContext(msg), - SessionPolicy: route.SessionPolicy, - }) -} - -func (al *AgentLoop) resolveSteeringTarget(msg bus.InboundMessage) (string, string, bool) { - if msg.Channel == "system" { - return "", "", false - } - - route, agent, err := al.resolveMessageRoute(msg) - if err != nil || agent == nil { - return "", "", false - } - allocation := al.allocateRouteSession(route, msg) - - return resolveScopeKey(allocation.SessionKey, msg.SessionKey), agent.ID, true -} - -func (al *AgentLoop) processSystemMessage( - ctx context.Context, - msg bus.InboundMessage, -) (string, error) { - if msg.Channel != "system" { - return "", fmt.Errorf( - "processSystemMessage called with non-system message channel: %s", - msg.Channel, - ) - } - - logger.InfoCF("agent", "Processing system message", - map[string]any{ - "sender_id": msg.SenderID, - "chat_id": msg.ChatID, - }) - - // Parse origin channel from chat_id (format: "channel:chat_id") - var originChannel, originChatID string - if idx := strings.Index(msg.ChatID, ":"); idx > 0 { - originChannel = msg.ChatID[:idx] - originChatID = msg.ChatID[idx+1:] - } else { - originChannel = "cli" - originChatID = msg.ChatID - } - - // Extract subagent result from message content - // Format: "Task 'label' completed.\n\nResult:\n" - content := msg.Content - if idx := strings.Index(content, "Result:\n"); idx >= 0 { - content = content[idx+8:] // Extract just the result part - } - - // Skip internal channels - only log, don't send to user - if constants.IsInternalChannel(originChannel) { - logger.InfoCF("agent", "Subagent completed (internal channel)", - map[string]any{ - "sender_id": msg.SenderID, - "content_len": len(content), - "channel": originChannel, - }) - return "", nil - } - - // Use default agent for system messages - agent := al.GetRegistry().GetDefaultAgent() - if agent == nil { - return "", fmt.Errorf("no default agent for system message") - } - - // Use the origin session for context - sessionKey := session.BuildMainSessionKey(agent.ID) - dispatch := DispatchRequest{ - SessionKey: sessionKey, - UserMessage: fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content), - } - if originChannel != "" || originChatID != "" { - dispatch.InboundContext = &bus.InboundContext{ - Channel: originChannel, - ChatID: originChatID, - ChatType: "direct", - SenderID: msg.SenderID, - } - } - - return al.runAgentLoop(ctx, agent, processOptions{ - Dispatch: dispatch, - DefaultResponse: "Background task completed.", - EnableSummary: false, - SendResponse: true, - }) -} - -// runAgentLoop remains the top-level shell that starts a turn and publishes -// any post-turn work. runTurn owns the full turn lifecycle. -func (al *AgentLoop) runAgentLoop( - ctx context.Context, - agent *AgentInstance, - opts processOptions, -) (string, error) { - opts = normalizeProcessOptions(opts) - - // Record last channel for heartbeat notifications (skip internal channels and cli) - if opts.Dispatch.Channel() != "" && - opts.Dispatch.ChatID() != "" && - !constants.IsInternalChannel(opts.Dispatch.Channel()) { - channelKey := fmt.Sprintf("%s:%s", opts.Dispatch.Channel(), opts.Dispatch.ChatID()) - if err := al.RecordLastChannel(channelKey); err != nil { - logger.WarnCF( - "agent", - "Failed to record last channel", - map[string]any{"error": err.Error()}, - ) - } - } - - ensureSessionMetadata( - agent.Sessions, - opts.Dispatch.SessionKey, - opts.Dispatch.SessionScope, - opts.Dispatch.SessionAliases, - ) - - turnScope := al.newTurnEventScope( - agent.ID, - opts.Dispatch.SessionKey, - newTurnContext(opts.Dispatch.InboundContext, opts.Dispatch.RouteResult, opts.Dispatch.SessionScope), - ) - ts := newTurnState(agent, opts, turnScope) - result, err := al.runTurn(ctx, ts) - if err != nil { - return "", err - } - if result.status == TurnEndStatusAborted { - return "", nil - } - - for _, followUp := range result.followUps { - if pubErr := al.bus.PublishInbound(ctx, followUp); pubErr != nil { - logger.WarnCF("agent", "Failed to publish follow-up after turn", - map[string]any{ - "turn_id": ts.turnID, - "error": pubErr.Error(), - }) - } - } - - if opts.SendResponse && result.finalContent != "" { - agentID, sessionKey, scope := outboundTurnMetadata( - agent.ID, - opts.Dispatch.SessionKey, - opts.Dispatch.SessionScope, - ) - al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Context: outboundContextFromInbound( - opts.Dispatch.InboundContext, - opts.Dispatch.Channel(), - opts.Dispatch.ChatID(), - opts.Dispatch.ReplyToMessageID(), - ), - AgentID: agentID, - SessionKey: sessionKey, - Scope: scope, - Content: result.finalContent, - }) - } - - if result.finalContent != "" { - responsePreview := utils.Truncate(result.finalContent, 120) - logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview), - map[string]any{ - "agent_id": agent.ID, - "session_key": opts.Dispatch.SessionKey, - "iterations": ts.currentIteration(), - "final_length": len(result.finalContent), - }) - } - - return result.finalContent, nil -} - -func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string) { - if al.channelManager == nil { - return "" - } - if ch, ok := al.channelManager.GetChannel(channelName); ok { - return ch.ReasoningChannelID() - } - return "" -} - -func (al *AgentLoop) publishPicoReasoning(ctx context.Context, reasoningContent, chatID string) { - if reasoningContent == "" || chatID == "" { - return - } - - if ctx.Err() != nil { - return - } - - pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second) - defer pubCancel() - - if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ - Context: bus.InboundContext{ - Channel: "pico", - ChatID: chatID, - Raw: map[string]string{ - metadataKeyMessageKind: messageKindThought, - }, - }, - Content: reasoningContent, - }); err != nil { - if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || - errors.Is(err, bus.ErrBusClosed) { - logger.DebugCF("agent", "Pico reasoning publish skipped (timeout/cancel)", map[string]any{ - "channel": "pico", - "error": err.Error(), - }) - } else { - logger.WarnCF("agent", "Failed to publish pico reasoning (best-effort)", map[string]any{ - "channel": "pico", - "error": err.Error(), - }) - } - } -} - -func (al *AgentLoop) handleReasoning( - ctx context.Context, - reasoningContent, channelName, channelID string, -) { - if reasoningContent == "" || channelName == "" || channelID == "" { - return - } - - // Check context cancellation before attempting to publish, - // since PublishOutbound's select may race between send and ctx.Done(). - if ctx.Err() != nil { - return - } - - // Use a short timeout so the goroutine does not block indefinitely when - // the outbound bus is full. Reasoning output is best-effort; dropping it - // is acceptable to avoid goroutine accumulation. - pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second) - defer pubCancel() - - if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ - Context: bus.NewOutboundContext(channelName, channelID, ""), - Content: reasoningContent, - }); err != nil { - // Treat context.DeadlineExceeded / context.Canceled as expected - // (bus full under load, or parent canceled). Check the error - // itself rather than ctx.Err(), because pubCtx may time out - // (5 s) while the parent ctx is still active. - // Also treat ErrBusClosed as expected — it occurs during normal - // shutdown when the bus is closed before all goroutines finish. - if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || - errors.Is(err, bus.ErrBusClosed) { - logger.DebugCF("agent", "Reasoning publish skipped (timeout/cancel)", map[string]any{ - "channel": channelName, - "error": err.Error(), - }) - } else { - logger.WarnCF("agent", "Failed to publish reasoning (best-effort)", map[string]any{ - "channel": channelName, - "error": err.Error(), - }) - } - } -} - -func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, error) { - turnCtx, turnCancel := context.WithCancel(ctx) - defer turnCancel() - ts.setTurnCancel(turnCancel) - - // Inject turnState and AgentLoop into context so tools (e.g. spawn) can retrieve them. - turnCtx = withTurnState(turnCtx, ts) - turnCtx = WithAgentLoop(turnCtx, al) - - al.registerActiveTurn(ts) - defer al.clearActiveTurn(ts) - - turnStatus := TurnEndStatusCompleted - defer func() { - al.emitEvent( - EventKindTurnEnd, - ts.eventMeta("runTurn", "turn.end"), - TurnEndPayload{ - Status: turnStatus, - Iterations: ts.currentIteration(), - Duration: time.Since(ts.startedAt), - FinalContentLen: ts.finalContentLen(), - }, - ) - }() - - al.emitEvent( - EventKindTurnStart, - ts.eventMeta("runTurn", "turn.start"), - TurnStartPayload{ - UserMessage: ts.userMessage, - MediaCount: len(ts.media), - }, - ) - - var history []providers.Message - var summary string - if !ts.opts.NoHistory { - // ContextManager assembles budget-aware history and summary. - if resp, err := al.contextManager.Assemble(turnCtx, &AssembleRequest{ - SessionKey: ts.sessionKey, - Budget: ts.agent.ContextWindow, - MaxTokens: ts.agent.MaxTokens, - }); err == nil && resp != nil { - history = resp.History - summary = resp.Summary - } - } - ts.captureRestorePoint(history, summary) - - messages := ts.agent.ContextBuilder.BuildMessages( - history, - summary, - ts.userMessage, - ts.media, - ts.channel, - ts.chatID, - ts.opts.Dispatch.SenderID(), - ts.opts.SenderDisplayName, - activeSkillNames(ts.agent, ts.opts)..., - ) - - cfg := al.GetConfig() - maxMediaSize := cfg.Agents.Defaults.GetMaxMediaSize() - messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) - - if !ts.opts.NoHistory { - toolDefs := ts.agent.Tools.ToProviderDefs() - if isOverContextBudget(ts.agent.ContextWindow, messages, toolDefs, ts.agent.MaxTokens) { - logger.WarnCF("agent", "Proactive compression: context budget exceeded before LLM call", - map[string]any{"session_key": ts.sessionKey}) - if err := al.contextManager.Compact(turnCtx, &CompactRequest{ - SessionKey: ts.sessionKey, - Reason: ContextCompressReasonProactive, - Budget: ts.agent.ContextWindow, - }); err != nil { - logger.WarnCF("agent", "Proactive compact failed", map[string]any{ - "session_key": ts.sessionKey, - "error": err.Error(), - }) - } - ts.refreshRestorePointFromSession(ts.agent) - // Re-assemble from CM after compact. - if resp, err := al.contextManager.Assemble(turnCtx, &AssembleRequest{ - SessionKey: ts.sessionKey, - Budget: ts.agent.ContextWindow, - MaxTokens: ts.agent.MaxTokens, - }); err == nil && resp != nil { - history = resp.History - summary = resp.Summary - } - messages = ts.agent.ContextBuilder.BuildMessages( - history, summary, ts.userMessage, - ts.media, ts.channel, ts.chatID, - ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName, - activeSkillNames(ts.agent, ts.opts)..., - ) - messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) - } - } - - // Save user message to session (from Incoming) - if !ts.opts.NoHistory && (strings.TrimSpace(ts.userMessage) != "" || len(ts.media) > 0) { - rootMsg := providers.Message{ - Role: "user", - Content: ts.userMessage, - Media: append([]string(nil), ts.media...), - } - if len(rootMsg.Media) > 0 { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, rootMsg) - } else { - ts.agent.Sessions.AddMessage(ts.sessionKey, rootMsg.Role, rootMsg.Content) - } - ts.recordPersistedMessage(rootMsg) - ts.ingestMessage(turnCtx, al, rootMsg) - } - - activeCandidates, activeModel, usedLight := al.selectCandidates(ts.agent, ts.userMessage, messages) - activeProvider := ts.agent.Provider - if usedLight && ts.agent.LightProvider != nil { - activeProvider = ts.agent.LightProvider - } - pendingMessages := append([]providers.Message(nil), ts.opts.InitialSteeringMessages...) - var finalContent string - -turnLoop: - for ts.currentIteration() < ts.agent.MaxIterations || len(pendingMessages) > 0 || func() bool { - graceful, _ := ts.gracefulInterruptRequested() - return graceful - }() { - if ts.hardAbortRequested() { - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - - iteration := ts.currentIteration() + 1 - ts.setIteration(iteration) - ts.setPhase(TurnPhaseRunning) - - if iteration > 1 { - if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - pendingMessages = append(pendingMessages, steerMsgs...) - } - } else if !ts.opts.SkipInitialSteeringPoll { - if steerMsgs := al.dequeueSteeringMessagesForScopeWithFallback(ts.sessionKey); len(steerMsgs) > 0 { - pendingMessages = append(pendingMessages, steerMsgs...) - } - } - - // Check if parent turn has ended (SubTurn support from HEAD) - if ts.parentTurnState != nil && ts.IsParentEnded() { - if !ts.critical { - logger.InfoCF("agent", "Parent turn ended, non-critical SubTurn exiting gracefully", map[string]any{ - "agent_id": ts.agentID, - "iteration": iteration, - "turn_id": ts.turnID, - }) - break - } - logger.InfoCF("agent", "Parent turn ended, critical SubTurn continues running", map[string]any{ - "agent_id": ts.agentID, - "iteration": iteration, - "turn_id": ts.turnID, - }) - } - - // Poll for pending SubTurn results (from HEAD) - if ts.pendingResults != nil { - select { - case result, ok := <-ts.pendingResults: - if ok && result != nil && result.ForLLM != "" { - content := al.cfg.FilterSensitiveData(result.ForLLM) - msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)} - pendingMessages = append(pendingMessages, msg) - } - default: - // No results available - } - } - - // Inject pending steering messages - if len(pendingMessages) > 0 { - resolvedPending := resolveMediaRefs(pendingMessages, al.mediaStore, maxMediaSize) - totalContentLen := 0 - for i, pm := range pendingMessages { - messages = append(messages, resolvedPending[i]) - totalContentLen += len(pm.Content) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, pm) - ts.recordPersistedMessage(pm) - ts.ingestMessage(turnCtx, al, pm) - } - logger.InfoCF("agent", "Injected steering message into context", - map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "content_len": len(pm.Content), - "media_count": len(pm.Media), - }) - } - al.emitEvent( - EventKindSteeringInjected, - ts.eventMeta("runTurn", "turn.steering.injected"), - SteeringInjectedPayload{ - Count: len(pendingMessages), - TotalContentLen: totalContentLen, - }, - ) - pendingMessages = nil - } - - logger.DebugCF("agent", "LLM iteration", - map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "max": ts.agent.MaxIterations, - }) - - gracefulTerminal, _ := ts.gracefulInterruptRequested() - providerToolDefs := ts.agent.Tools.ToProviderDefs() - - // Native web search support (from HEAD) - _, hasWebSearch := ts.agent.Tools.Get("web_search") - useNativeSearch := al.cfg.Tools.Web.PreferNative && - hasWebSearch && - func() bool { - // Check if provider supports native search - if ns, ok := ts.agent.Provider.(interface{ SupportsNativeSearch() bool }); ok { - return ns.SupportsNativeSearch() - } - return false - }() - - if useNativeSearch { - // Filter out client-side web_search tool - filtered := make([]providers.ToolDefinition, 0, len(providerToolDefs)) - for _, td := range providerToolDefs { - if td.Function.Name != "web_search" { - filtered = append(filtered, td) - } - } - providerToolDefs = filtered - } - - // Resolve media:// refs produced by tool results (e.g. load_image). - // Skipped on iteration 1 because inbound user media is already resolved - // before entering the loop; only subsequent iterations can contain new - // tool-generated media refs that need base64 encoding. - if iteration > 1 { - messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) - } - - callMessages := messages - if gracefulTerminal { - callMessages = append(append([]providers.Message(nil), messages...), ts.interruptHintMessage()) - providerToolDefs = nil - ts.markGracefulTerminalUsed() - } - - llmOpts := map[string]any{ - "max_tokens": ts.agent.MaxTokens, - "temperature": ts.agent.Temperature, - "prompt_cache_key": ts.agent.ID, - } - if useNativeSearch { - llmOpts["native_search"] = true - } - if ts.agent.ThinkingLevel != ThinkingOff { - if tc, ok := ts.agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { - llmOpts["thinking_level"] = string(ts.agent.ThinkingLevel) - } else { - logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring", - map[string]any{"agent_id": ts.agent.ID, "thinking_level": string(ts.agent.ThinkingLevel)}) - } - } - - llmModel := activeModel - if al.hooks != nil { - llmReq, decision := al.hooks.BeforeLLM(turnCtx, &LLMHookRequest{ - Meta: ts.eventMeta("runTurn", "turn.llm.request"), - Context: cloneTurnContext(ts.turnCtx), - Model: llmModel, - Messages: callMessages, - Tools: providerToolDefs, - Options: llmOpts, - GracefulTerminal: gracefulTerminal, - }) - switch decision.normalizedAction() { - case HookActionContinue, HookActionModify: - if llmReq != nil { - llmModel = llmReq.Model - callMessages = llmReq.Messages - providerToolDefs = llmReq.Tools - llmOpts = llmReq.Options - } - case HookActionAbortTurn: - turnStatus = TurnEndStatusError - return turnResult{}, al.hookAbortError(ts, "before_llm", decision) - case HookActionHardAbort: - _ = ts.requestHardAbort() - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - } - - al.emitEvent( - EventKindLLMRequest, - ts.eventMeta("runTurn", "turn.llm.request"), - LLMRequestPayload{ - Model: llmModel, - MessagesCount: len(callMessages), - ToolsCount: len(providerToolDefs), - MaxTokens: ts.agent.MaxTokens, - Temperature: ts.agent.Temperature, - }, - ) - - logger.DebugCF("agent", "LLM request", - map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "model": llmModel, - "messages_count": len(callMessages), - "tools_count": len(providerToolDefs), - "max_tokens": ts.agent.MaxTokens, - "temperature": ts.agent.Temperature, - "system_prompt_len": len(callMessages[0].Content), - }) - logger.DebugCF("agent", "Full LLM request", - map[string]any{ - "iteration": iteration, - "messages_json": formatMessagesForLog(callMessages), - "tools_json": formatToolsForLog(providerToolDefs), - }) - - callLLM := func(messagesForCall []providers.Message, toolDefsForCall []providers.ToolDefinition) (*providers.LLMResponse, error) { - providerCtx, providerCancel := context.WithCancel(turnCtx) - ts.setProviderCancel(providerCancel) - defer func() { - providerCancel() - ts.clearProviderCancel(providerCancel) - }() - - al.activeRequests.Add(1) - defer al.activeRequests.Done() - - if len(activeCandidates) > 1 && al.fallback != nil { - fbResult, fbErr := al.fallback.Execute( - providerCtx, - activeCandidates, - func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { - candidateProvider := activeProvider - if cp, ok := ts.agent.CandidateProviders[providers.ModelKey(provider, model)]; ok { - candidateProvider = cp - } - return candidateProvider.Chat(ctx, messagesForCall, toolDefsForCall, model, llmOpts) - }, - ) - if fbErr != nil { - return nil, fbErr - } - if fbResult.Provider != "" && len(fbResult.Attempts) > 0 { - logger.InfoCF( - "agent", - fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts", - fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1), - map[string]any{"agent_id": ts.agent.ID, "iteration": iteration}, - ) - } - return fbResult.Response, nil - } - return activeProvider.Chat(providerCtx, messagesForCall, toolDefsForCall, llmModel, llmOpts) - } - - var response *providers.LLMResponse - var err error - maxRetries := 2 - for retry := 0; retry <= maxRetries; retry++ { - response, err = callLLM(callMessages, providerToolDefs) - if err == nil { - break - } - if ts.hardAbortRequested() && errors.Is(err, context.Canceled) { - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - - // Retry without media if vision is unsupported - if hasMediaRefs(callMessages) && isVisionUnsupportedError(err) && retry < maxRetries { - al.emitEvent( - EventKindLLMRetry, - ts.eventMeta("runTurn", "turn.llm.retry"), - LLMRetryPayload{ - Attempt: retry + 1, - MaxRetries: maxRetries, - Reason: "vision_unsupported", - Error: err.Error(), - Backoff: 0, - }, - ) - logger.WarnCF("agent", "Vision unsupported, retrying without media", map[string]any{ - "error": err.Error(), - "retry": retry, - }) - callMessages = stripMessageMedia(callMessages) - // Also strip media from session history to prevent future errors - if !ts.opts.NoHistory { - history = stripMessageMedia(history) - ts.agent.Sessions.SetHistory(ts.sessionKey, history) - for i := range ts.persistedMessages { - ts.persistedMessages[i].Media = nil - } - ts.refreshRestorePointFromSession(ts.agent) - } - continue - } - - errMsg := strings.ToLower(err.Error()) - isTimeoutError := errors.Is(err, context.DeadlineExceeded) || - strings.Contains(errMsg, "deadline exceeded") || - strings.Contains(errMsg, "client.timeout") || - strings.Contains(errMsg, "timed out") || - strings.Contains(errMsg, "timeout exceeded") - - isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") || - strings.Contains(errMsg, "context window") || - strings.Contains(errMsg, "context_window") || - strings.Contains(errMsg, "maximum context length") || - strings.Contains(errMsg, "token limit") || - strings.Contains(errMsg, "too many tokens") || - strings.Contains(errMsg, "max_tokens") || - strings.Contains(errMsg, "invalidparameter") || - strings.Contains(errMsg, "prompt is too long") || - strings.Contains(errMsg, "request too large")) - - if isTimeoutError && retry < maxRetries { - backoff := time.Duration(retry+1) * 5 * time.Second - al.emitEvent( - EventKindLLMRetry, - ts.eventMeta("runTurn", "turn.llm.retry"), - LLMRetryPayload{ - Attempt: retry + 1, - MaxRetries: maxRetries, - Reason: "timeout", - Error: err.Error(), - Backoff: backoff, - }, - ) - logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{ - "error": err.Error(), - "retry": retry, - "backoff": backoff.String(), - }) - if sleepErr := sleepWithContext(turnCtx, backoff); sleepErr != nil { - if ts.hardAbortRequested() { - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - err = sleepErr - break - } - continue - } - - if isContextError && retry < maxRetries && !ts.opts.NoHistory { - al.emitEvent( - EventKindLLMRetry, - ts.eventMeta("runTurn", "turn.llm.retry"), - LLMRetryPayload{ - Attempt: retry + 1, - MaxRetries: maxRetries, - Reason: "context_limit", - Error: err.Error(), - }, - ) - logger.WarnCF( - "agent", - "Context window error detected, attempting compression", - map[string]any{ - "error": err.Error(), - "retry": retry, - }, - ) - - if retry == 0 && !constants.IsInternalChannel(ts.channel) { - al.bus.PublishOutbound(ctx, outboundMessageForTurn( - ts, - "Context window exceeded. Compressing history and retrying...", - )) - } - - if compactErr := al.contextManager.Compact(turnCtx, &CompactRequest{ - SessionKey: ts.sessionKey, - Reason: ContextCompressReasonRetry, - Budget: ts.agent.ContextWindow, - }); compactErr != nil { - logger.WarnCF("agent", "Context overflow compact failed", map[string]any{ - "session_key": ts.sessionKey, - "error": compactErr.Error(), - }) - } - ts.refreshRestorePointFromSession(ts.agent) - // Re-assemble from CM after compact. - if asmResp, asmErr := al.contextManager.Assemble(turnCtx, &AssembleRequest{ - SessionKey: ts.sessionKey, - Budget: ts.agent.ContextWindow, - MaxTokens: ts.agent.MaxTokens, - }); asmErr == nil && asmResp != nil { - history = asmResp.History - summary = asmResp.Summary - } - messages = ts.agent.ContextBuilder.BuildMessages( - history, summary, "", - nil, ts.channel, ts.chatID, ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName, - activeSkillNames(ts.agent, ts.opts)..., - ) - callMessages = messages - if gracefulTerminal { - callMessages = append(append([]providers.Message(nil), messages...), ts.interruptHintMessage()) - } - continue - } - break - } - - if err != nil { - turnStatus = TurnEndStatusError - al.emitEvent( - EventKindError, - ts.eventMeta("runTurn", "turn.error"), - ErrorPayload{ - Stage: "llm", - Message: err.Error(), - }, - ) - logger.ErrorCF("agent", "LLM call failed", - map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "model": llmModel, - "error": err.Error(), - }) - return turnResult{}, fmt.Errorf("LLM call failed after retries: %w", err) - } - - if al.hooks != nil { - llmResp, decision := al.hooks.AfterLLM(turnCtx, &LLMHookResponse{ - Meta: ts.eventMeta("runTurn", "turn.llm.response"), - Context: cloneTurnContext(ts.turnCtx), - Model: llmModel, - Response: response, - }) - switch decision.normalizedAction() { - case HookActionContinue, HookActionModify: - if llmResp != nil && llmResp.Response != nil { - response = llmResp.Response - } - case HookActionAbortTurn: - turnStatus = TurnEndStatusError - return turnResult{}, al.hookAbortError(ts, "after_llm", decision) - case HookActionHardAbort: - _ = ts.requestHardAbort() - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - } - - // Save finishReason to turnState for SubTurn truncation detection - if innerTS := turnStateFromContext(ctx); innerTS != nil { - innerTS.SetLastFinishReason(response.FinishReason) - // Save usage for token budget tracking - if response.Usage != nil { - innerTS.SetLastUsage(response.Usage) - } - } - - reasoningContent := response.Reasoning - if reasoningContent == "" { - reasoningContent = response.ReasoningContent - } - if ts.channel == "pico" { - go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID) - } else { - go al.handleReasoning( - turnCtx, - reasoningContent, - ts.channel, - al.targetReasoningChannelID(ts.channel), - ) - } - al.emitEvent( - EventKindLLMResponse, - ts.eventMeta("runTurn", "turn.llm.response"), - LLMResponsePayload{ - ContentLen: len(response.Content), - ToolCalls: len(response.ToolCalls), - HasReasoning: response.Reasoning != "" || response.ReasoningContent != "", - }, - ) - - llmResponseFields := map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "content_chars": len(response.Content), - "tool_calls": len(response.ToolCalls), - "reasoning": response.Reasoning, - "target_channel": al.targetReasoningChannelID(ts.channel), - "channel": ts.channel, - } - if response.Usage != nil { - llmResponseFields["prompt_tokens"] = response.Usage.PromptTokens - llmResponseFields["completion_tokens"] = response.Usage.CompletionTokens - llmResponseFields["total_tokens"] = response.Usage.TotalTokens - } - logger.DebugCF("agent", "LLM response", llmResponseFields) - - if al.bus != nil && ts.channel == "pico" && len(response.ToolCalls) > 0 && ts.opts.AllowInterimPicoPublish { - if strings.TrimSpace(response.Content) != "" { - outCtx, outCancel := context.WithTimeout(turnCtx, 3*time.Second) - err := al.bus.PublishOutbound(outCtx, bus.OutboundMessage{ - Channel: ts.channel, - ChatID: ts.chatID, - Content: response.Content, - }) - outCancel() - if err != nil { - logger.WarnCF("agent", "Failed to publish pico interim tool-call content", map[string]any{ - "error": err.Error(), - "channel": ts.channel, - "chat_id": ts.chatID, - "iteration": iteration, - }) - } - } - } - - if len(response.ToolCalls) == 0 || gracefulTerminal { - responseContent := response.Content - if responseContent == "" && response.ReasoningContent != "" && ts.channel != "pico" { - responseContent = response.ReasoningContent - } - if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - logger.InfoCF("agent", "Steering arrived after direct LLM response; continuing turn", - map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "steering_count": len(steerMsgs), - }) - pendingMessages = append(pendingMessages, steerMsgs...) - continue - } - finalContent = responseContent - logger.InfoCF("agent", "LLM response without tool calls (direct answer)", - map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "content_chars": len(finalContent), - }) - break - } - - normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls)) - for _, tc := range response.ToolCalls { - normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc)) - } - - toolNames := make([]string, 0, len(normalizedToolCalls)) - for _, tc := range normalizedToolCalls { - toolNames = append(toolNames, tc.Name) - } - logger.InfoCF("agent", "LLM requested tool calls", - map[string]any{ - "agent_id": ts.agent.ID, - "tools": toolNames, - "count": len(normalizedToolCalls), - "iteration": iteration, - }) - - allResponsesHandled := len(normalizedToolCalls) > 0 - handledAttachments := make([]providers.Attachment, 0) - assistantMsg := providers.Message{ - Role: "assistant", - Content: response.Content, - ReasoningContent: response.ReasoningContent, - } - for _, tc := range normalizedToolCalls { - argumentsJSON, _ := json.Marshal(tc.Arguments) - extraContent := tc.ExtraContent - thoughtSignature := "" - if tc.Function != nil { - thoughtSignature = tc.Function.ThoughtSignature - } - assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ - ID: tc.ID, - Type: "function", - Name: tc.Name, - Function: &providers.FunctionCall{ - Name: tc.Name, - Arguments: string(argumentsJSON), - ThoughtSignature: thoughtSignature, - }, - ExtraContent: extraContent, - ThoughtSignature: thoughtSignature, - }) - } - messages = append(messages, assistantMsg) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, assistantMsg) - ts.recordPersistedMessage(assistantMsg) - ts.ingestMessage(turnCtx, al, assistantMsg) - } - - ts.setPhase(TurnPhaseTools) - for i, tc := range normalizedToolCalls { - if ts.hardAbortRequested() { - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - - toolName := tc.Name - toolArgs := cloneStringAnyMap(tc.Arguments) - - if al.hooks != nil { - toolReq, decision := al.hooks.BeforeTool(turnCtx, &ToolCallHookRequest{ - Meta: ts.eventMeta("runTurn", "turn.tool.before"), - Context: cloneTurnContext(ts.turnCtx), - Tool: toolName, - Arguments: toolArgs, - }) - switch decision.normalizedAction() { - case HookActionContinue, HookActionModify: - if toolReq != nil { - toolName = toolReq.Tool - toolArgs = toolReq.Arguments - } - case HookActionRespond: - // Hook returns result directly, skip tool execution. - // SECURITY: This bypasses ApproveTool, allowing hooks to respond - // for any tool name without approval. This is intentional for - // plugin tools but means a before_tool hook can override even - // sensitive tools like bash. Hook configuration should be - // carefully reviewed to prevent unauthorized tool execution. - if toolReq != nil && toolReq.HookResult != nil { - hookResult := toolReq.HookResult - - argsJSON, _ := json.Marshal(toolArgs) - argsPreview := utils.Truncate(string(argsJSON), 200) - logger.InfoCF("agent", fmt.Sprintf("Tool call (hook respond): %s(%s)", toolName, argsPreview), - map[string]any{ - "agent_id": ts.agent.ID, - "tool": toolName, - "iteration": iteration, - }) - - // Emit ToolExecStart event (same as normal tool execution) - al.emitEvent( - EventKindToolExecStart, - ts.eventMeta("runTurn", "turn.tool.start"), - ToolExecStartPayload{ - Tool: toolName, - Arguments: cloneEventArguments(toolArgs), - }, - ) - - // Send tool feedback to chat channel if enabled (same as normal tool execution) - if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && - ts.channel != "" && - !ts.opts.SuppressToolFeedback { - argsJSON, _ := json.Marshal(toolArgs) - feedbackPreview := utils.Truncate( - string(argsJSON), - al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), - ) - feedbackMsg := utils.FormatToolFeedbackMessage(toolName, feedbackPreview) - fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) - _ = al.bus.PublishOutbound(fbCtx, bus.OutboundMessage{ - Channel: ts.channel, - ChatID: ts.chatID, - Content: feedbackMsg, - }) - fbCancel() - } - - toolDuration := time.Duration(0) // Hook execution time unknown - - // Send ForUser content to user - // For ResponseHandled results, send regardless of SendResponse setting, - // same as normal tool execution path. - shouldSendForUser := !hookResult.Silent && hookResult.ForUser != "" && - (ts.opts.SendResponse || hookResult.ResponseHandled) - if shouldSendForUser { - al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Context: bus.InboundContext{ - Channel: ts.channel, - ChatID: ts.chatID, - Raw: map[string]string{ - "is_tool_call": "true", - }, - }, - Content: hookResult.ForUser, - }) - } - - // Handle media from hook result (same as normal tool execution) - if len(hookResult.Media) > 0 && hookResult.ResponseHandled { - parts := make([]bus.MediaPart, 0, len(hookResult.Media)) - for _, ref := range hookResult.Media { - part := bus.MediaPart{Ref: ref} - if al.mediaStore != nil { - if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { - part.Filename = meta.Filename - part.ContentType = meta.ContentType - part.Type = inferMediaType(meta.Filename, meta.ContentType) - } - } - parts = append(parts, part) - } - outboundMedia := bus.OutboundMediaMessage{ - Channel: ts.channel, - ChatID: ts.chatID, - Parts: parts, - } - if al.channelManager != nil && ts.channel != "" && !constants.IsInternalChannel(ts.channel) { - if err := al.channelManager.SendMedia(ctx, outboundMedia); err != nil { - logger.WarnCF("agent", "Failed to deliver hook media", - map[string]any{ - "agent_id": ts.agent.ID, - "tool": toolName, - "channel": ts.channel, - "chat_id": ts.chatID, - "error": err.Error(), - }) - // Same as normal tool execution: notify LLM about delivery failure - hookResult.IsError = true - hookResult.ForLLM = fmt.Sprintf("failed to deliver attachment: %v", err) - } else { - handledAttachments = append(handledAttachments, buildProviderAttachments(al.mediaStore, hookResult.Media)...) - } - } else if al.bus != nil { - al.bus.PublishOutboundMedia(ctx, outboundMedia) - // Same as normal tool execution: bus only queues, media not yet delivered - hookResult.ResponseHandled = false - } - } - - // Track response handling status (same as normal tool execution) - if !hookResult.ResponseHandled { - allResponsesHandled = false - } - - // Build tool message - contentForLLM := hookResult.ContentForLLM() - if al.cfg.Tools.IsFilterSensitiveDataEnabled() { - contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) - } - - toolResultMsg := providers.Message{ - Role: "tool", - Content: contentForLLM, - ToolCallID: tc.ID, - } - - // Handle media for LLM vision (same as normal tool execution) - if len(hookResult.Media) > 0 && !hookResult.ResponseHandled { - hookResult.ArtifactTags = buildArtifactTags(al.mediaStore, hookResult.Media) - // Recalculate contentForLLM after adding ArtifactTags - contentForLLM = hookResult.ContentForLLM() - if al.cfg.Tools.IsFilterSensitiveDataEnabled() { - contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) - } - toolResultMsg.Content = contentForLLM - toolResultMsg.Media = append(toolResultMsg.Media, hookResult.Media...) - } - - // Emit ToolExecEnd event (after filtering, same as normal tool execution) - al.emitEvent( - EventKindToolExecEnd, - ts.eventMeta("runTurn", "turn.tool.end"), - ToolExecEndPayload{ - Tool: toolName, - Duration: toolDuration, - ForLLMLen: len(contentForLLM), - ForUserLen: len(hookResult.ForUser), - IsError: hookResult.IsError, - Async: hookResult.Async, - }, - ) - - messages = append(messages, toolResultMsg) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg) - ts.recordPersistedMessage(toolResultMsg) - ts.ingestMessage(turnCtx, al, toolResultMsg) - } - - // Same as normal tool execution: check for steering/interrupt/SubTurn after each tool - if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - pendingMessages = append(pendingMessages, steerMsgs...) - } - - skipReason := "" - skipMessage := "" - if len(pendingMessages) > 0 { - skipReason = "queued user steering message" - skipMessage = "Skipped due to queued user message." - } else if gracefulPending, _ := ts.gracefulInterruptRequested(); gracefulPending { - skipReason = "graceful interrupt requested" - skipMessage = "Skipped due to graceful interrupt." - } - - if skipReason != "" { - remaining := len(normalizedToolCalls) - i - 1 - if remaining > 0 { - logger.InfoCF("agent", "Turn checkpoint: skipping remaining tools after hook respond", - map[string]any{ - "agent_id": ts.agent.ID, - "completed": i + 1, - "skipped": remaining, - "reason": skipReason, - }) - for j := i + 1; j < len(normalizedToolCalls); j++ { - skippedTC := normalizedToolCalls[j] - al.emitEvent( - EventKindToolExecSkipped, - ts.eventMeta("runTurn", "turn.tool.skipped"), - ToolExecSkippedPayload{ - Tool: skippedTC.Name, - Reason: skipReason, - }, - ) - skippedMsg := providers.Message{ - Role: "tool", - Content: skipMessage, - ToolCallID: skippedTC.ID, - } - messages = append(messages, skippedMsg) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, skippedMsg) - ts.recordPersistedMessage(skippedMsg) - } - } - } - break - } - - // Also poll for any SubTurn results that arrived during tool execution. - if ts.pendingResults != nil { - select { - case result, ok := <-ts.pendingResults: - if ok && result != nil && result.ForLLM != "" { - content := al.cfg.FilterSensitiveData(result.ForLLM) - msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)} - messages = append(messages, msg) - ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) - } - default: - // No results available - } - } - - continue - } - // If no HookResult, fall back to continue with warning - logger.WarnCF("agent", "Hook returned respond action but no HookResult provided", - map[string]any{ - "agent_id": ts.agent.ID, - "tool": toolName, - "action": "respond", - }) - case HookActionDenyTool: - allResponsesHandled = false - denyContent := hookDeniedToolContent("Tool execution denied by hook", decision.Reason) - al.emitEvent( - EventKindToolExecSkipped, - ts.eventMeta("runTurn", "turn.tool.skipped"), - ToolExecSkippedPayload{ - Tool: toolName, - Reason: denyContent, - }, - ) - deniedMsg := providers.Message{ - Role: "tool", - Content: denyContent, - ToolCallID: tc.ID, - } - messages = append(messages, deniedMsg) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, deniedMsg) - ts.recordPersistedMessage(deniedMsg) - } - continue - case HookActionAbortTurn: - turnStatus = TurnEndStatusError - return turnResult{}, al.hookAbortError(ts, "before_tool", decision) - case HookActionHardAbort: - _ = ts.requestHardAbort() - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - } - - if al.hooks != nil { - approval := al.hooks.ApproveTool(turnCtx, &ToolApprovalRequest{ - Meta: ts.eventMeta("runTurn", "turn.tool.approve"), - Context: cloneTurnContext(ts.turnCtx), - Tool: toolName, - Arguments: toolArgs, - }) - if !approval.Approved { - allResponsesHandled = false - denyContent := hookDeniedToolContent("Tool execution denied by approval hook", approval.Reason) - al.emitEvent( - EventKindToolExecSkipped, - ts.eventMeta("runTurn", "turn.tool.skipped"), - ToolExecSkippedPayload{ - Tool: toolName, - Reason: denyContent, - }, - ) - deniedMsg := providers.Message{ - Role: "tool", - Content: denyContent, - ToolCallID: tc.ID, - } - messages = append(messages, deniedMsg) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, deniedMsg) - ts.recordPersistedMessage(deniedMsg) - } - continue - } - } - - argsJSON, _ := json.Marshal(toolArgs) - argsPreview := utils.Truncate(string(argsJSON), 200) - logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", toolName, argsPreview), - map[string]any{ - "agent_id": ts.agent.ID, - "tool": toolName, - "iteration": iteration, - }) - al.emitEvent( - EventKindToolExecStart, - ts.eventMeta("runTurn", "turn.tool.start"), - ToolExecStartPayload{ - Tool: toolName, - Arguments: cloneEventArguments(toolArgs), - }, - ) - - // Send tool feedback to chat channel if enabled (from HEAD) - if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && - ts.channel != "" && - !ts.opts.SuppressToolFeedback { - feedbackPreview := utils.Truncate( - string(argsJSON), - al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), - ) - feedbackMsg := utils.FormatToolFeedbackMessage(tc.Name, feedbackPreview) - fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) - _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurn(ts, feedbackMsg)) - fbCancel() - } - - toolCallID := tc.ID - toolIteration := iteration - asyncToolName := toolName - asyncCallback := func(_ context.Context, result *tools.ToolResult) { - // Send ForUser content directly to the user (immediate feedback), - // mirroring the synchronous tool execution path. - if !result.Silent && result.ForUser != "" { - outCtx, outCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer outCancel() - _ = al.bus.PublishOutbound(outCtx, outboundMessageForTurn(ts, result.ForUser)) - } - - // Determine content for the agent loop (ForLLM or error). - content := result.ContentForLLM() - if content == "" { - return - } - - // Filter sensitive data before publishing - content = al.cfg.FilterSensitiveData(content) - - logger.InfoCF("agent", "Async tool completed, publishing result", - map[string]any{ - "tool": asyncToolName, - "content_len": len(content), - "channel": ts.channel, - }) - al.emitEvent( - EventKindFollowUpQueued, - ts.scope.meta(toolIteration, "runTurn", "turn.follow_up.queued"), - FollowUpQueuedPayload{ - SourceTool: asyncToolName, - ContentLen: len(content), - }, - ) - - pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer pubCancel() - _ = al.bus.PublishInbound(pubCtx, bus.InboundMessage{ - Context: bus.InboundContext{ - Channel: "system", - ChatID: fmt.Sprintf("%s:%s", ts.channel, ts.chatID), - ChatType: "direct", - SenderID: fmt.Sprintf("async:%s", asyncToolName), - }, - Content: content, - }) - } - - toolStart := time.Now() - execCtx := tools.WithToolInboundContext( - turnCtx, - ts.channel, - ts.chatID, - ts.opts.Dispatch.MessageID(), - ts.opts.Dispatch.ReplyToMessageID(), - ) - execCtx = tools.WithToolSessionContext( - execCtx, - ts.agent.ID, - ts.sessionKey, - ts.opts.Dispatch.SessionScope, - ) - toolResult := ts.agent.Tools.ExecuteWithContext( - execCtx, - toolName, - toolArgs, - ts.channel, - ts.chatID, - asyncCallback, - ) - toolDuration := time.Since(toolStart) - - if ts.hardAbortRequested() { - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - - if al.hooks != nil { - toolResp, decision := al.hooks.AfterTool(turnCtx, &ToolResultHookResponse{ - Meta: ts.eventMeta("runTurn", "turn.tool.after"), - Context: cloneTurnContext(ts.turnCtx), - Tool: toolName, - Arguments: toolArgs, - Result: toolResult, - Duration: toolDuration, - }) - switch decision.normalizedAction() { - case HookActionContinue, HookActionModify: - if toolResp != nil { - if toolResp.Tool != "" { - toolName = toolResp.Tool - } - if toolResp.Result != nil { - toolResult = toolResp.Result - } - } - case HookActionAbortTurn: - turnStatus = TurnEndStatusError - return turnResult{}, al.hookAbortError(ts, "after_tool", decision) - case HookActionHardAbort: - _ = ts.requestHardAbort() - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - } - - if toolResult == nil { - toolResult = tools.ErrorResult("hook returned nil tool result") - } - - if len(toolResult.Media) > 0 && toolResult.ResponseHandled { - parts := make([]bus.MediaPart, 0, len(toolResult.Media)) - for _, ref := range toolResult.Media { - part := bus.MediaPart{Ref: ref} - if al.mediaStore != nil { - if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { - part.Filename = meta.Filename - part.ContentType = meta.ContentType - part.Type = inferMediaType(meta.Filename, meta.ContentType) - } - } - parts = append(parts, part) - } - outboundMedia := bus.OutboundMediaMessage{ - Channel: ts.channel, - ChatID: ts.chatID, - Context: outboundContextFromInbound( - ts.opts.Dispatch.InboundContext, - ts.channel, - ts.chatID, - ts.opts.Dispatch.ReplyToMessageID(), - ), - AgentID: ts.agent.ID, - SessionKey: ts.sessionKey, - Scope: outboundScopeFromSessionScope(ts.opts.Dispatch.SessionScope), - Parts: parts, - } - if al.channelManager != nil && ts.channel != "" && !constants.IsInternalChannel(ts.channel) { - if err := al.channelManager.SendMedia(ctx, outboundMedia); err != nil { - logger.WarnCF("agent", "Failed to deliver handled tool media", - map[string]any{ - "agent_id": ts.agent.ID, - "tool": toolName, - "channel": ts.channel, - "chat_id": ts.chatID, - "error": err.Error(), - }) - toolResult = tools.ErrorResult(fmt.Sprintf("failed to deliver attachment: %v", err)).WithError(err) - } else { - handledAttachments = append(handledAttachments, buildProviderAttachments(al.mediaStore, toolResult.Media)...) - } - } else if al.bus != nil { - al.bus.PublishOutboundMedia(ctx, outboundMedia) - // Queuing media is only best-effort; it has not been delivered yet. - toolResult.ResponseHandled = false - } - } - - if len(toolResult.Media) > 0 && !toolResult.ResponseHandled { - // For tools like load_image that produce media refs without sending them - // to the user channel (ResponseHandled == false), both Media and ArtifactTags - // coexist on the result: - // - Media: carries media:// refs that resolveMediaRefs will base64-encode - // into image_url parts in the next LLM iteration (enabling vision). - // - ArtifactTags: exposes the local file path as a structured [file:…] tag - // in the tool result text, so the LLM knows an artifact was produced. - toolResult.ArtifactTags = buildArtifactTags(al.mediaStore, toolResult.Media) - } - - if !toolResult.ResponseHandled { - allResponsesHandled = false - } - - shouldSendForUser := !toolResult.Silent && - toolResult.ForUser != "" && - (ts.opts.SendResponse || toolResult.ResponseHandled) - if shouldSendForUser { - al.bus.PublishOutbound(ctx, outboundMessageForTurn(ts, toolResult.ForUser)) - logger.DebugCF("agent", "Sent tool result to user", - map[string]any{ - "tool": toolName, - "content_len": len(toolResult.ForUser), - }) - } - contentForLLM := toolResult.ContentForLLM() - - // Filter sensitive data (API keys, tokens, secrets) before sending to LLM - if al.cfg.Tools.IsFilterSensitiveDataEnabled() { - contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) - } - - toolResultMsg := providers.Message{ - Role: "tool", - Content: contentForLLM, - ToolCallID: toolCallID, - } - if len(toolResult.Media) > 0 && !toolResult.ResponseHandled { - toolResultMsg.Media = append(toolResultMsg.Media, toolResult.Media...) - } - al.emitEvent( - EventKindToolExecEnd, - ts.eventMeta("runTurn", "turn.tool.end"), - ToolExecEndPayload{ - Tool: toolName, - Duration: toolDuration, - ForLLMLen: len(contentForLLM), - ForUserLen: len(toolResult.ForUser), - IsError: toolResult.IsError, - Async: toolResult.Async, - }, - ) - messages = append(messages, toolResultMsg) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg) - ts.recordPersistedMessage(toolResultMsg) - ts.ingestMessage(turnCtx, al, toolResultMsg) - } - - if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - pendingMessages = append(pendingMessages, steerMsgs...) - } - - skipReason := "" - skipMessage := "" - if len(pendingMessages) > 0 { - skipReason = "queued user steering message" - skipMessage = "Skipped due to queued user message." - } else if gracefulPending, _ := ts.gracefulInterruptRequested(); gracefulPending { - skipReason = "graceful interrupt requested" - skipMessage = "Skipped due to graceful interrupt." - } - - if skipReason != "" { - remaining := len(normalizedToolCalls) - i - 1 - if remaining > 0 { - logger.InfoCF("agent", "Turn checkpoint: skipping remaining tools", - map[string]any{ - "agent_id": ts.agent.ID, - "completed": i + 1, - "skipped": remaining, - "reason": skipReason, - }) - for j := i + 1; j < len(normalizedToolCalls); j++ { - skippedTC := normalizedToolCalls[j] - al.emitEvent( - EventKindToolExecSkipped, - ts.eventMeta("runTurn", "turn.tool.skipped"), - ToolExecSkippedPayload{ - Tool: skippedTC.Name, - Reason: skipReason, - }, - ) - skippedMsg := providers.Message{ - Role: "tool", - Content: skipMessage, - ToolCallID: skippedTC.ID, - } - messages = append(messages, skippedMsg) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, skippedMsg) - ts.recordPersistedMessage(skippedMsg) - } - } - } - break - } - - // Also poll for any SubTurn results that arrived during tool execution. - if ts.pendingResults != nil { - select { - case result, ok := <-ts.pendingResults: - if ok && result != nil && result.ForLLM != "" { - content := al.cfg.FilterSensitiveData(result.ForLLM) - msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)} - messages = append(messages, msg) - ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) - } - default: - // No results available - } - } - } - - if allResponsesHandled { - if len(pendingMessages) > 0 { - logger.InfoCF("agent", "Pending steering exists after handled tool delivery; continuing turn before finalizing", - map[string]any{ - "agent_id": ts.agent.ID, - "steering_count": len(pendingMessages), - "session_key": ts.sessionKey, - }) - finalContent = "" - goto turnLoop - } - - if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - logger.InfoCF("agent", "Steering arrived after handled tool delivery; continuing turn before finalizing", - map[string]any{ - "agent_id": ts.agent.ID, - "steering_count": len(steerMsgs), - "session_key": ts.sessionKey, - }) - pendingMessages = append(pendingMessages, steerMsgs...) - finalContent = "" - goto turnLoop - } - - summaryMsg := providers.Message{ - Role: "assistant", - Content: handledToolResponseSummary, - Attachments: append([]providers.Attachment(nil), handledAttachments...), - } - - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, summaryMsg) - ts.recordPersistedMessage(summaryMsg) - ts.ingestMessage(turnCtx, al, summaryMsg) - if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { - turnStatus = TurnEndStatusError - al.emitEvent( - EventKindError, - ts.eventMeta("runTurn", "turn.error"), - ErrorPayload{ - Stage: "session_save", - Message: err.Error(), - }, - ) - return turnResult{}, err - } - } - if ts.opts.EnableSummary { - al.contextManager.Compact(turnCtx, &CompactRequest{SessionKey: ts.sessionKey, Reason: ContextCompressReasonSummarize, Budget: ts.agent.ContextWindow}) - } - - ts.setPhase(TurnPhaseCompleted) - ts.setFinalContent("") - logger.InfoCF("agent", "Tool output satisfied delivery; ending turn without follow-up LLM", - map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "tool_count": len(normalizedToolCalls), - }) - return turnResult{ - finalContent: "", - status: turnStatus, - followUps: append([]bus.InboundMessage(nil), ts.followUps...), - }, nil - } - - ts.agent.Tools.TickTTL() - logger.DebugCF("agent", "TTL tick after tool execution", map[string]any{ - "agent_id": ts.agent.ID, "iteration": iteration, - }) - } - - if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - logger.InfoCF("agent", "Steering arrived after turn completion; continuing turn before finalizing", - map[string]any{ - "agent_id": ts.agent.ID, - "steering_count": len(steerMsgs), - "session_key": ts.sessionKey, - }) - pendingMessages = append(pendingMessages, steerMsgs...) - finalContent = "" - goto turnLoop - } - - if ts.hardAbortRequested() { - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - - if finalContent == "" { - if ts.currentIteration() >= ts.agent.MaxIterations && ts.agent.MaxIterations > 0 { - finalContent = toolLimitResponse - } else { - finalContent = ts.opts.DefaultResponse - } - } - - ts.setPhase(TurnPhaseFinalizing) - ts.setFinalContent(finalContent) - if !ts.opts.NoHistory { - finalMsg := providers.Message{Role: "assistant", Content: finalContent} - ts.agent.Sessions.AddMessage(ts.sessionKey, finalMsg.Role, finalMsg.Content) - ts.recordPersistedMessage(finalMsg) - ts.ingestMessage(turnCtx, al, finalMsg) - if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { - turnStatus = TurnEndStatusError - al.emitEvent( - EventKindError, - ts.eventMeta("runTurn", "turn.error"), - ErrorPayload{ - Stage: "session_save", - Message: err.Error(), - }, - ) - return turnResult{}, err - } - } - - if ts.opts.EnableSummary { - al.contextManager.Compact( - turnCtx, - &CompactRequest{ - SessionKey: ts.sessionKey, - Reason: ContextCompressReasonSummarize, - Budget: ts.agent.ContextWindow, - }, - ) - } - - ts.setPhase(TurnPhaseCompleted) - return turnResult{ - finalContent: finalContent, - status: turnStatus, - followUps: append([]bus.InboundMessage(nil), ts.followUps...), - }, nil -} - -func (al *AgentLoop) abortTurn(ts *turnState) (turnResult, error) { - ts.setPhase(TurnPhaseAborted) - if !ts.opts.NoHistory { - if err := ts.restoreSession(ts.agent); err != nil { - al.emitEvent( - EventKindError, - ts.eventMeta("abortTurn", "turn.error"), - ErrorPayload{ - Stage: "session_restore", - Message: err.Error(), - }, - ) - return turnResult{}, err - } - } - return turnResult{status: TurnEndStatusAborted}, nil -} - -func sleepWithContext(ctx context.Context, d time.Duration) error { - timer := time.NewTimer(d) - defer timer.Stop() - - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return nil - } -} - -// selectCandidates returns the model candidates and resolved model name to use -// for a conversation turn. When model routing is configured and the incoming -// message scores below the complexity threshold, it returns the light model -// candidates instead of the primary ones. -// -// The returned (candidates, model) pair is used for all LLM calls within one -// turn — tool follow-up iterations use the same tier as the initial call so -// that a multi-step tool chain doesn't switch models mid-way. -func (al *AgentLoop) selectCandidates( - agent *AgentInstance, - userMsg string, - history []providers.Message, -) (candidates []providers.FallbackCandidate, model string, usedLight bool) { - if agent.Router == nil || len(agent.LightCandidates) == 0 { - return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), false - } - - _, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model) - if !usedLight { - logger.DebugCF("agent", "Model routing: primary model selected", - map[string]any{ - "agent_id": agent.ID, - "score": score, - "threshold": agent.Router.Threshold(), - }) - return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), false - } - - logger.InfoCF("agent", "Model routing: light model selected", - map[string]any{ - "agent_id": agent.ID, - "light_model": agent.Router.LightModel(), - "score": score, - "threshold": agent.Router.Threshold(), - }) - return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel()), true -} - -// resolveContextManager selects the ContextManager implementation based on config. -func (al *AgentLoop) resolveContextManager() ContextManager { - name := al.cfg.Agents.Defaults.ContextManager - if name == "" || name == "legacy" { - return &legacyContextManager{al: al} - } - factory, ok := lookupContextManager(name) - if !ok { - logger.WarnCF("agent", "Unknown context manager, falling back to legacy", map[string]any{ - "name": name, - }) - return &legacyContextManager{al: al} - } - cm, err := factory(al.cfg.Agents.Defaults.ContextManagerConfig, al) - if err != nil { - logger.WarnCF("agent", "Failed to create context manager, falling back to legacy", map[string]any{ - "name": name, - "error": err.Error(), - }) - return &legacyContextManager{al: al} - } - return cm -} - -// GetStartupInfo returns information about loaded tools and skills for logging. -func (al *AgentLoop) GetStartupInfo() map[string]any { - info := make(map[string]any) - - registry := al.GetRegistry() - agent := registry.GetDefaultAgent() - if agent == nil { - return info - } - - // Tools info - toolsList := agent.Tools.List() - info["tools"] = map[string]any{ - "count": len(toolsList), - "names": toolsList, - } - - // Skills info - info["skills"] = agent.ContextBuilder.GetSkillsInfo() - - // Agents info - info["agents"] = map[string]any{ - "count": len(registry.ListAgentIDs()), - "ids": registry.ListAgentIDs(), - } - - return info -} - -// formatMessagesForLog formats messages for logging -func formatMessagesForLog(messages []providers.Message) string { - if len(messages) == 0 { - return "[]" - } - - var sb strings.Builder - sb.WriteString("[\n") - for i, msg := range messages { - fmt.Fprintf(&sb, " [%d] Role: %s\n", i, msg.Role) - if len(msg.ToolCalls) > 0 { - sb.WriteString(" ToolCalls:\n") - for _, tc := range msg.ToolCalls { - fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name) - if tc.Function != nil { - fmt.Fprintf( - &sb, - " Arguments: %s\n", - utils.Truncate(tc.Function.Arguments, 200), - ) - } - } - } - if msg.Content != "" { - content := utils.Truncate(msg.Content, 200) - fmt.Fprintf(&sb, " Content: %s\n", content) - } - if msg.ToolCallID != "" { - fmt.Fprintf(&sb, " ToolCallID: %s\n", msg.ToolCallID) - } - sb.WriteString("\n") - } - sb.WriteString("]") - return sb.String() -} - -// formatToolsForLog formats tool definitions for logging -func formatToolsForLog(toolDefs []providers.ToolDefinition) string { - if len(toolDefs) == 0 { - return "[]" - } - - var sb strings.Builder - sb.WriteString("[\n") - for i, tool := range toolDefs { - fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name) - fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description) - if len(tool.Function.Parameters) > 0 { - fmt.Fprintf( - &sb, - " Parameters: %s\n", - utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200), - ) - } - } - sb.WriteString("]") - return sb.String() -} - -// summarizeSession summarizes the conversation history for a session. -// findNearestUserMessage finds the nearest user message to the given index. -// It searches backward first, then forward if no user message is found. -// retryLLMCall calls the LLM with retry logic. -// summarizeBatch summarizes a batch of messages. -// estimateTokens estimates the number of tokens in a message list. -// Counts Content, ToolCalls arguments, and ToolCallID metadata so that -// tool-heavy conversations are not systematically undercounted. -func (al *AgentLoop) handleCommand( - ctx context.Context, - msg bus.InboundMessage, - agent *AgentInstance, - opts *processOptions, -) (string, bool) { - normalizeProcessOptionsInPlace(opts) - - if !commands.HasCommandPrefix(msg.Content) { - return "", false - } - - if matched, handled, reply := al.applyExplicitSkillCommand(msg.Content, agent, opts); matched { - return reply, handled - } - - if al.cmdRegistry == nil { - return "", false - } - - rt := al.buildCommandsRuntime(ctx, agent, opts) - executor := commands.NewExecutor(al.cmdRegistry, rt) - - var commandReply string - result := executor.Execute(ctx, commands.Request{ - Channel: msg.Channel, - ChatID: msg.ChatID, - SenderID: msg.SenderID, - Text: msg.Content, - Reply: func(text string) error { - commandReply = text - return nil - }, - }) - - switch result.Outcome { - case commands.OutcomeHandled: - if result.Err != nil { - return mapCommandError(result), true - } - if commandReply != "" { - return commandReply, true - } - return "", true - default: // OutcomePassthrough — let the message fall through to LLM - return "", false - } -} - -func activeSkillNames(agent *AgentInstance, opts processOptions) []string { - if agent == nil { - return nil - } - - combined := make([]string, 0, len(agent.SkillsFilter)+len(opts.ForcedSkills)) - combined = append(combined, agent.SkillsFilter...) - combined = append(combined, opts.ForcedSkills...) - if len(combined) == 0 { - return nil - } - - var resolved []string - seen := make(map[string]struct{}, len(combined)) - for _, name := range combined { - name = strings.TrimSpace(name) - if name == "" { - continue - } - if agent.ContextBuilder != nil { - if canonical, ok := agent.ContextBuilder.ResolveSkillName(name); ok { - name = canonical - } - } - key := strings.ToLower(name) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - resolved = append(resolved, name) - } - - return resolved -} - -func (al *AgentLoop) applyExplicitSkillCommand( - raw string, - agent *AgentInstance, - opts *processOptions, -) (matched bool, handled bool, reply string) { - normalizeProcessOptionsInPlace(opts) - - cmdName, ok := commands.CommandName(raw) - if !ok || cmdName != "use" { - return false, false, "" - } - - if agent == nil || agent.ContextBuilder == nil { - return true, true, commandsUnavailableSkillMessage() - } - - parts := strings.Fields(strings.TrimSpace(raw)) - if len(parts) < 2 { - return true, true, buildUseCommandHelp(agent) - } - - arg := strings.TrimSpace(parts[1]) - if strings.EqualFold(arg, "clear") || strings.EqualFold(arg, "off") { - if opts != nil { - al.clearPendingSkills(opts.Dispatch.SessionKey) - } - return true, true, "Cleared pending skill override." - } - - skillName, ok := agent.ContextBuilder.ResolveSkillName(arg) - if !ok { - return true, true, fmt.Sprintf("Unknown skill: %s\nUse /list skills to see installed skills.", arg) - } - - if len(parts) < 3 { - if opts == nil || strings.TrimSpace(opts.Dispatch.SessionKey) == "" { - return true, true, commandsUnavailableSkillMessage() - } - al.setPendingSkills(opts.Dispatch.SessionKey, []string{skillName}) - return true, true, fmt.Sprintf( - "Skill %q is armed for your next message. Send your next prompt normally, or use /use clear to cancel.", - skillName, - ) - } - - message := strings.TrimSpace(strings.Join(parts[2:], " ")) - if message == "" { - return true, true, buildUseCommandHelp(agent) - } - - if opts != nil { - opts.ForcedSkills = append(opts.ForcedSkills, skillName) - opts.Dispatch.UserMessage = message - opts.UserMessage = message - } - - return true, false, "" -} - -func (al *AgentLoop) buildCommandsRuntime( - ctx context.Context, - agent *AgentInstance, - opts *processOptions, -) *commands.Runtime { - normalizeProcessOptionsInPlace(opts) - - registry := al.GetRegistry() - cfg := al.GetConfig() - rt := &commands.Runtime{ - Config: cfg, - ListAgentIDs: registry.ListAgentIDs, - ListDefinitions: al.cmdRegistry.Definitions, - GetEnabledChannels: func() []string { - if al.channelManager == nil { - return nil - } - return al.channelManager.GetEnabledChannels() - }, - GetActiveTurn: func() any { - info := al.GetActiveTurn() - if info == nil { - return nil - } - return info - }, - SwitchChannel: func(value string) error { - if al.channelManager == nil { - return fmt.Errorf("channel manager not initialized") - } - if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" { - return fmt.Errorf("channel '%s' not found or not enabled", value) - } - return nil - }, - } - if agent != nil && agent.ContextBuilder != nil { - rt.ListSkillNames = agent.ContextBuilder.ListSkillNames - } - rt.ReloadConfig = func() error { - if al.reloadFunc == nil { - return fmt.Errorf("reload not configured") - } - return al.reloadFunc() - } - if agent != nil { - if agent.ContextBuilder != nil { - rt.ListSkillNames = agent.ContextBuilder.ListSkillNames - } - rt.GetModelInfo = func() (string, string) { - return agent.Model, resolvedCandidateProvider(agent.Candidates, cfg.Agents.Defaults.Provider) - } - rt.SwitchModel = func(value string) (string, error) { - value = strings.TrimSpace(value) - modelCfg, err := resolvedModelConfig(cfg, value, agent.Workspace) - if err != nil { - return "", err - } - - nextProvider, _, err := providers.CreateProviderFromConfig(modelCfg) - if err != nil { - return "", fmt.Errorf("failed to initialize model %q: %w", value, err) - } - - nextCandidates := resolveModelCandidates(cfg, cfg.Agents.Defaults.Provider, value, agent.Fallbacks) - if len(nextCandidates) == 0 { - return "", fmt.Errorf("model %q did not resolve to any provider candidates", value) - } - - oldModel := agent.Model - oldProvider := agent.Provider - agent.Model = value - agent.Provider = nextProvider - agent.Candidates = nextCandidates - agent.ThinkingLevel = parseThinkingLevel(modelCfg.ThinkingLevel) - - if oldProvider != nil && oldProvider != nextProvider { - if stateful, ok := oldProvider.(providers.StatefulProvider); ok { - stateful.Close() - } - } - return oldModel, nil - } - - rt.ClearHistory = func() error { - if opts == nil { - return fmt.Errorf("process options not available") - } - return al.contextManager.Clear(ctx, opts.SessionKey) - } - - rt.AskSideQuestion = func(ctx context.Context, question string) (string, error) { - return al.askSideQuestion(ctx, agent, opts, question) - } - } - return rt -} - -// askSideQuestion handles /btw commands by creating an isolated provider instance -// that doesn't share state with the main conversation provider. -func (al *AgentLoop) askSideQuestion( - ctx context.Context, - agent *AgentInstance, - opts *processOptions, - question string, -) (string, error) { - if agent == nil { - return "", fmt.Errorf("askSideQuestion: no agent available for /btw") - } - - question = strings.TrimSpace(question) - if question == "" { - return "", fmt.Errorf("askSideQuestion: %w", fmt.Errorf("Usage: /btw ")) - } - - if opts != nil { - normalizeProcessOptionsInPlace(opts) - } - - var media []string - var channel, chatID, senderID, senderDisplayName string - if opts != nil { - media = opts.Media - channel = opts.Channel - chatID = opts.ChatID - senderID = opts.SenderID - senderDisplayName = opts.SenderDisplayName - } - - // Build messages with context but WITHOUT adding to session history - var history []providers.Message - var summary string - if opts != nil && !opts.NoHistory { - if resp, err := al.contextManager.Assemble(ctx, &AssembleRequest{ - SessionKey: opts.SessionKey, - Budget: agent.ContextWindow, - MaxTokens: agent.MaxTokens, - }); err == nil && resp != nil { - history = resp.History - summary = resp.Summary - } - } - - messages := agent.ContextBuilder.BuildMessages( - history, - summary, - question, - media, - channel, - chatID, - senderID, - senderDisplayName, - ) - - maxMediaSize := al.GetConfig().Agents.Defaults.GetMaxMediaSize() - messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) - - activeCandidates, activeModel, usedLight := al.selectCandidates(agent, question, messages) - selectedModelName := sideQuestionModelName(agent, usedLight) - - llmOpts := map[string]any{ - "max_tokens": agent.MaxTokens, - "temperature": agent.Temperature, - "prompt_cache_key": agent.ID + ":btw", - } - - hookModelChanged := false - callProvider := func( - ctx context.Context, - candidate providers.FallbackCandidate, - model string, - forceModel bool, - callMessages []providers.Message, - ) (*providers.LLMResponse, error) { - provider, providerModel, cleanup, err := al.isolatedSideQuestionProvider(agent, selectedModelName, candidate) - if err != nil { - return nil, err - } - defer cleanup() - if !forceModel || strings.TrimSpace(model) == "" { - model = providerModel - } - callOpts := llmOpts - if _, exists := callOpts["thinking_level"]; !exists && agent.ThinkingLevel != ThinkingOff { - if tc, ok := provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { - callOpts = shallowCloneLLMOptions(llmOpts) - callOpts["thinking_level"] = string(agent.ThinkingLevel) - } - } - return provider.Chat(ctx, callMessages, nil, model, callOpts) - } - - turnCtx := newTurnContext(nil, nil, nil) - if opts != nil { - turnCtx = newTurnContext(opts.Dispatch.InboundContext, opts.Dispatch.RouteResult, opts.Dispatch.SessionScope) - } - llmModel := activeModel - if al.hooks != nil { - llmReq, decision := al.hooks.BeforeLLM(ctx, &LLMHookRequest{ - Meta: EventMeta{ - Source: "askSideQuestion", - TracePath: "turn.llm.request", - turnContext: cloneTurnContext(turnCtx), - }, - Context: cloneTurnContext(turnCtx), - Model: llmModel, - Messages: messages, - Tools: nil, - Options: llmOpts, - GracefulTerminal: false, - }) - switch decision.normalizedAction() { - case HookActionContinue, HookActionModify: - if llmReq != nil { - if strings.TrimSpace(llmReq.Model) != "" && llmReq.Model != llmModel { - hookModelChanged = true - } - llmModel = llmReq.Model - messages = llmReq.Messages - llmOpts = llmReq.Options - } - case HookActionAbortTurn: - reason := decision.Reason - if reason == "" { - reason = "hook requested turn abort" - } - return "", fmt.Errorf("hook aborted turn during before_llm: %s", reason) - case HookActionHardAbort: - reason := decision.Reason - if reason == "" { - reason = "hook requested turn abort" - } - return "", fmt.Errorf("hook aborted turn during before_llm: %s", reason) - } - } - if hookModelChanged { - // Hook-selected models must not continue through the pre-hook fallback - // candidate list, otherwise fallback execution would call the original - // candidate model and silently ignore the hook decision. - activeCandidates = nil - } - - callSideLLM := func(callMessages []providers.Message) (*providers.LLMResponse, error) { - if len(activeCandidates) > 1 && al.fallback != nil { - fbResult, err := al.fallback.Execute( - ctx, - activeCandidates, - func(ctx context.Context, providerName, model string) (*providers.LLMResponse, error) { - candidate := providers.FallbackCandidate{Provider: providerName, Model: model} - for _, activeCandidate := range activeCandidates { - if activeCandidate.Provider == providerName && activeCandidate.Model == model { - candidate = activeCandidate - break - } - } - return callProvider(ctx, candidate, model, false, callMessages) - }, - ) - if err != nil { - return nil, err - } - return fbResult.Response, nil - } - - var candidate providers.FallbackCandidate - if len(activeCandidates) > 0 { - candidate = activeCandidates[0] - } - return callProvider(ctx, candidate, llmModel, hookModelChanged, callMessages) - } - - // Retry without media if vision is unsupported - // Note: Vision retry is only applied to the initial call. If fallback chain - // is used, vision errors from fallback providers will not trigger retry. - var resp *providers.LLMResponse - var err error - resp, err = callSideLLM(messages) - if err != nil && hasMediaRefs(messages) && isVisionUnsupportedError(err) { - al.emitEvent( - EventKindLLMRetry, - EventMeta{ - Source: "askSideQuestion", - TracePath: "turn.llm.retry", - turnContext: cloneTurnContext(turnCtx), - }, - LLMRetryPayload{ - Attempt: 1, - MaxRetries: 1, - Reason: "vision_unsupported", - Error: err.Error(), - Backoff: 0, - }, - ) - messagesWithoutMedia := stripMessageMedia(messages) - resp, err = callSideLLM(messagesWithoutMedia) - } - if err != nil { - return "", err - } - if resp == nil { - return "", nil - } - - // Apply after_llm hooks - if al.hooks != nil { - llmResp, decision := al.hooks.AfterLLM(ctx, &LLMHookResponse{ - Meta: EventMeta{ - Source: "askSideQuestion", - TracePath: "turn.llm.response", - turnContext: cloneTurnContext(turnCtx), - }, - Context: cloneTurnContext(turnCtx), - Model: llmModel, - Response: resp, - }) - switch decision.normalizedAction() { - case HookActionContinue, HookActionModify: - if llmResp != nil && llmResp.Response != nil { - resp = llmResp.Response - } - case HookActionAbortTurn, HookActionHardAbort: - reason := decision.Reason - if reason == "" { - reason = "hook requested turn abort" - } - return "", fmt.Errorf("hook aborted turn during after_llm: %s", reason) - } - } - - return sideQuestionResponseContent(resp), nil -} - -func sideQuestionResponseContent(response *providers.LLMResponse) string { - if response == nil { - return "" - } - if response.Content != "" { - return response.Content - } - return response.ReasoningContent -} - -// shallowCloneLLMOptions creates a shallow copy of LLM options map. -// Note: This is a shallow copy - nested maps/slices are shared. -func shallowCloneLLMOptions(opts map[string]any) map[string]any { - clone := make(map[string]any, len(opts)) - for k, v := range opts { - clone[k] = v - } - return clone -} - -// hasMediaRefs checks if any message has media references. -func hasMediaRefs(messages []providers.Message) bool { - for _, msg := range messages { - if len(msg.Media) > 0 { - return true - } - } - return false -} - -// isolatedSideQuestionProvider creates a separate provider instance for /btw commands -// to avoid sharing state with the main conversation provider. -func (al *AgentLoop) isolatedSideQuestionProvider( - agent *AgentInstance, - baseModelName string, - candidate providers.FallbackCandidate, -) (providers.LLMProvider, string, func(), error) { - if agent == nil { - return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: no agent available for /btw") - } - - modelCfg, err := al.sideQuestionModelConfig(agent, baseModelName, candidate) - if err != nil { - return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: %w", err) - } - - factory := al.providerFactory - if factory == nil { - factory = providers.CreateProviderFromConfig - } - provider, modelID, err := factory(modelCfg) - if err != nil { - return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: %w", err) - } - - cleanup := func() { - closeProviderIfStateful(provider) - } - return provider, modelID, cleanup, nil -} - -// sideQuestionModelConfig resolves the model config for side questions. -func (al *AgentLoop) sideQuestionModelConfig( - agent *AgentInstance, - baseModelName string, - candidate providers.FallbackCandidate, -) (*config.ModelConfig, error) { - if agent == nil { - return nil, fmt.Errorf("sideQuestionModelConfig: no agent available for /btw") - } - - // If candidate has an identity key, use that - if name := modelNameFromIdentityKey(candidate.IdentityKey); name != "" { - modelCfg, err := resolvedModelConfig(al.GetConfig(), name, agent.Workspace) - if err == nil { - return modelCfg, nil - } - // Fallback: create a minimal config if lookup fails - } - - // Otherwise, clean up the base model name and use it - baseModelName = strings.TrimSpace(baseModelName) - modelCfg, err := resolvedModelConfig(al.GetConfig(), baseModelName, agent.Workspace) - if err != nil { - // Fallback: create a minimal config for test scenarios - model := strings.TrimSpace(baseModelName) - if candidate.Model != "" { - model = candidate.Model - } - if candidate.Provider != "" && candidate.Model != "" { - model = providers.NormalizeProvider(candidate.Provider) + "/" + candidate.Model - } else { - model = ensureProtocolModel(model) - } - return &config.ModelConfig{ - ModelName: baseModelName, - Model: model, - Workspace: agent.Workspace, - }, nil - } - - // If candidate specifies a different provider/model, override - clone := *modelCfg - if candidate.Provider != "" && candidate.Model != "" { - clone.Model = providers.NormalizeProvider(candidate.Provider) + "/" + candidate.Model - } - return &clone, nil -} - -// sideQuestionModelName determines which model name to use for side questions. -func sideQuestionModelName(agent *AgentInstance, usedLight bool) string { - if usedLight && len(agent.LightCandidates) > 0 { - // Use the first light candidate's model - return agent.LightCandidates[0].Model - } - return agent.Model -} - -// modelNameFromIdentityKey extracts the model name from an identity key. -func modelNameFromIdentityKey(identityKey string) string { - if identityKey == "" { - return "" - } - parts := strings.SplitN(identityKey, "/", 2) - if len(parts) == 2 { - return parts[1] - } - return identityKey -} - -// closeProviderIfStateful closes a provider if it implements StatefulProvider. -func closeProviderIfStateful(provider providers.LLMProvider) { - if stateful, ok := provider.(providers.StatefulProvider); ok { - stateful.Close() - } -} - -// makePendingTurnID generates a unique turn ID for placeholder turns. -// Format: "pending-{sessionKey}-{sequence}" -func makePendingTurnID(sessionKey string, seq uint64) string { - return pendingTurnPrefix + sessionKey + "-" + fmt.Sprintf("%d", seq) -} - -func commandsUnavailableSkillMessage() string { - return "Skill selection is unavailable in the current context." -} - -func buildUseCommandHelp(agent *AgentInstance) string { - if agent == nil || agent.ContextBuilder == nil { - return "Usage: /use [message]" - } - - names := agent.ContextBuilder.ListSkillNames() - if len(names) == 0 { - return "Usage: /use [message]\nNo installed skills found." - } - - return fmt.Sprintf( - "Usage: /use [message]\n\nInstalled Skills:\n- %s\n\nUse /use to apply a skill to your next message, or /use to force it immediately.", - strings.Join(names, "\n- "), - ) -} - -func (al *AgentLoop) setPendingSkills(sessionKey string, skillNames []string) { - sessionKey = strings.TrimSpace(sessionKey) - if sessionKey == "" || len(skillNames) == 0 { - return - } - - filtered := make([]string, 0, len(skillNames)) - for _, name := range skillNames { - name = strings.TrimSpace(name) - if name != "" { - filtered = append(filtered, name) - } - } - if len(filtered) == 0 { - return - } - - al.pendingSkills.Store(sessionKey, filtered) -} - -func (al *AgentLoop) takePendingSkills(sessionKey string) []string { - sessionKey = strings.TrimSpace(sessionKey) - if sessionKey == "" { - return nil - } - - value, ok := al.pendingSkills.LoadAndDelete(sessionKey) - if !ok { - return nil - } - - skills, ok := value.([]string) - if !ok { - return nil - } - - return append([]string(nil), skills...) -} - -func (al *AgentLoop) clearPendingSkills(sessionKey string) { - sessionKey = strings.TrimSpace(sessionKey) - if sessionKey == "" { - return - } - al.pendingSkills.Delete(sessionKey) -} - -func mapCommandError(result commands.ExecuteResult) string { - if result.Command == "" { - return fmt.Sprintf("Failed to execute command: %v", result.Err) - } - return fmt.Sprintf("Failed to execute /%s: %v", result.Command, result.Err) -} - -// isNativeSearchProvider reports whether the given LLM provider implements -// NativeSearchCapable and returns true for SupportsNativeSearch. -func isNativeSearchProvider(p providers.LLMProvider) bool { - if ns, ok := p.(providers.NativeSearchCapable); ok { - return ns.SupportsNativeSearch() - } - return false -} - -// filterClientWebSearch returns a copy of tools with the client-side -// web_search tool removed. Used when native provider search is preferred. -func filterClientWebSearch(tools []providers.ToolDefinition) []providers.ToolDefinition { - result := make([]providers.ToolDefinition, 0, len(tools)) - for _, t := range tools { - if strings.EqualFold(t.Function.Name, "web_search") { - continue - } - result = append(result, t) - } - return result -} - -// Helper to extract provider from registry for cleanup -func extractProvider(registry *AgentRegistry) (providers.LLMProvider, bool) { - if registry == nil { - return nil, false - } - // Get any agent to access the provider - defaultAgent := registry.GetDefaultAgent() - if defaultAgent == nil { - return nil, false - } - return defaultAgent.Provider, true -} diff --git a/pkg/agent/pipeline.go b/pkg/agent/pipeline.go new file mode 100644 index 000000000..c4b9ec3af --- /dev/null +++ b/pkg/agent/pipeline.go @@ -0,0 +1,40 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "github.com/sipeed/picoclaw/pkg/agent/interfaces" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// Pipeline holds the runtime dependencies used by Pipeline methods. +// It is constructed by runTurn via NewPipeline and passed to sub-methods +// so that the coordinator can delegate phase execution. +type Pipeline struct { + Bus interfaces.MessageBus + Cfg *config.Config + ContextManager ContextManager + Hooks *HookManager + Fallback *providers.FallbackChain + ChannelManager interfaces.ChannelManager + MediaStore media.MediaStore + Steering any // TODO: *Steering + al *AgentLoop +} + +// NewPipeline creates a Pipeline from an AgentLoop instance. +func NewPipeline(al *AgentLoop) *Pipeline { + return &Pipeline{ + Bus: al.bus, + Cfg: al.GetConfig(), + ContextManager: al.contextManager, + Hooks: al.hooks, + Fallback: al.fallback, + ChannelManager: al.channelManager, + MediaStore: al.mediaStore, + Steering: al.steering, + al: al, + } +} diff --git a/pkg/agent/pipeline_execute.go b/pkg/agent/pipeline_execute.go new file mode 100644 index 000000000..87254619c --- /dev/null +++ b/pkg/agent/pipeline_execute.go @@ -0,0 +1,712 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/constants" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/tools" + "github.com/sipeed/picoclaw/pkg/utils" +) + +// ExecuteTools executes the tool loop, handling BeforeTool/ApproveTool/AfterTool hooks, +// tool execution with async callbacks, media delivery, and steering injection. +// Returns ToolControl indicating what the coordinator should do next: +// - ToolControlContinue: all tool results handled, pendingMessages or steering exists, continue turn +// - ToolControlBreak: tool loop exited, proceed to coordinator's hardAbort/finalContent/finalize +func (p *Pipeline) ExecuteTools( + ctx context.Context, + turnCtx context.Context, + ts *turnState, + exec *turnExecution, + iteration int, +) ToolControl { + al := p.al + normalizedToolCalls := exec.normalizedToolCalls + + ts.setPhase(TurnPhaseTools) + messages := exec.messages + handledAttachments := make([]providers.Attachment, 0) + +toolLoop: + for i, tc := range normalizedToolCalls { + if ts.hardAbortRequested() { + exec.abortedByHardAbort = true + return ToolControlBreak + } + + toolName := tc.Name + toolArgs := cloneStringAnyMap(tc.Arguments) + + if al.hooks != nil { + toolReq, decision := al.hooks.BeforeTool(turnCtx, &ToolCallHookRequest{ + Meta: ts.eventMeta("runTurn", "turn.tool.before"), + Context: cloneTurnContext(ts.turnCtx), + Tool: toolName, + Arguments: toolArgs, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if toolReq != nil { + toolName = toolReq.Tool + toolArgs = toolReq.Arguments + } + case HookActionRespond: + if toolReq != nil && toolReq.HookResult != nil { + hookResult := toolReq.HookResult + + argsJSON, _ := json.Marshal(toolArgs) + argsPreview := utils.Truncate(string(argsJSON), 200) + logger.InfoCF("agent", fmt.Sprintf("Tool call (hook respond): %s(%s)", toolName, argsPreview), + map[string]any{ + "agent_id": ts.agent.ID, + "tool": toolName, + "iteration": iteration, + }) + + al.emitEvent( + EventKindToolExecStart, + ts.eventMeta("runTurn", "turn.tool.start"), + ToolExecStartPayload{ + Tool: toolName, + Arguments: cloneEventArguments(toolArgs), + }, + ) + + if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && + ts.channel != "" && + !ts.opts.SuppressToolFeedback { + argsJSON, _ := json.Marshal(toolArgs) + feedbackPreview := utils.Truncate( + string(argsJSON), + al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), + ) + feedbackMsg := utils.FormatToolFeedbackMessage(toolName, feedbackPreview) + fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) + _ = al.bus.PublishOutbound(fbCtx, bus.OutboundMessage{ + Channel: ts.channel, + ChatID: ts.chatID, + Content: feedbackMsg, + }) + fbCancel() + } + + toolDuration := time.Duration(0) + + shouldSendForUser := !hookResult.Silent && hookResult.ForUser != "" && + (ts.opts.SendResponse || hookResult.ResponseHandled) + if shouldSendForUser { + al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + Context: bus.InboundContext{ + Channel: ts.channel, + ChatID: ts.chatID, + Raw: map[string]string{ + "is_tool_call": "true", + }, + }, + Content: hookResult.ForUser, + }) + } + + if len(hookResult.Media) > 0 && hookResult.ResponseHandled { + parts := make([]bus.MediaPart, 0, len(hookResult.Media)) + for _, ref := range hookResult.Media { + part := bus.MediaPart{Ref: ref} + if al.mediaStore != nil { + if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { + part.Filename = meta.Filename + part.ContentType = meta.ContentType + part.Type = inferMediaType(meta.Filename, meta.ContentType) + } + } + parts = append(parts, part) + } + outboundMedia := bus.OutboundMediaMessage{ + Channel: ts.channel, + ChatID: ts.chatID, + Parts: parts, + } + if al.channelManager != nil && ts.channel != "" && !constants.IsInternalChannel(ts.channel) { + if err := al.channelManager.SendMedia(ctx, outboundMedia); err != nil { + logger.WarnCF("agent", "Failed to deliver hook media", + map[string]any{ + "agent_id": ts.agent.ID, + "tool": toolName, + "channel": ts.channel, + "chat_id": ts.chatID, + "error": err.Error(), + }) + hookResult.IsError = true + hookResult.ForLLM = fmt.Sprintf("failed to deliver attachment: %v", err) + } else { + handledAttachments = append( + handledAttachments, + buildProviderAttachments(al.mediaStore, hookResult.Media)..., + ) + } + } else if al.bus != nil { + al.bus.PublishOutboundMedia(ctx, outboundMedia) + hookResult.ResponseHandled = false + } + } + + if !hookResult.ResponseHandled { + exec.allResponsesHandled = false + } + + contentForLLM := hookResult.ContentForLLM() + if al.cfg.Tools.IsFilterSensitiveDataEnabled() { + contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) + } + + toolResultMsg := providers.Message{ + Role: "tool", + Content: contentForLLM, + ToolCallID: tc.ID, + } + + if len(hookResult.Media) > 0 && !hookResult.ResponseHandled { + hookResult.ArtifactTags = buildArtifactTags(al.mediaStore, hookResult.Media) + contentForLLM = hookResult.ContentForLLM() + if al.cfg.Tools.IsFilterSensitiveDataEnabled() { + contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) + } + toolResultMsg.Content = contentForLLM + toolResultMsg.Media = append(toolResultMsg.Media, hookResult.Media...) + } + + al.emitEvent( + EventKindToolExecEnd, + ts.eventMeta("runTurn", "turn.tool.end"), + ToolExecEndPayload{ + Tool: toolName, + Duration: toolDuration, + ForLLMLen: len(contentForLLM), + ForUserLen: len(hookResult.ForUser), + IsError: hookResult.IsError, + Async: hookResult.Async, + }, + ) + + messages = append(messages, toolResultMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg) + ts.recordPersistedMessage(toolResultMsg) + ts.ingestMessage(turnCtx, al, toolResultMsg) + } + + if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { + exec.pendingMessages = append(exec.pendingMessages, steerMsgs...) + } + + skipReason := "" + skipMessage := "" + if len(exec.pendingMessages) > 0 { + skipReason = "queued user steering message" + skipMessage = "Skipped due to queued user message." + } else if gracefulPending, _ := ts.gracefulInterruptRequested(); gracefulPending { + skipReason = "graceful interrupt requested" + skipMessage = "Skipped due to graceful interrupt." + } + + if skipReason != "" { + remaining := len(normalizedToolCalls) - i - 1 + if remaining > 0 { + logger.InfoCF("agent", "Turn checkpoint: skipping remaining tools after hook respond", + map[string]any{ + "agent_id": ts.agent.ID, + "completed": i + 1, + "skipped": remaining, + "reason": skipReason, + }) + for j := i + 1; j < len(normalizedToolCalls); j++ { + skippedTC := normalizedToolCalls[j] + al.emitEvent( + EventKindToolExecSkipped, + ts.eventMeta("runTurn", "turn.tool.skipped"), + ToolExecSkippedPayload{ + Tool: skippedTC.Name, + Reason: skipReason, + }, + ) + skippedMsg := providers.Message{ + Role: "tool", + Content: skipMessage, + ToolCallID: skippedTC.ID, + } + messages = append(messages, skippedMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, skippedMsg) + ts.recordPersistedMessage(skippedMsg) + } + } + } + break toolLoop + } + + if ts.pendingResults != nil { + select { + case result, ok := <-ts.pendingResults: + if ok && result != nil && result.ForLLM != "" { + content := al.cfg.FilterSensitiveData(result.ForLLM) + msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)} + messages = append(messages, msg) + ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) + } + default: + } + } + + continue + } + logger.WarnCF("agent", "Hook returned respond action but no HookResult provided", + map[string]any{ + "agent_id": ts.agent.ID, + "tool": toolName, + "action": "respond", + }) + case HookActionDenyTool: + exec.allResponsesHandled = false + denyContent := hookDeniedToolContent("Tool execution denied by hook", decision.Reason) + al.emitEvent( + EventKindToolExecSkipped, + ts.eventMeta("runTurn", "turn.tool.skipped"), + ToolExecSkippedPayload{ + Tool: toolName, + Reason: denyContent, + }, + ) + deniedMsg := providers.Message{ + Role: "tool", + Content: denyContent, + ToolCallID: tc.ID, + } + messages = append(messages, deniedMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, deniedMsg) + ts.recordPersistedMessage(deniedMsg) + } + continue + case HookActionAbortTurn: + exec.abortedByHook = true + return ToolControlBreak + case HookActionHardAbort: + _ = ts.requestHardAbort() + exec.abortedByHardAbort = true + return ToolControlBreak + } + } + + if al.hooks != nil { + approval := al.hooks.ApproveTool(turnCtx, &ToolApprovalRequest{ + Meta: ts.eventMeta("runTurn", "turn.tool.approve"), + Context: cloneTurnContext(ts.turnCtx), + Tool: toolName, + Arguments: toolArgs, + }) + if !approval.Approved { + exec.allResponsesHandled = false + denyContent := hookDeniedToolContent("Tool execution denied by approval hook", approval.Reason) + al.emitEvent( + EventKindToolExecSkipped, + ts.eventMeta("runTurn", "turn.tool.skipped"), + ToolExecSkippedPayload{ + Tool: toolName, + Reason: denyContent, + }, + ) + deniedMsg := providers.Message{ + Role: "tool", + Content: denyContent, + ToolCallID: tc.ID, + } + messages = append(messages, deniedMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, deniedMsg) + ts.recordPersistedMessage(deniedMsg) + } + continue + } + } + + argsJSON, _ := json.Marshal(toolArgs) + argsPreview := utils.Truncate(string(argsJSON), 200) + logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", toolName, argsPreview), + map[string]any{ + "agent_id": ts.agent.ID, + "tool": toolName, + "iteration": iteration, + }) + al.emitEvent( + EventKindToolExecStart, + ts.eventMeta("runTurn", "turn.tool.start"), + ToolExecStartPayload{ + Tool: toolName, + Arguments: cloneEventArguments(toolArgs), + }, + ) + + if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && + ts.channel != "" && + !ts.opts.SuppressToolFeedback { + feedbackPreview := utils.Truncate( + string(argsJSON), + al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), + ) + feedbackMsg := utils.FormatToolFeedbackMessage(tc.Name, feedbackPreview) + fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) + _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurn(ts, feedbackMsg)) + fbCancel() + } + + toolCallID := tc.ID + asyncToolName := toolName + asyncCallback := func(_ context.Context, result *tools.ToolResult) { + if !result.Silent && result.ForUser != "" { + outCtx, outCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer outCancel() + _ = al.bus.PublishOutbound(outCtx, outboundMessageForTurn(ts, result.ForUser)) + } + + content := result.ContentForLLM() + if content == "" { + return + } + + content = al.cfg.FilterSensitiveData(content) + + logger.InfoCF("agent", "Async tool completed, publishing result", + map[string]any{ + "tool": asyncToolName, + "content_len": len(content), + "channel": ts.channel, + }) + al.emitEvent( + EventKindFollowUpQueued, + ts.scope.meta(iteration, "runTurn", "turn.follow_up.queued"), + FollowUpQueuedPayload{ + SourceTool: asyncToolName, + ContentLen: len(content), + }, + ) + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + _ = al.bus.PublishInbound(pubCtx, bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "system", + ChatID: fmt.Sprintf("%s:%s", ts.channel, ts.chatID), + ChatType: "direct", + SenderID: fmt.Sprintf("async:%s", asyncToolName), + }, + Content: content, + }) + } + + toolStart := time.Now() + execCtx := tools.WithToolInboundContext( + turnCtx, + ts.channel, + ts.chatID, + ts.opts.Dispatch.MessageID(), + ts.opts.Dispatch.ReplyToMessageID(), + ) + execCtx = tools.WithToolSessionContext( + execCtx, + ts.agent.ID, + ts.sessionKey, + ts.opts.Dispatch.SessionScope, + ) + toolResult := ts.agent.Tools.ExecuteWithContext( + execCtx, + toolName, + toolArgs, + ts.channel, + ts.chatID, + asyncCallback, + ) + toolDuration := time.Since(toolStart) + + if ts.hardAbortRequested() { + exec.abortedByHardAbort = true + return ToolControlBreak + } + + if al.hooks != nil { + toolResp, decision := al.hooks.AfterTool(turnCtx, &ToolResultHookResponse{ + Meta: ts.eventMeta("runTurn", "turn.tool.after"), + Context: cloneTurnContext(ts.turnCtx), + Tool: toolName, + Arguments: toolArgs, + Result: toolResult, + Duration: toolDuration, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if toolResp != nil { + if toolResp.Tool != "" { + toolName = toolResp.Tool + } + if toolResp.Result != nil { + toolResult = toolResp.Result + } + } + case HookActionAbortTurn: + exec.abortedByHook = true + return ToolControlBreak + case HookActionHardAbort: + _ = ts.requestHardAbort() + exec.abortedByHardAbort = true + return ToolControlBreak + } + } + + if toolResult == nil { + toolResult = tools.ErrorResult("hook returned nil tool result") + } + + if len(toolResult.Media) > 0 && toolResult.ResponseHandled { + parts := make([]bus.MediaPart, 0, len(toolResult.Media)) + for _, ref := range toolResult.Media { + part := bus.MediaPart{Ref: ref} + if al.mediaStore != nil { + if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { + part.Filename = meta.Filename + part.ContentType = meta.ContentType + part.Type = inferMediaType(meta.Filename, meta.ContentType) + } + } + parts = append(parts, part) + } + outboundMedia := bus.OutboundMediaMessage{ + Channel: ts.channel, + ChatID: ts.chatID, + Context: outboundContextFromInbound( + ts.opts.Dispatch.InboundContext, + ts.channel, + ts.chatID, + ts.opts.Dispatch.ReplyToMessageID(), + ), + AgentID: ts.agent.ID, + SessionKey: ts.sessionKey, + Scope: outboundScopeFromSessionScope(ts.opts.Dispatch.SessionScope), + Parts: parts, + } + if al.channelManager != nil && ts.channel != "" && !constants.IsInternalChannel(ts.channel) { + if err := al.channelManager.SendMedia(ctx, outboundMedia); err != nil { + logger.WarnCF("agent", "Failed to deliver handled tool media", + map[string]any{ + "agent_id": ts.agent.ID, + "tool": toolName, + "channel": ts.channel, + "chat_id": ts.chatID, + "error": err.Error(), + }) + toolResult = tools.ErrorResult(fmt.Sprintf("failed to deliver attachment: %v", err)).WithError(err) + } else { + handledAttachments = append( + handledAttachments, + buildProviderAttachments(al.mediaStore, toolResult.Media)..., + ) + } + } else if al.bus != nil { + al.bus.PublishOutboundMedia(ctx, outboundMedia) + toolResult.ResponseHandled = false + } + } + + if len(toolResult.Media) > 0 && !toolResult.ResponseHandled { + toolResult.ArtifactTags = buildArtifactTags(al.mediaStore, toolResult.Media) + } + + if !toolResult.ResponseHandled { + exec.allResponsesHandled = false + } + + shouldSendForUser := !toolResult.Silent && + toolResult.ForUser != "" && + (ts.opts.SendResponse || toolResult.ResponseHandled) + if shouldSendForUser { + al.bus.PublishOutbound(ctx, outboundMessageForTurn(ts, toolResult.ForUser)) + logger.DebugCF("agent", "Sent tool result to user", + map[string]any{ + "tool": toolName, + "content_len": len(toolResult.ForUser), + }) + } + contentForLLM := toolResult.ContentForLLM() + + if al.cfg.Tools.IsFilterSensitiveDataEnabled() { + contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) + } + + toolResultMsg := providers.Message{ + Role: "tool", + Content: contentForLLM, + ToolCallID: toolCallID, + } + if len(toolResult.Media) > 0 && !toolResult.ResponseHandled { + toolResultMsg.Media = append(toolResultMsg.Media, toolResult.Media...) + } + al.emitEvent( + EventKindToolExecEnd, + ts.eventMeta("runTurn", "turn.tool.end"), + ToolExecEndPayload{ + Tool: toolName, + Duration: toolDuration, + ForLLMLen: len(contentForLLM), + ForUserLen: len(toolResult.ForUser), + IsError: toolResult.IsError, + Async: toolResult.Async, + }, + ) + messages = append(messages, toolResultMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg) + ts.recordPersistedMessage(toolResultMsg) + ts.ingestMessage(turnCtx, al, toolResultMsg) + } + + if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { + exec.pendingMessages = append(exec.pendingMessages, steerMsgs...) + } + + skipReason := "" + skipMessage := "" + if len(exec.pendingMessages) > 0 { + skipReason = "queued user steering message" + skipMessage = "Skipped due to queued user message." + } else if gracefulPending, _ := ts.gracefulInterruptRequested(); gracefulPending { + skipReason = "graceful interrupt requested" + skipMessage = "Skipped due to graceful interrupt." + } + + if skipReason != "" { + remaining := len(normalizedToolCalls) - i - 1 + if remaining > 0 { + logger.InfoCF("agent", "Turn checkpoint: skipping remaining tools", + map[string]any{ + "agent_id": ts.agent.ID, + "completed": i + 1, + "skipped": remaining, + "reason": skipReason, + }) + for j := i + 1; j < len(normalizedToolCalls); j++ { + skippedTC := normalizedToolCalls[j] + al.emitEvent( + EventKindToolExecSkipped, + ts.eventMeta("runTurn", "turn.tool.skipped"), + ToolExecSkippedPayload{ + Tool: skippedTC.Name, + Reason: skipReason, + }, + ) + skippedMsg := providers.Message{ + Role: "tool", + Content: skipMessage, + ToolCallID: skippedTC.ID, + } + messages = append(messages, skippedMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, skippedMsg) + ts.recordPersistedMessage(skippedMsg) + } + } + } + break toolLoop + } + + if ts.pendingResults != nil { + select { + case result, ok := <-ts.pendingResults: + if ok && result != nil && result.ForLLM != "" { + content := al.cfg.FilterSensitiveData(result.ForLLM) + msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)} + messages = append(messages, msg) + ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) + } + default: + } + } + } + + exec.messages = messages + + // Continue if pending steering exists (regardless of allResponsesHandled). + // This covers the case where tools were partially executed and skipped due to steering, + // but one tool had ResponseHandled=false (so allResponsesHandled=false). + if len(exec.pendingMessages) > 0 { + logger.InfoCF("agent", "Pending steering after partial tool execution; continuing turn", + map[string]any{ + "agent_id": ts.agent.ID, + "pending_count": len(exec.pendingMessages), + "allResponsesHandled": exec.allResponsesHandled, + }) + exec.allResponsesHandled = false + return ToolControlContinue + } + + // Poll for newly arrived steering + if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { + logger.InfoCF("agent", "Steering arrived after tool delivery; continuing turn", + map[string]any{ + "agent_id": ts.agent.ID, + "steering_count": len(steerMsgs), + }) + exec.pendingMessages = append(exec.pendingMessages, steerMsgs...) + exec.allResponsesHandled = false + return ToolControlContinue + } + + // No pending steering: finalize or break depending on allResponsesHandled + if exec.allResponsesHandled { + summaryMsg := providers.Message{ + Role: "assistant", + Content: handledToolResponseSummary, + Attachments: append([]providers.Attachment(nil), handledAttachments...), + } + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, summaryMsg) + ts.recordPersistedMessage(summaryMsg) + ts.ingestMessage(turnCtx, al, summaryMsg) + if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { + logger.WarnCF("agent", "Failed to save session after tool delivery", + map[string]any{ + "agent_id": ts.agent.ID, + "error": err.Error(), + }) + } + } + if ts.opts.EnableSummary { + al.contextManager.Compact(turnCtx, &CompactRequest{ + SessionKey: ts.sessionKey, + Reason: ContextCompressReasonSummarize, + Budget: ts.agent.ContextWindow, + }) + } + ts.setPhase(TurnPhaseCompleted) + ts.setFinalContent("") + logger.InfoCF("agent", "Tool output satisfied delivery; ending turn without follow-up LLM", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "tool_count": len(normalizedToolCalls), + }) + return ToolControlBreak + } + + // allResponsesHandled=false and no pending steering: continue so coordinator + // makes another LLM call. The tool result is in messages and the LLM will + // return it as finalContent in the next iteration. + ts.agent.Tools.TickTTL() + logger.DebugCF("agent", "TTL tick after tool execution", map[string]any{ + "agent_id": ts.agent.ID, "iteration": iteration, + }) + return ToolControlContinue +} diff --git a/pkg/agent/pipeline_finalize.go b/pkg/agent/pipeline_finalize.go new file mode 100644 index 000000000..43d44099a --- /dev/null +++ b/pkg/agent/pipeline_finalize.go @@ -0,0 +1,77 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// Finalize handles turn finalization, either: +// - Early return when allResponsesHandled=true (ExecuteTools already finalized) +// - Normal finalization for allResponsesHandled=false (sets finalContent, saves session, compact) +func (p *Pipeline) Finalize( + ctx context.Context, + turnCtx context.Context, + ts *turnState, + exec *turnExecution, + turnStatus TurnEndStatus, + finalContent string, +) (turnResult, error) { + al := p.al + + // When allResponsesHandled=true, ExecuteTools already finalized + // (added handledToolResponseSummary, saved session, set phase to Completed). + // But still check for hard abort - if requested, abort the turn. + if exec.allResponsesHandled { + if ts.hardAbortRequested() { + return al.abortTurn(ts) + } + ts.setPhase(TurnPhaseCompleted) + return turnResult{ + finalContent: finalContent, + status: turnStatus, + followUps: append([]bus.InboundMessage(nil), ts.followUps...), + }, nil + } + + ts.setPhase(TurnPhaseFinalizing) + ts.setFinalContent(finalContent) + if !ts.opts.NoHistory { + finalMsg := providers.Message{Role: "assistant", Content: finalContent} + ts.agent.Sessions.AddMessage(ts.sessionKey, finalMsg.Role, finalMsg.Content) + ts.recordPersistedMessage(finalMsg) + ts.ingestMessage(turnCtx, al, finalMsg) + if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { + al.emitEvent( + EventKindError, + ts.eventMeta("runTurn", "turn.error"), + ErrorPayload{ + Stage: "session_save", + Message: err.Error(), + }, + ) + return turnResult{status: TurnEndStatusError}, err + } + } + + if ts.opts.EnableSummary { + al.contextManager.Compact( + turnCtx, + &CompactRequest{ + SessionKey: ts.sessionKey, + Reason: ContextCompressReasonSummarize, + Budget: ts.agent.ContextWindow, + }, + ) + } + + ts.setPhase(TurnPhaseCompleted) + return turnResult{ + finalContent: finalContent, + status: turnStatus, + followUps: append([]bus.InboundMessage(nil), ts.followUps...), + }, nil +} diff --git a/pkg/agent/pipeline_llm.go b/pkg/agent/pipeline_llm.go new file mode 100644 index 000000000..c426c25c9 --- /dev/null +++ b/pkg/agent/pipeline_llm.go @@ -0,0 +1,525 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/constants" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// CallLLM performs an LLM call with fallback support, hook invocation, and retry logic. +// It handles PreLLM setup, the actual LLM invocation with retry, and AfterLLM processing. +// Returns Control indicating what the coordinator should do next. +func (p *Pipeline) CallLLM( + ctx context.Context, + turnCtx context.Context, + ts *turnState, + exec *turnExecution, + iteration int, +) (Control, error) { + al := p.al + maxMediaSize := p.Cfg.Agents.Defaults.GetMaxMediaSize() + + // PreLLM: resolve media refs (except on iteration 1 where user media is already resolved) + if iteration > 1 { + exec.messages = resolveMediaRefs(exec.messages, p.MediaStore, maxMediaSize) + } + + // PreLLM: graceful terminal handling + exec.gracefulTerminal, _ = ts.gracefulInterruptRequested() + exec.providerToolDefs = ts.agent.Tools.ToProviderDefs() + + // Native web search support + _, hasWebSearch := ts.agent.Tools.Get("web_search") + exec.useNativeSearch = al.cfg.Tools.Web.PreferNative && hasWebSearch && + func() bool { + if ns, ok := ts.agent.Provider.(interface{ SupportsNativeSearch() bool }); ok { + return ns.SupportsNativeSearch() + } + return false + }() + + if exec.useNativeSearch { + filtered := make([]providers.ToolDefinition, 0, len(exec.providerToolDefs)) + for _, td := range exec.providerToolDefs { + if td.Function.Name != "web_search" { + filtered = append(filtered, td) + } + } + exec.providerToolDefs = filtered + } + + exec.callMessages = exec.messages + if exec.gracefulTerminal { + exec.callMessages = append(append([]providers.Message(nil), exec.messages...), ts.interruptHintMessage()) + exec.providerToolDefs = nil + ts.markGracefulTerminalUsed() + } + + exec.llmOpts = map[string]any{ + "max_tokens": ts.agent.MaxTokens, + "temperature": ts.agent.Temperature, + "prompt_cache_key": ts.agent.ID, + } + if exec.useNativeSearch { + exec.llmOpts["native_search"] = true + } + if ts.agent.ThinkingLevel != ThinkingOff { + if tc, ok := ts.agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { + exec.llmOpts["thinking_level"] = string(ts.agent.ThinkingLevel) + } else { + logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring", + map[string]any{"agent_id": ts.agent.ID, "thinking_level": string(ts.agent.ThinkingLevel)}) + } + } + + exec.llmModel = exec.activeModel + + // BeforeLLM hook + if p.Hooks != nil { + llmReq, decision := p.Hooks.BeforeLLM(turnCtx, &LLMHookRequest{ + Meta: ts.eventMeta("runTurn", "turn.llm.request"), + Context: cloneTurnContext(ts.turnCtx), + Model: exec.llmModel, + Messages: exec.callMessages, + Tools: exec.providerToolDefs, + Options: exec.llmOpts, + GracefulTerminal: exec.gracefulTerminal, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if llmReq != nil { + exec.llmModel = llmReq.Model + exec.callMessages = llmReq.Messages + exec.providerToolDefs = llmReq.Tools + exec.llmOpts = llmReq.Options + } + case HookActionAbortTurn: + exec.abortedByHook = true + return ControlBreak, nil + case HookActionHardAbort: + _ = ts.requestHardAbort() + exec.abortedByHardAbort = true + return ControlBreak, nil + } + } + + al.emitEvent( + EventKindLLMRequest, + ts.eventMeta("runTurn", "turn.llm.request"), + LLMRequestPayload{ + Model: exec.llmModel, + MessagesCount: len(exec.callMessages), + ToolsCount: len(exec.providerToolDefs), + MaxTokens: ts.agent.MaxTokens, + Temperature: ts.agent.Temperature, + }, + ) + + logger.DebugCF("agent", "LLM request", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "model": exec.llmModel, + "messages_count": len(exec.callMessages), + "tools_count": len(exec.providerToolDefs), + "max_tokens": ts.agent.MaxTokens, + "temperature": ts.agent.Temperature, + "system_prompt_len": len(exec.callMessages[0].Content), + }) + logger.DebugCF("agent", "Full LLM request", + map[string]any{ + "iteration": iteration, + "messages_json": formatMessagesForLog(exec.callMessages), + "tools_json": formatToolsForLog(exec.providerToolDefs), + }) + + // LLM call closure with fallback support + callLLM := func(messagesForCall []providers.Message, toolDefsForCall []providers.ToolDefinition) (*providers.LLMResponse, error) { + providerCtx, providerCancel := context.WithCancel(turnCtx) + ts.setProviderCancel(providerCancel) + defer func() { + providerCancel() + ts.clearProviderCancel(providerCancel) + }() + + al.activeRequests.Add(1) + defer al.activeRequests.Done() + + if len(exec.activeCandidates) > 1 && p.Fallback != nil { + fbResult, fbErr := p.Fallback.Execute( + providerCtx, + exec.activeCandidates, + func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { + candidateProvider := exec.activeProvider + if cp, ok := ts.agent.CandidateProviders[providers.ModelKey(provider, model)]; ok { + candidateProvider = cp + } + return candidateProvider.Chat(ctx, messagesForCall, toolDefsForCall, model, exec.llmOpts) + }, + ) + if fbErr != nil { + return nil, fbErr + } + if fbResult.Provider != "" && len(fbResult.Attempts) > 0 { + logger.InfoCF( + "agent", + fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts", + fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1), + map[string]any{"agent_id": ts.agent.ID, "iteration": iteration}, + ) + } + return fbResult.Response, nil + } + return exec.activeProvider.Chat(providerCtx, messagesForCall, toolDefsForCall, exec.llmModel, exec.llmOpts) + } + + // Retry loop + var err error + maxRetries := 2 + for retry := 0; retry <= maxRetries; retry++ { + exec.response, err = callLLM(exec.callMessages, exec.providerToolDefs) + if err == nil { + break + } + if ts.hardAbortRequested() && errors.Is(err, context.Canceled) { + _ = ts.requestHardAbort() + exec.abortedByHardAbort = true + return ControlBreak, nil + } + + // Retry without media if vision is unsupported + if hasMediaRefs(exec.callMessages) && isVisionUnsupportedError(err) && retry < maxRetries { + al.emitEvent( + EventKindLLMRetry, + ts.eventMeta("runTurn", "turn.llm.retry"), + LLMRetryPayload{ + Attempt: retry + 1, + MaxRetries: maxRetries, + Reason: "vision_unsupported", + Error: err.Error(), + Backoff: 0, + }, + ) + logger.WarnCF("agent", "Vision unsupported, retrying without media", map[string]any{ + "error": err.Error(), + "retry": retry, + }) + exec.callMessages = stripMessageMedia(exec.callMessages) + if !ts.opts.NoHistory { + exec.history = stripMessageMedia(exec.history) + ts.agent.Sessions.SetHistory(ts.sessionKey, exec.history) + for i := range ts.persistedMessages { + ts.persistedMessages[i].Media = nil + } + ts.refreshRestorePointFromSession(ts.agent) + } + continue + } + + errMsg := strings.ToLower(err.Error()) + isTimeoutError := errors.Is(err, context.DeadlineExceeded) || + strings.Contains(errMsg, "deadline exceeded") || + strings.Contains(errMsg, "client.timeout") || + strings.Contains(errMsg, "timed out") || + strings.Contains(errMsg, "timeout exceeded") + + isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") || + strings.Contains(errMsg, "context window") || + strings.Contains(errMsg, "context_window") || + strings.Contains(errMsg, "maximum context length") || + strings.Contains(errMsg, "token limit") || + strings.Contains(errMsg, "too many tokens") || + strings.Contains(errMsg, "max_tokens") || + strings.Contains(errMsg, "invalidparameter") || + strings.Contains(errMsg, "prompt is too long") || + strings.Contains(errMsg, "request too large")) + + if isTimeoutError && retry < maxRetries { + backoff := time.Duration(retry+1) * 5 * time.Second + al.emitEvent( + EventKindLLMRetry, + ts.eventMeta("runTurn", "turn.llm.retry"), + LLMRetryPayload{ + Attempt: retry + 1, + MaxRetries: maxRetries, + Reason: "timeout", + Error: err.Error(), + Backoff: backoff, + }, + ) + logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{ + "error": err.Error(), + "retry": retry, + "backoff": backoff.String(), + }) + if sleepErr := sleepWithContext(turnCtx, backoff); sleepErr != nil { + if ts.hardAbortRequested() { + _ = ts.requestHardAbort() + return ControlBreak, nil + } + err = sleepErr + break + } + continue + } + + if isContextError && retry < maxRetries && !ts.opts.NoHistory { + al.emitEvent( + EventKindLLMRetry, + ts.eventMeta("runTurn", "turn.llm.retry"), + LLMRetryPayload{ + Attempt: retry + 1, + MaxRetries: maxRetries, + Reason: "context_limit", + Error: err.Error(), + }, + ) + logger.WarnCF( + "agent", + "Context window error detected, attempting compression", + map[string]any{ + "error": err.Error(), + "retry": retry, + }, + ) + + if retry == 0 && !constants.IsInternalChannel(ts.channel) { + al.bus.PublishOutbound(ctx, outboundMessageForTurn( + ts, + "Context window exceeded. Compressing history and retrying...", + )) + } + + if compactErr := p.ContextManager.Compact(ctx, &CompactRequest{ + SessionKey: ts.sessionKey, + Reason: ContextCompressReasonRetry, + Budget: ts.agent.ContextWindow, + }); compactErr != nil { + logger.WarnCF("agent", "Context overflow compact failed", map[string]any{ + "session_key": ts.sessionKey, + "error": compactErr.Error(), + }) + } + ts.refreshRestorePointFromSession(ts.agent) + if asmResp, asmErr := p.ContextManager.Assemble(ctx, &AssembleRequest{ + SessionKey: ts.sessionKey, + Budget: ts.agent.ContextWindow, + MaxTokens: ts.agent.MaxTokens, + }); asmErr == nil && asmResp != nil { + exec.history = asmResp.History + exec.summary = asmResp.Summary + } + exec.messages = ts.agent.ContextBuilder.BuildMessages( + exec.history, exec.summary, "", + nil, ts.channel, ts.chatID, ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName, + activeSkillNames(ts.agent, ts.opts)..., + ) + exec.callMessages = exec.messages + if exec.gracefulTerminal { + msgs := append([]providers.Message(nil), exec.messages...) + exec.callMessages = append(msgs, ts.interruptHintMessage()) + } + continue + } + break + } + + if err != nil { + al.emitEvent( + EventKindError, + ts.eventMeta("runTurn", "turn.error"), + ErrorPayload{ + Stage: "llm", + Message: err.Error(), + }, + ) + logger.ErrorCF("agent", "LLM call failed", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "model": exec.llmModel, + "error": err.Error(), + }) + return ControlBreak, fmt.Errorf("LLM call failed after retries: %w", err) + } + + // AfterLLM hook + if p.Hooks != nil { + llmResp, decision := p.Hooks.AfterLLM(turnCtx, &LLMHookResponse{ + Meta: ts.eventMeta("runTurn", "turn.llm.response"), + Context: cloneTurnContext(ts.turnCtx), + Model: exec.llmModel, + Response: exec.response, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if llmResp != nil && llmResp.Response != nil { + exec.response = llmResp.Response + } + case HookActionAbortTurn: + exec.abortedByHook = true + return ControlBreak, nil + case HookActionHardAbort: + _ = ts.requestHardAbort() + exec.abortedByHardAbort = true + return ControlBreak, nil + } + } + + // Save finishReason to turnState for SubTurn truncation detection + if innerTS := turnStateFromContext(ctx); innerTS != nil { + innerTS.SetLastFinishReason(exec.response.FinishReason) + if exec.response.Usage != nil { + innerTS.SetLastUsage(exec.response.Usage) + } + } + + reasoningContent := exec.response.Reasoning + if reasoningContent == "" { + reasoningContent = exec.response.ReasoningContent + } + if ts.channel == "pico" { + go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID) + } else { + go al.handleReasoning( + turnCtx, + reasoningContent, + ts.channel, + al.targetReasoningChannelID(ts.channel), + ) + } + al.emitEvent( + EventKindLLMResponse, + ts.eventMeta("runTurn", "turn.llm.response"), + LLMResponsePayload{ + ContentLen: len(exec.response.Content), + ToolCalls: len(exec.response.ToolCalls), + HasReasoning: exec.response.Reasoning != "" || exec.response.ReasoningContent != "", + }, + ) + + llmResponseFields := map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "content_chars": len(exec.response.Content), + "tool_calls": len(exec.response.ToolCalls), + "reasoning": exec.response.Reasoning, + "target_channel": al.targetReasoningChannelID(ts.channel), + "channel": ts.channel, + } + if exec.response.Usage != nil { + llmResponseFields["prompt_tokens"] = exec.response.Usage.PromptTokens + llmResponseFields["completion_tokens"] = exec.response.Usage.CompletionTokens + llmResponseFields["total_tokens"] = exec.response.Usage.TotalTokens + } + logger.DebugCF("agent", "LLM response", llmResponseFields) + + if al.bus != nil && ts.channel == "pico" && len(exec.response.ToolCalls) > 0 && ts.opts.AllowInterimPicoPublish { + if strings.TrimSpace(exec.response.Content) != "" { + outCtx, outCancel := context.WithTimeout(turnCtx, 3*time.Second) + publishErr := al.bus.PublishOutbound(outCtx, bus.OutboundMessage{ + Channel: ts.channel, + ChatID: ts.chatID, + Content: exec.response.Content, + }) + outCancel() + if publishErr != nil { + logger.WarnCF("agent", "Failed to publish pico interim tool-call content", map[string]any{ + "error": publishErr.Error(), + "channel": ts.channel, + "chat_id": ts.chatID, + "iteration": iteration, + }) + } + } + } + + // No-tool-call path: steering check and direct response + if len(exec.response.ToolCalls) == 0 || exec.gracefulTerminal { + responseContent := exec.response.Content + if responseContent == "" && exec.response.ReasoningContent != "" && ts.channel != "pico" { + responseContent = exec.response.ReasoningContent + } + if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { + logger.InfoCF("agent", "Steering arrived after direct LLM response; continuing turn", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "steering_count": len(steerMsgs), + }) + exec.pendingMessages = append(exec.pendingMessages, steerMsgs...) + return ControlContinue, nil + } + exec.finalContent = responseContent + logger.InfoCF("agent", "LLM response without tool calls (direct answer)", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "content_chars": len(exec.finalContent), + }) + return ControlBreak, nil + } + + // Tool-call path: normalize and prepare for tool execution + exec.normalizedToolCalls = make([]providers.ToolCall, 0, len(exec.response.ToolCalls)) + for _, tc := range exec.response.ToolCalls { + exec.normalizedToolCalls = append(exec.normalizedToolCalls, providers.NormalizeToolCall(tc)) + } + + toolNames := make([]string, 0, len(exec.normalizedToolCalls)) + for _, tc := range exec.normalizedToolCalls { + toolNames = append(toolNames, tc.Name) + } + logger.InfoCF("agent", "LLM requested tool calls", + map[string]any{ + "agent_id": ts.agent.ID, + "tools": toolNames, + "count": len(exec.normalizedToolCalls), + "iteration": iteration, + }) + + exec.allResponsesHandled = len(exec.normalizedToolCalls) > 0 + assistantMsg := providers.Message{ + Role: "assistant", + Content: exec.response.Content, + ReasoningContent: exec.response.ReasoningContent, + } + for _, tc := range exec.normalizedToolCalls { + argumentsJSON, _ := json.Marshal(tc.Arguments) + extraContent := tc.ExtraContent + thoughtSignature := "" + if tc.Function != nil { + thoughtSignature = tc.Function.ThoughtSignature + } + assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ + ID: tc.ID, + Type: "function", + Name: tc.Name, + Function: &providers.FunctionCall{ + Name: tc.Name, + Arguments: string(argumentsJSON), + ThoughtSignature: thoughtSignature, + }, + ExtraContent: extraContent, + ThoughtSignature: thoughtSignature, + }) + } + exec.messages = append(exec.messages, assistantMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, assistantMsg) + ts.recordPersistedMessage(assistantMsg) + ts.ingestMessage(turnCtx, al, assistantMsg) + } + + return ControlToolLoop, nil +} diff --git a/pkg/agent/pipeline_setup.go b/pkg/agent/pipeline_setup.go new file mode 100644 index 000000000..e6ead1012 --- /dev/null +++ b/pkg/agent/pipeline_setup.go @@ -0,0 +1,116 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "strings" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// SetupTurn extracts the one-time initialization phase, returning a +// turnExecution populated with history, messages, and candidate selection. +// It replaces lines 56-145 of the original runTurn. +func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution, error) { + cfg := p.Cfg + maxMediaSize := cfg.Agents.Defaults.GetMaxMediaSize() + + var history []providers.Message + var summary string + if !ts.opts.NoHistory { + if resp, err := p.ContextManager.Assemble(ctx, &AssembleRequest{ + SessionKey: ts.sessionKey, + Budget: ts.agent.ContextWindow, + MaxTokens: ts.agent.MaxTokens, + }); err == nil && resp != nil { + history = resp.History + summary = resp.Summary + } + } + ts.captureRestorePoint(history, summary) + + messages := ts.agent.ContextBuilder.BuildMessages( + history, + summary, + ts.userMessage, + ts.media, + ts.channel, + ts.chatID, + ts.opts.Dispatch.SenderID(), + ts.opts.SenderDisplayName, + activeSkillNames(ts.agent, ts.opts)..., + ) + + messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize) + + if !ts.opts.NoHistory { + toolDefs := ts.agent.Tools.ToProviderDefs() + if isOverContextBudget(ts.agent.ContextWindow, messages, toolDefs, ts.agent.MaxTokens) { + logger.WarnCF("agent", "Proactive compression: context budget exceeded before LLM call", + map[string]any{"session_key": ts.sessionKey}) + if err := p.ContextManager.Compact(ctx, &CompactRequest{ + SessionKey: ts.sessionKey, + Reason: ContextCompressReasonProactive, + Budget: ts.agent.ContextWindow, + }); err != nil { + logger.WarnCF("agent", "Proactive compact failed", map[string]any{ + "session_key": ts.sessionKey, + "error": err.Error(), + }) + } + ts.refreshRestorePointFromSession(ts.agent) + if resp, err := p.ContextManager.Assemble(ctx, &AssembleRequest{ + SessionKey: ts.sessionKey, + Budget: ts.agent.ContextWindow, + MaxTokens: ts.agent.MaxTokens, + }); err == nil && resp != nil { + history = resp.History + summary = resp.Summary + } + messages = ts.agent.ContextBuilder.BuildMessages( + history, summary, ts.userMessage, + ts.media, ts.channel, ts.chatID, + ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName, + activeSkillNames(ts.agent, ts.opts)..., + ) + messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize) + } + } + + if !ts.opts.NoHistory && (strings.TrimSpace(ts.userMessage) != "" || len(ts.media) > 0) { + rootMsg := providers.Message{ + Role: "user", + Content: ts.userMessage, + Media: append([]string(nil), ts.media...), + } + if len(rootMsg.Media) > 0 { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, rootMsg) + } else { + ts.agent.Sessions.AddMessage(ts.sessionKey, rootMsg.Role, rootMsg.Content) + } + ts.recordPersistedMessage(rootMsg) + ts.ingestMessage(ctx, p.al, rootMsg) + } + + activeCandidates, activeModel, usedLight := p.al.selectCandidates(ts.agent, ts.userMessage, messages) + activeProvider := ts.agent.Provider + if usedLight && ts.agent.LightProvider != nil { + activeProvider = ts.agent.LightProvider + } + + exec := newTurnExecution( + ts.agent, + ts.opts, + history, + summary, + messages, + ) + exec.activeCandidates = activeCandidates + exec.activeModel = activeModel + exec.activeProvider = activeProvider + exec.usedLight = usedLight + + return exec, nil +} diff --git a/pkg/agent/subturn.go b/pkg/agent/subturn.go index cd193017b..a65467dbb 100644 --- a/pkg/agent/subturn.go +++ b/pkg/agent/subturn.go @@ -462,7 +462,8 @@ func spawnSubTurn( }() // 8. Execute sub-turn via the real agent loop. - turnRes, turnErr := al.runTurn(childCtx, childTS) + pipeline := NewPipeline(al) + turnRes, turnErr := al.runTurn(childCtx, childTS, pipeline) // Release the concurrency semaphore immediately after runTurn completes, // before the cleanup defer runs. This prevents a deadlock where: diff --git a/pkg/agent/turn_coord.go b/pkg/agent/turn_coord.go new file mode 100644 index 000000000..4c8335933 --- /dev/null +++ b/pkg/agent/turn_coord.go @@ -0,0 +1,624 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipeline) (turnResult, error) { + turnCtx, turnCancel := context.WithCancel(ctx) + defer turnCancel() + ts.setTurnCancel(turnCancel) + + // Inject turnState and AgentLoop into context so tools (e.g. spawn) can retrieve them. + turnCtx = withTurnState(turnCtx, ts) + turnCtx = WithAgentLoop(turnCtx, al) + + al.registerActiveTurn(ts) + defer al.clearActiveTurn(ts) + + turnStatus := TurnEndStatusCompleted + defer func() { + al.emitEvent( + EventKindTurnEnd, + ts.eventMeta("runTurn", "turn.end"), + TurnEndPayload{ + Status: turnStatus, + Iterations: ts.currentIteration(), + Duration: time.Since(ts.startedAt), + FinalContentLen: ts.finalContentLen(), + }, + ) + }() + + al.emitEvent( + EventKindTurnStart, + ts.eventMeta("runTurn", "turn.start"), + TurnStartPayload{ + UserMessage: ts.userMessage, + MediaCount: len(ts.media), + }, + ) + + // SetupTurn extracts the one-time initialization phase. + exec, err := pipeline.SetupTurn(turnCtx, ts) + if err != nil { + return turnResult{}, err + } + + // Convenience references to exec fields used throughout the turn loop. + messages := exec.messages + pendingMessages := exec.pendingMessages + maxMediaSize := pipeline.Cfg.Agents.Defaults.GetMaxMediaSize() + finalContent := exec.finalContent + + for ts.currentIteration() < ts.agent.MaxIterations || len(exec.pendingMessages) > 0 || func() bool { + graceful, _ := ts.gracefulInterruptRequested() + return graceful + }() { + if ts.hardAbortRequested() { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + + iteration := ts.currentIteration() + 1 + ts.setIteration(iteration) + ts.setPhase(TurnPhaseRunning) + + if iteration > 1 { + // For subsequent iterations, read from exec.pendingMessages which + // is where ExecuteTools (or initial poll) deposits steering. + // We do NOT call dequeueSteeringMessagesForScope here because + // steering was already consumed from al.steering by ExecuteTools. + if len(exec.pendingMessages) > 0 { + pendingMessages = append(pendingMessages, exec.pendingMessages...) + exec.pendingMessages = nil + } + } else if !ts.opts.SkipInitialSteeringPoll { + if steerMsgs := al.dequeueSteeringMessagesForScopeWithFallback(ts.sessionKey); len(steerMsgs) > 0 { + pendingMessages = append(pendingMessages, steerMsgs...) + } + } + + // Check if parent turn has ended (SubTurn support from HEAD) + if ts.parentTurnState != nil && ts.IsParentEnded() { + if !ts.critical { + logger.InfoCF("agent", "Parent turn ended, non-critical SubTurn exiting gracefully", map[string]any{ + "agent_id": ts.agentID, + "iteration": iteration, + "turn_id": ts.turnID, + }) + break + } + logger.InfoCF("agent", "Parent turn ended, critical SubTurn continues running", map[string]any{ + "agent_id": ts.agentID, + "iteration": iteration, + "turn_id": ts.turnID, + }) + } + + // Poll for pending SubTurn results (from HEAD) + if ts.pendingResults != nil { + select { + case result, ok := <-ts.pendingResults: + if ok && result != nil && result.ForLLM != "" { + content := al.cfg.FilterSensitiveData(result.ForLLM) + msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)} + pendingMessages = append(pendingMessages, msg) + } + default: + // No results available + } + } + + // Inject pending steering messages + if len(pendingMessages) > 0 { + resolvedPending := resolveMediaRefs(pendingMessages, al.mediaStore, maxMediaSize) + totalContentLen := 0 + for i, pm := range pendingMessages { + messages = append(messages, resolvedPending[i]) + totalContentLen += len(pm.Content) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, pm) + ts.recordPersistedMessage(pm) + ts.ingestMessage(turnCtx, al, pm) + } + logger.InfoCF("agent", "Injected steering message into context", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "content_len": len(pm.Content), + "media_count": len(pm.Media), + }) + } + al.emitEvent( + EventKindSteeringInjected, + ts.eventMeta("runTurn", "turn.steering.injected"), + SteeringInjectedPayload{ + Count: len(pendingMessages), + TotalContentLen: totalContentLen, + }, + ) + // Clear exec.pendingMessages after injection so InitialSteeringMessages + // are not re-injected on subsequent iterations (Issue 2 fix). + exec.pendingMessages = nil + } + // Always sync messages into exec.messages so CallLLM sees the updated state + exec.messages = messages + + logger.DebugCF("agent", "LLM iteration", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "max": ts.agent.MaxIterations, + }) + + // Execute LLM call via Pipeline + ts.setPhase(TurnPhaseRunning) + ctrl, callErr := pipeline.CallLLM(ctx, turnCtx, ts, exec, iteration) + if callErr != nil { + turnStatus = TurnEndStatusError + return turnResult{}, callErr + } + messages = exec.messages + pendingMessages = exec.pendingMessages + finalContent = exec.finalContent + + switch ctrl { + case ControlContinue: + continue + case ControlBreak: + // Hard abort: delegate to abortTurn (sets TurnEndStatusAborted) + if exec.abortedByHardAbort { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + // Hook abort (HookActionAbortTurn): sets TurnEndStatusError, returns error + if exec.abortedByHook { + turnStatus = TurnEndStatusError + return turnResult{}, fmt.Errorf("hook requested turn abort") + } + // Ensure empty response falls back to DefaultResponse + if finalContent == "" { + finalContent = ts.opts.DefaultResponse + } + return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent) + case ControlToolLoop: + // Execute tools via Pipeline + toolCtrl := pipeline.ExecuteTools(ctx, turnCtx, ts, exec, iteration) + switch toolCtrl { + case ToolControlContinue: + // Re-read exec.messages since ExecuteTools may have updated it + // (added tool results/skipped messages) before returning ControlContinue + messages = exec.messages + continue + case ToolControlBreak: + // Hard abort: delegate to abortTurn (sets TurnEndStatusAborted) + if exec.abortedByHardAbort { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + // Hook abort (HookActionAbortTurn): sets TurnEndStatusError, returns error + if exec.abortedByHook { + turnStatus = TurnEndStatusError + return turnResult{}, fmt.Errorf("hook requested turn abort") + } + // ExecuteTools returned ControlBreak: + // - allResponsesHandled=true: finalize without DefaultResponse (exec.finalContent empty) + // - allResponsesHandled=false: coordinator applies DefaultResponse before finalize + if exec.allResponsesHandled { + finalContent = "" + } + return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent) + } + } + } + + if ts.hardAbortRequested() { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + + if finalContent == "" { + if ts.currentIteration() >= ts.agent.MaxIterations && ts.agent.MaxIterations > 0 { + finalContent = toolLimitResponse + } else { + finalContent = ts.opts.DefaultResponse + } + } + + // Check hard abort before finalizing (may have been set during tool execution) + if ts.hardAbortRequested() { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + + return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent) +} + +func (al *AgentLoop) abortTurn(ts *turnState) (turnResult, error) { + ts.setPhase(TurnPhaseAborted) + if !ts.opts.NoHistory { + if err := ts.restoreSession(ts.agent); err != nil { + al.emitEvent( + EventKindError, + ts.eventMeta("abortTurn", "turn.error"), + ErrorPayload{ + Stage: "session_restore", + Message: err.Error(), + }, + ) + return turnResult{}, err + } + } + return turnResult{status: TurnEndStatusAborted}, nil +} + +func (al *AgentLoop) selectCandidates( + agent *AgentInstance, + userMsg string, + history []providers.Message, +) (candidates []providers.FallbackCandidate, model string, usedLight bool) { + if agent.Router == nil || len(agent.LightCandidates) == 0 { + return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), false + } + + _, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model) + if !usedLight { + logger.DebugCF("agent", "Model routing: primary model selected", + map[string]any{ + "agent_id": agent.ID, + "score": score, + "threshold": agent.Router.Threshold(), + }) + return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), false + } + + logger.InfoCF("agent", "Model routing: light model selected", + map[string]any{ + "agent_id": agent.ID, + "light_model": agent.Router.LightModel(), + "score": score, + "threshold": agent.Router.Threshold(), + }) + return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel()), true +} + +func (al *AgentLoop) resolveContextManager() ContextManager { + name := al.cfg.Agents.Defaults.ContextManager + if name == "" || name == "legacy" { + return &legacyContextManager{al: al} + } + factory, ok := lookupContextManager(name) + if !ok { + logger.WarnCF("agent", "Unknown context manager, falling back to legacy", map[string]any{ + "name": name, + }) + return &legacyContextManager{al: al} + } + cm, err := factory(al.cfg.Agents.Defaults.ContextManagerConfig, al) + if err != nil { + logger.WarnCF("agent", "Failed to create context manager, falling back to legacy", map[string]any{ + "name": name, + "error": err.Error(), + }) + return &legacyContextManager{al: al} + } + return cm +} + +func (al *AgentLoop) askSideQuestion( + ctx context.Context, + agent *AgentInstance, + opts *processOptions, + question string, +) (string, error) { + if agent == nil { + return "", fmt.Errorf("askSideQuestion: no agent available for /btw") + } + + question = strings.TrimSpace(question) + if question == "" { + return "", fmt.Errorf("askSideQuestion: %w", fmt.Errorf("Usage: /btw ")) + } + + if opts != nil { + normalizeProcessOptionsInPlace(opts) + } + + var media []string + var channel, chatID, senderID, senderDisplayName string + if opts != nil { + media = opts.Media + channel = opts.Channel + chatID = opts.ChatID + senderID = opts.SenderID + senderDisplayName = opts.SenderDisplayName + } + + // Build messages with context but WITHOUT adding to session history + var history []providers.Message + var summary string + if opts != nil && !opts.NoHistory { + if resp, err := al.contextManager.Assemble(ctx, &AssembleRequest{ + SessionKey: opts.SessionKey, + Budget: agent.ContextWindow, + MaxTokens: agent.MaxTokens, + }); err == nil && resp != nil { + history = resp.History + summary = resp.Summary + } + } + + messages := agent.ContextBuilder.BuildMessages( + history, + summary, + question, + media, + channel, + chatID, + senderID, + senderDisplayName, + ) + + maxMediaSize := al.GetConfig().Agents.Defaults.GetMaxMediaSize() + messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) + + activeCandidates, activeModel, usedLight := al.selectCandidates(agent, question, messages) + selectedModelName := sideQuestionModelName(agent, usedLight) + + llmOpts := map[string]any{ + "max_tokens": agent.MaxTokens, + "temperature": agent.Temperature, + "prompt_cache_key": agent.ID + ":btw", + } + + hookModelChanged := false + callProvider := func( + ctx context.Context, + candidate providers.FallbackCandidate, + model string, + forceModel bool, + callMessages []providers.Message, + ) (*providers.LLMResponse, error) { + provider, providerModel, cleanup, err := al.isolatedSideQuestionProvider(agent, selectedModelName, candidate) + if err != nil { + return nil, err + } + defer cleanup() + if !forceModel || strings.TrimSpace(model) == "" { + model = providerModel + } + callOpts := llmOpts + if _, exists := callOpts["thinking_level"]; !exists && agent.ThinkingLevel != ThinkingOff { + if tc, ok := provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { + callOpts = shallowCloneLLMOptions(llmOpts) + callOpts["thinking_level"] = string(agent.ThinkingLevel) + } + } + return provider.Chat(ctx, callMessages, nil, model, callOpts) + } + + turnCtx := newTurnContext(nil, nil, nil) + if opts != nil { + turnCtx = newTurnContext(opts.Dispatch.InboundContext, opts.Dispatch.RouteResult, opts.Dispatch.SessionScope) + } + llmModel := activeModel + if al.hooks != nil { + llmReq, decision := al.hooks.BeforeLLM(ctx, &LLMHookRequest{ + Meta: EventMeta{ + Source: "askSideQuestion", + TracePath: "turn.llm.request", + turnContext: cloneTurnContext(turnCtx), + }, + Context: cloneTurnContext(turnCtx), + Model: llmModel, + Messages: messages, + Tools: nil, + Options: llmOpts, + GracefulTerminal: false, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if llmReq != nil { + if strings.TrimSpace(llmReq.Model) != "" && llmReq.Model != llmModel { + hookModelChanged = true + } + llmModel = llmReq.Model + messages = llmReq.Messages + llmOpts = llmReq.Options + } + case HookActionAbortTurn: + reason := decision.Reason + if reason == "" { + reason = "hook requested turn abort" + } + return "", fmt.Errorf("hook aborted turn during before_llm: %s", reason) + case HookActionHardAbort: + reason := decision.Reason + if reason == "" { + reason = "hook requested turn abort" + } + return "", fmt.Errorf("hook aborted turn during before_llm: %s", reason) + } + } + if hookModelChanged { + // Hook-selected models must not continue through the pre-hook fallback + // candidate list, otherwise fallback execution would call the original + // candidate model and silently ignore the hook decision. + activeCandidates = nil + } + + callSideLLM := func(callMessages []providers.Message) (*providers.LLMResponse, error) { + if len(activeCandidates) > 1 && al.fallback != nil { + fbResult, err := al.fallback.Execute( + ctx, + activeCandidates, + func(ctx context.Context, providerName, model string) (*providers.LLMResponse, error) { + candidate := providers.FallbackCandidate{Provider: providerName, Model: model} + for _, activeCandidate := range activeCandidates { + if activeCandidate.Provider == providerName && activeCandidate.Model == model { + candidate = activeCandidate + break + } + } + return callProvider(ctx, candidate, model, false, callMessages) + }, + ) + if err != nil { + return nil, err + } + return fbResult.Response, nil + } + + var candidate providers.FallbackCandidate + if len(activeCandidates) > 0 { + candidate = activeCandidates[0] + } + return callProvider(ctx, candidate, llmModel, hookModelChanged, callMessages) + } + + // Retry without media if vision is unsupported + // Note: Vision retry is only applied to the initial call. If fallback chain + // is used, vision errors from fallback providers will not trigger retry. + var resp *providers.LLMResponse + var err error + resp, err = callSideLLM(messages) + if err != nil && hasMediaRefs(messages) && isVisionUnsupportedError(err) { + al.emitEvent( + EventKindLLMRetry, + EventMeta{ + Source: "askSideQuestion", + TracePath: "turn.llm.retry", + turnContext: cloneTurnContext(turnCtx), + }, + LLMRetryPayload{ + Attempt: 1, + MaxRetries: 1, + Reason: "vision_unsupported", + Error: err.Error(), + Backoff: 0, + }, + ) + messagesWithoutMedia := stripMessageMedia(messages) + resp, err = callSideLLM(messagesWithoutMedia) + } + if err != nil { + return "", err + } + if resp == nil { + return "", nil + } + + // Apply after_llm hooks + if al.hooks != nil { + llmResp, decision := al.hooks.AfterLLM(ctx, &LLMHookResponse{ + Meta: EventMeta{ + Source: "askSideQuestion", + TracePath: "turn.llm.response", + turnContext: cloneTurnContext(turnCtx), + }, + Context: cloneTurnContext(turnCtx), + Model: llmModel, + Response: resp, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if llmResp != nil && llmResp.Response != nil { + resp = llmResp.Response + } + case HookActionAbortTurn, HookActionHardAbort: + reason := decision.Reason + if reason == "" { + reason = "hook requested turn abort" + } + return "", fmt.Errorf("hook aborted turn during after_llm: %s", reason) + } + } + + return sideQuestionResponseContent(resp), nil +} + +func (al *AgentLoop) isolatedSideQuestionProvider( + agent *AgentInstance, + baseModelName string, + candidate providers.FallbackCandidate, +) (providers.LLMProvider, string, func(), error) { + if agent == nil { + return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: no agent available for /btw") + } + + modelCfg, err := al.sideQuestionModelConfig(agent, baseModelName, candidate) + if err != nil { + return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: %w", err) + } + + factory := al.providerFactory + if factory == nil { + factory = providers.CreateProviderFromConfig + } + provider, modelID, err := factory(modelCfg) + if err != nil { + return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: %w", err) + } + + cleanup := func() { + closeProviderIfStateful(provider) + } + return provider, modelID, cleanup, nil +} + +func (al *AgentLoop) sideQuestionModelConfig( + agent *AgentInstance, + baseModelName string, + candidate providers.FallbackCandidate, +) (*config.ModelConfig, error) { + if agent == nil { + return nil, fmt.Errorf("sideQuestionModelConfig: no agent available for /btw") + } + + // If candidate has an identity key, use that + if name := modelNameFromIdentityKey(candidate.IdentityKey); name != "" { + modelCfg, err := resolvedModelConfig(al.GetConfig(), name, agent.Workspace) + if err == nil { + return modelCfg, nil + } + // Fallback: create a minimal config if lookup fails + } + + // Otherwise, clean up the base model name and use it + baseModelName = strings.TrimSpace(baseModelName) + modelCfg, err := resolvedModelConfig(al.GetConfig(), baseModelName, agent.Workspace) + if err != nil { + // Fallback: create a minimal config for test scenarios + model := strings.TrimSpace(baseModelName) + if candidate.Model != "" { + model = candidate.Model + } + if candidate.Provider != "" && candidate.Model != "" { + model = providers.NormalizeProvider(candidate.Provider) + "/" + candidate.Model + } else { + model = ensureProtocolModel(model) + } + return &config.ModelConfig{ + ModelName: baseModelName, + Model: model, + Workspace: agent.Workspace, + }, nil + } + + // If candidate specifies a different provider/model, override + clone := *modelCfg + if candidate.Provider != "" && candidate.Model != "" { + clone.Model = providers.NormalizeProvider(candidate.Provider) + "/" + candidate.Model + } + return &clone, nil +} diff --git a/pkg/agent/turn_coord_test.go b/pkg/agent/turn_coord_test.go new file mode 100644 index 000000000..7a362a662 --- /dev/null +++ b/pkg/agent/turn_coord_test.go @@ -0,0 +1,551 @@ +package agent + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// ============================================================================= +// Mock Providers for turn_coord Tests +// ============================================================================= + +// simpleConvProvider returns a simple text response without tools +type simpleConvProvider struct{} + +func (p *simpleConvProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + return &providers.LLMResponse{ + Content: "Hello! How can I help you today?", + FinishReason: "stop", + }, nil +} + +func (p *simpleConvProvider) GetDefaultModel() string { + return "simple-model" +} + +// toolCallRespProvider returns a tool call response +type toolCallRespProvider struct { + toolName string + toolArgs map[string]any + response string + callCount int + mu sync.Mutex +} + +func (p *toolCallRespProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.mu.Lock() + p.callCount++ + count := p.callCount + p.mu.Unlock() + + // First call returns a tool call, subsequent calls return final response + if count == 1 { + return &providers.LLMResponse{ + Content: "Let me search for that information.", + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Name: p.toolName, + Arguments: p.toolArgs, + }, + }, + FinishReason: "tool_calls", + }, nil + } + return &providers.LLMResponse{ + Content: p.response, + FinishReason: "stop", + }, nil +} + +func (p *toolCallRespProvider) GetDefaultModel() string { + return "tool-model" +} + +// errorProvider simulates various error conditions +type errorProvider struct { + errType string + callCount int + mu sync.Mutex +} + +func (p *errorProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.mu.Lock() + p.callCount++ + p.mu.Unlock() + + switch p.errType { + case "timeout": + return nil, context.DeadlineExceeded + case "context_length": + return nil, errors.New("context_length_exceeded") + case "vision": + return nil, errors.New("vision_unsupported") + default: + return nil, errors.New("unknown error") + } +} + +func (p *errorProvider) GetDefaultModel() string { + return "error-model" +} + +// ============================================================================= +// Test Helper Functions +// ============================================================================= + +func newTurnCoordTestLoop(t *testing.T, provider providers.LLMProvider) (*AgentLoop, *AgentInstance, func()) { + t.Helper() + tmpDir := t.TempDir() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, provider) + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + + return al, agent, func() { + al.Close() + } +} + +func makeTestProcessOpts(sessionKey string) processOptions { + return processOptions{ + SessionKey: sessionKey, + Channel: "cli", + ChatID: "test-chat", + UserMessage: "test message", + DefaultResponse: "I couldn't process your request.", + EnableSummary: false, + SendResponse: false, + NoHistory: false, + } +} + +// ============================================================================= +// Pipeline Method Tests: SetupTurn +// ============================================================================= + +func TestPipeline_SetupTurn_BasicInitialization(t *testing.T) { + al, agent, cleanup := newTurnCoordTestLoop(t, &simpleConvProvider{}) + defer cleanup() + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + if exec == nil { + t.Fatal("expected non-nil turnExecution") + } + if len(exec.messages) == 0 { + t.Error("expected messages to be populated") + } + if exec.iteration != 0 { + t.Errorf("expected iteration 0, got %d", exec.iteration) + } +} + +// ============================================================================= +// Pipeline Method Tests: CallLLM +// ============================================================================= + +func TestPipeline_CallLLM_SimpleResponse(t *testing.T) { + al, agent, cleanup := newTurnCoordTestLoop(t, &simpleConvProvider{}) + defer cleanup() + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + ctrl, err := pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err != nil { + t.Fatalf("CallLLM failed: %v", err) + } + if ctrl != ControlBreak { + t.Errorf("expected ControlBreak, got %v", ctrl) + } + if exec.response == nil { + t.Fatal("expected non-nil response") + } + if exec.response.Content == "" { + t.Error("expected non-empty content") + } +} + +func TestPipeline_CallLLM_WithToolCall(t *testing.T) { + provider := &toolCallRespProvider{ + toolName: "web_search", + toolArgs: map[string]any{"query": "test"}, + response: "Found information about test.", + } + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + ctrl, err := pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err != nil { + t.Fatalf("CallLLM failed: %v", err) + } + if ctrl != ControlToolLoop { + t.Errorf("expected ControlToolLoop, got %v", ctrl) + } + if len(exec.normalizedToolCalls) == 0 { + t.Fatal("expected tool calls") + } + if exec.normalizedToolCalls[0].Name != "web_search" { + t.Errorf("expected tool name 'web_search', got %q", exec.normalizedToolCalls[0].Name) + } +} + +func TestPipeline_CallLLM_TimeoutRetry(t *testing.T) { + errorPrv := &errorProvider{errType: "timeout"} + al, agent, cleanup := newTurnCoordTestLoop(t, errorPrv) + defer cleanup() + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + // Should retry and eventually fail after max retries + _, err = pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err == nil { + t.Error("expected error after retries") + } +} + +func TestPipeline_CallLLM_ContextLengthError(t *testing.T) { + errorPrv := &errorProvider{errType: "context_length"} + al, agent, cleanup := newTurnCoordTestLoop(t, errorPrv) + defer cleanup() + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + // Should trigger context compression and retry + _, err = pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + // May succeed after compression or fail - either is acceptable + t.Logf("CallLLM result after context error: err=%v", err) +} + +// ============================================================================= +// Pipeline Method Tests: ExecuteTools +// ============================================================================= + +func TestPipeline_ExecuteTools_NoTools(t *testing.T) { + // Provider returns no tool calls, so ExecuteTools should not be called + // This test verifies the ControlBreak path from CallLLM + provider := &simpleConvProvider{} + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + // First CallLLM returns ControlBreak (no tools) + ctrl, err := pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err != nil { + t.Fatalf("CallLLM failed: %v", err) + } + + if ctrl != ControlBreak { + t.Fatalf("expected ControlBreak, got %v", ctrl) + } + // No tools to execute, Finalize should be called directly +} + +// ============================================================================= +// runTurn Integration Tests +// ============================================================================= + +func TestRunTurn_SimpleConversation(t *testing.T) { + provider := &simpleConvProvider{} + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + pipeline := NewPipeline(al) + opts := makeTestProcessOpts("test-session-simple") + + ts := newTurnState(agent, opts, turnEventScope{ + turnID: "turn-simple", + context: newTurnContext(nil, nil, nil), + }) + + result, err := al.runTurn(context.Background(), ts, pipeline) + if err != nil { + t.Fatalf("runTurn failed: %v", err) + } + if result.status != TurnEndStatusCompleted { + t.Errorf("expected status Completed, got %v", result.status) + } + if result.finalContent == "" { + t.Error("expected non-empty finalContent") + } +} + +func TestRunTurn_MaxIterations(t *testing.T) { + // Provider always returns tool calls, should hit max iterations + provider := &toolCallRespProvider{ + toolName: "search", + toolArgs: map[string]any{"q": "x"}, + response: "done", + } + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + // Override max iterations to 2 + agent.MaxIterations = 2 + + pipeline := NewPipeline(al) + opts := makeTestProcessOpts("test-session-maxiter") + + ts := newTurnState(agent, opts, turnEventScope{ + turnID: "turn-maxiter", + context: newTurnContext(nil, nil, nil), + }) + + result, err := al.runTurn(context.Background(), ts, pipeline) + if err != nil { + t.Fatalf("runTurn failed: %v", err) + } + // Should complete due to max iterations + if result.status != TurnEndStatusCompleted { + t.Errorf("expected status Completed, got %v", result.status) + } +} + +func TestRunTurn_HardAbort(t *testing.T) { + // Provider simulates a slow response, but we'll abort mid-turn + slowProvider := &slowMockProvider{delay: 10 * time.Second} + al, agent, cleanup := newTurnCoordTestLoop(t, slowProvider) + defer cleanup() + + pipeline := NewPipeline(al) + opts := makeTestProcessOpts("test-session-abort") + + ts := newTurnState(agent, opts, turnEventScope{ + turnID: "turn-abort", + context: newTurnContext(nil, nil, nil), + }) + + // Run in goroutine with abort after short delay + done := make(chan struct{}) + + go func() { + al.runTurn(context.Background(), ts, pipeline) + close(done) + }() + + // Give it a moment to start + time.Sleep(50 * time.Millisecond) + + // Request hard abort + ts.requestHardAbort() + + // Wait for runTurn to complete + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("runTurn did not complete after abort") + } +} + +func TestRunTurn_SteeringMessageInjection(t *testing.T) { + provider := &simpleConvProvider{} + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + pipeline := NewPipeline(al) + opts := makeTestProcessOpts("test-session-steering") + + ts := newTurnState(agent, opts, turnEventScope{ + turnID: "turn-steering", + context: newTurnContext(nil, nil, nil), + }) + + // Enqueue steering message before runTurn + steeringMsg := providers.Message{ + Role: "user", + Content: "Steering message", + } + al.Steer(steeringMsg) + + result, err := al.runTurn(context.Background(), ts, pipeline) + if err != nil { + t.Fatalf("runTurn failed: %v", err) + } + if result.status != TurnEndStatusCompleted { + t.Errorf("expected status Completed, got %v", result.status) + } + // Steering message should have been injected +} + +func TestRunTurn_GracefulInterrupt(t *testing.T) { + provider := &toolCallRespProvider{ + toolName: "search", + toolArgs: map[string]any{"q": "test"}, + response: "Final response after interrupt", + } + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + pipeline := NewPipeline(al) + opts := makeTestProcessOpts("test-session-graceful") + + ts := newTurnState(agent, opts, turnEventScope{ + turnID: "turn-graceful", + context: newTurnContext(nil, nil, nil), + }) + + // Run in goroutine with graceful interrupt after first iteration + done := make(chan struct{}) + var result turnResult + + go func() { + result, _ = al.runTurn(context.Background(), ts, pipeline) + close(done) + }() + + // Give it a moment to start first iteration + time.Sleep(50 * time.Millisecond) + + // Request graceful interrupt + ts.requestGracefulInterrupt("Please stop") + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("runTurn did not complete after graceful interrupt") + } + + // Should complete gracefully + if result.status != TurnEndStatusCompleted { + t.Errorf("expected status Completed, got %v", result.status) + } +} + +// ============================================================================= +// turnState Tests +// ============================================================================= + +func TestTurnState_GracefulInterruptRequested(t *testing.T) { + ts := &turnState{ + gracefulInterrupt: false, + gracefulInterruptHint: "", + } + + // Initially should not be requested + requested, _ := ts.gracefulInterruptRequested() + if requested { + t.Error("expected no interrupt initially") + } + + // Request interrupt + ts.requestGracefulInterrupt("test hint") + + requested, hint := ts.gracefulInterruptRequested() + if !requested { + t.Error("expected interrupt to be requested") + } + if hint != "test hint" { + t.Errorf("expected hint 'test hint', got %q", hint) + } +} + +func TestTurnState_HardAbortRequested(t *testing.T) { + ts := &turnState{ + hardAbort: false, + } + + if ts.hardAbortRequested() { + t.Error("expected no hard abort initially") + } + + ts.requestHardAbort() + + if !ts.hardAbortRequested() { + t.Error("expected hard abort to be requested") + } +} diff --git a/pkg/agent/turn.go b/pkg/agent/turn_state.go similarity index 72% rename from pkg/agent/turn.go rename to pkg/agent/turn_state.go index cc67ec926..edf8654b5 100644 --- a/pkg/agent/turn.go +++ b/pkg/agent/turn_state.go @@ -1,3 +1,5 @@ +// PicoClaw - Ultra-lightweight personal AI agent + package agent import ( @@ -14,6 +16,10 @@ import ( "github.com/sipeed/picoclaw/pkg/tools" ) +// ============================================================================= +// TurnPhase - represents the current phase of a turn +// ============================================================================= + type TurnPhase string const ( @@ -25,6 +31,65 @@ const ( TurnPhaseAborted TurnPhase = "aborted" ) +// ============================================================================= +// Control signals - returned from Pipeline methods to drive runTurn's coordinator loop +// ============================================================================= + +type Control int + +const ( + // ControlContinue tells the coordinator to jump back to the top of the turn loop + // (equivalent to the original "goto turnLoop"). + ControlContinue Control = iota + // ControlBreak tells the coordinator to exit the turn loop and proceed to Finalize. + ControlBreak + // ControlToolLoop tells the coordinator to execute the tool loop. + ControlToolLoop +) + +// ToolControl signals returned from ExecuteTools to drive tool loop iteration. +type ToolControl int + +const ( + // ToolControlContinue tells the tool loop to jump to the next iteration + // (pendingMessages arrived, SubTurn results, etc.). + ToolControlContinue ToolControl = iota + // ToolControlBreak tells the tool loop to exit and return to the coordinator. + ToolControlBreak + // ToolControlFinalize tells the coordinator that all tool responses were + // handled and the turn should finalize without another LLM call. + ToolControlFinalize +) + +// LLMPhase indicates which phase the turn is executing in. +type LLMPhase int + +const ( + LLMPhaseSetup LLMPhase = iota + LLMPhasePreLLM + LLMPhaseLLMCall + LLMPhaseProcessing + LLMPhaseToolLoop + LLMPhaseTools + LLMPhaseFinalizing + LLMPhaseCompleted + LLMPhaseAborted +) + +// ============================================================================= +// turnResult - returned from runTurn +// ============================================================================= + +type turnResult struct { + finalContent string + status TurnEndStatus + followUps []bus.InboundMessage +} + +// ============================================================================= +// ActiveTurnInfo - public info about an active turn +// ============================================================================= + type ActiveTurnInfo struct { TurnID string AgentID string @@ -40,12 +105,70 @@ type ActiveTurnInfo struct { ChildTurnIDs []string } -type turnResult struct { +// ============================================================================= +// turnExecution - mutable state that persists across turn loop iterations +// ============================================================================= + +type turnExecution struct { + // Core message state (accumulates throughout the turn) + messages []providers.Message // built from ContextBuilder, grows per-iteration + pendingMessages []providers.Message // steering/SubTurn messages awaiting injection + history []providers.Message // from ContextManager.Assemble + summary string + + // Turn output finalContent string - status TurnEndStatus - followUps []bus.InboundMessage + + // Iteration tracking + iteration int + + // Per-iteration state set by Pipeline.PreLLM + activeCandidates []providers.FallbackCandidate + activeModel string + activeProvider providers.LLMProvider + usedLight bool + + // LLM call per-iteration state + response *providers.LLMResponse + normalizedToolCalls []providers.ToolCall + allResponsesHandled bool + callMessages []providers.Message + providerToolDefs []providers.ToolDefinition + llmModel string + llmOpts map[string]any + gracefulTerminal bool + useNativeSearch bool + + // Phase tracking + phase LLMPhase + + // Abort signaling for coordinator (set by Pipeline methods) + abortedByHardAbort bool // true when hard abort triggered during LLM/tools + abortedByHook bool // true when HookActionAbortTurn triggered } +// newTurnExecution creates a turnExecution initialized from turnState and options. +func newTurnExecution( + agent *AgentInstance, + opts processOptions, + history []providers.Message, + summary string, + messages []providers.Message, +) *turnExecution { + return &turnExecution{ + history: history, + summary: summary, + messages: messages, + pendingMessages: append([]providers.Message(nil), opts.InitialSteeringMessages...), + iteration: 0, + phase: LLMPhaseSetup, + } +} + +// ============================================================================= +// turnState - the full state for a turn, constructed once per turn +// ============================================================================= + type turnState struct { mu sync.RWMutex @@ -109,6 +232,10 @@ type turnState struct { al *AgentLoop } +// ============================================================================= +// turnState constructors and active turn management +// ============================================================================= + func newTurnState(agent *AgentInstance, opts processOptions, scope turnEventScope) *turnState { ts := &turnState{ agent: agent, @@ -194,6 +321,10 @@ func (al *AgentLoop) GetActiveTurnBySession(sessionKey string) *ActiveTurnInfo { return &info } +// ============================================================================= +// turnState - getters and setters +// ============================================================================= + func (ts *turnState) snapshot() ActiveTurnInfo { ts.mu.RLock() defer ts.mu.RUnlock() @@ -402,7 +533,9 @@ func (ts *turnState) interruptHintMessage() providers.Message { } } +// ============================================================================= // SubTurn-related methods +// ============================================================================= // Finish marks the turn as finished and closes the pendingResults channel func (ts *turnState) Finish(isHardAbort bool) { @@ -493,7 +626,9 @@ func (ts *turnState) SetLastUsage(usage *providers.UsageInfo) { ts.lastUsage = usage } -// Context helper functions for SubTurn +// ============================================================================= +// Context helper functions for turnState +// ============================================================================= type turnStateKeyType struct{} diff --git a/pkg/audio/asr/README_zh.md b/pkg/audio/asr/README.zh.md similarity index 100% rename from pkg/audio/asr/README_zh.md rename to pkg/audio/asr/README.zh.md diff --git a/pkg/audio/tts/README_zh.md b/pkg/audio/tts/README.zh.md similarity index 100% rename from pkg/audio/tts/README_zh.md rename to pkg/audio/tts/README.zh.md diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go index 2bf719dd4..c03c30d10 100644 --- a/pkg/auth/oauth.go +++ b/pkg/auth/oauth.go @@ -30,6 +30,15 @@ type OAuthProviderConfig struct { Port int } +type LoginBrowserOptions struct { + NoBrowser bool +} + +var ( + openBrowserFunc = OpenBrowser + browserLoginInput io.Reader = os.Stdin +) + func OpenAIOAuthConfig() OAuthProviderConfig { return OAuthProviderConfig{ Issuer: "https://auth.openai.com", @@ -76,6 +85,10 @@ func GenerateState() (string, error) { } func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { + return LoginBrowserWithOptions(cfg, LoginBrowserOptions{}) +} + +func LoginBrowserWithOptions(cfg OAuthProviderConfig, opts LoginBrowserOptions) (*AuthCredential, error) { pkce, err := GeneratePKCE() if err != nil { return nil, fmt.Errorf("generating PKCE: %w", err) @@ -86,55 +99,45 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { return nil, fmt.Errorf("generating state: %w", err) } - redirectURI := fmt.Sprintf("http://localhost:%d/auth/callback", cfg.Port) + redirectURI := oauthCallbackRedirectURI(cfg.Port) + callbackPort := cfg.Port + var resultCh <-chan callbackResult + + if !opts.NoBrowser { + callbackResultCh := make(chan callbackResult, 1) + listener, actualPort, err := listenOAuthCallback(cfg.Port) + if err != nil { + return nil, fmt.Errorf("starting callback server on port %d: %w", cfg.Port, err) + } + + redirectURI = oauthCallbackRedirectURI(actualPort) + callbackPort = actualPort + resultCh = callbackResultCh + + server := &http.Server{Handler: oauthCallbackHandler(state, callbackResultCh)} + go func() { + _ = server.Serve(listener) + }() + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = server.Shutdown(ctx) + }() + } authURL := buildAuthorizeURL(cfg, pkce, state, redirectURI) - resultCh := make(chan callbackResult, 1) - - mux := http.NewServeMux() - mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("state") != state { - resultCh <- callbackResult{err: fmt.Errorf("state mismatch")} - http.Error(w, "State mismatch", http.StatusBadRequest) - return - } - - code := r.URL.Query().Get("code") - if code == "" { - errMsg := r.URL.Query().Get("error") - resultCh <- callbackResult{err: fmt.Errorf("no code received: %s", errMsg)} - http.Error(w, "No authorization code received", http.StatusBadRequest) - return - } - - w.Header().Set("Content-Type", "text/html") - fmt.Fprint(w, "

Authentication successful!

You can close this window.

") - resultCh <- callbackResult{code: code} - }) - - listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.Port)) - if err != nil { - return nil, fmt.Errorf("starting callback server on port %d: %w", cfg.Port, err) - } - - server := &http.Server{Handler: mux} - go server.Serve(listener) - defer func() { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - server.Shutdown(ctx) - }() - fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL) - if err := OpenBrowser(authURL); err != nil { + if opts.NoBrowser { + fmt.Println("Browser auto-open disabled. Open the URL manually to continue.") + } else if err := openBrowserFunc(authURL); err != nil { fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL) } fmt.Printf( "Wait! If you are in a headless environment (like Coolify/VPS) and cannot reach localhost:%d,\n", - cfg.Port, + callbackPort, ) fmt.Println( "please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.", @@ -142,11 +145,16 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { fmt.Println("Waiting for authentication (browser or manual paste)...") // Start manual input in a goroutine - manualCh := make(chan string) + manualCh := make(chan string, 1) + manualDone := make(chan struct{}) + defer close(manualDone) go func() { - reader := bufio.NewReader(os.Stdin) + reader := bufio.NewReader(browserLoginInput) input, _ := reader.ReadString('\n') - manualCh <- strings.TrimSpace(input) + select { + case manualCh <- strings.TrimSpace(input): + case <-manualDone: + } }() select { @@ -176,6 +184,49 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { } } +func oauthCallbackRedirectURI(port int) string { + return fmt.Sprintf("http://localhost:%d/auth/callback", port) +} + +func oauthCallbackHandler(state string, resultCh chan<- callbackResult) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("state") != state { + resultCh <- callbackResult{err: fmt.Errorf("state mismatch")} + http.Error(w, "State mismatch", http.StatusBadRequest) + return + } + + code := r.URL.Query().Get("code") + if code == "" { + errMsg := r.URL.Query().Get("error") + resultCh <- callbackResult{err: fmt.Errorf("no code received: %s", errMsg)} + http.Error(w, "No authorization code received", http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, "

Authentication successful!

You can close this window.

") + resultCh <- callbackResult{code: code} + }) + return mux +} + +func listenOAuthCallback(port int) (net.Listener, int, error) { + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return nil, 0, err + } + + tcpAddr, ok := listener.Addr().(*net.TCPAddr) + if !ok { + _ = listener.Close() + return nil, 0, fmt.Errorf("unexpected listener address type %T", listener.Addr()) + } + + return listener, tcpAddr.Port, nil +} + type callbackResult struct { code string err error diff --git a/pkg/auth/oauth_test.go b/pkg/auth/oauth_test.go index 230ac7c2a..b318934f9 100644 --- a/pkg/auth/oauth_test.go +++ b/pkg/auth/oauth_test.go @@ -3,6 +3,7 @@ package auth import ( "encoding/base64" "encoding/json" + "net" "net/http" "net/http/httptest" "net/url" @@ -373,3 +374,118 @@ func TestParseDeviceCodeResponseInvalidInterval(t *testing.T) { t.Fatal("expected error for invalid interval") } } + +func TestLoginBrowserWithOptionsNoBrowserDoesNotRequireCallbackPort(t *testing.T) { + server := newMockOAuthTokenServer() + defer server.Close() + reservedListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("net.Listen() error: %v", err) + } + defer reservedListener.Close() + + reservedPort := reservedListener.Addr().(*net.TCPAddr).Port + origOpenBrowserFunc := openBrowserFunc + origBrowserLoginInput := browserLoginInput + t.Cleanup(func() { + openBrowserFunc = origOpenBrowserFunc + browserLoginInput = origBrowserLoginInput + }) + + var openCalls int + openBrowserFunc = func(string) error { + openCalls++ + return nil + } + browserLoginInput = strings.NewReader("manual-code\n") + + cfg := OAuthProviderConfig{ + Issuer: server.URL, + ClientID: "test-client", + Scopes: "openid", + Port: reservedPort, + } + + cred, err := LoginBrowserWithOptions(cfg, LoginBrowserOptions{NoBrowser: true}) + if err != nil { + t.Fatalf("LoginBrowserWithOptions() error: %v", err) + } + + if openCalls != 0 { + t.Fatalf("openBrowserFunc call count = %d, want 0", openCalls) + } + if cred.AccessToken != "mock-access-token" { + t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "mock-access-token") + } +} + +func TestLoginBrowserWithOptionsAutoOpensByDefault(t *testing.T) { + server := newMockOAuthTokenServer() + defer server.Close() + + origOpenBrowserFunc := openBrowserFunc + origBrowserLoginInput := browserLoginInput + t.Cleanup(func() { + openBrowserFunc = origOpenBrowserFunc + browserLoginInput = origBrowserLoginInput + }) + + var ( + openCalls int + browserURL string + ) + openBrowserFunc = func(url string) error { + openCalls++ + browserURL = url + return nil + } + browserLoginInput = strings.NewReader("manual-code\n") + + cfg := OAuthProviderConfig{ + Issuer: server.URL, + ClientID: "test-client", + Scopes: "openid", + Port: 0, + } + + _, err := LoginBrowserWithOptions(cfg, LoginBrowserOptions{}) + if err != nil { + t.Fatalf("LoginBrowserWithOptions() error: %v", err) + } + + if openCalls != 1 { + t.Fatalf("openBrowserFunc call count = %d, want 1", openCalls) + } + + parsedBrowserURL, err := url.Parse(browserURL) + if err != nil { + t.Fatalf("url.Parse(browserURL) error: %v", err) + } + + redirectURI, err := url.Parse(parsedBrowserURL.Query().Get("redirect_uri")) + if err != nil { + t.Fatalf("url.Parse(redirectURI) error: %v", err) + } + if redirectURI.Port() == "" { + t.Fatal("redirectURI port is empty") + } + if redirectURI.Port() == "0" { + t.Fatalf("redirectURI port = %q, want dynamically assigned port", redirectURI.Port()) + } +} + +func newMockOAuthTokenServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/oauth/token" { + http.Error(w, "not found", http.StatusNotFound) + return + } + + resp := map[string]any{ + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_in": 3600, + } + _ = json.NewEncoder(w).Encode(resp) + })) +} diff --git a/pkg/auth/store.go b/pkg/auth/store.go index dfea11df4..0e6567a03 100644 --- a/pkg/auth/store.go +++ b/pkg/auth/store.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "time" "github.com/sipeed/picoclaw/pkg/config" @@ -25,6 +26,11 @@ type AuthStore struct { Credentials map[string]*AuthCredential `json:"credentials"` } +const ( + providerGoogleAntigravity = "google-antigravity" + providerAntigravityAlias = "antigravity" +) + func (c *AuthCredential) IsExpired() bool { if c.ExpiresAt.IsZero() { return false @@ -43,6 +49,125 @@ func authFilePath() string { return filepath.Join(config.GetHome(), "auth.json") } +func canonicalProvider(provider string) string { + normalized := strings.ToLower(strings.TrimSpace(provider)) + switch normalized { + case providerAntigravityAlias: + return providerGoogleAntigravity + default: + return normalized + } +} + +func cloneCredential(cred *AuthCredential) *AuthCredential { + if cred == nil { + return nil + } + cp := *cred + return &cp +} + +func mergeCredentials(primary, secondary *AuthCredential) *AuthCredential { + if primary == nil { + return cloneCredential(secondary) + } + + merged := *primary + if secondary == nil { + return &merged + } + if merged.AccessToken == "" { + merged.AccessToken = secondary.AccessToken + } + if merged.RefreshToken == "" { + merged.RefreshToken = secondary.RefreshToken + } + if merged.AccountID == "" { + merged.AccountID = secondary.AccountID + } + if merged.ExpiresAt.IsZero() { + merged.ExpiresAt = secondary.ExpiresAt + } + if merged.Provider == "" { + merged.Provider = secondary.Provider + } + if merged.AuthMethod == "" { + merged.AuthMethod = secondary.AuthMethod + } + if merged.Email == "" { + merged.Email = secondary.Email + } + if merged.ProjectID == "" { + merged.ProjectID = secondary.ProjectID + } + + return &merged +} + +func shouldPreferCredential( + candidate *AuthCredential, + candidateCanonical bool, + current *AuthCredential, + currentCanonical bool, +) bool { + if candidate == nil { + return false + } + if current == nil { + return true + } + + switch { + case candidate.ExpiresAt.After(current.ExpiresAt): + return true + case current.ExpiresAt.After(candidate.ExpiresAt): + return false + case candidateCanonical != currentCanonical: + return candidateCanonical + default: + return false + } +} + +func normalizeStore(store *AuthStore) { + if store == nil { + return + } + if store.Credentials == nil { + store.Credentials = make(map[string]*AuthCredential) + return + } + + normalized := make(map[string]*AuthCredential, len(store.Credentials)) + canonicalFlags := make(map[string]bool, len(store.Credentials)) + + for provider, cred := range store.Credentials { + normalizedProvider := strings.ToLower(strings.TrimSpace(provider)) + canonical := canonicalProvider(provider) + normalizedCred := cloneCredential(cred) + if normalizedCred != nil { + normalizedCred.Provider = canonicalProvider(normalizedCred.Provider) + if normalizedCred.Provider == "" { + normalizedCred.Provider = canonical + } + } + + current := normalized[canonical] + currentCanonical := canonicalFlags[canonical] + candidateCanonical := normalizedProvider == canonical + + if shouldPreferCredential(normalizedCred, candidateCanonical, current, currentCanonical) { + normalized[canonical] = mergeCredentials(normalizedCred, current) + canonicalFlags[canonical] = candidateCanonical + continue + } + + normalized[canonical] = mergeCredentials(current, normalizedCred) + } + + store.Credentials = normalized +} + func LoadStore() (*AuthStore, error) { path := authFilePath() data, err := os.ReadFile(path) @@ -57,9 +182,7 @@ func LoadStore() (*AuthStore, error) { if err := json.Unmarshal(data, &store); err != nil { return nil, err } - if store.Credentials == nil { - store.Credentials = make(map[string]*AuthCredential) - } + normalizeStore(&store) return &store, nil } @@ -79,7 +202,7 @@ func GetCredential(provider string) (*AuthCredential, error) { if err != nil { return nil, err } - cred, ok := store.Credentials[provider] + cred, ok := store.Credentials[canonicalProvider(provider)] if !ok { return nil, nil } @@ -91,7 +214,17 @@ func SetCredential(provider string, cred *AuthCredential) error { if err != nil { return err } - store.Credentials[provider] = cred + + canonical := canonicalProvider(provider) + normalized := cloneCredential(cred) + if normalized != nil { + normalized.Provider = canonicalProvider(normalized.Provider) + if normalized.Provider == "" { + normalized.Provider = canonical + } + } + + store.Credentials[canonical] = normalized return SaveStore(store) } @@ -100,7 +233,7 @@ func DeleteCredential(provider string) error { if err != nil { return err } - delete(store.Credentials, provider) + delete(store.Credentials, canonicalProvider(provider)) return SaveStore(store) } diff --git a/pkg/auth/store_test.go b/pkg/auth/store_test.go index f6793cfce..578ed4ead 100644 --- a/pkg/auth/store_test.go +++ b/pkg/auth/store_test.go @@ -1,12 +1,24 @@ package auth import ( + "encoding/json" "os" "path/filepath" + "runtime" "testing" "time" + + "github.com/sipeed/picoclaw/pkg/config" ) +func setTestAuthHome(t *testing.T) string { + t.Helper() + + tmpDir := t.TempDir() + t.Setenv(config.EnvHome, filepath.Join(tmpDir, ".picoclaw")) + return tmpDir +} + func TestAuthCredentialIsExpired(t *testing.T) { tests := []struct { name string @@ -51,10 +63,7 @@ func TestAuthCredentialNeedsRefresh(t *testing.T) { } func TestStoreRoundtrip(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + setTestAuthHome(t) cred := &AuthCredential{ AccessToken: "test-access-token", @@ -88,10 +97,7 @@ func TestStoreRoundtrip(t *testing.T) { } func TestStoreFilePermissions(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + tmpDir := setTestAuthHome(t) cred := &AuthCredential{ AccessToken: "secret-token", @@ -108,16 +114,16 @@ func TestStoreFilePermissions(t *testing.T) { t.Fatalf("Stat() error: %v", err) } perm := info.Mode().Perm() + if runtime.GOOS == "windows" { + return + } if perm != 0o600 { t.Errorf("file permissions = %o, want 0600", perm) } } func TestStoreMultiProvider(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + setTestAuthHome(t) openaiCred := &AuthCredential{AccessToken: "openai-token", Provider: "openai", AuthMethod: "oauth"} anthropicCred := &AuthCredential{AccessToken: "anthropic-token", Provider: "anthropic", AuthMethod: "token"} @@ -147,10 +153,7 @@ func TestStoreMultiProvider(t *testing.T) { } func TestDeleteCredential(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + setTestAuthHome(t) cred := &AuthCredential{AccessToken: "to-delete", Provider: "openai", AuthMethod: "oauth"} if err := SetCredential("openai", cred); err != nil { @@ -171,10 +174,7 @@ func TestDeleteCredential(t *testing.T) { } func TestLoadStoreEmpty(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + setTestAuthHome(t) store, err := LoadStore() if err != nil { @@ -187,3 +187,319 @@ func TestLoadStoreEmpty(t *testing.T) { t.Errorf("expected empty credentials, got %d", len(store.Credentials)) } } + +func TestGetCredentialCanonicalizesLegacyAntigravityProvider(t *testing.T) { + tmpDir := setTestAuthHome(t) + + expiresAt := time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC) + store := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "expires_at": expiresAt.Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + "project_id": "project-1", + }, + }, + } + data, err := json.Marshal(store) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cred, err := GetCredential("google-antigravity") + if err != nil { + t.Fatalf("GetCredential() error: %v", err) + } + if cred == nil { + t.Fatal("GetCredential() returned nil") + } + if cred.Provider != "google-antigravity" { + t.Fatalf("Provider = %q, want %q", cred.Provider, "google-antigravity") + } + if !cred.ExpiresAt.Equal(expiresAt) { + t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, expiresAt) + } +} + +func TestLoadStoreMergesAntigravityAliasesPreferringNewerExpiry(t *testing.T) { + tmpDir := setTestAuthHome(t) + + legacyExpiry := time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC) + refreshedExpiry := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC) + store := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "refresh_token": "legacy-refresh", + "expires_at": legacyExpiry.Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + "email": "legacy@example.com", + }, + "google-antigravity": map[string]any{ + "access_token": "fresh-token", + "expires_at": refreshedExpiry.Format(time.RFC3339), + "provider": "google-antigravity", + "auth_method": "oauth", + "project_id": "project-2", + }, + }, + } + data, err := json.Marshal(store) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 1 { + t.Fatalf("credential count = %d, want 1", len(loaded.Credentials)) + } + + cred := loaded.Credentials["google-antigravity"] + if cred == nil { + t.Fatal("google-antigravity credential missing") + } + if cred.AccessToken != "fresh-token" { + t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "fresh-token") + } + if cred.RefreshToken != "legacy-refresh" { + t.Fatalf("RefreshToken = %q, want %q", cred.RefreshToken, "legacy-refresh") + } + if cred.Email != "legacy@example.com" { + t.Fatalf("Email = %q, want %q", cred.Email, "legacy@example.com") + } + if cred.ProjectID != "project-2" { + t.Fatalf("ProjectID = %q, want %q", cred.ProjectID, "project-2") + } + if !cred.ExpiresAt.Equal(refreshedExpiry) { + t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, refreshedExpiry) + } +} + +func TestLoadStorePrefersCanonicalKeyWhenExpiryMatchesAlias(t *testing.T) { + tmpDir := setTestAuthHome(t) + + expiresAt := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC) + store := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "refresh_token": "legacy-refresh", + "expires_at": expiresAt.Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + "email": "legacy@example.com", + }, + " Google-Antigravity ": map[string]any{ + "access_token": "fresh-token", + "expires_at": expiresAt.Format(time.RFC3339), + "provider": " Google-Antigravity ", + "auth_method": "oauth", + "project_id": "project-2", + }, + }, + } + data, err := json.Marshal(store) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 1 { + t.Fatalf("credential count = %d, want 1", len(loaded.Credentials)) + } + + cred := loaded.Credentials["google-antigravity"] + if cred == nil { + t.Fatal("google-antigravity credential missing") + } + if cred.AccessToken != "fresh-token" { + t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "fresh-token") + } + if cred.RefreshToken != "legacy-refresh" { + t.Fatalf("RefreshToken = %q, want %q", cred.RefreshToken, "legacy-refresh") + } + if cred.Email != "legacy@example.com" { + t.Fatalf("Email = %q, want %q", cred.Email, "legacy@example.com") + } + if cred.ProjectID != "project-2" { + t.Fatalf("ProjectID = %q, want %q", cred.ProjectID, "project-2") + } +} + +func TestSetCredentialReplacesLegacyAntigravityEntry(t *testing.T) { + tmpDir := setTestAuthHome(t) + + legacyStore := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "expires_at": time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC).Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + }, + }, + } + data, err := json.Marshal(legacyStore) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + refreshedExpiry := time.Date(2026, 4, 16, 12, 30, 0, 0, time.UTC) + err = SetCredential("google-antigravity", &AuthCredential{ + AccessToken: "fresh-token", + ExpiresAt: refreshedExpiry, + Provider: "google-antigravity", + AuthMethod: "oauth", + }) + if err != nil { + t.Fatalf("SetCredential() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 1 { + t.Fatalf("credential count = %d, want 1", len(loaded.Credentials)) + } + + cred := loaded.Credentials["google-antigravity"] + if cred == nil { + t.Fatal("google-antigravity credential missing") + } + if cred.AccessToken != "fresh-token" { + t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "fresh-token") + } + if !cred.ExpiresAt.Equal(refreshedExpiry) { + t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, refreshedExpiry) + } +} + +func TestDeleteCredentialRemovesLegacyAntigravityAlias(t *testing.T) { + tmpDir := setTestAuthHome(t) + + legacyStore := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "provider": "antigravity", + "auth_method": "oauth", + }, + }, + } + data, err := json.Marshal(legacyStore) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err = DeleteCredential(" google-antigravity ") + if err != nil { + t.Fatalf("DeleteCredential() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 0 { + t.Fatalf("credential count = %d, want 0", len(loaded.Credentials)) + } +} + +func TestSetCredentialCanonicalizesTrimmedMixedCaseProvider(t *testing.T) { + setTestAuthHome(t) + + expiresAt := time.Date(2026, 4, 16, 13, 0, 0, 0, time.UTC) + if err := SetCredential(" AnTiGrAvItY ", &AuthCredential{ + AccessToken: "fresh-token", + ExpiresAt: expiresAt, + Provider: " AnTiGrAvItY ", + AuthMethod: "oauth", + }); err != nil { + t.Fatalf("SetCredential() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 1 { + t.Fatalf("credential count = %d, want 1", len(loaded.Credentials)) + } + + cred := loaded.Credentials["google-antigravity"] + if cred == nil { + t.Fatal("google-antigravity credential missing") + } + if cred.Provider != "google-antigravity" { + t.Fatalf("Provider = %q, want %q", cred.Provider, "google-antigravity") + } + if !cred.ExpiresAt.Equal(expiresAt) { + t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, expiresAt) + } + + got, err := GetCredential(" GoOgLe-AnTiGrAvItY ") + if err != nil { + t.Fatalf("GetCredential() error: %v", err) + } + if got == nil { + t.Fatal("GetCredential() returned nil") + } + if got.Provider != "google-antigravity" { + t.Fatalf("GetCredential provider = %q, want %q", got.Provider, "google-antigravity") + } +} diff --git a/pkg/bus/types.go b/pkg/bus/types.go index aa06ca173..953e69d9c 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -61,6 +61,15 @@ type OutboundScope struct { Values map[string]string `json:"values,omitempty"` } +// ContextUsage describes how much of the model's context window the current +// session consumes, and how far it is from triggering compression. +type ContextUsage struct { + UsedTokens int `json:"used_tokens"` + TotalTokens int `json:"total_tokens"` // model context window + CompressAtTokens int `json:"compress_at_tokens"` // threshold that triggers compression + UsedPercent int `json:"used_percent"` // 0-100 +} + type OutboundMessage struct { Channel string `json:"channel"` ChatID string `json:"chat_id"` @@ -70,6 +79,7 @@ type OutboundMessage struct { Scope *OutboundScope `json:"scope,omitempty"` Content string `json:"content"` ReplyToMessageID string `json:"reply_to_message_id,omitempty"` + ContextUsage *ContextUsage `json:"context_usage,omitempty"` } // MediaPart describes a single media attachment to send. diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index dbca7ad43..4d1fad1ed 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -270,10 +270,12 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri } isThought := outboundMessageIsThought(msg) - outMsg := newMessage(TypeMessageCreate, map[string]any{ + payload := map[string]any{ PayloadKeyContent: msg.Content, PayloadKeyThought: isThought, - }) + } + setContextUsagePayload(payload, msg.ContextUsage) + outMsg := newMessage(TypeMessageCreate, payload) return nil, c.broadcastToSession(msg.ChatID, outMsg) } @@ -924,3 +926,16 @@ func validateInlineImageDataURL(mediaURL string) error { return nil } + +// setContextUsagePayload adds context window usage stats to a pico payload. +func setContextUsagePayload(payload map[string]any, u *bus.ContextUsage) { + if u == nil { + return + } + payload["context_usage"] = map[string]any{ + "used_tokens": u.UsedTokens, + "total_tokens": u.TotalTokens, + "compress_at_tokens": u.CompressAtTokens, + "used_percent": u.UsedPercent, + } +} diff --git a/pkg/channels/pico/protocol.go b/pkg/channels/pico/protocol.go index ecdc2d140..051beed1b 100644 --- a/pkg/channels/pico/protocol.go +++ b/pkg/channels/pico/protocol.go @@ -18,8 +18,6 @@ const ( TypeError = "error" TypePong = "pong" - PicoTokenPrefix = "pico-" - PayloadKeyContent = "content" PayloadKeyThought = "thought" diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go index 5cf9425cb..a7e401bb8 100644 --- a/pkg/commands/builtin.go +++ b/pkg/commands/builtin.go @@ -15,6 +15,7 @@ func BuiltinDefinitions() []Definition { switchCommand(), checkCommand(), clearCommand(), + contextCommand(), subagentsCommand(), reloadCommand(), } diff --git a/pkg/commands/cmd_context.go b/pkg/commands/cmd_context.go new file mode 100644 index 000000000..55481662c --- /dev/null +++ b/pkg/commands/cmd_context.go @@ -0,0 +1,42 @@ +package commands + +import ( + "context" + "fmt" +) + +func contextCommand() Definition { + return Definition{ + Name: "context", + Description: "Show current session context and token usage", + Usage: "/context", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.GetContextStats == nil { + return req.Reply(unavailableMsg) + } + stats := rt.GetContextStats() + if stats == nil { + return req.Reply("No active session context.") + } + return req.Reply(formatContextStats(stats)) + }, + } +} + +func formatContextStats(s *ContextStats) string { + remaining := s.CompressAtTokens - s.UsedTokens + if remaining < 0 { + remaining = 0 + } + usedWindowPercent := s.UsedTokens * 100 / max(s.TotalTokens, 1) + return fmt.Sprintf( + "Context usage \nMessages: %d \nUsed: ~%d / %d tokens (%d%%) \nCompress at: %d tokens \nCompression progress: %d%% \nRemaining: ~%d tokens", + s.MessageCount, + s.UsedTokens, + s.TotalTokens, + usedWindowPercent, + s.CompressAtTokens, + s.UsedPercent, + remaining, + ) +} diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go index 69373f561..68c286dde 100644 --- a/pkg/commands/runtime.go +++ b/pkg/commands/runtime.go @@ -6,6 +6,15 @@ import ( "github.com/sipeed/picoclaw/pkg/config" ) +// ContextStats describes current session context window usage. +type ContextStats struct { + UsedTokens int + TotalTokens int // model context window + CompressAtTokens int // compression threshold + UsedPercent int // 0-100 + MessageCount int +} + // Runtime provides runtime dependencies to command handlers. It is constructed // per-request by the agent loop so that per-request state (like session scope) // can coexist with long-lived callbacks (like GetModelInfo). @@ -18,6 +27,7 @@ type Runtime struct { ListSkillNames func() []string GetEnabledChannels func() []string GetActiveTurn func() any // Returning any to avoid circular dependency with agent package + GetContextStats func() *ContextStats SwitchModel func(value string) (oldModel string, err error) SwitchChannel func(value string) error ClearHistory func() error diff --git a/pkg/config/config_struct.go b/pkg/config/config_struct.go index 6eaf32bc1..65cfeb107 100644 --- a/pkg/config/config_struct.go +++ b/pkg/config/config_struct.go @@ -22,6 +22,11 @@ import ( type FlexibleStringSlice []string func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *f = nil + return nil + } + // Accept a single JSON string for convenience, e.g.: // "text": "Thinking..." var singleString string diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index d9ca0cb9d..24c86d452 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1258,6 +1258,11 @@ func TestFlexibleStringSlice_UnmarshalJSON(t *testing.T) { input string expected []string }{ + { + name: "null", + input: `null`, + expected: nil, + }, { name: "single string", input: `"Thinking..."`, @@ -1286,6 +1291,12 @@ func TestFlexibleStringSlice_UnmarshalJSON(t *testing.T) { if err := json.Unmarshal([]byte(tt.input), &f); err != nil { t.Fatalf("json.Unmarshal(%s) error = %v", tt.input, err) } + if tt.expected == nil { + if f != nil { + t.Fatalf("json.Unmarshal(%s) = %#v, want nil slice", tt.input, f) + } + return + } if len(f) != len(tt.expected) { t.Fatalf("json.Unmarshal(%s) len = %d, want %d", tt.input, len(f), len(tt.expected)) } diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index 039f45075..f58590d5b 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -9,7 +9,6 @@ import ( "path/filepath" "sort" "strconv" - "strings" "sync" "sync/atomic" "syscall" @@ -27,7 +26,7 @@ import ( _ "github.com/sipeed/picoclaw/pkg/channels/line" _ "github.com/sipeed/picoclaw/pkg/channels/maixcam" _ "github.com/sipeed/picoclaw/pkg/channels/onebot" - "github.com/sipeed/picoclaw/pkg/channels/pico" + _ "github.com/sipeed/picoclaw/pkg/channels/pico" _ "github.com/sipeed/picoclaw/pkg/channels/qq" _ "github.com/sipeed/picoclaw/pkg/channels/slack" _ "github.com/sipeed/picoclaw/pkg/channels/teams_webhook" @@ -316,8 +315,6 @@ func executeReload( ) error { defer runningServices.reloading.Store(false) - overridePicoToken(newCfg, runningServices.authToken) - return handleConfigReload(ctx, agentLoop, newCfg, provider, runningServices, msgBus, allowEmptyStartup, debug) } @@ -386,8 +383,6 @@ func setupAndStartServices( fms.Start() } - overridePicoToken(cfg, authToken) - runningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore) if err != nil { if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok { @@ -788,23 +783,6 @@ func setupCronTool( return cronService, nil } -// overridePicoToken replaces the pico channel token with the one from the PID file. -// The PID file is the single source of truth for the pico auth token; -// it is generated once at gateway startup and remains unchanged across reloads. -func overridePicoToken(cfg *config.Config, token string) { - picoBC := cfg.Channels.GetByType(config.ChannelPico) - if picoBC == nil || !picoBC.Enabled { - return - } - var picoCfg config.PicoSettings - picoBC.Decode(&picoCfg) - picoToken := picoCfg.Token.String() - if picoToken == "" || strings.HasPrefix(picoToken, pico.PicoTokenPrefix) { - return - } - picoCfg.SetToken(pico.PicoTokenPrefix + token + picoToken) -} - func createHeartbeatHandler(agentLoop *agent.AgentLoop) func(prompt, channel, chatID string) *tools.ToolResult { return func(prompt, channel, chatID string) *tools.ToolResult { if channel == "" || chatID == "" { diff --git a/pkg/isolation/README_CN.md b/pkg/isolation/README.zh.md similarity index 100% rename from pkg/isolation/README_CN.md rename to pkg/isolation/README.zh.md diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/cli/claude_cli_provider.go similarity index 99% rename from pkg/providers/claude_cli_provider.go rename to pkg/providers/cli/claude_cli_provider.go index c3d98c555..62851ca3a 100644 --- a/pkg/providers/claude_cli_provider.go +++ b/pkg/providers/cli/claude_cli_provider.go @@ -1,4 +1,4 @@ -package providers +package cliprovider import ( "bytes" diff --git a/pkg/providers/claude_cli_provider_integration_test.go b/pkg/providers/cli/claude_cli_provider_integration_test.go similarity index 99% rename from pkg/providers/claude_cli_provider_integration_test.go rename to pkg/providers/cli/claude_cli_provider_integration_test.go index f6e0d787a..cdfe7060e 100644 --- a/pkg/providers/claude_cli_provider_integration_test.go +++ b/pkg/providers/cli/claude_cli_provider_integration_test.go @@ -1,6 +1,6 @@ //go:build integration -package providers +package cliprovider import ( "context" diff --git a/pkg/providers/claude_cli_provider_test.go b/pkg/providers/cli/claude_cli_provider_test.go similarity index 92% rename from pkg/providers/claude_cli_provider_test.go rename to pkg/providers/cli/claude_cli_provider_test.go index bc9960f0c..ddef84ffc 100644 --- a/pkg/providers/claude_cli_provider_test.go +++ b/pkg/providers/cli/claude_cli_provider_test.go @@ -1,4 +1,4 @@ -package providers +package cliprovider import ( "context" @@ -9,8 +9,6 @@ import ( "strings" "testing" "time" - - "github.com/sipeed/picoclaw/pkg/config" ) // --- Compile-time interface check --- @@ -409,83 +407,6 @@ func TestChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) { } } -// --- CreateProvider factory tests --- - -func TestCreateProvider_ClaudeCli(t *testing.T) { - cfg := config.DefaultConfig() - cfg.ModelList = []*config.ModelConfig{ - {ModelName: "claude-sonnet-4.6", Model: "claude-cli/claude-sonnet-4.6", Workspace: "/test/ws"}, - } - cfg.Agents.Defaults.ModelName = "claude-sonnet-4.6" - - provider, _, err := CreateProvider(cfg) - if err != nil { - t.Fatalf("CreateProvider(claude-cli) error = %v", err) - } - - cliProvider, ok := provider.(*ClaudeCliProvider) - if !ok { - t.Fatalf("CreateProvider(claude-cli) returned %T, want *ClaudeCliProvider", provider) - } - if cliProvider.workspace != "/test/ws" { - t.Errorf("workspace = %q, want %q", cliProvider.workspace, "/test/ws") - } -} - -func TestCreateProvider_ClaudeCode(t *testing.T) { - cfg := config.DefaultConfig() - cfg.ModelList = []*config.ModelConfig{ - {ModelName: "claude-code", Model: "claude-cli/claude-code"}, - } - cfg.Agents.Defaults.ModelName = "claude-code" - - provider, _, err := CreateProvider(cfg) - if err != nil { - t.Fatalf("CreateProvider(claude-code) error = %v", err) - } - if _, ok := provider.(*ClaudeCliProvider); !ok { - t.Fatalf("CreateProvider(claude-code) returned %T, want *ClaudeCliProvider", provider) - } -} - -func TestCreateProvider_ClaudeCodec(t *testing.T) { - cfg := config.DefaultConfig() - cfg.ModelList = []*config.ModelConfig{ - {ModelName: "claudecode", Model: "claude-cli/claudecode"}, - } - cfg.Agents.Defaults.ModelName = "claudecode" - - provider, _, err := CreateProvider(cfg) - if err != nil { - t.Fatalf("CreateProvider(claudecode) error = %v", err) - } - if _, ok := provider.(*ClaudeCliProvider); !ok { - t.Fatalf("CreateProvider(claudecode) returned %T, want *ClaudeCliProvider", provider) - } -} - -func TestCreateProvider_ClaudeCliDefaultWorkspace(t *testing.T) { - cfg := config.DefaultConfig() - cfg.ModelList = []*config.ModelConfig{ - {ModelName: "claude-cli", Model: "claude-cli/claude-sonnet"}, - } - cfg.Agents.Defaults.ModelName = "claude-cli" - cfg.Agents.Defaults.Workspace = "" - - provider, _, err := CreateProvider(cfg) - if err != nil { - t.Fatalf("CreateProvider error = %v", err) - } - - cliProvider, ok := provider.(*ClaudeCliProvider) - if !ok { - t.Fatalf("returned %T, want *ClaudeCliProvider", provider) - } - if cliProvider.workspace != "." { - t.Errorf("workspace = %q, want %q (default)", cliProvider.workspace, ".") - } -} - // --- messagesToPrompt tests --- func TestMessagesToPrompt_SingleUser(t *testing.T) { diff --git a/pkg/providers/codex_cli_credentials.go b/pkg/providers/cli/codex_cli_credentials.go similarity index 99% rename from pkg/providers/codex_cli_credentials.go rename to pkg/providers/cli/codex_cli_credentials.go index c5b25f040..95e289097 100644 --- a/pkg/providers/codex_cli_credentials.go +++ b/pkg/providers/cli/codex_cli_credentials.go @@ -1,4 +1,4 @@ -package providers +package cliprovider import ( "encoding/json" diff --git a/pkg/providers/codex_cli_credentials_test.go b/pkg/providers/cli/codex_cli_credentials_test.go similarity index 99% rename from pkg/providers/codex_cli_credentials_test.go rename to pkg/providers/cli/codex_cli_credentials_test.go index 1e88c1120..abad6e248 100644 --- a/pkg/providers/codex_cli_credentials_test.go +++ b/pkg/providers/cli/codex_cli_credentials_test.go @@ -1,4 +1,4 @@ -package providers +package cliprovider import ( "os" diff --git a/pkg/providers/codex_cli_provider.go b/pkg/providers/cli/codex_cli_provider.go similarity index 99% rename from pkg/providers/codex_cli_provider.go rename to pkg/providers/cli/codex_cli_provider.go index a9c8b692a..d1a23c329 100644 --- a/pkg/providers/codex_cli_provider.go +++ b/pkg/providers/cli/codex_cli_provider.go @@ -1,4 +1,4 @@ -package providers +package cliprovider import ( "bufio" diff --git a/pkg/providers/codex_cli_provider_integration_test.go b/pkg/providers/cli/codex_cli_provider_integration_test.go similarity index 99% rename from pkg/providers/codex_cli_provider_integration_test.go rename to pkg/providers/cli/codex_cli_provider_integration_test.go index 17a8305ad..af18b8c6d 100644 --- a/pkg/providers/codex_cli_provider_integration_test.go +++ b/pkg/providers/cli/codex_cli_provider_integration_test.go @@ -1,6 +1,6 @@ //go:build integration -package providers +package cliprovider import ( "context" diff --git a/pkg/providers/codex_cli_provider_test.go b/pkg/providers/cli/codex_cli_provider_test.go similarity index 98% rename from pkg/providers/codex_cli_provider_test.go rename to pkg/providers/cli/codex_cli_provider_test.go index 0f66e25f4..8338fbc91 100644 --- a/pkg/providers/codex_cli_provider_test.go +++ b/pkg/providers/cli/codex_cli_provider_test.go @@ -1,4 +1,4 @@ -package providers +package cliprovider import ( "context" @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" ) @@ -400,6 +401,9 @@ func TestCodexCliProvider_GetDefaultModel(t *testing.T) { func createMockCodexCLI(t *testing.T, events []string) string { t.Helper() + if runtime.GOOS == "windows" { + t.Skip("mock CLI scripts not supported on Windows") + } tmpDir := t.TempDir() scriptPath := filepath.Join(tmpDir, "codex") @@ -471,6 +475,9 @@ func TestCodexCliProvider_MockCLI_Error(t *testing.T) { } func TestCodexCliProvider_MockCLI_WithModel(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("mock CLI scripts not supported on Windows") + } // Mock script that captures args to verify model flag is passed tmpDir := t.TempDir() scriptPath := filepath.Join(tmpDir, "codex") @@ -517,6 +524,9 @@ echo '{"type":"turn.completed"}'` } func TestCodexCliProvider_MockCLI_ContextCancel(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("mock CLI scripts not supported on Windows") + } // Script that sleeps forever tmpDir := t.TempDir() scriptPath := filepath.Join(tmpDir, "codex") diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/cli/github_copilot_provider.go similarity index 99% rename from pkg/providers/github_copilot_provider.go rename to pkg/providers/cli/github_copilot_provider.go index 472c14257..d1d8a3e23 100644 --- a/pkg/providers/github_copilot_provider.go +++ b/pkg/providers/cli/github_copilot_provider.go @@ -1,4 +1,4 @@ -package providers +package cliprovider import ( "context" diff --git a/pkg/providers/tool_call_extract.go b/pkg/providers/cli/tool_call_extract.go similarity index 98% rename from pkg/providers/tool_call_extract.go rename to pkg/providers/cli/tool_call_extract.go index 7ddea0e99..f1d1886ea 100644 --- a/pkg/providers/tool_call_extract.go +++ b/pkg/providers/cli/tool_call_extract.go @@ -1,4 +1,4 @@ -package providers +package cliprovider import ( "encoding/json" diff --git a/pkg/providers/toolcall_utils.go b/pkg/providers/cli/toolcall_utils.go similarity index 99% rename from pkg/providers/toolcall_utils.go rename to pkg/providers/cli/toolcall_utils.go index 7d0908158..b480082eb 100644 --- a/pkg/providers/toolcall_utils.go +++ b/pkg/providers/cli/toolcall_utils.go @@ -3,7 +3,7 @@ // // Copyright (c) 2026 PicoClaw contributors -package providers +package cliprovider import ( "encoding/json" diff --git a/pkg/providers/cli/types.go b/pkg/providers/cli/types.go new file mode 100644 index 000000000..f15897adf --- /dev/null +++ b/pkg/providers/cli/types.go @@ -0,0 +1,28 @@ +package cliprovider + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +type ( + ToolCall = protocoltypes.ToolCall + FunctionCall = protocoltypes.FunctionCall + LLMResponse = protocoltypes.LLMResponse + UsageInfo = protocoltypes.UsageInfo + Message = protocoltypes.Message + ToolDefinition = protocoltypes.ToolDefinition + ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition +) + +type LLMProvider interface { + Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + ) (*LLMResponse, error) + GetDefaultModel() string +} diff --git a/pkg/providers/cli_facade.go b/pkg/providers/cli_facade.go new file mode 100644 index 000000000..6580291bd --- /dev/null +++ b/pkg/providers/cli_facade.go @@ -0,0 +1,40 @@ +package providers + +import ( + "time" + + cliprovider "github.com/sipeed/picoclaw/pkg/providers/cli" +) + +type ( + ClaudeCliProvider = cliprovider.ClaudeCliProvider + CodexCliProvider = cliprovider.CodexCliProvider + CodexCliAuth = cliprovider.CodexCliAuth + GitHubCopilotProvider = cliprovider.GitHubCopilotProvider +) + +const CodexHomeEnvVar = cliprovider.CodexHomeEnvVar + +func NewClaudeCliProvider(workspace string) *ClaudeCliProvider { + return cliprovider.NewClaudeCliProvider(workspace) +} + +func NewCodexCliProvider(workspace string) *CodexCliProvider { + return cliprovider.NewCodexCliProvider(workspace) +} + +func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) { + return cliprovider.NewGitHubCopilotProvider(uri, connectMode, model) +} + +func ReadCodexCliCredentials() (accessToken, accountID string, expiresAt time.Time, err error) { + return cliprovider.ReadCodexCliCredentials() +} + +func CreateCodexCliTokenSource() func() (string, string, error) { + return cliprovider.CreateCodexCliTokenSource() +} + +func NormalizeToolCall(tc ToolCall) ToolCall { + return cliprovider.NormalizeToolCall(tc) +} diff --git a/pkg/providers/cli_factory_test.go b/pkg/providers/cli_factory_test.go new file mode 100644 index 000000000..b00eafb9f --- /dev/null +++ b/pkg/providers/cli_factory_test.go @@ -0,0 +1,99 @@ +package providers + +import ( + "reflect" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func testProviderWorkspace(t *testing.T, provider any) string { + t.Helper() + + v := reflect.ValueOf(provider) + if v.Kind() != reflect.Ptr || v.IsNil() { + t.Fatalf("provider = %T, want non-nil pointer", provider) + } + + field := v.Elem().FieldByName("workspace") + if !field.IsValid() || field.Kind() != reflect.String { + t.Fatalf("provider %T does not expose workspace field", provider) + } + + return field.String() +} + +func TestCreateProvider_ClaudeCli(t *testing.T) { + cfg := config.DefaultConfig() + cfg.ModelList = []*config.ModelConfig{ + {ModelName: "claude-sonnet-4.6", Model: "claude-cli/claude-sonnet-4.6", Workspace: "/test/ws"}, + } + cfg.Agents.Defaults.ModelName = "claude-sonnet-4.6" + + provider, _, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider(claude-cli) error = %v", err) + } + + cliProvider, ok := provider.(*ClaudeCliProvider) + if !ok { + t.Fatalf("CreateProvider(claude-cli) returned %T, want *ClaudeCliProvider", provider) + } + if got := testProviderWorkspace(t, cliProvider); got != "/test/ws" { + t.Errorf("workspace = %q, want %q", got, "/test/ws") + } +} + +func TestCreateProvider_ClaudeCode(t *testing.T) { + cfg := config.DefaultConfig() + cfg.ModelList = []*config.ModelConfig{ + {ModelName: "claude-code", Model: "claude-cli/claude-code"}, + } + cfg.Agents.Defaults.ModelName = "claude-code" + + provider, _, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider(claude-code) error = %v", err) + } + if _, ok := provider.(*ClaudeCliProvider); !ok { + t.Fatalf("CreateProvider(claude-code) returned %T, want *ClaudeCliProvider", provider) + } +} + +func TestCreateProvider_ClaudeCodec(t *testing.T) { + cfg := config.DefaultConfig() + cfg.ModelList = []*config.ModelConfig{ + {ModelName: "claudecode", Model: "claude-cli/claudecode"}, + } + cfg.Agents.Defaults.ModelName = "claudecode" + + provider, _, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider(claudecode) error = %v", err) + } + if _, ok := provider.(*ClaudeCliProvider); !ok { + t.Fatalf("CreateProvider(claudecode) returned %T, want *ClaudeCliProvider", provider) + } +} + +func TestCreateProvider_ClaudeCliDefaultWorkspace(t *testing.T) { + cfg := config.DefaultConfig() + cfg.ModelList = []*config.ModelConfig{ + {ModelName: "claude-cli", Model: "claude-cli/claude-sonnet"}, + } + cfg.Agents.Defaults.ModelName = "claude-cli" + cfg.Agents.Defaults.Workspace = "" + + provider, _, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider error = %v", err) + } + + cliProvider, ok := provider.(*ClaudeCliProvider) + if !ok { + t.Fatalf("returned %T, want *ClaudeCliProvider", provider) + } + if got := testProviderWorkspace(t, cliProvider); got != "." { + t.Errorf("workspace = %q, want %q (default)", got, ".") + } +} diff --git a/pkg/providers/facade_compat_test.go b/pkg/providers/facade_compat_test.go new file mode 100644 index 000000000..024c36abf --- /dev/null +++ b/pkg/providers/facade_compat_test.go @@ -0,0 +1,44 @@ +package providers + +import ( + "testing" + + cliprovider "github.com/sipeed/picoclaw/pkg/providers/cli" + oauthprovider "github.com/sipeed/picoclaw/pkg/providers/oauth" +) + +func TestNormalizeToolCallFacadeMatchesCLIProvider(t *testing.T) { + input := ToolCall{ + ID: "call_1", + Type: "function", + Function: &FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + } + + got := NormalizeToolCall(input) + want := cliprovider.NormalizeToolCall(input) + + if got.Name != want.Name { + t.Fatalf("Name = %q, want %q", got.Name, want.Name) + } + if got.Function == nil || want.Function == nil { + t.Fatalf("Function should not be nil: got=%v want=%v", got.Function, want.Function) + } + if got.Function.Name != want.Function.Name { + t.Fatalf("Function.Name = %q, want %q", got.Function.Name, want.Function.Name) + } + if got.Function.Arguments != want.Function.Arguments { + t.Fatalf("Function.Arguments = %q, want %q", got.Function.Arguments, want.Function.Arguments) + } + if got.Arguments["path"] != want.Arguments["path"] { + t.Fatalf("Arguments[path] = %v, want %v", got.Arguments["path"], want.Arguments["path"]) + } +} + +func TestAntigravityFacadeSignaturesRemainAvailable(t *testing.T) { + var _ func(string) (string, error) = FetchAntigravityProjectID + var _ func(string, string) ([]AntigravityModelInfo, error) = FetchAntigravityModels + var _ AntigravityModelInfo = oauthprovider.AntigravityModelInfo{} +} diff --git a/pkg/providers/httpapi/gemini_helpers.go b/pkg/providers/httpapi/gemini_helpers.go new file mode 100644 index 000000000..36d95cf9e --- /dev/null +++ b/pkg/providers/httpapi/gemini_helpers.go @@ -0,0 +1,139 @@ +package httpapi + +import ( + "encoding/json" + "strings" +) + +func normalizeStoredToolCall(tc ToolCall) (string, map[string]any, string) { + name := tc.Name + args := tc.Arguments + thoughtSignature := "" + + if name == "" && tc.Function != nil { + name = tc.Function.Name + thoughtSignature = tc.Function.ThoughtSignature + } else if tc.Function != nil { + thoughtSignature = tc.Function.ThoughtSignature + } + + if args == nil { + args = map[string]any{} + } + + if len(args) == 0 && tc.Function != nil && tc.Function.Arguments != "" { + var parsed map[string]any + if err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil && parsed != nil { + args = parsed + } + } + + return name, args, thoughtSignature +} + +func resolveToolResponseName(toolCallID string, toolCallNames map[string]string) string { + if toolCallID == "" { + return "" + } + + if name, ok := toolCallNames[toolCallID]; ok && name != "" { + return name + } + + return inferToolNameFromCallID(toolCallID) +} + +func inferToolNameFromCallID(toolCallID string) string { + if !strings.HasPrefix(toolCallID, "call_") { + return toolCallID + } + + rest := strings.TrimPrefix(toolCallID, "call_") + if idx := strings.LastIndex(rest, "_"); idx > 0 { + candidate := rest[:idx] + if candidate != "" { + return candidate + } + } + + return toolCallID +} + +func extractPartThoughtSignature(thoughtSignature string, thoughtSignatureSnake string) string { + if thoughtSignature != "" { + return thoughtSignature + } + if thoughtSignatureSnake != "" { + return thoughtSignatureSnake + } + return "" +} + +var geminiUnsupportedKeywords = map[string]bool{ + "patternProperties": true, + "additionalProperties": true, + "$schema": true, + "$id": true, + "$ref": true, + "$defs": true, + "definitions": true, + "examples": true, + "minLength": true, + "maxLength": true, + "minimum": true, + "maximum": true, + "multipleOf": true, + "pattern": true, + "format": true, + "minItems": true, + "maxItems": true, + "uniqueItems": true, + "minProperties": true, + "maxProperties": true, +} + +func sanitizeSchemaForGemini(schema map[string]any) map[string]any { + if schema == nil { + return nil + } + + result := make(map[string]any) + for k, v := range schema { + if geminiUnsupportedKeywords[k] { + continue + } + switch val := v.(type) { + case map[string]any: + result[k] = sanitizeSchemaForGemini(val) + case []any: + sanitized := make([]any, len(val)) + for i, item := range val { + if m, ok := item.(map[string]any); ok { + sanitized[i] = sanitizeSchemaForGemini(m) + } else { + sanitized[i] = item + } + } + result[k] = sanitized + default: + result[k] = v + } + } + + if _, hasProps := result["properties"]; hasProps { + if _, hasType := result["type"]; !hasType { + result["type"] = "object" + } + } + + return result +} + +func extractProtocol(model string) (protocol, modelID string) { + model = strings.TrimSpace(model) + protocol, modelID, found := strings.Cut(model, "/") + if !found { + return "openai", model + } + return protocol, modelID +} diff --git a/pkg/providers/gemini_provider.go b/pkg/providers/httpapi/gemini_provider.go similarity index 99% rename from pkg/providers/gemini_provider.go rename to pkg/providers/httpapi/gemini_provider.go index 561387534..d488d06f8 100644 --- a/pkg/providers/gemini_provider.go +++ b/pkg/providers/httpapi/gemini_provider.go @@ -1,4 +1,4 @@ -package providers +package httpapi import ( "bufio" @@ -303,7 +303,7 @@ func normalizeGeminiModel(model string) string { model = strings.TrimSpace(model) model = strings.TrimPrefix(model, "models/") if strings.Contains(model, "/") { - _, modelID := ExtractProtocol(model) + _, modelID := extractProtocol(model) if modelID != "" { return modelID } diff --git a/pkg/providers/gemini_provider_test.go b/pkg/providers/httpapi/gemini_provider_test.go similarity index 99% rename from pkg/providers/gemini_provider_test.go rename to pkg/providers/httpapi/gemini_provider_test.go index a0ab748eb..aade90358 100644 --- a/pkg/providers/gemini_provider_test.go +++ b/pkg/providers/httpapi/gemini_provider_test.go @@ -1,4 +1,4 @@ -package providers +package httpapi import ( "encoding/json" diff --git a/pkg/providers/http_provider.go b/pkg/providers/httpapi/http_provider.go similarity index 99% rename from pkg/providers/http_provider.go rename to pkg/providers/httpapi/http_provider.go index ac91f15f6..a84962622 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/httpapi/http_provider.go @@ -4,7 +4,7 @@ // // Copyright (c) 2026 PicoClaw contributors -package providers +package httpapi import ( "context" diff --git a/pkg/providers/httpapi/types.go b/pkg/providers/httpapi/types.go new file mode 100644 index 000000000..c8bcdc0dc --- /dev/null +++ b/pkg/providers/httpapi/types.go @@ -0,0 +1,43 @@ +package httpapi + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +type ( + ToolCall = protocoltypes.ToolCall + FunctionCall = protocoltypes.FunctionCall + LLMResponse = protocoltypes.LLMResponse + UsageInfo = protocoltypes.UsageInfo + Message = protocoltypes.Message + ToolDefinition = protocoltypes.ToolDefinition + ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition + ExtraContent = protocoltypes.ExtraContent + GoogleExtra = protocoltypes.GoogleExtra + ContentBlock = protocoltypes.ContentBlock + CacheControl = protocoltypes.CacheControl +) + +type LLMProvider interface { + Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + ) (*LLMResponse, error) + GetDefaultModel() string +} + +type StreamingProvider interface { + ChatStream( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + onChunk func(accumulated string), + ) (*LLMResponse, error) +} diff --git a/pkg/providers/httpapi_facade.go b/pkg/providers/httpapi_facade.go new file mode 100644 index 000000000..fea92dc43 --- /dev/null +++ b/pkg/providers/httpapi_facade.go @@ -0,0 +1,46 @@ +package providers + +import httpapi "github.com/sipeed/picoclaw/pkg/providers/httpapi" + +type ( + GeminiProvider = httpapi.GeminiProvider + HTTPProvider = httpapi.HTTPProvider +) + +func NewGeminiProvider( + apiKey string, + apiBase string, + proxy string, + userAgent string, + requestTimeoutSeconds int, + extraBody map[string]any, + customHeaders map[string]string, +) *GeminiProvider { + return httpapi.NewGeminiProvider(apiKey, apiBase, proxy, userAgent, requestTimeoutSeconds, extraBody, customHeaders) +} + +func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider { + return httpapi.NewHTTPProvider(apiKey, apiBase, proxy) +} + +func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider { + return httpapi.NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField) +} + +func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + apiKey, apiBase, proxy, maxTokensField, userAgent string, + requestTimeoutSeconds int, + extraBody map[string]any, + customHeaders map[string]string, +) *HTTPProvider { + return httpapi.NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + apiKey, + apiBase, + proxy, + maxTokensField, + userAgent, + requestTimeoutSeconds, + extraBody, + customHeaders, + ) +} diff --git a/pkg/providers/antigravity_provider.go b/pkg/providers/oauth/antigravity_provider.go similarity index 99% rename from pkg/providers/antigravity_provider.go rename to pkg/providers/oauth/antigravity_provider.go index b5ab847d5..38526dd7a 100644 --- a/pkg/providers/antigravity_provider.go +++ b/pkg/providers/oauth/antigravity_provider.go @@ -1,4 +1,4 @@ -package providers +package oauthprovider import ( "bufio" diff --git a/pkg/providers/antigravity_provider_test.go b/pkg/providers/oauth/antigravity_provider_test.go similarity index 99% rename from pkg/providers/antigravity_provider_test.go rename to pkg/providers/oauth/antigravity_provider_test.go index 9155e2d56..41cb5b0db 100644 --- a/pkg/providers/antigravity_provider_test.go +++ b/pkg/providers/oauth/antigravity_provider_test.go @@ -1,4 +1,4 @@ -package providers +package oauthprovider import "testing" diff --git a/pkg/providers/claude_provider.go b/pkg/providers/oauth/claude_provider.go similarity index 91% rename from pkg/providers/claude_provider.go rename to pkg/providers/oauth/claude_provider.go index 60639ca18..cf0052acd 100644 --- a/pkg/providers/claude_provider.go +++ b/pkg/providers/oauth/claude_provider.go @@ -1,9 +1,10 @@ -package providers +package oauthprovider import ( "context" "fmt" + "github.com/sipeed/picoclaw/pkg/auth" anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic" ) @@ -55,7 +56,7 @@ func (p *ClaudeProvider) GetDefaultModel() string { return p.delegate.GetDefaultModel() } -func createClaudeTokenSource() func() (string, error) { +func CreateClaudeTokenSource(getCredential func(string) (*auth.AuthCredential, error)) func() (string, error) { return func() (string, error) { cred, err := getCredential("anthropic") if err != nil { diff --git a/pkg/providers/claude_provider_test.go b/pkg/providers/oauth/claude_provider_test.go similarity index 99% rename from pkg/providers/claude_provider_test.go rename to pkg/providers/oauth/claude_provider_test.go index 98e07bb80..eea5423c3 100644 --- a/pkg/providers/claude_provider_test.go +++ b/pkg/providers/oauth/claude_provider_test.go @@ -1,4 +1,4 @@ -package providers +package oauthprovider import ( "encoding/json" diff --git a/pkg/providers/codex_provider.go b/pkg/providers/oauth/codex_provider.go similarity index 98% rename from pkg/providers/codex_provider.go rename to pkg/providers/oauth/codex_provider.go index d968215cc..0b125997b 100644 --- a/pkg/providers/codex_provider.go +++ b/pkg/providers/oauth/codex_provider.go @@ -1,4 +1,4 @@ -package providers +package oauthprovider import ( "context" @@ -240,7 +240,7 @@ func buildCodexParams( return params } -func createCodexTokenSource() func() (string, string, error) { +func CreateCodexTokenSource() func() (string, string, error) { return func() (string, string, error) { cred, err := auth.GetCredential("openai") if err != nil { diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/oauth/codex_provider_test.go similarity index 99% rename from pkg/providers/codex_provider_test.go rename to pkg/providers/oauth/codex_provider_test.go index ad5748e0c..aeeb18360 100644 --- a/pkg/providers/codex_provider_test.go +++ b/pkg/providers/oauth/codex_provider_test.go @@ -1,4 +1,4 @@ -package providers +package oauthprovider import ( "encoding/json" diff --git a/pkg/providers/oauth/types.go b/pkg/providers/oauth/types.go new file mode 100644 index 000000000..02ea4a21c --- /dev/null +++ b/pkg/providers/oauth/types.go @@ -0,0 +1,32 @@ +package oauthprovider + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +type ( + ToolCall = protocoltypes.ToolCall + FunctionCall = protocoltypes.FunctionCall + LLMResponse = protocoltypes.LLMResponse + UsageInfo = protocoltypes.UsageInfo + Message = protocoltypes.Message + ToolDefinition = protocoltypes.ToolDefinition + ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition + ExtraContent = protocoltypes.ExtraContent + GoogleExtra = protocoltypes.GoogleExtra + ContentBlock = protocoltypes.ContentBlock + CacheControl = protocoltypes.CacheControl +) + +type LLMProvider interface { + Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + ) (*LLMResponse, error) + GetDefaultModel() string +} diff --git a/pkg/providers/oauth_facade.go b/pkg/providers/oauth_facade.go new file mode 100644 index 000000000..c14117773 --- /dev/null +++ b/pkg/providers/oauth_facade.go @@ -0,0 +1,60 @@ +package providers + +import ( + oauthprovider "github.com/sipeed/picoclaw/pkg/providers/oauth" +) + +type ( + AntigravityProvider = oauthprovider.AntigravityProvider + AntigravityModelInfo = oauthprovider.AntigravityModelInfo + ClaudeProvider = oauthprovider.ClaudeProvider + CodexProvider = oauthprovider.CodexProvider +) + +func NewAntigravityProvider() *AntigravityProvider { + return oauthprovider.NewAntigravityProvider() +} + +func NewClaudeProvider(token string) *ClaudeProvider { + return oauthprovider.NewClaudeProvider(token) +} + +func NewClaudeProviderWithBaseURL(token, apiBase string) *ClaudeProvider { + return oauthprovider.NewClaudeProviderWithBaseURL(token, apiBase) +} + +func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string, error)) *ClaudeProvider { + return oauthprovider.NewClaudeProviderWithTokenSource(token, tokenSource) +} + +func NewClaudeProviderWithTokenSourceAndBaseURL( + token string, tokenSource func() (string, error), apiBase string, +) *ClaudeProvider { + return oauthprovider.NewClaudeProviderWithTokenSourceAndBaseURL(token, tokenSource, apiBase) +} + +func NewCodexProvider(token, accountID string) *CodexProvider { + return oauthprovider.NewCodexProvider(token, accountID) +} + +func NewCodexProviderWithTokenSource( + token, accountID string, tokenSource func() (string, string, error), +) *CodexProvider { + return oauthprovider.NewCodexProviderWithTokenSource(token, accountID, tokenSource) +} + +func FetchAntigravityProjectID(accessToken string) (string, error) { + return oauthprovider.FetchAntigravityProjectID(accessToken) +} + +func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelInfo, error) { + return oauthprovider.FetchAntigravityModels(accessToken, projectID) +} + +func createClaudeTokenSource() func() (string, error) { + return oauthprovider.CreateClaudeTokenSource(getCredential) +} + +func createCodexTokenSource() func() (string, string, error) { + return oauthprovider.CreateCodexTokenSource() +} diff --git a/pkg/tools/facade_compat_test.go b/pkg/tools/facade_compat_test.go new file mode 100644 index 000000000..672554209 --- /dev/null +++ b/pkg/tools/facade_compat_test.go @@ -0,0 +1,15 @@ +package tools + +import "testing" + +func TestFacadeConstructorsRemainAvailable(t *testing.T) { + if NewI2CTool() == nil { + t.Fatal("NewI2CTool should return a tool") + } + if NewSPITool() == nil { + t.Fatal("NewSPITool should return a tool") + } + if NewMessageTool() == nil { + t.Fatal("NewMessageTool should return a tool") + } +} diff --git a/pkg/tools/edit.go b/pkg/tools/fs/edit.go similarity index 99% rename from pkg/tools/edit.go rename to pkg/tools/fs/edit.go index c527dab54..827ea50c8 100644 --- a/pkg/tools/edit.go +++ b/pkg/tools/fs/edit.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" diff --git a/pkg/tools/edit_test.go b/pkg/tools/fs/edit_test.go similarity index 99% rename from pkg/tools/edit_test.go rename to pkg/tools/fs/edit_test.go index 83a7e778c..4c25322ef 100644 --- a/pkg/tools/edit_test.go +++ b/pkg/tools/fs/edit_test.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" diff --git a/pkg/tools/filesystem.go b/pkg/tools/fs/filesystem.go similarity index 99% rename from pkg/tools/filesystem.go rename to pkg/tools/fs/filesystem.go index 0f6811f33..262d88d99 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/fs/filesystem.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "bufio" @@ -24,6 +24,18 @@ import ( const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow +func ValidatePathWithAllowPaths( + path, workspace string, + restrict bool, + patterns []*regexp.Regexp, +) (string, error) { + return validatePathWithAllowPaths(path, workspace, restrict, patterns) +} + +func IsAllowedPath(path string, patterns []*regexp.Regexp) bool { + return isAllowedPath(path, patterns) +} + func validatePathWithAllowPaths( path, workspace string, restrict bool, diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/fs/filesystem_test.go similarity index 96% rename from pkg/tools/filesystem_test.go rename to pkg/tools/fs/filesystem_test.go index 0ab37c215..4387332be 100644 --- a/pkg/tools/filesystem_test.go +++ b/pkg/tools/fs/filesystem_test.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" @@ -1050,43 +1050,6 @@ func TestReadFileLinesTool_OffsetBeyondEOF(t *testing.T) { } } -func TestReadFileLinesTool_RegistryValidationSupportsMaxLinesAndRejectsLimit(t *testing.T) { - tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "registry_lines.txt") - - err := os.WriteFile(testFile, []byte("line 1\nline 2\nline 3\n"), 0o644) - if err != nil { - t.Fatalf("Failed to write test file: %v", err) - } - - reg := NewToolRegistry() - reg.Register(NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)) - - result := reg.Execute(context.Background(), "read_file", map[string]any{ - "path": testFile, - "start_line": 1, - "max_lines": 1, - }) - if result.IsError { - t.Fatalf("expected max_lines to pass registry validation, got: %s", result.ForLLM) - } - if !strings.Contains(result.ForLLM, "1|line 1\n") { - t.Fatalf("expected first line via max_lines, got: %s", result.ForLLM) - } - - result = reg.Execute(context.Background(), "read_file", map[string]any{ - "path": testFile, - "start_line": 2, - "limit": 1, - }) - if !result.IsError { - t.Fatalf("expected limit to be rejected, got success: %s", result.ForLLM) - } - if !strings.Contains(result.ForLLM, "unexpected property \"limit\"") { - t.Fatalf("expected registry validation error for limit, got: %s", result.ForLLM) - } -} - func TestReadFileLinesTool_RejectsOffset(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "legacy_offset.txt") diff --git a/pkg/tools/load_image.go b/pkg/tools/fs/load_image.go similarity index 99% rename from pkg/tools/load_image.go rename to pkg/tools/fs/load_image.go index 41ea6d054..6f612faea 100644 --- a/pkg/tools/load_image.go +++ b/pkg/tools/fs/load_image.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" diff --git a/pkg/tools/load_image_test.go b/pkg/tools/fs/load_image_test.go similarity index 90% rename from pkg/tools/load_image_test.go rename to pkg/tools/fs/load_image_test.go index 91118f93e..72f163d81 100644 --- a/pkg/tools/load_image_test.go +++ b/pkg/tools/fs/load_image_test.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" @@ -9,7 +9,6 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/media" - "github.com/sipeed/picoclaw/pkg/providers" ) func TestLoadImage_PathRequired(t *testing.T) { @@ -78,28 +77,6 @@ func TestLoadImage_FileTooLarge(t *testing.T) { } } -func TestSubagentManager_SetMediaResolver_StoresResolver(t *testing.T) { - manager := NewSubagentManager(nil, "gpt-test", "/tmp") - - called := false - manager.SetMediaResolver(func(msgs []providers.Message) []providers.Message { - called = true - return msgs - }) - - manager.mu.RLock() - got := manager.mediaResolver - manager.mu.RUnlock() - - if got == nil { - t.Fatal("expected mediaResolver to be set") - } - - if called { - t.Fatal("resolver should not be called during SetMediaResolver") - } -} - func TestLoadImage_SuccessPath(t *testing.T) { dir := t.TempDir() diff --git a/pkg/tools/send_file.go b/pkg/tools/fs/send_file.go similarity index 99% rename from pkg/tools/send_file.go rename to pkg/tools/fs/send_file.go index 44198381e..e4f90bf61 100644 --- a/pkg/tools/send_file.go +++ b/pkg/tools/fs/send_file.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" diff --git a/pkg/tools/send_file_test.go b/pkg/tools/fs/send_file_test.go similarity index 99% rename from pkg/tools/send_file_test.go rename to pkg/tools/fs/send_file_test.go index f36baf7d0..771393b75 100644 --- a/pkg/tools/send_file_test.go +++ b/pkg/tools/fs/send_file_test.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" diff --git a/pkg/tools/fs/shared.go b/pkg/tools/fs/shared.go new file mode 100644 index 000000000..6d46e692b --- /dev/null +++ b/pkg/tools/fs/shared.go @@ -0,0 +1,37 @@ +package fstools + +import ( + "context" + + toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" +) + +type ToolResult = toolshared.ToolResult + +func WithToolContext(ctx context.Context, channel, chatID string) context.Context { + return toolshared.WithToolContext(ctx, channel, chatID) +} + +func ToolChannel(ctx context.Context) string { + return toolshared.ToolChannel(ctx) +} + +func ToolChatID(ctx context.Context) string { + return toolshared.ToolChatID(ctx) +} + +func ErrorResult(message string) *ToolResult { + return toolshared.ErrorResult(message) +} + +func NewToolResult(forLLM string) *ToolResult { + return toolshared.NewToolResult(forLLM) +} + +func SilentResult(forLLM string) *ToolResult { + return toolshared.SilentResult(forLLM) +} + +func MediaResult(forLLM string, mediaRefs []string) *ToolResult { + return toolshared.MediaResult(forLLM, mediaRefs) +} diff --git a/pkg/tools/fs_facade.go b/pkg/tools/fs_facade.go new file mode 100644 index 000000000..5ed68f04c --- /dev/null +++ b/pkg/tools/fs_facade.go @@ -0,0 +1,100 @@ +package tools + +import ( + "regexp" + + "github.com/sipeed/picoclaw/pkg/media" + fstools "github.com/sipeed/picoclaw/pkg/tools/fs" +) + +type ( + ReadFileTool = fstools.ReadFileTool + ReadFileLinesTool = fstools.ReadFileLinesTool + WriteFileTool = fstools.WriteFileTool + ListDirTool = fstools.ListDirTool + EditFileTool = fstools.EditFileTool + AppendFileTool = fstools.AppendFileTool + LoadImageTool = fstools.LoadImageTool + SendFileTool = fstools.SendFileTool +) + +const MaxReadFileSize = fstools.MaxReadFileSize + +func NewReadFileTool( + workspace string, + restrict bool, + maxReadFileSize int, + allowPaths ...[]*regexp.Regexp, +) *ReadFileTool { + return fstools.NewReadFileTool(workspace, restrict, maxReadFileSize, allowPaths...) +} + +func NewReadFileBytesTool( + workspace string, + restrict bool, + maxReadFileSize int, + allowPaths ...[]*regexp.Regexp, +) *ReadFileTool { + return fstools.NewReadFileBytesTool(workspace, restrict, maxReadFileSize, allowPaths...) +} + +func NewReadFileLinesTool( + workspace string, + restrict bool, + maxReadFileSize int, + allowPaths ...[]*regexp.Regexp, +) *ReadFileLinesTool { + return fstools.NewReadFileLinesTool(workspace, restrict, maxReadFileSize, allowPaths...) +} + +func NewWriteFileTool( + workspace string, + restrict bool, + allowPaths ...[]*regexp.Regexp, +) *WriteFileTool { + return fstools.NewWriteFileTool(workspace, restrict, allowPaths...) +} + +func NewListDirTool( + workspace string, + restrict bool, + allowPaths ...[]*regexp.Regexp, +) *ListDirTool { + return fstools.NewListDirTool(workspace, restrict, allowPaths...) +} + +func NewEditFileTool( + workspace string, + restrict bool, + allowPaths ...[]*regexp.Regexp, +) *EditFileTool { + return fstools.NewEditFileTool(workspace, restrict, allowPaths...) +} + +func NewAppendFileTool( + workspace string, + restrict bool, + allowPaths ...[]*regexp.Regexp, +) *AppendFileTool { + return fstools.NewAppendFileTool(workspace, restrict, allowPaths...) +} + +func NewLoadImageTool( + workspace string, + restrict bool, + maxFileSize int, + store media.MediaStore, + allowPaths ...[]*regexp.Regexp, +) *LoadImageTool { + return fstools.NewLoadImageTool(workspace, restrict, maxFileSize, store, allowPaths...) +} + +func NewSendFileTool( + workspace string, + restrict bool, + maxFileSize int, + store media.MediaStore, + allowPaths ...[]*regexp.Regexp, +) *SendFileTool { + return fstools.NewSendFileTool(workspace, restrict, maxFileSize, store, allowPaths...) +} diff --git a/pkg/tools/fs_registry_compat_test.go b/pkg/tools/fs_registry_compat_test.go new file mode 100644 index 000000000..51e080217 --- /dev/null +++ b/pkg/tools/fs_registry_compat_test.go @@ -0,0 +1,46 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestReadFileLinesTool_RegistryValidationSupportsMaxLinesAndRejectsLimit(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "registry_lines.txt") + + err := os.WriteFile(testFile, []byte("line 1\nline 2\nline 3\n"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + reg := NewToolRegistry() + reg.Register(NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)) + + result := reg.Execute(context.Background(), "read_file", map[string]any{ + "path": testFile, + "start_line": 1, + "max_lines": 1, + }) + if result.IsError { + t.Fatalf("expected max_lines to pass registry validation, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "1|line 1\n") { + t.Fatalf("expected first line via max_lines, got: %s", result.ForLLM) + } + + result = reg.Execute(context.Background(), "read_file", map[string]any{ + "path": testFile, + "start_line": 2, + "limit": 1, + }) + if !result.IsError { + t.Fatalf("expected limit to be rejected, got success: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "unexpected property \"limit\"") { + t.Fatalf("expected registry validation error for limit, got: %s", result.ForLLM) + } +} diff --git a/pkg/tools/i2c.go b/pkg/tools/hardware/i2c.go similarity index 97% rename from pkg/tools/i2c.go rename to pkg/tools/hardware/i2c.go index 779b1d5a7..62e9557ee 100644 --- a/pkg/tools/i2c.go +++ b/pkg/tools/hardware/i2c.go @@ -1,4 +1,4 @@ -package tools +package hardwaretools import ( "context" @@ -120,16 +120,12 @@ func (t *I2CTool) detect() *ToolResult { // Helper functions for I2C operations (used by platform-specific implementations) // isValidBusID checks that a bus identifier is a simple number (prevents path injection) -// -//nolint:unused // Used by i2c_linux.go func isValidBusID(id string) bool { matched, _ := regexp.MatchString(`^\d+$`, id) return matched } // parseI2CAddress extracts and validates an I2C address from args -// -//nolint:unused // Used by i2c_linux.go func parseI2CAddress(args map[string]any) (int, *ToolResult) { addrFloat, ok := args["address"].(float64) if !ok { @@ -143,8 +139,6 @@ func parseI2CAddress(args map[string]any) (int, *ToolResult) { } // parseI2CBus extracts and validates an I2C bus from args -// -//nolint:unused // Used by i2c_linux.go func parseI2CBus(args map[string]any) (string, *ToolResult) { bus, ok := args["bus"].(string) if !ok || bus == "" { @@ -155,3 +149,9 @@ func parseI2CBus(args map[string]any) (string, *ToolResult) { } return bus, nil } + +var ( + _ = isValidBusID + _ = parseI2CAddress + _ = parseI2CBus +) diff --git a/pkg/tools/i2c_linux.go b/pkg/tools/hardware/i2c_linux.go similarity index 99% rename from pkg/tools/i2c_linux.go rename to pkg/tools/hardware/i2c_linux.go index 4eaaf8f09..771d11d90 100644 --- a/pkg/tools/i2c_linux.go +++ b/pkg/tools/hardware/i2c_linux.go @@ -1,4 +1,4 @@ -package tools +package hardwaretools import ( "encoding/json" diff --git a/pkg/tools/i2c_other.go b/pkg/tools/hardware/i2c_other.go similarity index 95% rename from pkg/tools/i2c_other.go rename to pkg/tools/hardware/i2c_other.go index 7becf8339..4a0a130e0 100644 --- a/pkg/tools/i2c_other.go +++ b/pkg/tools/hardware/i2c_other.go @@ -1,6 +1,6 @@ //go:build !linux -package tools +package hardwaretools // scan is a stub for non-Linux platforms. func (t *I2CTool) scan(args map[string]any) *ToolResult { diff --git a/pkg/tools/hardware/shared.go b/pkg/tools/hardware/shared.go new file mode 100644 index 000000000..3012f3e6c --- /dev/null +++ b/pkg/tools/hardware/shared.go @@ -0,0 +1,13 @@ +package hardwaretools + +import toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" + +type ToolResult = toolshared.ToolResult + +func ErrorResult(message string) *ToolResult { + return toolshared.ErrorResult(message) +} + +func SilentResult(forLLM string) *ToolResult { + return toolshared.SilentResult(forLLM) +} diff --git a/pkg/tools/spi.go b/pkg/tools/hardware/spi.go similarity index 98% rename from pkg/tools/spi.go rename to pkg/tools/hardware/spi.go index 0ca17e84f..0bc0d8f72 100644 --- a/pkg/tools/spi.go +++ b/pkg/tools/hardware/spi.go @@ -1,4 +1,4 @@ -package tools +package hardwaretools import ( "context" @@ -122,8 +122,6 @@ func (t *SPITool) list() *ToolResult { // Helper function for SPI operations (used by platform-specific implementations) // parseSPIArgs extracts and validates common SPI parameters -// -//nolint:unused // Used by spi_linux.go func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8, bits uint8, errMsg string) { dev, ok := args["device"].(string) if !ok || dev == "" { @@ -160,3 +158,5 @@ func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8, return dev, speed, mode, bits, "" } + +var _ = parseSPIArgs diff --git a/pkg/tools/spi_linux.go b/pkg/tools/hardware/spi_linux.go similarity index 99% rename from pkg/tools/spi_linux.go rename to pkg/tools/hardware/spi_linux.go index 9def73662..8502d6b9e 100644 --- a/pkg/tools/spi_linux.go +++ b/pkg/tools/hardware/spi_linux.go @@ -1,4 +1,4 @@ -package tools +package hardwaretools import ( "encoding/json" diff --git a/pkg/tools/spi_other.go b/pkg/tools/hardware/spi_other.go similarity index 94% rename from pkg/tools/spi_other.go rename to pkg/tools/hardware/spi_other.go index 5d078ac3f..89fc99e67 100644 --- a/pkg/tools/spi_other.go +++ b/pkg/tools/hardware/spi_other.go @@ -1,6 +1,6 @@ //go:build !linux -package tools +package hardwaretools // transfer is a stub for non-Linux platforms. func (t *SPITool) transfer(args map[string]any) *ToolResult { diff --git a/pkg/tools/hardware_facade.go b/pkg/tools/hardware_facade.go new file mode 100644 index 000000000..f55d152cf --- /dev/null +++ b/pkg/tools/hardware_facade.go @@ -0,0 +1,16 @@ +package tools + +import hardwaretools "github.com/sipeed/picoclaw/pkg/tools/hardware" + +type ( + I2CTool = hardwaretools.I2CTool + SPITool = hardwaretools.SPITool +) + +func NewI2CTool() *I2CTool { + return hardwaretools.NewI2CTool() +} + +func NewSPITool() *SPITool { + return hardwaretools.NewSPITool() +} diff --git a/pkg/tools/identifier_compat.go b/pkg/tools/identifier_compat.go new file mode 100644 index 000000000..c5a6d9cf3 --- /dev/null +++ b/pkg/tools/identifier_compat.go @@ -0,0 +1,48 @@ +package tools + +import "strings" + +func sanitizeIdentifierComponent(s string) string { + const maxLen = 64 + + s = strings.ToLower(s) + var b strings.Builder + b.Grow(len(s)) + + prevUnderscore := false + for _, r := range s { + isAllowed := (r >= 'a' && r <= 'z') || + (r >= '0' && r <= '9') || + r == '_' || r == '-' + + if !isAllowed { + if !prevUnderscore { + b.WriteRune('_') + prevUnderscore = true + } + continue + } + + if r == '_' { + if prevUnderscore { + continue + } + prevUnderscore = true + } else { + prevUnderscore = false + } + + b.WriteRune(r) + } + + result := strings.Trim(b.String(), "_") + if result == "" { + result = "unnamed" + } + + if len(result) > maxLen { + result = result[:maxLen] + } + + return result +} diff --git a/pkg/tools/integration/helpers.go b/pkg/tools/integration/helpers.go new file mode 100644 index 000000000..b34fbc6cd --- /dev/null +++ b/pkg/tools/integration/helpers.go @@ -0,0 +1,134 @@ +package integrationtools + +import ( + "fmt" + "math" + "mime" + "path/filepath" + "regexp" + "strconv" + "strings" + "unicode" +) + +var ( + inlineMarkdownDataURLRe = regexp.MustCompile(`!\[[^\]]*\]\((data:[^)]+)\)`) + inlineRawDataURLRe = regexp.MustCompile(`data:[^;\s]+;base64,[A-Za-z0-9+/=\r\n]+`) +) + +const ( + largeBase64OmittedMessage = "[Tool returned a large base64-like payload; omitted from model context.]" + inlineMediaOmittedMessage = "[Tool returned inline media content; omitted from model context.]" +) + +func sanitizeToolLLMContent(text string) string { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return text + } + if inlineMarkdownDataURLRe.MatchString(trimmed) || inlineRawDataURLRe.MatchString(trimmed) { + cleaned := inlineMarkdownDataURLRe.ReplaceAllString(trimmed, "") + cleaned = inlineRawDataURLRe.ReplaceAllString(cleaned, "") + cleaned = strings.TrimSpace(cleaned) + if cleaned == "" { + return inlineMediaOmittedMessage + } + return cleaned + "\n" + inlineMediaOmittedMessage + } + if looksLikeLargeBase64Payload(trimmed) { + return largeBase64OmittedMessage + } + return text +} + +func looksLikeLargeBase64Payload(text string) bool { + trimmed := strings.TrimSpace(text) + if len(trimmed) < 1024 { + return false + } + + nonSpace := 0 + base64Like := 0 + spaceCount := 0 + + for _, r := range trimmed { + if unicode.IsSpace(r) { + spaceCount++ + continue + } + nonSpace++ + if (r >= 'A' && r <= 'Z') || + (r >= 'a' && r <= 'z') || + (r >= '0' && r <= '9') || + r == '+' || r == '/' || r == '=' { + base64Like++ + } + } + + if nonSpace == 0 { + return false + } + + ratio := float64(base64Like) / float64(nonSpace) + return ratio >= 0.97 && spaceCount <= len(trimmed)/128 +} + +func extensionForMIMEType(mimeType string) string { + if mimeType == "" { + return ".bin" + } + if exts, err := mime.ExtensionsByType(mimeType); err == nil && len(exts) > 0 { + return exts[0] + } + + switch strings.ToLower(mimeType) { + case "image/jpeg": + return ".jpg" + case "image/png": + return ".png" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + case "audio/wav", "audio/x-wav": + return ".wav" + case "audio/mpeg": + return ".mp3" + case "audio/ogg": + return ".ogg" + case "video/mp4": + return ".mp4" + default: + return filepath.Ext(mimeType) + } +} + +func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, error) { + raw, exists := args[key] + if !exists { + return defaultVal, nil + } + + switch v := raw.(type) { + case float64: + if v != math.Trunc(v) { + return 0, fmt.Errorf("%s must be an integer, got float %v", key, v) + } + if v > math.MaxInt64 || v < math.MinInt64 { + return 0, fmt.Errorf("%s value %v overflows int64", key, v) + } + return int64(v), nil + case int: + return int64(v), nil + case int64: + return v, nil + case string: + parsed, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid integer format for %s parameter: %w", key, err) + } + return parsed, nil + default: + return 0, fmt.Errorf("unsupported type %T for %s parameter", raw, key) + } +} diff --git a/pkg/tools/mcp_tool.go b/pkg/tools/integration/mcp_tool.go similarity index 99% rename from pkg/tools/mcp_tool.go rename to pkg/tools/integration/mcp_tool.go index 1caf390cf..340bb9e8e 100644 --- a/pkg/tools/mcp_tool.go +++ b/pkg/tools/integration/mcp_tool.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/mcp_tool_test.go b/pkg/tools/integration/mcp_tool_test.go similarity index 99% rename from pkg/tools/mcp_tool_test.go rename to pkg/tools/integration/mcp_tool_test.go index f2b02d6f6..e5c54abb6 100644 --- a/pkg/tools/mcp_tool_test.go +++ b/pkg/tools/integration/mcp_tool_test.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/message.go b/pkg/tools/integration/message.go similarity index 99% rename from pkg/tools/message.go rename to pkg/tools/integration/message.go index 796e0af3d..98d87bcb3 100644 --- a/pkg/tools/message.go +++ b/pkg/tools/integration/message.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/message_test.go b/pkg/tools/integration/message_test.go similarity index 99% rename from pkg/tools/message_test.go rename to pkg/tools/integration/message_test.go index 649593252..c7b7d2b6e 100644 --- a/pkg/tools/message_test.go +++ b/pkg/tools/integration/message_test.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/reaction.go b/pkg/tools/integration/reaction.go similarity index 98% rename from pkg/tools/reaction.go rename to pkg/tools/integration/reaction.go index 3455b07a9..5a8dc87be 100644 --- a/pkg/tools/reaction.go +++ b/pkg/tools/integration/reaction.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/reaction_test.go b/pkg/tools/integration/reaction_test.go similarity index 99% rename from pkg/tools/reaction_test.go rename to pkg/tools/integration/reaction_test.go index 6fc90445a..f579fd914 100644 --- a/pkg/tools/reaction_test.go +++ b/pkg/tools/integration/reaction_test.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/integration/shared.go b/pkg/tools/integration/shared.go new file mode 100644 index 000000000..cc6aa3f28 --- /dev/null +++ b/pkg/tools/integration/shared.go @@ -0,0 +1,77 @@ +package integrationtools + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/session" + toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" +) + +type ( + Tool = toolshared.Tool + ToolResult = toolshared.ToolResult + AsyncCallback = toolshared.AsyncCallback +) + +func WithToolContext(ctx context.Context, channel, chatID string) context.Context { + return toolshared.WithToolContext(ctx, channel, chatID) +} + +func WithToolInboundContext( + ctx context.Context, + channel, chatID, messageID, replyToMessageID string, +) context.Context { + return toolshared.WithToolInboundContext(ctx, channel, chatID, messageID, replyToMessageID) +} + +func WithToolSessionContext( + ctx context.Context, + agentID, sessionKey string, + scope *session.SessionScope, +) context.Context { + return toolshared.WithToolSessionContext(ctx, agentID, sessionKey, scope) +} + +func ToolChannel(ctx context.Context) string { + return toolshared.ToolChannel(ctx) +} + +func ToolChatID(ctx context.Context) string { + return toolshared.ToolChatID(ctx) +} + +func ToolMessageID(ctx context.Context) string { + return toolshared.ToolMessageID(ctx) +} + +func ToolAgentID(ctx context.Context) string { + return toolshared.ToolAgentID(ctx) +} + +func ToolSessionKey(ctx context.Context) string { + return toolshared.ToolSessionKey(ctx) +} + +func ToolSessionScope(ctx context.Context) *session.SessionScope { + return toolshared.ToolSessionScope(ctx) +} + +func ErrorResult(message string) *ToolResult { + return toolshared.ErrorResult(message) +} + +func SilentResult(forLLM string) *ToolResult { + return toolshared.SilentResult(forLLM) +} + +func NewToolResult(forLLM string) *ToolResult { + return toolshared.NewToolResult(forLLM) +} + +func UserResult(content string) *ToolResult { + return toolshared.UserResult(content) +} + +func MediaResult(forLLM string, mediaRefs []string) *ToolResult { + return toolshared.MediaResult(forLLM, mediaRefs) +} diff --git a/pkg/tools/skills_install.go b/pkg/tools/integration/skills_install.go similarity index 99% rename from pkg/tools/skills_install.go rename to pkg/tools/integration/skills_install.go index 79d0672b9..1824f2c0a 100644 --- a/pkg/tools/skills_install.go +++ b/pkg/tools/integration/skills_install.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/skills_install_test.go b/pkg/tools/integration/skills_install_test.go similarity index 99% rename from pkg/tools/skills_install_test.go rename to pkg/tools/integration/skills_install_test.go index 125348883..01d2fd2bc 100644 --- a/pkg/tools/skills_install_test.go +++ b/pkg/tools/integration/skills_install_test.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/skills_search.go b/pkg/tools/integration/skills_search.go similarity index 99% rename from pkg/tools/skills_search.go rename to pkg/tools/integration/skills_search.go index 2b6cffd38..f080aba95 100644 --- a/pkg/tools/skills_search.go +++ b/pkg/tools/integration/skills_search.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/skills_search_test.go b/pkg/tools/integration/skills_search_test.go similarity index 99% rename from pkg/tools/skills_search_test.go rename to pkg/tools/integration/skills_search_test.go index 0e5387cf5..fcce48b49 100644 --- a/pkg/tools/skills_search_test.go +++ b/pkg/tools/integration/skills_search_test.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/tts_send.go b/pkg/tools/integration/tts_send.go similarity index 98% rename from pkg/tools/tts_send.go rename to pkg/tools/integration/tts_send.go index 3d569e3f7..6c9135624 100644 --- a/pkg/tools/tts_send.go +++ b/pkg/tools/integration/tts_send.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/web.go b/pkg/tools/integration/web.go similarity index 99% rename from pkg/tools/web.go rename to pkg/tools/integration/web.go index 9883d55e5..58db34589 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/integration/web.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "bytes" diff --git a/pkg/tools/web_test.go b/pkg/tools/integration/web_test.go similarity index 99% rename from pkg/tools/web_test.go rename to pkg/tools/integration/web_test.go index cf9d22d05..4ad5a3468 100644 --- a/pkg/tools/web_test.go +++ b/pkg/tools/integration/web_test.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "bytes" diff --git a/pkg/tools/integration_facade.go b/pkg/tools/integration_facade.go new file mode 100644 index 000000000..00c00b810 --- /dev/null +++ b/pkg/tools/integration_facade.go @@ -0,0 +1,101 @@ +package tools + +import ( + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/sipeed/picoclaw/pkg/audio/tts" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/skills" + integrationtools "github.com/sipeed/picoclaw/pkg/tools/integration" +) + +type ( + SendCallbackWithContext = integrationtools.SendCallbackWithContext + ReactionCallback = integrationtools.ReactionCallback + MCPManager = integrationtools.MCPManager + MCPTool = integrationtools.MCPTool + FindSkillsTool = integrationtools.FindSkillsTool + InstallSkillTool = integrationtools.InstallSkillTool + MessageTool = integrationtools.MessageTool + ReactionTool = integrationtools.ReactionTool + SendTTSTool = integrationtools.SendTTSTool + APIKeyPool = integrationtools.APIKeyPool + APIKeyIterator = integrationtools.APIKeyIterator + SearchProvider = integrationtools.SearchProvider + SearchResultItem = integrationtools.SearchResultItem + BraveSearchProvider = integrationtools.BraveSearchProvider + TavilySearchProvider = integrationtools.TavilySearchProvider + SogouSearchProvider = integrationtools.SogouSearchProvider + DuckDuckGoSearchProvider = integrationtools.DuckDuckGoSearchProvider + PerplexitySearchProvider = integrationtools.PerplexitySearchProvider + SearXNGSearchProvider = integrationtools.SearXNGSearchProvider + GLMSearchProvider = integrationtools.GLMSearchProvider + BaiduSearchProvider = integrationtools.BaiduSearchProvider + WebSearchTool = integrationtools.WebSearchTool + WebSearchToolOptions = integrationtools.WebSearchToolOptions + WebFetchTool = integrationtools.WebFetchTool +) + +func NewMCPTool(manager MCPManager, serverName string, tool *mcp.Tool) *MCPTool { + return integrationtools.NewMCPTool(manager, serverName, tool) +} + +func NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache) *FindSkillsTool { + return integrationtools.NewFindSkillsTool(registryMgr, cache) +} + +func NewInstallSkillTool(registryMgr *skills.RegistryManager, workspace string) *InstallSkillTool { + return integrationtools.NewInstallSkillTool(registryMgr, workspace) +} + +func NewMessageTool() *MessageTool { + return integrationtools.NewMessageTool() +} + +func NewReactionTool() *ReactionTool { + return integrationtools.NewReactionTool() +} + +func NewSendTTSTool(provider tts.TTSProvider, store media.MediaStore) *SendTTSTool { + return integrationtools.NewSendTTSTool(provider, store) +} + +func NewAPIKeyPool(keys []string) *APIKeyPool { + return integrationtools.NewAPIKeyPool(keys) +} + +func SetPreferredWebSearchLanguage(lang string) { + integrationtools.SetPreferredWebSearchLanguage(lang) +} + +func GetPreferredWebSearchLanguage() string { + return integrationtools.GetPreferredWebSearchLanguage() +} + +func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { + return integrationtools.NewWebSearchTool(opts) +} + +func NewWebFetchTool(maxChars int, format string, fetchLimitBytes int64) (*WebFetchTool, error) { + return integrationtools.NewWebFetchTool(maxChars, format, fetchLimitBytes) +} + +func NewWebFetchToolWithProxy( + maxChars int, + proxy string, + format string, + fetchLimitBytes int64, + privateHostWhitelist []string, +) (*WebFetchTool, error) { + return integrationtools.NewWebFetchToolWithProxy(maxChars, proxy, format, fetchLimitBytes, privateHostWhitelist) +} + +func NewWebFetchToolWithConfig( + maxChars int, + proxy string, + format string, + fetchLimitBytes int64, + privateHostWhitelist []string, +) (*WebFetchTool, error) { + return integrationtools.NewWebFetchToolWithConfig(maxChars, proxy, format, fetchLimitBytes, privateHostWhitelist) +} diff --git a/pkg/tools/load_image_compat_test.go b/pkg/tools/load_image_compat_test.go new file mode 100644 index 000000000..a29ee2042 --- /dev/null +++ b/pkg/tools/load_image_compat_test.go @@ -0,0 +1,29 @@ +package tools + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +func TestSubagentManager_SetMediaResolver_StoresResolver(t *testing.T) { + manager := NewSubagentManager(nil, "gpt-test", "/tmp") + + called := false + manager.SetMediaResolver(func(msgs []providers.Message) []providers.Message { + called = true + return msgs + }) + + manager.mu.RLock() + got := manager.mediaResolver + manager.mu.RUnlock() + + if got == nil { + t.Fatal("expected mediaResolver to be set") + } + + if called { + t.Fatal("resolver should not be called during SetMediaResolver") + } +} diff --git a/pkg/tools/path_compat.go b/pkg/tools/path_compat.go new file mode 100644 index 000000000..9e677cb2b --- /dev/null +++ b/pkg/tools/path_compat.go @@ -0,0 +1,19 @@ +package tools + +import ( + "regexp" + + fstools "github.com/sipeed/picoclaw/pkg/tools/fs" +) + +func validatePathWithAllowPaths( + path, workspace string, + restrict bool, + patterns []*regexp.Regexp, +) (string, error) { + return fstools.ValidatePathWithAllowPaths(path, workspace, restrict, patterns) +} + +func isAllowedPath(path string, patterns []*regexp.Regexp) bool { + return fstools.IsAllowedPath(path, patterns) +} diff --git a/pkg/tools/session.go b/pkg/tools/session.go index 141dd4b5e..8c7584254 100644 --- a/pkg/tools/session.go +++ b/pkg/tools/session.go @@ -242,11 +242,3 @@ func (sm *SessionManager) List() []SessionInfo { func generateSessionID() string { return uuid.New().String()[:8] } - -type SessionInfo struct { - ID string `json:"id"` - Command string `json:"command"` - Status string `json:"status"` - PID int `json:"pid"` - StartedAt int64 `json:"startedAt"` -} diff --git a/pkg/tools/base.go b/pkg/tools/shared/base.go similarity index 99% rename from pkg/tools/base.go rename to pkg/tools/shared/base.go index e1f9aacc0..5498d24ab 100644 --- a/pkg/tools/base.go +++ b/pkg/tools/shared/base.go @@ -1,4 +1,4 @@ -package tools +package toolshared import ( "context" diff --git a/pkg/tools/result.go b/pkg/tools/shared/result.go similarity index 95% rename from pkg/tools/result.go rename to pkg/tools/shared/result.go index c81213125..e4b16f7b3 100644 --- a/pkg/tools/result.go +++ b/pkg/tools/shared/result.go @@ -1,4 +1,4 @@ -package tools +package toolshared import ( "encoding/json" @@ -8,8 +8,8 @@ import ( ) const ( - handledToolLLMNote = "The requested output has already been delivered to the user in the current chat. Do not call send_file or any other delivery tool again. If you reply, provide only a brief confirmation." - artifactPathsLLMNote = "Use `send_file` with one of these paths to send it to the user, or use file/exec tools to save it inside the workspace if requested." + HandledToolLLMNote = "The requested output has already been delivered to the user in the current chat. Do not call send_file or any other delivery tool again. If you reply, provide only a brief confirmation." + ArtifactPathsLLMNote = "Use `send_file` with one of these paths to send it to the user, or use file/exec tools to save it inside the workspace if requested." ) // ToolResult represents the structured return value from tool execution. @@ -73,14 +73,14 @@ func (tr *ToolResult) ContentForLLM() string { } if tr.ResponseHandled { if content == "" { - return handledToolLLMNote + return HandledToolLLMNote } - if !strings.Contains(content, handledToolLLMNote) { - content += "\n" + handledToolLLMNote + if !strings.Contains(content, HandledToolLLMNote) { + content += "\n" + HandledToolLLMNote } } if len(tr.ArtifactTags) > 0 { - artifactNote := "Local artifact paths: " + strings.Join(tr.ArtifactTags, " ") + "\n" + artifactPathsLLMNote + artifactNote := "Local artifact paths: " + strings.Join(tr.ArtifactTags, " ") + "\n" + ArtifactPathsLLMNote if content == "" { content = artifactNote } else if !strings.Contains(content, artifactNote) { diff --git a/pkg/tools/types.go b/pkg/tools/shared/types.go similarity index 91% rename from pkg/tools/types.go rename to pkg/tools/shared/types.go index 4d1a18d5a..8a74d30f3 100644 --- a/pkg/tools/types.go +++ b/pkg/tools/shared/types.go @@ -1,4 +1,4 @@ -package tools +package toolshared import "context" @@ -77,3 +77,11 @@ type ExecResponse struct { Error string `json:"error,omitempty"` Sessions []SessionInfo `json:"sessions,omitempty"` } + +type SessionInfo struct { + ID string `json:"id"` + Command string `json:"command"` + Status string `json:"status"` + PID int `json:"pid"` + StartedAt int64 `json:"startedAt"` +} diff --git a/pkg/tools/shared_facade.go b/pkg/tools/shared_facade.go new file mode 100644 index 000000000..6e40e4e3a --- /dev/null +++ b/pkg/tools/shared_facade.go @@ -0,0 +1,110 @@ +package tools + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/session" + toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" +) + +type ( + Message = toolshared.Message + ToolCall = toolshared.ToolCall + FunctionCall = toolshared.FunctionCall + LLMResponse = toolshared.LLMResponse + UsageInfo = toolshared.UsageInfo + LLMProvider = toolshared.LLMProvider + ToolDefinition = toolshared.ToolDefinition + ToolFunctionDefinition = toolshared.ToolFunctionDefinition + ExecRequest = toolshared.ExecRequest + ExecResponse = toolshared.ExecResponse + SessionInfo = toolshared.SessionInfo + Tool = toolshared.Tool + AsyncCallback = toolshared.AsyncCallback + AsyncExecutor = toolshared.AsyncExecutor + ToolResult = toolshared.ToolResult +) + +const ( + handledToolLLMNote = toolshared.HandledToolLLMNote + artifactPathsLLMNote = toolshared.ArtifactPathsLLMNote +) + +func WithToolContext(ctx context.Context, channel, chatID string) context.Context { + return toolshared.WithToolContext(ctx, channel, chatID) +} + +func WithToolMessageContext(ctx context.Context, messageID, replyToMessageID string) context.Context { + return toolshared.WithToolMessageContext(ctx, messageID, replyToMessageID) +} + +func WithToolInboundContext( + ctx context.Context, + channel, chatID, messageID, replyToMessageID string, +) context.Context { + return toolshared.WithToolInboundContext(ctx, channel, chatID, messageID, replyToMessageID) +} + +func WithToolSessionContext( + ctx context.Context, + agentID, sessionKey string, + scope *session.SessionScope, +) context.Context { + return toolshared.WithToolSessionContext(ctx, agentID, sessionKey, scope) +} + +func ToolChannel(ctx context.Context) string { + return toolshared.ToolChannel(ctx) +} + +func ToolChatID(ctx context.Context) string { + return toolshared.ToolChatID(ctx) +} + +func ToolMessageID(ctx context.Context) string { + return toolshared.ToolMessageID(ctx) +} + +func ToolReplyToMessageID(ctx context.Context) string { + return toolshared.ToolReplyToMessageID(ctx) +} + +func ToolAgentID(ctx context.Context) string { + return toolshared.ToolAgentID(ctx) +} + +func ToolSessionKey(ctx context.Context) string { + return toolshared.ToolSessionKey(ctx) +} + +func ToolSessionScope(ctx context.Context) *session.SessionScope { + return toolshared.ToolSessionScope(ctx) +} + +func ToolToSchema(tool Tool) map[string]any { + return toolshared.ToolToSchema(tool) +} + +func NewToolResult(forLLM string) *ToolResult { + return toolshared.NewToolResult(forLLM) +} + +func SilentResult(forLLM string) *ToolResult { + return toolshared.SilentResult(forLLM) +} + +func AsyncResult(forLLM string) *ToolResult { + return toolshared.AsyncResult(forLLM) +} + +func ErrorResult(message string) *ToolResult { + return toolshared.ErrorResult(message) +} + +func UserResult(content string) *ToolResult { + return toolshared.UserResult(content) +} + +func MediaResult(forLLM string, mediaRefs []string) *ToolResult { + return toolshared.MediaResult(forLLM, mediaRefs) +} diff --git a/scripts/lint-docs.sh b/scripts/lint-docs.sh new file mode 100755 index 000000000..7351298b6 --- /dev/null +++ b/scripts/lint-docs.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +failures=0 + +error() { + local path="$1" + local reason="$2" + local suggestion="${3:-}" + + echo "docs lint: $path" >&2 + echo " reason: $reason" >&2 + if [[ -n "$suggestion" ]]; then + echo " fix: $suggestion" >&2 + fi + failures=1 +} + +lowercase() { + printf '%s' "$1" | tr '[:upper:]' '[:lower:]' +} + +suggest_noncanonical_translation_name() { + local path="$1" + local dir + local base + local stem + local locale + + dir="$(dirname "$path")" + base="$(basename "$path")" + + if [[ "$base" =~ ^(.+)_([A-Za-z]{2}(-[A-Za-z]{2})?)\.md$ ]]; then + stem="${BASH_REMATCH[1]}" + locale="$(lowercase "${BASH_REMATCH[2]}")" + printf '%s/%s.%s.md' "$dir" "$stem" "$locale" + return + fi + + if [[ "$base" =~ ^(.+)\.([A-Za-z]{2}(-[A-Za-z]{2})?)\.md$ ]]; then + stem="${BASH_REMATCH[1]}" + locale="$(lowercase "${BASH_REMATCH[2]}")" + printf '%s/%s.%s.md' "$dir" "$stem" "$locale" + return + fi + + printf 'rename it to use a lowercase ..md suffix beside the English source' +} + +suggest_docs_language_bucket_target() { + local path="$1" + local locale + local file + local name + local -a matches + + if [[ "$path" =~ ^docs/([A-Za-z]{2}(-[A-Za-z]{2})?)/.+\.md$ ]]; then + locale="$(lowercase "${BASH_REMATCH[1]}")" + file="$(basename "$path")" + name="${file%.md}" + mapfile -t matches < <(find docs/project docs/guides docs/reference docs/operations docs/security docs/architecture docs/channels docs/design docs/migration -type f -name "${name}.md" 2>/dev/null | sort) + if [[ "${#matches[@]}" -eq 1 ]]; then + printf '%s' "${matches[0]%.md}.${locale}.md" + return + fi + fi + + printf 'move it to a typed docs directory and rename it to ..md beside the English source' +} + +suggest_nested_locale_bucket_target() { + local path="$1" + local prefix + local locale + local rest + + if [[ "$path" =~ ^(docs/(project|guides|reference|operations|security|architecture|design|migration))/([A-Za-z]{2}(-[A-Za-z]{2})?)/(.*)\.md$ ]]; then + prefix="${BASH_REMATCH[1]}" + locale="$(lowercase "${BASH_REMATCH[3]}")" + rest="${BASH_REMATCH[5]}" + printf '%s/%s.%s.md' "$prefix" "$rest" "$locale" + return + fi + + if [[ "$path" =~ ^(docs/channels/[^/]+)/([A-Za-z]{2}(-[A-Za-z]{2})?)/(.*)\.md$ ]]; then + prefix="${BASH_REMATCH[1]}" + locale="$(lowercase "${BASH_REMATCH[2]}")" + rest="${BASH_REMATCH[4]}" + printf '%s/%s.%s.md' "$prefix" "$rest" "$locale" + return + fi + + printf 'move the file beside its English source and rename it to ..md' +} + +is_noncanonical_translation_name() { + local path="$1" + local base + + base="$(basename "$path")" + + [[ "$base" =~ ^.+_[A-Za-z]{2}(-[A-Za-z]{2})?\.md$ ]] && return 0 + [[ "$base" =~ ^.+\.[A-Z]{2}(-[A-Z]{2})?\.md$ ]] && return 0 + [[ "$base" =~ ^.+\.[a-z]{2}-[A-Z]{2}\.md$ ]] && return 0 + [[ "$base" =~ ^.+\.[A-Z]{2}-[a-z]{2}\.md$ ]] && return 0 + + return 1 +} + +is_noncanonical_locale_bucket() { + local path="$1" + + [[ "$path" =~ ^docs/(project|guides|reference|operations|security|architecture|design|migration)/[A-Za-z]{2}(-[A-Za-z]{2})?/ ]] && return 0 + [[ "$path" =~ ^docs/channels/[^/]+/[A-Za-z]{2}(-[A-Za-z]{2})?/ ]] && return 0 + return 1 +} + +is_root_docs_language_bucket() { + local path="$1" + [[ "$path" =~ ^docs/[A-Za-z]{2}(-[A-Za-z]{2})?/ ]] +} + +is_translation_file() { + local path="$1" + [[ "$path" =~ ^(.+)\.([a-z]{2})(-[a-z]{2})?\.md$ ]] +} + +translation_base() { + local path="$1" + local locale="$2" + + if [[ "$path" == docs/project/* ]]; then + local rel="${path#docs/project/}" + echo "${rel%.$locale.md}.md" + return + fi + + echo "${path%.$locale.md}.md" +} + +while IFS= read -r path; do + [[ -f "$path" ]] || continue + + case "$path" in + README.*.md) + error \ + "$path" \ + "translated project entry docs must live under docs/project/" \ + "move it to docs/project/$(basename "$path")" + ;; + CONTRIBUTING.*.md) + error \ + "$path" \ + "translated project entry docs must live under docs/project/" \ + "move it to docs/project/$(basename "$path")" + ;; + esac + + if [[ "$path" =~ (^|/)README_[A-Za-z0-9-]+\.md$ ]]; then + error \ + "$path" \ + "legacy README translation names are not allowed" \ + "rename it to use README..md, for example $(suggest_noncanonical_translation_name "$path")" + fi + + if is_noncanonical_translation_name "$path"; then + error \ + "$path" \ + "translation files must use lowercase ..md suffixes and no underscore variants" \ + "rename it to $(suggest_noncanonical_translation_name "$path")" + fi + + if is_root_docs_language_bucket "$path"; then + error \ + "$path" \ + "language bucket directories under docs/ are not allowed" \ + "move it to $(suggest_docs_language_bucket_target "$path")" + fi + + if is_noncanonical_locale_bucket "$path"; then + error \ + "$path" \ + "translations must live beside the English source, not under locale-named subdirectories" \ + "move it to $(suggest_nested_locale_bucket_target "$path")" + fi + + if [[ "$path" =~ ^docs/[^/]+\.md$ && "$path" != "docs/README.md" ]]; then + error \ + "$path" \ + "top-level docs Markdown files must move into a typed docs/ subdirectory" \ + "move it into one of docs/project/, docs/guides/, docs/reference/, docs/operations/, docs/security/, docs/architecture/, docs/channels/, docs/design/, or docs/migration/" + fi + + if is_translation_file "$path"; then + locale="${BASH_REMATCH[2]}${BASH_REMATCH[3]}" + + if [[ "$path" == docs/design/* ]]; then + continue + fi + + base="$(translation_base "$path" "$locale")" + if [[ ! -f "$base" ]]; then + error \ + "$path" \ + "missing English source document '$base'" \ + "add the English source document at '$base' or move this translation beside the correct English source" + fi + fi +done < <(git ls-files --cached --others --exclude-standard -- '*.md') + +if [[ "$failures" -ne 0 ]]; then + echo "docs lint: failed" >&2 + exit 1 +fi + +echo "docs lint: OK" diff --git a/web/README.md b/web/README.md index 9fc7007e9..0bda4b421 100644 --- a/web/README.md +++ b/web/README.md @@ -377,7 +377,7 @@ If you run only `make dev-backend`, either run `make dev-frontend` alongside it ## Related Docs - Main project overview: [`../README.md`](../README.md) -- Configuration guide: [`../docs/configuration.md`](../docs/configuration.md) -- Providers: [`../docs/providers.md`](../docs/providers.md) -- Troubleshooting: [`../docs/troubleshooting.md`](../docs/troubleshooting.md) +- Configuration guide: [`../docs/guides/configuration.md`](../docs/guides/configuration.md) +- Providers: [`../docs/guides/providers.md`](../docs/guides/providers.md) +- Troubleshooting: [`../docs/operations/troubleshooting.md`](../docs/operations/troubleshooting.md) - Official docs site: [docs.picoclaw.io](https://docs.picoclaw.io) diff --git a/web/backend/api/config.go b/web/backend/api/config.go index 80ab80f35..afcd3f74e 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -56,13 +56,22 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { } defer r.Body.Close() - var cfg config.Config - if err = json.Unmarshal(body, &cfg); err != nil { + var raw map[string]any + if err = json.Unmarshal(body, &raw); err != nil { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } - var raw map[string]any - if err = json.Unmarshal(body, &raw); err != nil { + if err = normalizeChannelArrayFields(raw); err != nil { + http.Error(w, fmt.Sprintf("Invalid channel array field: %v", err), http.StatusBadRequest) + return + } + normalizedBody, err := json.Marshal(raw) + if err != nil { + http.Error(w, "Failed to normalize config payload", http.StatusBadRequest) + return + } + var cfg config.Config + if err = json.Unmarshal(normalizedBody, &cfg); err != nil { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } @@ -94,8 +103,6 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { return } - // Refresh cached pico token in case user changed it. - refreshPicoToken(&cfg) h.applyRuntimeLogLevel() logger.Infof("configuration updated successfully") @@ -156,6 +163,10 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { // Recursively merge patch into base mergeMap(base, patch) + if err = normalizeChannelArrayFields(base); err != nil { + http.Error(w, fmt.Sprintf("Invalid channel array field: %v", err), http.StatusBadRequest) + return + } // Convert merged map back to Config struct merged, err := json.Marshal(base) @@ -193,8 +204,6 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { return } - // Refresh cached pico token in case user changed it. - refreshPicoToken(&newCfg) h.applyRuntimeLogLevel() logger.Infof("configuration updated successfully") @@ -386,6 +395,184 @@ func asMapField(value map[string]any, key string) (map[string]any, bool) { return m, isMap } +var ( + allowFromHiddenCharsRe = regexp.MustCompile("[\u200B\u200C\u200D\u200E\u200F\u202A-\u202E\u2060-\u2069\uFEFF]") + allowFromSplitRe = regexp.MustCompile("[,\uFF0C、;;\r\n\t]+") + conservativeSplitRe = regexp.MustCompile("[,\uFF0C\r\n\t]+") +) + +type stringArrayParserOptions struct { + stripHiddenChars bool +} + +func normalizeChannelArrayFields(raw map[string]any) error { + channelsMap, hasChannels := asMapField(raw, "channel_list") + if !hasChannels { + return nil + } + + defaultCfg := config.DefaultConfig() + for channelName, rawChannel := range channelsMap { + chMap, ok := rawChannel.(map[string]any) + if !ok { + continue + } + + if rawAllowFrom, exists := chMap["allow_from"]; exists { + normalized, err := normalizeStringArrayValue(rawAllowFrom, stringArrayParserOptions{ + stripHiddenChars: true, + }) + if err != nil { + return fmt.Errorf("channel_list.%s.allow_from: %w", channelName, err) + } + chMap["allow_from"] = normalized + } + + if groupTrigger, ok := asMapField(chMap, "group_trigger"); ok { + if rawPrefixes, exists := groupTrigger["prefixes"]; exists { + normalized, err := normalizeStringArrayValue(rawPrefixes, stringArrayParserOptions{}) + if err != nil { + return fmt.Errorf("channel_list.%s.group_trigger.prefixes: %w", channelName, err) + } + groupTrigger["prefixes"] = normalized + } + } + + settingsMap, hasSettings := asMapField(chMap, "settings") + if !hasSettings { + continue + } + + settingsType := channelSettingsType(defaultCfg, channelName, chMap) + if settingsType == nil { + continue + } + + for i := range settingsType.NumField() { + field := settingsType.Field(i) + if !field.IsExported() || !isStringSliceType(field.Type) { + continue + } + jsonKey := strings.Split(field.Tag.Get("json"), ",")[0] + if jsonKey == "" || jsonKey == "-" { + continue + } + rawValue, exists := settingsMap[jsonKey] + if !exists { + continue + } + + options := stringArrayParserOptions{} + if jsonKey == "allow_from" { + options.stripHiddenChars = true + } + normalized, err := normalizeStringArrayValue(rawValue, options) + if err != nil { + return fmt.Errorf("channel_list.%s.settings.%s: %w", channelName, jsonKey, err) + } + settingsMap[jsonKey] = normalized + } + } + return nil +} + +func channelSettingsType( + defaultCfg *config.Config, + channelName string, + channelMap map[string]any, +) reflect.Type { + if channelType, _ := channelMap["type"].(string); channelType != "" { + if bc := defaultCfg.Channels.GetByType(channelType); bc != nil { + if decoded, err := bc.GetDecoded(); err == nil && decoded != nil { + return derefType(reflect.TypeOf(decoded)) + } + } + } + + if bc := defaultCfg.Channels.Get(channelName); bc != nil { + if decoded, err := bc.GetDecoded(); err == nil && decoded != nil { + return derefType(reflect.TypeOf(decoded)) + } + } + + return nil +} + +func derefType(typ reflect.Type) reflect.Type { + for typ != nil && typ.Kind() == reflect.Ptr { + typ = typ.Elem() + } + return typ +} + +func isStringSliceType(typ reflect.Type) bool { + typ = derefType(typ) + return typ != nil && typ.Kind() == reflect.Slice && typ.Elem().Kind() == reflect.String +} + +func normalizeStringArrayValue(value any, options stringArrayParserOptions) ([]string, error) { + switch typed := value.(type) { + case nil: + return nil, nil + case string: + return parseStringArrayValue(typed, options), nil + case float64: + return normalizeStringArrayItems([]string{fmt.Sprintf("%.0f", typed)}, options), nil + case []string: + return normalizeStringArrayItems(typed, options), nil + case []any: + items := make([]string, 0, len(typed)) + for _, item := range typed { + switch raw := item.(type) { + case string: + items = append(items, raw) + case float64: + items = append(items, fmt.Sprintf("%.0f", raw)) + default: + return nil, fmt.Errorf("unsupported list item type %T", item) + } + } + return normalizeStringArrayItems(items, options), nil + default: + return nil, fmt.Errorf("unsupported list field type %T", value) + } +} + +func parseStringArrayValue(raw string, options stringArrayParserOptions) []string { + if strings.TrimSpace(raw) == "" { + return []string{} + } + splitRe := conservativeSplitRe + if options.stripHiddenChars { + splitRe = allowFromSplitRe + } + return normalizeStringArrayItems(splitRe.Split(raw, -1), options) +} + +func normalizeStringArrayItems(items []string, options stringArrayParserOptions) []string { + result := make([]string, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + normalized := item + if options.stripHiddenChars { + normalized = allowFromHiddenCharsRe.ReplaceAllString(normalized, "") + } + normalized = strings.TrimSpace(normalized) + if normalized == "" { + continue + } + if _, exists := seen[normalized]; exists { + continue + } + seen[normalized] = struct{}{} + result = append(result, normalized) + } + if len(result) == 0 { + return []string{} + } + return result +} + func getSecretString(m map[string]any, key string) (string, bool) { if raw, exists := m[key]; exists { s, isString := raw.(string) diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go index 0e0fa5229..8377c2eca 100644 --- a/web/backend/api/config_test.go +++ b/web/backend/api/config_test.go @@ -230,6 +230,285 @@ func TestHandlePatchConfig_SavesChannelListSettingsPatch(t *testing.T) { } } +func TestHandlePatchConfig_NormalizesStringChannelArrayFields(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "channel_list": { + "pico": { + "type": "pico", + "allow_from": " ou_a\u200b,\u2060ou_b\tou_c\u202e,ou_a ", + "group_trigger": { + "prefixes": "/,!;\n?,/" + }, + "settings": { + "allow_origins": "https://a.example.com,http://localhost:5173,https://a.example.com" + } + }, + "irc": { + "type": "irc", + "settings": { + "channels": "#ops,\n#dev,\n#ops", + "request_caps": "multi-prefix,echo-message\tbatch,multi-prefix" + } + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + picoChannel := cfg.Channels[config.ChannelPico] + if len(picoChannel.AllowFrom) != 3 || + picoChannel.AllowFrom[0] != "ou_a" || + picoChannel.AllowFrom[1] != "ou_b" || + picoChannel.AllowFrom[2] != "ou_c" { + t.Fatalf("pico allow_from = %#v, want [\"ou_a\", \"ou_b\", \"ou_c\"]", picoChannel.AllowFrom) + } + if len(picoChannel.GroupTrigger.Prefixes) != 3 || + picoChannel.GroupTrigger.Prefixes[0] != "/" || + picoChannel.GroupTrigger.Prefixes[1] != "!;" || + picoChannel.GroupTrigger.Prefixes[2] != "?" { + t.Fatalf( + "pico group_trigger.prefixes = %#v, want [\"/\", \"!;\", \"?\"]", + picoChannel.GroupTrigger.Prefixes, + ) + } + + decoded, err := picoChannel.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() pico error = %v", err) + } + picoCfg := decoded.(*config.PicoSettings) + if len(picoCfg.AllowOrigins) != 2 || + picoCfg.AllowOrigins[0] != "https://a.example.com" || + picoCfg.AllowOrigins[1] != "http://localhost:5173" { + t.Fatalf( + "pico allow_origins = %#v, want [\"https://a.example.com\", \"http://localhost:5173\"]", + picoCfg.AllowOrigins, + ) + } + + ircChannel := cfg.Channels[config.ChannelIRC] + decoded, err = ircChannel.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() irc error = %v", err) + } + ircCfg := decoded.(*config.IRCSettings) + if len(ircCfg.Channels) != 2 || + ircCfg.Channels[0] != "#ops" || + ircCfg.Channels[1] != "#dev" { + t.Fatalf("irc channels = %#v, want [\"#ops\", \"#dev\"]", ircCfg.Channels) + } + if len(ircCfg.RequestCaps) != 3 || + ircCfg.RequestCaps[0] != "multi-prefix" || + ircCfg.RequestCaps[1] != "echo-message" || + ircCfg.RequestCaps[2] != "batch" { + t.Fatalf( + "irc request_caps = %#v, want [\"multi-prefix\", \"echo-message\", \"batch\"]", + ircCfg.RequestCaps, + ) + } +} + +func TestHandlePatchConfig_NormalizesSingleNumericAllowFrom(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "channel_list": { + "telegram": { + "type": "telegram", + "allow_from": 123456 + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + telegramChannel := cfg.Channels[config.ChannelTelegram] + if len(telegramChannel.AllowFrom) != 1 || telegramChannel.AllowFrom[0] != "123456" { + t.Fatalf("telegram allow_from = %#v, want [\"123456\"]", telegramChannel.AllowFrom) + } +} + +func TestHandlePatchConfig_RejectsInvalidChannelArrayFields(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + telegramChannel := cfg.Channels[config.ChannelTelegram] + telegramChannel.AllowFrom = config.FlexibleStringSlice{"existing-user"} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + tests := []struct { + name string + body string + }{ + { + name: "object allow_from", + body: `{ + "channel_list": { + "telegram": { + "type": "telegram", + "allow_from": {"id": "bad"} + } + } + }`, + }, + { + name: "boolean allow_from", + body: `{ + "channel_list": { + "telegram": { + "type": "telegram", + "allow_from": true + } + } + }`, + }, + { + name: "object settings array", + body: `{ + "channel_list": { + "irc": { + "type": "irc", + "settings": { + "channels": {"name": "#ops"} + } + } + } + }`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf( + "PATCH /api/config status = %d, want %d, body=%s", + rec.Code, + http.StatusBadRequest, + rec.Body.String(), + ) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + telegramChannel := cfg.Channels[config.ChannelTelegram] + if len(telegramChannel.AllowFrom) != 1 || telegramChannel.AllowFrom[0] != "existing-user" { + t.Fatalf("telegram allow_from = %#v, want unchanged [\"existing-user\"]", telegramChannel.AllowFrom) + } + }) + } +} + +func TestHandlePatchConfig_ClearingAllowFromDoesNotLeaveEmptyStringItem(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + feishuChannel := cfg.Channels[config.ChannelFeishu] + feishuChannel.Enabled = true + feishuChannel.AllowFrom = config.FlexibleStringSlice{"ou_existing_user"} + decoded, err := feishuChannel.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + feishuCfg := decoded.(*config.FeishuSettings) + feishuCfg.AppID = "cli_existing_app" + feishuCfg.AppSecret = *config.NewSecureString("existing-secret") + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "channel_list": { + "feishu": { + "enabled": true, + "allow_from": "", + "settings": { + "app_id": "cli_existing_app" + } + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err = config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + feishuChannel = cfg.Channels[config.ChannelFeishu] + if len(feishuChannel.AllowFrom) != 0 { + t.Fatalf("feishu allow_from = %#v, want empty slice", feishuChannel.AllowFrom) + } + + configData, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile(configPath) error = %v", err) + } + if strings.Contains(string(configData), `"allow_from": [""]`) { + t.Fatalf("config file should not contain empty-string allow_from item: %s", string(configData)) + } +} + func TestHandlePatchConfig_CreatesMissingChannelWithTypeAndSecret(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index fa5652323..201000ff3 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -17,7 +17,6 @@ import ( "syscall" "time" - "github.com/sipeed/picoclaw/pkg/channels/pico" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/logger" @@ -37,28 +36,12 @@ var gateway = struct { startupDeadline time.Time logs *LogBuffer pidData *ppid.PidFileData // pid file data read from picoclaw.pid.json - picoToken string // cached pico token from config (for proxy auth validation) + picoToken string // cached raw pico token for upstream gateway proxy injection }{ runtimeStatus: "stopped", logs: NewLogBuffer(200), } -// refreshPicoToken updates gateway.picoToken from cfg -func refreshPicoToken(cfg *config.Config) { - gateway.mu.Lock() - defer gateway.mu.Unlock() - var picoCfg config.PicoSettings - if bc := cfg.Channels.GetByType(config.ChannelPico); bc != nil { - decoded, err := bc.GetDecoded() - if err == nil && decoded != nil { - if p, ok := decoded.(*config.PicoSettings); ok { - picoCfg = *p - } - } - } - gateway.picoToken = picoCfg.Token.String() -} - // refreshPicoTokensLocked reads the pico token from config and caches it. // Caller must hold gateway.mu (or be sole writer). func refreshPicoTokensLocked(configPath string) { @@ -101,18 +84,15 @@ const ( tokenPrefix = "token." ) -// picoComposedToken returns "pico-"+pidToken+picoToken for gateway auth. -func picoComposedToken(token string) string { +// picoGatewayProtocol returns the gateway-facing pico subprotocol that the +// launcher should inject when proxying browser traffic upstream. +func picoGatewayProtocol() string { gateway.mu.Lock() defer gateway.mu.Unlock() - // if not initial pico token, don't allow gateway auth - if gateway.picoToken == "" || gateway.pidData == nil { + if gateway.picoToken == "" { return "" } - if tokenPrefix+gateway.picoToken != token { - return "" - } - return pico.PicoTokenPrefix + gateway.pidData.Token + gateway.picoToken + return tokenPrefix + gateway.picoToken } var ( @@ -752,7 +732,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int gateway.logs.Reset() // Ensure Pico Channel is configured before starting gateway - changed, err := h.EnsurePicoChannel("") + changed, err := h.EnsurePicoChannel() if err != nil { logger.ErrorC("gateway", fmt.Sprintf("Warning: failed to ensure pico channel: %v", err)) // Non-fatal: gateway can still start without pico channel diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go index c6c2073e2..03af7a9d3 100644 --- a/web/backend/api/gateway_host.go +++ b/web/backend/api/gateway_host.go @@ -85,8 +85,22 @@ func requestHostName(r *http.Request) string { return netbind.ResolveAdaptiveLoopbackHost() } +func forwardedProtoFirst(r *http.Request) string { + raw := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")) + if raw == "" { + raw = forwardedRFC7239Proto(r) + } + if raw == "" { + return "" + } + if i := strings.IndexByte(raw, ','); i >= 0 { + raw = strings.TrimSpace(raw[:i]) + } + return strings.ToLower(raw) +} + func requestWSScheme(r *http.Request) string { - if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" { + if forwarded := forwardedProtoFirst(r); forwarded != "" { proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0])) if proto == "https" || proto == "wss" { return "wss" @@ -105,7 +119,7 @@ func requestWSScheme(r *http.Request) string { // requestHTTPScheme returns http or https for URLs that are not WebSockets (e.g. SSE). func requestHTTPScheme(r *http.Request) string { - if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" { + if forwarded := forwardedProtoFirst(r); forwarded != "" { proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0])) if proto == "https" || proto == "wss" { return "https" @@ -117,6 +131,7 @@ func requestHTTPScheme(r *http.Request) string { if r.TLS != nil { return "https" } + return "http" } @@ -138,6 +153,14 @@ func forwardedHostFirst(r *http.Request) string { // forwardedRFC7239Host parses host= from the first Forwarded header element (RFC 7239). func forwardedRFC7239Host(r *http.Request) string { + return forwardedRFC7239Param(r, "host") +} + +func forwardedRFC7239Proto(r *http.Request) string { + return forwardedRFC7239Param(r, "proto") +} + +func forwardedRFC7239Param(r *http.Request, key string) string { v := strings.TrimSpace(r.Header.Get("Forwarded")) if v == "" { return "" @@ -146,7 +169,7 @@ func forwardedRFC7239Host(r *http.Request) string { for _, part := range strings.Split(first, ";") { part = strings.TrimSpace(part) low := strings.ToLower(part) - if !strings.HasPrefix(low, "host=") { + if !strings.HasPrefix(low, key+"=") { continue } val := strings.TrimSpace(part[strings.IndexByte(part, '=')+1:]) @@ -177,13 +200,21 @@ func clientVisiblePort(r *http.Request, serverListenPort int) string { if p := forwardedPortFirst(r); p != "" { return p } + if fwdHost := forwardedHostFirst(r); fwdHost != "" { + if _, port, err := net.SplitHostPort(fwdHost); err == nil && port != "" { + return port + } + } if _, port, err := net.SplitHostPort(r.Host); err == nil && port != "" { return port } + if strings.TrimSpace(r.Host) == "" && forwardedHostFirst(r) == "" { + return strconv.Itoa(serverListenPort) + } if requestHTTPScheme(r) == "https" { return "443" } - return strconv.Itoa(serverListenPort) + return "80" } // joinClientVisibleHostPort builds host:port for absolute URLs returned to the browser. @@ -205,16 +236,7 @@ func (h *Handler) picoWebUIAddr(r *http.Request) string { if fwdHost := forwardedHostFirst(r); fwdHost != "" { return joinClientVisibleHostPort(r, fwdHost, wsPort) } - host := requestHostName(r) - // Use clientVisiblePort only when an explicit port is present in headers - // or Host header — do not infer from TLS/scheme, as serverPort takes priority. - if p := forwardedPortFirst(r); p != "" { - return net.JoinHostPort(host, p) - } - if _, port, err := net.SplitHostPort(r.Host); err == nil && port != "" { - return net.JoinHostPort(host, port) - } - return net.JoinHostPort(host, strconv.Itoa(wsPort)) + return joinClientVisibleHostPort(r, requestHostName(r), wsPort) } func (h *Handler) buildWsURL(r *http.Request) string { diff --git a/web/backend/api/gateway_host_test.go b/web/backend/api/gateway_host_test.go index d0fc26d7b..54d1010d2 100644 --- a/web/backend/api/gateway_host_test.go +++ b/web/backend/api/gateway_host_test.go @@ -50,7 +50,7 @@ func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) { cfg.Gateway.Host = "127.0.0.1" cfg.Gateway.Port = 18790 - req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil) + req := httptest.NewRequest("GET", "http://launcher.local/api/pico/info", nil) req.Host = "192.168.1.9:18800" if got := h.buildWsURL(req); got != "ws://192.168.1.9:18800/pico/ws" { @@ -181,12 +181,12 @@ func TestBuildWsURLUsesWSSWhenForwardedProtoIsHTTPS(t *testing.T) { cfg.Gateway.Host = "0.0.0.0" cfg.Gateway.Port = 18790 - req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil) + req := httptest.NewRequest("GET", "http://launcher.local/api/pico/info", nil) req.Host = "chat.example.com" req.Header.Set("X-Forwarded-Proto", "https") - if got := h.buildWsURL(req); got != "wss://chat.example.com:18800/pico/ws" { - t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:18800/pico/ws") + if got := h.buildWsURL(req); got != "wss://chat.example.com:443/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:443/pico/ws") } } @@ -198,12 +198,12 @@ func TestBuildWsURLUsesWSSWhenRequestIsTLS(t *testing.T) { cfg.Gateway.Host = "0.0.0.0" cfg.Gateway.Port = 18790 - req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil) + req := httptest.NewRequest("GET", "https://launcher.local/api/pico/info", nil) req.Host = "secure.example.com" req.TLS = &tls.ConnectionState{} - if got := h.buildWsURL(req); got != "wss://secure.example.com:18800/pico/ws" { - t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:18800/pico/ws") + if got := h.buildWsURL(req); got != "wss://secure.example.com:443/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:443/pico/ws") } } @@ -224,7 +224,7 @@ func TestBuildPicoURLsPreferXForwardedHost(t *testing.T) { cfg.Gateway.Host = "0.0.0.0" cfg.Gateway.Port = 18790 - req := httptest.NewRequest("GET", "http://127.0.0.1:18800/api/pico/token", nil) + req := httptest.NewRequest("GET", "http://127.0.0.1:18800/api/pico/info", nil) req.Host = "127.0.0.1:18800" req.Header.Set("X-Forwarded-Host", "vscode-tunnel.example.com") req.Header.Set("X-Forwarded-Proto", "https") @@ -249,13 +249,30 @@ func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) { cfg.Gateway.Host = "0.0.0.0" cfg.Gateway.Port = 18790 - req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil) + req := httptest.NewRequest("GET", "https://launcher.local/api/pico/info", nil) req.Host = "chat.example.com" req.TLS = &tls.ConnectionState{} req.Header.Set("X-Forwarded-Proto", "http") - if got := h.buildWsURL(req); got != "ws://chat.example.com:18800/pico/ws" { - t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:18800/pico/ws") + if got := h.buildWsURL(req); got != "ws://chat.example.com:80/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:80/pico/ws") + } +} + +func TestBuildWsURLDoesNotTrustOriginWhenProxyOmitsForwardedProto(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + req := httptest.NewRequest("GET", "http://launcher.local/api/pico/info", nil) + req.Host = "fs-952210-xwj.picoclaw.lan.sipeed.com" + req.Header.Set("Origin", "https://fs-952210-xwj.picoclaw.lan.sipeed.com") + + if got := h.buildWsURL(req); got != "ws://fs-952210-xwj.picoclaw.lan.sipeed.com:80/pico/ws" { + t.Fatalf( + "buildWsURL() = %q, want %q", + got, + "ws://fs-952210-xwj.picoclaw.lan.sipeed.com:80/pico/ws", + ) } } @@ -264,7 +281,7 @@ func TestBuildWsURLUsesRequestHostNotGatewayBindLoopback(t *testing.T) { h := NewHandler(configPath) h.SetServerOptions(18800, false, false, nil) - req := httptest.NewRequest("GET", "http://localhost:18800/api/pico/token", nil) + req := httptest.NewRequest("GET", "http://localhost:18800/api/pico/info", nil) req.Host = "localhost:18800" if got := h.buildWsURL(req); got != "ws://localhost:18800/pico/ws" { diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index 78bf34a63..998ed3317 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -121,6 +121,18 @@ func resetGatewayTestState(t *testing.T) { }) } +func TestPicoGatewayProtocol(t *testing.T) { + resetGatewayTestState(t) + + gateway.mu.Lock() + gateway.picoToken = "ui-token" + gateway.mu.Unlock() + + if got := picoGatewayProtocol(); got != tokenPrefix+"ui-token" { + t.Fatalf("picoGatewayProtocol() = %q, want %q", got, tokenPrefix+"ui-token") + } +} + type gatewayStartEnvSnapshot struct { GatewayHost string `json:"gateway_host"` GatewayHostSet bool `json:"gateway_host_set"` diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go index 70fd87817..66b0bb92d 100644 --- a/web/backend/api/pico.go +++ b/web/backend/api/pico.go @@ -16,7 +16,7 @@ import ( // registerPicoRoutes binds Pico Channel management endpoints to the ServeMux. func (h *Handler) registerPicoRoutes(mux *http.ServeMux) { - mux.HandleFunc("GET /api/pico/token", h.handleGetPicoToken) + mux.HandleFunc("GET /api/pico/info", h.handleGetPicoInfo) mux.HandleFunc("POST /api/pico/token", h.handleRegenPicoToken) mux.HandleFunc("POST /api/pico/setup", h.handlePicoSetup) @@ -30,12 +30,15 @@ func (h *Handler) registerPicoRoutes(mux *http.ServeMux) { // createWsProxy creates a reverse proxy to the current gateway WebSocket endpoint. // The gateway bind host and port are resolved from the latest configuration. -func (h *Handler) createWsProxy(origProtocol string, token string) *httputil.ReverseProxy { +func (h *Handler) createWsProxy(origProtocol string, upstreamProtocol string) *httputil.ReverseProxy { wsProxy := &httputil.ReverseProxy{ Rewrite: func(r *httputil.ProxyRequest) { target := h.gatewayProxyURL() r.SetURL(target) - r.Out.Header.Set(protocolKey, tokenPrefix+token) + r.Out.Header.Del(protocolKey) + if upstreamProtocol != "" { + r.Out.Header.Set(protocolKey, upstreamProtocol) + } }, ModifyResponse: func(r *http.Response) error { if prot := r.Header.Values(protocolKey); len(prot) > 0 { @@ -101,8 +104,50 @@ func (h *Handler) gatewayAvailableForProxy() bool { return available } +func decodePicoSettings(cfg *config.Config) (config.PicoSettings, bool) { + if cfg == nil { + return config.PicoSettings{}, false + } + + bc := cfg.Channels.GetByType(config.ChannelPico) + if bc == nil { + return config.PicoSettings{}, false + } + + var picoCfg config.PicoSettings + if err := bc.Decode(&picoCfg); err != nil { + return config.PicoSettings{}, false + } + + return picoCfg, bc.Enabled +} + +func (h *Handler) writePicoInfoResponse( + w http.ResponseWriter, + r *http.Request, + cfg *config.Config, + changed *bool, +) { + picoCfg, enabled := decodePicoSettings(cfg) + + resp := map[string]any{ + "ws_url": h.buildWsURL(r), + "enabled": enabled, + } + if changed != nil { + resp["changed"] = *changed + } + if picoCfg.Token.String() != "" { + resp["configured"] = true + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + // handleWebSocketProxy wraps a reverse proxy to handle WebSocket connections. -// It validates the client token before forwarding; rejects immediately on failure. +// It relies on launcher dashboard auth, then injects the raw pico token only +// on the upstream gateway request. func (h *Handler) handleWebSocketProxy() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !h.gatewayAvailableForProxy() { @@ -110,18 +155,20 @@ func (h *Handler) handleWebSocketProxy() http.HandlerFunc { http.Error(w, "Gateway not available", http.StatusServiceUnavailable) return } - prot := r.Header.Values(protocolKey) - if len(prot) > 0 { - origProtocol := prot[0] - newToken := picoComposedToken(prot[0]) - if newToken != "" { - h.createWsProxy(origProtocol, newToken).ServeHTTP(w, r) - return - } + + upstreamProtocol := picoGatewayProtocol() + if upstreamProtocol == "" { + logger.Warn("Pico token unavailable for WebSocket proxy") + http.Error(w, "Pico channel not configured", http.StatusServiceUnavailable) + return } - logger.Warnf("Invalid Pico token: %v", prot) - http.Error(w, "Invalid Pico token", http.StatusForbidden) + var origProtocol string + if prot := r.Header.Values(protocolKey); len(prot) > 0 { + origProtocol = prot[0] + } + + h.createWsProxy(origProtocol, upstreamProtocol).ServeHTTP(w, r) } } @@ -137,7 +184,7 @@ func (h *Handler) handlePicoMediaProxy() http.HandlerFunc { uiToken := gateway.picoToken gateway.mu.Unlock() - token := picoComposedToken(tokenPrefix + uiToken) + token := tokenPrefix + uiToken if token == "" { logger.Warnf("Missing Pico token for media proxy") http.Error(w, "Invalid Pico token", http.StatusForbidden) @@ -148,36 +195,21 @@ func (h *Handler) handlePicoMediaProxy() http.HandlerFunc { } } -// handleGetPicoToken returns the current WS token and URL for the frontend. +// handleGetPicoInfo returns non-secret Pico connection info for the launcher UI. // -// GET /api/pico/token -func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) { +// GET /api/pico/info +func (h *Handler) handleGetPicoInfo(w http.ResponseWriter, r *http.Request) { cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } - wsURL := h.buildWsURL(r) - - w.Header().Set("Content-Type", "application/json") - bc := cfg.Channels.GetByType(config.ChannelPico) - var picoCfg config.PicoSettings - if bc != nil { - bc.Decode(&picoCfg) - } - enabled := false - if bc != nil { - enabled = bc.Enabled - } - json.NewEncoder(w).Encode(map[string]any{ - "token": picoCfg.Token.String(), - "ws_url": wsURL, - "enabled": enabled, - }) + h.writePicoInfoResponse(w, r, cfg, nil) } -// handleRegenPicoToken generates a new Pico WebSocket token and saves it. +// handleRegenPicoToken rotates the raw Pico WebSocket token and returns +// non-secret connection info for the launcher UI. // // POST /api/pico/token func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) { @@ -202,28 +234,12 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) { return } - // Refresh cached pico token. - gateway.mu.Lock() - gateway.picoToken = token - gateway.mu.Unlock() - - wsURL := h.buildWsURL(r) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "token": token, - "ws_url": wsURL, - }) + h.writePicoInfoResponse(w, r, cfg, nil) } // EnsurePicoChannel enables the Pico channel with sane defaults if it isn't // already configured. Returns true when the config was modified. -// -// callerOrigin is the Origin header from the setup request. If non-empty and -// no origins are configured yet, it's written as the allowed origin so the -// WebSocket handshake works for whatever host the caller is on (LAN, custom -// port, etc.). Pass "" when there's no request context. -func (h *Handler) EnsurePicoChannel(callerOrigin string) (bool, error) { +func (h *Handler) EnsurePicoChannel() (bool, error) { cfg, err := config.LoadConfig(h.configPath) if err != nil { return false, fmt.Errorf("failed to load config: %w", err) @@ -248,12 +264,6 @@ func (h *Handler) EnsurePicoChannel(callerOrigin string) (bool, error) { picoCfg.Token = *config.NewSecureString(generateSecureToken()) changed = true } - - // Seed origins from the request instead of hardcoding ports. - if len(picoCfg.AllowOrigins) == 0 && callerOrigin != "" { - picoCfg.AllowOrigins = []string{callerOrigin} - changed = true - } } } @@ -270,37 +280,20 @@ func (h *Handler) EnsurePicoChannel(callerOrigin string) (bool, error) { // // POST /api/pico/setup func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) { - changed, err := h.EnsurePicoChannel(r.Header.Get("Origin")) + changed, err := h.EnsurePicoChannel() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - // Reload config (EnsurePicoChannel may have modified it) and refresh cache. + // Reload config (EnsurePicoChannel may have modified it). cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } - if changed { - refreshPicoToken(cfg) - } - wsURL := h.buildWsURL(r) - - var picoCfg2 config.PicoSettings - if bc := cfg.Channels.GetByType(config.ChannelPico); bc != nil { - if decoded, err := bc.GetDecoded(); err == nil && decoded != nil { - picoCfg2 = *decoded.(*config.PicoSettings) - } - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "token": picoCfg2.Token.String(), - "ws_url": wsURL, - "enabled": true, - "changed": changed, - }) + h.writePicoInfoResponse(w, r, cfg, &changed) } // generateSecureToken creates a random 32-character hex string. diff --git a/web/backend/api/pico_test.go b/web/backend/api/pico_test.go index 1e61b510d..6bdf0c6ca 100644 --- a/web/backend/api/pico_test.go +++ b/web/backend/api/pico_test.go @@ -12,16 +12,21 @@ import ( "strings" "testing" - "github.com/sipeed/picoclaw/pkg/channels/pico" "github.com/sipeed/picoclaw/pkg/config" ppid "github.com/sipeed/picoclaw/pkg/pid" ) +func newPicoProxyRequest(method, path string) *http.Request { + req := httptest.NewRequest(method, "http://launcher.local:18800"+path, nil) + req.Header.Set("Origin", "http://launcher.local:18800") + return req +} + func TestEnsurePicoChannel_FreshConfig(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - changed, err := h.EnsurePicoChannel("") + changed, err := h.EnsurePicoChannel() if err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -52,7 +57,7 @@ func TestEnsurePicoChannel_DoesNotEnableTokenQuery(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - if _, err := h.EnsurePicoChannel(""); err != nil { + if _, err := h.EnsurePicoChannel(); err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -72,11 +77,11 @@ func TestEnsurePicoChannel_DoesNotEnableTokenQuery(t *testing.T) { } } -func TestEnsurePicoChannel_DoesNotSetWildcardOrigins(t *testing.T) { +func TestEnsurePicoChannel_LeavesAllowOriginsEmptyByDefault(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - if _, err := h.EnsurePicoChannel("http://localhost:18800"); err != nil { + if _, err := h.EnsurePicoChannel(); err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -91,45 +96,16 @@ func TestEnsurePicoChannel_DoesNotSetWildcardOrigins(t *testing.T) { t.Fatalf("GetDecoded() error = %v", err) } picoCfg := decoded.(*config.PicoSettings) - for _, origin := range picoCfg.AllowOrigins { - if origin == "*" { - t.Error("setup must not set wildcard origin '*'") - } - } -} - -func TestEnsurePicoChannel_NoOriginWithoutCaller(t *testing.T) { - configPath := filepath.Join(t.TempDir(), "config.json") - h := NewHandler(configPath) - - if _, err := h.EnsurePicoChannel(""); err != nil { - t.Fatalf("EnsurePicoChannel() error = %v", err) - } - - cfg, err := config.LoadConfig(configPath) - if err != nil { - t.Fatalf("LoadConfig() error = %v", err) - } - - bc := cfg.Channels["pico"] - decoded, err := bc.GetDecoded() - if err != nil { - t.Fatalf("GetDecoded() error = %v", err) - } - picoCfg := decoded.(*config.PicoSettings) - // Without a caller origin, allow_origins stays empty (CheckOrigin - // allows all when the list is empty, so the channel still works). if len(picoCfg.AllowOrigins) != 0 { - t.Errorf("allow_origins = %v, want empty when no caller origin", picoCfg.AllowOrigins) + t.Errorf("allow_origins = %v, want empty", picoCfg.AllowOrigins) } } -func TestEnsurePicoChannel_SetsCallerOrigin(t *testing.T) { +func TestEnsurePicoChannel_NoOriginConfigurationRequired(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - lanOrigin := "http://192.168.1.9:18800" - if _, err := h.EnsurePicoChannel(lanOrigin); err != nil { + if _, err := h.EnsurePicoChannel(); err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -144,8 +120,8 @@ func TestEnsurePicoChannel_SetsCallerOrigin(t *testing.T) { t.Fatalf("GetDecoded() error = %v", err) } picoCfg := decoded.(*config.PicoSettings) - if len(picoCfg.AllowOrigins) != 1 || picoCfg.AllowOrigins[0] != lanOrigin { - t.Errorf("allow_origins = %v, want [%s]", picoCfg.AllowOrigins, lanOrigin) + if len(picoCfg.AllowOrigins) != 0 { + t.Errorf("allow_origins = %v, want empty", picoCfg.AllowOrigins) } } @@ -170,7 +146,7 @@ func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) { h := NewHandler(configPath) - changed, err := h.EnsurePicoChannel("") + changed, err := h.EnsurePicoChannel() if err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -214,7 +190,7 @@ func TestEnsurePicoChannel_ExistingConfigWithoutSecurityFile(t *testing.T) { h := NewHandler(configPath) - changed, err := h.EnsurePicoChannel("") + changed, err := h.EnsurePicoChannel() if err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -254,7 +230,7 @@ func TestEnsurePicoChannel_ConfiguresPicoWithoutGateway(t *testing.T) { } h := NewHandler(configPath) - if _, err := h.EnsurePicoChannel(""); err != nil { + if _, err := h.EnsurePicoChannel(); err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -281,10 +257,8 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - origin := "http://localhost:18800" - // First call sets things up - if _, err := h.EnsurePicoChannel(origin); err != nil { + if _, err := h.EnsurePicoChannel(); err != nil { t.Fatalf("first EnsurePicoChannel() error = %v", err) } @@ -298,7 +272,7 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) { token1 := picoCfg.Token.String() // Second call should be a no-op - changed, err := h.EnsurePicoChannel(origin) + changed, err := h.EnsurePicoChannel() if err != nil { t.Fatalf("second EnsurePicoChannel() error = %v", err) } @@ -318,7 +292,7 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) { } } -func TestHandlePicoSetup_IncludesRequestOrigin(t *testing.T) { +func TestHandlePicoSetup_DoesNotPersistRequestOrigin(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) @@ -343,8 +317,8 @@ func TestHandlePicoSetup_IncludesRequestOrigin(t *testing.T) { t.Fatalf("GetDecoded() error = %v", err) } picoCfg := decoded.(*config.PicoSettings) - if len(picoCfg.AllowOrigins) != 1 || picoCfg.AllowOrigins[0] != "http://10.0.0.5:3000" { - t.Errorf("allow_origins = %v, want [http://10.0.0.5:3000]", picoCfg.AllowOrigins) + if len(picoCfg.AllowOrigins) != 0 { + t.Errorf("allow_origins = %v, want empty", picoCfg.AllowOrigins) } } @@ -366,8 +340,8 @@ func TestHandlePicoSetup_Response(t *testing.T) { t.Fatalf("failed to decode response: %v", err) } - if resp["token"] == nil || resp["token"] == "" { - t.Error("response should contain a non-empty token") + if _, ok := resp["token"]; ok { + t.Error("response must not expose the raw pico token") } if resp["ws_url"] == nil || resp["ws_url"] == "" { t.Error("response should contain ws_url") @@ -378,6 +352,45 @@ func TestHandlePicoSetup_Response(t *testing.T) { if resp["changed"] != true { t.Error("response should have changed=true on first setup") } + if resp["configured"] != true { + t.Error("response should have configured=true") + } +} + +func TestHandleGetPicoInfo_OmitsToken(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + if _, err := h.EnsurePicoChannel(); err != nil { + t.Fatalf("EnsurePicoChannel() error = %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "http://launcher.local/api/pico/info", nil) + rec := httptest.NewRecorder() + + h.handleGetPicoInfo(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var resp map[string]any + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if _, ok := resp["token"]; ok { + t.Fatal("info response must not expose the raw pico token") + } + if resp["enabled"] != true { + t.Fatalf("enabled = %#v, want true", resp["enabled"]) + } + if resp["configured"] != true { + t.Fatalf("configured = %#v, want true", resp["configured"]) + } + if resp["ws_url"] == nil || resp["ws_url"] == "" { + t.Fatal("response should contain ws_url") + } } func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) { @@ -439,20 +452,10 @@ func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) { gateway.pidData = &ppid.PidFileData{} gateway.picoToken = "pico" - req1 := httptest.NewRequest(http.MethodGet, "/pico/ws", nil) - req1.Header.Set(protocolKey, tokenPrefix+"wrong_token") + req1 := newPicoProxyRequest(http.MethodGet, "/pico/ws") rec1 := httptest.NewRecorder() handler(rec1, req1) - if rec1.Code != http.StatusForbidden { - t.Fatalf("first status = %d, want %d", rec1.Code, http.StatusForbidden) - } - - req1 = httptest.NewRequest(http.MethodGet, "/pico/ws", nil) - req1.Header.Set(protocolKey, tokenPrefix+"pico") - rec1 = httptest.NewRecorder() - handler(rec1, req1) - if rec1.Code != http.StatusOK { t.Fatalf("first status = %d, want %d", rec1.Code, http.StatusOK) } @@ -465,8 +468,7 @@ func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) { t.Fatalf("SaveConfig() error = %v", err) } - req2 := httptest.NewRequest(http.MethodGet, "/pico/ws", nil) - req2.Header.Set(protocolKey, tokenPrefix+"pico") + req2 := newPicoProxyRequest(http.MethodGet, "/pico/ws") rec2 := httptest.NewRecorder() handler(rec2, req2) @@ -540,8 +542,7 @@ func TestHandleWebSocketProxyLoadsCachedPicoTokenWhenMissing(t *testing.T) { gateway.pidData = &ppid.PidFileData{} gateway.picoToken = "" - req := httptest.NewRequest(http.MethodGet, "/pico/ws?session_id=test-session", nil) - req.Header.Set(protocolKey, tokenPrefix+"cached-token") + req := newPicoProxyRequest(http.MethodGet, "/pico/ws?session_id=test-session") rec := httptest.NewRecorder() handler(rec, req) @@ -626,8 +627,7 @@ func TestHandleWebSocketProxyLoadsPidDataOnDemand(t *testing.T) { setGatewayRuntimeStatusLocked("stopped") gateway.mu.Unlock() - req := httptest.NewRequest(http.MethodGet, "/pico/ws?session_id=test-session", nil) - req.Header.Set(protocolKey, tokenPrefix+"ui-token") + req := newPicoProxyRequest(http.MethodGet, "/pico/ws?session_id=test-session") rec := httptest.NewRecorder() handler(rec, req) @@ -635,7 +635,7 @@ func TestHandleWebSocketProxyLoadsPidDataOnDemand(t *testing.T) { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } - expected := tokenPrefix + pico.PicoTokenPrefix + pidData.Token + "ui-token" + expected := tokenPrefix + "ui-token" if got := rec.Body.String(); got != expected { t.Fatalf("forwarded protocol = %q, want %q", got, expected) } @@ -668,7 +668,7 @@ func TestCreatePicoHTTPProxyInjectsGatewayAuth(t *testing.T) { t.Fatalf("SaveConfig() error = %v", err) } - proxy := h.createPicoHTTPProxy(pico.PicoTokenPrefix + "test-token" + "ui-token") + proxy := h.createPicoHTTPProxy(tokenPrefix + "test-token" + "ui-token") var capturedPath string var capturedAuth string proxy.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { @@ -692,7 +692,7 @@ func TestCreatePicoHTTPProxyInjectsGatewayAuth(t *testing.T) { if capturedPath != "/pico/media/attachment-1" { t.Fatalf("capturedPath = %q, want %q", capturedPath, "/pico/media/attachment-1") } - expected := "Bearer " + pico.PicoTokenPrefix + "test-token" + "ui-token" + expected := "Bearer " + tokenPrefix + "test-token" + "ui-token" if capturedAuth != expected { t.Fatalf("Authorization = %q, want %q", capturedAuth, expected) } @@ -745,8 +745,7 @@ func TestHandleWebSocketProxyRejectsStalePidDataAfterProcessExit(t *testing.T) { setGatewayRuntimeStatusLocked("running") gateway.mu.Unlock() - req := httptest.NewRequest(http.MethodGet, "/pico/ws?session_id=test-session", nil) - req.Header.Set(protocolKey, tokenPrefix+"ui-token") + req := newPicoProxyRequest(http.MethodGet, "/pico/ws?session_id=test-session") rec := httptest.NewRecorder() handler(rec, req) @@ -760,6 +759,78 @@ func TestHandleWebSocketProxyRejectsStalePidDataAfterProcessExit(t *testing.T) { } } +func TestHandleWebSocketProxy_AllowsArbitraryOrigin(t *testing.T) { + origMatcher := gatewayProcessMatcher + gatewayProcessMatcher = func(int) (bool, bool) { return true, true } + t.Cleanup(func() { gatewayProcessMatcher = origMatcher }) + + home := t.TempDir() + t.Setenv("PICOCLAW_HOME", home) + + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + handler := h.handleWebSocketProxy() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/pico/ws" { + t.Fatalf("path = %q, want %q", r.URL.Path, "/pico/ws") + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "proxied") + })) + defer server.Close() + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "127.0.0.1" + cfg.Gateway.Port = mustGatewayTestPort(t, server.URL) + bc := cfg.Channels["pico"] + bc.Enabled = true + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + decoded.(*config.PicoSettings).SetToken("ui-token") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + cmd := startGatewayLikeProcess(t) + t.Cleanup(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }) + writeTestPidFile(t, ppid.PidFileData{ + PID: cmd.Process.Pid, + Token: "test-token", + Host: cfg.Gateway.Host, + Port: cfg.Gateway.Port, + }) + t.Cleanup(func() { + ppid.RemovePidFile(globalConfigDir()) + }) + + origPidData := gateway.pidData + origPicoToken := gateway.picoToken + t.Cleanup(func() { + gateway.pidData = origPidData + gateway.picoToken = origPicoToken + }) + + gateway.pidData = &ppid.PidFileData{} + gateway.picoToken = "ui-token" + + req := httptest.NewRequest(http.MethodGet, "http://launcher.local/pico/ws?session_id=test-session", nil) + req.Header.Set("Origin", "http://evil.example") + rec := httptest.NewRecorder() + handler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + func mustGatewayTestPort(t *testing.T, rawURL string) int { t.Helper() diff --git a/web/backend/api/session.go b/web/backend/api/session.go index fb2047cb0..8382fe6fe 100644 --- a/web/backend/api/session.go +++ b/web/backend/api/session.go @@ -480,6 +480,9 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen attachments := sessionAttachments(msg) switch msg.Role { + case "tool": + continue + case "user": if sessionMessageVisible(msg) { transcript = append(transcript, sessionChatMessage{ @@ -531,7 +534,18 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen } } - return transcript + return filterSessionChatMessages(transcript) +} + +func filterSessionChatMessages(messages []sessionChatMessage) []sessionChatMessage { + filtered := messages[:0] + for _, msg := range messages { + if msg.Role != "user" && msg.Role != "assistant" { + continue + } + filtered = append(filtered, msg) + } + return filtered } func sessionAttachments(msg providers.Message) []sessionChatAttachment { @@ -636,22 +650,16 @@ func visibleAssistantToolSummaryMessages( messages := make([]sessionChatMessage, 0, len(toolCalls)) for _, tc := range toolCalls { - name := tc.Name - argsJSON := "" - if tc.Function != nil { - if name == "" { - name = tc.Function.Name - } - argsJSON = tc.Function.Arguments - } - + name, argsJSON := toolCallNameAndArguments(tc) if strings.TrimSpace(name) == "" { continue } - - if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 { - if encodedArgs, err := json.Marshal(tc.Arguments); err == nil { - argsJSON = string(encodedArgs) + if name == "web_search" || name == "web_fetch" { + continue + } + if name == "message" { + if _, ok := parseMessageToolContent(argsJSON); ok { + continue } } @@ -676,36 +684,53 @@ func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatM messages := make([]sessionChatMessage, 0, len(toolCalls)) for _, tc := range toolCalls { - name := tc.Name - argsJSON := "" - if tc.Function != nil { - if name == "" { - name = tc.Function.Name - } - argsJSON = tc.Function.Arguments + name, argsJSON := toolCallNameAndArguments(tc) + if name != "message" { + continue } - - switch name { - case "message": - var args struct { - Content string `json:"content"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - continue - } - if strings.TrimSpace(args.Content) == "" { - continue - } - messages = append(messages, sessionChatMessage{ - Role: "assistant", - Content: args.Content, - }) + content, ok := parseMessageToolContent(argsJSON) + if !ok { + continue } + messages = append(messages, sessionChatMessage{ + Role: "assistant", + Content: content, + }) } return messages } +func toolCallNameAndArguments(tc providers.ToolCall) (string, string) { + name := tc.Name + argsJSON := "" + if tc.Function != nil { + if name == "" { + name = tc.Function.Name + } + argsJSON = tc.Function.Arguments + } + if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 { + if encodedArgs, err := json.Marshal(tc.Arguments); err == nil { + argsJSON = string(encodedArgs) + } + } + return name, argsJSON +} + +func parseMessageToolContent(argsJSON string) (string, bool) { + var args struct { + Content string `json:"content"` + } + if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { + return "", false + } + if strings.TrimSpace(args.Content) == "" { + return "", false + } + return args.Content, true +} + // sessionsDir resolves the path to the gateway's session storage directory. // It reads the workspace from config, falling back to ~/.picoclaw/workspace. func (h *Handler) sessionsDir() (string, error) { diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go index 54733635d..76df11bd0 100644 --- a/web/backend/api/session_test.go +++ b/web/backend/api/session_test.go @@ -425,7 +425,7 @@ func TestHandleGetSession_OmitsTransientThoughtMessages(t *testing.T) { } } -func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) { +func TestHandleGetSession_ReconstructsVisibleMessageToolOutputWithoutDuplicateSummary(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -481,14 +481,19 @@ func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } - if len(resp.Messages) != 3 { - t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) + if len(resp.Messages) != 2 { + t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages)) } - if !strings.Contains(resp.Messages[1].Content, "`message`") { - t.Fatalf("tool summary message = %#v, want message tool summary", resp.Messages[1]) + if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" { + t.Fatalf("first message = %#v, want user/test", resp.Messages[0]) } - if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" { - t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[2]) + if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" { + t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[1]) + } + for _, msg := range resp.Messages { + if msg.Role == "tool" || strings.Contains(msg.Content, "`message`") { + t.Fatalf("unexpected raw tool or duplicate message-tool summary: %#v", msg) + } } } @@ -547,17 +552,17 @@ func TestHandleGetSession_PreservesFinalAssistantReplyAfterMessageToolOutput(t * if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } - if len(resp.Messages) != 4 { - t.Fatalf("len(resp.Messages) = %d, want 4", len(resp.Messages)) + if len(resp.Messages) != 3 { + t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) } - if !strings.Contains(resp.Messages[1].Content, "`message`") { - t.Fatalf("tool summary message = %#v, want message tool summary", resp.Messages[1]) + if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" { + t.Fatalf("first message = %#v, want user/test", resp.Messages[0]) } - if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" { - t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[2]) + if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" { + t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[1]) } - if resp.Messages[3].Role != "assistant" || resp.Messages[3].Content != "final assistant reply" { - t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[3]) + if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "final assistant reply" { + t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[2]) } } @@ -614,8 +619,8 @@ func TestHandleListSessions_MessageCountUsesVisibleTranscript(t *testing.T) { if len(items) != 1 { t.Fatalf("len(items) = %d, want 1", len(items)) } - if items[0].MessageCount != 3 { - t.Fatalf("items[0].MessageCount = %d, want 3", items[0].MessageCount) + if items[0].MessageCount != 2 { + t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount) } } @@ -646,6 +651,7 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) }, }, }, + {Role: "tool", Content: "raw read_file result", ToolCallID: "call_1"}, } { if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { t.Fatalf("AddFullMessage() error = %v", err) @@ -685,6 +691,11 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "model final reply" { t.Fatalf("assistant message = %#v, want model final reply", resp.Messages[2]) } + for _, msg := range resp.Messages { + if msg.Role == "tool" || strings.Contains(msg.Content, "raw read_file result") { + t.Fatalf("unexpected raw tool result in history: %#v", msg) + } + } } func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) { diff --git a/web/backend/main.go b/web/backend/main.go index 01ef5edf0..e42558398 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -544,7 +544,7 @@ func main() { // API Routes (e.g. /api/status) apiHandler = api.NewHandler(absPath) apiHandler.SetDebug(debug) - if _, err = apiHandler.EnsurePicoChannel(""); err != nil { + if _, err = apiHandler.EnsurePicoChannel(); err != nil { logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err)) } apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs) diff --git a/web/backend/middleware/launcher_dashboard_auth.go b/web/backend/middleware/launcher_dashboard_auth.go index c1c4c19c6..d72bd0f00 100644 --- a/web/backend/middleware/launcher_dashboard_auth.go +++ b/web/backend/middleware/launcher_dashboard_auth.go @@ -218,6 +218,10 @@ func validLauncherDashboardAuth(r *http.Request, cfg LauncherDashboardAuthConfig } func rejectLauncherDashboardAuth(w http.ResponseWriter, r *http.Request, canonicalPath string) { + if canonicalPath == "/pico/ws" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } if strings.HasPrefix(canonicalPath, "/api/") { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) diff --git a/web/backend/middleware/launcher_dashboard_auth_test.go b/web/backend/middleware/launcher_dashboard_auth_test.go index 1b919bf96..7b7418998 100644 --- a/web/backend/middleware/launcher_dashboard_auth_test.go +++ b/web/backend/middleware/launcher_dashboard_auth_test.go @@ -40,6 +40,7 @@ func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) { {http.MethodPost, "/api/auth/logout", http.StatusTeapot}, {http.MethodGet, "/api/auth/logout", http.StatusUnauthorized}, {http.MethodGet, "/api/config", http.StatusUnauthorized}, + {http.MethodGet, "/pico/ws", http.StatusUnauthorized}, } { rec := httptest.NewRecorder() req := httptest.NewRequest(tc.method, tc.path, nil) @@ -160,3 +161,22 @@ func TestLauncherDashboardAuth_CookieAndBearer(t *testing.T) { t.Fatalf("bearer auth: status = %d", rec2.Code) } } + +func TestLauncherDashboardAuth_WebSocketUnauthorizedDoesNotRedirect(t *testing.T) { + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"} + next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Fatal("next handler should not run without auth") + }) + h := LauncherDashboardAuth(cfg, next) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/pico/ws", nil) + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + } + if got := rec.Header().Get("Location"); got != "" { + t.Fatalf("Location = %q, want empty", got) + } +} diff --git a/web/frontend/eslint.config.js b/web/frontend/eslint.config.js index 85d380c4f..884649e41 100644 --- a/web/frontend/eslint.config.js +++ b/web/frontend/eslint.config.js @@ -22,6 +22,7 @@ export default defineConfig([ globals: globals.browser, }, rules: { + "react-hooks/set-state-in-effect": "off", "react-refresh/only-export-components": [ "warn", { allowConstantExport: true }, diff --git a/web/frontend/package.json b/web/frontend/package.json index 7595c46bf..835682617 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -20,9 +20,9 @@ "@fontsource-variable/inter": "^5.2.8", "@tabler/icons-react": "^3.40.0", "@tailwindcss/vite": "^4.2.2", - "@tanstack/react-query": "^5.97.0", - "@tanstack/react-router": "^1.167.0", - "@tanstack/react-router-devtools": "^1.163.3", + "@tanstack/react-query": "^5.99.0", + "@tanstack/react-router": "^1.168.23", + "@tanstack/react-router-devtools": "^1.166.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.20", @@ -33,14 +33,14 @@ "radix-ui": "^1.4.3", "react": "19.2.5", "react-dom": "19.2.5", - "react-i18next": "^17.0.2", + "react-i18next": "^17.0.3", "react-markdown": "^10.1.0", "react-textarea-autosize": "^8.5.9", "rehype-highlight": "^7.0.2", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", - "shadcn": "^4.2.0", + "shadcn": "^4.3.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.2", @@ -52,20 +52,20 @@ "@tailwindcss/typography": "^0.5.19", "@tanstack/router-plugin": "^1.164.0", "@trivago/prettier-plugin-sort-imports": "^6.0.2", - "@types/node": "^25.5.0", + "@types/node": "^25.6.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", - "@typescript-eslint/eslint-plugin": "^8.57.1", + "@typescript-eslint/eslint-plugin": "^8.58.2", "@vitejs/plugin-react": "^6.0.1", - "eslint": "^10.1.0", + "eslint": "^10.2.1", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.4.0", - "prettier": "^3.8.1", + "globals": "^17.5.0", + "prettier": "^3.8.3", "prettier-plugin-tailwindcss": "^0.7.2", "typescript": "~5.9.3", - "typescript-eslint": "^8.57.1", + "typescript-eslint": "^8.58.2", "vite": "^8.0.8" } } diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 721bd7e75..210c111c5 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -16,16 +16,16 @@ importers: version: 3.41.1(react@19.2.5) '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) + version: 4.2.2(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) '@tanstack/react-query': - specifier: ^5.97.0 - version: 5.97.0(react@19.2.5) + specifier: ^5.99.0 + version: 5.99.0(react@19.2.5) '@tanstack/react-router': - specifier: ^1.167.0 - version: 1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + specifier: ^1.168.23 + version: 1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/react-router-devtools': - specifier: ^1.163.3 - version: 1.166.11(@tanstack/react-router@1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.7)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + specifier: ^1.166.13 + version: 1.166.13(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.15)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -57,8 +57,8 @@ importers: specifier: 19.2.5 version: 19.2.5(react@19.2.5) react-i18next: - specifier: ^17.0.2 - version: 17.0.2(i18next@26.0.3(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3) + specifier: ^17.0.3 + version: 17.0.3(i18next@26.0.3(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.5) @@ -78,8 +78,8 @@ importers: specifier: ^4.0.1 version: 4.0.1 shadcn: - specifier: ^4.2.0 - version: 4.2.0(@types/node@25.5.0)(typescript@5.9.3) + specifier: ^4.3.0 + version: 4.3.0(@types/node@25.6.0)(typescript@5.9.3) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -98,19 +98,19 @@ importers: devDependencies: '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.1.0(jiti@2.6.1)) + version: 10.0.1(eslint@10.2.1(jiti@2.6.1)) '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.2.2) '@tanstack/router-plugin': specifier: ^1.164.0 - version: 1.167.9(@tanstack/react-router@1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) + version: 1.167.9(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) '@trivago/prettier-plugin-sort-imports': specifier: ^6.0.2 - version: 6.0.2(prettier@3.8.1) + version: 6.0.2(prettier@3.8.3) '@types/node': - specifier: ^25.5.0 - version: 25.5.0 + specifier: ^25.6.0 + version: 25.6.0 '@types/react': specifier: ^19.2.7 version: 19.2.14 @@ -118,41 +118,41 @@ importers: specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) '@typescript-eslint/eslint-plugin': - specifier: ^8.57.1 - version: 8.57.2(@typescript-eslint/parser@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) + specifier: ^8.58.2 + version: 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) + version: 6.0.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) eslint: - specifier: ^10.1.0 - version: 10.1.0(jiti@2.6.1) + specifier: ^10.2.1 + version: 10.2.1(jiti@2.6.1) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.1.0(jiti@2.6.1)) + version: 10.1.8(eslint@10.2.1(jiti@2.6.1)) eslint-plugin-react-hooks: - specifier: ^7.0.1 - version: 7.0.1(eslint@10.1.0(jiti@2.6.1)) + specifier: ^7.1.1 + version: 7.1.1(eslint@10.2.1(jiti@2.6.1)) eslint-plugin-react-refresh: specifier: ^0.5.2 - version: 0.5.2(eslint@10.1.0(jiti@2.6.1)) + version: 0.5.2(eslint@10.2.1(jiti@2.6.1)) globals: - specifier: ^17.4.0 - version: 17.4.0 + specifier: ^17.5.0 + version: 17.5.0 prettier: - specifier: ^3.8.1 - version: 3.8.1 + specifier: ^3.8.3 + version: 3.8.3 prettier-plugin-tailwindcss: specifier: ^0.7.2 - version: 0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1))(prettier@3.8.1) + version: 0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3))(prettier@3.8.3) typescript: specifier: ~5.9.3 version: 5.9.3 typescript-eslint: - specifier: ^8.57.1 - version: 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) + specifier: ^8.58.2 + version: 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^8.0.8 - version: 8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) + version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) packages: @@ -474,16 +474,16 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.23.3': - resolution: {integrity: sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==} + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/config-helpers@0.5.3': - resolution: {integrity: sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==} + '@eslint/config-helpers@0.5.5': + resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/core@1.1.1': - resolution: {integrity: sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==} + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/js@10.0.1': @@ -495,12 +495,12 @@ packages: eslint: optional: true - '@eslint/object-schema@3.0.3': - resolution: {integrity: sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==} + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/plugin-kit@0.6.1': - resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} + '@eslint/plugin-kit@0.7.1': + resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@floating-ui/core@1.7.5': @@ -521,8 +521,8 @@ packages: '@fontsource-variable/inter@5.2.8': resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==} - '@hono/node-server@1.19.13': - resolution: {integrity: sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==} + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} peerDependencies: hono: ^4 @@ -543,35 +543,35 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@inquirer/ansi@1.0.2': - resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} - engines: {node: '>=18'} + '@inquirer/ansi@2.0.5': + resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - '@inquirer/confirm@5.1.21': - resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} - engines: {node: '>=18'} + '@inquirer/confirm@6.0.11': + resolution: {integrity: sha512-pTpHjg0iEIRMYV/7oCZUMf27/383E6Wyhfc/MY+AVQGEoUobffIYWOK9YLP2XFRGz/9i6WlTQh1CkFVIo2Y7XA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/core@10.3.2': - resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} - engines: {node: '>=18'} + '@inquirer/core@11.1.8': + resolution: {integrity: sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/figures@1.0.15': - resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} - engines: {node: '>=18'} + '@inquirer/figures@2.0.5': + resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - '@inquirer/type@3.0.10': - resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} - engines: {node: '>=18'} + '@inquirer/type@4.0.5': + resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: @@ -641,6 +641,9 @@ packages: '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + '@open-draft/deferred-promise@3.0.0': + resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==} + '@open-draft/logger@0.3.0': resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} @@ -1375,36 +1378,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==} @@ -1488,24 +1497,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.2': resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.2': resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.2': resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.2': resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} @@ -1549,28 +1562,28 @@ packages: resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==} engines: {node: '>=20.19'} - '@tanstack/query-core@5.97.0': - resolution: {integrity: sha512-QdpLP5VzVMgo4VtaPppRA2W04UFjIqX+bxke/ZJhE5cfd5UPkRzqIAJQt9uXkQJjqE8LBOMbKv7f8HCsZltXlg==} + '@tanstack/query-core@5.99.0': + resolution: {integrity: sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==} - '@tanstack/react-query@5.97.0': - resolution: {integrity: sha512-y4So4eGcQoK2WVMAcDNZE9ofB/p5v1OlKvtc1F3uqHwrtifobT7q+ZnXk2mRkc8E84HKYSlAE9z6HXl2V0+ySQ==} + '@tanstack/react-query@5.99.0': + resolution: {integrity: sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==} peerDependencies: react: ^18 || ^19 - '@tanstack/react-router-devtools@1.166.11': - resolution: {integrity: sha512-WYR3q4Xui5yPT/5PXtQh8i03iUA7q8dONBjWpV3nsGdM8Cs1FxpfhLstW0wZO1dOvSyElscwTRCJ6nO5N8r3Lg==} + '@tanstack/react-router-devtools@1.166.13': + resolution: {integrity: sha512-6yKRFFJrEEOiGp5RAAuGCYsl81M4XAhJmLcu9PKj+HZle4A3dsP60lwHoqQYWHMK9nKKFkdXR+D8qxzxqtQbEA==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/react-router': ^1.168.2 - '@tanstack/router-core': ^1.168.2 + '@tanstack/react-router': ^1.168.15 + '@tanstack/router-core': ^1.168.11 react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' peerDependenciesMeta: '@tanstack/router-core': optional: true - '@tanstack/react-router@1.168.8': - resolution: {integrity: sha512-t0S0QueXubBKmI9eLPcN/A1sLQgTu8/yHerjrvvsGeD12zMdw0uJPKwEKpStQF2OThQtw64cs34uUSYXBUTSNw==} + '@tanstack/react-router@1.168.23': + resolution: {integrity: sha512-+GblieDnutG6oipJJPNtRJjrWF8QTZEG/l0532+BngFkVK48oHNOcvIkSoAFYftK1egAwM7KBxXsb0Ou+X6/MQ==} engines: {node: '>=20.19'} peerDependencies: react: '>=18.0.0 || >=19.0.0' @@ -1582,16 +1595,21 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/router-core@1.168.15': + resolution: {integrity: sha512-Wr0424NDtD8fT/uALobMZ9DdcfsTyXtW5IPR++7zvW8/7RaIOeaqXpVDId8ywaGtqPWLWOfaUg2zUtYtukoXYA==} + engines: {node: '>=20.19'} + hasBin: true + '@tanstack/router-core@1.168.7': resolution: {integrity: sha512-z4UEdlzMrFaKBsG4OIxlZEm+wsYBtEp//fnX6kW18jhQpETNcM6u2SXNdX+bcIYp6AaR7ERS3SBENzjC/xxwQQ==} engines: {node: '>=20.19'} hasBin: true - '@tanstack/router-devtools-core@1.167.1': - resolution: {integrity: sha512-ECMM47J4KmifUvJguGituSiBpfN8SyCUEoxQks5RY09hpIBfR2eswCv2e6cJimjkKwBQXOVTPkTUk/yRvER+9w==} + '@tanstack/router-devtools-core@1.167.3': + resolution: {integrity: sha512-fJ1VMhyQgnoashTrP763c2HRc9kofgF61L7Jb3F6eTHAmCKtGVx8BRtiFt37sr3U0P0jmaaiiSPGP6nT5JtVNg==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/router-core': ^1.168.2 + '@tanstack/router-core': ^1.168.11 csstype: ^3.0.10 peerDependenciesMeta: csstype: @@ -1684,8 +1702,8 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@25.5.0': - resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@types/node@25.6.0': + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} @@ -1695,6 +1713,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/set-cookie-parser@2.4.10': + resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} + '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -1707,63 +1728,63 @@ packages: '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} - '@typescript-eslint/eslint-plugin@8.57.2': - resolution: {integrity: sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==} + '@typescript-eslint/eslint-plugin@8.58.2': + resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.57.2 + '@typescript-eslint/parser': ^8.58.2 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.57.2': - resolution: {integrity: sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==} + '@typescript-eslint/parser@8.58.2': + resolution: {integrity: sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.57.2': - resolution: {integrity: sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==} + '@typescript-eslint/project-service@8.58.2': + resolution: {integrity: sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.57.2': - resolution: {integrity: sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==} + '@typescript-eslint/scope-manager@8.58.2': + resolution: {integrity: sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.57.2': - resolution: {integrity: sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==} + '@typescript-eslint/tsconfig-utils@8.58.2': + resolution: {integrity: sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.57.2': - resolution: {integrity: sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==} + '@typescript-eslint/type-utils@8.58.2': + resolution: {integrity: sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.57.2': - resolution: {integrity: sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==} + '@typescript-eslint/types@8.58.2': + resolution: {integrity: sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.57.2': - resolution: {integrity: sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==} + '@typescript-eslint/typescript-estree@8.58.2': + resolution: {integrity: sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.57.2': - resolution: {integrity: sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==} + '@typescript-eslint/utils@8.58.2': + resolution: {integrity: sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.57.2': - resolution: {integrity: sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==} + '@typescript-eslint/visitor-keys@8.58.2': + resolution: {integrity: sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': @@ -1995,6 +2016,9 @@ packages: cookie-es@2.0.0: resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==} + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -2100,8 +2124,8 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} - dotenv@17.4.1: - resolution: {integrity: sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} dunder-proto@1.0.1: @@ -2181,11 +2205,11 @@ packages: peerDependencies: eslint: '>=7.0.0' - eslint-plugin-react-hooks@7.0.1: - resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} engines: {node: '>=18'} peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 eslint-plugin-react-refresh@0.5.2: resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==} @@ -2204,8 +2228,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.1.0: - resolution: {integrity: sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==} + eslint@10.2.1: + resolution: {integrity: sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -2288,9 +2312,18 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-wrap-ansi@0.2.0: + resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -2408,8 +2441,8 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - globals@17.4.0: - resolution: {integrity: sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==} + globals@17.5.0: + resolution: {integrity: sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==} engines: {node: '>=18'} goober@2.1.18: @@ -2466,8 +2499,8 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} - headers-polyfill@4.0.3: - resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + headers-polyfill@5.0.1: + resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -2479,8 +2512,8 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} - hono@4.12.12: - resolution: {integrity: sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==} + hono@4.12.14: + resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==} engines: {node: '>=16.9.0'} html-parse-stringify@3.0.1: @@ -2644,8 +2677,8 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} - isbot@5.1.36: - resolution: {integrity: sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ==} + isbot@5.1.39: + resolution: {integrity: sha512-obH0yYahGXdzNxo+djmHhBYThUKDkz565cxkIlt2L9hXfv1NlaLKoDBHo6KxXsYrIXx2RK3x5vY36CfZcobxEw==} engines: {node: '>=18'} isexe@2.0.0: @@ -2771,24 +2804,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -3003,10 +3040,6 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} - minimatch@10.2.4: - resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} - engines: {node: 18 || 20 || >=22} - minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -3021,8 +3054,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msw@2.13.2: - resolution: {integrity: sha512-go2H1TIERKkC48pXiwec5l6sbNqYuvqOk3/vHGo1Zd+pq/H63oFawDQerH+WQdUw/flJFHDG7F+QdWMwhntA/A==} + msw@2.13.4: + resolution: {integrity: sha512-fPlKBeFe+8rpcyR3umUmmHuNwu6gc6T3STvkgEa9WDX/HEgal9wDeflpCUAIRtmvaLZM2igfI5y1bZ9G5J26KA==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -3031,9 +3064,9 @@ packages: typescript: optional: true - mute-stream@2.0.0: - resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} - engines: {node: ^18.17.0 || >=20.5.0} + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} @@ -3196,6 +3229,10 @@ packages: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.9: resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} engines: {node: ^10 || ^12 || >=14} @@ -3263,8 +3300,8 @@ packages: prettier-plugin-svelte: optional: true - prettier@3.8.1: - resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} engines: {node: '>=14'} hasBin: true @@ -3320,8 +3357,8 @@ packages: peerDependencies: react: ^19.2.5 - react-i18next@17.0.2: - resolution: {integrity: sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA==} + react-i18next@17.0.3: + resolution: {integrity: sha512-x4xjvUNZ56T+zfXWNedNnCET9Xq1IBYWX7IsWo5cCQ/RT+Rm7GWqt0h9PShFi4IhyMnsdiu1C6Jc4DE+/S3PFQ==} peerDependencies: i18next: '>= 26.0.1' react: '>= 16.8.0' @@ -3430,8 +3467,8 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} - rettime@0.10.1: - resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} + rettime@0.11.7: + resolution: {integrity: sha512-DoAm1WjR1eH7z8sHPtvvUMIZh4/CSKkGCz6CxPqOrEAnOGtOuHSnSE9OC+razqxKuf4ub7pAYyl/vZV0vGs5tg==} reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} @@ -3478,19 +3515,32 @@ packages: peerDependencies: seroval: ^1.0 + seroval-plugins@1.5.2: + resolution: {integrity: sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + seroval@1.5.1: resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==} engines: {node: '>=10'} + seroval@1.5.2: + resolution: {integrity: sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==} + engines: {node: '>=10'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-cookie-parser@3.1.0: + resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shadcn@4.2.0: - resolution: {integrity: sha512-ZDuV340itidaUd4Gi1BxQX+Y7Ush6BHp6URZBM2RyxUUBZ6yFtOWIr4nVY+Ro+YRSpo82v7JrsmtcU5xoBCMJQ==} + shadcn@4.3.0: + resolution: {integrity: sha512-7vhnBh2LVLyxOd1ZQWwXv7OATCnQcxdqc8FbZdNigZriNOwDsHklQmPpvPt1jcrFK5mzMI+cyuAYv8WzERx2Og==} hasBin: true shebang-command@2.0.0: @@ -3686,20 +3736,20 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} - typescript-eslint@8.57.2: - resolution: {integrity: sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==} + typescript-eslint@8.58.2: + resolution: {integrity: sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} @@ -3897,10 +3947,6 @@ packages: resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} engines: {node: '>=20'} - wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -3935,10 +3981,6 @@ packages: resolution: {integrity: sha512-/BY0AUXnS7IKO354uLLA2eRcWiqDifEbd6unXCsOxkFDAkhgUL3PH9X2bFoaU0YchnDXsF+iKleeTLJGckbXfA==} engines: {node: '>=18.19'} - yoctocolors-cjs@2.1.3: - resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} - engines: {node: '>=18'} - yoctocolors@2.1.2: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} @@ -4156,7 +4198,7 @@ snapshots: '@dotenvx/dotenvx@1.61.0': dependencies: commander: 11.1.0 - dotenv: 17.4.1 + dotenv: 17.4.2 eciesjs: 0.4.18 execa: 5.1.1 fdir: 6.5.0(picomatch@4.0.4) @@ -4264,38 +4306,38 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.1.0(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.2.1(jiti@2.6.1))': dependencies: - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.23.3': + '@eslint/config-array@0.23.5': dependencies: - '@eslint/object-schema': 3.0.3 + '@eslint/object-schema': 3.0.5 debug: 4.4.3 - minimatch: 10.2.4 + minimatch: 10.2.5 transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.5.3': + '@eslint/config-helpers@0.5.5': dependencies: - '@eslint/core': 1.1.1 + '@eslint/core': 1.2.1 - '@eslint/core@1.1.1': + '@eslint/core@1.2.1': dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.1.0(jiti@2.6.1))': + '@eslint/js@10.0.1(eslint@10.2.1(jiti@2.6.1))': optionalDependencies: - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) - '@eslint/object-schema@3.0.3': {} + '@eslint/object-schema@3.0.5': {} - '@eslint/plugin-kit@0.6.1': + '@eslint/plugin-kit@0.7.1': dependencies: - '@eslint/core': 1.1.1 + '@eslint/core': 1.2.1 levn: 0.4.1 '@floating-ui/core@1.7.5': @@ -4317,9 +4359,9 @@ snapshots: '@fontsource-variable/inter@5.2.8': {} - '@hono/node-server@1.19.13(hono@4.12.12)': + '@hono/node-server@1.19.14(hono@4.12.14)': dependencies: - hono: 4.12.12 + hono: 4.12.14 '@humanfs/core@0.19.1': {} @@ -4332,33 +4374,32 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@inquirer/ansi@1.0.2': {} + '@inquirer/ansi@2.0.5': {} - '@inquirer/confirm@5.1.21(@types/node@25.5.0)': + '@inquirer/confirm@6.0.11(@types/node@25.6.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.5.0) - '@inquirer/type': 3.0.10(@types/node@25.5.0) + '@inquirer/core': 11.1.8(@types/node@25.6.0) + '@inquirer/type': 4.0.5(@types/node@25.6.0) optionalDependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 - '@inquirer/core@10.3.2(@types/node@25.5.0)': + '@inquirer/core@11.1.8(@types/node@25.6.0)': dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@25.5.0) + '@inquirer/ansi': 2.0.5 + '@inquirer/figures': 2.0.5 + '@inquirer/type': 4.0.5(@types/node@25.6.0) cli-width: 4.1.0 - mute-stream: 2.0.0 + fast-wrap-ansi: 0.2.0 + mute-stream: 3.0.0 signal-exit: 4.1.0 - wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 - '@inquirer/figures@1.0.15': {} + '@inquirer/figures@2.0.5': {} - '@inquirer/type@3.0.10(@types/node@25.5.0)': + '@inquirer/type@4.0.5(@types/node@25.6.0)': optionalDependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -4381,7 +4422,7 @@ snapshots: '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.13(hono@4.12.12) + '@hono/node-server': 1.19.14(hono@4.12.14) ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 @@ -4391,7 +4432,7 @@ snapshots: eventsource-parser: 3.0.6 express: 5.2.1 express-rate-limit: 8.3.2(express@5.2.1) - hono: 4.12.12 + hono: 4.12.14 jose: 6.2.2 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -4439,6 +4480,8 @@ snapshots: '@open-draft/deferred-promise@2.2.0': {} + '@open-draft/deferred-promise@3.0.0': {} + '@open-draft/logger@0.3.0': dependencies: is-node-process: 1.2.0 @@ -5325,39 +5368,39 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.2.2 - '@tailwindcss/vite@4.2.2(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': + '@tailwindcss/vite@4.2.2(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': dependencies: '@tailwindcss/node': 4.2.2 '@tailwindcss/oxide': 4.2.2 tailwindcss: 4.2.2 - vite: 8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) + vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) '@tanstack/history@1.161.6': {} - '@tanstack/query-core@5.97.0': {} + '@tanstack/query-core@5.99.0': {} - '@tanstack/react-query@5.97.0(react@19.2.5)': + '@tanstack/react-query@5.99.0(react@19.2.5)': dependencies: - '@tanstack/query-core': 5.97.0 + '@tanstack/query-core': 5.99.0 react: 19.2.5 - '@tanstack/react-router-devtools@1.166.11(@tanstack/react-router@1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.7)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@tanstack/react-router-devtools@1.166.13(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.15)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@tanstack/react-router': 1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@tanstack/router-devtools-core': 1.167.1(@tanstack/router-core@1.168.7)(csstype@3.2.3) + '@tanstack/react-router': 1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tanstack/router-devtools-core': 1.167.3(@tanstack/router-core@1.168.15)(csstype@3.2.3) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) optionalDependencies: - '@tanstack/router-core': 1.168.7 + '@tanstack/router-core': 1.168.15 transitivePeerDependencies: - csstype - '@tanstack/react-router@1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@tanstack/history': 1.161.6 '@tanstack/react-store': 0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@tanstack/router-core': 1.168.7 - isbot: 5.1.36 + '@tanstack/router-core': 1.168.15 + isbot: 5.1.39 react: 19.2.5 react-dom: 19.2.5(react@19.2.5) @@ -5368,6 +5411,13 @@ snapshots: react-dom: 19.2.5(react@19.2.5) use-sync-external-store: 1.6.0(react@19.2.5) + '@tanstack/router-core@1.168.15': + dependencies: + '@tanstack/history': 1.161.6 + cookie-es: 3.1.1 + seroval: 1.5.2 + seroval-plugins: 1.5.2(seroval@1.5.2) + '@tanstack/router-core@1.168.7': dependencies: '@tanstack/history': 1.161.6 @@ -5375,9 +5425,9 @@ snapshots: seroval: 1.5.1 seroval-plugins: 1.5.1(seroval@1.5.1) - '@tanstack/router-devtools-core@1.167.1(@tanstack/router-core@1.168.7)(csstype@3.2.3)': + '@tanstack/router-devtools-core@1.167.3(@tanstack/router-core@1.168.15)(csstype@3.2.3)': dependencies: - '@tanstack/router-core': 1.168.7 + '@tanstack/router-core': 1.168.15 clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) optionalDependencies: @@ -5388,7 +5438,7 @@ snapshots: '@tanstack/router-core': 1.168.7 '@tanstack/router-utils': 1.161.6 '@tanstack/virtual-file-routes': 1.161.7 - prettier: 3.8.1 + prettier: 3.8.3 recast: 0.23.11 source-map: 0.7.6 tsx: 4.21.0 @@ -5396,7 +5446,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.167.9(@tanstack/react-router@1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': + '@tanstack/router-plugin@1.167.9(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -5412,8 +5462,8 @@ snapshots: unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: - '@tanstack/react-router': 1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - vite: 8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) + '@tanstack/react-router': 1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -5435,7 +5485,7 @@ snapshots: '@tanstack/virtual-file-routes@1.161.7': {} - '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1)': + '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3)': dependencies: '@babel/generator': 7.29.1 '@babel/parser': 7.29.2 @@ -5445,7 +5495,7 @@ snapshots: lodash-es: 4.17.23 minimatch: 9.0.9 parse-imports-exports: 0.2.4 - prettier: 3.8.1 + prettier: 3.8.3 transitivePeerDependencies: - supports-color @@ -5484,9 +5534,9 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@25.5.0': + '@types/node@25.6.0': dependencies: - undici-types: 7.18.2 + undici-types: 7.19.2 '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: @@ -5496,6 +5546,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/set-cookie-parser@2.4.10': + dependencies: + '@types/node': 25.6.0 + '@types/statuses@2.0.6': {} '@types/unist@2.0.11': {} @@ -5504,15 +5558,15 @@ snapshots: '@types/validate-npm-package-name@4.0.2': {} - '@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/type-utils': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.57.2 - eslint: 10.1.0(jiti@2.6.1) + '@typescript-eslint/parser': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.2 + eslint: 10.2.1(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -5520,58 +5574,58 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.57.2 + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.2 debug: 4.4.3 - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.57.2(typescript@5.9.3)': + '@typescript-eslint/project-service@8.58.2(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) - '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3) + '@typescript-eslint/types': 8.58.2 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.57.2': + '@typescript-eslint/scope-manager@8.58.2': dependencies: - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/visitor-keys': 8.57.2 + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/visitor-keys': 8.58.2 - '@typescript-eslint/tsconfig-utils@8.57.2(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.58.2(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.57.2': {} + '@typescript-eslint/types@8.58.2': {} - '@typescript-eslint/typescript-estree@8.57.2(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.57.2(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/visitor-keys': 8.57.2 + '@typescript-eslint/project-service': 8.58.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3) + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/visitor-keys': 8.58.2 debug: 4.4.3 - minimatch: 10.2.4 + minimatch: 10.2.5 semver: 7.7.4 tinyglobby: 0.2.16 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -5579,28 +5633,28 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - eslint: 10.1.0(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) + eslint: 10.2.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.57.2': + '@typescript-eslint/visitor-keys@8.58.2': dependencies: - '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/types': 8.58.2 eslint-visitor-keys: 5.0.1 '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@6.0.1(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': + '@vitejs/plugin-react@6.0.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) + vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) accepts@2.0.0: dependencies: @@ -5799,6 +5853,8 @@ snapshots: cookie-es@2.0.0: {} + cookie-es@3.1.1: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -5870,7 +5926,7 @@ snapshots: diff@8.0.4: {} - dotenv@17.4.1: {} + dotenv@17.4.2: {} dunder-proto@1.0.1: dependencies: @@ -5953,24 +6009,24 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.1.0(jiti@2.6.1)): + eslint-config-prettier@10.1.8(eslint@10.2.1(jiti@2.6.1)): dependencies: - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) - eslint-plugin-react-hooks@7.0.1(eslint@10.1.0(jiti@2.6.1)): + eslint-plugin-react-hooks@7.1.1(eslint@10.2.1(jiti@2.6.1)): dependencies: '@babel/core': 7.29.0 '@babel/parser': 7.29.2 - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) hermes-parser: 0.25.1 zod: 4.3.6 zod-validation-error: 4.0.2(zod@4.3.6) transitivePeerDependencies: - supports-color - eslint-plugin-react-refresh@0.5.2(eslint@10.1.0(jiti@2.6.1)): + eslint-plugin-react-refresh@0.5.2(eslint@10.2.1(jiti@2.6.1)): dependencies: - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) eslint-scope@9.1.2: dependencies: @@ -5983,14 +6039,14 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.1.0(jiti@2.6.1): + eslint@10.2.1(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.3 - '@eslint/config-helpers': 0.5.3 - '@eslint/core': 1.1.1 - '@eslint/plugin-kit': 0.6.1 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.5.5 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.1 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 @@ -6012,7 +6068,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.4 + minimatch: 10.2.5 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -6131,8 +6187,18 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + fast-uri@3.1.0: {} + fast-wrap-ansi@0.2.0: + dependencies: + fast-string-width: 3.0.2 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -6249,7 +6315,7 @@ snapshots: dependencies: is-glob: 4.0.3 - globals@17.4.0: {} + globals@17.5.0: {} goober@2.1.18(csstype@3.2.3): dependencies: @@ -6357,7 +6423,10 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 - headers-polyfill@4.0.3: {} + headers-polyfill@5.0.1: + dependencies: + '@types/set-cookie-parser': 2.4.10 + set-cookie-parser: 3.1.0 hermes-estree@0.25.1: {} @@ -6367,7 +6436,7 @@ snapshots: highlight.js@11.11.1: {} - hono@4.12.12: {} + hono@4.12.14: {} html-parse-stringify@3.0.1: dependencies: @@ -6488,7 +6557,7 @@ snapshots: dependencies: is-inside-container: 1.0.0 - isbot@5.1.36: {} + isbot@5.1.39: {} isexe@2.0.0: {} @@ -6997,10 +7066,6 @@ snapshots: mimic-function@5.0.1: {} - minimatch@10.2.4: - dependencies: - brace-expansion: 5.0.5 - minimatch@10.2.5: dependencies: brace-expansion: 5.0.5 @@ -7013,20 +7078,20 @@ snapshots: ms@2.1.3: {} - msw@2.13.2(@types/node@25.5.0)(typescript@5.9.3): + msw@2.13.4(@types/node@25.6.0)(typescript@5.9.3): dependencies: - '@inquirer/confirm': 5.1.21(@types/node@25.5.0) + '@inquirer/confirm': 6.0.11(@types/node@25.6.0) '@mswjs/interceptors': 0.41.3 - '@open-draft/deferred-promise': 2.2.0 + '@open-draft/deferred-promise': 3.0.0 '@types/statuses': 2.0.6 cookie: 1.1.1 graphql: 16.13.2 - headers-polyfill: 4.0.3 + headers-polyfill: 5.0.1 is-node-process: 1.2.0 outvariant: 1.4.3 path-to-regexp: 6.3.0 picocolors: 1.1.1 - rettime: 0.10.1 + rettime: 0.11.7 statuses: 2.0.2 strict-event-emitter: 0.5.1 tough-cookie: 6.0.1 @@ -7038,7 +7103,7 @@ snapshots: transitivePeerDependencies: - '@types/node' - mute-stream@2.0.0: {} + mute-stream@3.0.0: {} nanoid@3.3.11: {} @@ -7196,6 +7261,12 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 + postcss@8.5.10: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.9: dependencies: nanoid: 3.3.11 @@ -7206,13 +7277,13 @@ snapshots: prelude-ls@1.2.1: {} - prettier-plugin-tailwindcss@0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1))(prettier@3.8.1): + prettier-plugin-tailwindcss@0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3))(prettier@3.8.3): dependencies: - prettier: 3.8.1 + prettier: 3.8.3 optionalDependencies: - '@trivago/prettier-plugin-sort-imports': 6.0.2(prettier@3.8.1) + '@trivago/prettier-plugin-sort-imports': 6.0.2(prettier@3.8.3) - prettier@3.8.1: {} + prettier@3.8.3: {} pretty-ms@9.3.0: dependencies: @@ -7315,7 +7386,7 @@ snapshots: react: 19.2.5 scheduler: 0.27.0 - react-i18next@17.0.2(i18next@26.0.3(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3): + react-i18next@17.0.3(i18next@26.0.3(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3): dependencies: '@babel/runtime': 7.29.2 html-parse-stringify: 3.0.1 @@ -7460,7 +7531,7 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 - rettime@0.10.1: {} + rettime@0.11.7: {} reusify@1.1.0: {} @@ -7529,8 +7600,14 @@ snapshots: dependencies: seroval: 1.5.1 + seroval-plugins@1.5.2(seroval@1.5.2): + dependencies: + seroval: 1.5.2 + seroval@1.5.1: {} + seroval@1.5.2: {} + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -7540,9 +7617,11 @@ snapshots: transitivePeerDependencies: - supports-color + set-cookie-parser@3.1.0: {} + setprototypeof@1.2.0: {} - shadcn@4.2.0(@types/node@25.5.0)(typescript@5.9.3): + shadcn@4.3.0(@types/node@25.6.0)(typescript@5.9.3): dependencies: '@babel/core': 7.29.0 '@babel/parser': 7.29.2 @@ -7563,11 +7642,11 @@ snapshots: fuzzysort: 3.1.0 https-proxy-agent: 7.0.6 kleur: 4.1.5 - msw: 2.13.2(@types/node@25.5.0)(typescript@5.9.3) + msw: 2.13.4(@types/node@25.6.0)(typescript@5.9.3) node-fetch: 3.3.2 open: 11.0.0 ora: 8.2.0 - postcss: 8.5.9 + postcss: 8.5.10 postcss-selector-parser: 7.1.1 prompts: 2.4.2 recast: 0.23.11 @@ -7769,20 +7848,20 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typescript-eslint@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) - eslint: 10.1.0(jiti@2.6.1) + '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.2.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color typescript@5.9.3: {} - undici-types@7.18.2: {} + undici-types@7.19.2: {} unicorn-magic@0.3.0: {} @@ -7906,7 +7985,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0): + vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -7914,7 +7993,7 @@ snapshots: rolldown: 1.0.0-rc.15 tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 esbuild: 0.27.4 fsevents: 2.3.3 jiti: 2.6.1 @@ -7944,12 +8023,6 @@ snapshots: string-width: 8.2.0 strip-ansi: 7.2.0 - wrap-ansi@6.2.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -7985,8 +8058,6 @@ snapshots: dependencies: yoctocolors: 2.1.2 - yoctocolors-cjs@2.1.3: {} - yoctocolors@2.1.2: {} zod-to-json-schema@3.25.2(zod@3.25.76): diff --git a/web/frontend/src/api/pico.ts b/web/frontend/src/api/pico.ts index 6b8ceb49a..ca98a06da 100644 --- a/web/frontend/src/api/pico.ts +++ b/web/frontend/src/api/pico.ts @@ -2,16 +2,16 @@ import { launcherFetch } from "@/api/http" // API client for Pico Channel configuration. -interface PicoTokenResponse { - token: string +interface PicoInfoResponse { ws_url: string enabled: boolean + configured?: boolean } interface PicoSetupResponse { - token: string ws_url: string enabled: boolean + configured?: boolean changed: boolean } @@ -25,16 +25,16 @@ async function request(path: string, options?: RequestInit): Promise { return res.json() as Promise } -export async function getPicoToken(): Promise { - return request("/api/pico/token") +export async function getPicoInfo(): Promise { + return request("/api/pico/info") } -export async function regenPicoToken(): Promise { - return request("/api/pico/token", { method: "POST" }) +export async function regenPicoToken(): Promise { + return request("/api/pico/token", { method: "POST" }) } export async function setupPico(): Promise { return request("/api/pico/setup", { method: "POST" }) } -export type { PicoTokenResponse, PicoSetupResponse } +export type { PicoInfoResponse, PicoSetupResponse } diff --git a/web/frontend/src/components/channels/channel-array-list-field.tsx b/web/frontend/src/components/channels/channel-array-list-field.tsx new file mode 100644 index 000000000..ff601c07b --- /dev/null +++ b/web/frontend/src/components/channels/channel-array-list-field.tsx @@ -0,0 +1,180 @@ +import { IconX } from "@tabler/icons-react" +import { + type KeyboardEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react" +import { useTranslation } from "react-i18next" + +import { + mergeUniqueStringItems, + parseConservativeStringListInput, +} from "@/components/channels/channel-array-utils" +import { Field } from "@/components/shared-form" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +type StringListParser = (raw: string) => string[] +export type ArrayFieldFlusher = () => string[] | null + +type RegisterArrayFieldFlusher = ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, +) => void + +function areStringArraysEqual(left: string[], right: string[]): boolean { + if (left.length !== right.length) { + return false + } + return left.every((item, index) => item === right[index]) +} + +interface ChannelArrayListFieldProps { + label: string + hint?: string + error?: string + required?: boolean + value: string[] + onChange: (value: string[]) => void + placeholder?: string + parser?: StringListParser + fieldPath?: string + registerFlusher?: RegisterArrayFieldFlusher + resetVersion?: number +} + +export function ChannelArrayListField({ + label, + hint, + error, + required, + value, + onChange, + placeholder, + parser = parseConservativeStringListInput, + fieldPath, + registerFlusher, + resetVersion, +}: ChannelArrayListFieldProps) { + const { t } = useTranslation() + const [draft, setDraft] = useState("") + const draftRef = useRef("") + const valueRef = useRef(value) + const localValueRef = useRef(value) + const parserRef = useRef(parser) + const onChangeRef = useRef(onChange) + + useEffect(() => { + valueRef.current = value + localValueRef.current = value + }, [value]) + + useEffect(() => { + draftRef.current = "" + setDraft("") + }, [resetVersion]) + + useEffect(() => { + parserRef.current = parser + }, [parser]) + + useEffect(() => { + onChangeRef.current = onChange + }, [onChange]) + + const commitDraft = useCallback(() => { + const rawDraft = draftRef.current + if (rawDraft.trim() === "") { + if (!areStringArraysEqual(localValueRef.current, valueRef.current)) { + return localValueRef.current + } + draftRef.current = "" + setDraft("") + return null + } + draftRef.current = "" + setDraft("") + const nextItems = parserRef.current(rawDraft) + if (nextItems.length === 0) { + return null + } + const mergedItems = mergeUniqueStringItems(localValueRef.current, nextItems) + localValueRef.current = mergedItems + onChangeRef.current(mergedItems) + return mergedItems + }, []) + + useEffect(() => { + if (!fieldPath || !registerFlusher) { + return + } + registerFlusher(fieldPath, commitDraft) + return () => registerFlusher(fieldPath, null) + }, [commitDraft, fieldPath, registerFlusher]) + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter") { + return + } + event.preventDefault() + commitDraft() + } + + const handleRemove = (index: number) => { + const nextValue = value.filter((_, itemIndex) => itemIndex !== index) + localValueRef.current = nextValue + onChangeRef.current(nextValue) + } + + return ( + +
+ {value.length > 0 && ( +
+ {value.map((item, index) => ( + + {item} + + + ))} +
+ )} + +
+ { + const nextDraft = event.target.value + draftRef.current = nextDraft + setDraft(nextDraft) + }} + onKeyDown={handleKeyDown} + placeholder={placeholder} + /> + +
+
+
+ ) +} diff --git a/web/frontend/src/components/channels/channel-array-utils.ts b/web/frontend/src/components/channels/channel-array-utils.ts new file mode 100644 index 000000000..0f6268be8 --- /dev/null +++ b/web/frontend/src/components/channels/channel-array-utils.ts @@ -0,0 +1,72 @@ +const ALLOW_FROM_HIDDEN_CHARS_RE = + /\u200b|\u200c|\u200d|\u200e|\u200f|\u202a|\u202b|\u202c|\u202d|\u202e|\u2060|\u2061|\u2062|\u2063|\u2064|\u2066|\u2067|\u2068|\u2069|\ufeff/g + +function normalizeStringListItems( + items: string[], + options: { stripHiddenChars?: boolean } = {}, +): string[] { + const result: string[] = [] + const seen = new Set() + + for (const item of items) { + const normalized = options.stripHiddenChars + ? item.replace(ALLOW_FROM_HIDDEN_CHARS_RE, "") + : item + const trimmed = normalized.trim() + if (trimmed.length === 0 || seen.has(trimmed)) { + continue + } + seen.add(trimmed) + result.push(trimmed) + } + + return result +} + +function splitStringList( + raw: string, + separators: RegExp, + options: { stripHiddenChars?: boolean } = {}, +): string[] { + if (raw.trim() === "") { + return [] + } + return normalizeStringListItems(raw.split(separators), options) +} + +export function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + return value.filter((item): item is string => typeof item === "string") +} + +export function parseAllowFromInput(raw: string): string[] { + return splitStringList(raw, /[,\uFF0C、;;\n\r\t]+/, { + stripHiddenChars: true, + }) +} + +export function parseConservativeStringListInput(raw: string): string[] { + return splitStringList(raw, /[,\uFF0C\n\r\t]+/) +} + +export function normalizeAllowFromValues(value: unknown): string[] { + return normalizeStringListItems(asStringArray(value), { + stripHiddenChars: true, + }) +} + +export function mergeUniqueStringItems( + currentItems: string[], + nextItems: string[], +): string[] { + return normalizeStringListItems([...currentItems, ...nextItems]) +} + +export function serializeStringArrayForSubmit(value: unknown): unknown { + if (!Array.isArray(value)) { + return value + } + return normalizeStringListItems(asStringArray(value)).join("\n") +} diff --git a/web/frontend/src/components/channels/channel-config-page.tsx b/web/frontend/src/components/channels/channel-config-page.tsx index a235daf8d..f6609e3ba 100644 --- a/web/frontend/src/components/channels/channel-config-page.tsx +++ b/web/frontend/src/components/channels/channel-config-page.tsx @@ -9,6 +9,11 @@ import { getChannelsCatalog, patchAppConfig, } from "@/api/channels" +import { type ArrayFieldFlusher } from "@/components/channels/channel-array-list-field" +import { + normalizeAllowFromValues, + serializeStringArrayForSubmit, +} from "@/components/channels/channel-array-utils" import { SECRET_FIELD_MAP, buildEditConfig, @@ -48,6 +53,43 @@ function asBool(value: unknown): boolean { return value === true } +function setRecordValueByPath( + source: Record, + pathSegments: string[], + value: unknown, +): Record { + const [segment, ...rest] = pathSegments + if (!segment) { + return source + } + if (rest.length === 0) { + return { ...source, [segment]: value } + } + return { + ...source, + [segment]: setRecordValueByPath(asRecord(source[segment]), rest, value), + } +} + +function setConfigValueByPath( + source: ChannelConfig, + fieldPath: string, + value: unknown, +): ChannelConfig { + return setRecordValueByPath(source, fieldPath.split("."), value) +} + +function serializeGroupTriggerForSubmit(value: unknown): unknown { + const groupTrigger = asRecord(value) + if (Object.keys(groupTrigger).length === 0) { + return value + } + return { + ...groupTrigger, + prefixes: serializeStringArrayForSubmit(groupTrigger.prefixes), + } +} + const CHANNEL_COMMON_CONFIG_KEYS = new Set([ "allow_from", "group_trigger", @@ -82,12 +124,20 @@ function buildSavePayload( if (key.startsWith("_")) continue if (key === "enabled") continue if (CHANNEL_COMMON_CONFIG_KEYS.has(key)) { - payload[key] = value + if (key === "allow_from") { + payload[key] = serializeStringArrayForSubmit( + normalizeAllowFromValues(value), + ) + } else if (key === "group_trigger") { + payload[key] = serializeGroupTriggerForSubmit(value) + } else { + payload[key] = value + } continue } if (isSecretField(key)) continue - settings[key] = value + settings[key] = serializeStringArrayForSubmit(value) } for (const [secretKey, editKey] of Object.entries(SECRET_FIELD_MAP)) { @@ -244,6 +294,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { const [editConfig, setEditConfig] = useState({}) const [configuredSecrets, setConfiguredSecrets] = useState([]) const [enabled, setEnabled] = useState(false) + const [arrayFieldResetVersion, setArrayFieldResetVersion] = useState(0) + const arrayFieldFlushersRef = useRef(new Map()) const loadData = useCallback( async (silent = false) => { @@ -302,11 +354,6 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { previousGatewayStatusRef.current = gatewayState }, [gatewayState, loadData]) - const savePayload = useMemo(() => { - if (!channel) return null - return buildSavePayload(channel, editConfig, enabled) - }, [channel, editConfig, enabled]) - const configured = useMemo(() => { if (!channel) return false return isConfigured(channel, editConfig, configuredSecrets) @@ -362,20 +409,52 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { }) }, []) + const registerArrayFieldFlusher = useCallback( + (fieldPath: string, flusher: ArrayFieldFlusher | null) => { + if (flusher) { + arrayFieldFlushersRef.current.set(fieldPath, flusher) + return + } + arrayFieldFlushersRef.current.delete(fieldPath) + }, + [], + ) + + const flushPendingArrayFieldDrafts = useCallback( + (sourceConfig: ChannelConfig): ChannelConfig => { + let nextConfig = sourceConfig + for (const [fieldPath, flusher] of arrayFieldFlushersRef.current) { + const flushedValue = flusher() + if (flushedValue === null) { + continue + } + nextConfig = setConfigValueByPath(nextConfig, fieldPath, flushedValue) + } + return nextConfig + }, + [], + ) + const handleReset = () => { if (!channel) return setEditConfig(buildEditConfig(channel.name, baseConfig)) setEnabled(asBool(baseConfig.enabled)) setServerError("") setFieldErrors({}) + setArrayFieldResetVersion((version) => version + 1) } const handleSave = async () => { - if (!channel || !savePayload) return + if (!channel) return + + const preparedEditConfig = flushPendingArrayFieldDrafts(editConfig) + if (preparedEditConfig !== editConfig) { + setEditConfig(preparedEditConfig) + } const missingRequiredFields = requiredKeys.filter((key) => isMissingRequiredValue( - getFieldValueForValidation(editConfig, configuredSecrets, key), + getFieldValueForValidation(preparedEditConfig, configuredSecrets, key), ), ) if (missingRequiredFields.length > 0) { @@ -393,6 +472,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { setServerError("") setFieldErrors({}) try { + const savePayload = buildSavePayload(channel, preparedEditConfig, enabled) await patchAppConfig({ channel_list: { [channel.config_key]: savePayload, @@ -462,6 +542,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} configuredSecrets={configuredSecrets} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "discord": @@ -471,6 +553,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} configuredSecrets={configuredSecrets} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "slack": @@ -480,6 +564,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} configuredSecrets={configuredSecrets} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "feishu": @@ -489,6 +575,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} configuredSecrets={configuredSecrets} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "weixin": @@ -498,6 +586,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} isEdit={isEdit} onBindSuccess={() => void handleWeixinBindSuccess()} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "wecom": @@ -518,6 +608,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { hiddenKeys={[...hiddenKeys, "bot_id"]} requiredKeys={requiredKeys} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) @@ -530,6 +622,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { hiddenKeys={hiddenKeys} requiredKeys={requiredKeys} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) } diff --git a/web/frontend/src/components/channels/channel-forms/discord-form.tsx b/web/frontend/src/components/channels/channel-forms/discord-form.tsx index f72e1c5c7..d2a98d325 100644 --- a/web/frontend/src/components/channels/channel-forms/discord-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/discord-form.tsx @@ -1,6 +1,14 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" +import { + type ArrayFieldFlusher, + ChannelArrayListField, +} from "@/components/channels/channel-array-list-field" +import { + asStringArray, + parseAllowFromInput, +} from "@/components/channels/channel-array-utils" import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" import { Card, CardContent } from "@/components/ui/card" @@ -11,17 +19,17 @@ interface DiscordFormProps { onChange: (key: string, value: unknown) => void configuredSecrets: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { return typeof value === "string" ? value : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - function asBool(value: unknown): boolean { return value === true } @@ -38,6 +46,8 @@ export function DiscordForm({ onChange, configuredSecrets, fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: DiscordFormProps) { const { t } = useTranslation() const groupTriggerConfig = asRecord(config.group_trigger) @@ -78,24 +88,17 @@ export function DiscordForm({ placeholder="http://127.0.0.1:7890" /> - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + />
void configuredSecrets: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { @@ -21,16 +34,13 @@ function asBool(value: unknown): boolean { return typeof value === "boolean" ? value : false } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - export function FeishuForm({ config, onChange, configuredSecrets, fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: FeishuFormProps) { const { t } = useTranslation() @@ -104,24 +114,17 @@ export function FeishuForm({ /> - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + />
+ registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } // Fields to skip in the generic form (handled by enabled toggle or internal). @@ -48,11 +61,6 @@ function asString(value: unknown): string { return typeof value === "string" ? value : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record @@ -71,6 +79,8 @@ export function GenericForm({ hiddenKeys = [], requiredKeys = [], fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: GenericFormProps) { const { t } = useTranslation() const hiddenFieldSet = new Set(hiddenKeys) @@ -187,26 +197,18 @@ export function GenericForm({ if (Array.isArray(value)) { return ( - - - onChange( - key, - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - /> - + value={asStringArray(value)} + onChange={(nextValue) => onChange(key, nextValue)} + fieldPath={key} + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> ) } @@ -281,46 +283,31 @@ export function GenericForm({ {config.allow_from !== undefined && !hiddenFieldSet.has("allow_from") && ( - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> )} {config.allow_origins !== undefined && !hiddenFieldSet.has("allow_origins") && ( - - - onChange( - "allow_origins", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowOriginsPlaceholder")} - /> - + value={asStringArray(config.allow_origins)} + onChange={(value) => onChange("allow_origins", value)} + placeholder={t("channels.field.allowOriginsPlaceholder")} + fieldPath="allow_origins" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> )} {config.allow_token_query !== undefined && @@ -356,26 +343,21 @@ export function GenericForm({ />
- - - onChange("group_trigger", { - ...groupTriggerConfig, - prefixes: e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - }) - } - placeholder={t("channels.field.groupTriggerPrefixes")} - /> - + value={asStringArray(groupTriggerConfig.prefixes)} + onChange={(value) => + onChange("group_trigger", { + ...groupTriggerConfig, + prefixes: value, + }) + } + placeholder={t("channels.field.groupTriggerPrefixes")} + fieldPath="group_trigger.prefixes" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> )} diff --git a/web/frontend/src/components/channels/channel-forms/slack-form.tsx b/web/frontend/src/components/channels/channel-forms/slack-form.tsx index 14ffa0913..b8184e8bc 100644 --- a/web/frontend/src/components/channels/channel-forms/slack-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/slack-form.tsx @@ -1,32 +1,41 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" +import { + type ArrayFieldFlusher, + ChannelArrayListField, +} from "@/components/channels/channel-array-list-field" +import { + asStringArray, + parseAllowFromInput, +} from "@/components/channels/channel-array-utils" import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" import { Field, KeyInput } from "@/components/shared-form" import { Card, CardContent } from "@/components/ui/card" -import { Input } from "@/components/ui/input" interface SlackFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void configuredSecrets: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { return typeof value === "string" ? value : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - export function SlackForm({ config, onChange, configuredSecrets, fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: SlackFormProps) { const { t } = useTranslation() @@ -72,24 +81,17 @@ export function SlackForm({ - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + />
diff --git a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx index 696da245d..f9c7c778a 100644 --- a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx @@ -1,6 +1,14 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" +import { + type ArrayFieldFlusher, + ChannelArrayListField, +} from "@/components/channels/channel-array-list-field" +import { + asStringArray, + parseAllowFromInput, +} from "@/components/channels/channel-array-utils" import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" import { Card, CardContent } from "@/components/ui/card" @@ -11,17 +19,17 @@ interface TelegramFormProps { onChange: (key: string, value: unknown) => void configuredSecrets: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { return typeof value === "string" ? value : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record @@ -38,6 +46,8 @@ export function TelegramForm({ onChange, configuredSecrets, fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: TelegramFormProps) { const { t } = useTranslation() const typingConfig = asRecord(config.typing) @@ -91,24 +101,17 @@ export function TelegramForm({ placeholder="http://127.0.0.1:7890" /> - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + />
void isEdit: boolean onBindSuccess?: () => void + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { return typeof value === "string" ? value : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - export function WeixinForm({ config, onChange, isEdit, onBindSuccess, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: WeixinFormProps) { const { t } = useTranslation() @@ -321,24 +331,17 @@ export function WeixinForm({ - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> attachment.type !== "image", ) + const [isExpanded, setIsExpanded] = useAtom(showThoughtsAtom) const formattedTimestamp = timestamp !== "" ? formatMessageTime(timestamp) : "" @@ -52,38 +55,51 @@ export function AssistantMessage({ return (
-
-
- PicoClaw - {isThought && ( - - - {t("chat.reasoningLabel")} - - )} - {formattedTimestamp && ( - <> - - {formattedTimestamp} - - )} + {!isThought && ( +
+
+ PicoClaw + {formattedTimestamp && ( + <> + + {formattedTimestamp} + + )} +
-
+ )}
- {hasText && ( + {isThought && ( +
setIsExpanded(!isExpanded)} + > +
+ + {t("chat.reasoningLabel")} +
+ +
+ )} + {(!isThought || isExpanded) && hasText && (
@@ -98,10 +114,7 @@ export function AssistantMessage({ {(imageAttachments.length > 0 || fileAttachments.length > 0) && (
{imageAttachments.length > 0 && (
@@ -146,15 +159,12 @@ export function AssistantMessage({
)} - {hasText && ( + {!isThought && hasText && (
- {canInput ? ( - - ) : null} +
+ {contextUsage && ( + + )} + {canInput ? ( + + + + + + + + {t("chat.sendHint")} + + + ) : null} +
diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index 4932efa52..1012fe7ad 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -115,6 +115,7 @@ export function ChatPage() { connectionState, isTyping, activeSessionId, + contextUsage, sendMessage, switchSession, newChat, @@ -153,7 +154,7 @@ export function ChatPage() { }) const syncScrollState = (element: HTMLDivElement) => { - const { scrollTop, scrollHeight, clientHeight } = element + const { clientHeight, scrollHeight, scrollTop } = element setHasScrolled(scrollTop > 0) setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10) } @@ -294,7 +295,7 @@ export function ChatPage() {
{messages.length === 0 && !isTyping && ( @@ -342,8 +343,14 @@ export function ChatPage() { onAddImages={handleAddImages} onRemoveAttachment={handleRemoveAttachment} onSend={handleSend} + onContextDetail={() => { + if (sendMessage({ content: "/context", attachments: [] })) { + setInput("") + } + }} inputDisabledReason={inputDisabledReason} canSend={canSubmit} + contextUsage={contextUsage} />
) diff --git a/web/frontend/src/components/chat/context-usage-ring.tsx b/web/frontend/src/components/chat/context-usage-ring.tsx new file mode 100644 index 000000000..4a32e617b --- /dev/null +++ b/web/frontend/src/components/chat/context-usage-ring.tsx @@ -0,0 +1,161 @@ +import { IconArrowRight } from "@tabler/icons-react" +import { useEffect, useRef, useState } from "react" +import { useTranslation } from "react-i18next" + +import type { ContextUsage } from "@/store/chat" + +interface ContextUsageRingProps { + usage: ContextUsage + onDetailClick?: () => void +} + +function formatTokens(n: number): string { + if (n >= 1000) return `${(n / 1000).toFixed(1)}k` + return String(n) +} + +export function ContextUsageRing({ + usage, + onDetailClick, +}: ContextUsageRingProps) { + const { t } = useTranslation() + const [intent, setIntent] = useState(false) // user wants open + const [visible, setVisible] = useState(false) // DOM mounted + const [animated, setAnimated] = useState(false) // CSS target state + const [cooldown, setCooldown] = useState(false) + const containerRef = useRef(null) + const timerRef = useRef>(null) + const hoverIntent = useRef>(null) + const closeTimer = useRef>(null) + + useEffect(() => { + if (intent) { + // Mount first, animate in on next frame + if (closeTimer.current) clearTimeout(closeTimer.current) + setVisible(true) + requestAnimationFrame(() => { + requestAnimationFrame(() => setAnimated(true)) + }) + } else if (visible) { + // Animate out, then unmount + setAnimated(false) + closeTimer.current = setTimeout(() => setVisible(false), 150) + } + }, [intent, visible]) + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + if (hoverIntent.current) clearTimeout(hoverIntent.current) + if (closeTimer.current) clearTimeout(closeTimer.current) + } + }, []) + + const percent = Math.min(usage.used_percent, 100) + const radius = 8 + const circumference = 2 * Math.PI * radius + const offset = circumference - (percent / 100) * circumference + const barPercent = Math.min(percent, 100) + + const handleDetail = () => { + if (cooldown || !onDetailClick) return + setCooldown(true) + onDetailClick() + setIntent(false) + timerRef.current = setTimeout(() => setCooldown(false), 1000) + } + + // Desktop: hover to open, mouse leave to close (with small delay) + const handleMouseEnter = () => { + if (hoverIntent.current) clearTimeout(hoverIntent.current) + setIntent(true) + } + + const handleMouseLeave = () => { + hoverIntent.current = setTimeout(() => setIntent(false), 150) + } + + // Mobile: tap to toggle (preventDefault suppresses synthetic mouseenter) + const handleTouchStart = (e: React.TouchEvent) => { + e.preventDefault() + setIntent((v) => !v) + } + + return ( +
+ + + {visible && ( +
+
+ +
+ + {t("chat.contextTitle")} + + + {formatTokens(usage.used_tokens)} /{" "} + {formatTokens(usage.compress_at_tokens)} + +
+
+
+
+ + +
+ )} +
+ ) +} diff --git a/web/frontend/src/components/chat/typing-indicator.tsx b/web/frontend/src/components/chat/typing-indicator.tsx index 98580963d..df138553c 100644 --- a/web/frontend/src/components/chat/typing-indicator.tsx +++ b/web/frontend/src/components/chat/typing-indicator.tsx @@ -21,10 +21,7 @@ export function TypingIndicator() { return (
-
- PicoClaw -
-
+
diff --git a/web/frontend/src/components/chat/user-message.tsx b/web/frontend/src/components/chat/user-message.tsx index 96119a534..8bfdf24c9 100644 --- a/web/frontend/src/components/chat/user-message.tsx +++ b/web/frontend/src/components/chat/user-message.tsx @@ -1,3 +1,4 @@ +import { cn } from "@/lib/utils" import type { ChatAttachment } from "@/store/chat" interface UserMessageProps { @@ -7,6 +8,7 @@ interface UserMessageProps { export function UserMessage({ content, attachments = [] }: UserMessageProps) { const hasText = content.trim().length > 0 + const isCommand = content.trim().startsWith("/") const imageAttachments = attachments.filter( (attachment) => attachment.type === "image", ) @@ -27,8 +29,24 @@ export function UserMessage({ content, attachments = [] }: UserMessageProps) { )} {hasText && ( -
- {content} +
+ {isCommand ? ( +
+ + ❯ + + {content} +
+ ) : ( + content + )}
)}
diff --git a/web/frontend/src/components/ui/tooltip.tsx b/web/frontend/src/components/ui/tooltip.tsx index 757f05b03..6e71ad55f 100644 --- a/web/frontend/src/components/ui/tooltip.tsx +++ b/web/frontend/src/components/ui/tooltip.tsx @@ -30,10 +30,13 @@ function TooltipTrigger({ function TooltipContent({ className, + arrowClassName, sideOffset = 0, children, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + arrowClassName?: string +}) { return ( {children} - + ) diff --git a/web/frontend/src/features/chat/controller.ts b/web/frontend/src/features/chat/controller.ts index 28ef491fa..489194421 100644 --- a/web/frontend/src/features/chat/controller.ts +++ b/web/frontend/src/features/chat/controller.ts @@ -1,7 +1,6 @@ import { getDefaultStore } from "jotai" import { toast } from "sonner" -import { getPicoToken } from "@/api/pico" import { loadSessionMessages, mergeHistoryMessages, @@ -131,7 +130,6 @@ export async function connectChat() { updateChatStore({ connectionState: "connecting" }) try { - const { token } = await getPicoToken() const sessionId = activeSessionIdRef if (generation !== connectionGeneration) { @@ -139,18 +137,10 @@ export async function connectChat() { return } - if (!token) { - console.error("No pico token available") - updateChatStore({ connectionState: "error" }) - isConnecting = false - scheduleReconnect(generation, sessionId) - return - } - const wsScheme = window.location.protocol === "https:" ? "wss:" : "ws:" const wsUrl = `${wsScheme}//${window.location.host}/pico/ws` const url = `${wsUrl}?session_id=${encodeURIComponent(sessionId)}` - const socket = new WebSocket(url, [`token.${token}`]) + const socket = new WebSocket(url) if (generation !== connectionGeneration) { isConnecting = false @@ -402,6 +392,7 @@ export async function switchChatSession(sessionId: string) { messages: historyMessages, isTyping: false, hasHydratedActiveSession: true, + contextUsage: undefined, }) if (store.get(gatewayAtom).status === "running") { @@ -425,6 +416,7 @@ export async function newChatSession() { messages: [], isTyping: false, hasHydratedActiveSession: true, + contextUsage: undefined, }) if (store.get(gatewayAtom).status === "running") { diff --git a/web/frontend/src/features/chat/protocol.ts b/web/frontend/src/features/chat/protocol.ts index b022c0abb..b255f55cb 100644 --- a/web/frontend/src/features/chat/protocol.ts +++ b/web/frontend/src/features/chat/protocol.ts @@ -4,6 +4,7 @@ import { normalizeUnixTimestamp } from "@/features/chat/state" import { type AssistantMessageKind, type ChatAttachment, + type ContextUsage, updateChatStore, } from "@/store/chat" @@ -25,7 +26,9 @@ function hasAssistantKindPayload(payload: Record): boolean { return typeof payload.thought === "boolean" } -function parseAttachments(payload: Record): ChatAttachment[] | undefined { +function parseAttachments( + payload: Record, +): ChatAttachment[] | undefined { const raw = payload.attachments if (!Array.isArray(raw)) { return undefined @@ -69,6 +72,24 @@ function parseAttachments(payload: Record): ChatAttachment[] | return attachments.length > 0 ? attachments : undefined } +function parseContextUsage( + payload: Record, +): ContextUsage | undefined { + const raw = payload.context_usage + if (!raw || typeof raw !== "object") return undefined + const obj = raw as Record + const used = Number(obj.used_tokens) + const total = Number(obj.total_tokens) + if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0) + return undefined + return { + used_tokens: used, + total_tokens: total, + compress_at_tokens: Number(obj.compress_at_tokens) || 0, + used_percent: Number(obj.used_percent) || 0, + } +} + export function handlePicoMessage( message: PicoMessage, expectedSessionId: string, @@ -86,6 +107,7 @@ export function handlePicoMessage( const messageId = (payload.message_id as string) || `pico-${Date.now()}` const kind = parseAssistantMessageKind(payload) const attachments = parseAttachments(payload) + const contextUsage = parseContextUsage(payload) const timestamp = message.timestamp !== undefined && Number.isFinite(Number(message.timestamp)) @@ -105,6 +127,7 @@ export function handlePicoMessage( }, ], isTyping: false, + ...(contextUsage ? { contextUsage } : {}), })) break } diff --git a/web/frontend/src/hooks/use-pico-chat.ts b/web/frontend/src/hooks/use-pico-chat.ts index 3ac2e1613..02467bd60 100644 --- a/web/frontend/src/hooks/use-pico-chat.ts +++ b/web/frontend/src/hooks/use-pico-chat.ts @@ -55,7 +55,7 @@ export function formatMessageTime(dateRaw: number | string | Date): string { } export function usePicoChat() { - const { messages, connectionState, isTyping, activeSessionId } = + const { messages, connectionState, isTyping, activeSessionId, contextUsage } = useAtomValue(chatAtom) return { @@ -63,6 +63,7 @@ export function usePicoChat() { connectionState, isTyping, activeSessionId, + contextUsage, sendMessage: sendChatMessage, switchSession: switchChatSession, newChat: newChatSession, diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index c96d4b71b..f58aff66a 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -38,7 +38,7 @@ "chat": { "welcome": "How can I help you today?", "welcomeDesc": "Ask me about weather, settings, or any other tasks. I'm here to assist you.", - "placeholder": "Start a new message...\nPress Enter to send, Shift + Enter for a new line", + "placeholder": "Start a new message...", "disabledPlaceholder": { "gatewayUnknown": "Unable to chat: Gateway status is still being checked. Please wait, then refresh the page or restart Launcher if needed.", "gatewayStarting": "Unable to chat: Gateway is starting. Wait for startup to complete, then try again.", @@ -60,6 +60,7 @@ "step4": "Almost there..." }, "reasoningLabel": "Reasoning", + "toolLabel": "Tool", "history": "History", "noHistory": "No chat history yet", "historyLoadFailed": "Failed to load chat history", @@ -72,6 +73,10 @@ "notConnected": "Gateway is not running. Start it to chat.", "noModel": "No default model configured. Go to Models page to set one." }, + "sendMessage": "Send message", + "sendHint": "Press Enter to send\nShift + Enter for a new line", + "contextTitle": "Context", + "contextDetail": "View Details", "attachImage": "Add images", "removeImage": "Remove image", "uploadedImage": "Uploaded image", @@ -359,6 +364,7 @@ "allowFromPlaceholder": "e.g. 123456, 789012", "allowOrigins": "Allow Origins", "allowOriginsPlaceholder": "e.g. https://example.com, http://localhost:5173", + "removeListItem": "Remove {{value}}", "secretPlaceholder": "Enter secret", "secretHintSet": "A value is already set. Leave blank to keep it unchanged." }, @@ -386,10 +392,10 @@ "typingEnabled": "Display typing status while the assistant is generating a response.", "placeholderEnabled": "Enable temporary placeholder messages before the final reply is sent.", "groupTriggerMentionOnly": "In group chats, respond only when the bot is mentioned.", - "groupTriggerPrefixes": "Custom group-chat trigger prefixes, separated by commas.", + "groupTriggerPrefixes": "Custom group-chat trigger prefixes. Add items one by one, or paste multiple values at once.", "isLark": "Use Lark international domain (open.larksuite.com) instead of Feishu domain (open.feishu.cn).", - "allowFrom": "Allowed user or group IDs, separated by commas.", - "allowOrigins": "Allowed origin domains, separated by commas.", + "allowFrom": "Allowed user or group IDs. Add items one by one, or paste multiple values at once.", + "allowOrigins": "Allowed origin domains. Add items one by one, or paste multiple values at once.", "wsUrl": "WebSocket service URL.", "reconnectInterval": "Reconnect interval after disconnection (seconds).", "bridgeUrl": "Bridge service URL.", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 4a9e59cf4..21d6e57cf 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -38,7 +38,7 @@ "chat": { "welcome": "今天我能为您做些什么?", "welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。", - "placeholder": "输入新消息...\n按 Enter 发送,Shift + Enter 换行", + "placeholder": "输入新消息...", "disabledPlaceholder": { "gatewayUnknown": "无法对话:网关状态仍在检测中。请稍候重试,如仍无效请刷新页面或重启 Launcher。", "gatewayStarting": "无法对话:网关正在启动。请等待启动完成后重试。", @@ -60,6 +60,7 @@ "step4": "马上就好..." }, "reasoningLabel": "思考", + "toolLabel": "工具", "history": "历史记录", "noHistory": "暂无对话历史", "historyLoadFailed": "加载历史记录失败", @@ -72,6 +73,10 @@ "notConnected": "服务未运行,请先启动以进行对话。", "noModel": "未设置默认模型,请前往模型页面进行配置。" }, + "sendMessage": "发送消息", + "sendHint": "按 Enter 发送\nShift + Enter 换行", + "contextTitle": "上下文", + "contextDetail": "查看详情", "attachImage": "添加图片", "removeImage": "移除图片", "uploadedImage": "已上传图片", @@ -359,6 +364,7 @@ "allowFromPlaceholder": "例如 123456, 789012", "allowOrigins": "允许来源域名", "allowOriginsPlaceholder": "例如 https://example.com, http://localhost:5173", + "removeListItem": "删除 {{value}}", "secretPlaceholder": "输入密钥", "secretHintSet": "配置已保存,留空表示不修改" }, @@ -386,10 +392,10 @@ "typingEnabled": "在生成回复时显示“正在输入”状态", "placeholderEnabled": "在最终回复发送前,先发送临时占位消息", "groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应", - "groupTriggerPrefixes": "群聊触发前缀,多个值用逗号分隔", + "groupTriggerPrefixes": "群聊触发前缀。可逐项添加,也支持一次粘贴多个值。", "isLark": "使用 Lark 国际版域名(open.larksuite.com)替代飞书域名(open.feishu.cn)", - "allowFrom": "允许访问的用户或群组 ID,多个值用逗号分隔", - "allowOrigins": "允许访问的来源域名,多个值用逗号分隔", + "allowFrom": "允许访问的用户或群组 ID。可逐项添加,也支持一次粘贴多个值。", + "allowOrigins": "允许访问的来源域名。可逐项添加,也支持一次粘贴多个值。", "wsUrl": "WebSocket 服务地址", "reconnectInterval": "断线后的重连间隔(秒)", "bridgeUrl": "桥接服务地址", diff --git a/web/frontend/src/store/chat.ts b/web/frontend/src/store/chat.ts index 2935acde2..393254416 100644 --- a/web/frontend/src/store/chat.ts +++ b/web/frontend/src/store/chat.ts @@ -23,6 +23,13 @@ export interface ChatMessage { attachments?: ChatAttachment[] } +export interface ContextUsage { + used_tokens: number + total_tokens: number + compress_at_tokens: number + used_percent: number +} + export type ConnectionState = | "disconnected" | "connecting" @@ -35,6 +42,7 @@ export interface ChatStoreState { isTyping: boolean activeSessionId: string hasHydratedActiveSession: boolean + contextUsage?: ContextUsage } type ChatStorePatch = Partial @@ -49,6 +57,8 @@ const DEFAULT_CHAT_STATE: ChatStoreState = { export const chatAtom = atom(DEFAULT_CHAT_STATE) +export const showThoughtsAtom = atom(true) + const store = getDefaultStore() export function getChatState() {